Updates-and-holding-screens #8
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
Loading…
Reference in New Issue