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:
@@ -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