Fixed add timeout on compact view.
This commit is contained in:
parent
a4c20a8e43
commit
4d9f412a1f
|
|
@ -10,16 +10,10 @@ import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover
|
||||||
import { ColorSwatchPicker } from "@/components/board/color-swatch-picker";
|
import { ColorSwatchPicker } from "@/components/board/color-swatch-picker";
|
||||||
import { useBoard } from "@/components/board/board-context";
|
import { useBoard } from "@/components/board/board-context";
|
||||||
import { useBoardView } from "@/components/board/board-view-provider";
|
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";
|
import { DEFAULT_GROUP_COLOR_KEY, type GroupColorKey } from "@/lib/colors";
|
||||||
|
|
||||||
const TITLE_MAX = 20;
|
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 }) {
|
export function AddGroupPopover({ categoryId }: { categoryId: string }) {
|
||||||
const { addGroup } = useBoard();
|
const { addGroup } = useBoard();
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
import { useSortable } from "@dnd-kit/sortable";
|
import { useSortable } from "@dnd-kit/sortable";
|
||||||
import { CSS } from "@dnd-kit/utilities";
|
import { CSS } from "@dnd-kit/utilities";
|
||||||
import {
|
import {
|
||||||
|
|
@ -32,7 +32,7 @@ import { getBrighterColor, getComplementaryColor } from "@/lib/colors";
|
||||||
import { isDarkTheme, useGroupColor } from "@/components/theme/use-dark-theme";
|
import { isDarkTheme, useGroupColor } from "@/components/theme/use-dark-theme";
|
||||||
import { useBoard } from "@/components/board/board-context";
|
import { useBoard } from "@/components/board/board-context";
|
||||||
import { useBoardView } from "@/components/board/board-view-provider";
|
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 { NotesDialog } from "@/components/board/notes-dialog";
|
||||||
import { TodoAiDialog } from "@/components/board/todo-ai-dialog";
|
import { TodoAiDialog } from "@/components/board/todo-ai-dialog";
|
||||||
import { TodoCreateDialog } from "@/components/board/todo-create-dialog";
|
import { TodoCreateDialog } from "@/components/board/todo-create-dialog";
|
||||||
|
|
@ -89,7 +89,7 @@ function AddTodoMenu({
|
||||||
export function GroupCard({ group }: { group: GroupDTO }) {
|
export function GroupCard({ group }: { group: GroupDTO }) {
|
||||||
const { toggleTodoDone, removeGroup, archiveGroup, aiConfigured } = useBoard();
|
const { toggleTodoDone, removeGroup, archiveGroup, aiConfigured } = useBoard();
|
||||||
const { view } = useBoardView();
|
const { view } = useBoardView();
|
||||||
const { holds, beginHold, cancelHold } = useHoldOnComplete();
|
const { holds, beginHold, cancelHold, pauseHold } = useHoldOnComplete();
|
||||||
const compact = view === "compact";
|
const compact = view === "compact";
|
||||||
// True for any theme whose surfaces are dark -- Dark and Ocean both use
|
// 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`.
|
// 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 [todoCreateOpen, setTodoCreateOpen] = useState(false);
|
||||||
const [statusUpdateOpen, setStatusUpdateOpen] = useState(false);
|
const [statusUpdateOpen, setStatusUpdateOpen] = useState(false);
|
||||||
const [expandedWhileComplete, setExpandedWhileComplete] = 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 {
|
const {
|
||||||
attributes,
|
attributes,
|
||||||
|
|
@ -341,8 +374,8 @@ export function GroupCard({ group }: { group: GroupDTO }) {
|
||||||
<AddTodoMenu
|
<AddTodoMenu
|
||||||
group={group}
|
group={group}
|
||||||
aiConfigured={aiConfigured}
|
aiConfigured={aiConfigured}
|
||||||
onAddClick={() => setTodoCreateOpen(true)}
|
onAddClick={() => handleAddTodoWindow(true, setTodoCreateOpen, todoAiOpen)}
|
||||||
onAiClick={() => setTodoAiOpen(true)}
|
onAiClick={() => handleAddTodoWindow(true, setTodoAiOpen, todoCreateOpen)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -362,8 +395,8 @@ export function GroupCard({ group }: { group: GroupDTO }) {
|
||||||
<div className="mt-1 flex justify-end">
|
<div className="mt-1 flex justify-end">
|
||||||
<AddTodoMenu
|
<AddTodoMenu
|
||||||
group={group}
|
group={group}
|
||||||
onAddClick={() => setTodoCreateOpen(true)}
|
onAddClick={() => handleAddTodoWindow(true, setTodoCreateOpen, todoAiOpen)}
|
||||||
onAiClick={() => setTodoAiOpen(true)}
|
onAiClick={() => handleAddTodoWindow(true, setTodoAiOpen, todoCreateOpen)}
|
||||||
aiConfigured={aiConfigured}
|
aiConfigured={aiConfigured}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -447,13 +480,17 @@ export function GroupCard({ group }: { group: GroupDTO }) {
|
||||||
|
|
||||||
<EditGroupDialog group={group} open={editOpen} onOpenChange={setEditOpen} />
|
<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
|
<TodoCreateDialog
|
||||||
groupId={group.id}
|
groupId={group.id}
|
||||||
categoryId={group.categoryId}
|
categoryId={group.categoryId}
|
||||||
open={todoCreateOpen}
|
open={todoCreateOpen}
|
||||||
onOpenChange={setTodoCreateOpen}
|
onOpenChange={(next) => handleAddTodoWindow(next, setTodoCreateOpen, todoAiOpen)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<StatusUpdateDialog group={group} open={statusUpdateOpen} onOpenChange={setStatusUpdateOpen} />
|
<StatusUpdateDialog group={group} open={statusUpdateOpen} onOpenChange={setStatusUpdateOpen} />
|
||||||
|
|
|
||||||
|
|
@ -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
|
// Ids are opaque strings, so unrelated features (a to-do id, a group id, a
|
||||||
// scheduled occurrence's `${scheduledTodoId}-${occurrenceDate}` key) can
|
// scheduled occurrence's `${scheduledTodoId}-${occurrenceDate}` key) can
|
||||||
// safely share one instance without knowing about each other.
|
// 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 HOLD_MS = 6_000;
|
||||||
const FADE_MS = 1_000;
|
const FADE_MS = 1_000;
|
||||||
const COLLAPSE_MS = 200;
|
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";
|
export type HoldPhase = "visible" | "fading" | "collapsing";
|
||||||
|
|
||||||
|
|
@ -39,6 +50,12 @@ interface HoldOnCompleteContextValue {
|
||||||
// override just the "visible" stage's length.
|
// override just the "visible" stage's length.
|
||||||
beginHold: (id: string, holdMs?: number) => void;
|
beginHold: (id: string, holdMs?: number) => void;
|
||||||
cancelHold: (id: string) => 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);
|
const HoldOnCompleteContext = createContext<HoldOnCompleteContextValue | null>(null);
|
||||||
|
|
@ -86,6 +103,25 @@ export function HoldOnCompleteProvider({ children }: { children: React.ReactNode
|
||||||
[clearTimers, setPhase]
|
[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
|
// Belt-and-suspenders: drop any still-pending timers on unmount so they
|
||||||
// don't fire setState against a gone provider.
|
// don't fire setState against a gone provider.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -96,7 +132,7 @@ export function HoldOnCompleteProvider({ children }: { children: React.ReactNode
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<HoldOnCompleteContext.Provider value={{ holds, beginHold, cancelHold }}>
|
<HoldOnCompleteContext.Provider value={{ holds, beginHold, cancelHold, pauseHold }}>
|
||||||
{children}
|
{children}
|
||||||
</HoldOnCompleteContext.Provider>
|
</HoldOnCompleteContext.Provider>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue