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`
|
- **Reminders** — `Task` + `TaskCompletion`; recurrence in `src/lib/tasks/schedule.ts`
|
||||||
(incl. `occurrencesInRange` projection). **/calendar** renders a month view of
|
(incl. `occurrencesInRange` projection). **/calendar** renders a month view of
|
||||||
all tasks (garden + house) plus PlantType bloom/harvest windows
|
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/`).
|
- **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.
|
- **Suggestions** — `@otm/account-panel` (Gitea npm registry) → files issues to the repo.
|
||||||
- **Backup & Restore** — `/api/admin/backup` + `/api/admin/restore` (admin only).
|
- **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 const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export default async function CalendarPage() {
|
export default async function CalendarPage() {
|
||||||
const [tasks, types] = await Promise.all([
|
const [tasks, types, plants] = await Promise.all([
|
||||||
db.task.findMany({
|
db.task.findMany({
|
||||||
where: { active: true },
|
where: { active: true },
|
||||||
orderBy: { nextDue: "asc" },
|
orderBy: { nextDue: "asc" },
|
||||||
@@ -33,6 +33,10 @@ export default async function CalendarPage() {
|
|||||||
bloomStart: true, bloomEnd: true, harvestStart: true, harvestEnd: true,
|
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) => ({
|
const calendarTasks: CalendarTask[] = tasks.map((t) => ({
|
||||||
@@ -53,6 +57,11 @@ export default async function CalendarPage() {
|
|||||||
|
|
||||||
const seasonTypes: SeasonType[] = types;
|
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 (
|
return (
|
||||||
<div className="space-y-6 max-w-4xl">
|
<div className="space-y-6 max-w-4xl">
|
||||||
<div>
|
<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.
|
Everything due around the homestead — garden and house — plus what's blooming and ready to pick.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<CalendarView tasks={calendarTasks} seasonTypes={seasonTypes} />
|
<CalendarView tasks={calendarTasks} seasonTypes={seasonTypes} plantNames={plantNames} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,9 +5,10 @@ import { Badge } from "@/components/ui/badge";
|
|||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import {
|
import {
|
||||||
ChevronLeft, Sun, Droplets, Flower2, Apple, AlertTriangle, Ruler, MapPin, Leaf,
|
ChevronLeft, Sun, Droplets, Flower2, Apple, AlertTriangle, Ruler, MapPin, Leaf,
|
||||||
GraduationCap, ExternalLink,
|
GraduationCap, ExternalLink, Sprout, ThumbsUp, ThumbsDown, Repeat,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { CATEGORY_LABELS } from "@/lib/garden/categories";
|
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 { EditPlantTypeButton, type PlantTypeFields } from "@/components/garden/plant-type-dialog";
|
||||||
import { DeletePlantTypeButton } from "@/components/garden/delete-plant-type-button";
|
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" };
|
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 } }) {
|
export default async function PlantTypeDetailPage({ params }: { params: { id: string } }) {
|
||||||
const type = await db.plantType.findUnique({
|
const type = await db.plantType.findUnique({
|
||||||
where: { id: params.id },
|
where: { id: params.id },
|
||||||
@@ -132,6 +204,8 @@ export default async function PlantTypeDetailPage({ params }: { params: { id: st
|
|||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<GrowingTogether name={type.commonName} />
|
||||||
|
|
||||||
{(type.careNotes || type.notes) && (
|
{(type.careNotes || type.notes) && (
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="pt-4 space-y-3 text-sm">
|
<CardContent className="pt-4 space-y-3 text-sm">
|
||||||
|
|||||||
@@ -5,11 +5,14 @@
|
|||||||
// nextDue via occurrencesInRange; windows come from PlantType months.
|
// nextDue via occurrencesInRange; windows come from PlantType months.
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import Link from "next/link";
|
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 { Button } from "@/components/ui/button";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
import { occurrencesInRange, describeRecurrence, type RecurrenceType } from "@/lib/tasks/schedule";
|
import { occurrencesInRange, describeRecurrence, type RecurrenceType } from "@/lib/tasks/schedule";
|
||||||
import { monthInWindow } from "@/lib/garden/season";
|
import { monthInWindow } from "@/lib/garden/season";
|
||||||
|
import { tipsForMonth, type MonthlySuggestion } from "@/lib/garden/knowledge";
|
||||||
import { CompleteTaskButton } from "@/components/tasks/complete-task-button";
|
import { CompleteTaskButton } from "@/components/tasks/complete-task-button";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
@@ -33,6 +36,105 @@ export type SeasonType = {
|
|||||||
harvestEnd: number | null;
|
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> = {
|
const CATEGORY_STYLES: Record<CalendarTask["category"], string> = {
|
||||||
GARDEN: "bg-[hsl(var(--leaf)/.12)] text-[hsl(var(--leaf))]",
|
GARDEN: "bg-[hsl(var(--leaf)/.12)] text-[hsl(var(--leaf))]",
|
||||||
HOME: "bg-amber-100 text-amber-800",
|
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")}`;
|
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[];
|
tasks: CalendarTask[];
|
||||||
seasonTypes: SeasonType[];
|
seasonTypes: SeasonType[];
|
||||||
|
plantNames: string[];
|
||||||
}) {
|
}) {
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
const todayIso = iso(today);
|
const todayIso = iso(today);
|
||||||
@@ -189,6 +292,14 @@ export function CalendarView({ tasks, seasonTypes }: {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<SuggestionList
|
||||||
|
month={month + 1}
|
||||||
|
year={year}
|
||||||
|
monthLabel={monthStart.toLocaleDateString(undefined, { month: "long" })}
|
||||||
|
plantNames={plantNames}
|
||||||
|
existingTitles={tasks.map((t) => t.title)}
|
||||||
|
/>
|
||||||
|
|
||||||
{selected && (
|
{selected && (
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-sm font-medium text-muted-foreground mb-2">
|
<h3 className="text-sm font-medium text-muted-foreground mb-2">
|
||||||
|
|||||||
@@ -8,6 +8,15 @@ export type ChangelogEntry = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const CHANGELOG: 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",
|
version: "0.26.0",
|
||||||
date: "2026-07-06",
|
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`.
|
// 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