diff --git a/components/board/add-group-popover.tsx b/components/board/add-group-popover.tsx index b994fc3..a1b91a3 100644 --- a/components/board/add-group-popover.tsx +++ b/components/board/add-group-popover.tsx @@ -10,16 +10,10 @@ 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 { NEW_GROUP_HOLD_MS, 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(); diff --git a/components/board/group-card.tsx b/components/board/group-card.tsx index 2cc8a8d..ab405c3 100644 --- a/components/board/group-card.tsx +++ b/components/board/group-card.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState } from "react"; +import { useRef, useState } from "react"; import { useSortable } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; import { @@ -32,7 +32,7 @@ import { getBrighterColor, getComplementaryColor } from "@/lib/colors"; import { isDarkTheme, useGroupColor } from "@/components/theme/use-dark-theme"; import { useBoard } from "@/components/board/board-context"; import { useBoardView } from "@/components/board/board-view-provider"; -import { useHoldOnComplete } from "@/components/hold-on-complete"; +import { NEW_GROUP_HOLD_MS, 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"; @@ -89,7 +89,7 @@ function AddTodoMenu({ export function GroupCard({ group }: { group: GroupDTO }) { const { toggleTodoDone, removeGroup, archiveGroup, aiConfigured } = useBoard(); const { view } = useBoardView(); - const { holds, beginHold, cancelHold } = useHoldOnComplete(); + const { holds, beginHold, cancelHold, pauseHold } = useHoldOnComplete(); const compact = view === "compact"; // True for any theme whose surfaces are dark -- Dark and Ocean both use // their `dark` color variant (see lib/colors.ts); light themes use `light`. @@ -101,6 +101,39 @@ export function GroupCard({ group }: { group: GroupDTO }) { const [todoCreateOpen, setTodoCreateOpen] = useState(false); const [statusUpdateOpen, setStatusUpdateOpen] = useState(false); const [expandedWhileComplete, setExpandedWhileComplete] = useState(false); + // True while one of this card's "add to-do" windows is open and we paused + // the group's own hold to keep the card (and the window mounted inside it) + // from unmounting mid-form -- a brand-new empty group in compact view is + // the case that needs it: its creation hold would otherwise expire while + // the user is still typing and close the window right out from under them. + const addTodoHoldPausedRef = useRef(false); + + // Opens or closes one of this card's "add to-do" windows (the plain + // dialog or the AI dialog). While at least one is open, a group that is + // on screen only because of its own hold (i.e. still empty) gets that + // hold frozen, so the card -- and the window mounted inside it -- can't + // be unmounted mid-form. When the last window closes, a still-empty + // group gets a fresh grace period to be noticed or filled before finally + // fading out; a group the window did fill drops the paused hold entirely + // (it stays visible for its own open work, and a leftover hold entry + // would otherwise pin it to compact view forever). + function handleAddTodoWindow( + next: boolean, + setter: (open: boolean) => void, + otherOpen: boolean + ) { + setter(next); + if (next) { + if (!otherOpen && compact && holds.has(group.id)) { + addTodoHoldPausedRef.current = true; + pauseHold(group.id); + } + } else if (!otherOpen && addTodoHoldPausedRef.current) { + addTodoHoldPausedRef.current = false; + if (group.todos.some((t) => !t.completed)) cancelHold(group.id); + else beginHold(group.id, NEW_GROUP_HOLD_MS); + } + } const { attributes, @@ -341,8 +374,8 @@ export function GroupCard({ group }: { group: GroupDTO }) { setTodoCreateOpen(true)} - onAiClick={() => setTodoAiOpen(true)} + onAddClick={() => handleAddTodoWindow(true, setTodoCreateOpen, todoAiOpen)} + onAiClick={() => handleAddTodoWindow(true, setTodoAiOpen, todoCreateOpen)} /> )} @@ -362,8 +395,8 @@ export function GroupCard({ group }: { group: GroupDTO }) {
setTodoCreateOpen(true)} - onAiClick={() => setTodoAiOpen(true)} + onAddClick={() => handleAddTodoWindow(true, setTodoCreateOpen, todoAiOpen)} + onAiClick={() => handleAddTodoWindow(true, setTodoAiOpen, todoCreateOpen)} aiConfigured={aiConfigured} />
@@ -447,13 +480,17 @@ export function GroupCard({ group }: { group: GroupDTO }) { - + handleAddTodoWindow(next, setTodoAiOpen, todoCreateOpen)} + /> handleAddTodoWindow(next, setTodoCreateOpen, todoAiOpen)} /> diff --git a/components/hold-on-complete.tsx b/components/hold-on-complete.tsx index 55cc09d..dd85b58 100644 --- a/components/hold-on-complete.tsx +++ b/components/hold-on-complete.tsx @@ -26,9 +26,20 @@ import { createContext, useCallback, useContext, useEffect, useRef, useState } f // 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"; @@ -39,6 +50,12 @@ interface HoldOnCompleteContextValue { // 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); @@ -86,6 +103,25 @@ export function HoldOnCompleteProvider({ children }: { children: React.ReactNode [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(() => { @@ -96,7 +132,7 @@ export function HoldOnCompleteProvider({ children }: { children: React.ReactNode }, []); return ( - + {children} );