diff --git a/prisma/migrations/20260707012217_v0_28_root_vegetable_category/migration.sql b/prisma/migrations/20260707012217_v0_28_root_vegetable_category/migration.sql new file mode 100644 index 0000000..ead75fd --- /dev/null +++ b/prisma/migrations/20260707012217_v0_28_root_vegetable_category/migration.sql @@ -0,0 +1,2 @@ +-- AlterEnum +ALTER TYPE "PlantCategory" ADD VALUE 'ROOT_VEGETABLE'; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 4a992b0..dd889f9 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -122,6 +122,7 @@ enum PlantCategory { GROUNDCOVER // strawberries, clover, creeping thyme VINE // grapes, hops, kiwi ANNUAL_VEGETABLE // tomatoes, squash, beans, peppers — seasonal veg + ROOT_VEGETABLE // carrots, beets, sweet potatoes, radishes ANNUAL // legacy, treated as annual vegetable ANNUAL_FLOWER // zinnias, marigolds, nasturtiums, cosmos PERENNIAL // asparagus, rhubarb, perennial veg diff --git a/src/app/api/plant-types/route.ts b/src/app/api/plant-types/route.ts index 5ce03a2..aca4866 100644 --- a/src/app/api/plant-types/route.ts +++ b/src/app/api/plant-types/route.ts @@ -17,7 +17,7 @@ const createSchema = z.object({ bloomEnd: month.optional(), harvestStart: month.optional(), harvestEnd: month.optional(), - toxicToPets: z.boolean().optional(), + toxicToPets: z.boolean().nullable().optional(), // "Unknown" in the dialog arrives as null edibleParts: z.string().optional(), spacing: z.string().optional(), imageUrl: z.string().optional(), diff --git a/src/lib/changelog.ts b/src/lib/changelog.ts index 04e68b6..d85c972 100644 --- a/src/lib/changelog.ts +++ b/src/lib/changelog.ts @@ -8,6 +8,15 @@ export type ChangelogEntry = { }; export const CHANGELOG: ChangelogEntry[] = [ + { + version: "0.28.0", + date: "2026-07-06", + changes: [ + "Fixed: adding a new plant type by hand failed with a confusing \"expected boolean\" error when \"Toxic to pets?\" was left as Unknown. Leaving it Unknown works now.", + "New \"Root vegetable\" category for plants and plant types — carrots, beets, sweet potatoes, radishes.", + "The garden brain now knows sweet potatoes (including Japanese ones): plant slips in warm June soil, dig before the first fall frost, cure warm to sweeten.", + ], + }, { version: "0.27.0", date: "2026-07-06", diff --git a/src/lib/garden/categories.ts b/src/lib/garden/categories.ts index d70fc8c..26b17be 100644 --- a/src/lib/garden/categories.ts +++ b/src/lib/garden/categories.ts @@ -9,6 +9,7 @@ export const CATEGORY_LABELS: Record = { GROUNDCOVER: "Groundcover", VINE: "Vine", ANNUAL_VEGETABLE: "Annual vegetable", + ROOT_VEGETABLE: "Root vegetable", ANNUAL: "Annual vegetable", ANNUAL_FLOWER: "Annual flower", PERENNIAL: "Perennial", @@ -29,6 +30,7 @@ export const CATEGORY_ORDER: PlantCategory[] = [ "PERENNIAL", "PERENNIAL_FLOWER", "ANNUAL_VEGETABLE", + "ROOT_VEGETABLE", "ANNUAL_FLOWER", "BULB", "MUSHROOM", diff --git a/src/lib/garden/knowledge.test.ts b/src/lib/garden/knowledge.test.ts index 7a145a8..ce09056 100644 --- a/src/lib/garden/knowledge.test.ts +++ b/src/lib/garden/knowledge.test.ts @@ -22,6 +22,12 @@ describe("knowledgeFor", () => { it("returns null for unknown plants", () => { expect(knowledgeFor("Dusty Miller")).toBeNull(); }); + + it("prefers the most specific match — sweet potatoes are not potatoes", () => { + expect(knowledgeFor("Sweet potato")?.name).toBe("Sweet potato"); + expect(knowledgeFor("Japanese Sweet Potato")?.name).toBe("Sweet potato"); + expect(knowledgeFor("Potato")?.name).toBe("Potato"); + }); }); describe("tipsForMonth", () => { @@ -37,6 +43,13 @@ describe("tipsForMonth", () => { expect(nov.some((t) => t.crop === null && t.action.includes("Winter-mulch"))).toBe(true); }); + it("suggests only the best-match crop — no potato-hilling for sweet potatoes", () => { + const june = tipsForMonth(6, ["Japanese sweet potato"]); + const actions = june.map((t) => t.action); + expect(actions).toContain("Plant sweet potato slips"); + expect(actions).not.toContain("Hill potatoes"); + }); + 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); diff --git a/src/lib/garden/knowledge.ts b/src/lib/garden/knowledge.ts index 3e6f70e..13bb3f4 100644 --- a/src/lib/garden/knowledge.ts +++ b/src/lib/garden/knowledge.ts @@ -140,6 +140,17 @@ export const KNOWLEDGE: CropKnowledge[] = [ { months: [10], action: "Finish digging carrots", detail: "A light frost sweetens them; dig before the ground locks up." }, ], }, + { + name: "Sweet potato", + aliases: ["Japanese sweet potato"], + family: "root", + rotationNote: "Not a nightshade despite the name (it's a morning glory) — rotates fine after tomatoes.", + uwQuery: "growing sweet potatoes", + tips: [ + { months: [6], action: "Plant sweet potato slips", detail: "They demand warm soil — early June here, black plastic mulch helps a zone-4 season." }, + { months: [9], action: "Dig sweet potatoes before frost", detail: "Vines die at the first touch of frost; cure warm (80–85°F) for a week to sweeten." }, + ], + }, { name: "Beet", family: "root", @@ -387,13 +398,25 @@ function nameMatches(knowledgeName: string, plantName: string): boolean { return kStems.every((w) => pStems.includes(w)); } -/** The knowledge entry for a plant/type name, if we have one. */ +/** + * The knowledge entry for a plant/type name, if we have one. Prefers the most + * specific match: "Japanese sweet potato" must hit Sweet potato, not Potato. + */ export function knowledgeFor(plantName: string): CropKnowledge | null { + let best: CropKnowledge | null = null; + let bestScore = 0; for (const crop of KNOWLEDGE) { - if (nameMatches(crop.name, plantName)) return crop; - if (crop.aliases?.some((a) => nameMatches(a, plantName))) return crop; + const names = [crop.name, ...(crop.aliases ?? [])]; + for (const n of names) { + if (!nameMatches(n, plantName)) continue; + const score = norm(n).split(" ").filter(Boolean).length; // matched words + if (score > bestScore) { + best = crop; + bestScore = score; + } + } } - return null; + return best; } export type MonthlySuggestion = { @@ -411,11 +434,14 @@ export function tipsForMonth(month: number, ownedNames: string[]): MonthlySugges const out: MonthlySuggestion[] = []; const seen = new Set(); + // Resolve each owned name to its single best crop, so "Sweet potato" + // doesn't also drag in regular Potato advice. + const grownCrops = new Set( + ownedNames.map((n) => knowledgeFor(n)?.name).filter((n): n is string => !!n), + ); + for (const crop of KNOWLEDGE) { - const grown = ownedNames.some( - (n) => nameMatches(crop.name, n) || crop.aliases?.some((a) => nameMatches(a, n)), - ); - if (!grown) continue; + if (!grownCrops.has(crop.name)) continue; for (const tip of crop.tips) { if (!tip.months.includes(month)) continue; const key = `${crop.name}|${tip.action}`; diff --git a/src/lib/version.ts b/src/lib/version.ts index aa50d98..9f6c643 100644 --- a/src/lib/version.ts +++ b/src/lib/version.ts @@ -1,2 +1,2 @@ // Bump on every user-visible release. Changelog entries live in `src/lib/changelog.ts`. -export const APP_VERSION = "0.27.0"; +export const APP_VERSION = "0.28.0";