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:
@@ -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`.
|
||||
|
||||
@@ -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;
|
||||
@@ -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())
|
||||
|
||||
|
||||
@@ -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 & 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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 } }) {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
15
src/app/api/v1/plant-types/uw-search/route.ts
Normal file
15
src/app/api/v1/plant-types/uw-search/route.ts
Normal 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 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);
|
||||
}
|
||||
}
|
||||
@@ -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")} />
|
||||
<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 & 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">
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 UW–Madison 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",
|
||||
|
||||
49
src/lib/garden/uw-extension.test.ts
Normal file
49
src/lib/garden/uw-extension.test.ts
Normal 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 balm — a gardener’s friend […]")).toBe(
|
||||
"Bee balm — a gardener’s 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 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([]);
|
||||
});
|
||||
});
|
||||
75
src/lib/garden/uw-extension.ts
Normal file
75
src/lib/garden/uw-extension.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
// UW–Madison 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(/&/g, "&")
|
||||
.replace(/’|’/g, "’")
|
||||
.replace(/‘|‘/g, "‘")
|
||||
.replace(/“|“/g, "“")
|
||||
.replace(/”|”/g, "”")
|
||||
.replace(/–|–/g, "–")
|
||||
.replace(/—|—/g, "—")
|
||||
.replace(/…|…/g, "…")
|
||||
.replace(/ /g, " ")
|
||||
.replace(/\[…\]|\[…\]/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);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user