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
|
- **Garden** — Plant registry + care/harvest logs, plant **groups**, reusable
|
||||||
**PlantType** records, Wikipedia photos. Plants sit on the shared location tree.
|
**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
|
- **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
|
Item that holds Items, with a cycle guard), with **Label**s (M:N via
|
||||||
`LabelOnItem`), `ItemAttachment` (PHOTO/MANUAL/RECEIPT/WARRANTY), and `ItemLog`.
|
`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"
|
edibleParts String? // "Fruit, young leaves"
|
||||||
spacing String? // "10–15 ft", "18 in"
|
spacing String? // "10–15 ft", "18 in"
|
||||||
imageUrl String?
|
imageUrl String?
|
||||||
|
uwExtensionUrl String? // linked UW–Madison Extension article (hort.extension.wisc.edu)
|
||||||
|
uwExtensionTitle String? // its title, shown as the link text
|
||||||
notes String?
|
notes String?
|
||||||
active Boolean @default(true)
|
active Boolean @default(true)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
@@ -282,7 +284,9 @@ model PlantLog {
|
|||||||
type PlantLogType
|
type PlantLogType
|
||||||
date DateTime @default(now())
|
date DateTime @default(now())
|
||||||
notes String?
|
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?
|
actorId String?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { db } from "@/lib/db";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
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 { formatDate } from "@/lib/utils";
|
||||||
import { CATEGORY_LABELS, LOG_TYPE_LABELS, LOG_TYPE_COLORS } from "@/lib/garden/categories";
|
import { CATEGORY_LABELS, LOG_TYPE_LABELS, LOG_TYPE_COLORS } from "@/lib/garden/categories";
|
||||||
import { AddLogButton } from "@/components/garden/add-log-button";
|
import { AddLogButton } from "@/components/garden/add-log-button";
|
||||||
@@ -22,7 +22,7 @@ export default async function PlantDetailPage({ params }: { params: { id: string
|
|||||||
include: {
|
include: {
|
||||||
logs: { orderBy: { date: "desc" } },
|
logs: { orderBy: { date: "desc" } },
|
||||||
location: { select: { id: true, name: true } },
|
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" } }),
|
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>
|
</Link>
|
||||||
</div>
|
</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 && (
|
{plant.plantedAt && (
|
||||||
<div className="flex items-center gap-2 text-muted-foreground">
|
<div className="flex items-center gap-2 text-muted-foreground">
|
||||||
<CalendarDays className="h-4 w-4 shrink-0" />
|
<CalendarDays className="h-4 w-4 shrink-0" />
|
||||||
@@ -99,7 +112,7 @@ export default async function PlantDetailPage({ params }: { params: { id: string
|
|||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<h2 className="font-display text-lg font-semibold">Care & harvest log</h2>
|
<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>
|
</div>
|
||||||
|
|
||||||
{plant.logs.length === 0 ? (
|
{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]}`}>
|
<span className={`text-xs font-semibold uppercase tracking-wide ${LOG_TYPE_COLORS[log.type]}`}>
|
||||||
{LOG_TYPE_LABELS[log.type]}
|
{LOG_TYPE_LABELS[log.type]}
|
||||||
</span>
|
</span>
|
||||||
{log.quantity && (
|
{(log.amount != null || log.quantity) && (
|
||||||
<span className="text-xs text-muted-foreground">· {log.quantity}</span>
|
<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>
|
<span className="text-xs text-muted-foreground ml-auto">{formatDate(log.date)}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { Badge } from "@/components/ui/badge";
|
|||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import {
|
import {
|
||||||
ChevronLeft, Sun, Droplets, Flower2, Apple, AlertTriangle, Ruler, MapPin, Leaf,
|
ChevronLeft, Sun, Droplets, Flower2, Apple, AlertTriangle, Ruler, MapPin, Leaf,
|
||||||
|
GraduationCap, ExternalLink,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { CATEGORY_LABELS } from "@/lib/garden/categories";
|
import { CATEGORY_LABELS } from "@/lib/garden/categories";
|
||||||
import { EditPlantTypeButton, type PlantTypeFields } from "@/components/garden/plant-type-dialog";
|
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,
|
toxicToPets: type.toxicToPets,
|
||||||
edibleParts: type.edibleParts,
|
edibleParts: type.edibleParts,
|
||||||
spacing: type.spacing,
|
spacing: type.spacing,
|
||||||
|
uwExtensionUrl: type.uwExtensionUrl,
|
||||||
|
uwExtensionTitle: type.uwExtensionTitle,
|
||||||
notes: type.notes,
|
notes: type.notes,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -113,6 +116,22 @@ export default async function PlantTypeDetailPage({ params }: { params: { id: st
|
|||||||
</Card>
|
</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) && (
|
{(type.careNotes || type.notes) && (
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="pt-4 space-y-3 text-sm">
|
<CardContent className="pt-4 space-y-3 text-sm">
|
||||||
|
|||||||
@@ -23,11 +23,14 @@ const updateSchema = z.object({
|
|||||||
edibleParts: z.string().optional(),
|
edibleParts: z.string().optional(),
|
||||||
spacing: z.string().optional(),
|
spacing: z.string().optional(),
|
||||||
imageUrl: z.string().optional(),
|
imageUrl: z.string().optional(),
|
||||||
|
uwExtensionUrl: z.string().optional(),
|
||||||
|
uwExtensionTitle: z.string().optional(),
|
||||||
notes: z.string().optional(),
|
notes: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const TEXT_FIELDS = [
|
const TEXT_FIELDS = [
|
||||||
"species", "careNotes", "sun", "water", "edibleParts", "spacing", "imageUrl", "notes",
|
"species", "careNotes", "sun", "water", "edibleParts", "spacing", "imageUrl",
|
||||||
|
"uwExtensionUrl", "uwExtensionTitle", "notes",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export async function PATCH(req: Request, { params }: { params: { id: string } }) {
|
export async function PATCH(req: Request, { params }: { params: { id: string } }) {
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ const createSchema = z.object({
|
|||||||
edibleParts: z.string().optional(),
|
edibleParts: z.string().optional(),
|
||||||
spacing: z.string().optional(),
|
spacing: z.string().optional(),
|
||||||
imageUrl: z.string().optional(),
|
imageUrl: z.string().optional(),
|
||||||
|
uwExtensionUrl: z.string().optional(),
|
||||||
|
uwExtensionTitle: z.string().optional(),
|
||||||
notes: z.string().optional(),
|
notes: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -66,6 +68,8 @@ export async function POST(req: Request) {
|
|||||||
edibleParts: data.edibleParts?.trim() || null,
|
edibleParts: data.edibleParts?.trim() || null,
|
||||||
spacing: data.spacing?.trim() || null,
|
spacing: data.spacing?.trim() || null,
|
||||||
imageUrl: data.imageUrl?.trim() || null,
|
imageUrl: data.imageUrl?.trim() || null,
|
||||||
|
uwExtensionUrl: data.uwExtensionUrl?.trim() || null,
|
||||||
|
uwExtensionTitle: data.uwExtensionTitle?.trim() || null,
|
||||||
notes: data.notes?.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",
|
"OBSERVATION", "CARE", "HARVEST", "TREATMENT", "TRANSPLANT", "DIED",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const UNIT_SUGGESTIONS = ["lbs", "oz", "each", "pints", "quarts", "gallons", "bunches", "heads", "cups"];
|
||||||
|
|
||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
type: z.nativeEnum(PlantLogType),
|
type: z.nativeEnum(PlantLogType),
|
||||||
date: z.string().optional(),
|
date: z.string().optional(),
|
||||||
notes: 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>;
|
type FormValues = z.infer<typeof schema>;
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
plantId: string;
|
plantId: string;
|
||||||
plantName: 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 [open, setOpen] = useState(false);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const [logType, setLogType] = useState<PlantLogType>("OBSERVATION");
|
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>({
|
const form = useForm<FormValues>({
|
||||||
resolver: zodResolver(schema),
|
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) {
|
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",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
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) {
|
if (!res.ok) {
|
||||||
const err = await res.json().catch(() => ({}));
|
const err = await res.json().catch(() => ({}));
|
||||||
toast({ title: "Error", description: err.error ?? "Could not save log entry.", variant: "destructive" });
|
toast({ title: "Error", description: err.error ?? "Could not save log entry.", variant: "destructive" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
toast({ title: "Log entry saved!" });
|
toast({
|
||||||
|
title: "Log entry saved!",
|
||||||
|
description: isHarvest && toPantry ? "Also added to Pantry & Freezer." : undefined,
|
||||||
|
});
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
form.reset({ type: "OBSERVATION", date: new Date().toISOString().split("T")[0] });
|
resetAll();
|
||||||
setLogType("OBSERVATION");
|
|
||||||
router.refresh();
|
router.refresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,10 +148,67 @@ export function AddLogButton({ plantId, plantName }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{logType === "HARVEST" && (
|
{logType === "HARVEST" && (
|
||||||
<div className="space-y-1.5">
|
<>
|
||||||
<Label htmlFor="quantity">How much did you harvest?</Label>
|
<div className="space-y-1.5">
|
||||||
<Input id="quantity" placeholder="2 lbs, 1 gallon, a big handful…" {...form.register("quantity")} />
|
<Label>How much did you harvest?</Label>
|
||||||
</div>
|
<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">
|
<div className="space-y-1.5">
|
||||||
|
|||||||
@@ -14,9 +14,11 @@ import {
|
|||||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
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";
|
import { CATEGORY_LABELS, CATEGORY_ORDER } from "@/lib/garden/categories";
|
||||||
|
|
||||||
|
type UwMatch = { id: number; title: string; url: string; excerpt: string };
|
||||||
|
|
||||||
export type PlantTypeFields = {
|
export type PlantTypeFields = {
|
||||||
id?: string;
|
id?: string;
|
||||||
commonName: string;
|
commonName: string;
|
||||||
@@ -32,6 +34,8 @@ export type PlantTypeFields = {
|
|||||||
toxicToPets: boolean | null;
|
toxicToPets: boolean | null;
|
||||||
edibleParts: string | null;
|
edibleParts: string | null;
|
||||||
spacing: string | null;
|
spacing: string | null;
|
||||||
|
uwExtensionUrl: string | null;
|
||||||
|
uwExtensionTitle: string | null;
|
||||||
notes: string | null;
|
notes: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -43,7 +47,8 @@ function blank(): PlantTypeFields {
|
|||||||
commonName: "", species: null, category: "OTHER", careNotes: null,
|
commonName: "", species: null, category: "OTHER", careNotes: null,
|
||||||
sun: null, water: null, bloomStart: null, bloomEnd: null,
|
sun: null, water: null, bloomStart: null, bloomEnd: null,
|
||||||
harvestStart: null, harvestEnd: null, toxicToPets: 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 [f, setF] = useState<PlantTypeFields>(initial);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
|
const [uwMatches, setUwMatches] = useState<UwMatch[] | null>(null);
|
||||||
|
const [uwBusy, setUwBusy] = useState(false);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
@@ -80,6 +87,28 @@ function PlantTypeDialog({
|
|||||||
setF((p) => ({ ...p, [key]: value }));
|
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) {
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!f.commonName.trim()) { setError("Please enter the plant name."); return; }
|
if (!f.commonName.trim()) { setError("Please enter the plant name."); return; }
|
||||||
@@ -95,6 +124,8 @@ function PlantTypeDialog({
|
|||||||
water: f.water ?? "",
|
water: f.water ?? "",
|
||||||
edibleParts: f.edibleParts ?? "",
|
edibleParts: f.edibleParts ?? "",
|
||||||
spacing: f.spacing ?? "",
|
spacing: f.spacing ?? "",
|
||||||
|
uwExtensionUrl: f.uwExtensionUrl ?? "",
|
||||||
|
uwExtensionTitle: f.uwExtensionTitle ?? "",
|
||||||
notes: f.notes ?? "",
|
notes: f.notes ?? "",
|
||||||
toxicToPets: f.toxicToPets,
|
toxicToPets: f.toxicToPets,
|
||||||
};
|
};
|
||||||
@@ -217,6 +248,56 @@ function PlantTypeDialog({
|
|||||||
placeholder="Fruit, young leaves" />
|
placeholder="Fruit, young leaves" />
|
||||||
</div>
|
</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">
|
<div className="col-span-2 space-y-1.5">
|
||||||
<Label>Care notes</Label>
|
<Label>Care notes</Label>
|
||||||
<Textarea rows={3} value={f.careNotes ?? ""} onChange={(e) => set("careNotes", e.target.value)}
|
<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({
|
export const plantLogInput = z.object({
|
||||||
type: z.enum(["OBSERVATION", "CARE", "HARVEST", "TREATMENT", "TRANSPLANT", "DIED"]),
|
type: z.enum(["OBSERVATION", "CARE", "HARVEST", "TREATMENT", "TRANSPLANT", "DIED"]),
|
||||||
notes: z.string().optional(),
|
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
|
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({
|
export const taskCreateInput = z.object({
|
||||||
|
|||||||
@@ -8,6 +8,15 @@ export type ChangelogEntry = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const CHANGELOG: 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",
|
version: "0.23.0",
|
||||||
date: "2026-07-06",
|
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.
|
// and (later) the MCP server, so the logic lives once.
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import { ApiError, type AuthContext } from "@/lib/api/authenticate";
|
import { ApiError, type AuthContext } from "@/lib/api/authenticate";
|
||||||
|
import { createPantryItem } from "@/lib/services/pantry";
|
||||||
import type { PlantLogInput } from "@/lib/api/schemas";
|
import type { PlantLogInput } from "@/lib/api/schemas";
|
||||||
|
|
||||||
export function listPlants(_ctx: AuthContext, opts?: { q?: string }) {
|
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) {
|
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 (!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({
|
const log = await db.plantLog.create({
|
||||||
data: {
|
data: {
|
||||||
plantId,
|
plantId,
|
||||||
type: input.type,
|
type: input.type,
|
||||||
date: input.date ? new Date(input.date) : new Date(),
|
date,
|
||||||
notes: input.notes?.trim() || null,
|
notes: input.notes?.trim() || null,
|
||||||
quantity: input.quantity?.trim() || null,
|
quantity: input.quantity?.trim() || null,
|
||||||
|
amount: input.amount ?? null,
|
||||||
|
unit: input.unit?.trim() || null,
|
||||||
actorId: ctx.userId,
|
actorId: ctx.userId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -53,5 +62,23 @@ export async function addPlantLog(ctx: AuthContext, plantId: string, input: Plan
|
|||||||
payload: { type: input.type, via: ctx.via },
|
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`.
|
// 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