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:
@@ -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).
|
||||
|
||||
@@ -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's blooming and ready to pick.
|
||||
</p>
|
||||
</div>
|
||||
<CalendarView tasks={calendarTasks} seasonTypes={seasonTypes} />
|
||||
<CalendarView tasks={calendarTasks} seasonTypes={seasonTypes} plantNames={plantNames} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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<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">
|
||||
|
||||
@@ -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",
|
||||
|
||||
69
src/lib/garden/knowledge.test.ts
Normal file
69
src/lib/garden/knowledge.test.ts
Normal 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 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");
|
||||
});
|
||||
});
|
||||
435
src/lib/garden/knowledge.ts
Normal file
435
src/lib/garden/knowledge.ts
Normal 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 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<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 — 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<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;
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user