v0.5.1 — Reminders, plant groups, location views, Wikipedia photos, Docker deploy

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Bonna Moon
2026-06-15 20:53:03 -05:00
parent e3f6a8ec92
commit 04dec2a198
32 changed files with 1988 additions and 181 deletions

View File

@@ -16,15 +16,19 @@ export async function generateMetadata({ params }: { params: { id: string } }) {
}
export default async function PlantDetailPage({ params }: { params: { id: string } }) {
const plant = await db.plant.findUnique({
where: { id: params.id },
include: {
logs: { orderBy: { date: "desc" } },
},
});
const [plant, allPlants, groups] = await Promise.all([
db.plant.findUnique({
where: { id: params.id },
include: { logs: { orderBy: { date: "desc" } } },
}),
db.plant.findMany({ where: { active: true }, select: { zone: true } }),
db.plantGroup.findMany({ where: { active: true }, select: { id: true, name: true }, orderBy: { name: "asc" } }),
]);
if (!plant || !plant.active) notFound();
const zones = Array.from(new Set(allPlants.map((p) => p.zone).filter(Boolean) as string[])).sort();
return (
<div className="space-y-6 max-w-2xl">
<div className="flex items-center gap-3">
@@ -44,10 +48,16 @@ export default async function PlantDetailPage({ params }: { params: { id: string
</div>
<div className="flex items-center gap-2">
<EditPlantButton plant={plant} />
<EditPlantButton plant={plant} zones={zones} groups={groups} />
<DeletePlantButton plantId={plant.id} plantName={plant.commonName} />
</div>
{plant.imageUrl && (
<div className="rounded-lg overflow-hidden h-56 bg-muted">
<img src={plant.imageUrl} alt={plant.commonName} className="w-full h-full object-cover" />
</div>
)}
<Card>
<CardContent className="pt-4 space-y-2 text-sm">
{plant.zone && (

View File

@@ -1,23 +1,28 @@
import { db } from "@/lib/db";
import Link from "next/link";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Leaf, Plus, MapPin, CalendarDays } from "lucide-react";
import { formatDate } from "@/lib/utils";
import { AddPlantButton } from "@/components/garden/add-plant-button";
import { CATEGORY_LABELS } from "@/lib/garden/categories";
import { GardenGrid } from "@/components/garden/garden-grid";
export const metadata = { title: "Garden" };
export default async function GardenPage() {
const plants = await db.plant.findMany({
where: { active: true },
orderBy: [{ category: "asc" }, { commonName: "asc" }],
include: {
_count: { select: { logs: true } },
logs: { orderBy: { date: "desc" }, take: 1, select: { date: true, type: true } },
},
});
const [plants, groups] = await Promise.all([
db.plant.findMany({
where: { active: true },
orderBy: [{ commonName: "asc" }],
include: {
_count: { select: { logs: true } },
logs: { orderBy: { date: "desc" }, take: 1, select: { date: true, type: true } },
group: { select: { id: true, name: true } },
},
}),
db.plantGroup.findMany({
where: { active: true },
orderBy: { name: "asc" },
include: { _count: { select: { plants: true } } },
}),
]);
const zones = Array.from(new Set(plants.map((p) => p.zone).filter(Boolean) as string[])).sort();
return (
<div className="space-y-6">
@@ -28,70 +33,10 @@ export default async function GardenPage() {
{plants.length} {plants.length === 1 ? "plant" : "plants"} on your acre
</p>
</div>
<AddPlantButton />
<AddPlantButton groups={groups} zones={zones} />
</div>
{plants.length === 0 ? (
<div className="text-center py-16 text-muted-foreground">
<Leaf className="h-12 w-12 mx-auto mb-4 opacity-20" />
<p className="font-medium">No plants yet</p>
<p className="text-sm mt-1">Add your first plant to start building your food forest registry.</p>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{plants.map((plant) => (
<Link key={plant.id} href={`/garden/${plant.id}`}>
<Card className="hover:shadow-md transition-shadow h-full cursor-pointer">
<CardContent className="pt-4 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]}
</Badge>
</div>
<div className="space-y-1 text-xs text-muted-foreground">
{plant.zone && (
<div className="flex items-center gap-1.5">
<MapPin className="h-3 w-3 shrink-0" />
<span className="truncate">{plant.zone}</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>
))}
</div>
)}
<GardenGrid plants={plants} groups={groups} />
</div>
);
}

View File

@@ -0,0 +1,158 @@
import { db } from "@/lib/db";
import { isOverdue, isDueSoon, describeRecurrence } from "@/lib/tasks/schedule";
import { formatDate } from "@/lib/utils";
import { AddTaskButton } from "@/components/tasks/add-task-button";
import { CompleteTaskButton } from "@/components/tasks/complete-task-button";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent } from "@/components/ui/card";
import { CalendarDays, Leaf, Home, Wrench, CheckCircle2, AlertCircle, Clock } from "lucide-react";
import Link from "next/link";
export const metadata = { title: "Reminders" };
const CATEGORY_ICONS = {
GARDEN: Leaf,
HOME: Home,
EQUIPMENT: Wrench,
OTHER: Clock,
};
const CATEGORY_LABELS = {
GARDEN: "Garden",
HOME: "Home",
EQUIPMENT: "Equipment",
OTHER: "Other",
};
export default async function TasksPage() {
const tasks = await db.task.findMany({
where: { active: true },
orderBy: { nextDue: "asc" },
include: { plant: { select: { id: true, commonName: true, variety: true } } },
});
const overdue = tasks.filter((t) => isOverdue(t.nextDue));
const upcoming = tasks.filter((t) => !isOverdue(t.nextDue) && isDueSoon(t.nextDue, 60));
const later = tasks.filter((t) => !isOverdue(t.nextDue) && !isDueSoon(t.nextDue, 60));
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="font-display text-2xl font-semibold">Reminders</h1>
<p className="text-sm text-muted-foreground mt-0.5">
{overdue.length > 0
? `${overdue.length} overdue · ${upcoming.length} coming up`
: `${upcoming.length} coming up in the next 60 days`}
</p>
</div>
<AddTaskButton />
</div>
{tasks.length === 0 && (
<div className="text-center py-16 text-muted-foreground">
<CalendarDays className="h-12 w-12 mx-auto mb-4 opacity-20" />
<p className="font-medium">No reminders yet</p>
<p className="text-sm mt-1">Add your first reminder to start tracking what needs doing.</p>
</div>
)}
{overdue.length > 0 && (
<Section title="Overdue" icon={AlertCircle} iconClass="text-destructive">
{overdue.map((task) => (
<TaskCard key={task.id} task={task} overdue />
))}
</Section>
)}
{upcoming.length > 0 && (
<Section title="Coming up" icon={CalendarDays} iconClass="text-[hsl(var(--leaf))]">
{upcoming.map((task) => (
<TaskCard key={task.id} task={task} />
))}
</Section>
)}
{later.length > 0 && (
<Section title="Later" icon={Clock} iconClass="text-muted-foreground">
{later.map((task) => (
<TaskCard key={task.id} task={task} />
))}
</Section>
)}
</div>
);
}
function Section({ title, icon: Icon, iconClass, children }: {
title: string;
icon: React.ElementType;
iconClass: string;
children: React.ReactNode;
}) {
return (
<div className="space-y-2">
<div className="flex items-center gap-2 mb-3">
<Icon className={`h-4 w-4 ${iconClass}`} />
<h2 className="font-display text-lg font-semibold">{title}</h2>
</div>
{children}
</div>
);
}
function TaskCard({ task, overdue = false }: {
task: Awaited<ReturnType<typeof db.task.findMany>>[0] & {
plant: { id: string; commonName: string; variety: string | null } | null;
};
overdue?: boolean;
}) {
const Icon = CATEGORY_ICONS[task.category];
const daysUntil = Math.ceil((new Date(task.nextDue).getTime() - Date.now()) / 86400000);
return (
<Card className={overdue ? "border-destructive/40" : ""}>
<CardContent className="py-3 px-4 flex items-start gap-3">
<Icon className={`h-4 w-4 mt-0.5 shrink-0 ${overdue ? "text-destructive" : "text-muted-foreground"}`} />
<div className="flex-1 min-w-0">
<div className="flex items-start gap-2 flex-wrap">
<p className="font-medium text-sm">{task.title}</p>
<Badge variant="secondary" className="text-xs shrink-0">
{CATEGORY_LABELS[task.category]}
</Badge>
</div>
{task.plant && (
<Link href={`/garden/${task.plant.id}`} className="text-xs text-[hsl(var(--leaf))] hover:underline">
{task.plant.commonName}{task.plant.variety ? ` · ${task.plant.variety}` : ""}
</Link>
)}
{task.notes && (
<p className="text-xs text-muted-foreground mt-0.5">{task.notes}</p>
)}
<p className="text-xs text-muted-foreground mt-1">
{describeRecurrence(task.recurrence, task.intervalDays, task.month)}
{task.lastDone && ` · Last done ${formatDate(task.lastDone)}`}
</p>
</div>
<div className="flex items-center gap-2 shrink-0">
<div className="text-right">
<p className={`text-xs font-medium ${overdue ? "text-destructive" : "text-muted-foreground"}`}>
{overdue
? `${Math.abs(daysUntil)}d overdue`
: daysUntil === 0 ? "Today"
: daysUntil === 1 ? "Tomorrow"
: `In ${daysUntil} days`}
</p>
<p className="text-xs text-muted-foreground">{formatDate(task.nextDue)}</p>
</div>
<CompleteTaskButton taskId={task.id} taskTitle={task.title} />
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,57 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { db } from "@/lib/db";
import { requireAuth } from "@/lib/auth";
const schema = z.object({
type: z.enum(["OBSERVATION", "CARE", "HARVEST", "TREATMENT", "TRANSPLANT", "DIED"]),
notes: z.string().optional(),
quantity: z.string().optional(),
date: z.string().optional(),
});
export async function POST(req: Request, { params }: { params: { id: string } }) {
try {
const session = await requireAuth();
const group = await db.plantGroup.findUnique({
where: { id: params.id },
include: { plants: { where: { active: true }, select: { id: true } } },
});
if (!group || !group.active) return NextResponse.json({ error: "Not found" }, { status: 404 });
const data = schema.parse(await req.json());
const date = data.date ? new Date(data.date) : new Date();
// Write a log entry for every active plant in the group.
const logs = await db.$transaction(
group.plants.map((p) =>
db.plantLog.create({
data: {
plantId: p.id,
type: data.type,
date,
notes: data.notes?.trim() || null,
quantity: data.quantity?.trim() || null,
actorId: session.user.id,
},
})
)
);
await db.auditEvent.create({
data: {
action: "plant_group.log",
entityId: group.id,
actorId: session.user.id,
payload: { groupName: group.name, type: data.type, plantCount: logs.length },
},
});
return NextResponse.json({ count: logs.length }, { status: 201 });
} catch (err) {
if (err instanceof z.ZodError) return NextResponse.json({ error: err.issues[0]?.message }, { status: 400 });
if (err instanceof Error && err.message === "Unauthorized") return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
console.error(err);
return NextResponse.json({ error: "Server error" }, { status: 500 });
}
}

View File

@@ -0,0 +1,37 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { db } from "@/lib/db";
import { requireAuth } from "@/lib/auth";
const schema = z.object({
name: z.string().min(1),
notes: z.string().optional(),
});
export async function GET() {
const groups = await db.plantGroup.findMany({
where: { active: true },
orderBy: { name: "asc" },
include: { _count: { select: { plants: true } } },
});
return NextResponse.json(groups);
}
export async function POST(req: Request) {
try {
const session = await requireAuth();
const data = schema.parse(await req.json());
const group = await db.plantGroup.create({
data: { name: data.name.trim(), notes: data.notes?.trim() || null },
});
await db.auditEvent.create({
data: { action: "plant_group.create", entityId: group.id, actorId: session.user.id, payload: { name: group.name } },
});
return NextResponse.json(group, { status: 201 });
} catch (err) {
if (err instanceof z.ZodError) return NextResponse.json({ error: err.issues[0]?.message }, { status: 400 });
if (err instanceof Error && err.message === "Unauthorized") return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
console.error(err);
return NextResponse.json({ error: "Server error" }, { status: 500 });
}
}

View File

@@ -2,6 +2,7 @@ import { NextResponse } from "next/server";
import { z } from "zod";
import { db } from "@/lib/db";
import { requireAuth, isAdmin } from "@/lib/auth";
import { fetchPlantImageUrl } from "@/lib/garden/plant-image";
const updateSchema = z.object({
commonName: z.string().min(1),
species: z.string().optional(),
@@ -11,6 +12,7 @@ const updateSchema = z.object({
plantedAt: z.string().optional(),
source: z.string().optional(),
notes: z.string().optional(),
groupId: z.string().nullable().optional(),
});
export async function PATCH(req: Request, { params }: { params: { id: string } }) {
@@ -22,17 +24,29 @@ export async function PATCH(req: Request, { params }: { params: { id: string } }
const body = await req.json();
const data = updateSchema.parse(body);
const species = data.species?.trim() || null;
const commonName = data.commonName.trim();
// Re-fetch image if species or name changed.
const speciesChanged = species !== plant.species;
const nameChanged = commonName !== plant.commonName;
const imageUrl = (speciesChanged || nameChanged || !plant.imageUrl)
? await fetchPlantImageUrl(species, commonName)
: undefined;
const updated = await db.plant.update({
where: { id: params.id },
data: {
commonName: data.commonName.trim(),
species: data.species?.trim() || null,
commonName,
species,
variety: data.variety?.trim() || null,
category: data.category,
zone: data.zone?.trim() || null,
plantedAt: data.plantedAt ? new Date(data.plantedAt) : null,
source: data.source?.trim() || null,
notes: data.notes?.trim() || null,
groupId: data.groupId ?? null,
...(imageUrl !== undefined ? { imageUrl } : {}),
},
});

View File

@@ -2,15 +2,17 @@ import { NextResponse } from "next/server";
import { z } from "zod";
import { db } from "@/lib/db";
import { requireAuth } from "@/lib/auth";
import { fetchPlantImageUrl } from "@/lib/garden/plant-image";
const createSchema = z.object({
commonName: z.string().min(1),
species: z.string().optional(),
variety: z.string().optional(),
category: z.string().min(1),
zone: z.string().optional(),
plantedAt: z.string().optional(), // ISO date string
plantedAt: z.string().optional(),
source: z.string().optional(),
notes: z.string().optional(),
groupId: z.string().optional(),
});
export async function POST(req: Request) {
@@ -19,16 +21,22 @@ export async function POST(req: Request) {
const body = await req.json();
const data = createSchema.parse(body);
const species = data.species?.trim() || null;
const commonName = data.commonName.trim();
const imageUrl = await fetchPlantImageUrl(species, commonName);
const plant = await db.plant.create({
data: {
commonName: data.commonName.trim(),
species: data.species?.trim() || null,
commonName,
species,
variety: data.variety?.trim() || null,
category: data.category,
zone: data.zone?.trim() || null,
plantedAt: data.plantedAt ? new Date(data.plantedAt) : null,
source: data.source?.trim() || null,
notes: data.notes?.trim() || null,
groupId: data.groupId || null,
imageUrl,
},
});

View File

@@ -0,0 +1,58 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { db } from "@/lib/db";
import { requireAuth } from "@/lib/auth";
import { nextDueDate } from "@/lib/tasks/schedule";
const schema = z.object({ notes: z.string().optional() });
export async function POST(req: Request, { params }: { params: { id: string } }) {
try {
const session = await requireAuth();
const task = await db.task.findUnique({ where: { id: params.id } });
if (!task || !task.active) return NextResponse.json({ error: "Not found" }, { status: 404 });
const body = await req.json().catch(() => ({}));
const data = schema.parse(body);
const now = new Date();
const next = nextDueDate(task.recurrence, now, task.intervalDays, task.month);
// Record the completion and advance the due date in one transaction.
const [completion] = await db.$transaction([
db.taskCompletion.create({
data: {
taskId: task.id,
doneAt: now,
notes: data.notes?.trim() || null,
actorId: session.user.id,
},
}),
db.task.update({
where: { id: task.id },
data: {
lastDone: now,
nextDue: next ?? task.nextDue,
// Deactivate one-time tasks once completed.
active: task.recurrence === "ONCE" ? false : true,
},
}),
]);
await db.auditEvent.create({
data: {
action: "task.complete",
entityId: task.id,
actorId: session.user.id,
payload: { title: task.title, nextDue: next },
},
});
return NextResponse.json({ completion, nextDue: next });
} catch (err) {
if (err instanceof z.ZodError) return NextResponse.json({ error: err.issues[0]?.message ?? "Invalid data" }, { status: 400 });
if (err instanceof Error && err.message === "Unauthorized") return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
console.error(err);
return NextResponse.json({ error: "Server error" }, { status: 500 });
}
}

View File

@@ -0,0 +1,53 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { db } from "@/lib/db";
import { requireAuth } from "@/lib/auth";
import { nextDueDate } from "@/lib/tasks/schedule";
const createSchema = z.object({
title: z.string().min(1),
notes: z.string().optional(),
category: z.enum(["GARDEN", "HOME", "EQUIPMENT", "OTHER"]),
plantId: z.string().optional(),
recurrence: z.enum(["ONCE", "CUSTOM", "MONTHLY", "YEARLY"]),
intervalDays: z.number().int().positive().optional(),
month: z.number().int().min(1).max(12).optional(),
nextDue: z.string(), // ISO date string
});
export async function POST(req: Request) {
try {
const session = await requireAuth();
const body = await req.json();
const data = createSchema.parse(body);
const task = await db.task.create({
data: {
title: data.title.trim(),
notes: data.notes?.trim() || null,
category: data.category,
plantId: data.plantId || null,
recurrence: data.recurrence,
intervalDays: data.intervalDays ?? null,
month: data.month ?? null,
nextDue: new Date(data.nextDue),
},
});
await db.auditEvent.create({
data: {
action: "task.create",
entityId: task.id,
actorId: session.user.id,
payload: { title: task.title, recurrence: task.recurrence },
},
});
return NextResponse.json(task, { status: 201 });
} catch (err) {
if (err instanceof z.ZodError) return NextResponse.json({ error: err.issues[0]?.message ?? "Invalid data" }, { status: 400 });
if (err instanceof Error && err.message === "Unauthorized") return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
console.error(err);
return NextResponse.json({ error: "Server error" }, { status: 500 });
}
}

View File

@@ -0,0 +1,58 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { db } from "@/lib/db";
import { requireAuth } from "@/lib/auth";
const schema = z.object({
type: z.enum(["OBSERVATION", "CARE", "HARVEST", "TREATMENT", "TRANSPLANT", "DIED"]),
notes: z.string().optional(),
quantity: z.string().optional(),
date: z.string().optional(),
});
export async function POST(req: Request, { params }: { params: { zone: string } }) {
try {
const session = await requireAuth();
const zoneName = decodeURIComponent(params.zone);
const plants = await db.plant.findMany({
where: { zone: zoneName, active: true },
select: { id: true },
});
if (plants.length === 0) return NextResponse.json({ error: "No plants found in this zone" }, { status: 404 });
const data = schema.parse(await req.json());
const date = data.date ? new Date(data.date) : new Date();
const logs = await db.$transaction(
plants.map((p) =>
db.plantLog.create({
data: {
plantId: p.id,
type: data.type,
date,
notes: data.notes?.trim() || null,
quantity: data.quantity?.trim() || null,
actorId: session.user.id,
},
})
)
);
await db.auditEvent.create({
data: {
action: "zone.log",
actorId: session.user.id,
payload: { zone: zoneName, type: data.type, plantCount: logs.length },
},
});
return NextResponse.json({ count: logs.length }, { status: 201 });
} catch (err) {
if (err instanceof z.ZodError) return NextResponse.json({ error: err.issues[0]?.message }, { status: 400 });
if (err instanceof Error && err.message === "Unauthorized") return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
console.error(err);
return NextResponse.json({ error: "Server error" }, { status: 500 });
}
}