Compare commits
3
Commits
b8e7570dc0
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eb71302e1c | ||
|
|
3df1e8d7fa | ||
|
|
6ccf897fc4 |
@@ -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);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
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(request: Request, { params }: Params) {
|
||||||
|
const { id } = await params;
|
||||||
|
const { date, delta } = await request.json();
|
||||||
|
|
||||||
|
if (!date || typeof date !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(date)) {
|
||||||
|
return NextResponse.json({ error: 'Invalid date' }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (delta !== 1 && delta !== -1) {
|
||||||
|
return NextResponse.json({ error: 'Delta must be 1 or -1' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = db.prepare('SELECT * FROM counters WHERE id = ? AND archived_at IS NULL').get(Number(id)) as Record<string, unknown> | undefined;
|
||||||
|
if (!existing) return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
||||||
|
|
||||||
|
// Store at noon local server time so SQLite's 'localtime' grouping matches the picked date
|
||||||
|
const createdAt = new Date(`${date}T12:00:00`).getTime();
|
||||||
|
if (isNaN(createdAt)) return NextResponse.json({ error: 'Invalid date' }, { status: 400 });
|
||||||
|
|
||||||
|
const result = db.transaction(() => {
|
||||||
|
const updated = db.prepare(
|
||||||
|
delta === 1
|
||||||
|
? 'UPDATE counters SET value = value + 1 WHERE id = ? RETURNING *'
|
||||||
|
: 'UPDATE counters SET value = value - 1 WHERE id = ? AND value > 0 RETURNING *'
|
||||||
|
).get(Number(id)) as Record<string, unknown> | undefined;
|
||||||
|
if (!updated) return null;
|
||||||
|
db.prepare('INSERT INTO events (counter_id, delta, created_at) VALUES (?, ?, ?)').run(Number(id), delta, createdAt);
|
||||||
|
return updated;
|
||||||
|
})();
|
||||||
|
|
||||||
|
if (!result) return NextResponse.json({ error: 'Counter cannot go below 0' }, { status: 400 });
|
||||||
|
return NextResponse.json(result);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ interface Props {
|
|||||||
counterId: number | null;
|
counterId: number | null;
|
||||||
cache?: Map<number, HistoryData>;
|
cache?: Map<number, HistoryData>;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
onLog?: (counter: Counter) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function toLocalDateStr(d: Date): string {
|
function toLocalDateStr(d: Date): string {
|
||||||
@@ -47,12 +48,18 @@ function computeLongestStreak(sortedDates: string[]): number {
|
|||||||
return best;
|
return best;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CounterDetailModal({ counterId, cache, onClose }: Props) {
|
export default function CounterDetailModal({ counterId, cache, onClose, onLog }: Props) {
|
||||||
const [data, setData] = useState<HistoryData | null>(null);
|
const [data, setData] = useState<HistoryData | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [logDate, setLogDate] = useState(() => { const d = new Date(); d.setDate(d.getDate() - 1); return toLocalDateStr(d); });
|
||||||
|
const [logging, setLogging] = useState(false);
|
||||||
|
const [logError, setLogError] = useState('');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!counterId) return;
|
if (!counterId) return;
|
||||||
|
const yesterday = new Date(); yesterday.setDate(yesterday.getDate() - 1);
|
||||||
|
setLogDate(toLocalDateStr(yesterday));
|
||||||
|
setLogError('');
|
||||||
const cached = cache?.get(counterId);
|
const cached = cache?.get(counterId);
|
||||||
if (cached) { setData(cached); setLoading(false); return; }
|
if (cached) { setData(cached); setLoading(false); return; }
|
||||||
setData(null);
|
setData(null);
|
||||||
@@ -70,6 +77,33 @@ export default function CounterDetailModal({ counterId, cache, onClose }: Props)
|
|||||||
return () => window.removeEventListener('keydown', handler);
|
return () => window.removeEventListener('keydown', handler);
|
||||||
}, [counterId, onClose]);
|
}, [counterId, onClose]);
|
||||||
|
|
||||||
|
async function handleLog(delta: number) {
|
||||||
|
if (!counterId || !logDate) return;
|
||||||
|
setLogging(true);
|
||||||
|
setLogError('');
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/counters/${counterId}/log`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ date: logDate, delta }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.json();
|
||||||
|
setLogError(body.error ?? 'Failed to log entry');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const updated: Counter = await res.json();
|
||||||
|
cache?.delete(counterId);
|
||||||
|
const histRes = await fetch(`/api/counters/${counterId}/history`);
|
||||||
|
const d: HistoryData = await histRes.json();
|
||||||
|
cache?.set(counterId, d);
|
||||||
|
setData(d);
|
||||||
|
onLog?.(updated);
|
||||||
|
} finally {
|
||||||
|
setLogging(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!counterId) return null;
|
if (!counterId) return null;
|
||||||
|
|
||||||
const dateSet = new Set(data?.dailyActivity.map(d => d.date) ?? []);
|
const dateSet = new Set(data?.dailyActivity.map(d => d.date) ?? []);
|
||||||
@@ -165,6 +199,42 @@ export default function CounterDetailModal({ counterId, cache, onClose }: Props)
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Log past entry */}
|
||||||
|
<div className="border-t border-ctp-surface1 pt-4">
|
||||||
|
<h3 className="text-sm font-bold text-ctp-text mb-3">Log past entry</h3>
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={logDate}
|
||||||
|
max={toLocalDateStr(new Date())}
|
||||||
|
onChange={e => setLogDate(e.target.value)}
|
||||||
|
className="rounded-lg border border-ctp-surface1 bg-ctp-surface0 text-ctp-text px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ctp-mauve"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => handleLog(-1)}
|
||||||
|
disabled={logging || !logDate || (data?.dailyActivity.find(d => d.date === logDate)?.value ?? 0) <= 0}
|
||||||
|
aria-label="Log decrement"
|
||||||
|
className="w-10 h-10 flex items-center justify-center rounded-xl bg-ctp-surface1 text-ctp-red hover:bg-ctp-red/20 active:bg-ctp-red/30 disabled:opacity-40 transition-colors"
|
||||||
|
>
|
||||||
|
<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="5" y1="12" x2="19" y2="12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleLog(1)}
|
||||||
|
disabled={logging || !logDate}
|
||||||
|
aria-label="Log increment"
|
||||||
|
className="w-10 h-10 flex items-center justify-center rounded-xl bg-ctp-surface1 text-ctp-green hover:bg-ctp-green/20 active:bg-ctp-green/30 disabled:opacity-40 transition-colors"
|
||||||
|
>
|
||||||
|
<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="5" y1="12" x2="19" y2="12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{logError && <p className="text-xs text-ctp-red">{logError}</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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,28 +193,48 @@ 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 ? (
|
||||||
<button
|
<div className="flex gap-2">
|
||||||
type="button"
|
<button
|
||||||
onClick={() => {
|
type="button"
|
||||||
if (confirmDelete) {
|
onClick={() => { onUnarchive?.(initial.id); onClose(); }}
|
||||||
if (deleteTimer.current) clearTimeout(deleteTimer.current);
|
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"
|
||||||
onDelete(initial.id);
|
>
|
||||||
onClose();
|
Unarchive
|
||||||
} else {
|
</button>
|
||||||
setConfirmDelete(true);
|
{onDelete && (
|
||||||
deleteTimer.current = setTimeout(() => setConfirmDelete(false), 3000);
|
<button
|
||||||
}
|
type="button"
|
||||||
}}
|
onClick={() => {
|
||||||
className={`px-4 py-2 text-sm rounded-lg font-medium transition-colors ${
|
if (confirmDelete) {
|
||||||
confirmDelete
|
if (deleteTimer.current) clearTimeout(deleteTimer.current);
|
||||||
? 'bg-ctp-red hover:bg-ctp-maroon text-ctp-base'
|
onDelete(initial.id);
|
||||||
: 'text-ctp-red hover:bg-ctp-red/10 border border-ctp-red/30'
|
onClose();
|
||||||
}`}
|
} else {
|
||||||
>
|
setConfirmDelete(true);
|
||||||
{confirmDelete ? 'Confirm delete' : 'Delete counter'}
|
deleteTimer.current = setTimeout(() => setConfirmDelete(false), 3000);
|
||||||
</button>
|
}
|
||||||
|
}}
|
||||||
|
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'}
|
||||||
|
</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">
|
||||||
|
|||||||
+93
-5
@@ -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,12 +487,18 @@ 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
|
||||||
counterId={historyCounterId}
|
counterId={historyCounterId}
|
||||||
cache={historyCache.current}
|
cache={historyCache.current}
|
||||||
onClose={() => setHistoryCounterId(null)}
|
onClose={() => setHistoryCounterId(null)}
|
||||||
|
onLog={updated => {
|
||||||
|
setCounters(prev => prev.map(c => c.id === updated.id ? { ...c, value: updated.value } : c));
|
||||||
|
setArchivedCounters(prev => prev.map(c => c.id === updated.id ? { ...c, value: updated.value } : c));
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Generated
+574
-488
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -13,7 +13,7 @@
|
|||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"better-sqlite3": "^12.10.0",
|
"better-sqlite3": "^12.10.0",
|
||||||
"next": "16.2.7",
|
"next": "^16.3.2",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-dom": "19.2.4"
|
"react-dom": "19.2.4"
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user