feat: garden care tracking - logs, schedules, reminders (task 73)

- logCare/deleteCareLog actions with schedule update and reminder wiring
- upsertCareSchedule/deleteCareSchedule/toggleCareSchedule actions
- updateScheduleAfterCare helper cancels old reminder, schedules new one
- getCareLogs/getCareSchedules/getOverduePlants/getCareDueSoon queries
- CareLogForm, CareScheduleEditor, CareHistoryList components
- Plant detail Care tab wired up with schedule editor + log form + history
- Log plant care quick-add added to garden manifest
This commit is contained in:
ginnoir
2026-06-01 20:23:43 -05:00
parent fe282ba470
commit 0fef3d4ab6
9 changed files with 714 additions and 9 deletions
+7 -3
View File
@@ -1,15 +1,19 @@
import { notFound } from "next/navigation";
import { getPlant } from "@/modules/garden/server/queries";
import { getCareLogs, getCareSchedules, getPlant } from "@/modules/garden/server/queries";
import { PlantDetail } from "@/modules/garden/components/plant-detail";
export default async function PlantPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const plant = await getPlant(id);
const [plant, careLogs, careSchedules] = await Promise.all([
getPlant(id),
getCareLogs(id),
getCareSchedules(id),
]);
if (!plant) notFound();
return (
<div className="page-content">
<PlantDetail plant={plant} />
<PlantDetail plant={plant} careLogs={careLogs} careSchedules={careSchedules} />
</div>
);
}
@@ -0,0 +1,45 @@
import type { CareLogDto } from "../server/queries";
const CARE_ICONS: Record<string, string> = {
watering: "💧",
fertilizing: "🌱",
repotting: "🪴",
pruning: "✂️",
"pest-control": "🐛",
other: "📋",
};
function timeAgo(iso: string): string {
const diff = Date.now() - new Date(iso).getTime();
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
if (days === 0) return "Today";
if (days === 1) return "Yesterday";
return `${days} days ago`;
}
type Props = {
logs: CareLogDto[];
};
export function CareHistoryList({ logs }: Props) {
if (logs.length === 0) {
return <p className="text-sm text-[var(--ink-mute)]">No care events logged yet.</p>;
}
return (
<ul className="flex flex-col gap-2">
{logs.map((log) => (
<li key={log.id} className="flex gap-3 items-start text-sm">
<span className="text-lg leading-none mt-0.5" aria-hidden>
{CARE_ICONS[log.careType] ?? "📋"}
</span>
<div className="flex flex-col gap-0.5">
<span className="font-medium capitalize">{log.careType}</span>
<span className="text-xs text-[var(--ink-mute)]">{timeAgo(log.performedAt)}</span>
{log.notes && <p className="text-xs text-[var(--ink-mute)]">{log.notes}</p>}
</div>
</li>
))}
</ul>
);
}
@@ -0,0 +1,97 @@
"use client";
import { useState, useTransition } from "react";
import { logCare } from "../server/actions";
const CARE_TYPES = ["watering", "fertilizing", "repotting", "pruning", "pest-control", "other"];
type Props = {
plantId: string;
defaultCareType?: string;
onSuccess?: () => void;
};
export function CareLogForm({ plantId, defaultCareType = "watering", onSuccess }: Props) {
const [careType, setCareType] = useState(defaultCareType);
const [notes, setNotes] = useState("");
const [performedAt, setPerformedAt] = useState("");
const [error, setError] = useState<string | null>(null);
const [isPending, startTransition] = useTransition();
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError(null);
startTransition(async () => {
try {
await logCare({
plantId,
careType,
notes: notes || null,
performedAt: performedAt || undefined,
});
setNotes("");
setPerformedAt("");
onSuccess?.();
} catch {
setError("Failed to log care. Please try again.");
}
});
}
return (
<form onSubmit={handleSubmit} className="flex flex-col gap-3">
<div className="flex flex-col gap-1">
<label htmlFor="care-type" className="text-sm font-medium">
Care type
</label>
<select
id="care-type"
value={careType}
onChange={(e) => setCareType(e.target.value)}
className="input input-sm"
required
>
{CARE_TYPES.map((t) => (
<option key={t} value={t}>
{t.charAt(0).toUpperCase() + t.slice(1).replace("-", " ")}
</option>
))}
</select>
</div>
<div className="flex flex-col gap-1">
<label htmlFor="performed-at" className="text-sm font-medium">
Date &amp; time (optional defaults to now)
</label>
<input
id="performed-at"
type="datetime-local"
value={performedAt}
onChange={(e) => setPerformedAt(e.target.value)}
className="input input-sm"
/>
</div>
<div className="flex flex-col gap-1">
<label htmlFor="care-notes" className="text-sm font-medium">
Notes (optional)
</label>
<textarea
id="care-notes"
value={notes}
onChange={(e) => setNotes(e.target.value)}
rows={2}
maxLength={2000}
className="input input-sm resize-none"
placeholder="Any observations…"
/>
</div>
{error && <p className="text-sm text-red-500">{error}</p>}
<button type="submit" className="btn btn-primary btn-sm self-start" disabled={isPending}>
{isPending ? "Logging…" : "Log care"}
</button>
</form>
);
}
@@ -0,0 +1,171 @@
"use client";
import { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { deleteCareSchedule, toggleCareSchedule, upsertCareSchedule } from "../server/actions";
import type { CareScheduleDto } from "../server/queries";
const CARE_TYPES = ["watering", "fertilizing", "repotting", "pruning", "pest-control", "other"];
type Props = {
plantId: string;
schedules: CareScheduleDto[];
};
function daysLabel(n: number | null): string {
if (n === null) return "No due date";
if (n < 0) return `${Math.abs(n)} day${Math.abs(n) !== 1 ? "s" : ""} overdue`;
if (n === 0) return "Due today";
return `Due in ${n} day${n !== 1 ? "s" : ""}`;
}
export function CareScheduleEditor({ plantId, schedules }: Props) {
const [showForm, setShowForm] = useState(false);
const [careType, setCareType] = useState("watering");
const [intervalDays, setIntervalDays] = useState("7");
const [formError, setFormError] = useState<string | null>(null);
const [isPending, startTransition] = useTransition();
const router = useRouter();
function handleAddSchedule(e: React.FormEvent) {
e.preventDefault();
const days = parseInt(intervalDays, 10);
if (isNaN(days) || days < 1 || days > 365) {
setFormError("Interval must be between 1 and 365 days.");
return;
}
setFormError(null);
startTransition(async () => {
try {
await upsertCareSchedule({ plantId, careType, intervalDays: days });
setShowForm(false);
setCareType("watering");
setIntervalDays("7");
router.refresh();
} catch {
setFormError("Failed to save schedule.");
}
});
}
function handleDelete(id: string) {
startTransition(async () => {
await deleteCareSchedule({ id });
router.refresh();
});
}
function handleToggle(id: string, enabled: boolean) {
startTransition(async () => {
await toggleCareSchedule({ id, enabled });
router.refresh();
});
}
return (
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<p className="text-sm font-semibold text-[var(--ink-mute)] uppercase tracking-wide">
Schedules
</p>
<button
className="btn btn-ghost btn-xs"
onClick={() => setShowForm((v) => !v)}
disabled={isPending}
>
{showForm ? "Cancel" : "Add schedule"}
</button>
</div>
{showForm && (
<form
onSubmit={handleAddSchedule}
className="flex flex-col gap-2 p-3 bg-[var(--surface-2)] rounded-lg"
>
<div className="flex gap-2">
<div className="flex flex-col gap-1 flex-1">
<label htmlFor="sched-care-type" className="text-xs font-medium">
Care type
</label>
<select
id="sched-care-type"
value={careType}
onChange={(e) => setCareType(e.target.value)}
className="input input-xs"
>
{CARE_TYPES.map((t) => (
<option key={t} value={t}>
{t.charAt(0).toUpperCase() + t.slice(1).replace("-", " ")}
</option>
))}
</select>
</div>
<div className="flex flex-col gap-1 w-28">
<label htmlFor="sched-interval" className="text-xs font-medium">
Interval (days)
</label>
<input
id="sched-interval"
type="number"
min={1}
max={365}
value={intervalDays}
onChange={(e) => setIntervalDays(e.target.value)}
className="input input-xs"
required
/>
</div>
</div>
{formError && <p className="text-xs text-red-500">{formError}</p>}
<button type="submit" className="btn btn-primary btn-xs self-start" disabled={isPending}>
{isPending ? "Saving…" : "Add"}
</button>
</form>
)}
{schedules.length === 0 && !showForm && (
<p className="text-sm text-[var(--ink-mute)]">No schedules yet.</p>
)}
{schedules.map((s) => (
<div
key={s.id}
className={`flex items-center justify-between gap-2 p-2 rounded-lg border ${
s.isOverdue
? "border-red-300 bg-red-50 dark:bg-red-950/20"
: "border-[var(--ink-faint)]"
}`}
>
<div className="flex flex-col gap-0.5">
<span className="text-sm font-medium capitalize">{s.careType}</span>
<span className="text-xs text-[var(--ink-mute)]">
Every {s.intervalDays} days · {daysLabel(s.daysUntilDue)}
</span>
</div>
<div className="flex items-center gap-2 shrink-0">
<button
onClick={() => handleToggle(s.id, !s.enabled)}
disabled={isPending}
title={s.enabled ? "Disable" : "Enable"}
className={`text-xs px-2 py-0.5 rounded-full border transition-colors ${
s.enabled
? "border-green-400 text-green-700 dark:text-green-400"
: "border-[var(--ink-faint)] text-[var(--ink-mute)]"
}`}
>
{s.enabled ? "Active" : "Paused"}
</button>
<button
onClick={() => handleDelete(s.id)}
disabled={isPending}
title="Delete schedule"
className="text-[var(--ink-mute)] hover:text-red-500 text-xs"
>
</button>
</div>
</div>
))}
</div>
);
}
+25 -4
View File
@@ -4,11 +4,18 @@ import { useState, useTransition } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { deletePlant, addPlantImage, removePlantImage, setPrimaryImage } from "../server/actions";
import type { PlantDetailDto } from "../server/queries";
import type { CareLogDto, CareScheduleDto, PlantDetailDto } from "../server/queries";
import { CareHistoryList } from "./care-history-list";
import { CareLogForm } from "./care-log-form";
import { CareScheduleEditor } from "./care-schedule-editor";
type Tab = "info" | "gallery" | "care";
type Props = { plant: PlantDetailDto };
type Props = {
plant: PlantDetailDto;
careLogs: CareLogDto[];
careSchedules: CareScheduleDto[];
};
function InfoRow({
label,
@@ -34,7 +41,7 @@ function healthBadgeClass(status: string): string {
return "badge-warning";
}
export function PlantDetail({ plant }: Props) {
export function PlantDetail({ plant, careLogs, careSchedules }: Props) {
const [tab, setTab] = useState<Tab>("info");
const [confirming, setConfirming] = useState(false);
const [isPending, startTransition] = useTransition();
@@ -252,7 +259,21 @@ export function PlantDetail({ plant }: Props) {
{/* Care */}
{tab === "care" && (
<p className="text-sm text-[var(--ink-mute)]">Care tracking coming soon.</p>
<div className="flex flex-col gap-6">
<CareScheduleEditor plantId={plant.id} schedules={careSchedules} />
<div className="border-t border-[var(--ink-faint)] pt-4">
<p className="text-sm font-semibold text-[var(--ink-mute)] uppercase tracking-wide mb-3">
Log care
</p>
<CareLogForm plantId={plant.id} onSuccess={() => router.refresh()} />
</div>
<div className="border-t border-[var(--ink-faint)] pt-4">
<p className="text-sm font-semibold text-[var(--ink-mute)] uppercase tracking-wide mb-3">
History
</p>
<CareHistoryList logs={careLogs} />
</div>
</div>
)}
</div>
);
+6
View File
@@ -132,6 +132,12 @@ const gardenManifest: ModuleManifest = {
icon: "sprout",
url: "/garden/containers/new",
},
{
id: "garden.log-care",
label: "Log plant care",
icon: "droplets",
url: "/garden",
},
],
};
+177 -1
View File
@@ -6,7 +6,9 @@ import { z } from "zod";
import { db } from "@/lib/db";
import { getCurrentSession } from "@/lib/session";
import { logActivity } from "@/modules/_core/activity";
import { gardenContainers, gardenPlants } from "../schema";
import { cancelReminder, scheduleReminder } from "@/modules/_core/reminders";
import { gardenCareLogs, gardenCareSchedules, gardenContainers, gardenPlants } from "../schema";
import { updateScheduleAfterCare } from "./care-schedule";
const containerInput = z.object({
name: z.string().trim().min(1).max(120),
@@ -305,3 +307,177 @@ async function assertCanAccessPlant(id: string, householdId: string) {
if (!row) throw new Error("Forbidden");
}
// ─── Care log actions ─────────────────────────────────────────────────────────
const careLogInput = z.object({
plantId: z.string().uuid(),
careType: z.string().trim().min(1).max(40),
notes: z.string().trim().max(2000).nullable().optional(),
performedAt: z.string().optional(),
});
export async function logCare(input: z.input<typeof careLogInput>) {
const parsed = careLogInput.parse(input);
const { household, user } = await getCurrentSession();
await assertCanAccessPlant(parsed.plantId, household.id);
const performedAt = parsed.performedAt ? new Date(parsed.performedAt) : new Date();
const [log] = await db
.insert(gardenCareLogs)
.values({
plantId: parsed.plantId,
householdId: household.id,
careType: parsed.careType,
performedBy: user.id,
notes: parsed.notes ?? null,
performedAt,
})
.returning();
if (!log) throw new Error("Care log was not created");
await updateScheduleAfterCare(parsed.plantId, parsed.careType, user.id, household.id);
await logActivity({
entityType: "garden.plant",
entityId: parsed.plantId,
action: "update",
payload: { careType: parsed.careType },
});
revalidatePath(`/garden/plants/${parsed.plantId}`);
return log;
}
export async function deleteCareLog(input: { id: string }) {
const parsed = z.object({ id: z.string().uuid() }).parse(input);
const { household } = await getCurrentSession();
const [row] = await db
.select({ id: gardenCareLogs.id, plantId: gardenCareLogs.plantId })
.from(gardenCareLogs)
.where(and(eq(gardenCareLogs.id, parsed.id), eq(gardenCareLogs.householdId, household.id)))
.limit(1);
if (!row) throw new Error("Forbidden");
await db.delete(gardenCareLogs).where(eq(gardenCareLogs.id, parsed.id));
revalidatePath(`/garden/plants/${row.plantId}`);
}
// ─── Care schedule actions ────────────────────────────────────────────────────
const careScheduleInput = z.object({
plantId: z.string().uuid(),
careType: z.string().trim().min(1).max(40),
intervalDays: z.number().int().min(1).max(365),
enabled: z.boolean().optional(),
});
export async function upsertCareSchedule(input: z.input<typeof careScheduleInput>) {
const parsed = careScheduleInput.parse(input);
const { household, user } = await getCurrentSession();
await assertCanAccessPlant(parsed.plantId, household.id);
const now = new Date();
const enabled = parsed.enabled ?? true;
const [existing] = await db
.select({ id: gardenCareSchedules.id, lastPerformedAt: gardenCareSchedules.lastPerformedAt })
.from(gardenCareSchedules)
.where(
and(
eq(gardenCareSchedules.plantId, parsed.plantId),
eq(gardenCareSchedules.careType, parsed.careType),
),
)
.limit(1);
const base = existing?.lastPerformedAt ?? now;
const nextDueAt = new Date(base.getTime() + parsed.intervalDays * 24 * 60 * 60 * 1000);
const [schedule] = await db
.insert(gardenCareSchedules)
.values({
plantId: parsed.plantId,
householdId: household.id,
careType: parsed.careType,
intervalDays: parsed.intervalDays,
nextDueAt,
enabled,
})
.onConflictDoUpdate({
target: [gardenCareSchedules.plantId, gardenCareSchedules.careType],
set: { intervalDays: parsed.intervalDays, nextDueAt, enabled, updatedAt: now },
})
.returning();
if (!schedule) throw new Error("Schedule was not created");
await cancelReminder("garden.schedule", schedule.id);
if (enabled) {
await scheduleReminder({
householdId: household.id,
entityType: "garden.schedule",
entityId: schedule.id,
fireAt: nextDueAt,
createdBy: user.id,
});
}
revalidatePath(`/garden/plants/${parsed.plantId}`);
return schedule;
}
export async function deleteCareSchedule(input: { id: string }) {
const parsed = z.object({ id: z.string().uuid() }).parse(input);
const { household } = await getCurrentSession();
const [row] = await db
.select({ id: gardenCareSchedules.id, plantId: gardenCareSchedules.plantId })
.from(gardenCareSchedules)
.where(
and(eq(gardenCareSchedules.id, parsed.id), eq(gardenCareSchedules.householdId, household.id)),
)
.limit(1);
if (!row) throw new Error("Forbidden");
await cancelReminder("garden.schedule", parsed.id);
await db.delete(gardenCareSchedules).where(eq(gardenCareSchedules.id, parsed.id));
revalidatePath(`/garden/plants/${row.plantId}`);
}
export async function toggleCareSchedule(input: { id: string; enabled: boolean }) {
const parsed = z.object({ id: z.string().uuid(), enabled: z.boolean() }).parse(input);
const { household, user } = await getCurrentSession();
const [row] = await db
.select()
.from(gardenCareSchedules)
.where(
and(eq(gardenCareSchedules.id, parsed.id), eq(gardenCareSchedules.householdId, household.id)),
)
.limit(1);
if (!row) throw new Error("Forbidden");
await db
.update(gardenCareSchedules)
.set({ enabled: parsed.enabled, updatedAt: new Date() })
.where(eq(gardenCareSchedules.id, parsed.id));
await cancelReminder("garden.schedule", parsed.id);
if (parsed.enabled && row.nextDueAt) {
await scheduleReminder({
householdId: household.id,
entityType: "garden.schedule",
entityId: parsed.id,
fireAt: row.nextDueAt,
createdBy: user.id,
});
}
revalidatePath(`/garden/plants/${row.plantId}`);
}
@@ -0,0 +1,38 @@
import { and, eq } from "drizzle-orm";
import { db } from "@/lib/db";
import { cancelReminder, scheduleReminder } from "@/modules/_core/reminders";
import { gardenCareSchedules } from "../schema";
export async function updateScheduleAfterCare(
plantId: string,
careType: string,
userId: string,
householdId: string,
) {
const [schedule] = await db
.select()
.from(gardenCareSchedules)
.where(
and(eq(gardenCareSchedules.plantId, plantId), eq(gardenCareSchedules.careType, careType)),
)
.limit(1);
if (!schedule || !schedule.enabled) return;
const now = new Date();
const nextDueAt = new Date(now.getTime() + schedule.intervalDays * 24 * 60 * 60 * 1000);
await db
.update(gardenCareSchedules)
.set({ lastPerformedAt: now, nextDueAt, updatedAt: now })
.where(eq(gardenCareSchedules.id, schedule.id));
await cancelReminder("garden.schedule", schedule.id);
await scheduleReminder({
householdId,
entityType: "garden.schedule",
entityId: schedule.id,
fireAt: nextDueAt,
createdBy: userId,
});
}
+148 -1
View File
@@ -1,4 +1,4 @@
import { and, desc, eq, sql } from "drizzle-orm";
import { and, desc, eq, lte, sql } from "drizzle-orm";
import { db } from "@/lib/db";
import { getCurrentSession } from "@/lib/session";
import { gardenCareLogs, gardenCareSchedules, gardenContainers, gardenPlants } from "../schema";
@@ -345,3 +345,150 @@ export async function searchPlants(query: string, householdId: string) {
url: `/garden/plants/${r.id}`,
}));
}
// ─── Care queries ─────────────────────────────────────────────────────────────
export type CareLogDto = {
id: string;
careType: string;
notes: string | null;
performedAt: string;
performedBy: string | null;
};
export type CareScheduleDto = {
id: string;
careType: string;
intervalDays: number;
lastPerformedAt: string | null;
nextDueAt: string | null;
enabled: boolean;
daysUntilDue: number | null;
isOverdue: boolean;
};
export async function getCareLogs(plantId: string, limit = 20): Promise<CareLogDto[]> {
const { household } = await getCurrentSession();
const rows = await db
.select({
id: gardenCareLogs.id,
careType: gardenCareLogs.careType,
notes: gardenCareLogs.notes,
performedAt: gardenCareLogs.performedAt,
performedBy: gardenCareLogs.performedBy,
})
.from(gardenCareLogs)
.where(and(eq(gardenCareLogs.plantId, plantId), eq(gardenCareLogs.householdId, household.id)))
.orderBy(desc(gardenCareLogs.performedAt))
.limit(limit);
return rows.map((r) => ({
id: r.id,
careType: r.careType,
notes: r.notes,
performedAt: r.performedAt.toISOString(),
performedBy: r.performedBy,
}));
}
export async function getCareSchedules(plantId: string): Promise<CareScheduleDto[]> {
const { household } = await getCurrentSession();
const rows = await db
.select()
.from(gardenCareSchedules)
.where(
and(
eq(gardenCareSchedules.plantId, plantId),
eq(gardenCareSchedules.householdId, household.id),
),
)
.orderBy(gardenCareSchedules.careType);
const now = new Date();
return rows.map((s) => {
const next = s.nextDueAt;
const daysUntilDue = next
? Math.ceil((next.getTime() - now.getTime()) / (1000 * 60 * 60 * 24))
: null;
return {
id: s.id,
careType: s.careType,
intervalDays: s.intervalDays,
lastPerformedAt: s.lastPerformedAt?.toISOString() ?? null,
nextDueAt: next?.toISOString() ?? null,
enabled: s.enabled,
daysUntilDue,
isOverdue: daysUntilDue !== null && daysUntilDue < 0,
};
});
}
export type OverduePlantDto = {
id: string;
name: string;
primaryImageUrl: string | null;
mostOverdueAt: Date;
};
export async function getOverduePlants(householdId: string): Promise<OverduePlantDto[]> {
const rows = await db
.select({
id: gardenPlants.id,
name: gardenPlants.name,
primaryImageUrl: gardenPlants.primaryImageUrl,
mostOverdueAt: sql<Date>`min(${gardenCareSchedules.nextDueAt})`,
})
.from(gardenPlants)
.innerJoin(
gardenCareSchedules,
and(
eq(gardenCareSchedules.plantId, gardenPlants.id),
eq(gardenCareSchedules.enabled, true),
lte(gardenCareSchedules.nextDueAt, sql`now()`),
),
)
.where(eq(gardenPlants.householdId, householdId))
.groupBy(gardenPlants.id, gardenPlants.name, gardenPlants.primaryImageUrl)
.orderBy(sql`min(${gardenCareSchedules.nextDueAt})`);
return rows;
}
export type CareDueSoonDto = {
id: string;
name: string;
primaryImageUrl: string | null;
nextDueAt: Date;
};
export async function getCareDueSoon(
householdId: string,
withinDays: number,
): Promise<CareDueSoonDto[]> {
const cutoff = new Date();
cutoff.setDate(cutoff.getDate() + withinDays);
const rows = await db
.select({
id: gardenPlants.id,
name: gardenPlants.name,
primaryImageUrl: gardenPlants.primaryImageUrl,
nextDueAt: sql<Date>`min(${gardenCareSchedules.nextDueAt})`,
})
.from(gardenPlants)
.innerJoin(
gardenCareSchedules,
and(
eq(gardenCareSchedules.plantId, gardenPlants.id),
eq(gardenCareSchedules.enabled, true),
lte(gardenCareSchedules.nextDueAt, cutoff),
),
)
.where(eq(gardenPlants.householdId, householdId))
.groupBy(gardenPlants.id, gardenPlants.name, gardenPlants.primaryImageUrl)
.orderBy(sql`min(${gardenCareSchedules.nextDueAt})`);
return rows;
}