Organize/components/hold-on-complete.tsx

110 lines
4.4 KiB
TypeScript

"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.
const HOLD_MS = 6_000;
const FADE_MS = 1_000;
const COLLAPSE_MS = 200;
export type HoldPhase = "visible" | "fading" | "collapsing";
interface HoldOnCompleteContextValue {
holds: Map<string, HoldPhase>;
// 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;
}
const HoldOnCompleteContext = createContext<HoldOnCompleteContextValue | null>(null);
export function HoldOnCompleteProvider({ children }: { children: React.ReactNode }) {
const [holds, setHolds] = useState<Map<string, HoldPhase>>(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<string, ReturnType<typeof setTimeout>[]>());
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]
);
// 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 (
<HoldOnCompleteContext.Provider value={{ holds, beginHold, cancelHold }}>
{children}
</HoldOnCompleteContext.Provider>
);
}
export function useHoldOnComplete() {
const ctx = useContext(HoldOnCompleteContext);
if (!ctx) throw new Error("useHoldOnComplete must be used within a HoldOnCompleteProvider");
return ctx;
}