Implement hold-on-complete animations for smooth item dismissal

This commit is contained in:
Brian Fertig 2026-08-24 15:22:07 -06:00
parent 938f59d10d
commit 084d458335
7 changed files with 317 additions and 72 deletions

View File

@ -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>
<HoldOnCompleteProvider>
<div className="flex h-screen overflow-hidden"> <div className="flex h-screen overflow-hidden">
<SideNav userEmail={session.user.email ?? ""} role={session.user.role} /> <SideNav userEmail={session.user.email ?? ""} role={session.user.role} />
<main className="flex-1 overflow-auto">{children}</main> <main className="flex-1 overflow-auto">{children}</main>
<ScheduledPanel /> <ScheduledPanel />
</div> </div>
</HoldOnCompleteProvider>
</BoardViewProvider> </BoardViewProvider>
</ProjectsProvider> </ProjectsProvider>
</ScheduledPanelProvider> </ScheduledPanelProvider>

View File

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

View File

@ -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>

View File

@ -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,20 +282,42 @@ 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
key={todo.id}
className="grid transition-[grid-template-rows] duration-200 ease-in"
style={{ gridTemplateRows: holdPhase === "collapsing" ? "0fr" : "1fr" }}
>
<div className="overflow-hidden">
<div
className="flex items-start gap-2 py-0.5 transition-opacity duration-1000 ease-in"
style={{
opacity: holdPhase === "fading" || holdPhase === "collapsing" ? 0 : 1,
}}
>
<TodoCheckbox <TodoCheckbox
checked={todo.completed} checked={todo.completed}
onCheckedChange={(checked) => onCheckedChange={(checked) => {
toggleTodoDone(todo.id, group.id, group.categoryId, checked) toggleTodoDone(todo.id, group.id, group.categoryId, checked);
} if (!compact) return;
// Checking off holds it on screen for a grace
// period; unchecking -- including as a
// change-of-mind mid-hold -- cancels that hold
// outright so it snaps back to a normal open row.
if (checked) beginHold(todo.id);
else cancelHold(todo.id);
}}
accentColor={borderColor} accentColor={borderColor}
isDark={isDark} isDark={isDark}
className="mt-0.5" className="mt-0.5"
@ -299,17 +336,20 @@ export function GroupCard({ group }: { group: GroupDTO }) {
onAiClick={() => setTodoAiOpen(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}

View File

@ -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;
}

View File

@ -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)}
/> />

View File

@ -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,7 +47,15 @@ export function ScheduledTodoItem({
); );
return ( return (
<li className="flex items-start gap-2 py-1"> <li
className="grid transition-[grid-template-rows] duration-200 ease-in"
style={{ gridTemplateRows: holdPhase === "collapsing" ? "0fr" : "1fr" }}
>
<div className="overflow-hidden">
<div
className="flex items-start gap-2 py-1 transition-opacity duration-1000 ease-in"
style={{ opacity: holdPhase === "fading" || holdPhase === "collapsing" ? 0 : 1 }}
>
<Checkbox <Checkbox
checked={occurrence.completed} checked={occurrence.completed}
onCheckedChange={(checked) => onToggle(!!checked)} onCheckedChange={(checked) => onToggle(!!checked)}
@ -56,6 +70,8 @@ export function ScheduledTodoItem({
{occurrence.details && ( {occurrence.details && (
<StickyNote className="mt-0.5 size-3 shrink-0 text-muted-foreground/70" /> <StickyNote className="mt-0.5 size-3 shrink-0 text-muted-foreground/70" />
)} )}
</div>
</div>
</li> </li>
); );
} }