From 0b0deff700c92a2ee4d9fa6564245d3527e5a319 Mon Sep 17 00:00:00 2001 From: ginnoir Date: Mon, 1 Jun 2026 20:33:15 -0500 Subject: [PATCH] feat: garden calendar + lists integrations (task 74) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- src/app/garden/page.tsx | 6 +- src/app/garden/plants/[id]/page.tsx | 11 +- .../components/care-schedule-editor.tsx | 175 ++++++++++++++---- .../garden/components/plant-detail.tsx | 6 +- .../garden/components/push-overdue-button.tsx | 47 +++++ src/modules/garden/server/actions.ts | 115 +++++++++++- src/modules/garden/server/calendar-bridge.ts | 3 + src/modules/garden/server/lists-bridge.ts | 2 + 8 files changed, 322 insertions(+), 43 deletions(-) create mode 100644 src/modules/garden/components/push-overdue-button.tsx create mode 100644 src/modules/garden/server/calendar-bridge.ts create mode 100644 src/modules/garden/server/lists-bridge.ts diff --git a/src/app/garden/page.tsx b/src/app/garden/page.tsx index fbe29df..cac04dc 100644 --- a/src/app/garden/page.tsx +++ b/src/app/garden/page.tsx @@ -2,6 +2,7 @@ import Link from "next/link"; import { listContainers, listPlants } from "@/modules/garden/server/queries"; import { ContainerList } from "@/modules/garden/components/container-list"; import { PlantList } from "@/modules/garden/components/plant-list"; +import { PushOverdueButton } from "@/modules/garden/components/push-overdue-button"; type Props = { searchParams: Promise<{ tab?: string }>; @@ -18,7 +19,10 @@ export default async function GardenPage({ searchParams }: Props) { return (
-

Garden

+
+

Garden

+ +
}) { const { id } = await params; - const [plant, careLogs, careSchedules] = await Promise.all([ + const [plant, careLogs, careSchedules, calendars] = await Promise.all([ getPlant(id), getCareLogs(id), getCareSchedules(id), + listCalendars(), ]); if (!plant) notFound(); return (
- +
); } diff --git a/src/modules/garden/components/care-schedule-editor.tsx b/src/modules/garden/components/care-schedule-editor.tsx index 4ed154f..3b59120 100644 --- a/src/modules/garden/components/care-schedule-editor.tsx +++ b/src/modules/garden/components/care-schedule-editor.tsx @@ -1,8 +1,15 @@ "use client"; +import Link from "next/link"; import { useState, useTransition } from "react"; import { useRouter } from "next/navigation"; -import { deleteCareSchedule, toggleCareSchedule, upsertCareSchedule } from "../server/actions"; +import { + deleteCareSchedule, + scheduleOnCalendar, + toggleCareSchedule, + upsertCareSchedule, +} from "../server/actions"; +import type { CalendarDto } from "../server/calendar-bridge"; import type { CareScheduleDto } from "../server/queries"; const CARE_TYPES = ["watering", "fertilizing", "repotting", "pruning", "pest-control", "other"]; @@ -10,6 +17,7 @@ const CARE_TYPES = ["watering", "fertilizing", "repotting", "pruning", "pest-con type Props = { plantId: string; schedules: CareScheduleDto[]; + calendars: CalendarDto[]; }; function daysLabel(n: number | null): string { @@ -19,11 +27,15 @@ function daysLabel(n: number | null): string { return `Due in ${n} day${n !== 1 ? "s" : ""}`; } -export function CareScheduleEditor({ plantId, schedules }: Props) { +export function CareScheduleEditor({ plantId, schedules, calendars }: Props) { const [showForm, setShowForm] = useState(false); const [careType, setCareType] = useState("watering"); const [intervalDays, setIntervalDays] = useState("7"); const [formError, setFormError] = useState(null); + const [calendarOpenId, setCalendarOpenId] = useState(null); + const [selectedCalendarId, setSelectedCalendarId] = useState(calendars[0]?.id ?? ""); + const [reminderMinutes, setReminderMinutes] = useState(""); + const [calendarSuccess, setCalendarSuccess] = useState(null); const [isPending, startTransition] = useTransition(); const router = useRouter(); @@ -62,6 +74,24 @@ export function CareScheduleEditor({ plantId, schedules }: Props) { }); } + function handleScheduleOnCalendar(scheduleId: string) { + if (!selectedCalendarId) return; + startTransition(async () => { + try { + await scheduleOnCalendar({ + scheduleId, + calendarId: selectedCalendarId, + reminderMinutesBefore: reminderMinutes ? parseInt(reminderMinutes, 10) : undefined, + }); + setCalendarOpenId(null); + setCalendarSuccess(scheduleId); + setTimeout(() => setCalendarSuccess(null), 4000); + } catch (err) { + setFormError(err instanceof Error ? err.message : "Failed to add to calendar."); + } + }); + } + return (
@@ -128,42 +158,113 @@ export function CareScheduleEditor({ plantId, schedules }: Props) { )} {schedules.map((s) => ( -
-
- {s.careType} - - Every {s.intervalDays} days Β· {daysLabel(s.daysUntilDue)} - -
-
- - +
+
+
+ {s.careType} + + Every {s.intervalDays} days Β· {daysLabel(s.daysUntilDue)} + +
+
+ {calendars.length > 0 && s.nextDueAt && ( + + )} + + +
+ + {calendarOpenId === s.id && ( +
+
+
+ + +
+
+ + setReminderMinutes(e.target.value)} + className="input input-xs" + /> +
+
+
+ + +
+
+ )} + + {calendarSuccess === s.id && ( +

+ Event added to calendar.{" "} + + View in calendar β†’ + +

+ )}
))}
diff --git a/src/modules/garden/components/plant-detail.tsx b/src/modules/garden/components/plant-detail.tsx index 5f3f3ab..d6c31ce 100644 --- a/src/modules/garden/components/plant-detail.tsx +++ b/src/modules/garden/components/plant-detail.tsx @@ -4,6 +4,7 @@ import { useState, useTransition } from "react"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { deletePlant, addPlantImage, removePlantImage, setPrimaryImage } from "../server/actions"; +import type { CalendarDto } from "../server/calendar-bridge"; import type { CareLogDto, CareScheduleDto, PlantDetailDto } from "../server/queries"; import { CareHistoryList } from "./care-history-list"; import { CareLogForm } from "./care-log-form"; @@ -15,6 +16,7 @@ type Props = { plant: PlantDetailDto; careLogs: CareLogDto[]; careSchedules: CareScheduleDto[]; + calendars: CalendarDto[]; }; function InfoRow({ @@ -41,7 +43,7 @@ function healthBadgeClass(status: string): string { return "badge-warning"; } -export function PlantDetail({ plant, careLogs, careSchedules }: Props) { +export function PlantDetail({ plant, careLogs, careSchedules, calendars }: Props) { const [tab, setTab] = useState("info"); const [confirming, setConfirming] = useState(false); const [isPending, startTransition] = useTransition(); @@ -260,7 +262,7 @@ export function PlantDetail({ plant, careLogs, careSchedules }: Props) { {/* Care */} {tab === "care" && (
- +

Log care diff --git a/src/modules/garden/components/push-overdue-button.tsx b/src/modules/garden/components/push-overdue-button.tsx new file mode 100644 index 0000000..884fb37 --- /dev/null +++ b/src/modules/garden/components/push-overdue-button.tsx @@ -0,0 +1,47 @@ +"use client"; + +import { useEffect, useState, useTransition } from "react"; +import { pushOverdueToTaskList } from "../server/actions"; + +export function PushOverdueButton() { + const [message, setMessage] = useState(null); + const [isError, setIsError] = useState(false); + const [isPending, startTransition] = useTransition(); + + useEffect(() => { + if (!message) return; + const id = setTimeout(() => setMessage(null), 4000); + return () => clearTimeout(id); + }, [message]); + + function handleClick() { + setMessage(null); + setIsError(false); + startTransition(async () => { + try { + const { added } = await pushOverdueToTaskList(); + setMessage( + added === 0 + ? "No overdue care tasks." + : `Added ${added} task${added !== 1 ? "s" : ""} to your task list.`, + ); + } catch { + setIsError(true); + setMessage("Failed to push tasks. Please try again."); + } + }); + } + + return ( +

+ + {message && ( + + {message} + + )} +
+ ); +} diff --git a/src/modules/garden/server/actions.ts b/src/modules/garden/server/actions.ts index 1f67f26..a16876a 100644 --- a/src/modules/garden/server/actions.ts +++ b/src/modules/garden/server/actions.ts @@ -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 = { + 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) { + 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 }; +} diff --git a/src/modules/garden/server/calendar-bridge.ts b/src/modules/garden/server/calendar-bridge.ts new file mode 100644 index 0000000..4598232 --- /dev/null +++ b/src/modules/garden/server/calendar-bridge.ts @@ -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"; diff --git a/src/modules/garden/server/lists-bridge.ts b/src/modules/garden/server/lists-bridge.ts new file mode 100644 index 0000000..b96676d --- /dev/null +++ b/src/modules/garden/server/lists-bridge.ts @@ -0,0 +1,2 @@ +export { addItem as addListItem } from "@/modules/lists/server/actions"; +export { getList, listLists } from "@/modules/lists/server/queries";