From cc212c87c7a9145b6455a9b9d1dabe6ef651b9e2 Mon Sep 17 00:00:00 2001 From: Bonna Moon Date: Mon, 6 Jul 2026 15:26:31 -0500 Subject: [PATCH] =?UTF-8?q?v0.25.0=20=E2=80=94=20Garden=20makeover:=20card?= =?UTF-8?q?=20shelf,=20one-tap=20logging,=20weekly=20journal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The garden page is now a photo-forward shelf: every plant visible as a card with search and filter chips (groups + By place) replacing the old collapse trees. Each card carries quick-log buttons — droplet logs Watered (CARE) in one tap with an inline confirmation flash, eye takes a one-line observation, basket opens a compact harvest dialog (structured amount + send-to-pantry). New "This week in your garden" journal feed shows the last 14 days of activity across all plants. Co-Authored-By: Claude Fable 5 --- src/app/(dashboard)/garden/page.tsx | 26 +- src/components/garden/garden-grid.tsx | 399 ++++++++------------ src/components/garden/garden-journal.tsx | 68 ++++ src/components/garden/quick-log-actions.tsx | 253 +++++++++++++ src/lib/changelog.ts | 9 + src/lib/version.ts | 2 +- 6 files changed, 517 insertions(+), 240 deletions(-) create mode 100644 src/components/garden/garden-journal.tsx create mode 100644 src/components/garden/quick-log-actions.tsx diff --git a/src/app/(dashboard)/garden/page.tsx b/src/app/(dashboard)/garden/page.tsx index af2fe55..a969459 100644 --- a/src/app/(dashboard)/garden/page.tsx +++ b/src/app/(dashboard)/garden/page.tsx @@ -1,11 +1,14 @@ import { db } from "@/lib/db"; import { AddPlantButton } from "@/components/garden/add-plant-button"; import { GardenGrid } from "@/components/garden/garden-grid"; +import { GardenJournal } from "@/components/garden/garden-journal"; export const metadata = { title: "Garden" }; +export const dynamic = "force-dynamic"; export default async function GardenPage() { - const [plants, groups, locations] = await Promise.all([ + const twoWeeksAgo = new Date(Date.now() - 14 * 86_400_000); + const [plants, groups, locations, recentLogs] = await Promise.all([ db.plant.findMany({ where: { active: true }, orderBy: [{ commonName: "asc" }], @@ -26,10 +29,25 @@ export default async function GardenPage() { orderBy: { name: "asc" }, select: { id: true, name: true }, }), + db.plantLog.findMany({ + where: { date: { gte: twoWeeksAgo }, plant: { active: true } }, + orderBy: { date: "desc" }, + take: 10, + select: { + id: true, type: true, date: true, notes: true, quantity: true, + amount: true, unit: true, + plant: { select: { id: true, commonName: true, variety: true } }, + }, + }), ]); + const journal = recentLogs.map((l) => ({ + ...l, + amount: l.amount != null ? Number(l.amount) : null, + })); + return ( -
+

Food Forest

@@ -40,7 +58,9 @@ export default async function GardenPage() {
- + + +
); } diff --git a/src/components/garden/garden-grid.tsx b/src/components/garden/garden-grid.tsx index 2b304f9..f81639b 100644 --- a/src/components/garden/garden-grid.tsx +++ b/src/components/garden/garden-grid.tsx @@ -1,15 +1,16 @@ "use client"; -import { useState } from "react"; +// The garden "shelf": every plant visible as a photo card with one-tap +// logging. Search + chips replace the old collapse trees. +import { useMemo, useState } from "react"; import Link from "next/link"; -import { Card, CardContent } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; -import { Leaf, MapPin, CalendarDays, ChevronDown, ChevronRight, Users } from "lucide-react"; -import { formatDate } from "@/lib/utils"; -import { CATEGORY_LABELS } from "@/lib/garden/categories"; +import { Input } from "@/components/ui/input"; +import { Leaf, MapPin, Search, Users } from "lucide-react"; import { GroupLogButton } from "@/components/garden/group-log-button"; import { LocationLogButton } from "@/components/garden/location-log-button"; +import { QuickLogActions } from "@/components/garden/quick-log-actions"; +import { cn } from "@/lib/utils"; type Plant = { id: string; @@ -27,41 +28,55 @@ type Plant = { logs: { date: Date; type: string }[]; }; +type Group = { id: string; name: string; _count: { plants: number } }; +type LocationOption = { id: string; name: string }; + // Dual-read (Release A): prefer the Location name, fall back to the legacy zone. function placeName(p: Plant): string | null { return p.location?.name ?? p.zone ?? null; } -type Group = { - id: string; - name: string; - _count: { plants: number }; +const LAST_VERB: Record = { + OBSERVATION: "noted", + CARE: "tended", + HARVEST: "picked", + TREATMENT: "treated", + TRANSPLANT: "moved", + DIED: "died", }; -type ViewMode = "type" | "location"; - -// Cluster plants that share the same commonName + variety into one row. -function clusterByVariety(plants: Plant[]) { - const map = new Map(); - for (const p of plants) { - const key = `${p.commonName}||${p.variety ?? ""}`; - if (!map.has(key)) map.set(key, []); - map.get(key)!.push(p); - } - return Array.from(map.values()); +function lastActivity(p: Plant): string | null { + const log = p.logs[0]; + if (!log) return null; + const days = Math.floor((Date.now() - new Date(log.date).getTime()) / 86_400_000); + const when = days <= 0 ? "today" : days === 1 ? "yesterday" : `${days}d ago`; + return `${LAST_VERB[log.type] ?? "logged"} ${when}`; } -export function GardenGrid({ plants, groups }: { plants: Plant[]; groups: Group[] }) { - const [expanded, setExpanded] = useState>(new Set()); - const [view, setView] = useState("type"); +export function GardenGrid({ plants, groups, locations }: { + plants: Plant[]; + groups: Group[]; + locations: LocationOption[]; +}) { + const [search, setSearch] = useState(""); + const [chip, setChip] = useState("all"); // "all" | "place" | group id - function toggle(id: string) { - setExpanded((prev) => { - const next = new Set(prev); - next.has(id) ? next.delete(id) : next.add(id); - return next; - }); - } + const filtered = useMemo(() => { + const q = search.trim().toLowerCase(); + let list = plants; + if (chip !== "all" && chip !== "place") list = list.filter((p) => p.group?.id === chip); + if (q) { + list = list.filter((p) => + p.commonName.toLowerCase().includes(q) || + p.variety?.toLowerCase().includes(q) || + p.species?.toLowerCase().includes(q) || + placeName(p)?.toLowerCase().includes(q), + ); + } + return [...list].sort((a, b) => a.commonName.localeCompare(b.commonName)); + }, [plants, search, chip]); + + const activeGroup = groups.find((g) => g.id === chip); if (plants.length === 0) { return ( @@ -74,137 +89,78 @@ export function GardenGrid({ plants, groups }: { plants: Plant[]; groups: Group[ } return ( -
-
- - +
+
+ + setSearch(e.target.value)} + placeholder="Find a plant…" + className="pl-9" + />
- {view === "type" ? ( - - ) : ( - - )} -
- ); -} - -function TypeView({ plants, groups, expanded, toggle }: { - plants: Plant[]; - groups: Group[]; - expanded: Set; - toggle: (id: string) => void; -}) { - const ungrouped = plants.filter((p) => !p.group); - const grouped = groups.map((g) => ({ - ...g, - plants: plants.filter((p) => p.group?.id === g.id), - })).filter((g) => g.plants.length > 0); - - return ( -
- {grouped.map((g) => { - const isOpen = expanded.has(g.id); - const clusters = clusterByVariety(g.plants); - return ( -
-
- - -
- {isOpen ? ( -
- {clusters.map((cluster) => ( - - ))} -
- ) : ( - - )} -
- ); - })} - - {ungrouped.length > 0 && ( -
- {grouped.length > 0 &&

Other plants

} -
- {clusterByVariety(ungrouped).map((cluster) => ( - - ))} -
-
- )} -
- ); -} - -// A single variety that may span multiple locations. -function VarietyRow({ plants, expanded, toggle }: { - plants: Plant[]; - expanded: Set; - toggle: (id: string) => void; -}) { - const rep = plants[0]; - const key = `variety||${rep.commonName}||${rep.variety ?? ""}`; - const isOpen = expanded.has(key); - - // Collapsible row with location chips. - return ( -
- + )} +
- {isOpen && ( -
- {plants.map((p) => )} -
+ {chip === "place" ? ( + + ) : ( + + )} + + {filtered.length === 0 && ( +

+ No plants match{search ? ` “${search.trim()}”` : ""}. +

)}
); } -function LocationView({ plants, expanded, toggle }: { - plants: Plant[]; - expanded: Set; - toggle: (id: string) => void; +function Chip({ active, onClick, children }: { + active: boolean; onClick: () => void; children: React.ReactNode; }) { - // Group by the shared Location (id). Plants not yet on the spine but with a - // legacy zone string still group by that name so nothing disappears mid-migration. + return ( + + ); +} + +function CardGrid({ plants, locations }: { plants: Plant[]; locations: LocationOption[] }) { + return ( +
+ {plants.map((p) => )} +
+ ); +} + +function PlaceSections({ plants, locations }: { plants: Plant[]; locations: LocationOption[] }) { type Place = { key: string; locationId: string | null; name: string; plants: Plant[] }; const byKey = new Map(); const noPlace: Plant[] = []; @@ -216,110 +172,81 @@ function LocationView({ plants, expanded, toggle }: { if (!byKey.has(key)) byKey.set(key, { key, locationId: p.locationId, name, plants: [] }); byKey.get(key)!.plants.push(p); } - const places = Array.from(byKey.values()).sort((a, b) => a.name.localeCompare(b.name)); return (
- {places.map((place) => { - const isOpen = expanded.has(place.key); - return ( -
-
- - {place.locationId && } -
- {isOpen ? ( -
- {place.plants.map((p) => )} -
- ) : ( - - )} + {places.map((place) => ( +
+
+ + {place.name} + + · {place.plants.length} {place.plants.length === 1 ? "plant" : "plants"} + + {place.locationId && }
- ); - })} - + +
+ ))} {noPlace.length > 0 && (
- {places.length > 0 &&

No location set

} -
- {noPlace.map((p) => )} -
+

No location set

+
)}
); } -function PlantCard({ plant }: { plant: Plant }) { +function PlantCard({ plant, locations }: { plant: Plant; locations: LocationOption[] }) { + const place = placeName(plant); + const activity = lastActivity(plant); + return ( - - - {plant.imageUrl && ( -
+
+ + {plant.imageUrl ? ( +
{plant.commonName}
- )} - {!plant.imageUrl && ( -
- + ) : ( +
+
)} - -
-
-

- {plant.commonName} - {plant.variety && · {plant.variety}} -

- {plant.species &&

{plant.species}

} -
- - {CATEGORY_LABELS[plant.category as keyof typeof CATEGORY_LABELS]} - -
- -
- {placeName(plant) && ( -
- - {placeName(plant)} -
- )} - {plant.plantedAt && ( -
- - Planted {formatDate(plant.plantedAt)} -
- )} - {plant.logs[0] && ( -
- - Last {plant.logs[0].type.toLowerCase()} {formatDate(plant.logs[0].date)} -
- )} -
- -
- {plant._count.logs} log {plant._count.logs === 1 ? "entry" : "entries"} -
-
- - + +
+ +

+ {plant.commonName} + {plant.variety && · {plant.variety}} +

+ +

+ {place && ( + + + {place} + + )} + {place && activity && " · "} + {activity} +

+ {plant.group && ( + + + {plant.group.name} + + )} +
+ +
+
+
); } diff --git a/src/components/garden/garden-journal.tsx b/src/components/garden/garden-journal.tsx new file mode 100644 index 0000000..b432df0 --- /dev/null +++ b/src/components/garden/garden-journal.tsx @@ -0,0 +1,68 @@ +// "This week in your garden" — recent log entries across all plants, +// rendered like diary lines. Server component; data comes from the page. +import Link from "next/link"; +import { Droplets, Eye, ShoppingBasket, Sprout, Syringe, Shovel } from "lucide-react"; + +export type JournalEntry = { + id: string; + type: string; + date: Date; + notes: string | null; + quantity: string | null; + amount: number | null; + unit: string | null; + plant: { id: string; commonName: string; variety: string | null }; +}; + +const ICONS: Record = { + OBSERVATION: , + CARE: , + HARVEST: , + TREATMENT: , + TRANSPLANT: , +}; + +function dayLabel(date: Date): string { + const d = new Date(date); + const days = Math.floor((Date.now() - d.getTime()) / 86_400_000); + if (days <= 0) return "Today"; + if (days === 1) return "Yesterday"; + if (days < 7) return d.toLocaleDateString(undefined, { weekday: "short" }); + return d.toLocaleDateString(undefined, { month: "short", day: "numeric" }); +} + +function entryText(e: JournalEntry): string { + const bits: string[] = []; + if (e.type === "HARVEST") { + const qty = e.amount != null ? `${e.amount} ${e.unit ?? ""}`.trim() : e.quantity; + bits.push(qty ? `picked ${qty}` : "harvested"); + } + if (e.notes) bits.push(e.notes); + if (bits.length === 0) bits.push(e.type.toLowerCase()); + return bits.join(" — "); +} + +export function GardenJournal({ entries }: { entries: JournalEntry[] }) { + if (entries.length === 0) return null; + + return ( +
+

This week in your garden

+
+ {entries.map((e) => ( +
+ {ICONS[e.type] ?? } +

+ + {e.plant.commonName} + {e.plant.variety && ({e.plant.variety})} + + — {entryText(e)} +

+ {dayLabel(e.date)} +
+ ))} +
+
+ ); +} diff --git a/src/components/garden/quick-log-actions.tsx b/src/components/garden/quick-log-actions.tsx new file mode 100644 index 0000000..c1cf81d --- /dev/null +++ b/src/components/garden/quick-log-actions.tsx @@ -0,0 +1,253 @@ +"use client"; + +// One-tap logging from a plant card: droplet = watered (CARE), eye = quick +// observation note, basket = harvest (amount + optional pantry hand-off). +import { useEffect, useRef, useState } from "react"; +import { useRouter } from "next/navigation"; +import { Droplets, Eye, ShoppingBasket, Check } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, +} from "@/components/ui/dialog"; +import { + Select, SelectContent, SelectItem, SelectTrigger, SelectValue, +} from "@/components/ui/select"; +import { useToast } from "@/hooks/use-toast"; + +const UNIT_SUGGESTIONS = ["lbs", "oz", "each", "pints", "quarts", "gallons", "bunches", "heads", "cups"]; +const NO_LOCATION = "__none__"; + +async function postLog(plantId: string, body: Record) { + const res = await fetch(`/api/v1/plants/${plantId}/logs`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + throw new Error(err.error ?? "Could not save"); + } + return res.json(); +} + +export function QuickLogActions({ plantId, plantName, locations }: { + plantId: string; + plantName: string; + locations: { id: string; name: string }[]; +}) { + const router = useRouter(); + const { toast } = useToast(); + const [mode, setMode] = useState<"idle" | "noting" | "done">("idle"); + const [doneLabel, setDoneLabel] = useState(""); + const [note, setNote] = useState(""); + const [busy, setBusy] = useState(false); + const noteRef = useRef(null); + const doneTimer = useRef>(); + + useEffect(() => { + if (mode === "noting") noteRef.current?.focus(); + return () => clearTimeout(doneTimer.current); + }, [mode]); + + function flashDone(label: string) { + setDoneLabel(label); + setMode("done"); + doneTimer.current = setTimeout(() => setMode("idle"), 2500); + router.refresh(); + } + + async function logWatered() { + setBusy(true); + try { + await postLog(plantId, { type: "CARE", notes: "Watered" }); + flashDone("Watered — logged"); + } catch (err) { + toast({ title: "Error", description: String(err).replace("Error: ", ""), variant: "destructive" }); + } finally { + setBusy(false); + } + } + + async function logNote() { + const text = note.trim(); + if (!text) { setMode("idle"); return; } + setBusy(true); + try { + await postLog(plantId, { type: "OBSERVATION", notes: text }); + setNote(""); + flashDone("Noted"); + } catch (err) { + toast({ title: "Error", description: String(err).replace("Error: ", ""), variant: "destructive" }); + } finally { + setBusy(false); + } + } + + if (mode === "done") { + return ( +
+ + {doneLabel} +
+ ); + } + + if (mode === "noting") { + return ( +
{ e.preventDefault(); logNote(); }} + > + setNote(e.target.value)} + onKeyDown={(e) => { if (e.key === "Escape") { setNote(""); setMode("idle"); } }} + placeholder="What did you notice?" + className="h-8 text-xs" + disabled={busy} + /> + +
+ ); + } + + return ( +
+ + + flashDone("Harvest logged")} + /> +
+ ); +} + +function QuickHarvestDialog({ plantId, plantName, locations, onLogged }: { + plantId: string; + plantName: string; + locations: { id: string; name: string }[]; + onLogged: () => void; +}) { + const { toast } = useToast(); + const [open, setOpen] = useState(false); + const [amount, setAmount] = useState(""); + const [unit, setUnit] = useState(""); + const [toPantry, setToPantry] = useState(true); + const [locationId, setLocationId] = useState(NO_LOCATION); + const [busy, setBusy] = useState(false); + + async function submit(e: React.FormEvent) { + e.preventDefault(); + const amt = amount ? parseFloat(amount) : null; + if (amount && (amt == null || isNaN(amt) || amt < 0)) { + toast({ title: "Enter the amount as a number", variant: "destructive" }); + return; + } + setBusy(true); + try { + await postLog(plantId, { + type: "HARVEST", + amount: amt ?? undefined, + unit: unit.trim() || undefined, + pantry: toPantry + ? { locationId: locationId === NO_LOCATION ? null : locationId } + : undefined, + }); + setOpen(false); + setAmount(""); + setUnit(""); + onLogged(); + } catch (err) { + toast({ title: "Error", description: String(err).replace("Error: ", ""), variant: "destructive" }); + } finally { + setBusy(false); + } + } + + return ( + <> + + + + + + Harvest — {plantName} + +
+
+ setAmount(e.target.value)} + /> + setUnit(e.target.value)} + /> + + {UNIT_SUGGESTIONS.map((u) => +
+
+ + {toPantry && ( +
+ + +
+ )} +
+ + + + +
+
+
+ + ); +} diff --git a/src/lib/changelog.ts b/src/lib/changelog.ts index 510bfa9..b109ce1 100644 --- a/src/lib/changelog.ts +++ b/src/lib/changelog.ts @@ -8,6 +8,15 @@ export type ChangelogEntry = { }; export const CHANGELOG: ChangelogEntry[] = [ + { + version: "0.25.0", + date: "2026-07-06", + changes: [ + "The Garden got a makeover! Every plant now shows as a photo card, always visible — no more clicking through folded-up lists. Find things with the new search box or the filter chips (your groups, or \"By place\").", + "Log without leaving the page: every card has three little buttons — the water drop logs \"Watered\" in one tap, the eye lets you jot a quick note, and the basket logs a harvest (with the amount and the send-to-pantry option). A small green \"logged\" flash confirms it saved.", + "New \"This week in your garden\" journal at the bottom of the Garden page — your recent activity across all plants, readable like a diary.", + ], + }, { version: "0.24.0", date: "2026-07-06", diff --git a/src/lib/version.ts b/src/lib/version.ts index eddd993..bd3409a 100644 --- a/src/lib/version.ts +++ b/src/lib/version.ts @@ -1,2 +1,2 @@ // Bump on every user-visible release. Changelog entries live in `src/lib/changelog.ts`. -export const APP_VERSION = "0.24.0"; +export const APP_VERSION = "0.25.0";