import { z } from "zod";
import { and, eq, sql } from "drizzle-orm";
import { db } from "@/lib/db";
import { registerItemToggleHook } from "../_core/registry";
import type { ModuleManifest, WidgetContext } from "../_core/module";
import {
loadContainerForShare,
loadPlantForShare,
type ContainerShareData,
type PlantShareData,
} from "./server/share-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",
name: "Garden",
nav: { href: "/garden", label: "Garden", icon: "sprout" },
entities: [
{
type: "garden.plant",
label: { singular: "Plant", plural: "Plants" },
share: { canShare: true, defaultCapabilities: ["read"] },
search: { search: searchPlants },
resolveUrl: (id) => `/garden/plants/${id}`,
loadForShare: (id) => loadPlantForShare(id),
renderSharedView: ({ data }) => {
const d = data as PlantShareData;
return (
{d.primaryImageUrl && (

)}
{d.name}
{d.scientificName && (
{d.scientificName}
)}
{d.healthStatus} ยท {d.category}
{d.wateringNotes && (
Watering
{d.wateringNotes}
)}
{d.fertilizingNotes && (
Fertilizing
{d.fertilizingNotes}
)}
{d.notes &&
{d.notes}
}
{d.images.length > 1 && (
{d.images.slice(1).map((url) => (

))}
)}
);
},
renderActivity: (entry) => {
const name = entry.payload?.name as string | undefined;
if (entry.action === "create") return `Added plant${name ? ` "${name}"` : ""}`;
if (entry.action === "delete") return `Removed plant${name ? ` "${name}"` : ""}`;
return `Updated${name ? ` "${name}"` : " plant"}`;
},
},
{
type: "garden.container",
label: { singular: "Container", plural: "Containers" },
share: { canShare: true, defaultCapabilities: ["read"] },
search: { search: searchContainers },
resolveUrl: (id) => `/garden/containers/${id}`,
loadForShare: (id) => loadContainerForShare(id),
renderSharedView: ({ data }) => {
const d = data as ContainerShareData;
return (
{d.name}
{d.type}
{d.locationNotes &&
{d.locationNotes}
}
{d.plants.length > 0 && (
Plants
{d.plants.map((p) => (
-
{p.name}
{p.scientificName && (
({p.scientificName})
)}
))}
)}
);
},
renderActivity: (entry) => {
const name = entry.payload?.name as string | undefined;
if (entry.action === "create") return `Created container${name ? ` "${name}"` : ""}`;
if (entry.action === "delete") return "Deleted container";
return `Updated container${name ? ` "${name}"` : ""}`;
},
},
],
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: "leaf",
url: "/garden/plants/new",
},
{
id: "garden.add-container",
label: "Add container",
icon: "box",
url: "/garden/containers/new",
},
{
id: "garden.log-care",
label: "Log plant care",
icon: "droplets",
url: "/garden?logCare=1",
},
],
};
import { gardenCareLogs, gardenPlants } from "./schema";
registerItemToggleHook("garden.care-task", async ({ metadata, done, userId }) => {
if (!done) return;
const plantId = metadata.gardenPlantId;
const careType = metadata.gardenCareType;
if (typeof plantId !== "string" || typeof careType !== "string") return;
const [plant] = await db
.select({ householdId: gardenPlants.householdId })
.from(gardenPlants)
.where(eq(gardenPlants.id, plantId))
.limit(1);
if (!plant) return;
// Avoid duplicate log if care was already logged within the last 10 minutes
const recentLog = await db
.select({ id: gardenCareLogs.id })
.from(gardenCareLogs)
.where(
and(
eq(gardenCareLogs.plantId, plantId),
eq(gardenCareLogs.careType, careType),
sql`${gardenCareLogs.performedAt} > now() - interval '10 minutes'`,
),
)
.limit(1);
if (recentLog[0]) return;
await db.insert(gardenCareLogs).values({
plantId,
householdId: plant.householdId,
careType,
performedBy: userId,
notes: "Logged via task list",
performedAt: new Date(),
});
});
export default gardenManifest;