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:
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user