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 (
-
-
);
}
-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.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 (
+
+ );
+ }
+
+ 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 (
+ <>
+
+
+
+ >
+ );
+}
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";