Delay item disappearance after check-off; add setting in Settings

Items on the lists page and dashboard widget now show as crossed-out for
a configurable duration (default 3s) before vanishing. Unchecking within
the window cancels the removal. The delay is stored in localStorage and
configurable via Settings → Lists (options: 1s / 3s / 5s / 10s / 30s).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
ginnoir
2026-05-06 15:02:04 -05:00
co-authored by Claude Sonnet 4.6
parent 8fda3e47dd
commit 2ad9521ef9
5 changed files with 163 additions and 24 deletions
+10
View File
@@ -4,6 +4,7 @@ import { getEntityType } from "@/modules/_core/registry";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { ThemePicker } from "@/components/theme-picker";
import { CompletionDelaySetting } from "@/components/completion-delay-setting";
import { revokeShareLinkAction } from "./actions";
import Link from "next/link";
@@ -28,6 +29,15 @@ export default async function SettingsPage() {
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Lists</CardTitle>
</CardHeader>
<CardContent>
<CompletionDelaySetting />
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Active Share Links</CardTitle>
@@ -0,0 +1,30 @@
"use client";
import { DELAY_OPTIONS, useCompletionDelay } from "@/hooks/use-completion-delay";
export function CompletionDelaySetting() {
const { delay, setDelay } = useCompletionDelay();
return (
<div className="flex items-center justify-between gap-4">
<div>
<p className="text-sm font-medium">Completion delay</p>
<p className="text-xs text-muted-foreground">
How long a checked-off item stays visible before disappearing from list cards and the
dashboard.
</p>
</div>
<select
value={delay}
onChange={(e) => setDelay(Number(e.target.value))}
className="rounded-md border border-input bg-background px-3 py-1.5 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
>
{DELAY_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
);
}
+32
View File
@@ -0,0 +1,32 @@
"use client";
import { useState } from "react";
const STORAGE_KEY = "completion-delay-ms";
export const DEFAULT_DELAY_MS = 3000;
export const DELAY_OPTIONS = [
{ label: "1 second", value: 1000 },
{ label: "3 seconds", value: 3000 },
{ label: "5 seconds", value: 5000 },
{ label: "10 seconds", value: 10000 },
{ label: "30 seconds", value: 30000 },
];
function readDelay(): number {
if (typeof window === "undefined") return DEFAULT_DELAY_MS;
const raw = localStorage.getItem(STORAGE_KEY);
const parsed = raw ? parseInt(raw, 10) : NaN;
return Number.isFinite(parsed) ? parsed : DEFAULT_DELAY_MS;
}
export function useCompletionDelay() {
const [delay, setDelayState] = useState<number>(readDelay);
function setDelay(ms: number) {
setDelayState(ms);
localStorage.setItem(STORAGE_KEY, String(ms));
}
return { delay, setDelay };
}
+29 -8
View File
@@ -1,7 +1,8 @@
"use client";
import Link from "next/link";
import { useOptimistic, useTransition } from "react";
import { useRef, useState, useTransition } from "react";
import { useCompletionDelay } from "@/hooks/use-completion-delay";
import { toggleItem } from "../server/actions";
type WidgetItem = {
@@ -13,16 +14,34 @@ type WidgetItem = {
};
export function ListWidget({ initialItems }: { initialItems: WidgetItem[] }) {
const [items, setOptimistic] = useOptimistic(
initialItems,
(current: WidgetItem[], { id, done }: { id: string; done: boolean }) =>
current.map((item) => (item.id === id ? { ...item, done } : item)),
);
const [items, setItems] = useState<WidgetItem[]>(initialItems);
const [, startTransition] = useTransition();
const { delay } = useCompletionDelay();
const timers = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
function toggle(item: WidgetItem, done: boolean) {
if (done) {
setItems((current) =>
current.map((i) => (i.id === item.id ? { ...i, done: true } : i)),
);
const t = setTimeout(() => {
setItems((current) => current.filter((i) => i.id !== item.id));
timers.current.delete(item.id);
}, delay);
timers.current.set(item.id, t);
} else {
const existing = timers.current.get(item.id);
if (existing) {
clearTimeout(existing);
timers.current.delete(item.id);
}
setItems((current) =>
current.map((i) => (i.id === item.id ? { ...i, done: false } : i)),
);
}
startTransition(async () => {
setOptimistic({ id: item.id, done });
await toggleItem({ id: item.id, done });
});
}
@@ -44,7 +63,9 @@ export function ListWidget({ initialItems }: { initialItems: WidgetItem[] }) {
/>
<Link
href={`/lists/${item.listId}`}
className={`truncate hover:underline ${item.done ? "text-muted-foreground line-through" : ""}`}
className={`truncate hover:underline transition-colors ${
item.done ? "text-muted-foreground line-through" : ""
}`}
>
{item.text}
</Link>
+62 -16
View File
@@ -2,10 +2,11 @@
import Link from "next/link";
import { ChevronDown, ChevronRight, ExternalLink, Plus } from "lucide-react";
import { useMemo, useState, useTransition } from "react";
import { useMemo, useRef, useState, useTransition } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useCompletionDelay } from "@/hooks/use-completion-delay";
import type { ListIndexItem, ListWithItemsDto } from "../server/queries";
import { createList, toggleItem } from "../server/actions";
@@ -14,6 +15,8 @@ export function ListsIndex({ lists }: { lists: ListWithItemsDto[] }) {
const [type, setType] = useState("shopping");
const [name, setName] = useState("");
const [, startTransition] = useTransition();
const { delay } = useCompletionDelay();
const timers = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
const grouped = useMemo(() => {
const groups = new Map<string, ListWithItemsDto[]>();
@@ -44,18 +47,55 @@ export function ListsIndex({ lists }: { lists: ListWithItemsDto[] }) {
}
function handleToggle(listId: string, item: ListIndexItem, done: boolean) {
setListRows((current) =>
current.map((list) =>
list.id !== listId
? list
: {
...list,
openCount: list.openCount + (done ? -1 : 1),
doneCount: list.doneCount + (done ? 1 : -1),
items: list.items.filter((i) => i.id !== item.id),
},
),
);
const timerId = `${listId}:${item.id}`;
if (done) {
// Mark done immediately (strikethrough) and schedule removal
setListRows((current) =>
current.map((list) =>
list.id !== listId
? list
: {
...list,
openCount: list.openCount - 1,
doneCount: list.doneCount + 1,
items: list.items.map((i) => (i.id === item.id ? { ...i, done: true } : i)),
},
),
);
const t = setTimeout(() => {
setListRows((current) =>
current.map((list) =>
list.id !== listId
? list
: { ...list, items: list.items.filter((i) => i.id !== item.id) },
),
);
timers.current.delete(timerId);
}, delay);
timers.current.set(timerId, t);
} else {
// Cancel pending removal and restore item
const existing = timers.current.get(timerId);
if (existing) {
clearTimeout(existing);
timers.current.delete(timerId);
}
setListRows((current) =>
current.map((list) =>
list.id !== listId
? list
: {
...list,
openCount: list.openCount + 1,
doneCount: list.doneCount - 1,
items: list.items.map((i) => (i.id === item.id ? { ...i, done: false } : i)),
},
),
);
}
startTransition(async () => {
await toggleItem({ id: item.id, done });
});
@@ -162,16 +202,22 @@ function ListCard({
checked={item.done}
onChange={(e) => onToggle(list.id, item, e.target.checked)}
/>
<span className="text-sm truncate">{item.text}</span>
<span
className={`text-sm truncate transition-colors ${
item.done ? "text-muted-foreground line-through" : ""
}`}
>
{item.text}
</span>
</li>
))}
{list.openCount > list.items.length && (
{list.openCount > list.items.filter((i) => !i.done).length && (
<li className="px-4 py-2">
<Link
href={`/lists/${list.id}`}
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
>
+{list.openCount - list.items.length} more open list
+{list.openCount - list.items.filter((i) => !i.done).length} more open list
</Link>
</li>
)}