From 304d750ec5fc231c3416ede494e6fd9718406a87 Mon Sep 17 00:00:00 2001 From: Brian Fertig Date: Fri, 28 Aug 2026 13:19:25 -0600 Subject: [PATCH] Reminders and Notifications -- on scheduled to dos --- components/scheduled/scheduled-panel.tsx | 153 ++++++++++++++++-- .../scheduled/scheduled-todo-dialog.tsx | 101 ++++++++++-- components/scheduled/scheduled-todo-item.tsx | 11 ++ .../scheduled/use-scheduled-notifications.ts | 114 +++++++++++++ lib/actions/scheduled-todos.ts | 48 +++++- lib/scheduled-todos.ts | 2 + lib/time-of-day.ts | 65 ++++++++ lib/validation/scheduled-todo.ts | 9 ++ .../migration.sql | 2 + .../migration.sql | 7 + prisma/schema.prisma | 44 +++-- types/scheduled-todo.ts | 11 ++ 12 files changed, 521 insertions(+), 46 deletions(-) create mode 100644 components/scheduled/use-scheduled-notifications.ts create mode 100644 lib/time-of-day.ts create mode 100644 prisma/migrations/20260828000000_add_scheduled_todo_time_due/migration.sql create mode 100644 prisma/migrations/20260828010000_add_scheduled_todo_remind_minutes_before/migration.sql diff --git a/components/scheduled/scheduled-panel.tsx b/components/scheduled/scheduled-panel.tsx index 7d48dd5..6b73216 100644 --- a/components/scheduled/scheduled-panel.tsx +++ b/components/scheduled/scheduled-panel.tsx @@ -1,12 +1,13 @@ "use client"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { usePathname } from "next/navigation"; import { toast } from "sonner"; -import { AlertTriangle, CalendarClock, ChevronLeft, ChevronRight, Plus } from "lucide-react"; +import { AlertTriangle, Bell, BellOff, CalendarClock, ChevronLeft, ChevronRight, Plus } from "lucide-react"; import { cn } from "@/lib/utils"; import { formatWeekdayDate } from "@/lib/format"; +import { isTimeDuePast } from "@/lib/time-of-day"; import { Button } from "@/components/ui/button"; import { Separator } from "@/components/ui/separator"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; @@ -14,8 +15,14 @@ import { useScheduledPanel } from "@/components/scheduled/scheduled-panel-provid import { ScheduledTodoItem } from "@/components/scheduled/scheduled-todo-item"; import { ScheduledTodoDialog } from "@/components/scheduled/scheduled-todo-dialog"; import { getScheduledBoard, toggleScheduledOccurrence } from "@/lib/actions/scheduled-todos"; -import { useHoldOnComplete } from "@/components/hold-on-complete"; -import type { ScheduledBoardDTO, ScheduledOccurrenceDTO } from "@/types/scheduled-todo"; +import { useHoldOnComplete, type HoldPhase } from "@/components/hold-on-complete"; +import { useScheduledNotifications } from "@/components/scheduled/use-scheduled-notifications"; +import type { ScheduledBoardDTO, ScheduledDayDTO, ScheduledOccurrenceDTO } from "@/types/scheduled-todo"; + +// How often the client re-checks whether any of today's occurrences have +// crossed their time due -- fine-grained enough that "overdue" shows up +// within a minute of actually being overdue, without polling constantly. +const TIME_DUE_CHECK_INTERVAL_MS = 60_000; function occurrenceKey(scheduledTodoId: string, occurrenceDate: string) { return `${scheduledTodoId}-${occurrenceDate}`; @@ -34,6 +41,38 @@ function matchesOccurrence(o: ScheduledOccurrenceDTO, scheduledTodoId: string, o return o.scheduledTodoId === scheduledTodoId && o.occurrenceDate === occurrenceDate; } +/** + * Promotes today's own occurrences into Overdue once their time due has + * passed, without waiting for the date to roll over -- the server only + * buckets by date (it doesn't know the viewer's timezone), so same-day + * time comparisons happen here against the browser's own clock instead. + * `holds` keeps a freshly-checked-off one here through its fade, same as + * ExpandedPanel's own visibleOverdue filter does for "real" overdue rows + * (see useHoldOnComplete) -- once the hold ends it settles back into + * `remaining` for good, indistinguishable from any other completed + * Today item. + */ +function splitTodayByTimeDue( + today: ScheduledDayDTO, + now: Date, + holds: Map +): { overdueToday: ScheduledOccurrenceDTO[]; remaining: ScheduledOccurrenceDTO[] } { + const overdueToday: ScheduledOccurrenceDTO[] = []; + const remaining: ScheduledOccurrenceDTO[] = []; + + for (const o of today.occurrences) { + const pastDue = !!o.timeDue && isTimeDuePast(today.date, o.timeDue, now); + const key = occurrenceKey(o.scheduledTodoId, o.occurrenceDate); + if (pastDue && (!o.completed || holds.has(key))) { + overdueToday.push(o); + } else { + remaining.push(o); + } + } + + return { overdueToday, remaining }; +} + function withToggledOccurrence( board: ScheduledBoardDTO, scheduledTodoId: string, @@ -63,7 +102,7 @@ export function ScheduledPanel() { const [board, setBoard] = useState(null); const [dialogOpen, setDialogOpen] = useState(false); const [editingId, setEditingId] = useState(null); - const { beginHold, cancelHold } = useHoldOnComplete(); + const { holds, beginHold, cancelHold } = useHoldOnComplete(); const refresh = useCallback(() => { getScheduledBoard(projectId) @@ -76,6 +115,50 @@ export function ScheduledPanel() { refresh(); }, [refresh]); + // Ticks on its own so a today occurrence with a time due crosses into + // Overdue live, without needing a click or a page refresh to notice. + const [now, setNow] = useState(() => new Date()); + useEffect(() => { + const id = setInterval(() => setNow(new Date()), TIME_DUE_CHECK_INTERVAL_MS); + return () => clearInterval(id); + }, []); + + // The board actually rendered -- board.today with anything past its time + // due moved into board.overdue (see splitTodayByTimeDue). Recomputed from + // the raw board on every render rather than stored, so toggling an + // occurrence or the clock ticking forward both stay in sync automatically. + const displayBoard = useMemo(() => { + if (!board) return null; + const { overdueToday, remaining } = splitTodayByTimeDue(board.today, now, holds); + return { + ...board, + overdue: [...board.overdue, ...overdueToday], + today: { ...board.today, occurrences: remaining }, + }; + }, [board, now, holds]); + + // Fed the raw board (not displayBoard) -- it does its own pastDue check + // against board.today directly, independent of the hold-aware promotion + // above. + const notifications = useScheduledNotifications(board, now); + + async function handleToggleNotifications() { + if (notifications.enabled) { + notifications.disable(); + return; + } + if (notifications.permission === "denied") { + toast.error("Notifications are blocked for this site -- enable them in your browser's site settings."); + return; + } + const granted = await notifications.requestEnable(); + if (granted) { + toast.success("You'll get a notification when a scheduled to-do's time due passes."); + } else { + toast.error("Notification permission wasn't granted."); + } + } + async function handleToggle(occurrence: ScheduledOccurrenceDTO, completed: boolean) { setBoard((prev) => prev ? withToggledOccurrence(prev, occurrence.scheduledTodoId, occurrence.occurrenceDate, completed) : prev @@ -107,12 +190,14 @@ export function ScheduledPanel() { // Reflects true remaining work, not the hold-delayed view -- the // collapsed rail's badge should drop the instant something's checked off, - // even while Overdue's own list still shows it fading out. - const overdueCount = board?.overdue.filter((o) => !o.completed).length ?? 0; + // even while Overdue's own list still shows it fading out. Counted off + // displayBoard so a today item past its time due counts as overdue here + // too, not as still-due-today. + const overdueCount = displayBoard?.overdue.filter((o) => !o.completed).length ?? 0; // Today's occurrences list includes already-checked-off ones (so they can // still be toggled back), unlike overdue -- so this needs its own filter // rather than just `board.today.occurrences.length`. - const todayCount = board?.today.occurrences.filter((o) => !o.completed).length ?? 0; + const todayCount = displayBoard?.today.occurrences.filter((o) => !o.completed).length ?? 0; return ( <> @@ -131,11 +216,14 @@ export function ScheduledPanel() { /> ) : ( )} @@ -254,12 +342,18 @@ function ExpandedPanel({ onEditOccurrence, onAdd, onCollapse, + notificationsSupported, + notificationsEnabled, + onToggleNotifications, }: { board: ScheduledBoardDTO | null; onToggleOccurrence: (occurrence: ScheduledOccurrenceDTO, completed: boolean) => void; onEditOccurrence: (scheduledTodoId: string) => void; onAdd: () => void; onCollapse: () => void; + notificationsSupported: boolean; + notificationsEnabled: boolean; + onToggleNotifications: () => void; }) { const { holds } = useHoldOnComplete(); @@ -283,14 +377,39 @@ function ExpandedPanel({ Scheduled - +
+ {notificationsSupported && ( + + + {notificationsEnabled ? ( + + ) : ( + + )} + + } + /> + + {notificationsEnabled ? "Notifications on" : "Notify me when something's due"} + + + )} + +
diff --git a/components/scheduled/scheduled-todo-dialog.tsx b/components/scheduled/scheduled-todo-dialog.tsx index d66e307..2a7eb3f 100644 --- a/components/scheduled/scheduled-todo-dialog.tsx +++ b/components/scheduled/scheduled-todo-dialog.tsx @@ -2,7 +2,7 @@ import { useEffect, useState } from "react"; import { toast } from "sonner"; -import { Trash2 } from "lucide-react"; +import { Trash2, X } from "lucide-react"; import { cn } from "@/lib/utils"; import { Button } from "@/components/ui/button"; @@ -33,6 +33,7 @@ import { updateScheduledTodo, } from "@/lib/actions/scheduled-todos"; import type { RecurrenceInput } from "@/lib/validation/scheduled-todo"; +import { REMIND_OPTIONS } from "@/lib/time-of-day"; const TITLE_MAX = 100; const WEEKDAY_LABELS = ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"]; @@ -74,6 +75,14 @@ export function ScheduledTodoDialog({ const [title, setTitle] = useState(""); const [details, setDetails] = useState(""); const [startDate, setStartDate] = useState(todayDateKey()); + // "HH:MM" (24-hour) or "" for no time due -- exactly what reads and writes, so it needs no reformatting either way. + const [timeDue, setTimeDue] = useState(""); + // Only meaningful (and only shown, enabled) while timeDue is set -- + // defaults to "When due" the moment a time gets set, since that's what a + // to-do with a time due already did before "Remind me" existed. Null = + // "Don't remind me", picked explicitly. + const [remindMinutesBefore, setRemindMinutesBefore] = useState(0); const [isRecurring, setIsRecurring] = useState(false); const [frequency, setFrequency] = useState("WEEKLY"); const [interval, setInterval] = useState(1); @@ -95,6 +104,8 @@ export function ScheduledTodoDialog({ setTitle(""); setDetails(""); setStartDate(todayDateKey()); + setTimeDue(""); + setRemindMinutesBefore(0); setIsRecurring(false); setFrequency("WEEKLY"); setInterval(1); @@ -113,6 +124,8 @@ export function ScheduledTodoDialog({ setTitle(todo.title); setDetails(todo.details ?? ""); setStartDate(todo.startDate); + setTimeDue(todo.timeDue ?? ""); + setRemindMinutesBefore(todo.timeDue ? todo.remindMinutesBefore : 0); const recurrence = todo.recurrence; setIsRecurring(!!recurrence); setFrequency(recurrence?.frequency ?? "WEEKLY"); @@ -170,11 +183,20 @@ export function ScheduledTodoDialog({ details: details.trim() || null, startDate, recurrence: buildRecurrence() ?? null, + timeDue: timeDue || null, + remindMinutesBefore: timeDue ? remindMinutesBefore : null, }); } else { await createScheduledTodo( { projectId }, - { title: trimmedTitle, details: details.trim() || undefined, startDate, recurrence: buildRecurrence() } + { + title: trimmedTitle, + details: details.trim() || undefined, + startDate, + recurrence: buildRecurrence(), + timeDue: timeDue || undefined, + remindMinutesBefore: timeDue ? remindMinutesBefore : null, + } ); } onSaved(); @@ -235,17 +257,72 @@ export function ScheduledTodoDialog({ /> +
+
+ + setStartDate(e.target.value)} + required + /> +
+
+ +
+ {/* Native time input: its own picker already steps by + 15 minutes (`step`) and it's freely typable, so no + custom combobox is needed for either requirement. */} + setTimeDue(e.target.value)} + /> + {timeDue && ( + + )} +
+
+
+
- - setStartDate(e.target.value)} - required - /> + + + {!timeDue && ( +

Set a time due to enable reminders.

+ )}