feat: garden calendar + lists integrations (task 74)

- calendar-bridge.ts and lists-bridge.ts isolate cross-module deps
- scheduleOnCalendar action: creates event from care schedule with 30-min
  slot, title convention (Water/Fertilize/Repot/Prune/custom), reminder opt
- pushOverdueToTaskList action: inserts one task per overdue (plant+schedule)
  pair into the default task list, skips duplicate text entries
- CareScheduleEditor: per-row calendar 📅 button opens inline form with
  calendar select + optional reminder minutes + success link to /calendar
- Garden page header: PushOverdueButton with auto-dismissing feedback toast
This commit is contained in:
ginnoir
2026-06-01 20:33:15 -05:00
parent eabcebdb00
commit 0b0deff700
8 changed files with 322 additions and 43 deletions
+114 -1
View File
@@ -1,6 +1,6 @@
"use server";
import { and, eq } from "drizzle-orm";
import { and, eq, lte, sql } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { db } from "@/lib/db";
@@ -8,7 +8,9 @@ import { getCurrentSession } from "@/lib/session";
import { logActivity } from "@/modules/_core/activity";
import { cancelReminder, scheduleReminder } from "@/modules/_core/reminders";
import { gardenCareLogs, gardenCareSchedules, gardenContainers, gardenPlants } from "../schema";
import { createCalendarEvent } from "./calendar-bridge";
import { updateScheduleAfterCare } from "./care-schedule";
import { addListItem, getList, listLists } from "./lists-bridge";
const containerInput = z.object({
name: z.string().trim().min(1).max(120),
@@ -481,3 +483,114 @@ export async function toggleCareSchedule(input: { id: string; enabled: boolean }
revalidatePath(`/garden/plants/${row.plantId}`);
}
// ─── 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(),
reminderMinutesBefore: z.number().int().min(0).max(1440).optional(),
});
export async function scheduleOnCalendar(input: z.input<typeof scheduleOnCalendarInput>) {
const parsed = scheduleOnCalendarInput.parse(input);
const { household } = await getCurrentSession();
const [row] = await db
.select({
nextDueAt: gardenCareSchedules.nextDueAt,
careType: gardenCareSchedules.careType,
plantName: gardenPlants.name,
})
.from(gardenCareSchedules)
.innerJoin(gardenPlants, eq(gardenCareSchedules.plantId, gardenPlants.id))
.where(
and(
eq(gardenCareSchedules.id, parsed.scheduleId),
eq(gardenCareSchedules.householdId, household.id),
),
)
.limit(1);
if (!row) throw new Error("Forbidden");
if (!row.nextDueAt) throw new Error("This schedule has no upcoming due date set.");
const startAt = row.nextDueAt;
const endAt = new Date(startAt.getTime() + 30 * 60 * 1000);
const title = buildCareTitle(row.careType, row.plantName);
await createCalendarEvent({
calendarId: parsed.calendarId,
title,
startAt,
endAt,
allDay: false,
notes: `Scheduled from garden. Next due: ${startAt.toLocaleDateString()}`,
remindMinutesBefore: parsed.reminderMinutesBefore ?? null,
});
revalidatePath("/calendar");
}
// ─── Lists integration ────────────────────────────────────────────────────────
export async function pushOverdueToTaskList(): Promise<{ added: number }> {
const { household } = await getCurrentSession();
const overduePairs = 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, household.id),
eq(gardenCareSchedules.enabled, true),
lte(gardenCareSchedules.nextDueAt, sql`now()`),
),
);
if (overduePairs.length === 0) return { added: 0 };
const taskLists = await listLists({ type: "task" });
const taskList = taskLists[0];
if (!taskList) return { added: 0 };
const listDetail = await getList(taskList.id);
const existingTexts = new Set(listDetail.items.map((i) => i.text));
let added = 0;
for (const pair of overduePairs) {
const title = buildCareTitle(pair.careType, pair.plantName);
if (existingTexts.has(title)) continue;
const daysOverdue = pair.nextDueAt
? Math.abs(Math.floor((Date.now() - pair.nextDueAt.getTime()) / (1000 * 60 * 60 * 24)))
: 0;
await addListItem({
listId: taskList.id,
text: title,
notes: `Overdue by ${daysOverdue} day(s)`,
dueAt: pair.nextDueAt,
});
existingTexts.add(title);
added++;
}
return { added };
}
@@ -0,0 +1,3 @@
export { createEvent as createCalendarEvent } from "@/modules/calendar/server/actions";
export { listCalendars } from "@/modules/calendar/server/queries";
export type { CalendarDto } from "@/modules/calendar/server/queries";
@@ -0,0 +1,2 @@
export { addItem as addListItem } from "@/modules/lists/server/actions";
export { getList, listLists } from "@/modules/lists/server/queries";