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:
@@ -0,0 +1,2 @@
|
||||
-- AlterEnum
|
||||
ALTER TYPE "PlantCategory" ADD VALUE 'ROOT_VEGETABLE';
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<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}`;
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user