v0.28.0 — Fix null toxicToPets on type create; Root vegetable category; sweet potato knowledge

The plant-type CREATE schema rejected toxicToPets: null (the dialog's
'Unknown'), failing every manually-entered type with 'expected boolean,
received null' — now nullable like the PATCH schema always was. New
ROOT_VEGETABLE PlantCategory (enum migration + labels/order). Garden
brain gains Sweet potato (slips in June, dig before frost, warm cure),
and knowledgeFor/tipsForMonth now prefer the most specific name match so
'Japanese sweet potato' gets sweet-potato advice instead of Potato's
nightshade rotation and hilling (regression-tested).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Bonna Moon
2026-07-06 20:27:51 -05:00
parent fe24cd73be
commit a50f7eaa49
8 changed files with 63 additions and 10 deletions

View File

@@ -0,0 +1,2 @@
-- AlterEnum
ALTER TYPE "PlantCategory" ADD VALUE 'ROOT_VEGETABLE';

View File

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

View File

@@ -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(),

View File

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

View File

@@ -9,6 +9,7 @@ export const CATEGORY_LABELS: Record<PlantCategory, string> = {
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",

View File

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

View File

@@ -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 (8085°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<string>();
// 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}`;

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.27.0";
export const APP_VERSION = "0.28.0";