- care reminders now fire with plant-specific body ("Time to water Pothos")
via title/body columns on the reminders table (migration 0016)
- bi-directional task sync: checking off a garden-linked task list item
creates a care log; logging care from garden marks the linked task done
(list_items.metadata stores gardenPlantId + gardenCareType linkage)
- upload error response no longer leaks debug detail; error message
corrected from "5 MB" to "100 MB"
241 lines
7.9 KiB
TypeScript
241 lines
7.9 KiB
TypeScript
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 <CareDueWidget rows={rows} />;
|
|
}
|
|
|
|
async function GardenOverviewWidgetServer({ ctx }: { config: unknown; ctx: WidgetContext }) {
|
|
const stats = await getGardenOverviewStats(ctx.householdId);
|
|
return <GardenOverviewWidget stats={stats} />;
|
|
}
|
|
|
|
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 (
|
|
<div className="flex flex-col gap-3">
|
|
{d.primaryImageUrl && (
|
|
<img
|
|
src={d.primaryImageUrl}
|
|
alt=""
|
|
className="w-full max-h-48 object-cover rounded-lg"
|
|
/>
|
|
)}
|
|
<h2 className="text-xl font-bold">{d.name}</h2>
|
|
{d.scientificName && (
|
|
<p className="text-sm italic text-[var(--ink-mute)]">{d.scientificName}</p>
|
|
)}
|
|
<p className="text-sm capitalize">
|
|
{d.healthStatus} · {d.category}
|
|
</p>
|
|
{d.wateringNotes && (
|
|
<div>
|
|
<p className="text-xs font-semibold uppercase text-[var(--ink-mute)] mb-1">
|
|
Watering
|
|
</p>
|
|
<p className="text-sm">{d.wateringNotes}</p>
|
|
</div>
|
|
)}
|
|
{d.fertilizingNotes && (
|
|
<div>
|
|
<p className="text-xs font-semibold uppercase text-[var(--ink-mute)] mb-1">
|
|
Fertilizing
|
|
</p>
|
|
<p className="text-sm">{d.fertilizingNotes}</p>
|
|
</div>
|
|
)}
|
|
{d.notes && <p className="text-sm">{d.notes}</p>}
|
|
{d.images.length > 1 && (
|
|
<div className="grid grid-cols-3 gap-2 mt-1">
|
|
{d.images.slice(1).map((url) => (
|
|
<img
|
|
key={url}
|
|
src={url}
|
|
alt=""
|
|
className="w-full aspect-square object-cover rounded"
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
},
|
|
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 (
|
|
<div className="flex flex-col gap-3">
|
|
<h2 className="text-xl font-bold">{d.name}</h2>
|
|
<p className="text-sm capitalize text-[var(--ink-mute)]">{d.type}</p>
|
|
{d.locationNotes && <p className="text-sm">{d.locationNotes}</p>}
|
|
{d.plants.length > 0 && (
|
|
<div>
|
|
<p className="text-sm font-medium mb-1">Plants</p>
|
|
<ul className="text-sm space-y-1">
|
|
{d.plants.map((p) => (
|
|
<li key={p.id}>
|
|
{p.name}
|
|
{p.scientificName && (
|
|
<span className="text-[var(--ink-mute)] italic ml-1">
|
|
({p.scientificName})
|
|
</span>
|
|
)}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
},
|
|
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) => <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: [
|
|
{
|
|
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;
|