v0.25.0 — Garden makeover: card shelf, one-tap logging, weekly journal

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 <noreply@anthropic.com>
This commit is contained in:
Bonna Moon
2026-07-06 15:26:31 -05:00
parent e65d28524b
commit cc212c87c7
6 changed files with 517 additions and 240 deletions

View File

@@ -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 (
<div className="space-y-6">
<div className="space-y-8">
<div className="flex items-center justify-between">
<div>
<h1 className="font-display text-2xl font-semibold">Food Forest</h1>
@@ -40,7 +58,9 @@ export default async function GardenPage() {
<AddPlantButton groups={groups} locations={locations} />
</div>
<GardenGrid plants={plants} groups={groups} />
<GardenGrid plants={plants} groups={groups} locations={locations} />
<GardenJournal entries={journal} />
</div>
);
}

View File

@@ -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<string, string> = {
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<string, Plant[]>();
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<Set<string>>(new Set());
const [view, setView] = useState<ViewMode>("type");
export function GardenGrid({ plants, groups, locations }: {
plants: Plant[];
groups: Group[];
locations: LocationOption[];
}) {
const [search, setSearch] = useState("");
const [chip, setChip] = useState<string>("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 (
<div className="space-y-6">
<div className="flex items-center gap-1 p-1 bg-muted rounded-lg w-fit">
<Button size="sm" variant={view === "type" ? "secondary" : "ghost"} className="h-7 text-xs" onClick={() => setView("type")}>
By type
</Button>
<Button size="sm" variant={view === "location" ? "secondary" : "ghost"} className="h-7 text-xs" onClick={() => setView("location")}>
By location
</Button>
<div className="space-y-4">
<div className="relative">
<Search className="h-4 w-4 absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" />
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Find a plant…"
className="pl-9"
/>
</div>
{view === "type" ? (
<TypeView plants={plants} groups={groups} expanded={expanded} toggle={toggle} />
) : (
<LocationView plants={plants} expanded={expanded} toggle={toggle} />
)}
</div>
);
}
function TypeView({ plants, groups, expanded, toggle }: {
plants: Plant[];
groups: Group[];
expanded: Set<string>;
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 (
<div className="space-y-6">
{grouped.map((g) => {
const isOpen = expanded.has(g.id);
const clusters = clusterByVariety(g.plants);
return (
<div key={g.id} className="space-y-2">
<div className="flex items-center gap-2">
<button
onClick={() => toggle(g.id)}
className="flex items-center gap-2 text-left hover:text-foreground transition-colors"
>
{isOpen ? <ChevronDown className="h-4 w-4 text-muted-foreground" /> : <ChevronRight className="h-4 w-4 text-muted-foreground" />}
<Users className="h-4 w-4 text-[hsl(var(--leaf))]" />
<span className="font-display font-semibold">{g.name}</span>
<span className="text-sm text-muted-foreground">· {g.plants.length} {g.plants.length === 1 ? "plant" : "plants"}</span>
</button>
<GroupLogButton groupId={g.id} groupName={g.name} />
</div>
{isOpen ? (
<div className="pl-6 space-y-2">
{clusters.map((cluster) => (
<VarietyRow key={`${cluster[0].commonName}||${cluster[0].variety}`} plants={cluster} expanded={expanded} toggle={toggle} />
))}
</div>
) : (
<button onClick={() => toggle(g.id)} className="pl-6 text-sm text-muted-foreground hover:text-foreground transition-colors">
{clusters.map((c) => c[0].variety ? `${c[0].commonName} (${c[0].variety})` : c[0].commonName).join(", ")}
</button>
)}
</div>
);
})}
{ungrouped.length > 0 && (
<div className="space-y-2">
{grouped.length > 0 && <p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Other plants</p>}
<div className="space-y-2">
{clusterByVariety(ungrouped).map((cluster) => (
<VarietyRow key={`${cluster[0].commonName}||${cluster[0].variety}`} plants={cluster} expanded={expanded} toggle={toggle} />
))}
</div>
</div>
)}
</div>
);
}
// A single variety that may span multiple locations.
function VarietyRow({ plants, expanded, toggle }: {
plants: Plant[];
expanded: Set<string>;
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 (
<div className="space-y-2">
<button
onClick={() => toggle(key)}
className="flex items-start gap-2 text-left hover:text-foreground transition-colors w-full"
>
{isOpen ? <ChevronDown className="h-4 w-4 text-muted-foreground mt-0.5 shrink-0" /> : <ChevronRight className="h-4 w-4 text-muted-foreground mt-0.5 shrink-0" />}
<div className="flex-1 min-w-0">
<span className="font-medium text-sm">
{rep.commonName}
{rep.variety && <span className="text-muted-foreground font-normal"> · {rep.variety}</span>}
<div className="flex items-center gap-1.5 flex-wrap">
<Chip active={chip === "all"} onClick={() => setChip("all")}>
All · {plants.length}
</Chip>
{groups.filter((g) => g._count.plants > 0).map((g) => (
<Chip key={g.id} active={chip === g.id} onClick={() => setChip(g.id)}>
{g.name}
</Chip>
))}
<Chip active={chip === "place"} onClick={() => setChip("place")}>
<MapPin className="h-3 w-3" /> By place
</Chip>
{activeGroup && (
<span className="ml-auto">
<GroupLogButton groupId={activeGroup.id} groupName={activeGroup.name} />
</span>
{!isOpen && (
<div className="flex flex-wrap gap-1 mt-1">
{plants.map((p) => (
<Badge key={p.id} variant="outline" className="text-xs gap-1">
<MapPin className="h-2.5 w-2.5" />
{placeName(p) ?? "No location"}
</Badge>
))}
</div>
)}
</div>
</button>
)}
</div>
{isOpen && (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 pl-6">
{plants.map((p) => <PlantCard key={p.id} plant={p} />)}
</div>
{chip === "place" ? (
<PlaceSections plants={filtered} locations={locations} />
) : (
<CardGrid plants={filtered} locations={locations} />
)}
{filtered.length === 0 && (
<p className="text-sm text-muted-foreground text-center py-8">
No plants match{search ? `${search.trim()}` : ""}.
</p>
)}
</div>
);
}
function LocationView({ plants, expanded, toggle }: {
plants: Plant[];
expanded: Set<string>;
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 (
<button
onClick={onClick}
className={cn(
"inline-flex items-center gap-1 rounded-full border px-3 py-1 text-xs transition-colors",
active
? "bg-[hsl(var(--leaf))] text-white border-[hsl(var(--leaf))]"
: "bg-background hover:bg-accent text-muted-foreground",
)}
>
{children}
</button>
);
}
function CardGrid({ plants, locations }: { plants: Plant[]; locations: LocationOption[] }) {
return (
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3">
{plants.map((p) => <PlantCard key={p.id} plant={p} locations={locations} />)}
</div>
);
}
function PlaceSections({ plants, locations }: { plants: Plant[]; locations: LocationOption[] }) {
type Place = { key: string; locationId: string | null; name: string; plants: Plant[] };
const byKey = new Map<string, Place>();
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 (
<div className="space-y-6">
{places.map((place) => {
const isOpen = expanded.has(place.key);
return (
<div key={place.key} className="space-y-2">
<div className="flex items-center gap-2">
<button
onClick={() => toggle(place.key)}
className="flex items-center gap-2 text-left hover:text-foreground transition-colors"
>
{isOpen ? <ChevronDown className="h-4 w-4 text-muted-foreground" /> : <ChevronRight className="h-4 w-4 text-muted-foreground" />}
<MapPin className="h-4 w-4 text-[hsl(var(--leaf))]" />
<span className="font-display font-semibold">{place.name}</span>
<span className="text-sm text-muted-foreground">· {place.plants.length} {place.plants.length === 1 ? "plant" : "plants"}</span>
</button>
{place.locationId && <LocationLogButton locationId={place.locationId} locationName={place.name} />}
</div>
{isOpen ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 pl-6">
{place.plants.map((p) => <PlantCard key={p.id} plant={p} />)}
</div>
) : (
<button onClick={() => toggle(place.key)} className="pl-6 text-sm text-muted-foreground hover:text-foreground transition-colors">
{place.plants.map((p) => p.variety ? `${p.commonName} (${p.variety})` : p.commonName).join(", ")}
</button>
)}
{places.map((place) => (
<div key={place.key} className="space-y-2">
<div className="flex items-center gap-2">
<MapPin className="h-4 w-4 text-[hsl(var(--leaf))]" />
<span className="font-display font-semibold">{place.name}</span>
<span className="text-sm text-muted-foreground">
· {place.plants.length} {place.plants.length === 1 ? "plant" : "plants"}
</span>
{place.locationId && <LocationLogButton locationId={place.locationId} locationName={place.name} />}
</div>
);
})}
<CardGrid plants={place.plants} locations={locations} />
</div>
))}
{noPlace.length > 0 && (
<div className="space-y-2">
{places.length > 0 && <p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">No location set</p>}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{noPlace.map((p) => <PlantCard key={p.id} plant={p} />)}
</div>
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">No location set</p>
<CardGrid plants={noPlace} locations={locations} />
</div>
)}
</div>
);
}
function PlantCard({ plant }: { plant: Plant }) {
function PlantCard({ plant, locations }: { plant: Plant; locations: LocationOption[] }) {
const place = placeName(plant);
const activity = lastActivity(plant);
return (
<Link href={`/garden/${plant.id}`}>
<Card className="hover:shadow-md transition-shadow h-full cursor-pointer overflow-hidden">
{plant.imageUrl && (
<div className="h-36 w-full overflow-hidden bg-muted">
<div className="rounded-lg border bg-card overflow-hidden hover:shadow-md transition-shadow flex flex-col">
<Link href={`/garden/${plant.id}`} className="block group">
{plant.imageUrl ? (
<div className="h-28 w-full overflow-hidden bg-muted">
<img
src={plant.imageUrl}
alt={plant.commonName}
className="h-full w-full object-cover"
className="h-full w-full object-cover group-hover:scale-[1.03] transition-transform"
/>
</div>
)}
{!plant.imageUrl && (
<div className="h-36 w-full bg-[hsl(var(--leaf)/.08)] flex items-center justify-center">
<Leaf className="h-10 w-10 text-[hsl(var(--leaf)/.3)]" />
) : (
<div className="h-28 w-full bg-[hsl(var(--leaf)/.08)] flex items-center justify-center">
<Leaf className="h-9 w-9 text-[hsl(var(--leaf)/.3)]" />
</div>
)}
<CardContent className="pt-3 pb-4 space-y-2">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="font-medium truncate">
{plant.commonName}
{plant.variety && <span className="text-muted-foreground font-normal"> · {plant.variety}</span>}
</p>
{plant.species && <p className="text-xs text-muted-foreground italic truncate">{plant.species}</p>}
</div>
<Badge variant="secondary" className="shrink-0 text-xs">
{CATEGORY_LABELS[plant.category as keyof typeof CATEGORY_LABELS]}
</Badge>
</div>
<div className="space-y-1 text-xs text-muted-foreground">
{placeName(plant) && (
<div className="flex items-center gap-1.5">
<MapPin className="h-3 w-3 shrink-0" />
<span className="truncate">{placeName(plant)}</span>
</div>
)}
{plant.plantedAt && (
<div className="flex items-center gap-1.5">
<CalendarDays className="h-3 w-3 shrink-0" />
<span>Planted {formatDate(plant.plantedAt)}</span>
</div>
)}
{plant.logs[0] && (
<div className="flex items-center gap-1.5">
<Leaf className="h-3 w-3 shrink-0 text-[hsl(var(--leaf))]" />
<span>Last {plant.logs[0].type.toLowerCase()} {formatDate(plant.logs[0].date)}</span>
</div>
)}
</div>
<div className="pt-1 text-xs text-muted-foreground/60">
{plant._count.logs} log {plant._count.logs === 1 ? "entry" : "entries"}
</div>
</CardContent>
</Card>
</Link>
</Link>
<div className="p-2.5 pt-2 flex-1 flex flex-col gap-1.5">
<Link href={`/garden/${plant.id}`} className="min-w-0">
<p className="font-medium text-sm truncate leading-tight">
{plant.commonName}
{plant.variety && <span className="text-muted-foreground font-normal"> · {plant.variety}</span>}
</p>
</Link>
<p className="text-xs text-muted-foreground truncate">
{place && (
<span className="inline-flex items-center gap-1">
<MapPin className="h-3 w-3 shrink-0" />
{place}
</span>
)}
{place && activity && " · "}
{activity}
</p>
{plant.group && (
<Badge variant="outline" className="w-fit text-[10px] gap-1 py-0">
<Users className="h-2.5 w-2.5" />
{plant.group.name}
</Badge>
)}
<div className="mt-auto pt-1">
<QuickLogActions plantId={plant.id} plantName={plant.commonName} locations={locations} />
</div>
</div>
</div>
);
}

View File

@@ -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<string, React.ReactNode> = {
OBSERVATION: <Eye className="h-4 w-4 text-muted-foreground" />,
CARE: <Droplets className="h-4 w-4 text-sky-600" />,
HARVEST: <ShoppingBasket className="h-4 w-4 text-[hsl(var(--leaf))]" />,
TREATMENT: <Syringe className="h-4 w-4 text-amber-600" />,
TRANSPLANT: <Shovel className="h-4 w-4 text-muted-foreground" />,
};
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 (
<div>
<h2 className="font-display text-lg font-semibold mb-3">This week in your garden</h2>
<div className="rounded-lg border bg-card divide-y">
{entries.map((e) => (
<div key={e.id} className="flex items-center gap-3 px-4 py-2.5 text-sm">
<span className="shrink-0">{ICONS[e.type] ?? <Sprout className="h-4 w-4 text-muted-foreground" />}</span>
<p className="flex-1 min-w-0 truncate">
<Link href={`/garden/${e.plant.id}`} className="font-medium hover:underline underline-offset-2">
{e.plant.commonName}
{e.plant.variety && <span className="text-muted-foreground font-normal"> ({e.plant.variety})</span>}
</Link>
<span className="text-muted-foreground"> {entryText(e)}</span>
</p>
<span className="text-xs text-muted-foreground shrink-0">{dayLabel(e.date)}</span>
</div>
))}
</div>
</div>
);
}

View File

@@ -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<string, unknown>) {
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<HTMLInputElement>(null);
const doneTimer = useRef<ReturnType<typeof setTimeout>>();
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 (
<div className="flex items-center justify-center gap-1.5 h-8 rounded-md bg-[hsl(var(--leaf)/.12)] text-[hsl(var(--leaf))] text-xs font-medium">
<Check className="h-3.5 w-3.5" />
{doneLabel}
</div>
);
}
if (mode === "noting") {
return (
<form
className="flex gap-1.5"
onSubmit={(e) => { e.preventDefault(); logNote(); }}
>
<Input
ref={noteRef}
value={note}
onChange={(e) => 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}
/>
<Button type="submit" size="sm" className="h-8 px-2.5 text-xs" disabled={busy}>
{busy ? "…" : "Save"}
</Button>
</form>
);
}
return (
<div className="flex gap-1.5">
<Button
variant="outline" size="sm" className="h-8 flex-1"
title="Watered" aria-label={`Log watering for ${plantName}`}
disabled={busy} onClick={logWatered}
>
<Droplets className="h-4 w-4 text-sky-600" />
</Button>
<Button
variant="outline" size="sm" className="h-8 flex-1"
title="Note something" aria-label={`Add a note for ${plantName}`}
onClick={() => setMode("noting")}
>
<Eye className="h-4 w-4 text-muted-foreground" />
</Button>
<QuickHarvestDialog
plantId={plantId}
plantName={plantName}
locations={locations}
onLogged={() => flashDone("Harvest logged")}
/>
</div>
);
}
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 (
<>
<Button
variant="outline" size="sm"
className="h-8 flex-1 border-[hsl(var(--leaf)/.5)]"
title="Harvest" aria-label={`Log a harvest for ${plantName}`}
onClick={() => setOpen(true)}
>
<ShoppingBasket className="h-4 w-4 text-[hsl(var(--leaf))]" />
</Button>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-xs">
<DialogHeader>
<DialogTitle>Harvest {plantName}</DialogTitle>
</DialogHeader>
<form onSubmit={submit} className="space-y-3">
<div className="flex gap-2">
<Input
type="number" step="any" min="0" placeholder="2.5"
className="w-24" value={amount} autoFocus
onChange={(e) => setAmount(e.target.value)}
/>
<Input
list="quick-harvest-units" placeholder="lbs, each…"
className="flex-1" value={unit}
onChange={(e) => setUnit(e.target.value)}
/>
<datalist id="quick-harvest-units">
{UNIT_SUGGESTIONS.map((u) => <option key={u} value={u} />)}
</datalist>
</div>
<div className="space-y-2 rounded-md border p-2.5">
<label className="flex items-center gap-2 text-sm 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 &amp; Freezer
</label>
{toPantry && (
<div className="space-y-1">
<Label className="text-xs">Stored where?</Label>
<Select value={locationId} onValueChange={setLocationId}>
<SelectTrigger className="h-8"><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>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setOpen(false)}>Cancel</Button>
<Button type="submit" disabled={busy}>{busy ? "Saving…" : "Log harvest"}</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</>
);
}

View File

@@ -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",

View File

@@ -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";