Fixed add timeout on compact view.

This commit is contained in:
Brian Fertig 2026-08-31 14:56:31 -06:00
parent a4c20a8e43
commit 4d9f412a1f
3 changed files with 84 additions and 17 deletions

View File

@ -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();

View File

@ -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 }) {
<AddTodoMenu
group={group}
aiConfigured={aiConfigured}
onAddClick={() => setTodoCreateOpen(true)}
onAiClick={() => setTodoAiOpen(true)}
onAddClick={() => handleAddTodoWindow(true, setTodoCreateOpen, todoAiOpen)}
onAiClick={() => handleAddTodoWindow(true, setTodoAiOpen, todoCreateOpen)}
/>
)}
</div>
@ -362,8 +395,8 @@ export function GroupCard({ group }: { group: GroupDTO }) {
<div className="mt-1 flex justify-end">
<AddTodoMenu
group={group}
onAddClick={() => setTodoCreateOpen(true)}
onAiClick={() => setTodoAiOpen(true)}
onAddClick={() => handleAddTodoWindow(true, setTodoCreateOpen, todoAiOpen)}
onAiClick={() => handleAddTodoWindow(true, setTodoAiOpen, todoCreateOpen)}
aiConfigured={aiConfigured}
/>
</div>
@ -447,13 +480,17 @@ export function GroupCard({ group }: { group: GroupDTO }) {
<EditGroupDialog group={group} open={editOpen} onOpenChange={setEditOpen} />
<TodoAiDialog group={group} open={todoAiOpen} onOpenChange={setTodoAiOpen} />
<TodoAiDialog
group={group}
open={todoAiOpen}
onOpenChange={(next) => handleAddTodoWindow(next, setTodoAiOpen, todoCreateOpen)}
/>
<TodoCreateDialog
groupId={group.id}
categoryId={group.categoryId}
open={todoCreateOpen}
onOpenChange={setTodoCreateOpen}
onOpenChange={(next) => handleAddTodoWindow(next, setTodoCreateOpen, todoAiOpen)}
/>
<StatusUpdateDialog group={group} open={statusUpdateOpen} onOpenChange={setStatusUpdateOpen} />

View File

@ -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<HoldOnCompleteContextValue | null>(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 (
<HoldOnCompleteContext.Provider value={{ holds, beginHold, cancelHold }}>
<HoldOnCompleteContext.Provider value={{ holds, beginHold, cancelHold, pauseHold }}>
{children}
</HoldOnCompleteContext.Provider>
);