diff --git a/src/app/settings/page.tsx b/src/app/settings/page.tsx index 9b06293..c6a1665 100644 --- a/src/app/settings/page.tsx +++ b/src/app/settings/page.tsx @@ -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() { + + + Lists + + + + + + Active Share Links diff --git a/src/components/completion-delay-setting.tsx b/src/components/completion-delay-setting.tsx new file mode 100644 index 0000000..b582814 --- /dev/null +++ b/src/components/completion-delay-setting.tsx @@ -0,0 +1,30 @@ +"use client"; + +import { DELAY_OPTIONS, useCompletionDelay } from "@/hooks/use-completion-delay"; + +export function CompletionDelaySetting() { + const { delay, setDelay } = useCompletionDelay(); + + return ( + + + Completion delay + + How long a checked-off item stays visible before disappearing from list cards and the + dashboard. + + + 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) => ( + + {opt.label} + + ))} + + + ); +} diff --git a/src/hooks/use-completion-delay.ts b/src/hooks/use-completion-delay.ts new file mode 100644 index 0000000..435052d --- /dev/null +++ b/src/hooks/use-completion-delay.ts @@ -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(readDelay); + + function setDelay(ms: number) { + setDelayState(ms); + localStorage.setItem(STORAGE_KEY, String(ms)); + } + + return { delay, setDelay }; +} diff --git a/src/modules/lists/components/list-widget.tsx b/src/modules/lists/components/list-widget.tsx index 1a2ddc9..d99cef2 100644 --- a/src/modules/lists/components/list-widget.tsx +++ b/src/modules/lists/components/list-widget.tsx @@ -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(initialItems); const [, startTransition] = useTransition(); + const { delay } = useCompletionDelay(); + const timers = useRef>>(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[] }) { /> {item.text} diff --git a/src/modules/lists/components/lists-index.tsx b/src/modules/lists/components/lists-index.tsx index 7c6610f..9b09098 100644 --- a/src/modules/lists/components/lists-index.tsx +++ b/src/modules/lists/components/lists-index.tsx @@ -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>>(new Map()); const grouped = useMemo(() => { const groups = new Map(); @@ -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)} /> - {item.text} + + {item.text} + ))} - {list.openCount > list.items.length && ( + {list.openCount > list.items.filter((i) => !i.done).length && ( - +{list.openCount - list.items.length} more — open list + +{list.openCount - list.items.filter((i) => !i.done).length} more — open list )}
Completion delay
+ How long a checked-off item stays visible before disappearing from list cards and the + dashboard. +