From 6b25d675372f84c1bf05b307841ac5f5735e1f2a Mon Sep 17 00:00:00 2001 From: ginnoir Date: Mon, 1 Jun 2026 22:43:53 -0500 Subject: [PATCH] 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 --- .../garden/components/plant-widget.tsx | 124 +++++++++++++++++ src/modules/garden/manifest.tsx | 64 ++++++++- src/modules/garden/server/queries.ts | 125 ++++++++++++++++++ 3 files changed, 307 insertions(+), 6 deletions(-) create mode 100644 src/modules/garden/components/plant-widget.tsx diff --git a/src/modules/garden/components/plant-widget.tsx b/src/modules/garden/components/plant-widget.tsx new file mode 100644 index 0000000..6d9408a --- /dev/null +++ b/src/modules/garden/components/plant-widget.tsx @@ -0,0 +1,124 @@ +import Link from "next/link"; +import { logCare } from "../server/actions"; +import type { CareDueWidgetRow, GardenOverviewStats } from "../server/queries"; + +const CARE_ICONS: Record = { + watering: "💧", + fertilizing: "ðŸŒą", + repotting: "ðŸŠī", + pruning: "✂ïļ", + "pest-control": "🐛", + other: "📋", +}; + +function urgencyLabel(days: number): { text: string; cls: string } { + if (days < 0) + return { text: `${Math.abs(days)}d overdue`, cls: "text-red-600 dark:text-red-400" }; + if (days === 0) return { text: "due today", cls: "text-amber-600 dark:text-amber-400" }; + return { text: `in ${days}d`, cls: "text-[var(--ink-mute)]" }; +} + +// ─── Care-due widget ────────────────────────────────────────────────────────── + +export function CareDueWidget({ rows }: { rows: CareDueWidgetRow[] }) { + if (rows.length === 0) { + return

All plants are on schedule.

; + } + + const displayed = rows.slice(0, 10); + + return ( +
+ {displayed.map((row) => { + const { text, cls } = urgencyLabel(row.daysUntilDue); + const icon = CARE_ICONS[row.careType] ?? "📋"; + return ( +
+ + {icon} + + + {row.plantName} + + {text} + +
+ ); + })} + {rows.length > 10 && ( + + View all ({rows.length}) → + + )} +
+ ); +} + +function InlineLogButton({ plantId, careType }: { plantId: string; careType: string }) { + async function action() { + "use server"; + await logCare({ plantId, careType }); + } + return ( +
+ +
+ ); +} + +// ─── Overview widget ────────────────────────────────────────────────────────── + +export function GardenOverviewWidget({ stats }: { stats: GardenOverviewStats }) { + function nextCareLabel(): string { + const n = stats.nextCare; + if (!n) return "No upcoming care scheduled."; + const dayText = + n.daysUntilDue < 0 + ? "overdue" + : n.daysUntilDue === 0 + ? "today" + : `in ${n.daysUntilDue} day${n.daysUntilDue !== 1 ? "s" : ""}`; + const careLabel = n.careType.charAt(0).toUpperCase() + n.careType.slice(1); + return `${careLabel} ${n.plantName} — ${dayText}`; + } + + return ( +
+
+
+ {stats.plantCount} + + {stats.plantCount === 1 ? "plant" : "plants"} + +
+
+ {stats.containerCount} + + {stats.containerCount === 1 ? "container" : "containers"} + +
+ {stats.overdueCount > 0 && ( +
+ + {stats.overdueCount} + + overdue +
+ )} +
+

{nextCareLabel()}

+ + View garden → + +
+ ); +} diff --git a/src/modules/garden/manifest.tsx b/src/modules/garden/manifest.tsx index 75260f0..c58e2b9 100644 --- a/src/modules/garden/manifest.tsx +++ b/src/modules/garden/manifest.tsx @@ -1,11 +1,35 @@ -import type { ModuleManifest } from "../_core/module"; +import { z } from "zod"; +import type { ModuleManifest, WidgetContext } from "../_core/module"; import { loadContainerForShare, loadPlantForShare, type ContainerShareData, type PlantShareData, } from "./server/share-queries"; -import { searchContainers, searchPlants } from "./server/queries"; +import { + listContainers, + searchContainers, + searchPlants, + getCareDueWidgetRows, + getGardenOverviewStats, +} from "./server/queries"; +import { CareDueWidget, GardenOverviewWidget } from "./components/plant-widget"; + +const careDueConfigSchema = z.object({ + containerIds: z.union([z.literal("all"), z.array(z.string().uuid())]), + daysAhead: z.number().int().min(0).max(30).default(0), +}); + +async function CareDueWidgetServer({ config, ctx }: { config: unknown; ctx: WidgetContext }) { + const parsed = careDueConfigSchema.parse(config); + const rows = await getCareDueWidgetRows(ctx.householdId, parsed.containerIds, parsed.daysAhead); + return ; +} + +async function GardenOverviewWidgetServer({ ctx }: { config: unknown; ctx: WidgetContext }) { + const stats = await getGardenOverviewStats(ctx.householdId); + return ; +} const gardenManifest: ModuleManifest = { id: "garden", @@ -118,25 +142,53 @@ const gardenManifest: ModuleManifest = { }, }, ], - dashboardWidgets: [], + dashboardWidgets: [ + { + id: "garden.care-due", + title: "Plants needing care", + description: "Shows plants with overdue or upcoming care schedules.", + category: "Garden", + defaultSize: { w: 4, h: 4 }, + minSize: { w: 3, h: 2 }, + defaultPriority: 40, + configSchema: careDueConfigSchema, + defaultConfig: { containerIds: "all", daysAhead: 0 }, + resolveConfigOptions: async () => ({ + containers: (await listContainers()).map((c) => ({ id: c.id, name: c.name })), + }), + render: (props) => , + }, + { + id: "garden.overview", + title: "Garden overview", + description: "Plant and container counts with next care summary.", + category: "Garden", + defaultSize: { w: 3, h: 2 }, + minSize: { w: 2, h: 2 }, + defaultPriority: 41, + configSchema: z.object({}), + defaultConfig: {}, + render: (props) => , + }, + ], quickAdds: [ { id: "garden.add-plant", label: "Add plant", - icon: "sprout", + icon: "leaf", url: "/garden/plants/new", }, { id: "garden.add-container", label: "Add container", - icon: "sprout", + icon: "box", url: "/garden/containers/new", }, { id: "garden.log-care", label: "Log plant care", icon: "droplets", - url: "/garden", + url: "/garden?logCare=1", }, ], }; diff --git a/src/modules/garden/server/queries.ts b/src/modules/garden/server/queries.ts index ea02a8f..9c18e60 100644 --- a/src/modules/garden/server/queries.ts +++ b/src/modules/garden/server/queries.ts @@ -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 { + 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 { + const [plantRow, containerRow] = await Promise.all([ + db + .select({ count: sql`count(*)::int` }) + .from(gardenPlants) + .where(eq(gardenPlants.householdId, householdId)), + db + .select({ count: sql`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`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 }; +}