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
+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;
}