Implement hold-on-complete animations for smooth item dismissal
This commit is contained in:
parent
938f59d10d
commit
084d458335
|
|
@ -8,6 +8,7 @@ import { ProjectsProvider } from "@/components/projects/projects-context";
|
||||||
import { ScheduledPanelProvider } from "@/components/scheduled/scheduled-panel-provider";
|
import { ScheduledPanelProvider } from "@/components/scheduled/scheduled-panel-provider";
|
||||||
import { ScheduledPanel } from "@/components/scheduled/scheduled-panel";
|
import { ScheduledPanel } from "@/components/scheduled/scheduled-panel";
|
||||||
import { BoardViewProvider } from "@/components/board/board-view-provider";
|
import { BoardViewProvider } from "@/components/board/board-view-provider";
|
||||||
|
import { HoldOnCompleteProvider } from "@/components/hold-on-complete";
|
||||||
|
|
||||||
export default async function AppLayout({ children }: { children: React.ReactNode }) {
|
export default async function AppLayout({ children }: { children: React.ReactNode }) {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
|
|
@ -28,11 +29,13 @@ export default async function AppLayout({ children }: { children: React.ReactNod
|
||||||
<ScheduledPanelProvider>
|
<ScheduledPanelProvider>
|
||||||
<ProjectsProvider initialProjects={projects}>
|
<ProjectsProvider initialProjects={projects}>
|
||||||
<BoardViewProvider>
|
<BoardViewProvider>
|
||||||
<div className="flex h-screen overflow-hidden">
|
<HoldOnCompleteProvider>
|
||||||
<SideNav userEmail={session.user.email ?? ""} role={session.user.role} />
|
<div className="flex h-screen overflow-hidden">
|
||||||
<main className="flex-1 overflow-auto">{children}</main>
|
<SideNav userEmail={session.user.email ?? ""} role={session.user.role} />
|
||||||
<ScheduledPanel />
|
<main className="flex-1 overflow-auto">{children}</main>
|
||||||
</div>
|
<ScheduledPanel />
|
||||||
|
</div>
|
||||||
|
</HoldOnCompleteProvider>
|
||||||
</BoardViewProvider>
|
</BoardViewProvider>
|
||||||
</ProjectsProvider>
|
</ProjectsProvider>
|
||||||
</ScheduledPanelProvider>
|
</ScheduledPanelProvider>
|
||||||
|
|
|
||||||
|
|
@ -9,12 +9,22 @@ import { Label } from "@/components/ui/label";
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
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 { 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();
|
||||||
|
const { view } = useBoardView();
|
||||||
|
const { beginHold } = useHoldOnComplete();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [title, setTitle] = useState("");
|
const [title, setTitle] = useState("");
|
||||||
const [color, setColor] = useState<GroupColorKey>(DEFAULT_GROUP_COLOR_KEY);
|
const [color, setColor] = useState<GroupColorKey>(DEFAULT_GROUP_COLOR_KEY);
|
||||||
|
|
@ -28,6 +38,7 @@ export function AddGroupPopover({ categoryId }: { categoryId: string }) {
|
||||||
const group = await addGroup(categoryId, trimmed, color);
|
const group = await addGroup(categoryId, trimmed, color);
|
||||||
setPending(false);
|
setPending(false);
|
||||||
if (group) {
|
if (group) {
|
||||||
|
if (view === "compact") beginHold(group.id, NEW_GROUP_HOLD_MS);
|
||||||
setTitle("");
|
setTitle("");
|
||||||
setColor(DEFAULT_GROUP_COLOR_KEY);
|
setColor(DEFAULT_GROUP_COLOR_KEY);
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
|
|
|
||||||
|
|
@ -19,20 +19,31 @@ import { AddGroupPopover } from "@/components/board/add-group-popover";
|
||||||
import { ConfirmDeleteDialog } from "@/components/confirm-delete-dialog";
|
import { ConfirmDeleteDialog } from "@/components/confirm-delete-dialog";
|
||||||
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 type { CategoryDTO } from "@/types/board";
|
import type { CategoryDTO } from "@/types/board";
|
||||||
|
|
||||||
export function CategoryLane({ category }: { category: CategoryDTO }) {
|
export function CategoryLane({ category }: { category: CategoryDTO }) {
|
||||||
const { removeCategory } = useBoard();
|
const { removeCategory } = useBoard();
|
||||||
const { view } = useBoardView();
|
const { view } = useBoardView();
|
||||||
|
const { holds } = useHoldOnComplete();
|
||||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||||
const isEmpty = category.groups.length === 0;
|
const isEmpty = category.groups.length === 0;
|
||||||
|
|
||||||
// Compact hides any group with no unchecked to-dos left -- which also
|
// 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
|
// 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 =
|
const visibleGroups =
|
||||||
view === "compact"
|
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;
|
: category.groups;
|
||||||
|
|
||||||
// The lane itself is sortable (so lanes can be reordered), which
|
// The lane itself is sortable (so lanes can be reordered), which
|
||||||
|
|
@ -134,9 +145,33 @@ export function CategoryLane({ category }: { category: CategoryDTO }) {
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<SortableContext items={groupIds} strategy={verticalListSortingStrategy}>
|
<SortableContext items={groupIds} strategy={verticalListSortingStrategy}>
|
||||||
{visibleGroups.map((group) => (
|
{visibleGroups.map((group) => {
|
||||||
<GroupCard key={group.id} group={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 (
|
||||||
|
<div
|
||||||
|
key={group.id}
|
||||||
|
className="grid transition-[grid-template-rows] duration-200 ease-in"
|
||||||
|
style={{ gridTemplateRows: holdPhase === "collapsing" ? "0fr" : "1fr" }}
|
||||||
|
>
|
||||||
|
<div className="overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="transition-opacity duration-1000 ease-in"
|
||||||
|
style={{
|
||||||
|
opacity: holdPhase === "fading" || holdPhase === "collapsing" ? 0 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<GroupCard group={group} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</SortableContext>
|
</SortableContext>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ import {
|
||||||
import { getBrighterColor, getComplementaryColor, getGroupColor } from "@/lib/colors";
|
import { getBrighterColor, getComplementaryColor, getGroupColor } from "@/lib/colors";
|
||||||
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 { 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";
|
||||||
|
|
@ -87,6 +88,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 compact = view === "compact";
|
const compact = view === "compact";
|
||||||
const { resolvedTheme } = useTheme();
|
const { resolvedTheme } = useTheme();
|
||||||
const [editingTodo, setEditingTodo] = useState<TodoDTO | null>(null);
|
const [editingTodo, setEditingTodo] = useState<TodoDTO | null>(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.
|
// 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 canArchive = group.todos.length > 0 && group.todos.every((t) => t.completed);
|
||||||
const completedCount = group.todos.filter((t) => t.completed).length;
|
const completedCount = group.todos.filter((t) => t.completed).length;
|
||||||
// Compact only ever surfaces open work -- checked-off todos are dropped
|
// Compact surfaces open work, plus anything still mid-hold after being
|
||||||
// from the list entirely rather than shown crossed-out.
|
// freshly checked off (see useCompactHold) -- it stays on screen,
|
||||||
const visibleTodos = compact ? group.todos.filter((t) => !t.completed) : group.todos;
|
// 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
|
// In the default view, a fully-checked-off group collapses down to just
|
||||||
// its header (title, notes icon, kebab menu) with a disclosure triangle
|
// its header (title, notes icon, kebab menu) with a disclosure triangle
|
||||||
|
|
@ -267,49 +282,74 @@ export function GroupCard({ group }: { group: GroupDTO }) {
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
|
|
||||||
// In compact, the "+" rides along on the last visible row
|
// In compact, the "+" rides along on the last *open* row
|
||||||
// instead of taking a row of its own -- one less line per
|
// instead of taking a row of its own -- a to-do mid-hold
|
||||||
// group. (A compact group always has at least one unchecked
|
// (see useCompactHold) can be lingering after it, fading
|
||||||
// to-do -- CategoryLane hides it otherwise -- so there's
|
// out, so this isn't simply the last row overall.
|
||||||
// always a last row to put it on.)
|
const isLastRow = compact && index === lastOpenTodoIndex;
|
||||||
const isLastRow = compact && index === visibleTodos.length - 1;
|
// 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 (
|
return (
|
||||||
<li key={todo.id} className="flex items-start gap-2 py-0.5">
|
<li
|
||||||
<TodoCheckbox
|
key={todo.id}
|
||||||
checked={todo.completed}
|
className="grid transition-[grid-template-rows] duration-200 ease-in"
|
||||||
onCheckedChange={(checked) =>
|
style={{ gridTemplateRows: holdPhase === "collapsing" ? "0fr" : "1fr" }}
|
||||||
toggleTodoDone(todo.id, group.id, group.categoryId, checked)
|
>
|
||||||
}
|
<div className="overflow-hidden">
|
||||||
accentColor={borderColor}
|
<div
|
||||||
isDark={isDark}
|
className="flex items-start gap-2 py-0.5 transition-opacity duration-1000 ease-in"
|
||||||
className="mt-0.5"
|
style={{
|
||||||
aria-label={`Mark "${todo.title}" ${todo.completed ? "incomplete" : "complete"}`}
|
opacity: holdPhase === "fading" || holdPhase === "collapsing" ? 0 : 1,
|
||||||
/>
|
}}
|
||||||
{todo.details ? (
|
>
|
||||||
<MouseFollowTooltip content={todo.details}>{todoButton}</MouseFollowTooltip>
|
<TodoCheckbox
|
||||||
) : (
|
checked={todo.completed}
|
||||||
todoButton
|
onCheckedChange={(checked) => {
|
||||||
)}
|
toggleTodoDone(todo.id, group.id, group.categoryId, checked);
|
||||||
{isLastRow && (
|
if (!compact) return;
|
||||||
<AddTodoMenu
|
// Checking off holds it on screen for a grace
|
||||||
group={group}
|
// period; unchecking -- including as a
|
||||||
aiConfigured={aiConfigured}
|
// change-of-mind mid-hold -- cancels that hold
|
||||||
onAddClick={() => setTodoCreateOpen(true)}
|
// outright so it snaps back to a normal open row.
|
||||||
onAiClick={() => setTodoAiOpen(true)}
|
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 ? (
|
||||||
|
<MouseFollowTooltip content={todo.details}>{todoButton}</MouseFollowTooltip>
|
||||||
|
) : (
|
||||||
|
todoButton
|
||||||
|
)}
|
||||||
|
{isLastRow && (
|
||||||
|
<AddTodoMenu
|
||||||
|
group={group}
|
||||||
|
aiConfigured={aiConfigured}
|
||||||
|
onAddClick={() => setTodoCreateOpen(true)}
|
||||||
|
onAiClick={() => setTodoAiOpen(true)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
{compact ? (
|
{compact ? (
|
||||||
// Defensive fallback -- shouldn't normally happen, since
|
// No open row to ride on -- either every to-do here is
|
||||||
// CategoryLane already hides a compact group with zero
|
// mid-hold after being freshly checked off (about to clear
|
||||||
// visible to-dos, but keeps the "+" reachable if that ever
|
// the group entirely once its fade finishes), or, as a
|
||||||
// changes.
|
// defensive fallback that shouldn't otherwise happen,
|
||||||
visibleTodos.length === 0 && (
|
// CategoryLane failed to hide an already-empty group.
|
||||||
|
lastOpenTodoIndex === -1 && (
|
||||||
<div className="mt-1 flex justify-end">
|
<div className="mt-1 flex justify-end">
|
||||||
<AddTodoMenu
|
<AddTodoMenu
|
||||||
group={group}
|
group={group}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,109 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { createContext, useCallback, useContext, useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
// --- "Confirm before it's gone" hold ---------------------------------------
|
||||||
|
//
|
||||||
|
// Some lists drop an item from view the instant some action makes it no
|
||||||
|
// longer "belong" there: checking off a compact-view to-do or an Overdue
|
||||||
|
// occurrence, or -- with nothing to check off yet -- a brand-new empty
|
||||||
|
// group in compact view, which would otherwise never even flash on screen.
|
||||||
|
// That underlying change still applies instantly; the item's row just
|
||||||
|
// sticks around a while longer before actually leaving the list, long
|
||||||
|
// enough to catch an accidental tap or give a freshly-created group a
|
||||||
|
// moment to be noticed. Three timed stages, each its own setTimeout so a
|
||||||
|
// mid-flight cancel (an uncheck, or the group being given real content)
|
||||||
|
// can stop it cleanly:
|
||||||
|
// "visible" (0s-holdMs) full opacity, still on screen.
|
||||||
|
// "fading" (+0s-1s) opacity eases to 0 over 1s.
|
||||||
|
// "collapsing" (+1s-1.2s) height eases to 0 so the row (and
|
||||||
|
// anything that only existed to contain
|
||||||
|
// it) contracts around it, then it's gone.
|
||||||
|
// This is presentation-only bookkeeping -- whatever underlying state a
|
||||||
|
// caller is reacting to (a to-do's `completed` flag, a group merely
|
||||||
|
// existing) changes immediately regardless; this only delays the point at
|
||||||
|
// which a list's own filtering stops counting the item as "still here".
|
||||||
|
// 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.
|
||||||
|
const HOLD_MS = 6_000;
|
||||||
|
const FADE_MS = 1_000;
|
||||||
|
const COLLAPSE_MS = 200;
|
||||||
|
|
||||||
|
export type HoldPhase = "visible" | "fading" | "collapsing";
|
||||||
|
|
||||||
|
interface HoldOnCompleteContextValue {
|
||||||
|
holds: Map<string, HoldPhase>;
|
||||||
|
// 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<HoldOnCompleteContextValue | null>(null);
|
||||||
|
|
||||||
|
export function HoldOnCompleteProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const [holds, setHolds] = useState<Map<string, HoldPhase>>(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<string, ReturnType<typeof setTimeout>[]>());
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<HoldOnCompleteContext.Provider value={{ holds, beginHold, cancelHold }}>
|
||||||
|
{children}
|
||||||
|
</HoldOnCompleteContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useHoldOnComplete() {
|
||||||
|
const ctx = useContext(HoldOnCompleteContext);
|
||||||
|
if (!ctx) throw new Error("useHoldOnComplete must be used within a HoldOnCompleteProvider");
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
|
|
@ -14,8 +14,13 @@ import { useScheduledPanel } from "@/components/scheduled/scheduled-panel-provid
|
||||||
import { ScheduledTodoItem } from "@/components/scheduled/scheduled-todo-item";
|
import { ScheduledTodoItem } from "@/components/scheduled/scheduled-todo-item";
|
||||||
import { ScheduledTodoDialog } from "@/components/scheduled/scheduled-todo-dialog";
|
import { ScheduledTodoDialog } from "@/components/scheduled/scheduled-todo-dialog";
|
||||||
import { getScheduledBoard, toggleScheduledOccurrence } from "@/lib/actions/scheduled-todos";
|
import { getScheduledBoard, toggleScheduledOccurrence } from "@/lib/actions/scheduled-todos";
|
||||||
|
import { useHoldOnComplete } from "@/components/hold-on-complete";
|
||||||
import type { ScheduledBoardDTO, ScheduledOccurrenceDTO } from "@/types/scheduled-todo";
|
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
|
/** `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
|
* 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
|
* a page it doesn't render (see the plan notes on why this fetches
|
||||||
|
|
@ -38,12 +43,13 @@ function withToggledOccurrence(
|
||||||
const flip = (o: ScheduledOccurrenceDTO) =>
|
const flip = (o: ScheduledOccurrenceDTO) =>
|
||||||
matchesOccurrence(o, scheduledTodoId, occurrenceDate) ? { ...o, completed } : o;
|
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 {
|
return {
|
||||||
// Overdue only ever lists incomplete occurrences -- completing one
|
overdue: board.overdue.map(flip),
|
||||||
// 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),
|
|
||||||
today: { ...board.today, occurrences: board.today.occurrences.map(flip) },
|
today: { ...board.today, occurrences: board.today.occurrences.map(flip) },
|
||||||
upcoming: board.upcoming.map((day) => ({ ...day, occurrences: day.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<ScheduledBoardDTO | null>(null);
|
const [board, setBoard] = useState<ScheduledBoardDTO | null>(null);
|
||||||
const [dialogOpen, setDialogOpen] = useState(false);
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
const [editingId, setEditingId] = useState<string | null>(null);
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
|
const { beginHold, cancelHold } = useHoldOnComplete();
|
||||||
|
|
||||||
const refresh = useCallback(() => {
|
const refresh = useCallback(() => {
|
||||||
getScheduledBoard(projectId)
|
getScheduledBoard(projectId)
|
||||||
|
|
@ -73,6 +80,13 @@ export function ScheduledPanel() {
|
||||||
setBoard((prev) =>
|
setBoard((prev) =>
|
||||||
prev ? withToggledOccurrence(prev, occurrence.scheduledTodoId, occurrence.occurrenceDate, completed) : 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 {
|
try {
|
||||||
await toggleScheduledOccurrence(occurrence.scheduledTodoId, occurrence.occurrenceDate, completed);
|
await toggleScheduledOccurrence(occurrence.scheduledTodoId, occurrence.occurrenceDate, completed);
|
||||||
} catch {
|
} catch {
|
||||||
|
|
@ -91,7 +105,10 @@ export function ScheduledPanel() {
|
||||||
setDialogOpen(true);
|
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
|
// Today's occurrences list includes already-checked-off ones (so they can
|
||||||
// still be toggled back), unlike overdue -- so this needs its own filter
|
// still be toggled back), unlike overdue -- so this needs its own filter
|
||||||
// rather than just `board.today.occurrences.length`.
|
// rather than just `board.today.occurrences.length`.
|
||||||
|
|
@ -244,6 +261,19 @@ function ExpandedPanel({
|
||||||
onAdd: () => void;
|
onAdd: () => void;
|
||||||
onCollapse: () => 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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex items-center justify-between gap-2 p-3">
|
<div className="flex items-center justify-between gap-2 p-3">
|
||||||
|
|
@ -268,18 +298,19 @@ function ExpandedPanel({
|
||||||
<p className="p-2 text-center text-sm text-muted-foreground">Loading…</p>
|
<p className="p-2 text-center text-sm text-muted-foreground">Loading…</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
{board.overdue.length > 0 && (
|
{visibleOverdue.length > 0 && (
|
||||||
<section className="rounded-lg border-2 border-destructive bg-destructive/10 p-3">
|
<section className="rounded-lg border-2 border-destructive bg-destructive/10 p-3">
|
||||||
<div className="mb-1.5 flex items-center gap-1.5 text-sm font-bold text-destructive">
|
<div className="mb-1.5 flex items-center gap-1.5 text-sm font-bold text-destructive">
|
||||||
<AlertTriangle className="size-4 animate-pulse" />
|
<AlertTriangle className="size-4 animate-pulse" />
|
||||||
Overdue ({board.overdue.length})
|
Overdue ({visibleOverdue.length})
|
||||||
</div>
|
</div>
|
||||||
<ul>
|
<ul>
|
||||||
{board.overdue.map((o) => (
|
{visibleOverdue.map((o) => (
|
||||||
<ScheduledTodoItem
|
<ScheduledTodoItem
|
||||||
key={`${o.scheduledTodoId}-${o.occurrenceDate}`}
|
key={`${o.scheduledTodoId}-${o.occurrenceDate}`}
|
||||||
occurrence={o}
|
occurrence={o}
|
||||||
emphasis="overdue"
|
emphasis="overdue"
|
||||||
|
holdPhase={holds.get(occurrenceKey(o.scheduledTodoId, o.occurrenceDate))}
|
||||||
onToggle={(completed) => onToggleOccurrence(o, completed)}
|
onToggle={(completed) => onToggleOccurrence(o, completed)}
|
||||||
onEdit={() => onEditOccurrence(o.scheduledTodoId)}
|
onEdit={() => onEditOccurrence(o.scheduledTodoId)}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import { StickyNote } from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { MouseFollowTooltip } from "@/components/board/mouse-follow-tooltip";
|
import { MouseFollowTooltip } from "@/components/board/mouse-follow-tooltip";
|
||||||
|
import type { HoldPhase } from "@/components/hold-on-complete";
|
||||||
import type { ScheduledOccurrenceDTO } from "@/types/scheduled-todo";
|
import type { ScheduledOccurrenceDTO } from "@/types/scheduled-todo";
|
||||||
|
|
||||||
/** One occurrence row -- "emphasis" is how loud this date's section reads
|
/** 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({
|
export function ScheduledTodoItem({
|
||||||
occurrence,
|
occurrence,
|
||||||
emphasis = "normal",
|
emphasis = "normal",
|
||||||
|
holdPhase,
|
||||||
onToggle,
|
onToggle,
|
||||||
onEdit,
|
onEdit,
|
||||||
}: {
|
}: {
|
||||||
occurrence: ScheduledOccurrenceDTO;
|
occurrence: ScheduledOccurrenceDTO;
|
||||||
emphasis?: "overdue" | "today" | "normal";
|
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;
|
onToggle: (completed: boolean) => void;
|
||||||
onEdit: () => void;
|
onEdit: () => void;
|
||||||
}) {
|
}) {
|
||||||
|
|
@ -41,21 +47,31 @@ export function ScheduledTodoItem({
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li className="flex items-start gap-2 py-1">
|
<li
|
||||||
<Checkbox
|
className="grid transition-[grid-template-rows] duration-200 ease-in"
|
||||||
checked={occurrence.completed}
|
style={{ gridTemplateRows: holdPhase === "collapsing" ? "0fr" : "1fr" }}
|
||||||
onCheckedChange={(checked) => onToggle(!!checked)}
|
>
|
||||||
className="mt-0.5"
|
<div className="overflow-hidden">
|
||||||
aria-label={`Mark "${occurrence.title}" ${occurrence.completed ? "incomplete" : "complete"}`}
|
<div
|
||||||
/>
|
className="flex items-start gap-2 py-1 transition-opacity duration-1000 ease-in"
|
||||||
{occurrence.details ? (
|
style={{ opacity: holdPhase === "fading" || holdPhase === "collapsing" ? 0 : 1 }}
|
||||||
<MouseFollowTooltip content={occurrence.details}>{titleButton}</MouseFollowTooltip>
|
>
|
||||||
) : (
|
<Checkbox
|
||||||
titleButton
|
checked={occurrence.completed}
|
||||||
)}
|
onCheckedChange={(checked) => onToggle(!!checked)}
|
||||||
{occurrence.details && (
|
className="mt-0.5"
|
||||||
<StickyNote className="mt-0.5 size-3 shrink-0 text-muted-foreground/70" />
|
aria-label={`Mark "${occurrence.title}" ${occurrence.completed ? "incomplete" : "complete"}`}
|
||||||
)}
|
/>
|
||||||
|
{occurrence.details ? (
|
||||||
|
<MouseFollowTooltip content={occurrence.details}>{titleButton}</MouseFollowTooltip>
|
||||||
|
) : (
|
||||||
|
titleButton
|
||||||
|
)}
|
||||||
|
{occurrence.details && (
|
||||||
|
<StickyNote className="mt-0.5 size-3 shrink-0 text-muted-foreground/70" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue