feat: garden care reminder bodies, task sync, upload cleanup

- 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"
This commit is contained in:
ginnoir
2026-06-01 23:58:00 -05:00
parent dd980c6932
commit 7f2c5a44dd
12 changed files with 205 additions and 28 deletions
+44
View File
@@ -1,4 +1,7 @@
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,
@@ -193,4 +196,45 @@ const gardenManifest: ModuleManifest = {
],
};
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;
+47 -13
View File
@@ -7,10 +7,13 @@ import { db } from "@/lib/db";
import { getCurrentSession } from "@/lib/session";
import { logActivity } from "@/modules/_core/activity";
import { cancelReminder, scheduleReminder } from "@/modules/_core/reminders";
import { listItems } from "@/modules/lists/schema";
import { notifyListChanged } from "@/modules/lists/server/realtime";
import { gardenCareLogs, gardenCareSchedules, gardenContainers, gardenPlants } from "../schema";
import { createCalendarEvent } from "./calendar-bridge";
import { buildCareReminderBody, buildCareTitle } from "./care-utils";
import { updateScheduleAfterCare } from "./care-schedule";
import { addListItem, getList, listLists } from "./lists-bridge";
import { addGardenCareTask, getList, listLists } from "./lists-bridge";
const containerInput = z.object({
name: z.string().trim().min(1).max(120),
@@ -347,6 +350,29 @@ export async function logCare(input: z.input<typeof careLogInput>) {
action: "update",
payload: { careType: parsed.careType },
});
// Mark linked task item done if one exists for this plant + care type
const linkedItems = await db
.select({ id: listItems.id, listId: listItems.listId })
.from(listItems)
.where(
and(
sql`${listItems.metadata}->>'gardenPlantId' = ${parsed.plantId}`,
sql`${listItems.metadata}->>'gardenCareType' = ${parsed.careType}`,
eq(listItems.done, false),
),
)
.limit(1);
if (linkedItems[0]) {
await db
.update(listItems)
.set({ done: true, updatedAt: new Date() })
.where(eq(listItems.id, linkedItems[0].id));
await notifyListChanged(linkedItems[0].listId);
revalidatePath(`/lists/${linkedItems[0].listId}`);
}
revalidatePath(`/garden/plants/${parsed.plantId}`);
return log;
}
@@ -416,6 +442,12 @@ export async function upsertCareSchedule(input: z.input<typeof careScheduleInput
if (!schedule) throw new Error("Schedule was not created");
const [plantRow] = await db
.select({ name: gardenPlants.name })
.from(gardenPlants)
.where(eq(gardenPlants.id, parsed.plantId))
.limit(1);
await cancelReminder("garden.schedule", schedule.id);
if (enabled) {
await scheduleReminder({
@@ -424,6 +456,8 @@ export async function upsertCareSchedule(input: z.input<typeof careScheduleInput
entityId: schedule.id,
fireAt: nextDueAt,
createdBy: user.id,
title: "Garden care reminder",
body: plantRow ? buildCareReminderBody(parsed.careType, plantRow.name) : undefined,
});
}
@@ -472,12 +506,20 @@ export async function toggleCareSchedule(input: { id: string; enabled: boolean }
await cancelReminder("garden.schedule", parsed.id);
if (parsed.enabled && row.nextDueAt) {
const [togglePlantRow] = await db
.select({ name: gardenPlants.name })
.from(gardenPlants)
.where(eq(gardenPlants.id, row.plantId))
.limit(1);
await scheduleReminder({
householdId: household.id,
entityType: "garden.schedule",
entityId: parsed.id,
fireAt: row.nextDueAt,
createdBy: user.id,
title: "Garden care reminder",
body: togglePlantRow ? buildCareReminderBody(row.careType, togglePlantRow.name) : undefined,
});
}
@@ -486,17 +528,6 @@ export async function toggleCareSchedule(input: { id: string; enabled: boolean }
// ─── Calendar integration ─────────────────────────────────────────────────────
function buildCareTitle(careType: string, plantName: string): string {
const verbs: Record<string, string> = {
watering: "Water",
fertilizing: "Fertilize",
repotting: "Repot",
pruning: "Prune",
};
const verb = verbs[careType];
return verb ? `${verb} ${plantName}` : `${careType}${plantName}`;
}
const scheduleOnCalendarInput = z.object({
scheduleId: z.string().uuid(),
calendarId: z.string().uuid(),
@@ -550,6 +581,7 @@ export async function pushOverdueToTaskList(): Promise<{ added: number }> {
const overduePairs = await db
.select({
plantId: gardenPlants.id,
plantName: gardenPlants.name,
careType: gardenCareSchedules.careType,
nextDueAt: gardenCareSchedules.nextDueAt,
@@ -582,11 +614,13 @@ export async function pushOverdueToTaskList(): Promise<{ added: number }> {
? Math.abs(Math.floor((Date.now() - pair.nextDueAt.getTime()) / (1000 * 60 * 60 * 24)))
: 0;
await addListItem({
await addGardenCareTask({
listId: taskList.id,
text: title,
notes: `Overdue by ${daysOverdue} day(s)`,
dueAt: pair.nextDueAt,
gardenPlantId: pair.plantId,
gardenCareType: pair.careType,
});
existingTexts.add(title);
added++;
+10 -1
View File
@@ -1,7 +1,8 @@
import { and, eq } from "drizzle-orm";
import { db } from "@/lib/db";
import { cancelReminder, scheduleReminder } from "@/modules/_core/reminders";
import { gardenCareSchedules } from "../schema";
import { gardenCareSchedules, gardenPlants } from "../schema";
import { buildCareReminderBody } from "./care-utils";
export async function updateScheduleAfterCare(
plantId: string,
@@ -27,6 +28,12 @@ export async function updateScheduleAfterCare(
.set({ lastPerformedAt: now, nextDueAt, updatedAt: now })
.where(eq(gardenCareSchedules.id, schedule.id));
const [plant] = await db
.select({ name: gardenPlants.name })
.from(gardenPlants)
.where(eq(gardenPlants.id, plantId))
.limit(1);
await cancelReminder("garden.schedule", schedule.id);
await scheduleReminder({
householdId,
@@ -34,5 +41,7 @@ export async function updateScheduleAfterCare(
entityId: schedule.id,
fireAt: nextDueAt,
createdBy: userId,
title: "Garden care reminder",
body: plant ? buildCareReminderBody(careType, plant.name) : undefined,
});
}
+16
View File
@@ -0,0 +1,16 @@
const CARE_VERBS: Record<string, string> = {
watering: "Water",
fertilizing: "Fertilize",
repotting: "Repot",
pruning: "Prune",
};
export function buildCareTitle(careType: string, plantName: string): string {
const verb = CARE_VERBS[careType];
return verb ? `${verb} ${plantName}` : `${careType}${plantName}`;
}
export function buildCareReminderBody(careType: string, plantName: string): string {
const verb = CARE_VERBS[careType]?.toLowerCase() ?? careType;
return `Time to ${verb} ${plantName}`;
}
+19
View File
@@ -1,2 +1,21 @@
export { addItem as addListItem } from "@/modules/lists/server/actions";
export { getList, listLists } from "@/modules/lists/server/queries";
import { addItem } from "@/modules/lists/server/actions";
export async function addGardenCareTask(input: {
listId: string;
text: string;
notes?: string | null;
dueAt?: Date | null;
gardenPlantId: string;
gardenCareType: string;
}) {
return addItem({
listId: input.listId,
text: input.text,
notes: input.notes ?? null,
dueAt: input.dueAt ?? null,
metadata: { gardenPlantId: input.gardenPlantId, gardenCareType: input.gardenCareType },
});
}