v0.27.0 — Garden brain: zone-4b knowledge, calendar suggestions, UW citations

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 <noreply@anthropic.com>
This commit is contained in:
Bonna Moon
2026-07-06 19:35:45 -05:00
parent 5319e8af2f
commit fe24cd73be
8 changed files with 721 additions and 7 deletions

View File

@@ -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).

View File

@@ -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 (
<div className="space-y-6 max-w-4xl">
<div>
@@ -61,7 +70,7 @@ export default async function CalendarPage() {
Everything due around the homestead garden and house plus what&apos;s blooming and ready to pick.
</p>
</div>
<CalendarView tasks={calendarTasks} seasonTypes={seasonTypes} />
<CalendarView tasks={calendarTasks} seasonTypes={seasonTypes} plantNames={plantNames} />
</div>
);
}

View File

@@ -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 (
<Card>
<CardContent className="pt-4 space-y-3 text-sm">
<div className="flex items-center justify-between gap-2">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
Growing {k.name.toLowerCase()} in zone 4b
</p>
<a
href={uwSearchUrl(k.uwQuery)}
target="_blank"
rel="noreferrer"
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground underline-offset-2 hover:underline"
>
UW Extension <ExternalLink className="h-3 w-3" />
</a>
</div>
{k.tips.length > 0 && (
<ul className="space-y-1">
{k.tips.map((t) => (
<li key={t.action} className="flex items-start gap-2">
<Sprout className="h-3.5 w-3.5 mt-0.5 shrink-0 text-[hsl(var(--leaf))]" />
<span>
<span className="text-muted-foreground">{t.months.map((m) => MONTHS[m]).join(", ")}:</span>{" "}
{t.action}
{t.detail && <span className="text-muted-foreground"> {t.detail}</span>}
</span>
</li>
))}
</ul>
)}
{hasCompanions && (
<div className="flex flex-col gap-1 pt-2 border-t">
{k.friends && k.friends.length > 0 && (
<p className="flex items-start gap-2">
<ThumbsUp className="h-3.5 w-3.5 mt-0.5 shrink-0 text-[hsl(var(--leaf))]" />
<span><span className="text-muted-foreground">Grows well with:</span> {k.friends.join(", ")}</span>
</p>
)}
{k.foes && k.foes.length > 0 && (
<p className="flex items-start gap-2">
<ThumbsDown className="h-3.5 w-3.5 mt-0.5 shrink-0 text-[hsl(var(--ember))]" />
<span><span className="text-muted-foreground">Keep away from:</span> {k.foes.join(", ")}</span>
</p>
)}
</div>
)}
{(k.family || k.rotationNote) && (
<p className="flex items-start gap-2 pt-2 border-t">
<Repeat className="h-3.5 w-3.5 mt-0.5 shrink-0 text-muted-foreground" />
<span>
{k.family && <span className="text-muted-foreground">Rotation family: {ROTATION_LABELS[k.family]}.</span>}
{k.rotationNote && <span> {k.rotationNote}</span>}
</span>
</p>
)}
</CardContent>
</Card>
);
}
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
</a>
)}
<GrowingTogether name={type.commonName} />
{(type.careNotes || type.notes) && (
<Card>
<CardContent className="pt-4 space-y-3 text-sm">

View File

@@ -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; // 112
year: number;
monthLabel: string;
plantNames: string[];
existingTitles: string[];
}) {
const router = useRouter();
const { toast } = useToast();
const [added, setAdded] = useState<Set<string>>(new Set());
const [busy, setBusy] = useState<string | null>(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 (
<div className="rounded-lg border bg-[hsl(var(--leaf)/.05)] overflow-hidden">
<div className="flex items-center gap-2 px-4 py-2.5 border-b bg-[hsl(var(--leaf)/.08)]">
<Sparkles className="h-4 w-4 text-[hsl(var(--leaf))]" />
<span className="text-sm font-medium">Good to do in {monthLabel}</span>
<span className="text-xs text-muted-foreground">· for what you grow (zone 4b)</span>
</div>
<div className="divide-y">
{open.map((s) => (
<div key={s.action} className="flex items-center gap-3 px-4 py-2.5 text-sm">
<div className="flex-1 min-w-0">
<p className="font-medium">
{s.action}
{s.crop && <span className="text-muted-foreground font-normal text-xs"> · {s.crop}</span>}
</p>
{s.detail && <p className="text-xs text-muted-foreground">{s.detail}</p>}
</div>
<a
href={s.uwUrl}
target="_blank"
rel="noreferrer"
title="Read more at UW Extension"
className="text-muted-foreground hover:text-foreground shrink-0"
>
<ExternalLink className="h-3.5 w-3.5" />
</a>
<Button
variant="outline"
size="sm"
className="h-7 gap-1 text-xs shrink-0"
disabled={busy === s.action}
onClick={() => addReminder(s)}
>
{busy === s.action ? "…" : <><BellPlus className="h-3 w-3" /> Remind me</>}
</Button>
</div>
))}
</div>
</div>
);
}
const CATEGORY_STYLES: Record<CalendarTask["category"], string> = {
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 }: {
</div>
</div>
<SuggestionList
month={month + 1}
year={year}
monthLabel={monthStart.toLocaleDateString(undefined, { month: "long" })}
plantNames={plantNames}
existingTitles={tasks.map((t) => t.title)}
/>
{selected && (
<div>
<h3 className="text-sm font-medium text-muted-foreground mb-2">

View File

@@ -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 UWMadison 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",

View File

@@ -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 112 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");
});
});

435
src/lib/garden/knowledge.ts Normal file
View File

@@ -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 UWMadison 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[]; // 112, 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<RotationFamily, string> = {
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 — 45 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.55.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 13 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: "46 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 (112), 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<string>();
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;
}

View File

@@ -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";