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 { ScheduledPanel } from "@/components/scheduled/scheduled-panel";
|
||||
import { BoardViewProvider } from "@/components/board/board-view-provider";
|
||||
import { HoldOnCompleteProvider } from "@/components/hold-on-complete";
|
||||
|
||||
export default async function AppLayout({ children }: { children: React.ReactNode }) {
|
||||
const session = await auth();
|
||||
|
|
@ -28,11 +29,13 @@ export default async function AppLayout({ children }: { children: React.ReactNod
|
|||
<ScheduledPanelProvider>
|
||||
<ProjectsProvider initialProjects={projects}>
|
||||
<BoardViewProvider>
|
||||
<HoldOnCompleteProvider>
|
||||
<div className="flex h-screen overflow-hidden">
|
||||
<SideNav userEmail={session.user.email ?? ""} role={session.user.role} />
|
||||
<main className="flex-1 overflow-auto">{children}</main>
|
||||
<ScheduledPanel />
|
||||
</div>
|
||||
</HoldOnCompleteProvider>
|
||||
</BoardViewProvider>
|
||||
</ProjectsProvider>
|
||||
</ScheduledPanelProvider>
|
||||
|
|
|
|||
|
|
@ -9,12 +9,22 @@ import { Label } from "@/components/ui/label";
|
|||
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 { 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();
|
||||
const { view } = useBoardView();
|
||||
const { beginHold } = useHoldOnComplete();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [title, setTitle] = useState("");
|
||||
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);
|
||||
setPending(false);
|
||||
if (group) {
|
||||
if (view === "compact") beginHold(group.id, NEW_GROUP_HOLD_MS);
|
||||
setTitle("");
|
||||
setColor(DEFAULT_GROUP_COLOR_KEY);
|
||||
setOpen(false);
|
||||
|
|
|
|||
|
|
@ -19,20 +19,31 @@ import { AddGroupPopover } from "@/components/board/add-group-popover";
|
|||
import { ConfirmDeleteDialog } from "@/components/confirm-delete-dialog";
|
||||
import { useBoard } from "@/components/board/board-context";
|
||||
import { useBoardView } from "@/components/board/board-view-provider";
|
||||
import { useHoldOnComplete } from "@/components/hold-on-complete";
|
||||
import type { CategoryDTO } from "@/types/board";
|
||||
|
||||
export function CategoryLane({ category }: { category: CategoryDTO }) {
|
||||
const { removeCategory } = useBoard();
|
||||
const { view } = useBoardView();
|
||||
const { holds } = useHoldOnComplete();
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
const isEmpty = category.groups.length === 0;
|
||||
|
||||
// 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
|
||||
// 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 =
|
||||
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;
|
||||
|
||||
// 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}>
|
||||
{visibleGroups.map((group) => (
|
||||
<GroupCard key={group.id} group={group} />
|
||||
))}
|
||||
{visibleGroups.map((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>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ import {
|
|||
import { getBrighterColor, getComplementaryColor, getGroupColor } from "@/lib/colors";
|
||||
import { useBoard } from "@/components/board/board-context";
|
||||
import { useBoardView } from "@/components/board/board-view-provider";
|
||||
import { 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";
|
||||
|
|
@ -87,6 +88,7 @@ function AddTodoMenu({
|
|||
export function GroupCard({ group }: { group: GroupDTO }) {
|
||||
const { toggleTodoDone, removeGroup, archiveGroup, aiConfigured } = useBoard();
|
||||
const { view } = useBoardView();
|
||||
const { holds, beginHold, cancelHold } = useHoldOnComplete();
|
||||
const compact = view === "compact";
|
||||
const { resolvedTheme } = useTheme();
|
||||
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.
|
||||
const canArchive = group.todos.length > 0 && group.todos.every((t) => t.completed);
|
||||
const completedCount = group.todos.filter((t) => t.completed).length;
|
||||
// Compact only ever surfaces open work -- checked-off todos are dropped
|
||||
// from the list entirely rather than shown crossed-out.
|
||||
const visibleTodos = compact ? group.todos.filter((t) => !t.completed) : group.todos;
|
||||
// Compact surfaces open work, plus anything still mid-hold after being
|
||||
// freshly checked off (see useCompactHold) -- it stays on screen,
|
||||
// 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
|
||||
// its header (title, notes icon, kebab menu) with a disclosure triangle
|
||||
|
|
@ -267,20 +282,42 @@ export function GroupCard({ group }: { group: GroupDTO }) {
|
|||
</button>
|
||||
);
|
||||
|
||||
// In compact, the "+" rides along on the last visible row
|
||||
// instead of taking a row of its own -- one less line per
|
||||
// group. (A compact group always has at least one unchecked
|
||||
// to-do -- CategoryLane hides it otherwise -- so there's
|
||||
// always a last row to put it on.)
|
||||
const isLastRow = compact && index === visibleTodos.length - 1;
|
||||
// In compact, the "+" rides along on the last *open* row
|
||||
// instead of taking a row of its own -- a to-do mid-hold
|
||||
// (see useCompactHold) can be lingering after it, fading
|
||||
// out, so this isn't simply the last row overall.
|
||||
const isLastRow = compact && index === lastOpenTodoIndex;
|
||||
// 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 (
|
||||
<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
|
||||
checked={todo.completed}
|
||||
onCheckedChange={(checked) =>
|
||||
toggleTodoDone(todo.id, group.id, group.categoryId, checked)
|
||||
}
|
||||
onCheckedChange={(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}
|
||||
isDark={isDark}
|
||||
className="mt-0.5"
|
||||
|
|
@ -299,17 +336,20 @@ export function GroupCard({ group }: { group: GroupDTO }) {
|
|||
onAiClick={() => setTodoAiOpen(true)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
|
||||
{compact ? (
|
||||
// Defensive fallback -- shouldn't normally happen, since
|
||||
// CategoryLane already hides a compact group with zero
|
||||
// visible to-dos, but keeps the "+" reachable if that ever
|
||||
// changes.
|
||||
visibleTodos.length === 0 && (
|
||||
// No open row to ride on -- either every to-do here is
|
||||
// mid-hold after being freshly checked off (about to clear
|
||||
// the group entirely once its fade finishes), or, as a
|
||||
// defensive fallback that shouldn't otherwise happen,
|
||||
// CategoryLane failed to hide an already-empty group.
|
||||
lastOpenTodoIndex === -1 && (
|
||||
<div className="mt-1 flex justify-end">
|
||||
<AddTodoMenu
|
||||
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 { ScheduledTodoDialog } from "@/components/scheduled/scheduled-todo-dialog";
|
||||
import { getScheduledBoard, toggleScheduledOccurrence } from "@/lib/actions/scheduled-todos";
|
||||
import { useHoldOnComplete } from "@/components/hold-on-complete";
|
||||
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
|
||||
* 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
|
||||
|
|
@ -38,12 +43,13 @@ function withToggledOccurrence(
|
|||
const flip = (o: ScheduledOccurrenceDTO) =>
|
||||
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 {
|
||||
// Overdue only ever lists incomplete occurrences -- completing one
|
||||
// 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),
|
||||
overdue: board.overdue.map(flip),
|
||||
today: { ...board.today, occurrences: board.today.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 [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const { beginHold, cancelHold } = useHoldOnComplete();
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
getScheduledBoard(projectId)
|
||||
|
|
@ -73,6 +80,13 @@ export function ScheduledPanel() {
|
|||
setBoard((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 {
|
||||
await toggleScheduledOccurrence(occurrence.scheduledTodoId, occurrence.occurrenceDate, completed);
|
||||
} catch {
|
||||
|
|
@ -91,7 +105,10 @@ export function ScheduledPanel() {
|
|||
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
|
||||
// still be toggled back), unlike overdue -- so this needs its own filter
|
||||
// rather than just `board.today.occurrences.length`.
|
||||
|
|
@ -244,6 +261,19 @@ function ExpandedPanel({
|
|||
onAdd: () => 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 (
|
||||
<>
|
||||
<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>
|
||||
) : (
|
||||
<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">
|
||||
<div className="mb-1.5 flex items-center gap-1.5 text-sm font-bold text-destructive">
|
||||
<AlertTriangle className="size-4 animate-pulse" />
|
||||
Overdue ({board.overdue.length})
|
||||
Overdue ({visibleOverdue.length})
|
||||
</div>
|
||||
<ul>
|
||||
{board.overdue.map((o) => (
|
||||
{visibleOverdue.map((o) => (
|
||||
<ScheduledTodoItem
|
||||
key={`${o.scheduledTodoId}-${o.occurrenceDate}`}
|
||||
occurrence={o}
|
||||
emphasis="overdue"
|
||||
holdPhase={holds.get(occurrenceKey(o.scheduledTodoId, o.occurrenceDate))}
|
||||
onToggle={(completed) => onToggleOccurrence(o, completed)}
|
||||
onEdit={() => onEditOccurrence(o.scheduledTodoId)}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { StickyNote } from "lucide-react";
|
|||
import { cn } from "@/lib/utils";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { MouseFollowTooltip } from "@/components/board/mouse-follow-tooltip";
|
||||
import type { HoldPhase } from "@/components/hold-on-complete";
|
||||
import type { ScheduledOccurrenceDTO } from "@/types/scheduled-todo";
|
||||
|
||||
/** 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({
|
||||
occurrence,
|
||||
emphasis = "normal",
|
||||
holdPhase,
|
||||
onToggle,
|
||||
onEdit,
|
||||
}: {
|
||||
occurrence: ScheduledOccurrenceDTO;
|
||||
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;
|
||||
onEdit: () => void;
|
||||
}) {
|
||||
|
|
@ -41,7 +47,15 @@ export function ScheduledTodoItem({
|
|||
);
|
||||
|
||||
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
|
||||
checked={occurrence.completed}
|
||||
onCheckedChange={(checked) => onToggle(!!checked)}
|
||||
|
|
@ -56,6 +70,8 @@ export function ScheduledTodoItem({
|
|||
{occurrence.details && (
|
||||
<StickyNote className="mt-0.5 size-3 shrink-0 text-muted-foreground/70" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue