diff --git a/app/(app)/layout.tsx b/app/(app)/layout.tsx index eff22f4..cc57b55 100644 --- a/app/(app)/layout.tsx +++ b/app/(app)/layout.tsx @@ -8,6 +8,7 @@ import { ProjectsProvider } from "@/components/projects/projects-context"; import { ScheduledPanelProvider } from "@/components/scheduled/scheduled-panel-provider"; import { ScheduledPanel } from "@/components/scheduled/scheduled-panel"; import { BoardViewProvider } from "@/components/board/board-view-provider"; +import { HoldOnCompleteProvider } from "@/components/hold-on-complete"; export default async function AppLayout({ children }: { children: React.ReactNode }) { const session = await auth(); @@ -28,11 +29,13 @@ export default async function AppLayout({ children }: { children: React.ReactNod -
- -
{children}
- -
+ +
+ +
{children}
+ +
+
diff --git a/components/board/add-group-popover.tsx b/components/board/add-group-popover.tsx index 285d217..27a65ac 100644 --- a/components/board/add-group-popover.tsx +++ b/components/board/add-group-popover.tsx @@ -9,12 +9,22 @@ import { Label } from "@/components/ui/label"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { ColorSwatchPicker } from "@/components/board/color-swatch-picker"; import { useBoard } from "@/components/board/board-context"; +import { useBoardView } from "@/components/board/board-view-provider"; +import { useHoldOnComplete } from "@/components/hold-on-complete"; import { DEFAULT_GROUP_COLOR_KEY, type GroupColorKey } from "@/lib/colors"; const TITLE_MAX = 20; +// Compact hides any group with nothing open in it -- without a hold, a +// brand-new (necessarily empty) group would never appear at all. Longer +// than the usual 6s grace period since there's no accidental click to +// forgive here; this is purely "give it a moment to be noticed / add a +// to-do to it before it disappears". +const NEW_GROUP_HOLD_MS = 15_000; export function AddGroupPopover({ categoryId }: { categoryId: string }) { const { addGroup } = useBoard(); + const { view } = useBoardView(); + const { beginHold } = useHoldOnComplete(); const [open, setOpen] = useState(false); const [title, setTitle] = useState(""); const [color, setColor] = useState(DEFAULT_GROUP_COLOR_KEY); @@ -28,6 +38,7 @@ export function AddGroupPopover({ categoryId }: { categoryId: string }) { const group = await addGroup(categoryId, trimmed, color); setPending(false); if (group) { + if (view === "compact") beginHold(group.id, NEW_GROUP_HOLD_MS); setTitle(""); setColor(DEFAULT_GROUP_COLOR_KEY); setOpen(false); diff --git a/components/board/category-lane.tsx b/components/board/category-lane.tsx index 59c04a6..9bc6ca9 100644 --- a/components/board/category-lane.tsx +++ b/components/board/category-lane.tsx @@ -19,20 +19,31 @@ import { AddGroupPopover } from "@/components/board/add-group-popover"; import { ConfirmDeleteDialog } from "@/components/confirm-delete-dialog"; import { useBoard } from "@/components/board/board-context"; import { useBoardView } from "@/components/board/board-view-provider"; +import { useHoldOnComplete } from "@/components/hold-on-complete"; import type { CategoryDTO } from "@/types/board"; export function CategoryLane({ category }: { category: CategoryDTO }) { const { removeCategory } = useBoard(); const { view } = useBoardView(); + const { holds } = useHoldOnComplete(); const [confirmOpen, setConfirmOpen] = useState(false); const isEmpty = category.groups.length === 0; // Compact hides any group with no unchecked to-dos left -- which also // covers a brand-new group with no to-dos at all, since it has zero - // unchecked ones too. + // unchecked ones too. Two things override that and force a group to stay + // visible a while longer (see useHoldOnComplete): + // - a to-do still mid-hold counts as "unchecked" here too, so checking + // off a group's very last open to-do doesn't yank the whole card (and + // its undo checkbox) out from under the fade-out. + // - a group itself can be held right after creation, so a freshly-made + // (necessarily empty) group gets a moment on screen instead of never + // appearing at all. const visibleGroups = view === "compact" - ? category.groups.filter((g) => g.todos.some((t) => !t.completed)) + ? category.groups.filter( + (g) => holds.has(g.id) || g.todos.some((t) => !t.completed || holds.has(t.id)) + ) : category.groups; // The lane itself is sortable (so lanes can be reordered), which @@ -134,9 +145,33 @@ export function CategoryLane({ category }: { category: CategoryDTO }) { )} > - {visibleGroups.map((group) => ( - - ))} + {visibleGroups.map((group) => { + // Only a group being shown *solely* because of its own hold + // (i.e. still empty) actually animates out -- once it has + // real open work it's staying for good, so it renders at + // rest even if its hold hasn't technically expired yet. + const hasOpenTodo = group.todos.some((t) => !t.completed || holds.has(t.id)); + const holdPhase = view === "compact" && !hasOpenTodo ? holds.get(group.id) : undefined; + + return ( +
+
+
+ +
+
+
+ ); + })}
diff --git a/components/board/group-card.tsx b/components/board/group-card.tsx index 6c36700..83ed246 100644 --- a/components/board/group-card.tsx +++ b/components/board/group-card.tsx @@ -31,6 +31,7 @@ import { import { getBrighterColor, getComplementaryColor, getGroupColor } from "@/lib/colors"; import { useBoard } from "@/components/board/board-context"; import { useBoardView } from "@/components/board/board-view-provider"; +import { useHoldOnComplete } from "@/components/hold-on-complete"; import { NotesDialog } from "@/components/board/notes-dialog"; import { TodoAiDialog } from "@/components/board/todo-ai-dialog"; import { TodoCreateDialog } from "@/components/board/todo-create-dialog"; @@ -87,6 +88,7 @@ function AddTodoMenu({ export function GroupCard({ group }: { group: GroupDTO }) { const { toggleTodoDone, removeGroup, archiveGroup, aiConfigured } = useBoard(); const { view } = useBoardView(); + const { holds, beginHold, cancelHold } = useHoldOnComplete(); const compact = view === "compact"; const { resolvedTheme } = useTheme(); const [editingTodo, setEditingTodo] = useState(null); @@ -141,9 +143,22 @@ export function GroupCard({ group }: { group: GroupDTO }) { // with no to-dos yet, or with any still open, isn't "finished" yet. const canArchive = group.todos.length > 0 && group.todos.every((t) => t.completed); const completedCount = group.todos.filter((t) => t.completed).length; - // Compact only ever surfaces open work -- checked-off todos are dropped - // from the list entirely rather than shown crossed-out. - const visibleTodos = compact ? group.todos.filter((t) => !t.completed) : group.todos; + // Compact surfaces open work, plus anything still mid-hold after being + // freshly checked off (see useCompactHold) -- it stays on screen, + // crossed out, through its fade before actually dropping out of the list. + const visibleTodos = compact + ? group.todos.filter((t) => !t.completed || holds.has(t.id)) + : group.todos; + // The "+" rides on the last still-open row (see isLastRow below), not + // necessarily the last row overall -- a held to-do can be lingering at + // the tail while it fades out. -1 when every visible row is mid-hold, + // which the fallback block below covers. + let lastOpenTodoIndex = -1; + if (compact) { + visibleTodos.forEach((t, i) => { + if (!t.completed) lastOpenTodoIndex = i; + }); + } // In the default view, a fully-checked-off group collapses down to just // its header (title, notes icon, kebab menu) with a disclosure triangle @@ -267,49 +282,74 @@ export function GroupCard({ group }: { group: GroupDTO }) { ); - // In compact, the "+" rides along on the last visible row - // instead of taking a row of its own -- one less line per - // group. (A compact group always has at least one unchecked - // to-do -- CategoryLane hides it otherwise -- so there's - // always a last row to put it on.) - const isLastRow = compact && index === visibleTodos.length - 1; + // In compact, the "+" rides along on the last *open* row + // instead of taking a row of its own -- a to-do mid-hold + // (see useCompactHold) can be lingering after it, fading + // out, so this isn't simply the last row overall. + const isLastRow = compact && index === lastOpenTodoIndex; + // Held to-dos are the only completed ones compact ever + // keeps mounted (see visibleTodos above); everywhere else + // this is undefined and the row renders at rest, exactly + // as before. + const holdPhase = compact ? holds.get(todo.id) : undefined; return ( -
  • - - toggleTodoDone(todo.id, group.id, group.categoryId, checked) - } - accentColor={borderColor} - isDark={isDark} - className="mt-0.5" - aria-label={`Mark "${todo.title}" ${todo.completed ? "incomplete" : "complete"}`} - /> - {todo.details ? ( - {todoButton} - ) : ( - todoButton - )} - {isLastRow && ( - setTodoCreateOpen(true)} - onAiClick={() => setTodoAiOpen(true)} - /> - )} +
  • +
    +
    + { + toggleTodoDone(todo.id, group.id, group.categoryId, checked); + if (!compact) return; + // Checking off holds it on screen for a grace + // period; unchecking -- including as a + // change-of-mind mid-hold -- cancels that hold + // outright so it snaps back to a normal open row. + if (checked) beginHold(todo.id); + else cancelHold(todo.id); + }} + accentColor={borderColor} + isDark={isDark} + className="mt-0.5" + aria-label={`Mark "${todo.title}" ${todo.completed ? "incomplete" : "complete"}`} + /> + {todo.details ? ( + {todoButton} + ) : ( + todoButton + )} + {isLastRow && ( + setTodoCreateOpen(true)} + onAiClick={() => setTodoAiOpen(true)} + /> + )} +
    +
  • ); })} {compact ? ( - // Defensive fallback -- shouldn't normally happen, since - // CategoryLane already hides a compact group with zero - // visible to-dos, but keeps the "+" reachable if that ever - // changes. - visibleTodos.length === 0 && ( + // No open row to ride on -- either every to-do here is + // mid-hold after being freshly checked off (about to clear + // the group entirely once its fade finishes), or, as a + // defensive fallback that shouldn't otherwise happen, + // CategoryLane failed to hide an already-empty group. + lastOpenTodoIndex === -1 && (
    ; + // 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(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] + ); + + // 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; +} diff --git a/components/scheduled/scheduled-panel.tsx b/components/scheduled/scheduled-panel.tsx index e1fc504..253f793 100644 --- a/components/scheduled/scheduled-panel.tsx +++ b/components/scheduled/scheduled-panel.tsx @@ -14,8 +14,13 @@ 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"; +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 @@ -38,12 +43,13 @@ function withToggledOccurrence( 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 only ever lists incomplete occurrences -- completing one - // removes it rather than leaving a checked-off item in the flashy list. - overdue: completed - ? board.overdue.filter((o) => !matchesOccurrence(o, scheduledTodoId, occurrenceDate)) - : board.overdue.map(flip), + 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) })), }; @@ -57,6 +63,7 @@ export function ScheduledPanel() { const [board, setBoard] = useState(null); const [dialogOpen, setDialogOpen] = useState(false); const [editingId, setEditingId] = useState(null); + const { beginHold, cancelHold } = useHoldOnComplete(); const refresh = useCallback(() => { getScheduledBoard(projectId) @@ -73,6 +80,13 @@ export function ScheduledPanel() { 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 { @@ -91,7 +105,10 @@ export function ScheduledPanel() { setDialogOpen(true); } - const overdueCount = board?.overdue.length ?? 0; + // 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; // 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`. @@ -244,6 +261,19 @@ function ExpandedPanel({ onAdd: () => void; onCollapse: () => 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 ( <>
    @@ -268,18 +298,19 @@ function ExpandedPanel({

    Loading…

    ) : (
    - {board.overdue.length > 0 && ( + {visibleOverdue.length > 0 && (
    - Overdue ({board.overdue.length}) + Overdue ({visibleOverdue.length})
      - {board.overdue.map((o) => ( + {visibleOverdue.map((o) => ( onToggleOccurrence(o, completed)} onEdit={() => onEditOccurrence(o.scheduledTodoId)} /> diff --git a/components/scheduled/scheduled-todo-item.tsx b/components/scheduled/scheduled-todo-item.tsx index ed098e9..8a2f13a 100644 --- a/components/scheduled/scheduled-todo-item.tsx +++ b/components/scheduled/scheduled-todo-item.tsx @@ -5,6 +5,7 @@ import { StickyNote } from "lucide-react"; import { cn } from "@/lib/utils"; import { Checkbox } from "@/components/ui/checkbox"; import { MouseFollowTooltip } from "@/components/board/mouse-follow-tooltip"; +import type { HoldPhase } from "@/components/hold-on-complete"; import type { ScheduledOccurrenceDTO } from "@/types/scheduled-todo"; /** One occurrence row -- "emphasis" is how loud this date's section reads @@ -13,11 +14,16 @@ import type { ScheduledOccurrenceDTO } from "@/types/scheduled-todo"; export function ScheduledTodoItem({ occurrence, emphasis = "normal", + holdPhase, onToggle, onEdit, }: { occurrence: ScheduledOccurrenceDTO; emphasis?: "overdue" | "today" | "normal"; + // Only ever set for Overdue rows -- see ExpandedPanel/useHoldOnComplete. + // A freshly-checked-off one rides this through "visible" -> "fading" -> + // "collapsing" before ExpandedPanel actually drops it from the list. + holdPhase?: HoldPhase; onToggle: (completed: boolean) => void; onEdit: () => void; }) { @@ -41,21 +47,31 @@ export function ScheduledTodoItem({ ); return ( -
    • - onToggle(!!checked)} - className="mt-0.5" - aria-label={`Mark "${occurrence.title}" ${occurrence.completed ? "incomplete" : "complete"}`} - /> - {occurrence.details ? ( - {titleButton} - ) : ( - titleButton - )} - {occurrence.details && ( - - )} +
    • +
      +
      + onToggle(!!checked)} + className="mt-0.5" + aria-label={`Mark "${occurrence.title}" ${occurrence.completed ? "incomplete" : "complete"}`} + /> + {occurrence.details ? ( + {titleButton} + ) : ( + titleButton + )} + {occurrence.details && ( + + )} +
      +
    • ); }