feat: garden dashboard widgets and manifest completion (task 75)

- getCareDueWidgetRows query: schedule rows due within N days, filtered
  by optional container list, ordered by next_due_at
- getGardenOverviewStats query: plant/container/overdue counts + next care
- CareDueWidget: compact list sorted by urgency, inline log-care button,
  truncates at 10 rows with View all link
- GardenOverviewWidget: stat row (plants, containers, overdue) + next care
  line + garden link
- plant-widget.tsx server component: InlineLogButton uses form action to
  call logCare without client JS or navigation
- manifest: garden.care-due and garden.overview widgets registered;
  resolveConfigOptions returns container list for care-due config picker
- quickAdds: icons updated (leaf/box/droplets), log-care URL ->
  /garden?logCare=1
This commit is contained in:
ginnoir
2026-06-01 22:43:53 -05:00
parent 080d26816e
commit 6b25d67537
3 changed files with 307 additions and 6 deletions
+125
View File
@@ -492,3 +492,128 @@ export async function getCareDueSoon(
return rows;
}
// ─── Widget queries ───────────────────────────────────────────────────────────
export type CareDueWidgetRow = {
plantId: string;
plantName: string;
scheduleId: string;
careType: string;
nextDueAt: Date | null;
daysUntilDue: number;
};
export async function getCareDueWidgetRows(
householdId: string,
containerIds: "all" | string[],
daysAhead: number,
): Promise<CareDueWidgetRow[]> {
const cutoff = new Date();
cutoff.setDate(cutoff.getDate() + daysAhead);
const baseConditions = [
eq(gardenCareSchedules.householdId, householdId),
eq(gardenCareSchedules.enabled, true),
lte(gardenCareSchedules.nextDueAt, cutoff),
];
const containerFilter =
containerIds !== "all" && containerIds.length > 0
? sql`${gardenPlants.containerId} = ANY(ARRAY[${sql.join(
containerIds.map((id) => sql`${id}::uuid`),
sql`, `,
)}])`
: undefined;
const where =
containerFilter !== undefined
? and(...baseConditions, containerFilter)
: and(...baseConditions);
const rows = await db
.select({
plantId: gardenPlants.id,
plantName: gardenPlants.name,
scheduleId: gardenCareSchedules.id,
careType: gardenCareSchedules.careType,
nextDueAt: gardenCareSchedules.nextDueAt,
})
.from(gardenCareSchedules)
.innerJoin(gardenPlants, eq(gardenCareSchedules.plantId, gardenPlants.id))
.where(where)
.orderBy(gardenCareSchedules.nextDueAt);
const now = Date.now();
return rows.map((r) => ({
plantId: r.plantId,
plantName: r.plantName,
scheduleId: r.scheduleId,
careType: r.careType,
nextDueAt: r.nextDueAt,
daysUntilDue: r.nextDueAt
? Math.ceil((r.nextDueAt.getTime() - now) / (1000 * 60 * 60 * 24))
: 0,
}));
}
export type GardenOverviewStats = {
plantCount: number;
containerCount: number;
overdueCount: number;
nextCare: { plantName: string; careType: string; daysUntilDue: number } | null;
};
export async function getGardenOverviewStats(householdId: string): Promise<GardenOverviewStats> {
const [plantRow, containerRow] = await Promise.all([
db
.select({ count: sql<number>`count(*)::int` })
.from(gardenPlants)
.where(eq(gardenPlants.householdId, householdId)),
db
.select({ count: sql<number>`count(*)::int` })
.from(gardenContainers)
.where(eq(gardenContainers.householdId, householdId)),
]);
const plantCount = plantRow[0]?.count ?? 0;
const containerCount = containerRow[0]?.count ?? 0;
const overdueRows = await db
.select({ count: sql<number>`count(*)::int` })
.from(gardenCareSchedules)
.where(
and(
eq(gardenCareSchedules.householdId, householdId),
eq(gardenCareSchedules.enabled, true),
lte(gardenCareSchedules.nextDueAt, sql`now()`),
),
);
const overdueCount = overdueRows[0]?.count ?? 0;
const nextRows = await db
.select({
plantName: gardenPlants.name,
careType: gardenCareSchedules.careType,
nextDueAt: gardenCareSchedules.nextDueAt,
})
.from(gardenCareSchedules)
.innerJoin(gardenPlants, eq(gardenCareSchedules.plantId, gardenPlants.id))
.where(
and(eq(gardenCareSchedules.householdId, householdId), eq(gardenCareSchedules.enabled, true)),
)
.orderBy(gardenCareSchedules.nextDueAt)
.limit(1);
const next = nextRows[0];
const now = Date.now();
const nextCare = next?.nextDueAt
? {
plantName: next.plantName,
careType: next.careType,
daysUntilDue: Math.ceil((next.nextDueAt.getTime() - now) / (1000 * 60 * 60 * 24)),
}
: null;
return { plantCount, containerCount, overdueCount, nextCare };
}