"use client"; import { useCallback, useEffect, useMemo, useState } from "react"; import { usePathname } from "next/navigation"; import { toast } from "sonner"; import { AlertTriangle, Bell, BellOff, CalendarClock, ChevronLeft, ChevronRight, Plus } from "lucide-react"; import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"; 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"; import { useScheduledPanel } from "@/components/scheduled/scheduled-panel-provider"; 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, 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}`; } /** `layout.tsx` is shared by Home and every Project board, so the panel * derives its own scope from the URL rather than a prop threaded down from * a page it doesn't render (see the plan notes on why this fetches * client-side instead of taking initial data as a prop like KanbanBoard). */ function projectIdFromPathname(pathname: string): string | null { const match = /^\/projects\/([^/]+)/.exec(pathname); return match ? match[1] : null; } function matchesOccurrence(o: ScheduledOccurrenceDTO, scheduledTodoId: string, occurrenceDate: string) { 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, occurrenceDate: string, completed: boolean ): ScheduledBoardDTO { const flip = (o: ScheduledOccurrenceDTO) => matchesOccurrence(o, scheduledTodoId, occurrenceDate) ? { ...o, completed } : o; // Overdue only ever lists incomplete occurrences -- flip it here just // like the other sections, and let ExpandedPanel's own hold-aware filter // (see useHoldOnComplete) decide when a freshly-completed one actually // drops out of the list, rather than yanking it out the instant it's // checked. return { overdue: board.overdue.map(flip), today: { ...board.today, occurrences: board.today.occurrences.map(flip) }, upcoming: board.upcoming.map((day) => ({ ...day, occurrences: day.occurrences.map(flip) })), }; } export function ScheduledPanel() { const { collapsed, toggle } = useScheduledPanel(); const pathname = usePathname(); const projectId = projectIdFromPathname(pathname); const [board, setBoard] = useState(null); const [dialogOpen, setDialogOpen] = useState(false); const [editingId, setEditingId] = useState(null); // Mobile bottom sheet (the dock bar below opens it). Kept separate from // `dialogOpen` -- that one is the add/edit form dialog. const [sheetOpen, setSheetOpen] = useState(false); const { holds, beginHold, cancelHold } = useHoldOnComplete(); const refresh = useCallback(() => { getScheduledBoard(projectId) .then(setBoard) .catch(() => setBoard(null)); }, [projectId]); useEffect(() => { setBoard(null); 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 ); // Only the Overdue section actually consults these holds (see // ExpandedPanel), so it's harmless to key one for every occurrence -- // checking off a Today/Upcoming item just starts a hold nothing ever // looks at, which quietly expires on its own. const key = occurrenceKey(occurrence.scheduledTodoId, occurrence.occurrenceDate); if (completed) beginHold(key); else cancelHold(key); try { await toggleScheduledOccurrence(occurrence.scheduledTodoId, occurrence.occurrenceDate, completed); } catch { toast.error("Couldn't update scheduled to-do. Try again."); refresh(); } } function handleEdit(scheduledTodoId: string) { setEditingId(scheduledTodoId); setDialogOpen(true); } function handleAdd() { setEditingId(null); setDialogOpen(true); } // 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. 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 = displayBoard?.today.occurrences.filter((o) => !o.completed).length ?? 0; return ( <> {/* Mobile (< md) entry point: a dock bar that sits at the bottom of the app column (in-flow, so nothing overlaps it) carrying the same overdue/today urgency signals as the desktop collapsed rail. It opens the full panel as a bottom sheet below rather than dedicating permanent screen width to it. */} Scheduled to-dos {/* Grabber -- visual affordance that this is a sheet, not the whole screen. */}
setSheetOpen(false)} notificationsSupported={notifications.supported} notificationsEnabled={notifications.enabled} onToggleNotifications={handleToggleNotifications} />
); } function CollapsedRail({ overdueCount, todayCount, onAdd, onExpand, }: { overdueCount: number; todayCount: number; onAdd: () => void; onExpand: () => void; }) { return ( <>
} /> Scheduled to-dos {/* Sits right below the calendar icon rather than as a corner overlay -- a ping ring behind the solid circle makes overdue work as loud/eye-catching as possible. */} {overdueCount > 0 && ( {overdueCount > 9 ? "9+" : overdueCount} } /> {overdueCount} overdue )} {/* Below the overdue circle when both are present, otherwise right below the calendar icon -- a plain opacity pulse (no ping ring) keeps it visibly calmer than overdue's. */} {todayCount > 0 && ( {todayCount > 9 ? "9+" : todayCount} } /> {todayCount} due today )}
} /> Add Scheduled To-Do
); } function ExpandedPanel({ board, onToggleOccurrence, 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(); // A freshly-checked-off occurrence lingers here through its hold (see // useHoldOnComplete) instead of dropping out the instant it's completed; // once its hold expires it's gone for good, same as before. The header // count below counts this same list, so it never disagrees with what's // actually on screen -- unlike the collapsed rail's badge, which drops // the instant something's checked off (see ScheduledPanel's overdueCount). const visibleOverdue = board?.overdue.filter( (o) => !o.completed || holds.has(occurrenceKey(o.scheduledTodoId, o.occurrenceDate)) ) ?? []; return ( <>
Scheduled
{notificationsSupported && ( {notificationsEnabled ? ( ) : ( )} } /> {notificationsEnabled ? "Notifications on" : "Notify me when something's due"} )}
{!board ? (

Loading…

) : (
{visibleOverdue.length > 0 && (
Overdue ({visibleOverdue.length})
    {visibleOverdue.map((o) => ( onToggleOccurrence(o, completed)} onEdit={() => onEditOccurrence(o.scheduledTodoId)} /> ))}
)}

Today — {formatWeekdayDate(board.today.date)}

{board.today.occurrences.length > 0 ? (
    {board.today.occurrences.map((o) => ( onToggleOccurrence(o, completed)} onEdit={() => onEditOccurrence(o.scheduledTodoId)} /> ))}
) : (

Nothing scheduled today.

)}
{board.upcoming.map((day, index) => ( // Fades a little more for each day further from today, so // today reads as the loudest thing in the column.

{formatWeekdayDate(day.date)}

{day.occurrences.length > 0 ? (
    {day.occurrences.map((o) => ( onToggleOccurrence(o, completed)} onEdit={() => onEditOccurrence(o.scheduledTodoId)} /> ))}
) : (

Nothing scheduled.

)}
))}
)}
); }