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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
68
src/components/garden/garden-journal.tsx
Normal file
68
src/components/garden/garden-journal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
253
src/components/garden/quick-log-actions.tsx
Normal file
253
src/components/garden/quick-log-actions.tsx
Normal 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 & 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user