From 3df1e8d7fae7715d4badf87b3bc9e16e65d0e884 Mon Sep 17 00:00:00 2001
From: Sebastian Slettebakken <43045439+sebastas@users.noreply.github.com>
Date: Tue, 25 Aug 2026 13:25:21 +0200
Subject: [PATCH] feat: implement archiving functionality for counters and
update related logic
---
app/api/counters/[id]/archive/route.ts | 14 ++++
app/api/counters/[id]/decrement/route.ts | 2 +-
app/api/counters/[id]/increment/route.ts | 2 +-
app/api/counters/[id]/unarchive/route.ts | 14 ++++
app/api/counters/route.ts | 8 +-
app/components/CounterCard.tsx | 6 +-
app/components/CounterModal.tsx | 68 +++++++++++------
app/page.tsx | 94 ++++++++++++++++++++++--
lib/db.ts | 9 ++-
9 files changed, 182 insertions(+), 35 deletions(-)
create mode 100644 app/api/counters/[id]/archive/route.ts
create mode 100644 app/api/counters/[id]/unarchive/route.ts
diff --git a/app/api/counters/[id]/archive/route.ts b/app/api/counters/[id]/archive/route.ts
new file mode 100644
index 0000000..6742429
--- /dev/null
+++ b/app/api/counters/[id]/archive/route.ts
@@ -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);
+}
diff --git a/app/api/counters/[id]/decrement/route.ts b/app/api/counters/[id]/decrement/route.ts
index 91f88b2..b245326 100644
--- a/app/api/counters/[id]/decrement/route.ts
+++ b/app/api/counters/[id]/decrement/route.ts
@@ -8,7 +8,7 @@ type Params = { params: Promise<{ id: string }> };
export async function POST(_req: Request, { params }: Params) {
const { id } = await params;
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());
return row;
});
diff --git a/app/api/counters/[id]/increment/route.ts b/app/api/counters/[id]/increment/route.ts
index b53e6a3..8a26429 100644
--- a/app/api/counters/[id]/increment/route.ts
+++ b/app/api/counters/[id]/increment/route.ts
@@ -8,7 +8,7 @@ type Params = { params: Promise<{ id: string }> };
export async function POST(_req: Request, { params }: Params) {
const { id } = await params;
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());
return row;
});
diff --git a/app/api/counters/[id]/unarchive/route.ts b/app/api/counters/[id]/unarchive/route.ts
new file mode 100644
index 0000000..f23e70b
--- /dev/null
+++ b/app/api/counters/[id]/unarchive/route.ts
@@ -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);
+}
diff --git a/app/api/counters/route.ts b/app/api/counters/route.ts
index 8f8d26a..f1eb57e 100644
--- a/app/api/counters/route.ts
+++ b/app/api/counters/route.ts
@@ -3,8 +3,12 @@ import db from '@/lib/db';
export const dynamic = 'force-dynamic';
-export async function GET() {
- const counters = db.prepare('SELECT * FROM counters ORDER BY order_index ASC, id ASC').all();
+export async function GET(request: Request) {
+ 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);
}
diff --git a/app/components/CounterCard.tsx b/app/components/CounterCard.tsx
index 1821ac6..6730b32 100644
--- a/app/components/CounterCard.tsx
+++ b/app/components/CounterCard.tsx
@@ -7,6 +7,7 @@ export interface Counter {
image_path: string | null;
group_id: number | null;
order_index: number;
+ archived_at?: number | null;
}
interface Props {
@@ -69,7 +70,7 @@ export default function CounterCard({ counter, onIncrement, onDecrement, onEdit,
- {/* Delete — only shown when editing an existing counter */}
- {initial && onDelete ? (
-
{
- if (confirmDelete) {
- if (deleteTimer.current) clearTimeout(deleteTimer.current);
- onDelete(initial.id);
- onClose();
- } else {
- setConfirmDelete(true);
- deleteTimer.current = setTimeout(() => setConfirmDelete(false), 3000);
- }
- }}
- className={`px-4 py-2 text-sm rounded-lg font-medium transition-colors ${
- confirmDelete
- ? 'bg-ctp-red hover:bg-ctp-maroon text-ctp-base'
- : 'text-ctp-red hover:bg-ctp-red/10 border border-ctp-red/30'
- }`}
- >
- {confirmDelete ? 'Confirm delete' : 'Delete counter'}
-
+ {initial ? (
+ initial.archived_at ? (
+
+ { 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
+
+ {onDelete && (
+ {
+ if (confirmDelete) {
+ if (deleteTimer.current) clearTimeout(deleteTimer.current);
+ onDelete(initial.id);
+ onClose();
+ } else {
+ setConfirmDelete(true);
+ deleteTimer.current = setTimeout(() => setConfirmDelete(false), 3000);
+ }
+ }}
+ className={`px-4 py-2 text-sm rounded-lg font-medium transition-colors ${
+ confirmDelete
+ ? 'bg-ctp-red hover:bg-ctp-maroon text-ctp-base'
+ : 'text-ctp-red hover:bg-ctp-red/10 border border-ctp-red/30'
+ }`}
+ >
+ {confirmDelete ? 'Confirm delete' : 'Delete'}
+
+ )}
+
+ ) : (
+
{ 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
+
+ )
) :
}
diff --git a/app/page.tsx b/app/page.tsx
index d0d8927..f47abb2 100644
--- a/app/page.tsx
+++ b/app/page.tsx
@@ -34,6 +34,8 @@ export default function Home() {
const [loading, setLoading] = useState(true);
const [historyCounterId, setHistoryCounterId] = useState
(null);
const [editMode, setEditMode] = useState(false);
+ const [archivedCounters, setArchivedCounters] = useState([]);
+ const [showArchived, setShowArchived] = useState(false);
// Always-current ref so drag handlers never read stale closure state
const countersRef = useRef(counters);
@@ -65,6 +67,14 @@ export default function Home() {
useEffect(() => { load(); }, []);
useEffect(() => { router.prefetch('/stats'); }, [router]);
+ useEffect(() => {
+ if (showArchived) {
+ fetch('/api/counters?archived=true')
+ .then(r => r.json())
+ .then(setArchivedCounters)
+ .catch(() => {});
+ }
+ }, [showArchived]);
// ── Optimistic increment/decrement ─────────────────────────────────────────
const handleIncrement = useCallback(async (id: number) => {
@@ -86,7 +96,9 @@ export default function Home() {
if (editingCounter) {
// Optimistic update — close immediately, patch in background
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);
setEditingCounter(null);
const res = await fetch(`/api/counters/${editingCounter.id}`, {
@@ -96,10 +108,9 @@ export default function Home() {
});
if (res.ok) {
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 {
- // Roll back
- setCounters(prev => prev.map(c => c.id === editingCounter.id ? editingCounter : c));
+ setTarget(prev => prev.map(c => c.id === editingCounter.id ? editingCounter : c));
}
} else {
// Optimistic create with a temporary id
@@ -126,9 +137,41 @@ export default function Home() {
// ── Delete counter ─────────────────────────────────────────────────────────
async function handleDeleteCounter(id: number) {
const prev = counters.find(c => c.id === id);
+ const prevArchived = archivedCounters.find(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' });
- 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 ─────────────────────────────────────────────────────────────────
@@ -275,6 +318,21 @@ export default function Home() {
Stats
+ 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'
+ }`}
+ >
+
+ Archived
+
{addingGroup ? (
)}
+
+ {showArchived && (
+
+
Archived
+ {archivedCounters.length === 0 ? (
+
No archived counters.
+ ) : (
+
+ {archivedCounters.map(counter => (
+ {}}
+ onDecrement={() => {}}
+ onEdit={c => { setEditingCounter(c); setModalOpen(true); }}
+ onHistory={setHistoryCounterId}
+ onPrefetch={handlePrefetch}
+ editMode={false}
+ />
+ ))}
+
+ )}
+
+ )}
{ setModalOpen(false); setEditingCounter(null); }}
onSave={handleSaveCounter}
onDelete={handleDeleteCounter}
+ onArchive={handleArchiveCounter}
+ onUnarchive={handleUnarchiveCounter}
/>
c.name);
+if (!cols.includes('archived_at')) {
+ db.exec('ALTER TABLE counters ADD COLUMN archived_at INTEGER');
+}
+
export { UPLOADS_DIR };
export default db;