diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..5376656 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,12 @@ +.git +.gitignore +.next +.data +node_modules +npm-debug.log* + +# Dev / editor +.env*.local +*.md +Dockerfile +.dockerignore diff --git a/.gitignore b/.gitignore index 5ef6a52..1172865 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,8 @@ # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. +# Local dev data (SQLite DB + uploads) +/.data + # dependencies /node_modules /.pnp diff --git a/AGENTS.md b/AGENTS.md deleted file mode 100644 index 8bd0e39..0000000 --- a/AGENTS.md +++ /dev/null @@ -1,5 +0,0 @@ - -# This is NOT the Next.js you know - -This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices. - diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 43c994c..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1 +0,0 @@ -@AGENTS.md diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..8c4d23a --- /dev/null +++ b/Dockerfile @@ -0,0 +1,38 @@ +# ── Stage 1: deps ───────────────────────────────────────────────────────────── +FROM node:24-alpine3.21 AS deps +WORKDIR /app +COPY package.json package-lock.json ./ +RUN npm ci + +# ── Stage 2: builder ────────────────────────────────────────────────────────── +FROM node:24-alpine3.21 AS builder +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY . . +RUN npm run build + +# ── Stage 3: runner ─────────────────────────────────────────────────────────── +FROM node:24-alpine3.21 AS runner +WORKDIR /app + +ENV NODE_ENV=production +# DATA_DIR is the single volume mount point for both SQLite and uploads +ENV DATA_DIR=/data + +# Create a non-root user +RUN addgroup --system --gid 1001 nodejs && \ + adduser --system --uid 1001 nextjs && \ + mkdir -p /data/uploads && chown -R nextjs:nodejs /data + +# Copy standalone build output +COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ +COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static +COPY --from=builder --chown=nextjs:nodejs /app/public ./public + +USER nextjs + +EXPOSE 3000 +ENV PORT=3000 +ENV HOSTNAME=0.0.0.0 + +CMD ["node", "server.js"] diff --git a/app/api/counters/[id]/decrement/route.ts b/app/api/counters/[id]/decrement/route.ts new file mode 100644 index 0000000..91f88b2 --- /dev/null +++ b/app/api/counters/[id]/decrement/route.ts @@ -0,0 +1,18 @@ +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 tx = db.transaction(() => { + const row = db.prepare('UPDATE counters SET value = value - 1 WHERE id = ? AND value > 0 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; + }); + const result = tx(); + if (!result) return NextResponse.json({ error: 'Not found' }, { status: 404 }); + return NextResponse.json(result); +} diff --git a/app/api/counters/[id]/history/route.ts b/app/api/counters/[id]/history/route.ts new file mode 100644 index 0000000..68a6fd2 --- /dev/null +++ b/app/api/counters/[id]/history/route.ts @@ -0,0 +1,31 @@ +import { NextResponse } from 'next/server'; +import db from '@/lib/db'; + +export const dynamic = 'force-dynamic'; + +type Params = { params: Promise<{ id: string }> }; + +export async function GET(_req: Request, { params }: Params) { + const { id } = await params; + const counter = db.prepare('SELECT * FROM counters WHERE id = ?').get(Number(id)); + if (!counter) return NextResponse.json({ error: 'Not found' }, { status: 404 }); + + const cutoff = Date.now() - 365 * 24 * 60 * 60 * 1000; + + const dailyActivity = db.prepare(` + SELECT + date(created_at / 1000, 'unixepoch', 'localtime') AS date, + SUM(delta) AS value + FROM events + WHERE counter_id = ? AND created_at >= ? + GROUP BY date + HAVING SUM(delta) > 0 + ORDER BY date ASC + `).all(Number(id), cutoff); + + const allTimeTotal = (db.prepare( + 'SELECT COALESCE(SUM(delta), 0) AS total FROM events WHERE counter_id = ?' + ).get(Number(id)) as { total: number }).total; + + return NextResponse.json({ counter, dailyActivity, allTimeTotal }); +} diff --git a/app/api/counters/[id]/increment/route.ts b/app/api/counters/[id]/increment/route.ts new file mode 100644 index 0000000..b53e6a3 --- /dev/null +++ b/app/api/counters/[id]/increment/route.ts @@ -0,0 +1,18 @@ +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 tx = db.transaction(() => { + const row = db.prepare('UPDATE counters SET value = value + 1 WHERE id = ? 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; + }); + const result = tx(); + if (!result) return NextResponse.json({ error: 'Not found' }, { status: 404 }); + return NextResponse.json(result); +} diff --git a/app/api/counters/[id]/route.ts b/app/api/counters/[id]/route.ts new file mode 100644 index 0000000..7a807c9 --- /dev/null +++ b/app/api/counters/[id]/route.ts @@ -0,0 +1,49 @@ +import { NextResponse } from 'next/server'; +import fs from 'fs'; +import path from 'path'; +import db, { UPLOADS_DIR } from '@/lib/db'; + +export const dynamic = 'force-dynamic'; + +type Params = { params: Promise<{ id: string }> }; + +export async function GET(_req: Request, { params }: Params) { + const { id } = await params; + const counter = db.prepare('SELECT * FROM counters WHERE id = ?').get(Number(id)); + if (!counter) return NextResponse.json({ error: 'Not found' }, { status: 404 }); + return NextResponse.json(counter); +} + +export async function PUT(request: Request, { params }: Params) { + const { id } = await params; + const body = await request.json(); + const existing = db.prepare('SELECT * FROM counters WHERE id = ?').get(Number(id)) as Record | undefined; + if (!existing) return NextResponse.json({ error: 'Not found' }, { status: 404 }); + + const name = body.name?.trim() ?? existing.name; + const value = body.value !== undefined ? body.value : existing.value; + const group_id = body.group_id !== undefined ? body.group_id : existing.group_id; + const image_path = body.image_path !== undefined ? body.image_path : existing.image_path; + + db.prepare( + 'UPDATE counters SET name = ?, value = ?, group_id = ?, image_path = ? WHERE id = ?' + ).run(name, value, group_id, image_path, Number(id)); + + const updated = db.prepare('SELECT * FROM counters WHERE id = ?').get(Number(id)); + return NextResponse.json(updated); +} + +export async function DELETE(_req: Request, { params }: Params) { + const { id } = await params; + const existing = db.prepare('SELECT * FROM counters WHERE id = ?').get(Number(id)) as Record | undefined; + if (!existing) return NextResponse.json({ error: 'Not found' }, { status: 404 }); + + // Remove associated image if present + if (existing.image_path) { + const imgFile = path.join(UPLOADS_DIR, path.basename(existing.image_path as string)); + try { fs.unlinkSync(imgFile); } catch { /* ignore missing file */ } + } + + db.prepare('DELETE FROM counters WHERE id = ?').run(Number(id)); + return new NextResponse(null, { status: 204 }); +} diff --git a/app/api/counters/route.ts b/app/api/counters/route.ts new file mode 100644 index 0000000..8f8d26a --- /dev/null +++ b/app/api/counters/route.ts @@ -0,0 +1,24 @@ +import { NextResponse } from 'next/server'; +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(); + return NextResponse.json(counters); +} + +export async function POST(request: Request) { + const { name, value = 0, group_id = null, image_path = null } = await request.json(); + if (!name?.trim()) { + return NextResponse.json({ error: 'Name is required' }, { status: 400 }); + } + const maxOrder = (db.prepare( + 'SELECT COALESCE(MAX(order_index), -1) AS m FROM counters' + ).get() as { m: number }).m; + const result = db.prepare( + 'INSERT INTO counters (name, value, group_id, image_path, order_index) VALUES (?, ?, ?, ?, ?)' + ).run(name.trim(), value, group_id, image_path, maxOrder + 1); + const counter = db.prepare('SELECT * FROM counters WHERE id = ?').get(result.lastInsertRowid); + return NextResponse.json(counter, { status: 201 }); +} diff --git a/app/api/groups/[id]/route.ts b/app/api/groups/[id]/route.ts new file mode 100644 index 0000000..9350333 --- /dev/null +++ b/app/api/groups/[id]/route.ts @@ -0,0 +1,28 @@ +import { NextResponse } from 'next/server'; +import db from '@/lib/db'; + +export const dynamic = 'force-dynamic'; + +type Params = { params: Promise<{ id: string }> }; + +export async function PUT(request: Request, { params }: Params) { + const { id } = await params; + const { name } = await request.json(); + const existing = db.prepare('SELECT * FROM groups WHERE id = ?').get(Number(id)); + if (!existing) return NextResponse.json({ error: 'Not found' }, { status: 404 }); + if (!name?.trim()) return NextResponse.json({ error: 'Name is required' }, { status: 400 }); + db.prepare('UPDATE groups SET name = ? WHERE id = ?').run(name.trim(), Number(id)); + const updated = db.prepare('SELECT * FROM groups WHERE id = ?').get(Number(id)); + return NextResponse.json(updated); +} + +export async function DELETE(_req: Request, { params }: Params) { + const { id } = await params; + const existing = db.prepare('SELECT * FROM groups WHERE id = ?').get(Number(id)); + if (!existing) return NextResponse.json({ error: 'Not found' }, { status: 404 }); + db.transaction(() => { + db.prepare('UPDATE counters SET group_id = NULL WHERE group_id = ?').run(Number(id)); + db.prepare('DELETE FROM groups WHERE id = ?').run(Number(id)); + })(); + return new NextResponse(null, { status: 204 }); +} diff --git a/app/api/groups/route.ts b/app/api/groups/route.ts new file mode 100644 index 0000000..1f5f46c --- /dev/null +++ b/app/api/groups/route.ts @@ -0,0 +1,24 @@ +import { NextResponse } from 'next/server'; +import db from '@/lib/db'; + +export const dynamic = 'force-dynamic'; + +export async function GET() { + const groups = db.prepare('SELECT * FROM groups ORDER BY order_index ASC, id ASC').all(); + return NextResponse.json(groups); +} + +export async function POST(request: Request) { + const { name } = await request.json(); + if (!name?.trim()) { + return NextResponse.json({ error: 'Name is required' }, { status: 400 }); + } + const maxOrder = (db.prepare( + 'SELECT COALESCE(MAX(order_index), -1) AS m FROM groups' + ).get() as { m: number }).m; + const result = db.prepare( + 'INSERT INTO groups (name, order_index) VALUES (?, ?)' + ).run(name.trim(), maxOrder + 1); + const group = db.prepare('SELECT * FROM groups WHERE id = ?').get(result.lastInsertRowid); + return NextResponse.json(group, { status: 201 }); +} diff --git a/app/api/reorder/route.ts b/app/api/reorder/route.ts new file mode 100644 index 0000000..2ad7250 --- /dev/null +++ b/app/api/reorder/route.ts @@ -0,0 +1,32 @@ +import { NextResponse } from 'next/server'; +import db from '@/lib/db'; + +export const dynamic = 'force-dynamic'; + +// Body: { counters?: { id: number; order_index: number; group_id: number | null }[], groups?: { id: number; order_index: number }[] } +export async function PUT(request: Request) { + const { counters, groups } = await request.json(); + + const updateCounters = db.prepare( + 'UPDATE counters SET order_index = ?, group_id = ? WHERE id = ?' + ); + const updateGroups = db.prepare( + 'UPDATE groups SET order_index = ? WHERE id = ?' + ); + + const reorderAll = db.transaction(() => { + if (Array.isArray(counters)) { + for (const c of counters) { + updateCounters.run(c.order_index, c.group_id ?? null, c.id); + } + } + if (Array.isArray(groups)) { + for (const g of groups) { + updateGroups.run(g.order_index, g.id); + } + } + }); + + reorderAll(); + return new NextResponse(null, { status: 204 }); +} diff --git a/app/api/upload/route.ts b/app/api/upload/route.ts new file mode 100644 index 0000000..90e2f89 --- /dev/null +++ b/app/api/upload/route.ts @@ -0,0 +1,37 @@ +import { NextResponse } from 'next/server'; +import path from 'path'; +import { writeFile } from 'fs/promises'; +import { randomUUID } from 'crypto'; +import { UPLOADS_DIR } from '@/lib/db'; + +export const dynamic = 'force-dynamic'; + +const ALLOWED_TYPES: Record = { + 'image/jpeg': 'jpg', + 'image/png': 'png', + 'image/gif': 'gif', + 'image/webp': 'webp', +}; +const MAX_SIZE_BYTES = 10 * 1024 * 1024; // 10 MB + +export async function POST(request: Request) { + const formData = await request.formData(); + const file = formData.get('file'); + + if (!file || !(file instanceof File)) { + return NextResponse.json({ error: 'No file provided' }, { status: 400 }); + } + if (!ALLOWED_TYPES[file.type]) { + return NextResponse.json({ error: 'Invalid file type' }, { status: 400 }); + } + if (file.size > MAX_SIZE_BYTES) { + return NextResponse.json({ error: 'File too large (max 10 MB)' }, { status: 400 }); + } + + const ext = ALLOWED_TYPES[file.type]; + const filename = `${randomUUID()}.${ext}`; + const buffer = Buffer.from(await file.arrayBuffer()); + await writeFile(path.join(UPLOADS_DIR, filename), buffer); + + return NextResponse.json({ filename }); +} diff --git a/app/api/uploads/[filename]/route.ts b/app/api/uploads/[filename]/route.ts new file mode 100644 index 0000000..67b502f --- /dev/null +++ b/app/api/uploads/[filename]/route.ts @@ -0,0 +1,48 @@ +import { NextResponse } from 'next/server'; +import path from 'path'; +import { readFile, stat, access } from 'fs/promises'; +import { createHash } from 'crypto'; +import { UPLOADS_DIR } from '@/lib/db'; + +export const dynamic = 'force-dynamic'; + +type Params = { params: Promise<{ filename: string }> }; + +const MIME_MAP: Record = { + jpg: 'image/jpeg', + jpeg: 'image/jpeg', + png: 'image/png', + gif: 'image/gif', + webp: 'image/webp', +}; + +export async function GET(req: Request, { params }: Params) { + const { filename } = await params; + // Prevent path traversal + const safe = path.basename(filename); + const filePath = path.join(UPLOADS_DIR, safe); + try { await access(filePath); } catch { + return NextResponse.json({ error: 'Not found' }, { status: 404 }); + } + + const ext = safe.split('.').pop()?.toLowerCase() ?? ''; + const contentType = MIME_MAP[ext] ?? 'application/octet-stream'; + + const stats = await stat(filePath); + const etag = `"${createHash('md5').update(`${stats.size}-${stats.mtimeMs}`).digest('hex')}"`; + + // Return 304 if browser already has this version + if (req.headers.get('if-none-match') === etag) { + return new NextResponse(null, { status: 304 }); + } + + const buffer = await readFile(filePath); + return new NextResponse(buffer, { + headers: { + 'Content-Type': contentType, + 'Cache-Control': 'public, max-age=31536000, immutable', + 'ETag': etag, + 'Last-Modified': stats.mtime.toUTCString(), + }, + }); +} diff --git a/app/components/CalendarView.tsx b/app/components/CalendarView.tsx new file mode 100644 index 0000000..ff66fe0 --- /dev/null +++ b/app/components/CalendarView.tsx @@ -0,0 +1,244 @@ +'use client'; + +import { useEffect, useState } from 'react'; + +export interface DayCounter { + date: string; // YYYY-MM-DD + counter_id: number; + counter_name: string; + image_path: string | null; + increments: number; +} + +interface Props { + dailyCounters: DayCounter[]; +} + +// Mon-first: 0=Mon … 6=Sun +const DOW = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; +const MONTH_NAMES = [ + 'January','February','March','April','May','June', + 'July','August','September','October','November','December', +]; + +function pad(n: number) { return String(n).padStart(2, '0'); } +function toDateStr(y: number, m: number, d: number) { + return `${y}-${pad(m + 1)}-${pad(d)}`; +} + +// Returns 0 (Mon) … 6 (Sun) for a given date +function mondayDow(year: number, month: number, day: number) { + const dow = new Date(year, month, day).getDay(); // 0=Sun…6=Sat + return (dow + 6) % 7; +} + +export default function CalendarView({ dailyCounters }: Props) { + const now = new Date(); + const [viewYear, setViewYear] = useState(now.getFullYear()); + const [viewMonth, setViewMonth] = useState(now.getMonth()); + const [selectedDay, setSelectedDay] = useState<{ date: string; counters: DayCounter[] } | null>(null); + + // Close modal on Escape + useEffect(() => { + if (!selectedDay) return; + const handler = (e: KeyboardEvent) => { if (e.key === 'Escape') setSelectedDay(null); }; + window.addEventListener('keydown', handler); + return () => window.removeEventListener('keydown', handler); + }, [selectedDay]); + + const byDate = new Map(); + for (const dc of dailyCounters) { + if (!byDate.has(dc.date)) byDate.set(dc.date, []); + byDate.get(dc.date)!.push(dc); + } + + const todayStr = toDateStr(now.getFullYear(), now.getMonth(), now.getDate()); + const isCurrentMonth = viewYear === now.getFullYear() && viewMonth === now.getMonth(); + + function prev() { + if (viewMonth === 0) { setViewMonth(11); setViewYear(y => y - 1); } + else setViewMonth(m => m - 1); + } + function next() { + if (viewMonth === 11) { setViewMonth(0); setViewYear(y => y + 1); } + else setViewMonth(m => m + 1); + } + + const daysInMonth = new Date(viewYear, viewMonth + 1, 0).getDate(); + const firstOffset = mondayDow(viewYear, viewMonth, 1); // blank cells before day 1 + const totalCells = Math.ceil((daysInMonth + firstOffset) / 7) * 7; + + const cells = Array.from({ length: totalCells }, (_, i) => { + const dayNum = i - firstOffset + 1; + if (dayNum < 1 || dayNum > daysInMonth) return null; + const date = toDateStr(viewYear, viewMonth, dayNum); + return { dayNum, date, counters: byDate.get(date) ?? [] }; + }); + + return ( +
+ {/* Month navigation */} +
+ + +
+ + {MONTH_NAMES[viewMonth]} {viewYear} + + {!isCurrentMonth && ( + + )} +
+ + +
+ + {/* Day-of-week header */} +
+ {DOW.map(d => ( +
+ {d} +
+ ))} +
+ + {/* Day cells */} +
+ {cells.map((cell, i) => + cell ? ( +
setSelectedDay({ date: cell.date, counters: cell.counters })} + className={`bg-ctp-base min-h-[4.5rem] p-1 flex flex-col gap-0.5 cursor-pointer hover:bg-ctp-surface0 transition-colors ${ + cell.date === todayStr ? 'ring-1 ring-inset ring-ctp-mauve' : '' + }`} + > + 0 + ? 'text-ctp-text' + : 'text-ctp-overlay1' + }`}> + {cell.dayNum} + + + {cell.counters.slice(0, 3).map(c => ( +
+ + {c.counter_name} + + + +{c.increments} + +
+ ))} + + {cell.counters.length > 3 && ( + + +{cell.counters.length - 3} more + + )} +
+ ) : ( +
+ ) + )} +
+ + {/* Day detail modal */} + {selectedDay && ( +
setSelectedDay(null)} + > +
e.stopPropagation()} + > + {/* Header */} +
+

+ {new Date(selectedDay.date + 'T00:00:00').toLocaleDateString(undefined, { + weekday: 'long', month: 'long', day: 'numeric', + })} +

+ +
+ + {/* Counter list */} + {selectedDay.counters.length === 0 ? ( +

No activity on this day.

+ ) : ( +
+ {selectedDay.counters.map(c => ( +
+ {c.image_path ? ( + // eslint-disable-next-line @next/next/no-img-element + + ) : ( +
+ # +
+ )} +
+

{c.counter_name}

+

+{c.increments}

+
+
+ ))} +
+ )} + + {/* Total */} + {selectedDay.counters.length > 1 && ( +

+ Total: + +{selectedDay.counters.reduce((s, c) => s + c.increments, 0)} + +

+ )} +
+
+ )} +
+ ); +} diff --git a/app/components/CounterCard.tsx b/app/components/CounterCard.tsx new file mode 100644 index 0000000..1821ac6 --- /dev/null +++ b/app/components/CounterCard.tsx @@ -0,0 +1,97 @@ +'use client'; + +export interface Counter { + id: number; + name: string; + value: number; + image_path: string | null; + group_id: number | null; + order_index: number; +} + +interface Props { + counter: Counter; + onIncrement: (id: number) => void; + onDecrement: (id: number) => void; + onEdit: (counter: Counter) => void; + onHistory: (id: number) => void; + onPrefetch?: (id: number) => void; + editMode?: boolean; + dragHandleProps?: Record; +} + +export default function CounterCard({ counter, onIncrement, onDecrement, onEdit, onHistory, onPrefetch, editMode, dragHandleProps }: Props) { + + return ( +
+ + {/* Title area — click opens edit modal */} + + + {/* Middle: image — click opens history modal */} + + + {/* Counter controls */} +
+
+ + + {counter.value} + + +
+
+
+ ); +} diff --git a/app/components/CounterDetailModal.tsx b/app/components/CounterDetailModal.tsx new file mode 100644 index 0000000..151ca3f --- /dev/null +++ b/app/components/CounterDetailModal.tsx @@ -0,0 +1,174 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { Counter } from './CounterCard'; +import HistoryChart, { DayValue } from './HistoryChart'; +import CalendarView, { DayCounter } from './CalendarView'; + +export interface HistoryData { + counter: Counter; + dailyActivity: DayValue[]; + allTimeTotal: number; +} + +interface Props { + counterId: number | null; + cache?: Map; + onClose: () => void; +} + +function toLocalDateStr(d: Date): string { + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; +} + +function computeCurrentStreak(dateSet: Set): number { + const today = new Date(); + let streak = 0; + const startOffset = dateSet.has(toLocalDateStr(today)) ? 0 : 1; + for (let i = startOffset; i < 400; i++) { + const d = new Date(today); + d.setDate(d.getDate() - i); + if (dateSet.has(toLocalDateStr(d))) streak++; + else break; + } + return streak; +} + +function computeLongestStreak(sortedDates: string[]): number { + if (sortedDates.length === 0) return 0; + let best = 1, cur = 1; + for (let i = 1; i < sortedDates.length; i++) { + const prev = new Date(sortedDates[i - 1] + 'T00:00:00'); + const curr = new Date(sortedDates[i] + 'T00:00:00'); + const diff = (curr.getTime() - prev.getTime()) / 86400000; + if (diff === 1) { cur++; if (cur > best) best = cur; } + else cur = 1; + } + return best; +} + +export default function CounterDetailModal({ counterId, cache, onClose }: Props) { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(false); + + useEffect(() => { + if (!counterId) return; + const cached = cache?.get(counterId); + if (cached) { setData(cached); setLoading(false); return; } + setData(null); + setLoading(true); + fetch(`/api/counters/${counterId}/history`) + .then(r => r.json()) + .then((d: HistoryData) => { cache?.set(counterId, d); setData(d); setLoading(false); }) + .catch(() => setLoading(false)); + }, [counterId, cache]); + + useEffect(() => { + if (!counterId) return; + const handler = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); }; + window.addEventListener('keydown', handler); + return () => window.removeEventListener('keydown', handler); + }, [counterId, onClose]); + + if (!counterId) return null; + + const dateSet = new Set(data?.dailyActivity.map(d => d.date) ?? []); + const sortedDates = [...dateSet].sort(); + const currentStreak = computeCurrentStreak(dateSet); + const longestStreak = computeLongestStreak(sortedDates); + + const calendarData: DayCounter[] = (data?.dailyActivity ?? []).map(d => ({ + date: d.date, + counter_id: data!.counter.id, + counter_name: data!.counter.name, + image_path: data!.counter.image_path, + increments: d.value, + })); + + const stats = data ? [ + { label: 'Current value', value: data.counter.value.toLocaleString() }, + { label: 'All-time net', value: data.allTimeTotal >= 0 ? `+${data.allTimeTotal}` : String(data.allTimeTotal) }, + { label: 'Current streak', value: `${currentStreak}d` }, + { label: 'Longest streak', value: `${longestStreak}d` }, + ] : []; + + return ( +
+
e.stopPropagation()} + > + {/* Header */} +
+ {data?.counter.image_path && ( + // eslint-disable-next-line @next/next/no-img-element + + )} +

+ {data?.counter.name ?? '…'} +

+ +
+ + {/* Body */} +
+ {loading && ( +
+

Loading…

+
+ )} + + {!loading && data && ( + <> + {/* Stat cards */} +
+ {stats.map(({ label, value }) => ( +
+

{value}

+

{label}

+
+ ))} +
+ + {data.dailyActivity.length === 0 ? ( +
+

No activity in the last year.

+

Start tapping + to see history here.

+
+ ) : ( + <> + {/* 90-day bar chart */} +
+

Last 90 Days

+ +
+ + {/* Calendar */} +
+

Activity Calendar

+ +
+ + )} + + )} +
+
+
+ ); +} diff --git a/app/components/CounterModal.tsx b/app/components/CounterModal.tsx new file mode 100644 index 0000000..6251faa --- /dev/null +++ b/app/components/CounterModal.tsx @@ -0,0 +1,239 @@ +'use client'; + +import { useEffect, useRef, useState } from 'react'; +import { Counter } from './CounterCard'; + +export interface Group { + id: number; + name: string; + order_index: number; +} + +interface Props { + open: boolean; + initial?: Counter | null; + groups: Group[]; + onClose: () => void; + onSave: (data: { + name: string; + value: number; + group_id: number | null; + image_path: string | null; + }) => void; + onDelete?: (id: number) => void; +} + +export default function CounterModal({ open, initial, groups, onClose, onSave, onDelete }: Props) { + const [confirmDelete, setConfirmDelete] = useState(false); + const deleteTimer = useRef | null>(null); + const [name, setName] = useState(''); + const [value, setValue] = useState(0); + const [groupId, setGroupId] = useState(null); + const [imagePath, setImagePath] = useState(null); + const [previewUrl, setPreviewUrl] = useState(null); + const [uploading, setUploading] = useState(false); + const [error, setError] = useState(''); + const nameRef = useRef(null); + + // Revoke blob URLs when previewUrl changes (prevents memory leaks) + useEffect(() => { + const url = previewUrl; + return () => { if (url?.startsWith('blob:')) URL.revokeObjectURL(url); }; + }, [previewUrl]); + + useEffect(() => { + if (open) { + setName(initial?.name ?? ''); + setValue(initial?.value ?? 0); + setGroupId(initial?.group_id ?? null); + setImagePath(initial?.image_path ?? null); + setPreviewUrl(initial?.image_path ? `/api/uploads/${initial.image_path}` : null); + setError(''); + setConfirmDelete(false); + if (deleteTimer.current) clearTimeout(deleteTimer.current); + setTimeout(() => nameRef.current?.focus(), 50); + } + }, [open, initial]); + + async function handleFileChange(e: React.ChangeEvent) { + const file = e.target.files?.[0]; + if (!file) return; + setPreviewUrl(URL.createObjectURL(file)); + setUploading(true); + try { + const fd = new FormData(); + fd.append('file', file); + const res = await fetch('/api/upload', { method: 'POST', body: fd }); + if (!res.ok) { + const body = await res.json(); + setError(body.error ?? 'Upload failed'); + return; + } + const { filename } = await res.json(); + setImagePath(filename); + } catch { + setError('Upload failed'); + } finally { + setUploading(false); + } + } + + function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + if (!name.trim()) { setError('Name is required'); return; } + if (uploading) { setError('Please wait for the image to finish uploading'); return; } + onSave({ name: name.trim(), value, group_id: groupId, image_path: imagePath }); + } + + function handleBackdrop(e: React.MouseEvent) { + if (e.target === e.currentTarget) onClose(); + } + + useEffect(() => { + if (!open) return; + function onKeyDown(e: KeyboardEvent) { + if (e.key === 'Escape') onClose(); + } + window.addEventListener('keydown', onKeyDown); + return () => window.removeEventListener('keydown', onKeyDown); + }, [open, onClose]); + + if (!open) return null; + + return ( +
+
+
+

+ {initial ? 'Edit Counter' : 'New Counter'} +

+ +
+ +
+ {error && ( +

{error}

+ )} + + {/* Name */} +
+ + setName(e.target.value)} + className="w-full 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" + placeholder="Counter name" + /> +
+ + {/* Initial value */} +
+ + setValue(Number(e.target.value))} + className="w-full 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" + /> +
+ + {/* Group */} +
+ + +
+ + {/* Image upload */} +
+ + {previewUrl && ( +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + Preview + +
+ )} + +
+ +
+ {/* Delete — only shown when editing an existing counter */} + {initial && onDelete ? ( + + ) : } + +
+ + +
+
+
+
+
+ ); +} diff --git a/app/components/GroupSection.tsx b/app/components/GroupSection.tsx new file mode 100644 index 0000000..b634e92 --- /dev/null +++ b/app/components/GroupSection.tsx @@ -0,0 +1,144 @@ +'use client'; + +import { useState } from 'react'; +import { SortableContext, rectSortingStrategy } from '@dnd-kit/sortable'; +import { useDroppable } from '@dnd-kit/core'; +import SortableCounter from './SortableCounter'; +import { Counter } from './CounterCard'; +import { Group } from './CounterModal'; + +interface Props { + group: Group | null; // null = ungrouped section + counters: Counter[]; + onIncrement: (id: number) => void; + onDecrement: (id: number) => void; + onEdit: (counter: Counter) => void; + onHistory: (id: number) => void; + onPrefetch?: (id: number) => void; + editMode: boolean; + dragHandleProps?: Record; + onRenameGroup?: (group: Group) => void; + onDeleteGroup?: (id: number) => void; +} + +export default function GroupSection({ + group, + counters, + onIncrement, + onDecrement, + onEdit, + onHistory, + onPrefetch, + editMode, + dragHandleProps, + onRenameGroup, + onDeleteGroup, +}: Props) { + const droppableId = group ? `group-${group.id}` : 'ungrouped'; + const { setNodeRef, isOver } = useDroppable({ id: droppableId, data: { groupId: group?.id ?? null } }); + const [editingName, setEditingName] = useState(false); + const [newName, setNewName] = useState(group?.name ?? ''); + const [confirmDelete, setConfirmDelete] = useState(false); + + function commitRename() { + if (group && newName.trim() && newName.trim() !== group.name) { + onRenameGroup?.({ ...group, name: newName.trim() }); + } + setEditingName(false); + } + + const isEmpty = counters.length === 0; + + return ( +
+ {/* Section header */} +
+ {group && editMode && dragHandleProps && ( +
+ + + + +
+ )} + {group ? ( + editingName ? ( + setNewName(e.target.value)} + onBlur={commitRename} + onKeyDown={e => { if (e.key === 'Enter') commitRename(); if (e.key === 'Escape') setEditingName(false); }} + className="text-lg font-bold bg-transparent border-b-2 border-ctp-mauve outline-none text-ctp-text" + /> + ) : ( + editMode ? ( +

{ setNewName(group.name); setEditingName(true); }} + > + {group.name} +

+ ) : ( +

{group.name}

+ ) + ) + ) : ( +

Ungrouped

+ )} + + {group && editMode && ( + + )} +
+ + {/* Drop zone */} +
+ c.id)} strategy={rectSortingStrategy}> + {counters.map(counter => ( + + ))} + + {isEmpty && ( +

+ Drop counters here +

+ )} +
+
+ ); +} diff --git a/app/components/HistoryChart.tsx b/app/components/HistoryChart.tsx new file mode 100644 index 0000000..28db2d3 --- /dev/null +++ b/app/components/HistoryChart.tsx @@ -0,0 +1,105 @@ +'use client'; + +import { useState } from 'react'; + +export interface DayValue { + date: string; // YYYY-MM-DD + value: number; +} + +interface Props { + data: DayValue[]; + days?: number; +} + +function toLocalDateStr(d: Date): string { + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; +} + +const MONTH_ABBR = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; + +export default function HistoryChart({ data, days = 90 }: Props) { + const [hovered, setHovered] = useState(null); + + const byDate = new Map(data.map(d => [d.date, d.value])); + const today = new Date(); + + const filled = Array.from({ length: days }, (_, i) => { + const d = new Date(today); + d.setDate(d.getDate() - (days - 1 - i)); + const date = toLocalDateStr(d); + return { date, value: byDate.get(date) ?? 0, month: d.getMonth() }; + }); + + const maxVal = Math.max(...filled.map(d => d.value), 1); + const CHART_H = 40; + const BAR_UNIT = 100 / days; + + // Show a month label whenever the month changes + const monthLabels: { label: string; pct: number }[] = []; + filled.forEach((d, i) => { + if (i === 0 || d.month !== filled[i - 1].month) { + monthLabels.push({ label: MONTH_ABBR[d.month], pct: (i / days) * 100 }); + } + }); + + const hoveredDay = hovered !== null ? filled[hovered] : null; + + return ( +
+
+ {/* Tooltip */} + {hoveredDay && hovered !== null && ( +
+ + {hoveredDay.value > 0 ? `+${hoveredDay.value}` : '–'} + + {hoveredDay.date} +
+ )} + + + {filled.map((d, i) => { + const barH = d.value > 0 ? Math.max(1.5, (d.value / maxVal) * (CHART_H - 2)) : 1; + const x = i * BAR_UNIT + BAR_UNIT * 0.1; + const w = BAR_UNIT * 0.8; + const y = d.value > 0 ? CHART_H - barH : CHART_H - 1; + return ( + 0 ? 'fill-ctp-mauve' : 'fill-ctp-surface1'} + style={{ opacity: hovered === i ? 0.6 : 1 }} + onMouseEnter={() => setHovered(i)} + onMouseLeave={() => setHovered(null)} + /> + ); + })} + +
+ + {/* Month labels */} +
+ {monthLabels.map(({ label, pct }) => ( + + {label} + + ))} +
+
+ ); +} diff --git a/app/components/SortableCounter.tsx b/app/components/SortableCounter.tsx new file mode 100644 index 0000000..bd0d747 --- /dev/null +++ b/app/components/SortableCounter.tsx @@ -0,0 +1,41 @@ +'use client'; + +import { useSortable } from '@dnd-kit/sortable'; +import { CSS } from '@dnd-kit/utilities'; +import CounterCard, { Counter } from './CounterCard'; + +interface Props { + counter: Counter; + onIncrement: (id: number) => void; + onDecrement: (id: number) => void; + onEdit: (counter: Counter) => void; + onHistory: (id: number) => void; + onPrefetch?: (id: number) => void; + editMode: boolean; +} + +export default function SortableCounter({ counter, editMode, ...handlers }: Props) { + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ + id: counter.id, + data: { type: 'counter', groupId: counter.group_id }, + disabled: !editMode, + }); + + const style = { + transform: CSS.Transform.toString(transform), + transition, + opacity: isDragging ? 0.4 : 1, + zIndex: isDragging ? 50 : undefined, + }; + + return ( +
+ +
+ ); +} diff --git a/app/components/SortableGroupSection.tsx b/app/components/SortableGroupSection.tsx new file mode 100644 index 0000000..9ab8525 --- /dev/null +++ b/app/components/SortableGroupSection.tsx @@ -0,0 +1,45 @@ +'use client'; + +import { useSortable } from '@dnd-kit/sortable'; +import { CSS } from '@dnd-kit/utilities'; +import GroupSection from './GroupSection'; +import { Counter } from './CounterCard'; +import { Group } from './CounterModal'; + +interface Props { + group: Group; + counters: Counter[]; + onIncrement: (id: number) => void; + onDecrement: (id: number) => void; + onEdit: (counter: Counter) => void; + onHistory: (id: number) => void; + onPrefetch?: (id: number) => void; + editMode: boolean; + onRenameGroup: (group: Group) => void; + onDeleteGroup: (id: number) => void; +} + +export default function SortableGroupSection({ group, editMode, ...props }: Props) { + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ + id: `grp-${group.id}`, + data: { type: 'group' }, + disabled: !editMode, + }); + + const style = { + transform: CSS.Transform.toString(transform), + transition, + opacity: isDragging ? 0.4 : 1, + }; + + return ( +
+ +
+ ); +} diff --git a/app/favicon.ico b/app/favicon.ico index 718d6fe..04cc6b2 100644 Binary files a/app/favicon.ico and b/app/favicon.ico differ diff --git a/app/globals.css b/app/globals.css index a2dc41e..ce42b7f 100644 --- a/app/globals.css +++ b/app/globals.css @@ -1,26 +1,12 @@ @import "tailwindcss"; - -:root { - --background: #ffffff; - --foreground: #171717; -} +@import "@catppuccin/tailwindcss/mocha.css"; @theme inline { - --color-background: var(--background); - --color-foreground: var(--foreground); --font-sans: var(--font-geist-sans); - --font-mono: var(--font-geist-mono); } -@media (prefers-color-scheme: dark) { - :root { - --background: #0a0a0a; - --foreground: #ededed; +@layer base { + button, [role="button"], label, select { + cursor: pointer; } } - -body { - background: var(--background); - color: var(--foreground); - font-family: Arial, Helvetica, sans-serif; -} diff --git a/app/layout.tsx b/app/layout.tsx index 976eb90..7f25882 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,5 +1,5 @@ import type { Metadata } from "next"; -import { Geist, Geist_Mono } from "next/font/google"; +import { Geist } from "next/font/google"; import "./globals.css"; const geistSans = Geist({ @@ -7,14 +7,9 @@ const geistSans = Geist({ subsets: ["latin"], }); -const geistMono = Geist_Mono({ - variable: "--font-geist-mono", - subsets: ["latin"], -}); - export const metadata: Metadata = { - title: "Create Next App", - description: "Generated by create next app", + title: "Tally Counter", + description: "Keep track of your tally counters", }; export default function RootLayout({ @@ -25,7 +20,7 @@ export default function RootLayout({ return ( {children} diff --git a/app/page.tsx b/app/page.tsx index 3f36f7c..d0d8927 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,65 +1,417 @@ -import Image from "next/image"; +'use client'; + +import { useCallback, useEffect, useRef, useState } from 'react'; +import Link from 'next/link'; +import { useRouter } from 'next/navigation'; +import { + DndContext, + DragEndEvent, + DragOverEvent, + DragStartEvent, + DragOverlay, + PointerSensor, + useSensor, + useSensors, + closestCenter, +} from '@dnd-kit/core'; +import { arrayMove, SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable'; +import CounterCard, { Counter } from './components/CounterCard'; +import CounterModal, { Group } from './components/CounterModal'; +import CounterDetailModal, { HistoryData } from './components/CounterDetailModal'; +import GroupSection from './components/GroupSection'; +import SortableGroupSection from './components/SortableGroupSection'; export default function Home() { - return ( -
-
- Next.js logo -
-

- To get started, edit the page.tsx file. -

-

- Looking for a starting point or more instructions? Head over to{" "} - - Templates - {" "} - or the{" "} - - Learning - {" "} - center. -

-
-
- - Vercel logomark - Deploy Now - - - Documentation - + const router = useRouter(); + const [counters, setCounters] = useState([]); + const [groups, setGroups] = useState([]); + const [modalOpen, setModalOpen] = useState(false); + const [editingCounter, setEditingCounter] = useState(null); + const [activeCounter, setActiveCounter] = useState(null); + const [activeGroup, setActiveGroup] = useState(null); + const [newGroupName, setNewGroupName] = useState(''); + const [addingGroup, setAddingGroup] = useState(false); + const [loading, setLoading] = useState(true); + const [historyCounterId, setHistoryCounterId] = useState(null); + const [editMode, setEditMode] = useState(false); + + // Always-current ref so drag handlers never read stale closure state + const countersRef = useRef(counters); + countersRef.current = counters; + + // Pre-fetch cache for history modal + const historyCache = useRef>(new Map()); + const handlePrefetch = useCallback((id: number) => { + if (historyCache.current.has(id)) return; + fetch(`/api/counters/${id}/history`) + .then(r => r.json()) + .then(d => historyCache.current.set(id, d)) + .catch(() => {}); + }, []); + + const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 6 } })); + + // ── Data fetching ────────────────────────────────────────────────────────── + async function load() { + try { + const [cRes, gRes] = await Promise.all([fetch('/api/counters'), fetch('/api/groups')]); + const [c, g] = await Promise.all([cRes.json(), gRes.json()]); + setCounters(c); + setGroups(g); + } finally { + setLoading(false); + } + } + + useEffect(() => { load(); }, []); + useEffect(() => { router.prefetch('/stats'); }, [router]); + + // ── Optimistic increment/decrement ───────────────────────────────────────── + const handleIncrement = useCallback(async (id: number) => { + setCounters(prev => prev.map(c => c.id === id ? { ...c, value: c.value + 1 } : c)); + const res = await fetch(`/api/counters/${id}/increment`, { method: 'POST' }); + if (!res.ok) setCounters(prev => prev.map(c => c.id === id ? { ...c, value: c.value - 1 } : c)); + }, []); + + const handleDecrement = useCallback(async (id: number) => { + const counter = countersRef.current.find(c => c.id === id); + if (!counter || counter.value <= 0) return; + setCounters(prev => prev.map(c => c.id === id ? { ...c, value: c.value - 1 } : c)); + const res = await fetch(`/api/counters/${id}/decrement`, { method: 'POST' }); + if (!res.ok) setCounters(prev => prev.map(c => c.id === id ? { ...c, value: c.value + 1 } : c)); + }, []); + + // ── Create / Edit counter ────────────────────────────────────────────────── + async function handleSaveCounter(data: { name: string; value: number; group_id: number | null; image_path: string | null }) { + if (editingCounter) { + // Optimistic update — close immediately, patch in background + const optimistic = { ...editingCounter, ...data }; + setCounters(prev => prev.map(c => c.id === editingCounter.id ? optimistic : c)); + setModalOpen(false); + setEditingCounter(null); + const res = await fetch(`/api/counters/${editingCounter.id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }); + if (res.ok) { + const updated = await res.json(); + setCounters(prev => prev.map(c => c.id === updated.id ? updated : c)); + } else { + // Roll back + setCounters(prev => prev.map(c => c.id === editingCounter.id ? editingCounter : c)); + } + } else { + // Optimistic create with a temporary id + const tempId = -Date.now(); + const optimistic = { ...data, id: tempId, order_index: 0 }; + setCounters(prev => [...prev, optimistic]); + setModalOpen(false); + setEditingCounter(null); + const res = await fetch('/api/counters', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }); + if (res.ok) { + const created = await res.json(); + setCounters(prev => prev.map(c => c.id === tempId ? created : c)); + } else { + // Roll back + setCounters(prev => prev.filter(c => c.id !== tempId)); + } + } + } + + // ── Delete counter ───────────────────────────────────────────────────────── + async function handleDeleteCounter(id: number) { + const prev = counters.find(c => c.id === id); + setCounters(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)); + } + + // ── Groups ───────────────────────────────────────────────────────────────── + async function handleCreateGroup() { + if (!newGroupName.trim()) return; + const res = await fetch('/api/groups', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: newGroupName.trim() }), + }); + const created = await res.json(); + setGroups(prev => [...prev, created]); + setNewGroupName(''); + setAddingGroup(false); + } + + async function handleRenameGroup(group: Group) { + const res = await fetch(`/api/groups/${group.id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: group.name }), + }); + const updated = await res.json(); + setGroups(prev => prev.map(g => g.id === group.id ? updated : g)); + } + + async function handleDeleteGroup(id: number) { + setGroups(prev => prev.filter(g => g.id !== id)); + setCounters(prev => prev.map(c => c.group_id === id ? { ...c, group_id: null } : c)); + await fetch(`/api/groups/${id}`, { method: 'DELETE' }); + } + + // ── Drag and drop ────────────────────────────────────────────────────────── + function handleDragStart({ active }: DragStartEvent) { + if (active.data.current?.type === 'group') { + setActiveGroup(groups.find(g => `grp-${g.id}` === String(active.id)) ?? null); + setActiveCounter(null); + } else { + setActiveCounter(countersRef.current.find(c => c.id === active.id) ?? null); + setActiveGroup(null); + } + } + + function handleDragOver({ active, over }: DragOverEvent) { + if (active.data.current?.type === 'group') return; + if (!over) return; + const activeId = active.id as number; + const overId = over.id as string | number; + + let targetGroupId: number | null = null; + if (typeof overId === 'string' && overId.startsWith('group-')) { + targetGroupId = Number(overId.replace('group-', '')); + } else if (overId === 'ungrouped') { + targetGroupId = null; + } else { + const overCounter = countersRef.current.find(c => c.id === overId); + if (overCounter) targetGroupId = overCounter.group_id; + } + + const activeC = countersRef.current.find(c => c.id === activeId); + if (!activeC) return; + if (activeC.group_id === targetGroupId) return; + setCounters(prev => prev.map(c => c.id === activeId ? { ...c, group_id: targetGroupId } : c)); + } + + function handleDragEnd({ active, over }: DragEndEvent) { + setActiveCounter(null); + setActiveGroup(null); + if (!over) return; + + // Group reorder + if (active.data.current?.type === 'group') { + const activeIdx = groups.findIndex(g => `grp-${g.id}` === String(active.id)); + const overIdx = groups.findIndex(g => `grp-${g.id}` === String(over.id)); + if (activeIdx === -1 || overIdx === -1 || activeIdx === overIdx) return; + setGroups(arrayMove(groups, activeIdx, overIdx).map((g, i) => ({ ...g, order_index: i }))); + return; + } + + // Counter reorder + const activeId = active.id as number; + const overId = over.id as string | number; + + if (typeof overId === 'string') { + let targetGroupId: number | null = null; + if (overId.startsWith('group-')) targetGroupId = Number(overId.replace('group-', '')); + setCounters(prev => prev.map(c => c.id === activeId ? { ...c, group_id: targetGroupId } : c)); + return; + } + + setCounters(prev => { + const activeIdx = prev.findIndex(c => c.id === activeId); + const overIdx = prev.findIndex(c => c.id === (overId as number)); + if (activeIdx === -1 || overIdx === -1 || activeIdx === overIdx) return prev; + const targetGroupId = prev[overIdx].group_id; + return arrayMove(prev, activeIdx, overIdx).map((c, i) => ({ + ...c, + order_index: i, + group_id: c.id === activeId ? targetGroupId : c.group_id, + })); + }); + } + + // ── Derived data ─────────────────────────────────────────────────────────── + const ungrouped = counters.filter(c => c.group_id === null); + + if (loading) { + return ( +
+
+
+

Tally counters

+
+ {[16, 24, 16, 28].map((w, i) => ( +
+ ))} +
+
+
+ {Array.from({ length: 10 }).map((_, i) => ( +
+ ))} +
-
+ ); + } + + return ( +
+
+ {/* Header */} +
+

Tally counters

+
+ + + + + + + Stats + + {addingGroup ? ( +
+ setNewGroupName(e.target.value)} + onKeyDown={e => { if (e.key === 'Enter') handleCreateGroup(); if (e.key === 'Escape') { setAddingGroup(false); setNewGroupName(''); } }} + placeholder="Group name…" + 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" + /> + + +
+ ) : ( + + )} + + +
+
+ + {/* Board */} + + {/* Groups */} + `grp-${g.id}`)} strategy={verticalListSortingStrategy}> + {groups.map(group => ( + c.group_id === group.id)} + onIncrement={handleIncrement} + onDecrement={handleDecrement} + onEdit={c => { setEditingCounter(c); setModalOpen(true); }} + onHistory={setHistoryCounterId} + onPrefetch={handlePrefetch} + editMode={editMode} + onRenameGroup={handleRenameGroup} + onDeleteGroup={handleDeleteGroup} + /> + ))} + + + { setEditingCounter(c); setModalOpen(true); }} + onHistory={setHistoryCounterId} + onPrefetch={handlePrefetch} + editMode={editMode} + /> + + + {activeCounter && ( +
+ {}} + onDecrement={() => {}} + onEdit={() => {}} + onHistory={() => {}} + /> +
+ )} + {activeGroup && ( +
+ {activeGroup.name} +
+ )} +
+
+ + {counters.length === 0 && ( +
+

No counters yet.

+ +
+ )} +
+ + { setModalOpen(false); setEditingCounter(null); }} + onSave={handleSaveCounter} + onDelete={handleDeleteCounter} + /> + + setHistoryCounterId(null)} + /> +
); } diff --git a/app/stats/loading.tsx b/app/stats/loading.tsx new file mode 100644 index 0000000..06ad0d6 --- /dev/null +++ b/app/stats/loading.tsx @@ -0,0 +1,39 @@ +export default function StatsLoading() { + return ( +
+
+
+
+
+
+
+
+
+
+ {Array.from({ length: 4 }).map((_, i) => ( +
+
+
+
+
+
+
+
+
+
+ ))} +
+
+
+
+
+ {Array.from({ length: 35 }).map((_, i) => ( +
+ ))} +
+
+
+
+
+ ); +} diff --git a/app/stats/page.tsx b/app/stats/page.tsx new file mode 100644 index 0000000..3091821 --- /dev/null +++ b/app/stats/page.tsx @@ -0,0 +1,131 @@ +import Link from 'next/link'; +import db from '@/lib/db'; +import CalendarView, { DayCounter } from '../components/CalendarView'; + +export const dynamic = 'force-dynamic'; + +interface TopCounter { + id: number; + name: string; + image_path: string | null; + increments: number; +} + +export default function StatsPage() { + const cutoff = Date.now() - 365 * 24 * 60 * 60 * 1000; + + const topCounters = db.prepare(` + SELECT + c.id, + c.name, + c.image_path, + COALESCE(SUM(e.delta), 0) AS increments + FROM counters c + LEFT JOIN events e ON e.counter_id = c.id + GROUP BY c.id + HAVING increments > 0 + ORDER BY increments DESC + LIMIT 10 + `).all() as TopCounter[]; + + const dailyCounters = db.prepare(` + SELECT + date(e.created_at / 1000, 'unixepoch', 'localtime') AS date, + c.id AS counter_id, + c.name AS counter_name, + c.image_path AS image_path, + SUM(e.delta) AS increments + FROM events e + JOIN counters c ON c.id = e.counter_id + WHERE e.created_at >= ? + GROUP BY date, c.id + HAVING SUM(e.delta) > 0 + ORDER BY date ASC, increments DESC + `).all(cutoff) as DayCounter[]; + + const maxIncrements = Math.max(...topCounters.map(c => c.increments), 1); + const totalIncrements = dailyCounters.reduce((s, d) => s + d.increments, 0); + + return ( +
+
+ + {/* Header */} +
+ + + + + Back + +

Statistics

+
+ + {/* Empty state */} + {totalIncrements === 0 && topCounters.length === 0 ? ( +
+

No activity recorded yet.

+

Start tapping + on your counters to see stats appear here.

+
+ ) : ( +
+ + {/* Top counters */} + {topCounters.length > 0 && ( +
+

Most Active Counters

+
+ {topCounters.map(counter => ( +
+
+
+ {counter.image_path ? ( + // eslint-disable-next-line @next/next/no-img-element + + ) : ( +
+ )} + + {counter.name} + +
+ + +{counter.increments} + +
+
+
+
+
+ ))} +
+
+ )} + + {/* Activity calendar */} + {totalIncrements > 0 && ( +
+
+

Activity — Last 12 Months

+ {totalIncrements} total increments +
+ +
+ )} + +
+ )} +
+
+ ); +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..9cef501 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,13 @@ +services: + app: + build: . + ports: + - "3000:3000" + volumes: + - app_data:/data + environment: + - DATA_DIR=/data + restart: unless-stopped + +volumes: + app_data: diff --git a/lib/db.ts b/lib/db.ts new file mode 100644 index 0000000..c5fcfad --- /dev/null +++ b/lib/db.ts @@ -0,0 +1,46 @@ +import Database from 'better-sqlite3'; +import path from 'path'; +import fs from 'fs'; + +const DATA_DIR = process.env.DATA_DIR ?? path.join(process.cwd(), '.data'); +const UPLOADS_DIR = path.join(DATA_DIR, 'uploads'); +const DB_PATH = path.join(DATA_DIR, 'tally.db'); + +// Ensure directories exist +fs.mkdirSync(UPLOADS_DIR, { recursive: true }); + +const db = new Database(DB_PATH, { timeout: 5000 }); + +// Performance: WAL mode for concurrent reads + writes +db.pragma('journal_mode = WAL'); +db.pragma('foreign_keys = ON'); + +// Schema migrations +db.exec(` + CREATE TABLE IF NOT EXISTS groups ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + order_index INTEGER NOT NULL DEFAULT 0 + ); + + CREATE TABLE IF NOT EXISTS counters ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + value INTEGER NOT NULL DEFAULT 0, + image_path TEXT, + group_id INTEGER REFERENCES groups(id) ON DELETE SET NULL, + order_index INTEGER NOT NULL DEFAULT 0 + ); + + CREATE TABLE IF NOT EXISTS events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + counter_id INTEGER NOT NULL REFERENCES counters(id) ON DELETE CASCADE, + delta INTEGER NOT NULL, + created_at INTEGER NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_events_counter ON events(counter_id); + CREATE INDEX IF NOT EXISTS idx_events_date ON events(created_at); +`); + +export { UPLOADS_DIR }; +export default db; diff --git a/next.config.ts b/next.config.ts index e9ffa30..687d933 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,7 +1,9 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { - /* config options here */ + output: "standalone", + // Images are served via our own /api/uploads route handler, so no external hostname needed. + // next/image requires a remotePatterns entry only for external domains. }; export default nextConfig; diff --git a/package-lock.json b/package-lock.json index 43ee72c..f5fe8f1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,20 +1,26 @@ { - "name": "tally-counter-scaffold", + "name": "tally-counter", "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "tally-counter-scaffold", + "name": "tally-counter", "version": "0.1.0", "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", + "better-sqlite3": "^12.10.0", "next": "16.2.7", "react": "19.2.4", "react-dom": "19.2.4" }, "devDependencies": { + "@catppuccin/tailwindcss": "^1.0.0", "@tailwindcss/postcss": "^4", - "@types/node": "^20", + "@types/better-sqlite3": "^7.6.13", + "@types/node": "^22", "@types/react": "^19", "@types/react-dom": "^19", "eslint": "^9", @@ -277,28 +283,68 @@ "node": ">=6.9.0" } }, - "node_modules/@emnapi/core": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", - "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "node_modules/@catppuccin/tailwindcss": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@catppuccin/tailwindcss/-/tailwindcss-1.0.0.tgz", + "integrity": "sha512-l8pOlcYe2ncGd8a1gUmL5AHmKlxR2+CHuG5kt4Me6IZwzntW1DoLmj89BH+DcsPHBsdDGLrTSv35emlYyU3FeQ==", "dev": true, "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.1", - "tslib": "^2.4.0" + "engines": { + "node": ">=22.0.0" } }, - "node_modules/@emnapi/runtime": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", - "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "node_modules/@dnd-kit/accessibility": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", + "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/core": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", + "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", "license": "MIT", - "optional": true, "peer": true, "dependencies": { - "tslib": "^2.4.0" + "@dnd-kit/accessibility": "^3.1.1", + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/sortable": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz", + "integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==", + "license": "MIT", + "dependencies": { + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@dnd-kit/core": "^6.3.0", + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/utilities": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz", + "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" } }, "node_modules/@emnapi/wasi-threads": { @@ -1547,6 +1593,16 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/better-sqlite3": { + "version": "7.6.13", + "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz", + "integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -1569,9 +1625,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "20.19.42", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.42.tgz", - "integrity": "sha512-5L7SUaFC1RyDraj2yRhyBzHTobyXHmohD100CChNtyPyleoq37Mqab5Gn8XEKI04dfN/oqPdpHk38MgcQWHbZg==", + "version": "22.19.20", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.20.tgz", + "integrity": "sha512-6tELRwSDYWW9EdZhbeZmYGZ1/7Djkt+Ah3/ScEYT9cDord7UJzasR/4D3VONg9tQI5CDp+/CZC1AXj2pCFOvpw==", "dev": true, "license": "MIT", "dependencies": { @@ -2502,6 +2558,26 @@ "dev": true, "license": "MIT" }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/baseline-browser-mapping": { "version": "2.10.34", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.34.tgz", @@ -2514,6 +2590,40 @@ "node": ">=6.0.0" } }, + "node_modules/better-sqlite3": { + "version": "12.10.0", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.10.0.tgz", + "integrity": "sha512-CyzaZRQKyHkB2ZInfTTl2nvT33EbDpjkLEbE8/Zck3Ll6O0qqvuGdrJ45HgtH+HykRg88ITY3AdreBGN70aBSQ==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" + }, + "engines": { + "node": "20.x || 22.x || 23.x || 24.x || 25.x || 26.x" + } + }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "license": "MIT", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, "node_modules/brace-expansion": { "version": "1.1.15", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", @@ -2573,6 +2683,30 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, "node_modules/call-bind": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", @@ -2670,6 +2804,12 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC" + }, "node_modules/client-only": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", @@ -2811,6 +2951,30 @@ } } }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -2858,7 +3022,6 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "devOptional": true, "license": "Apache-2.0", "engines": { "node": ">=8" @@ -2906,6 +3069,15 @@ "dev": true, "license": "MIT" }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, "node_modules/enhanced-resolve": { "version": "5.23.0", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.23.0.tgz", @@ -3312,6 +3484,7 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -3527,6 +3700,15 @@ "node": ">=0.10.0" } }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "license": "(MIT OR WTFPL)", + "engines": { + "node": ">=6" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -3601,6 +3783,12 @@ "node": ">=16.0.0" } }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "license": "MIT" + }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -3668,6 +3856,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -3799,6 +3993,12 @@ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "license": "MIT" + }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -3973,6 +4173,26 @@ "hermes-estree": "0.25.1" } }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -4010,6 +4230,18 @@ "node": ">=0.8.19" } }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" + }, "node_modules/internal-slot": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", @@ -4970,6 +5202,18 @@ "node": ">=8.6" } }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/minimatch": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", @@ -4987,12 +5231,17 @@ "version": "1.2.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "license": "MIT" + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -5018,6 +5267,12 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "license": "MIT" + }, "node_modules/napi-postinstall": { "version": "0.3.4", "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz", @@ -5122,6 +5377,30 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/node-abi": { + "version": "3.92.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.92.0.tgz", + "integrity": "sha512-KdHvFWZjEKDf0cakgFjebl371GPsISX2oZHcuyKqM7DtogIsHrqKeLTo8wBHxaXRAQlY2PsPlZmfo+9ZCxEREQ==", + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-abi/node_modules/semver": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.2.tgz", + "integrity": "sha512-c8jsqUZm3omBOI66G90z1Dyw5z622G8oLG+omfsHBJf3CWQTlOcwOjvOG6wtiNfW6anKm/eA39LMwMtMez2TiQ==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/node-exports-info": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.0.tgz", @@ -5274,6 +5553,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -5440,6 +5728,33 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -5462,6 +5777,16 @@ "react-is": "^16.13.1" } }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -5493,6 +5818,30 @@ ], "license": "MIT" }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/rc/node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/react": { "version": "19.2.4", "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", @@ -5523,6 +5872,20 @@ "dev": true, "license": "MIT" }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -5666,6 +6029,26 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/safe-push-apply": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", @@ -5923,6 +6306,51 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -5953,6 +6381,15 @@ "node": ">= 0.4" } }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, "node_modules/string.prototype.includes": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz", @@ -6160,6 +6597,34 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/tar-fs": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", + "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -6267,6 +6732,18 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -6502,6 +6979,12 @@ "punycode": "^2.1.0" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -6617,6 +7100,12 @@ "node": ">=0.10.0" } }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/package.json b/package.json index f4f1a7a..d0e8853 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "name": "tally-counter-scaffold", + "name": "tally-counter", "version": "0.1.0", "private": true, "scripts": { @@ -9,13 +9,19 @@ "lint": "eslint" }, "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", + "better-sqlite3": "^12.10.0", "next": "16.2.7", "react": "19.2.4", "react-dom": "19.2.4" }, "devDependencies": { + "@catppuccin/tailwindcss": "^1.0.0", "@tailwindcss/postcss": "^4", - "@types/node": "^20", + "@types/better-sqlite3": "^7.6.13", + "@types/node": "^22", "@types/react": "^19", "@types/react-dom": "^19", "eslint": "^9", diff --git a/public/file.svg b/public/file.svg deleted file mode 100644 index 004145c..0000000 --- a/public/file.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/public/globe.svg b/public/globe.svg deleted file mode 100644 index 567f17b..0000000 --- a/public/globe.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/public/next.svg b/public/next.svg deleted file mode 100644 index 5174b28..0000000 --- a/public/next.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/public/vercel.svg b/public/vercel.svg deleted file mode 100644 index 7705396..0000000 --- a/public/vercel.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/public/window.svg b/public/window.svg deleted file mode 100644 index b2b2a44..0000000 --- a/public/window.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file