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:
@@ -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<string, string> = {
|
||||||
|
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 <p className="text-sm text-[var(--ink-mute)]">All plants are on schedule.</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const displayed = rows.slice(0, 10);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
{displayed.map((row) => {
|
||||||
|
const { text, cls } = urgencyLabel(row.daysUntilDue);
|
||||||
|
const icon = CARE_ICONS[row.careType] ?? "📋";
|
||||||
|
return (
|
||||||
|
<div key={row.scheduleId} className="flex items-center gap-2 py-1">
|
||||||
|
<span className="text-base leading-none" aria-hidden>
|
||||||
|
{icon}
|
||||||
|
</span>
|
||||||
|
<Link
|
||||||
|
href={`/garden/plants/${row.plantId}`}
|
||||||
|
className="text-sm flex-1 truncate hover:underline"
|
||||||
|
>
|
||||||
|
{row.plantName}
|
||||||
|
</Link>
|
||||||
|
<span className={`text-xs shrink-0 ${cls}`}>{text}</span>
|
||||||
|
<InlineLogButton plantId={row.plantId} careType={row.careType} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{rows.length > 10 && (
|
||||||
|
<Link href="/garden" className="text-xs text-[var(--ink-mute)] hover:underline mt-1">
|
||||||
|
View all ({rows.length}) →
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function InlineLogButton({ plantId, careType }: { plantId: string; careType: string }) {
|
||||||
|
async function action() {
|
||||||
|
"use server";
|
||||||
|
await logCare({ plantId, careType });
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<form action={action}>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
title={`Log ${careType}`}
|
||||||
|
className="text-xs text-[var(--ink-mute)] hover:text-[var(--ink)] px-1"
|
||||||
|
>
|
||||||
|
✓
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 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 (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
<span className="text-2xl font-bold leading-none">{stats.plantCount}</span>
|
||||||
|
<span className="text-xs text-[var(--ink-mute)] mt-0.5">
|
||||||
|
{stats.plantCount === 1 ? "plant" : "plants"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
<span className="text-2xl font-bold leading-none">{stats.containerCount}</span>
|
||||||
|
<span className="text-xs text-[var(--ink-mute)] mt-0.5">
|
||||||
|
{stats.containerCount === 1 ? "container" : "containers"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{stats.overdueCount > 0 && (
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
<span className="text-2xl font-bold leading-none text-red-500">
|
||||||
|
{stats.overdueCount}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-[var(--ink-mute)] mt-0.5">overdue</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-[var(--ink-mute)]">{nextCareLabel()}</p>
|
||||||
|
<Link href="/garden" className="text-xs text-[var(--ink-mute)] hover:underline self-start">
|
||||||
|
View garden →
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,11 +1,35 @@
|
|||||||
import type { ModuleManifest } from "../_core/module";
|
import { z } from "zod";
|
||||||
|
import type { ModuleManifest, WidgetContext } from "../_core/module";
|
||||||
import {
|
import {
|
||||||
loadContainerForShare,
|
loadContainerForShare,
|
||||||
loadPlantForShare,
|
loadPlantForShare,
|
||||||
type ContainerShareData,
|
type ContainerShareData,
|
||||||
type PlantShareData,
|
type PlantShareData,
|
||||||
} from "./server/share-queries";
|
} 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 <CareDueWidget rows={rows} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function GardenOverviewWidgetServer({ ctx }: { config: unknown; ctx: WidgetContext }) {
|
||||||
|
const stats = await getGardenOverviewStats(ctx.householdId);
|
||||||
|
return <GardenOverviewWidget stats={stats} />;
|
||||||
|
}
|
||||||
|
|
||||||
const gardenManifest: ModuleManifest = {
|
const gardenManifest: ModuleManifest = {
|
||||||
id: "garden",
|
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) => <CareDueWidgetServer {...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) => <GardenOverviewWidgetServer {...props} />,
|
||||||
|
},
|
||||||
|
],
|
||||||
quickAdds: [
|
quickAdds: [
|
||||||
{
|
{
|
||||||
id: "garden.add-plant",
|
id: "garden.add-plant",
|
||||||
label: "Add plant",
|
label: "Add plant",
|
||||||
icon: "sprout",
|
icon: "leaf",
|
||||||
url: "/garden/plants/new",
|
url: "/garden/plants/new",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "garden.add-container",
|
id: "garden.add-container",
|
||||||
label: "Add container",
|
label: "Add container",
|
||||||
icon: "sprout",
|
icon: "box",
|
||||||
url: "/garden/containers/new",
|
url: "/garden/containers/new",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "garden.log-care",
|
id: "garden.log-care",
|
||||||
label: "Log plant care",
|
label: "Log plant care",
|
||||||
icon: "droplets",
|
icon: "droplets",
|
||||||
url: "/garden",
|
url: "/garden?logCare=1",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -492,3 +492,128 @@ export async function getCareDueSoon(
|
|||||||
|
|
||||||
return rows;
|
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 };
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user