diff --git a/CLAUDE.md b/CLAUDE.md index a912d1c..20ea39b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 UW–Madison 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`. diff --git a/prisma/migrations/20260706152846_v0_24_harvest_amounts_uw_link/migration.sql b/prisma/migrations/20260706152846_v0_24_harvest_amounts_uw_link/migration.sql new file mode 100644 index 0000000..d140f7d --- /dev/null +++ b/prisma/migrations/20260706152846_v0_24_harvest_amounts_uw_link/migration.sql @@ -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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index fc79d6c..4a992b0 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -195,6 +195,8 @@ model PlantType { edibleParts String? // "Fruit, young leaves" spacing String? // "10–15 ft", "18 in" imageUrl String? + uwExtensionUrl String? // linked UW–Madison 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()) diff --git a/src/app/(dashboard)/garden/[id]/page.tsx b/src/app/(dashboard)/garden/[id]/page.tsx index cf7cec1..1df74d5 100644 --- a/src/app/(dashboard)/garden/[id]/page.tsx +++ b/src/app/(dashboard)/garden/[id]/page.tsx @@ -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 )} + {plant.plantType?.uwExtensionUrl && ( +
+ + + UW Extension: {plant.plantType.uwExtensionTitle || "article"} + +
+ )} {plant.plantedAt && (
@@ -99,7 +112,7 @@ export default async function PlantDetailPage({ params }: { params: { id: string

Care & harvest log

- +
{plant.logs.length === 0 ? ( @@ -116,8 +129,10 @@ export default async function PlantDetailPage({ params }: { params: { id: string {LOG_TYPE_LABELS[log.type]} - {log.quantity && ( - · {log.quantity} + {(log.amount != null || log.quantity) && ( + + · {log.amount != null ? `${Number(log.amount)} ${log.unit ?? ""}`.trim() : log.quantity} + )} {formatDate(log.date)}
diff --git a/src/app/(dashboard)/garden/types/[id]/page.tsx b/src/app/(dashboard)/garden/types/[id]/page.tsx index a538e57..76d9968 100644 --- a/src/app/(dashboard)/garden/types/[id]/page.tsx +++ b/src/app/(dashboard)/garden/types/[id]/page.tsx @@ -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 )} + {type.uwExtensionUrl && ( + + + + UW Extension:{" "} + {type.uwExtensionTitle || "article"} + + + + )} + {(type.careNotes || type.notes) && ( diff --git a/src/app/api/plant-types/[id]/route.ts b/src/app/api/plant-types/[id]/route.ts index 78a6eb2..8630db7 100644 --- a/src/app/api/plant-types/[id]/route.ts +++ b/src/app/api/plant-types/[id]/route.ts @@ -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 } }) { diff --git a/src/app/api/plant-types/route.ts b/src/app/api/plant-types/route.ts index 00caad2..5ce03a2 100644 --- a/src/app/api/plant-types/route.ts +++ b/src/app/api/plant-types/route.ts @@ -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, }, }); diff --git a/src/app/api/v1/plant-types/uw-search/route.ts b/src/app/api/v1/plant-types/uw-search/route.ts new file mode 100644 index 0000000..a8a27c0 --- /dev/null +++ b/src/app/api/v1/plant-types/uw-search/route.ts @@ -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 UW–Madison +// 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); + } +} diff --git a/src/components/garden/add-log-button.tsx b/src/components/garden/add-log-button.tsx index 02ea4aa..3efdb49 100644 --- a/src/components/garden/add-log-button.tsx +++ b/src/components/garden/add-log-button.tsx @@ -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; 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("OBSERVATION"); + const [toPantry, setToPantry] = useState(false); + const [pantryLocationId, setPantryLocationId] = useState(NO_LOCATION); + const [bestBefore, setBestBefore] = useState(""); const form = useForm({ 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) {
{logType === "HARVEST" && ( -
- - -
+ <> +
+ +
+ + + + {UNIT_SUGGESTIONS.map((u) => +
+
+ +
+ + {toPantry && ( +
+
+ + +
+
+ + setBestBefore(e.target.value)} + /> +
+
+ )} +
+ )}
diff --git a/src/components/garden/plant-type-dialog.tsx b/src/components/garden/plant-type-dialog.tsx index f60dfe8..84ecce1 100644 --- a/src/components/garden/plant-type-dialog.tsx +++ b/src/components/garden/plant-type-dialog.tsx @@ -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(initial); const [busy, setBusy] = useState(false); const [error, setError] = useState(""); + const [uwMatches, setUwMatches] = useState(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" />
+
+ + {f.uwExtensionUrl ? ( +
+ + {f.uwExtensionTitle || f.uwExtensionUrl} + + + +
+ ) : ( + + )} + {uwMatches && ( +
+ {uwMatches.length === 0 && ( +

+ No UW Extension articles matched “{f.commonName.trim()}”. +

+ )} + {uwMatches.map((m) => ( + + ))} +
+ )} +
+