v0.24.0 — Garden: harvest→pantry, structured amounts, UW Extension links

Harvest logs now carry a structured amount+unit (legacy freeform quantity
kept for old rows) and can simultaneously create a GARDEN-source pantry
item via addPlantLog's new pantry input. PlantTypes can link a UW–Madison
Extension article, searched through /api/v1/plant-types/uw-search (proxy
to hort.extension.wisc.edu's WordPress REST API); the link shows on the
type page and each plant. The garden log dialog now posts to /api/v1.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Bonna Moon
2026-07-06 11:14:11 -05:00
parent 25b1ba9aeb
commit e65d28524b
16 changed files with 443 additions and 25 deletions

View File

@@ -9,6 +9,12 @@ user-facing overview.
- **Garden** — Plant registry + care/harvest logs, plant **groups**, reusable
**PlantType** records, Wikipedia photos. Plants sit on the shared location tree.
Harvest logs carry structured `amount`+`unit` (legacy freeform `quantity` kept
for old rows — don't regress); a harvest can simultaneously create a pantry
item (`addPlantLog`'s `pantry` input → GARDEN-source consumable). PlantTypes
can link a UWMadison Extension article (`uwExtensionUrl/Title`), searched via
`/api/v1/plant-types/uw-search` (proxy to hort.extension.wisc.edu's WP REST
API; helper `src/lib/garden/uw-extension.ts`).
- **Inventory** — durable **Item**s that nest (`parentItemId`: a toolbox is an
Item that holds Items, with a cycle guard), with **Label**s (M:N via
`LabelOnItem`), `ItemAttachment` (PHOTO/MANUAL/RECEIPT/WARRANTY), and `ItemLog`.

View File

@@ -0,0 +1,7 @@
-- AlterTable
ALTER TABLE "PlantLog" ADD COLUMN "amount" DECIMAL(10,2),
ADD COLUMN "unit" TEXT;
-- AlterTable
ALTER TABLE "PlantType" ADD COLUMN "uwExtensionTitle" TEXT,
ADD COLUMN "uwExtensionUrl" TEXT;

View File

@@ -195,6 +195,8 @@ model PlantType {
edibleParts String? // "Fruit, young leaves"
spacing String? // "1015 ft", "18 in"
imageUrl String?
uwExtensionUrl String? // linked UWMadison Extension article (hort.extension.wisc.edu)
uwExtensionTitle String? // its title, shown as the link text
notes String?
active Boolean @default(true)
createdAt DateTime @default(now())
@@ -282,7 +284,9 @@ model PlantLog {
type PlantLogType
date DateTime @default(now())
notes String?
quantity String? // freeform for harvests: "2 lbs", "1 gallon", "a big handful"
quantity String? // legacy freeform harvests ("a big handful") — new logs use amount+unit
amount Decimal? @db.Decimal(10, 2) // structured harvest quantity (queryable totals)
unit String? // "lbs", "each", "quarts" — pairs with amount
actorId String?
createdAt DateTime @default(now())

View File

@@ -3,7 +3,7 @@ import { db } from "@/lib/db";
import Link from "next/link";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { MapPin, CalendarDays, ChevronLeft, Sprout, Package, BookOpen } from "lucide-react";
import { MapPin, CalendarDays, ChevronLeft, Sprout, Package, BookOpen, GraduationCap } from "lucide-react";
import { formatDate } from "@/lib/utils";
import { CATEGORY_LABELS, LOG_TYPE_LABELS, LOG_TYPE_COLORS } from "@/lib/garden/categories";
import { AddLogButton } from "@/components/garden/add-log-button";
@@ -22,7 +22,7 @@ export default async function PlantDetailPage({ params }: { params: { id: string
include: {
logs: { orderBy: { date: "desc" } },
location: { select: { id: true, name: true } },
plantType: { select: { id: true, commonName: true } },
plantType: { select: { id: true, commonName: true, uwExtensionUrl: true, uwExtensionTitle: true } },
},
}),
db.location.findMany({ where: { active: true }, select: { id: true, name: true }, orderBy: { name: "asc" } }),
@@ -78,6 +78,19 @@ export default async function PlantDetailPage({ params }: { params: { id: string
</Link>
</div>
)}
{plant.plantType?.uwExtensionUrl && (
<div className="flex items-center gap-2 text-muted-foreground">
<GraduationCap className="h-4 w-4 shrink-0" />
<a
href={plant.plantType.uwExtensionUrl}
target="_blank"
rel="noreferrer"
className="hover:text-foreground underline-offset-2 hover:underline truncate"
>
UW Extension: {plant.plantType.uwExtensionTitle || "article"}
</a>
</div>
)}
{plant.plantedAt && (
<div className="flex items-center gap-2 text-muted-foreground">
<CalendarDays className="h-4 w-4 shrink-0" />
@@ -99,7 +112,7 @@ export default async function PlantDetailPage({ params }: { params: { id: string
<div>
<div className="flex items-center justify-between mb-3">
<h2 className="font-display text-lg font-semibold">Care &amp; harvest log</h2>
<AddLogButton plantId={plant.id} plantName={plant.commonName} />
<AddLogButton plantId={plant.id} plantName={plant.commonName} locations={locations} />
</div>
{plant.logs.length === 0 ? (
@@ -116,8 +129,10 @@ export default async function PlantDetailPage({ params }: { params: { id: string
<span className={`text-xs font-semibold uppercase tracking-wide ${LOG_TYPE_COLORS[log.type]}`}>
{LOG_TYPE_LABELS[log.type]}
</span>
{log.quantity && (
<span className="text-xs text-muted-foreground">· {log.quantity}</span>
{(log.amount != null || log.quantity) && (
<span className="text-xs text-muted-foreground">
· {log.amount != null ? `${Number(log.amount)} ${log.unit ?? ""}`.trim() : log.quantity}
</span>
)}
<span className="text-xs text-muted-foreground ml-auto">{formatDate(log.date)}</span>
</div>

View File

@@ -5,6 +5,7 @@ import { Badge } from "@/components/ui/badge";
import { Card, CardContent } from "@/components/ui/card";
import {
ChevronLeft, Sun, Droplets, Flower2, Apple, AlertTriangle, Ruler, MapPin, Leaf,
GraduationCap, ExternalLink,
} from "lucide-react";
import { CATEGORY_LABELS } from "@/lib/garden/categories";
import { EditPlantTypeButton, type PlantTypeFields } from "@/components/garden/plant-type-dialog";
@@ -56,6 +57,8 @@ export default async function PlantTypeDetailPage({ params }: { params: { id: st
toxicToPets: type.toxicToPets,
edibleParts: type.edibleParts,
spacing: type.spacing,
uwExtensionUrl: type.uwExtensionUrl,
uwExtensionTitle: type.uwExtensionTitle,
notes: type.notes,
};
@@ -113,6 +116,22 @@ export default async function PlantTypeDetailPage({ params }: { params: { id: st
</Card>
)}
{type.uwExtensionUrl && (
<a
href={type.uwExtensionUrl}
target="_blank"
rel="noreferrer"
className="flex items-center gap-2 text-sm rounded-lg border px-3 py-2.5 hover:bg-accent transition-colors"
>
<GraduationCap className="h-4 w-4 shrink-0 text-[hsl(var(--leaf))]" />
<span className="min-w-0 truncate">
<span className="text-muted-foreground">UW Extension:</span>{" "}
{type.uwExtensionTitle || "article"}
</span>
<ExternalLink className="h-3.5 w-3.5 ml-auto shrink-0 text-muted-foreground" />
</a>
)}
{(type.careNotes || type.notes) && (
<Card>
<CardContent className="pt-4 space-y-3 text-sm">

View File

@@ -23,11 +23,14 @@ const updateSchema = z.object({
edibleParts: z.string().optional(),
spacing: z.string().optional(),
imageUrl: z.string().optional(),
uwExtensionUrl: z.string().optional(),
uwExtensionTitle: z.string().optional(),
notes: z.string().optional(),
});
const TEXT_FIELDS = [
"species", "careNotes", "sun", "water", "edibleParts", "spacing", "imageUrl", "notes",
"species", "careNotes", "sun", "water", "edibleParts", "spacing", "imageUrl",
"uwExtensionUrl", "uwExtensionTitle", "notes",
] as const;
export async function PATCH(req: Request, { params }: { params: { id: string } }) {

View File

@@ -21,6 +21,8 @@ const createSchema = z.object({
edibleParts: z.string().optional(),
spacing: z.string().optional(),
imageUrl: z.string().optional(),
uwExtensionUrl: z.string().optional(),
uwExtensionTitle: z.string().optional(),
notes: z.string().optional(),
});
@@ -66,6 +68,8 @@ export async function POST(req: Request) {
edibleParts: data.edibleParts?.trim() || null,
spacing: data.spacing?.trim() || null,
imageUrl: data.imageUrl?.trim() || null,
uwExtensionUrl: data.uwExtensionUrl?.trim() || null,
uwExtensionTitle: data.uwExtensionTitle?.trim() || null,
notes: data.notes?.trim() || null,
},
});

View File

@@ -0,0 +1,15 @@
import { NextResponse } from "next/server";
import { authenticateRequest, handleApiError } from "@/lib/api/authenticate";
import { searchUwExtension } from "@/lib/garden/uw-extension";
// GET /api/v1/plant-types/uw-search?q=raspberry — proxy to the UWMadison
// Extension horticulture article search (avoids CORS, keeps the client thin).
export async function GET(req: Request) {
try {
await authenticateRequest(req, "plant-types:read");
const q = new URL(req.url).searchParams.get("q") ?? "";
return NextResponse.json(await searchUwExtension(q));
} catch (err) {
return handleApiError(err);
}
}

View File

@@ -24,24 +24,33 @@ const LOG_TYPE_ORDER: PlantLogType[] = [
"OBSERVATION", "CARE", "HARVEST", "TREATMENT", "TRANSPLANT", "DIED",
];
const UNIT_SUGGESTIONS = ["lbs", "oz", "each", "pints", "quarts", "gallons", "bunches", "heads", "cups"];
const schema = z.object({
type: z.nativeEnum(PlantLogType),
date: z.string().optional(),
notes: z.string().optional(),
quantity: z.string().optional(),
amount: z.string().optional(), // form keeps strings; parsed on submit
unit: z.string().optional(),
});
type FormValues = z.infer<typeof schema>;
interface Props {
plantId: string;
plantName: string;
locations: { id: string; name: string }[];
}
export function AddLogButton({ plantId, plantName }: Props) {
const NO_LOCATION = "__none__";
export function AddLogButton({ plantId, plantName, locations }: Props) {
const [open, setOpen] = useState(false);
const router = useRouter();
const { toast } = useToast();
const [logType, setLogType] = useState<PlantLogType>("OBSERVATION");
const [toPantry, setToPantry] = useState(false);
const [pantryLocationId, setPantryLocationId] = useState<string>(NO_LOCATION);
const [bestBefore, setBestBefore] = useState("");
const form = useForm<FormValues>({
resolver: zodResolver(schema),
@@ -51,21 +60,50 @@ export function AddLogButton({ plantId, plantName }: Props) {
},
});
function resetAll() {
form.reset({ type: "OBSERVATION", date: new Date().toISOString().split("T")[0] });
setLogType("OBSERVATION");
setToPantry(false);
setPantryLocationId(NO_LOCATION);
setBestBefore("");
}
async function onSubmit(values: FormValues) {
const res = await fetch(`/api/plants/${plantId}/logs`, {
const isHarvest = values.type === "HARVEST";
const amount = isHarvest && values.amount ? parseFloat(values.amount) : null;
if (isHarvest && values.amount && (amount == null || isNaN(amount) || amount < 0)) {
toast({ title: "Enter the amount as a number", description: "e.g. 2 or 2.5 — pick a unit next to it.", variant: "destructive" });
return;
}
const res = await fetch(`/api/v1/plants/${plantId}/logs`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(values),
body: JSON.stringify({
type: values.type,
date: values.date,
notes: values.notes,
amount: amount != null && !isNaN(amount) ? amount : undefined,
unit: isHarvest ? values.unit || undefined : undefined,
pantry: isHarvest && toPantry
? {
locationId: pantryLocationId === NO_LOCATION ? null : pantryLocationId,
bestBefore: bestBefore || null,
}
: undefined,
}),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
toast({ title: "Error", description: err.error ?? "Could not save log entry.", variant: "destructive" });
return;
}
toast({ title: "Log entry saved!" });
toast({
title: "Log entry saved!",
description: isHarvest && toPantry ? "Also added to Pantry & Freezer." : undefined,
});
setOpen(false);
form.reset({ type: "OBSERVATION", date: new Date().toISOString().split("T")[0] });
setLogType("OBSERVATION");
resetAll();
router.refresh();
}
@@ -110,10 +148,67 @@ export function AddLogButton({ plantId, plantName }: Props) {
</div>
{logType === "HARVEST" && (
<div className="space-y-1.5">
<Label htmlFor="quantity">How much did you harvest?</Label>
<Input id="quantity" placeholder="2 lbs, 1 gallon, a big handful…" {...form.register("quantity")} />
</div>
<>
<div className="space-y-1.5">
<Label>How much did you harvest?</Label>
<div className="flex gap-2">
<Input
type="number"
step="any"
min="0"
placeholder="2.5"
className="w-24"
{...form.register("amount")}
/>
<Input
list="harvest-units"
placeholder="lbs, each, quarts…"
className="flex-1"
{...form.register("unit")}
/>
<datalist id="harvest-units">
{UNIT_SUGGESTIONS.map((u) => <option key={u} value={u} />)}
</datalist>
</div>
</div>
<div className="space-y-2 rounded-md border p-3">
<label className="flex items-center gap-2 text-sm font-medium cursor-pointer">
<input
type="checkbox"
className="h-4 w-4 accent-[hsl(var(--leaf))]"
checked={toPantry}
onChange={(e) => setToPantry(e.target.checked)}
/>
Add to Pantry &amp; Freezer
</label>
{toPantry && (
<div className="space-y-2 pt-1">
<div className="space-y-1">
<Label className="text-xs">Where will it be stored?</Label>
<Select value={pantryLocationId} onValueChange={setPantryLocationId}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value={NO_LOCATION}>No specific spot</SelectItem>
{locations.map((l) => (
<SelectItem key={l.id} value={l.id}>{l.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label htmlFor="best-before" className="text-xs">Best before (optional)</Label>
<Input
id="best-before"
type="date"
value={bestBefore}
onChange={(e) => setBestBefore(e.target.value)}
/>
</div>
</div>
)}
</div>
</>
)}
<div className="space-y-1.5">

View File

@@ -14,9 +14,11 @@ import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from "@/components/ui/select";
import { useToast } from "@/hooks/use-toast";
import { Plus, Pencil } from "lucide-react";
import { Plus, Pencil, Search, X, ExternalLink } from "lucide-react";
import { CATEGORY_LABELS, CATEGORY_ORDER } from "@/lib/garden/categories";
type UwMatch = { id: number; title: string; url: string; excerpt: string };
export type PlantTypeFields = {
id?: string;
commonName: string;
@@ -32,6 +34,8 @@ export type PlantTypeFields = {
toxicToPets: boolean | null;
edibleParts: string | null;
spacing: string | null;
uwExtensionUrl: string | null;
uwExtensionTitle: string | null;
notes: string | null;
};
@@ -43,7 +47,8 @@ function blank(): PlantTypeFields {
commonName: "", species: null, category: "OTHER", careNotes: null,
sun: null, water: null, bloomStart: null, bloomEnd: null,
harvestStart: null, harvestEnd: null, toxicToPets: null,
edibleParts: null, spacing: null, notes: null,
edibleParts: null, spacing: null, uwExtensionUrl: null,
uwExtensionTitle: null, notes: null,
};
}
@@ -73,6 +78,8 @@ function PlantTypeDialog({
const [f, setF] = useState<PlantTypeFields>(initial);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [uwMatches, setUwMatches] = useState<UwMatch[] | null>(null);
const [uwBusy, setUwBusy] = useState(false);
const router = useRouter();
const { toast } = useToast();
@@ -80,6 +87,28 @@ function PlantTypeDialog({
setF((p) => ({ ...p, [key]: value }));
}
async function searchUw() {
const q = f.commonName.trim();
if (!q) { setError("Enter the plant name first, then look it up."); return; }
setError("");
setUwBusy(true);
try {
const res = await fetch(`/api/v1/plant-types/uw-search?q=${encodeURIComponent(q)}`);
const data = await res.json();
if (!res.ok) throw new Error(data.error ?? "Lookup failed");
setUwMatches(data);
} catch (err) {
toast({ title: "UW Extension lookup failed", description: String(err).replace("Error: ", ""), variant: "destructive" });
} finally {
setUwBusy(false);
}
}
function pickUw(m: UwMatch) {
setF((p) => ({ ...p, uwExtensionUrl: m.url, uwExtensionTitle: m.title }));
setUwMatches(null);
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!f.commonName.trim()) { setError("Please enter the plant name."); return; }
@@ -95,6 +124,8 @@ function PlantTypeDialog({
water: f.water ?? "",
edibleParts: f.edibleParts ?? "",
spacing: f.spacing ?? "",
uwExtensionUrl: f.uwExtensionUrl ?? "",
uwExtensionTitle: f.uwExtensionTitle ?? "",
notes: f.notes ?? "",
toxicToPets: f.toxicToPets,
};
@@ -217,6 +248,56 @@ function PlantTypeDialog({
placeholder="Fruit, young leaves" />
</div>
<div className="col-span-2 space-y-1.5">
<Label>UW Extension article</Label>
{f.uwExtensionUrl ? (
<div className="flex items-center gap-2 text-sm border rounded-md px-3 py-2">
<a
href={f.uwExtensionUrl}
target="_blank"
rel="noreferrer"
className="flex-1 min-w-0 truncate underline underline-offset-2 hover:text-foreground text-muted-foreground"
>
{f.uwExtensionTitle || f.uwExtensionUrl}
</a>
<ExternalLink className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
<button
type="button"
title="Remove link"
className="text-muted-foreground hover:text-destructive shrink-0"
onClick={() => setF((p) => ({ ...p, uwExtensionUrl: null, uwExtensionTitle: null }))}
>
<X className="h-3.5 w-3.5" />
</button>
</div>
) : (
<Button type="button" variant="outline" size="sm" onClick={searchUw} disabled={uwBusy} className="gap-1.5">
<Search className="h-3.5 w-3.5" />
{uwBusy ? "Searching…" : "Look up on UW Extension"}
</Button>
)}
{uwMatches && (
<div className="border rounded-md divide-y max-h-48 overflow-y-auto">
{uwMatches.length === 0 && (
<p className="text-xs text-muted-foreground p-3">
No UW Extension articles matched {f.commonName.trim()}.
</p>
)}
{uwMatches.map((m) => (
<button
key={m.id}
type="button"
onClick={() => pickUw(m)}
className="w-full text-left p-2.5 hover:bg-accent transition-colors"
>
<p className="text-sm font-medium">{m.title}</p>
{m.excerpt && <p className="text-xs text-muted-foreground line-clamp-2">{m.excerpt}</p>}
</button>
))}
</div>
)}
</div>
<div className="col-span-2 space-y-1.5">
<Label>Care notes</Label>
<Textarea rows={3} value={f.careNotes ?? ""} onChange={(e) => set("careNotes", e.target.value)}

View File

@@ -5,8 +5,17 @@ import { z } from "zod";
export const plantLogInput = z.object({
type: z.enum(["OBSERVATION", "CARE", "HARVEST", "TREATMENT", "TRANSPLANT", "DIED"]),
notes: z.string().optional(),
quantity: z.string().optional(),
quantity: z.string().optional(), // legacy freeform; prefer amount+unit
amount: z.coerce.number().nonnegative().nullable().optional(),
unit: z.string().optional(),
date: z.string().optional(), // ISO date; defaults to now
// HARVEST only: also create a pantry item (GARDEN source, linked to the plant)
pantry: z
.object({
locationId: z.string().nullable().optional(),
bestBefore: z.string().nullable().optional(),
})
.optional(),
});
export const taskCreateInput = z.object({

View File

@@ -8,6 +8,15 @@ export type ChangelogEntry = {
};
export const CHANGELOG: ChangelogEntry[] = [
{
version: "0.24.0",
date: "2026-07-06",
changes: [
"Garden: logging a harvest now asks how much as a real number with a unit (2.5 lbs, 12 each) — so the app can start totaling what each plant gives you. Old \"a big handful\" entries still show fine.",
"Garden → Kitchen: when you log a harvest there's a new \"Add to Pantry & Freezer\" checkbox — tick it, pick where it's stored, and the harvest lands in the pantry automatically, linked to the plant and marked as from Our garden.",
"Plant types can now link to a UWMadison Extension article: open a plant type, click Edit → \"Look up on UW Extension\", and pick the matching article. The link shows on the type page and on each of those plants.",
],
},
{
version: "0.23.0",
date: "2026-07-06",

View File

@@ -0,0 +1,49 @@
import { describe, expect, it } from "vitest";
import { mapUwArticles, stripWpHtml, type UwApiItem } from "./uw-extension";
describe("stripWpHtml", () => {
it("removes tags and collapses whitespace", () => {
expect(stripWpHtml("<p>This article covers cane blight.</p>\n")).toBe(
"This article covers cane blight.",
);
});
it("decodes the entities WordPress emits", () => {
expect(stripWpHtml("Bee&nbsp;balm &#8212; a gardener&#8217;s friend [&#8230;]")).toBe(
"Bee balm — a gardeners friend …",
);
});
});
describe("mapUwArticles", () => {
const raw: UwApiItem[] = [
{
id: 101,
link: "https://hort.extension.wisc.edu/articles/growing-raspberries/",
title: { rendered: "Growing Raspberries in Wisconsin" },
excerpt: { rendered: "<p>Raspberries thrive in&nbsp;Wisconsin.</p>" },
},
{
id: 102,
link: "https://hort.extension.wisc.edu/articles/cane-blight/",
title: { rendered: "" }, // no title → dropped
excerpt: { rendered: "<p>x</p>" },
},
];
it("maps to clean title/url/excerpt and drops titleless entries", () => {
const out = mapUwArticles(raw);
expect(out).toEqual([
{
id: 101,
title: "Growing Raspberries in Wisconsin",
url: "https://hort.extension.wisc.edu/articles/growing-raspberries/",
excerpt: "Raspberries thrive in Wisconsin.",
},
]);
});
it("survives malformed items", () => {
expect(mapUwArticles([{} as UwApiItem, null as unknown as UwApiItem])).toEqual([]);
});
});

View File

@@ -0,0 +1,75 @@
// UWMadison Division of Extension horticulture articles
// (hort.extension.wisc.edu) — searched via the site's public WordPress REST
// API. We store only a link + title on PlantType; the content stays theirs.
import { ApiError } from "@/lib/api/authenticate";
const UW_API = "https://hort.extension.wisc.edu/wp-json/wp/v2/ext_article";
export type UwArticle = {
id: number;
title: string;
url: string;
excerpt: string; // plain text, trimmed
};
// The slice of the WP response we read. `rendered` strings carry HTML.
export type UwApiItem = {
id: number;
link: string;
title?: { rendered?: string };
excerpt?: { rendered?: string };
};
/** Strip tags and decode the entities WP typically emits. */
export function stripWpHtml(html: string): string {
return html
.replace(/<[^>]*>/g, " ")
.replace(/&amp;/g, "&")
.replace(/&#8217;|&rsquo;/g, "")
.replace(/&#8216;|&lsquo;/g, "")
.replace(/&#8220;|&ldquo;/g, "“")
.replace(/&#8221;|&rdquo;/g, "”")
.replace(/&#8211;|&ndash;/g, "")
.replace(/&#8212;|&mdash;/g, "—")
.replace(/&hellip;|&#8230;/g, "…")
.replace(/&nbsp;/g, " ")
.replace(/\[&#8230;\]|\[…\]/g, "…")
.replace(/\s+/g, " ")
.trim();
}
/** Map raw WP items to clean articles, dropping malformed entries. */
export function mapUwArticles(items: UwApiItem[]): UwArticle[] {
return items
.filter(i => i && typeof i.id === "number" && typeof i.link === "string")
.map(i => ({
id: i.id,
title: stripWpHtml(i.title?.rendered ?? ""),
url: i.link,
excerpt: stripWpHtml(i.excerpt?.rendered ?? ""),
}))
.filter(a => a.title.length > 0);
}
/** Search UW Extension articles by plant/topic name. */
export async function searchUwExtension(q: string): Promise<UwArticle[]> {
const query = q.trim();
if (!query) return [];
const url = `${UW_API}?search=${encodeURIComponent(query)}&per_page=8&_fields=id,link,title,excerpt`;
let res: Response;
try {
res = await fetch(url, {
headers: { Accept: "application/json" },
signal: AbortSignal.timeout(8000),
cache: "no-store",
});
} catch {
throw new ApiError(502, "Couldn't reach UW Extension — try again in a moment");
}
if (!res.ok) throw new ApiError(502, "UW Extension lookup failed");
const json = (await res.json()) as UwApiItem[];
if (!Array.isArray(json)) throw new ApiError(502, "Unexpected reply from UW Extension");
return mapUwArticles(json);
}

View File

@@ -2,6 +2,7 @@
// and (later) the MCP server, so the logic lives once.
import { db } from "@/lib/db";
import { ApiError, type AuthContext } from "@/lib/api/authenticate";
import { createPantryItem } from "@/lib/services/pantry";
import type { PlantLogInput } from "@/lib/api/schemas";
export function listPlants(_ctx: AuthContext, opts?: { q?: string }) {
@@ -32,16 +33,24 @@ export async function getPlant(_ctx: AuthContext, id: string) {
}
export async function addPlantLog(ctx: AuthContext, plantId: string, input: PlantLogInput) {
const plant = await db.plant.findUnique({ where: { id: plantId }, select: { active: true } });
const plant = await db.plant.findUnique({
where: { id: plantId },
select: { active: true, commonName: true, variety: true },
});
if (!plant || !plant.active) throw new ApiError(404, "Plant not found");
if (input.pantry && input.type !== "HARVEST")
throw new ApiError(400, "Only harvests can go to the pantry");
const date = input.date ? new Date(input.date) : new Date();
const log = await db.plantLog.create({
data: {
plantId,
type: input.type,
date: input.date ? new Date(input.date) : new Date(),
date,
notes: input.notes?.trim() || null,
quantity: input.quantity?.trim() || null,
amount: input.amount ?? null,
unit: input.unit?.trim() || null,
actorId: ctx.userId,
},
});
@@ -53,5 +62,23 @@ export async function addPlantLog(ctx: AuthContext, plantId: string, input: Plan
payload: { type: input.type, via: ctx.via },
},
});
return log;
// Harvest → Pantry: land the produce where the cooking happens.
let pantryItem = null;
if (input.pantry) {
pantryItem = await createPantryItem(ctx, {
name: plant.variety ? `${plant.commonName} (${plant.variety})` : plant.commonName,
quantity: input.amount ?? 1,
unit: input.unit?.trim() || null,
locationId: input.pantry.locationId ?? null,
storedAt: date.toISOString(),
bestBefore: input.pantry.bestBefore ?? null,
source: "GARDEN",
plantId,
pantryCategory: "Garden produce",
notes: input.notes?.trim() || null,
});
}
return { ...log, pantryItem };
}

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.23.0";
export const APP_VERSION = "0.24.0";