From fe24cd73be6eed9624045cc18a92ae8a21e724a6 Mon Sep 17 00:00:00 2001 From: Bonna Moon Date: Mon, 6 Jul 2026 19:35:45 -0500 Subject: [PATCH] =?UTF-8?q?v0.27.0=20=E2=80=94=20Garden=20brain:=20zone-4b?= =?UTF-8?q?=20knowledge,=20calendar=20suggestions,=20UW=20citations?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit src/lib/garden/knowledge.ts holds hand-curated zone-4b (~May 15 frost) knowledge for 28 crops/fruits/flowers: monthly care timing, companion friends/foes, rotation families. Pure matchers knowledgeFor/tipsForMonth use whole-word stem matching (tested — 'Pear' must not hit 'Pea'). The calendar gains a "Good to do this month" list matched to owned plants, deduped against existing task titles, with one-click Remind me → YEARLY task via /api/v1/tasks. Plant type pages gain a "Growing in zone 4b" card (timeline, companions, rotation). Tips cite UW Extension via site search URLs — no fabricated article links. Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 9 +- src/app/(dashboard)/calendar/page.tsx | 13 +- .../(dashboard)/garden/types/[id]/page.tsx | 76 ++- src/components/calendar/calendar-view.tsx | 115 ++++- src/lib/changelog.ts | 9 + src/lib/garden/knowledge.test.ts | 69 +++ src/lib/garden/knowledge.ts | 435 ++++++++++++++++++ src/lib/version.ts | 2 +- 8 files changed, 721 insertions(+), 7 deletions(-) create mode 100644 src/lib/garden/knowledge.test.ts create mode 100644 src/lib/garden/knowledge.ts diff --git a/CLAUDE.md b/CLAUDE.md index e6391e7..10b6b88 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -35,7 +35,14 @@ user-facing overview. - **Reminders** — `Task` + `TaskCompletion`; recurrence in `src/lib/tasks/schedule.ts` (incl. `occurrencesInRange` projection). **/calendar** renders a month view of all tasks (garden + house) plus PlantType bloom/harvest windows - (`src/lib/garden/season.ts`). + (`src/lib/garden/season.ts`) and garden-brain suggestions. +- **Garden brain** — `src/lib/garden/knowledge.ts`: hand-curated zone-4b + (~May 15 frost) care timing, companions/foes, rotation families for ~28 + crops; pure matchers `knowledgeFor`/`tipsForMonth` (whole-word stem match — + beware "Pear"/"Pea"-style false hits, tested). Surfaced as calendar + "Good to do this month" (one-click YEARLY task via /api/v1/tasks) and a + "Growing in zone 4b" card on type pages. Tips cite UW Extension via site + search URLs — never fabricate article URLs. - **REST API** — `/api/v1/*` authed by `ApiToken` (per-scope; logic in `src/lib/api/`). - **Suggestions** — `@otm/account-panel` (Gitea npm registry) → files issues to the repo. - **Backup & Restore** — `/api/admin/backup` + `/api/admin/restore` (admin only). diff --git a/src/app/(dashboard)/calendar/page.tsx b/src/app/(dashboard)/calendar/page.tsx index 59fe6c4..9364ee0 100644 --- a/src/app/(dashboard)/calendar/page.tsx +++ b/src/app/(dashboard)/calendar/page.tsx @@ -5,7 +5,7 @@ export const metadata = { title: "Calendar" }; export const dynamic = "force-dynamic"; export default async function CalendarPage() { - const [tasks, types] = await Promise.all([ + const [tasks, types, plants] = await Promise.all([ db.task.findMany({ where: { active: true }, orderBy: { nextDue: "asc" }, @@ -33,6 +33,10 @@ export default async function CalendarPage() { bloomStart: true, bloomEnd: true, harvestStart: true, harvestEnd: true, }, }), + db.plant.findMany({ + where: { active: true }, + select: { commonName: true, plantType: { select: { commonName: true } } }, + }), ]); const calendarTasks: CalendarTask[] = tasks.map((t) => ({ @@ -53,6 +57,11 @@ export default async function CalendarPage() { const seasonTypes: SeasonType[] = types; + // Names the garden brain matches against: plants + their linked types. + const plantNames = Array.from( + new Set(plants.flatMap((p) => [p.commonName, p.plantType?.commonName]).filter((n): n is string => !!n)), + ); + return (
@@ -61,7 +70,7 @@ export default async function CalendarPage() { Everything due around the homestead — garden and house — plus what's blooming and ready to pick.

- +
); } diff --git a/src/app/(dashboard)/garden/types/[id]/page.tsx b/src/app/(dashboard)/garden/types/[id]/page.tsx index 76d9968..04fe657 100644 --- a/src/app/(dashboard)/garden/types/[id]/page.tsx +++ b/src/app/(dashboard)/garden/types/[id]/page.tsx @@ -5,9 +5,10 @@ import { Badge } from "@/components/ui/badge"; import { Card, CardContent } from "@/components/ui/card"; import { ChevronLeft, Sun, Droplets, Flower2, Apple, AlertTriangle, Ruler, MapPin, Leaf, - GraduationCap, ExternalLink, + GraduationCap, ExternalLink, Sprout, ThumbsUp, ThumbsDown, Repeat, } from "lucide-react"; import { CATEGORY_LABELS } from "@/lib/garden/categories"; +import { knowledgeFor, uwSearchUrl, ROTATION_LABELS } from "@/lib/garden/knowledge"; import { EditPlantTypeButton, type PlantTypeFields } from "@/components/garden/plant-type-dialog"; import { DeletePlantTypeButton } from "@/components/garden/delete-plant-type-button"; @@ -25,6 +26,77 @@ export async function generateMetadata({ params }: { params: { id: string } }) { return { title: t?.commonName ?? "Plant type" }; } +// Garden-brain card: companions, antagonists, rotation family, and the +// zone-4b care timeline for this kind of plant (when we know it). +function GrowingTogether({ name }: { name: string }) { + const k = knowledgeFor(name); + if (!k) return null; + + const hasCompanions = (k.friends?.length ?? 0) > 0 || (k.foes?.length ?? 0) > 0; + + return ( + + +
+

+ Growing {k.name.toLowerCase()} in zone 4b +

+ + UW Extension + +
+ + {k.tips.length > 0 && ( +
    + {k.tips.map((t) => ( +
  • + + + {t.months.map((m) => MONTHS[m]).join(", ")}:{" "} + {t.action} + {t.detail && — {t.detail}} + +
  • + ))} +
+ )} + + {hasCompanions && ( +
+ {k.friends && k.friends.length > 0 && ( +

+ + Grows well with: {k.friends.join(", ")} +

+ )} + {k.foes && k.foes.length > 0 && ( +

+ + Keep away from: {k.foes.join(", ")} +

+ )} +
+ )} + + {(k.family || k.rotationNote) && ( +

+ + + {k.family && Rotation family: {ROTATION_LABELS[k.family]}.} + {k.rotationNote && {k.rotationNote}} + +

+ )} +
+
+ ); +} + export default async function PlantTypeDetailPage({ params }: { params: { id: string } }) { const type = await db.plantType.findUnique({ where: { id: params.id }, @@ -132,6 +204,8 @@ export default async function PlantTypeDetailPage({ params }: { params: { id: st )} + + {(type.careNotes || type.notes) && ( diff --git a/src/components/calendar/calendar-view.tsx b/src/components/calendar/calendar-view.tsx index 83ab820..d13edda 100644 --- a/src/components/calendar/calendar-view.tsx +++ b/src/components/calendar/calendar-view.tsx @@ -5,11 +5,14 @@ // nextDue via occurrencesInRange; windows come from PlantType months. import { useMemo, useState } from "react"; import Link from "next/link"; -import { AlertTriangle, Apple, ChevronLeft, ChevronRight, Flower2 } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { AlertTriangle, Apple, BellPlus, ChevronLeft, ChevronRight, ExternalLink, Flower2, Sparkles } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; +import { useToast } from "@/hooks/use-toast"; import { occurrencesInRange, describeRecurrence, type RecurrenceType } from "@/lib/tasks/schedule"; import { monthInWindow } from "@/lib/garden/season"; +import { tipsForMonth, type MonthlySuggestion } from "@/lib/garden/knowledge"; import { CompleteTaskButton } from "@/components/tasks/complete-task-button"; import { cn } from "@/lib/utils"; @@ -33,6 +36,105 @@ export type SeasonType = { harvestEnd: number | null; }; +// "Good to do this month" — garden-brain suggestions matched to the plants on +// hand, each one a click away from becoming a real yearly reminder. +function SuggestionList({ month, year, monthLabel, plantNames, existingTitles }: { + month: number; // 1–12 + year: number; + monthLabel: string; + plantNames: string[]; + existingTitles: string[]; +}) { + const router = useRouter(); + const { toast } = useToast(); + const [added, setAdded] = useState>(new Set()); + const [busy, setBusy] = useState(null); + + const suggestions = useMemo( + () => tipsForMonth(month, plantNames), + [month, plantNames], + ); + const existing = useMemo( + () => new Set(existingTitles.map((t) => t.toLowerCase())), + [existingTitles], + ); + const open = suggestions.filter( + (s) => !existing.has(s.action.toLowerCase()) && !added.has(s.action), + ); + + if (open.length === 0) return null; + + async function addReminder(s: MonthlySuggestion) { + setBusy(s.action); + try { + const today = new Date(); + const inViewedMonth = today.getFullYear() === year && today.getMonth() + 1 === month; + const nextDue = inViewedMonth ? today : new Date(year, month - 1, 1); + const res = await fetch("/api/v1/tasks", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + title: s.action, + notes: [s.crop, s.detail].filter(Boolean).join(" — ") || undefined, + category: "GARDEN", + recurrence: "YEARLY", + month, + nextDue: nextDue.toISOString(), + }), + }); + if (!res.ok) throw new Error((await res.json()).error ?? "Could not add reminder"); + setAdded((prev) => new Set(prev).add(s.action)); + toast({ title: "Reminder added", description: `"${s.action}" will come back every ${monthLabel}.` }); + router.refresh(); + } catch (err) { + toast({ title: "Error", description: String(err).replace("Error: ", ""), variant: "destructive" }); + } finally { + setBusy(null); + } + } + + return ( +
+
+ + Good to do in {monthLabel} + · for what you grow (zone 4b) +
+
+ {open.map((s) => ( +
+
+

+ {s.action} + {s.crop && · {s.crop}} +

+ {s.detail &&

{s.detail}

} +
+ + + + +
+ ))} +
+
+ ); +} + const CATEGORY_STYLES: Record = { GARDEN: "bg-[hsl(var(--leaf)/.12)] text-[hsl(var(--leaf))]", HOME: "bg-amber-100 text-amber-800", @@ -46,9 +148,10 @@ function iso(d: Date): string { return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; } -export function CalendarView({ tasks, seasonTypes }: { +export function CalendarView({ tasks, seasonTypes, plantNames }: { tasks: CalendarTask[]; seasonTypes: SeasonType[]; + plantNames: string[]; }) { const today = new Date(); const todayIso = iso(today); @@ -189,6 +292,14 @@ export function CalendarView({ tasks, seasonTypes }: { + t.title)} + /> + {selected && (

diff --git a/src/lib/changelog.ts b/src/lib/changelog.ts index 6be537e..04e68b6 100644 --- a/src/lib/changelog.ts +++ b/src/lib/changelog.ts @@ -8,6 +8,15 @@ export type ChangelogEntry = { }; export const CHANGELOG: ChangelogEntry[] = [ + { + version: "0.27.0", + date: "2026-07-06", + changes: [ + "The garden got a brain! The Calendar now shows \"Good to do this month\" — timely advice matched to what you actually grow, tuned for our zone (4b, ~May 15 last frost): when to prune the raspberries, start tomato seeds, plant garlic, divide the irises, and more. Each tip has a \"Remind me\" button that turns it into a yearly reminder, and a link to read more at UW–Madison Extension.", + "Plant type pages now show a \"Growing in zone 4b\" card: the care timeline for that plant, what it grows well next to, what to keep it away from, and its crop-rotation family (so you know not to plant tomatoes where the potatoes just were).", + "This starts with 28 common crops, fruits, and flowers — it'll grow over time.", + ], + }, { version: "0.26.0", date: "2026-07-06", diff --git a/src/lib/garden/knowledge.test.ts b/src/lib/garden/knowledge.test.ts new file mode 100644 index 0000000..7a145a8 --- /dev/null +++ b/src/lib/garden/knowledge.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from "vitest"; +import { knowledgeFor, tipsForMonth, uwSearchUrl, KNOWLEDGE } from "./knowledge"; + +describe("knowledgeFor", () => { + it("matches exact and case-insensitive names", () => { + expect(knowledgeFor("Tomato")?.name).toBe("Tomato"); + expect(knowledgeFor("raspberry")?.name).toBe("Raspberry"); + }); + + it("matches plurals and richer plant names", () => { + expect(knowledgeFor("Raspberries")?.name).toBe("Raspberry"); + expect(knowledgeFor("Heritage Raspberry")?.name).toBe("Raspberry"); + expect(knowledgeFor("Bearded Iris")?.name).toBe("Iris"); + }); + + it("matches via aliases", () => { + expect(knowledgeFor("Zucchini")?.name).toBe("Squash"); + expect(knowledgeFor("Kale")?.name).toBe("Broccoli"); + expect(knowledgeFor("Pear")?.name).toBe("Apple"); + }); + + it("returns null for unknown plants", () => { + expect(knowledgeFor("Dusty Miller")).toBeNull(); + }); +}); + +describe("tipsForMonth", () => { + it("only suggests for plants actually grown", () => { + const march = tipsForMonth(3, ["Raspberry", "Bearded Iris"]); + const actions = march.map((t) => t.action); + expect(actions).toContain("Prune raspberries"); + expect(actions).not.toContain("Prune apple and pear trees"); + }); + + it("includes general homestead tips regardless of plants", () => { + const nov = tipsForMonth(11, []); + expect(nov.some((t) => t.crop === null && t.action.includes("Winter-mulch"))).toBe(true); + }); + + it("dedupes when several plants match the same crop", () => { + const july = tipsForMonth(7, ["Bearded Iris", "Iris sibirica", "Yellow Flag Iris"]); + expect(july.filter((t) => t.crop === "Iris")).toHaveLength(1); + }); + + it("every suggestion carries a UW link", () => { + for (const t of tipsForMonth(6, ["Tomato", "Garlic", "Lilac"])) { + expect(t.uwUrl).toMatch(/^https:\/\/hort\.extension\.wisc\.edu\/\?s=/); + } + }); +}); + +describe("knowledge data sanity", () => { + it("all months are 1–12 and every crop has tips + a query", () => { + for (const crop of KNOWLEDGE) { + expect(crop.tips.length).toBeGreaterThan(0); + expect(crop.uwQuery.length).toBeGreaterThan(0); + for (const tip of crop.tips) { + for (const m of tip.months) { + expect(m).toBeGreaterThanOrEqual(1); + expect(m).toBeLessThanOrEqual(12); + } + } + } + }); + + it("uwSearchUrl encodes queries", () => { + expect(uwSearchUrl("growing tomatoes")).toBe("https://hort.extension.wisc.edu/?s=growing%20tomatoes"); + }); +}); diff --git a/src/lib/garden/knowledge.ts b/src/lib/garden/knowledge.ts new file mode 100644 index 0000000..3e6f70e --- /dev/null +++ b/src/lib/garden/knowledge.ts @@ -0,0 +1,435 @@ +// The garden brain — curated zone-4b (Eau Claire, WI) growing knowledge: +// month-by-month care timing, companion friends/foes, and crop-rotation +// families. Timing assumes ~May 15 last frost and ~Oct 1 first frost. +// +// This is starter reference data distilled from standard extension guidance; +// every entry links to a UW–Madison Extension site search (uwUrl) so the full +// article is one click away. Curated by hand — extend freely, keep it honest. + +export type RotationFamily = + | "nightshade" | "legume" | "brassica" | "allium" | "cucurbit" + | "root" | "leafy" | "corn" | "perennial"; + +export type CareTip = { + months: number[]; // 1–12, zone 4b + action: string; // short imperative — becomes a suggested reminder title + detail?: string; +}; + +export type CropKnowledge = { + name: string; + aliases?: string[]; + family?: RotationFamily; + friends?: string[]; + foes?: string[]; + rotationNote?: string; + tips: CareTip[]; + uwQuery: string; +}; + +export function uwSearchUrl(query: string): string { + return `https://hort.extension.wisc.edu/?s=${encodeURIComponent(query)}`; +} + +export const ROTATION_LABELS: Record = { + nightshade: "Nightshades (tomato, pepper, potato, eggplant)", + legume: "Legumes (beans, peas)", + brassica: "Brassicas (cabbage, broccoli, kale…)", + allium: "Alliums (onion, garlic, leek)", + cucurbit: "Cucurbits (squash, cucumber, melon)", + root: "Roots (carrot, beet, radish)", + leafy: "Leafy greens (lettuce, spinach, chard)", + corn: "Corn", + perennial: "Perennial (stays put — no rotation)", +}; + +export const KNOWLEDGE: CropKnowledge[] = [ + { + name: "Tomato", + family: "nightshade", + friends: ["Basil", "Carrot", "Onion", "Marigold"], + foes: ["Cabbage family", "Fennel", "Potato", "Corn"], + rotationNote: "Don't replant where tomatoes, peppers, or potatoes grew in the last 3 years — shared soil diseases build up.", + uwQuery: "growing tomatoes", + tips: [ + { months: [4], action: "Start tomato seeds indoors", detail: "6 weeks before the ~May 15 frost date." }, + { months: [5, 6], action: "Harden off and transplant tomatoes", detail: "Out after frost danger — late May into June." }, + { months: [6, 7, 8], action: "Water tomatoes deeply and evenly", detail: "Uneven watering invites blossom-end rot; mulch helps." }, + { months: [9], action: "Pick remaining green tomatoes before frost", detail: "They ripen indoors on the counter." }, + ], + }, + { + name: "Pepper", + family: "nightshade", + friends: ["Basil", "Onion"], + foes: ["Fennel"], + rotationNote: "Rotate with the other nightshades — 3 years out of the same bed.", + uwQuery: "growing peppers", + tips: [ + { months: [3, 4], action: "Start pepper seeds indoors", detail: "Peppers are slow — 8 weeks before frost." }, + { months: [6], action: "Transplant peppers", detail: "They sulk in cold soil; wait for warm June nights." }, + ], + }, + { + name: "Potato", + family: "nightshade", + friends: ["Beans", "Corn", "Cabbage"], + foes: ["Tomato", "Squash", "Cucumber", "Sunflower"], + rotationNote: "Shares blight with tomatoes — keep them apart and rotate 3 years.", + uwQuery: "growing potatoes", + tips: [ + { months: [5], action: "Plant seed potatoes", detail: "Mid-May, once soil works easily." }, + { months: [6, 7], action: "Hill potatoes", detail: "Mound soil over the row so tubers never see sun." }, + { months: [9], action: "Dig potatoes", detail: "After the vines die back; cure before storing." }, + ], + }, + { + name: "Bean", + aliases: ["Green bean", "Pole bean", "Bush bean"], + family: "legume", + friends: ["Corn", "Squash", "Carrot"], + foes: ["Onion", "Garlic", "Leek"], + rotationNote: "Legumes feed the soil — a great crop to grow before heavy feeders like brassicas or corn.", + uwQuery: "growing beans", + tips: [ + { months: [5, 6], action: "Direct-sow beans", detail: "After frost; succession-sow every 2 weeks for a steady picking." }, + ], + }, + { + name: "Pea", + family: "legume", + friends: ["Carrot", "Radish"], + foes: ["Onion", "Garlic"], + uwQuery: "growing peas", + tips: [ + { months: [4, 5], action: "Direct-sow peas", detail: "As early as the soil can be worked — peas love cold feet." }, + ], + }, + { + name: "Garlic", + family: "allium", + friends: ["Tomato", "Cabbage family"], + foes: ["Beans", "Peas"], + uwQuery: "growing garlic", + tips: [ + { months: [10], action: "Plant garlic cloves", detail: "Mid-October, mulched well — it overwinters." }, + { months: [6], action: "Snap off garlic scapes", detail: "Sends energy to the bulb; scapes are dinner." }, + { months: [7], action: "Lift garlic", detail: "When the lower third of leaves have browned; cure in shade." }, + ], + }, + { + name: "Onion", + aliases: ["Shallot", "Leek"], + family: "allium", + friends: ["Carrot", "Beet", "Cabbage family"], + foes: ["Beans", "Peas"], + uwQuery: "growing onions", + tips: [ + { months: [4, 5], action: "Set out onion plants or sets", detail: "Onions size up on day length — get them in early." }, + { months: [8, 9], action: "Harvest onions", detail: "When tops flop over; cure until necks are papery." }, + ], + }, + { + name: "Carrot", + family: "root", + friends: ["Onion", "Tomato", "Pea"], + foes: ["Dill"], + uwQuery: "growing carrots", + tips: [ + { months: [4, 5, 6], action: "Sow carrots", detail: "Keep the seedbed moist until they're up — it takes patience." }, + { months: [10], action: "Finish digging carrots", detail: "A light frost sweetens them; dig before the ground locks up." }, + ], + }, + { + name: "Beet", + family: "root", + friends: ["Onion", "Cabbage family"], + foes: ["Pole beans"], + uwQuery: "growing beets", + tips: [ + { months: [4, 5, 6, 7], action: "Sow beets", detail: "Succession-sow into midsummer for fall roots." }, + ], + }, + { + name: "Lettuce", + aliases: ["Salad greens"], + family: "leafy", + friends: ["Carrot", "Radish"], + uwQuery: "growing lettuce", + tips: [ + { months: [4, 5], action: "Sow lettuce", detail: "Cool-season crop; shade cloth stretches it into summer." }, + { months: [8], action: "Sow fall lettuce", detail: "Late-summer sowing beats the September chill." }, + ], + }, + { + name: "Spinach", + family: "leafy", + uwQuery: "growing spinach", + tips: [ + { months: [4], action: "Sow spinach", detail: "Bolts in heat — spring and fall are its seasons here." }, + { months: [8, 9], action: "Sow fall spinach", detail: "Overwinters under mulch for the earliest spring greens." }, + ], + }, + { + name: "Broccoli", + aliases: ["Cabbage", "Cauliflower", "Kale", "Brussels sprouts", "Kohlrabi"], + family: "brassica", + friends: ["Onion", "Dill", "Chamomile"], + foes: ["Tomato", "Strawberry"], + rotationNote: "Rotate brassicas on a 3-year cycle to dodge clubroot and cabbage maggots.", + uwQuery: "growing broccoli cabbage", + tips: [ + { months: [4], action: "Start brassica seeds indoors", detail: "Cabbage, broccoli, kale — 4–5 weeks before transplanting." }, + { months: [5], action: "Transplant brassicas", detail: "They shrug off light frost; row covers stop the cabbage moths." }, + { months: [7], action: "Sow fall brassicas", detail: "A July sowing makes the best autumn broccoli and kale." }, + ], + }, + { + name: "Squash", + aliases: ["Zucchini", "Pumpkin", "Winter squash"], + family: "cucurbit", + friends: ["Corn", "Beans", "Nasturtium"], + foes: ["Potato"], + uwQuery: "growing squash", + tips: [ + { months: [6], action: "Sow or transplant squash", detail: "Warm soil only — early June here." }, + { months: [9, 10], action: "Harvest winter squash before hard frost", detail: "Leave an inch of stem; cure 2 weeks for storage." }, + ], + }, + { + name: "Cucumber", + family: "cucurbit", + friends: ["Beans", "Dill"], + foes: ["Potato"], + uwQuery: "growing cucumbers", + tips: [ + { months: [6], action: "Sow cucumbers", detail: "Direct-sow in warm June soil; trellis for straight fruit." }, + ], + }, + { + name: "Corn", + aliases: ["Sweet corn"], + family: "corn", + friends: ["Beans", "Squash"], + foes: ["Tomato"], + uwQuery: "growing sweet corn", + tips: [ + { months: [5, 6], action: "Sow sweet corn in blocks", detail: "Blocks, not rows — corn is wind-pollinated." }, + ], + }, + { + name: "Strawberry", + family: "perennial", + foes: ["Cabbage family"], + rotationNote: "Avoid beds that recently grew tomatoes/potatoes (verticillium lingers).", + uwQuery: "growing strawberries", + tips: [ + { months: [5], action: "Plant strawberries", detail: "Pinch first-year blossoms on June-bearers — bigger crops after." }, + { months: [7], action: "Renovate June-bearing strawberries", detail: "Right after harvest: mow, narrow the rows, fertilize." }, + { months: [11], action: "Mulch strawberries for winter", detail: "Straw on after the ground starts to freeze." }, + ], + }, + { + name: "Raspberry", + family: "perennial", + foes: ["Potato"], + rotationNote: "Keep away from where nightshades grew — verticillium wilt.", + uwQuery: "growing raspberries", + tips: [ + { months: [3, 4], action: "Prune raspberries", detail: "Remove last year's spent canes; thin the rest to the sturdiest." }, + { months: [7, 8, 9], action: "Pick raspberries often", detail: "Every couple of days keeps the patch producing." }, + ], + }, + { + name: "Blueberry", + family: "perennial", + uwQuery: "growing blueberries", + tips: [ + { months: [3], action: "Prune blueberries", detail: "While dormant; remove the oldest canes." }, + { months: [5], action: "Check blueberry soil pH", detail: "They need acid soil (pH 4.5–5.5) — sulfur takes months to work." }, + ], + }, + { + name: "Currant", + aliases: ["Gooseberry"], + family: "perennial", + uwQuery: "growing currants gooseberries", + tips: [ + { months: [3], action: "Prune currants and gooseberries", detail: "Keep a mix of 1–3 year old stems; remove older wood." }, + { months: [7], action: "Pick currants", detail: "Whole strigs at once — easier to sort in the kitchen." }, + ], + }, + { + name: "Grape", + family: "perennial", + uwQuery: "pruning grapes", + tips: [ + { months: [3], action: "Prune grape vines", detail: "Hard, while fully dormant — grapes fruit on new wood from year-old canes." }, + ], + }, + { + name: "Apple", + aliases: ["Pear"], + family: "perennial", + uwQuery: "apple tree care", + tips: [ + { months: [3], action: "Prune apple and pear trees", detail: "Late winter, before buds break — open the center to light." }, + { months: [6], action: "Thin apples", detail: "One fruit per cluster, a hand-width apart; bigger, healthier apples." }, + { months: [11], action: "Guard young fruit trees for winter", detail: "Trunk guards against voles and rabbits; pull mulch back from bark." }, + ], + }, + { + name: "Cherry", + aliases: ["Plum", "Stone fruit"], + family: "perennial", + uwQuery: "growing cherries plums", + tips: [ + { months: [3], action: "Prune stone fruit trees", detail: "Late dormant season, on a dry day." }, + { months: [7], action: "Net cherries against birds", detail: "The birds are watching the same calendar you are." }, + ], + }, + { + name: "Rhubarb", + family: "perennial", + uwQuery: "growing rhubarb", + tips: [ + { months: [5, 6], action: "Harvest rhubarb", detail: "Pull stalks, don't cut; stop by July so the crown recharges." }, + { months: [9], action: "Divide crowded rhubarb", detail: "Every 5 years or so, when stalks get thin." }, + ], + }, + { + name: "Asparagus", + family: "perennial", + uwQuery: "growing asparagus", + tips: [ + { months: [5, 6], action: "Harvest asparagus", detail: "Snap spears until mid-June, then let the ferns grow — they feed next year." }, + { months: [6], action: "Fertilize asparagus after harvest", detail: "The ferns do the work for next spring." }, + { months: [11], action: "Cut down asparagus ferns", detail: "After they brown; removes overwintering asparagus-beetle habitat." }, + ], + }, + { + name: "Basil", + family: "leafy", + friends: ["Tomato"], + uwQuery: "growing basil", + tips: [ + { months: [4], action: "Start basil indoors", detail: "It hates cold — never rush it outside." }, + { months: [6, 7, 8], action: "Pinch basil tops", detail: "Pinch before it flowers to keep leaves coming." }, + ], + }, + { + name: "Iris", + aliases: ["Bearded iris"], + family: "perennial", + uwQuery: "iris care dividing", + tips: [ + { months: [7, 8], action: "Divide crowded irises", detail: "4–6 weeks after bloom; discard old center rhizomes (check for borers)." }, + { months: [10], action: "Cut iris foliage back", detail: "Fall cleanup breaks the iris-borer cycle." }, + ], + }, + { + name: "Peony", + family: "perennial", + uwQuery: "peony care", + tips: [ + { months: [9], action: "Plant or divide peonies", detail: "Eyes no deeper than 2 inches — planting deep is why peonies sulk." }, + { months: [10], action: "Cut peonies to the ground", detail: "Fall removal keeps botrytis from overwintering." }, + ], + }, + { + name: "Lilac", + aliases: ["Forsythia", "Spring-flowering shrub"], + family: "perennial", + uwQuery: "pruning lilacs", + tips: [ + { months: [6], action: "Prune lilacs right after bloom", detail: "They bloom on old wood — prune in fall/winter and you cut off next year's flowers." }, + ], + }, + { + name: "Bee balm", + aliases: ["Coneflower", "Black-eyed Susan", "Perennial flower"], + family: "perennial", + uwQuery: "dividing perennials", + tips: [ + { months: [5], action: "Divide summer perennials", detail: "Spring division for the ones that bloom after midsummer." }, + { months: [7, 8], action: "Deadhead perennial flowers", detail: "Keeps the show going and self-seeding polite." }, + ], + }, +]; + +// Homestead-wide reminders that apply regardless of what's planted. +export const GENERAL_TIPS: (CareTip & { uwQuery: string })[] = [ + { months: [3], action: "Set up seed-starting lights", detail: "The zone-4 season starts indoors in March.", uwQuery: "starting seeds indoors" }, + { months: [5], action: "Watch for late frost", detail: "Eau Claire's average last frost is ~May 15; keep covers handy.", uwQuery: "frost protection" }, + { months: [6], action: "Mulch the vegetable beds", detail: "After soil warms: holds water, blocks weeds.", uwQuery: "mulching garden" }, + { months: [9], action: "Sow a cover crop in empty beds", detail: "Oats or rye protect and feed the soil over winter.", uwQuery: "cover crops garden" }, + { months: [10], action: "Fall garden cleanup", detail: "Remove diseased plant debris (compost the healthy stuff).", uwQuery: "fall garden cleanup" }, + { months: [11], action: "Winter-mulch perennials", detail: "After the ground freezes, not before — the goal is keeping it frozen.", uwQuery: "winter mulch perennials" }, +]; + +// --------------------------------------------------------------------------- +// Matching — connect knowledge entries to the plants Bonna actually grows. +// --------------------------------------------------------------------------- + +function norm(s: string): string { + return s.toLowerCase().replace(/[^a-z ]/g, "").trim(); +} + +/** + * Whole-word, plural-tolerant match: "Raspberries" hits "Raspberry", + * "Bearded Iris" hits "Iris" — but "Pear" must never hit "Pea". + */ +function nameMatches(knowledgeName: string, plantName: string): boolean { + const stem = (w: string) => (w.endsWith("ies") ? w.slice(0, -3) + "y" : w.replace(/e?s$/, "")); + const kStems = norm(knowledgeName).split(" ").filter(Boolean).map(stem); + const pStems = norm(plantName).split(" ").filter(Boolean).map(stem); + if (kStems.length === 0 || pStems.length === 0) return false; + return kStems.every((w) => pStems.includes(w)); +} + +/** The knowledge entry for a plant/type name, if we have one. */ +export function knowledgeFor(plantName: string): CropKnowledge | null { + for (const crop of KNOWLEDGE) { + if (nameMatches(crop.name, plantName)) return crop; + if (crop.aliases?.some((a) => nameMatches(a, plantName))) return crop; + } + return null; +} + +export type MonthlySuggestion = { + crop: string | null; // null = general homestead tip + action: string; + detail?: string; + uwUrl: string; +}; + +/** + * What's worth doing this month (1–12), given the plant names on hand. + * General tips always apply; crop tips only if a matching plant is grown. + */ +export function tipsForMonth(month: number, ownedNames: string[]): MonthlySuggestion[] { + const out: MonthlySuggestion[] = []; + const seen = new Set(); + + for (const crop of KNOWLEDGE) { + const grown = ownedNames.some( + (n) => nameMatches(crop.name, n) || crop.aliases?.some((a) => nameMatches(a, n)), + ); + if (!grown) continue; + for (const tip of crop.tips) { + if (!tip.months.includes(month)) continue; + const key = `${crop.name}|${tip.action}`; + if (seen.has(key)) continue; + seen.add(key); + out.push({ crop: crop.name, action: tip.action, detail: tip.detail, uwUrl: uwSearchUrl(crop.uwQuery) }); + } + } + + for (const tip of GENERAL_TIPS) { + if (tip.months.includes(month)) { + out.push({ crop: null, action: tip.action, detail: tip.detail, uwUrl: uwSearchUrl(tip.uwQuery) }); + } + } + + return out; +} diff --git a/src/lib/version.ts b/src/lib/version.ts index 2bdb10a..aa50d98 100644 --- a/src/lib/version.ts +++ b/src/lib/version.ts @@ -1,2 +1,2 @@ // Bump on every user-visible release. Changelog entries live in `src/lib/changelog.ts`. -export const APP_VERSION = "0.26.0"; +export const APP_VERSION = "0.27.0";