"use client"; import { createContext, useCallback, useContext, useEffect, useRef, useState } from "react"; // --- "Confirm before it's gone" hold --------------------------------------- // // Some lists drop an item from view the instant some action makes it no // longer "belong" there: checking off a compact-view to-do or an Overdue // occurrence, or -- with nothing to check off yet -- a brand-new empty // group in compact view, which would otherwise never even flash on screen. // That underlying change still applies instantly; the item's row just // sticks around a while longer before actually leaving the list, long // enough to catch an accidental tap or give a freshly-created group a // moment to be noticed. Three timed stages, each its own setTimeout so a // mid-flight cancel (an uncheck, or the group being given real content) // can stop it cleanly: // "visible" (0s-holdMs) full opacity, still on screen. // "fading" (+0s-1s) opacity eases to 0 over 1s. // "collapsing" (+1s-1.2s) height eases to 0 so the row (and // anything that only existed to contain // it) contracts around it, then it's gone. // This is presentation-only bookkeeping -- whatever underlying state a // caller is reacting to (a to-do's `completed` flag, a group merely // existing) changes immediately regardless; this only delays the point at // which a list's own filtering stops counting the item as "still here". // Ids are opaque strings, so unrelated features (a to-do id, a group id, a // scheduled occurrence's `${scheduledTodoId}-${occurrenceDate}` key) can // safely share one instance without knowing about each other. // // A hold can also be frozen mid-flight (pauseHold) and later resumed // (beginHold) or dropped (cancelHold) -- e.g. to keep a brand-new group on // screen for as long as its "add to-do" window is still open, however long // that takes. const HOLD_MS = 6_000; const FADE_MS = 1_000; const COLLAPSE_MS = 200; // The one-off grace period a brand-new (necessarily empty) group gets in // compact view -- without it, compact's "hide empty groups" rule would drop // it before it was ever seen, so this is its "moment to be noticed / have a // to-do added" window. Longer than the usual hold since there's no // accidental click to forgive here. export const NEW_GROUP_HOLD_MS = 15_000; export type HoldPhase = "visible" | "fading" | "collapsing"; interface HoldOnCompleteContextValue { holds: Map; // holdMs defaults to the standard 6s grace period; pass a longer one // (e.g. giving a freshly-created group more time to be noticed) to // override just the "visible" stage's length. beginHold: (id: string, holdMs?: number) => void; cancelHold: (id: string) => void; // Freeze a live hold in place: clears its pending stage timers and // (re)sets it to full "visible" with nothing scheduled after, so the item // stays on screen until beginHold (resume the staged fade-out) or // cancelHold (drop it) is called for it again. No-op if the id has no // live hold. pauseHold: (id: string) => void; } const HoldOnCompleteContext = createContext(null); export function HoldOnCompleteProvider({ children }: { children: React.ReactNode }) { const [holds, setHolds] = useState>(new Map()); // Per-id pending timeouts, so a cancel (or a fresh re-check restarting // the clock) can clear exactly its own stage timers and no one else's. const timers = useRef(new Map[]>()); const clearTimers = useCallback((id: string) => { for (const timer of timers.current.get(id) ?? []) clearTimeout(timer); timers.current.delete(id); }, []); const setPhase = useCallback((id: string, phase: HoldPhase | null) => { setHolds((prev) => { const next = new Map(prev); if (phase === null) next.delete(id); else next.set(id, phase); return next; }); }, []); const beginHold = useCallback( (id: string, holdMs: number = HOLD_MS) => { clearTimers(id); setPhase(id, "visible"); timers.current.set(id, [ setTimeout(() => setPhase(id, "fading"), holdMs), setTimeout(() => setPhase(id, "collapsing"), holdMs + FADE_MS), setTimeout(() => setPhase(id, null), holdMs + FADE_MS + COLLAPSE_MS), ]); }, [clearTimers, setPhase] ); // Called both to catch an accidental-check undo mid-hold and, harmlessly, // on every ordinary uncheck of an already-settled item. const cancelHold = useCallback( (id: string) => { clearTimers(id); setPhase(id, null); }, [clearTimers, setPhase] ); // Freeze an existing hold: the item snaps back (and stays) at full // "visible" opacity with no stage timers pending, so it lingers on screen // indefinitely -- call beginHold to resume the staged fade-out, or // cancelHold to drop the hold outright. const pauseHold = useCallback( (id: string) => { clearTimers(id); setHolds((prev) => { // Pausing is only meaningful for an id that's already mid-hold -- // don't invent a hold for one that isn't. if (!prev.has(id)) return prev; const next = new Map(prev); next.set(id, "visible"); return next; }); }, [clearTimers] ); // Belt-and-suspenders: drop any still-pending timers on unmount so they // don't fire setState against a gone provider. useEffect(() => { const pending = timers.current; return () => { for (const list of pending.values()) list.forEach(clearTimeout); }; }, []); return ( {children} ); } export function useHoldOnComplete() { const ctx = useContext(HoldOnCompleteContext); if (!ctx) throw new Error("useHoldOnComplete must be used within a HoldOnCompleteProvider"); return ctx; }