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
|
GROUNDCOVER // strawberries, clover, creeping thyme
|
||||||
VINE // grapes, hops, kiwi
|
VINE // grapes, hops, kiwi
|
||||||
ANNUAL_VEGETABLE // tomatoes, squash, beans, peppers — seasonal veg
|
ANNUAL_VEGETABLE // tomatoes, squash, beans, peppers — seasonal veg
|
||||||
|
ROOT_VEGETABLE // carrots, beets, sweet potatoes, radishes
|
||||||
ANNUAL // legacy, treated as annual vegetable
|
ANNUAL // legacy, treated as annual vegetable
|
||||||
ANNUAL_FLOWER // zinnias, marigolds, nasturtiums, cosmos
|
ANNUAL_FLOWER // zinnias, marigolds, nasturtiums, cosmos
|
||||||
PERENNIAL // asparagus, rhubarb, perennial veg
|
PERENNIAL // asparagus, rhubarb, perennial veg
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ const createSchema = z.object({
|
|||||||
bloomEnd: month.optional(),
|
bloomEnd: month.optional(),
|
||||||
harvestStart: month.optional(),
|
harvestStart: month.optional(),
|
||||||
harvestEnd: 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(),
|
edibleParts: z.string().optional(),
|
||||||
spacing: z.string().optional(),
|
spacing: z.string().optional(),
|
||||||
imageUrl: z.string().optional(),
|
imageUrl: z.string().optional(),
|
||||||
|
|||||||
@@ -8,6 +8,15 @@ export type ChangelogEntry = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const CHANGELOG: 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",
|
version: "0.27.0",
|
||||||
date: "2026-07-06",
|
date: "2026-07-06",
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export const CATEGORY_LABELS: Record<PlantCategory, string> = {
|
|||||||
GROUNDCOVER: "Groundcover",
|
GROUNDCOVER: "Groundcover",
|
||||||
VINE: "Vine",
|
VINE: "Vine",
|
||||||
ANNUAL_VEGETABLE: "Annual vegetable",
|
ANNUAL_VEGETABLE: "Annual vegetable",
|
||||||
|
ROOT_VEGETABLE: "Root vegetable",
|
||||||
ANNUAL: "Annual vegetable",
|
ANNUAL: "Annual vegetable",
|
||||||
ANNUAL_FLOWER: "Annual flower",
|
ANNUAL_FLOWER: "Annual flower",
|
||||||
PERENNIAL: "Perennial",
|
PERENNIAL: "Perennial",
|
||||||
@@ -29,6 +30,7 @@ export const CATEGORY_ORDER: PlantCategory[] = [
|
|||||||
"PERENNIAL",
|
"PERENNIAL",
|
||||||
"PERENNIAL_FLOWER",
|
"PERENNIAL_FLOWER",
|
||||||
"ANNUAL_VEGETABLE",
|
"ANNUAL_VEGETABLE",
|
||||||
|
"ROOT_VEGETABLE",
|
||||||
"ANNUAL_FLOWER",
|
"ANNUAL_FLOWER",
|
||||||
"BULB",
|
"BULB",
|
||||||
"MUSHROOM",
|
"MUSHROOM",
|
||||||
|
|||||||
@@ -22,6 +22,12 @@ describe("knowledgeFor", () => {
|
|||||||
it("returns null for unknown plants", () => {
|
it("returns null for unknown plants", () => {
|
||||||
expect(knowledgeFor("Dusty Miller")).toBeNull();
|
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", () => {
|
describe("tipsForMonth", () => {
|
||||||
@@ -37,6 +43,13 @@ describe("tipsForMonth", () => {
|
|||||||
expect(nov.some((t) => t.crop === null && t.action.includes("Winter-mulch"))).toBe(true);
|
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", () => {
|
it("dedupes when several plants match the same crop", () => {
|
||||||
const july = tipsForMonth(7, ["Bearded Iris", "Iris sibirica", "Yellow Flag Iris"]);
|
const july = tipsForMonth(7, ["Bearded Iris", "Iris sibirica", "Yellow Flag Iris"]);
|
||||||
expect(july.filter((t) => t.crop === "Iris")).toHaveLength(1);
|
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." },
|
{ 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",
|
name: "Beet",
|
||||||
family: "root",
|
family: "root",
|
||||||
@@ -387,13 +398,25 @@ function nameMatches(knowledgeName: string, plantName: string): boolean {
|
|||||||
return kStems.every((w) => pStems.includes(w));
|
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 {
|
export function knowledgeFor(plantName: string): CropKnowledge | null {
|
||||||
|
let best: CropKnowledge | null = null;
|
||||||
|
let bestScore = 0;
|
||||||
for (const crop of KNOWLEDGE) {
|
for (const crop of KNOWLEDGE) {
|
||||||
if (nameMatches(crop.name, plantName)) return crop;
|
const names = [crop.name, ...(crop.aliases ?? [])];
|
||||||
if (crop.aliases?.some((a) => nameMatches(a, plantName))) return crop;
|
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 = {
|
export type MonthlySuggestion = {
|
||||||
@@ -411,11 +434,14 @@ export function tipsForMonth(month: number, ownedNames: string[]): MonthlySugges
|
|||||||
const out: MonthlySuggestion[] = [];
|
const out: MonthlySuggestion[] = [];
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
|
|
||||||
for (const crop of KNOWLEDGE) {
|
// Resolve each owned name to its single best crop, so "Sweet potato"
|
||||||
const grown = ownedNames.some(
|
// doesn't also drag in regular Potato advice.
|
||||||
(n) => nameMatches(crop.name, n) || crop.aliases?.some((a) => nameMatches(a, n)),
|
const grownCrops = new Set(
|
||||||
|
ownedNames.map((n) => knowledgeFor(n)?.name).filter((n): n is string => !!n),
|
||||||
);
|
);
|
||||||
if (!grown) continue;
|
|
||||||
|
for (const crop of KNOWLEDGE) {
|
||||||
|
if (!grownCrops.has(crop.name)) continue;
|
||||||
for (const tip of crop.tips) {
|
for (const tip of crop.tips) {
|
||||||
if (!tip.months.includes(month)) continue;
|
if (!tip.months.includes(month)) continue;
|
||||||
const key = `${crop.name}|${tip.action}`;
|
const key = `${crop.name}|${tip.action}`;
|
||||||
|
|||||||
@@ -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.27.0";
|
export const APP_VERSION = "0.28.0";
|
||||||
|
|||||||
Reference in New Issue
Block a user