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