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:
@@ -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;
|
||||
|
||||
@@ -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++;
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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 },
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user