feat: implement archiving functionality for counters and update related logic

This commit is contained in:
2026-08-25 13:25:21 +02:00
parent 6ccf897fc4
commit 3df1e8d7fa
9 changed files with 182 additions and 35 deletions
+14
View File
@@ -0,0 +1,14 @@
import { NextResponse } from 'next/server';
import db from '@/lib/db';
export const dynamic = 'force-dynamic';
type Params = { params: Promise<{ id: string }> };
export async function POST(_req: Request, { params }: Params) {
const { id } = await params;
const existing = db.prepare('SELECT * FROM counters WHERE id = ?').get(Number(id));
if (!existing) return NextResponse.json({ error: 'Not found' }, { status: 404 });
const updated = db.prepare('UPDATE counters SET archived_at = ? WHERE id = ? RETURNING *').get(Date.now(), Number(id));
return NextResponse.json(updated);
}
+1 -1
View File
@@ -8,7 +8,7 @@ type Params = { params: Promise<{ id: string }> };
export async function POST(_req: Request, { params }: Params) { export async function POST(_req: Request, { params }: Params) {
const { id } = await params; const { id } = await params;
const tx = db.transaction(() => { const tx = db.transaction(() => {
const row = db.prepare('UPDATE counters SET value = value - 1 WHERE id = ? AND value > 0 RETURNING *').get(Number(id)); const row = db.prepare('UPDATE counters SET value = value - 1 WHERE id = ? AND value > 0 AND archived_at IS NULL RETURNING *').get(Number(id));
if (row) db.prepare('INSERT INTO events (counter_id, delta, created_at) VALUES (?, -1, ?)').run(Number(id), Date.now()); if (row) db.prepare('INSERT INTO events (counter_id, delta, created_at) VALUES (?, -1, ?)').run(Number(id), Date.now());
return row; return row;
}); });
+1 -1
View File
@@ -8,7 +8,7 @@ type Params = { params: Promise<{ id: string }> };
export async function POST(_req: Request, { params }: Params) { export async function POST(_req: Request, { params }: Params) {
const { id } = await params; const { id } = await params;
const tx = db.transaction(() => { const tx = db.transaction(() => {
const row = db.prepare('UPDATE counters SET value = value + 1 WHERE id = ? RETURNING *').get(Number(id)); const row = db.prepare('UPDATE counters SET value = value + 1 WHERE id = ? AND archived_at IS NULL RETURNING *').get(Number(id));
if (row) db.prepare('INSERT INTO events (counter_id, delta, created_at) VALUES (?, 1, ?)').run(Number(id), Date.now()); if (row) db.prepare('INSERT INTO events (counter_id, delta, created_at) VALUES (?, 1, ?)').run(Number(id), Date.now());
return row; return row;
}); });
+14
View File
@@ -0,0 +1,14 @@
import { NextResponse } from 'next/server';
import db from '@/lib/db';
export const dynamic = 'force-dynamic';
type Params = { params: Promise<{ id: string }> };
export async function POST(_req: Request, { params }: Params) {
const { id } = await params;
const existing = db.prepare('SELECT * FROM counters WHERE id = ?').get(Number(id));
if (!existing) return NextResponse.json({ error: 'Not found' }, { status: 404 });
const updated = db.prepare('UPDATE counters SET archived_at = NULL WHERE id = ? RETURNING *').get(Number(id));
return NextResponse.json(updated);
}
+6 -2
View File
@@ -3,8 +3,12 @@ import db from '@/lib/db';
export const dynamic = 'force-dynamic'; export const dynamic = 'force-dynamic';
export async function GET() { export async function GET(request: Request) {
const counters = db.prepare('SELECT * FROM counters ORDER BY order_index ASC, id ASC').all(); const { searchParams } = new URL(request.url);
const archived = searchParams.get('archived') === 'true';
const counters = archived
? db.prepare('SELECT * FROM counters WHERE archived_at IS NOT NULL ORDER BY archived_at DESC').all()
: db.prepare('SELECT * FROM counters WHERE archived_at IS NULL ORDER BY order_index ASC, id ASC').all();
return NextResponse.json(counters); return NextResponse.json(counters);
} }
+4 -2
View File
@@ -7,6 +7,7 @@ export interface Counter {
image_path: string | null; image_path: string | null;
group_id: number | null; group_id: number | null;
order_index: number; order_index: number;
archived_at?: number | null;
} }
interface Props { interface Props {
@@ -69,7 +70,7 @@ export default function CounterCard({ counter, onIncrement, onDecrement, onEdit,
<div className="flex items-center bg-ctp-surface1 rounded-2xl overflow-hidden"> <div className="flex items-center bg-ctp-surface1 rounded-2xl overflow-hidden">
<button <button
onClick={() => onDecrement(counter.id)} onClick={() => onDecrement(counter.id)}
disabled={counter.value <= 0} disabled={counter.value <= 0 || !!counter.archived_at}
aria-label="Decrement" aria-label="Decrement"
className="w-11 h-11 flex items-center justify-center text-ctp-red hover:bg-ctp-red/20 active:bg-ctp-red/30 active:scale-95 transition-all disabled:opacity-25 disabled:pointer-events-none" className="w-11 h-11 flex items-center justify-center text-ctp-red hover:bg-ctp-red/20 active:bg-ctp-red/30 active:scale-95 transition-all disabled:opacity-25 disabled:pointer-events-none"
> >
@@ -83,7 +84,8 @@ export default function CounterCard({ counter, onIncrement, onDecrement, onEdit,
<button <button
onClick={() => onIncrement(counter.id)} onClick={() => onIncrement(counter.id)}
aria-label="Increment" aria-label="Increment"
className="w-11 h-11 flex items-center justify-center text-ctp-green hover:bg-ctp-green/20 active:bg-ctp-green/30 active:scale-95 transition-all" disabled={!!counter.archived_at}
className="w-11 h-11 flex items-center justify-center text-ctp-green hover:bg-ctp-green/20 active:bg-ctp-green/30 active:scale-95 transition-all disabled:opacity-25 disabled:pointer-events-none"
> >
<svg xmlns="http://www.w3.org/2000/svg" className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round"> <svg xmlns="http://www.w3.org/2000/svg" className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round">
<line x1="12" y1="5" x2="12" y2="19" /> <line x1="12" y1="5" x2="12" y2="19" />
+26 -4
View File
@@ -21,9 +21,11 @@ interface Props {
image_path: string | null; image_path: string | null;
}) => void; }) => void;
onDelete?: (id: number) => void; onDelete?: (id: number) => void;
onArchive?: (id: number) => void;
onUnarchive?: (id: number) => void;
} }
export default function CounterModal({ open, initial, groups, onClose, onSave, onDelete }: Props) { export default function CounterModal({ open, initial, groups, onClose, onSave, onDelete, onArchive, onUnarchive }: Props) {
const [confirmDelete, setConfirmDelete] = useState(false); const [confirmDelete, setConfirmDelete] = useState(false);
const deleteTimer = useRef<ReturnType<typeof setTimeout> | null>(null); const deleteTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const [name, setName] = useState(''); const [name, setName] = useState('');
@@ -191,8 +193,17 @@ export default function CounterModal({ open, initial, groups, onClose, onSave, o
</div> </div>
<div className="flex items-center justify-between gap-3 pt-2"> <div className="flex items-center justify-between gap-3 pt-2">
{/* Delete — only shown when editing an existing counter */} {initial ? (
{initial && onDelete ? ( initial.archived_at ? (
<div className="flex gap-2">
<button
type="button"
onClick={() => { onUnarchive?.(initial.id); onClose(); }}
className="px-4 py-2 text-sm rounded-lg font-medium border border-ctp-green/40 text-ctp-green hover:bg-ctp-green/10 transition-colors"
>
Unarchive
</button>
{onDelete && (
<button <button
type="button" type="button"
onClick={() => { onClick={() => {
@@ -211,8 +222,19 @@ export default function CounterModal({ open, initial, groups, onClose, onSave, o
: 'text-ctp-red hover:bg-ctp-red/10 border border-ctp-red/30' : 'text-ctp-red hover:bg-ctp-red/10 border border-ctp-red/30'
}`} }`}
> >
{confirmDelete ? 'Confirm delete' : 'Delete counter'} {confirmDelete ? 'Confirm delete' : 'Delete'}
</button> </button>
)}
</div>
) : (
<button
type="button"
onClick={() => { onArchive?.(initial.id); onClose(); }}
className="px-4 py-2 text-sm rounded-lg font-medium border border-ctp-overlay1/40 text-ctp-subtext1 hover:bg-ctp-surface1 transition-colors"
>
Archive
</button>
)
) : <span />} ) : <span />}
<div className="flex gap-3"> <div className="flex gap-3">
+89 -5
View File
@@ -34,6 +34,8 @@ export default function Home() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [historyCounterId, setHistoryCounterId] = useState<number | null>(null); const [historyCounterId, setHistoryCounterId] = useState<number | null>(null);
const [editMode, setEditMode] = useState(false); const [editMode, setEditMode] = useState(false);
const [archivedCounters, setArchivedCounters] = useState<Counter[]>([]);
const [showArchived, setShowArchived] = useState(false);
// Always-current ref so drag handlers never read stale closure state // Always-current ref so drag handlers never read stale closure state
const countersRef = useRef(counters); const countersRef = useRef(counters);
@@ -65,6 +67,14 @@ export default function Home() {
useEffect(() => { load(); }, []); useEffect(() => { load(); }, []);
useEffect(() => { router.prefetch('/stats'); }, [router]); useEffect(() => { router.prefetch('/stats'); }, [router]);
useEffect(() => {
if (showArchived) {
fetch('/api/counters?archived=true')
.then(r => r.json())
.then(setArchivedCounters)
.catch(() => {});
}
}, [showArchived]);
// ── Optimistic increment/decrement ───────────────────────────────────────── // ── Optimistic increment/decrement ─────────────────────────────────────────
const handleIncrement = useCallback(async (id: number) => { const handleIncrement = useCallback(async (id: number) => {
@@ -86,7 +96,9 @@ export default function Home() {
if (editingCounter) { if (editingCounter) {
// Optimistic update — close immediately, patch in background // Optimistic update — close immediately, patch in background
const optimistic = { ...editingCounter, ...data }; const optimistic = { ...editingCounter, ...data };
setCounters(prev => prev.map(c => c.id === editingCounter.id ? optimistic : c)); const isArchived = !!editingCounter.archived_at;
const setTarget = isArchived ? setArchivedCounters : setCounters;
setTarget(prev => prev.map(c => c.id === editingCounter.id ? optimistic : c));
setModalOpen(false); setModalOpen(false);
setEditingCounter(null); setEditingCounter(null);
const res = await fetch(`/api/counters/${editingCounter.id}`, { const res = await fetch(`/api/counters/${editingCounter.id}`, {
@@ -96,10 +108,9 @@ export default function Home() {
}); });
if (res.ok) { if (res.ok) {
const updated = await res.json(); const updated = await res.json();
setCounters(prev => prev.map(c => c.id === updated.id ? updated : c)); setTarget(prev => prev.map(c => c.id === updated.id ? updated : c));
} else { } else {
// Roll back setTarget(prev => prev.map(c => c.id === editingCounter.id ? editingCounter : c));
setCounters(prev => prev.map(c => c.id === editingCounter.id ? editingCounter : c));
} }
} else { } else {
// Optimistic create with a temporary id // Optimistic create with a temporary id
@@ -126,9 +137,41 @@ export default function Home() {
// ── Delete counter ───────────────────────────────────────────────────────── // ── Delete counter ─────────────────────────────────────────────────────────
async function handleDeleteCounter(id: number) { async function handleDeleteCounter(id: number) {
const prev = counters.find(c => c.id === id); const prev = counters.find(c => c.id === id);
const prevArchived = archivedCounters.find(c => c.id === id);
setCounters(cs => cs.filter(c => c.id !== id)); setCounters(cs => cs.filter(c => c.id !== id));
setArchivedCounters(cs => cs.filter(c => c.id !== id));
const res = await fetch(`/api/counters/${id}`, { method: 'DELETE' }); const res = await fetch(`/api/counters/${id}`, { method: 'DELETE' });
if (!res.ok && prev) setCounters(cs => [...cs, prev].sort((a, b) => a.order_index - b.order_index)); if (!res.ok) {
if (prev) setCounters(cs => [...cs, prev].sort((a, b) => a.order_index - b.order_index));
if (prevArchived) setArchivedCounters(cs => [...cs, prevArchived]);
}
}
// ── Archive / Unarchive counter ──────────────────────────────────────────────────────
async function handleArchiveCounter(id: number) {
const counter = counters.find(c => c.id === id);
if (!counter) return;
setCounters(cs => cs.filter(c => c.id !== id));
const res = await fetch(`/api/counters/${id}/archive`, { method: 'POST' });
if (res.ok) {
const archived = await res.json();
if (showArchived) setArchivedCounters(prev => [archived, ...prev]);
} else {
setCounters(cs => [...cs, counter].sort((a, b) => a.order_index - b.order_index));
}
}
async function handleUnarchiveCounter(id: number) {
const counter = archivedCounters.find(c => c.id === id);
if (!counter) return;
setArchivedCounters(prev => prev.filter(c => c.id !== id));
const res = await fetch(`/api/counters/${id}/unarchive`, { method: 'POST' });
if (res.ok) {
const unarchived = await res.json();
setCounters(prev => [...prev, unarchived].sort((a, b) => a.order_index - b.order_index));
} else {
setArchivedCounters(prev => [...prev, counter]);
}
} }
// ── Groups ───────────────────────────────────────────────────────────────── // ── Groups ─────────────────────────────────────────────────────────────────
@@ -275,6 +318,21 @@ export default function Home() {
</svg> </svg>
Stats Stats
</Link> </Link>
<button
onClick={() => setShowArchived(v => !v)}
className={`flex items-center gap-1.5 px-4 py-2 text-sm rounded-lg border transition-colors ${
showArchived
? 'border-ctp-yellow/50 bg-ctp-yellow/10 text-ctp-yellow hover:bg-ctp-yellow/20'
: 'border-ctp-surface1 text-ctp-subtext1 hover:bg-ctp-surface0'
}`}
>
<svg xmlns="http://www.w3.org/2000/svg" className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="21 8 21 21 3 21 3 8" />
<rect x="1" y="3" width="22" height="5" />
<line x1="10" y1="12" x2="14" y2="12" />
</svg>
Archived
</button>
{addingGroup ? ( {addingGroup ? (
<div className="flex gap-2"> <div className="flex gap-2">
<input <input
@@ -396,6 +454,30 @@ export default function Home() {
</button> </button>
</div> </div>
)} )}
{showArchived && (
<div className="mt-10">
<h2 className="text-lg font-bold text-ctp-overlay1 mb-3">Archived</h2>
{archivedCounters.length === 0 ? (
<p className="text-ctp-overlay0 text-sm">No archived counters.</p>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-3 opacity-60">
{archivedCounters.map(counter => (
<CounterCard
key={counter.id}
counter={counter}
onIncrement={() => {}}
onDecrement={() => {}}
onEdit={c => { setEditingCounter(c); setModalOpen(true); }}
onHistory={setHistoryCounterId}
onPrefetch={handlePrefetch}
editMode={false}
/>
))}
</div>
)}
</div>
)}
</div> </div>
<CounterModal <CounterModal
@@ -405,6 +487,8 @@ export default function Home() {
onClose={() => { setModalOpen(false); setEditingCounter(null); }} onClose={() => { setModalOpen(false); setEditingCounter(null); }}
onSave={handleSaveCounter} onSave={handleSaveCounter}
onDelete={handleDeleteCounter} onDelete={handleDeleteCounter}
onArchive={handleArchiveCounter}
onUnarchive={handleUnarchiveCounter}
/> />
<CounterDetailModal <CounterDetailModal
+8 -1
View File
@@ -29,7 +29,8 @@ db.exec(`
value INTEGER NOT NULL DEFAULT 0, value INTEGER NOT NULL DEFAULT 0,
image_path TEXT, image_path TEXT,
group_id INTEGER REFERENCES groups(id) ON DELETE SET NULL, group_id INTEGER REFERENCES groups(id) ON DELETE SET NULL,
order_index INTEGER NOT NULL DEFAULT 0 order_index INTEGER NOT NULL DEFAULT 0,
archived_at INTEGER
); );
CREATE TABLE IF NOT EXISTS events ( CREATE TABLE IF NOT EXISTS events (
@@ -42,5 +43,11 @@ db.exec(`
CREATE INDEX IF NOT EXISTS idx_events_date ON events(created_at); CREATE INDEX IF NOT EXISTS idx_events_date ON events(created_at);
`); `);
// Incremental migrations
const cols = (db.prepare("PRAGMA table_info(counters)").all() as { name: string }[]).map(c => c.name);
if (!cols.includes('archived_at')) {
db.exec('ALTER TABLE counters ADD COLUMN archived_at INTEGER');
}
export { UPLOADS_DIR }; export { UPLOADS_DIR };
export default db; export default db;