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 && (
+
+ )}
{plant.plantedAt && (
@@ -99,7 +112,7 @@ export default async function PlantDetailPage({ params }: { params: { id: string
{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" && (
-
-
-
-
+ <>
+
+
+
+
+ {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 ? (
+
+ ) : (
+
+ )}
+ {uwMatches && (
+
+ {uwMatches.length === 0 && (
+
+ No UW Extension articles matched “{f.commonName.trim()}”.
+
+ )}
+ {uwMatches.map((m) => (
+
+ ))}
+
+ )}
+
+