Compare commits

...

7 Commits

Author SHA1 Message Date
sebastas 260c8475a4 fix: remove public COPY (directory not used)
Build and publish Docker image / build-and-push (release) Successful in 17s
2026-06-06 17:45:23 +02:00
sebastas dbbca3c13e Version 1.0.0
Build and publish Docker image / build-and-push (release) Failing after 3m35s
2026-06-06 17:39:20 +02:00
sebastas 1094b2c92b Added workflow for publishing package 2026-06-06 17:38:20 +02:00
sebastas 5ad2ad691f Add non-root user support and entrypoint script for Docker setup 2026-06-06 17:27:10 +02:00
sebastas 21620290e2 Add some docs 2026-06-06 17:18:26 +02:00
sebastas 3e127afbae Web app ready 2026-06-06 17:14:53 +02:00
sebastas ff187a5bd4 Initial commit from Create Next App 2026-06-06 17:14:53 +02:00
38 changed files with 9545 additions and 1 deletions
+12
View File
@@ -0,0 +1,12 @@
.git
.gitignore
.next
.data
node_modules
npm-debug.log*
# Dev / editor
.env*.local
*.md
Dockerfile
.dockerignore
+48
View File
@@ -0,0 +1,48 @@
name: Build and publish Docker image
on:
release:
types: [published]
env:
REGISTRY: gitea.kanawave.net
REGISTRY_USERNAME: sebastas
IMAGE_NAME: ${{ gitea.repository }}
jobs:
build-and-push:
runs-on: ubuntu-latest
# Permissions are not yet supported in Gitea Actions -> https://github.com/go-gitea/gitea/issues/23642#issuecomment-2119876692
# permissions:
# contents: read
# packages: write
# attestations: write
# id-token: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Login to registry
uses: docker/login-action@v2
with:
registry: ${{ env.REGISTRY }}
username: ${{ env.REGISTRY_USERNAME }}
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Extract metadata (tags, labels)
id: meta
uses: docker/metadata-action@v4
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
- name: Build and push
id: push
uses: docker/build-push-action@v5
with:
context: .
# file: ./Dockerfile
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
+44
View File
@@ -0,0 +1,44 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# Local dev data (SQLite DB + uploads)
/.data
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
+40
View File
@@ -0,0 +1,40 @@
# ── 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 with default UID/GID (overridable at runtime via PUID/PGID)
RUN apk add --no-cache su-exec shadow && \
addgroup --gid 1001 nodejs && \
adduser --uid 1001 --ingroup nodejs --disabled-password --gecos "" 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 --chmod=755 entrypoint.sh /entrypoint.sh
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME=0.0.0.0
ENV PUID=1001
ENV PGID=1001
ENTRYPOINT ["/entrypoint.sh"]
CMD ["node", "server.js"]
+45 -1
View File
@@ -1,3 +1,47 @@
# tally-counter
a tally counter app
A self-hosted tally counter web app. Create counters, organise them into groups, track history, and view stats.
## Features
- Create, edit, and delete counters with optional photos
- Organise counters into groups with drag-and-drop reordering
- Per-counter history modal — streak stats, 90-day chart, activity calendar
- Global stats page with top counters and 12-month calendar
- Counters can't go below 0
- Hover-to-prefetch on history modal for instant opens
- Fully persistent via SQLite (better-sqlite3, WAL mode)
## Tech stack
- **Next.js 16** — App Router, TypeScript, standalone output
- **Tailwind CSS v4** — Catppuccin Mocha theme
- **better-sqlite3** — embedded SQLite, no external database needed
- **@dnd-kit** — drag-and-drop for counters and groups
## Running locally
```bash
npm install
npm run dev
```
Open [http://localhost:3000](http://localhost:3000).
Data is stored in `.data/` (SQLite DB + uploaded images).
## Docker
```bash
docker build -t tally-counter .
docker run -p 3000:3000 -v tally_data:/data tally-counter
```
The `/data` volume holds both the database and uploaded images. Mount it to persist data across container restarts.
## Environment variables
| Variable | Default | Description |
|------------|----------------------|------------------------------------|
| `DATA_DIR` | `.data` (dev) / `/data` (Docker) | Directory for SQLite DB and uploads |
| `PORT` | `3000` | HTTP port |
+18
View File
@@ -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);
}
+31
View File
@@ -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 });
}
+18
View File
@@ -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);
}
+49
View File
@@ -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<string, unknown> | 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<string, unknown> | 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 });
}
+24
View File
@@ -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 });
}
+28
View File
@@ -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 });
}
+24
View File
@@ -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 });
}
+32
View File
@@ -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 });
}
+37
View File
@@ -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<string, string> = {
'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 });
}
+48
View File
@@ -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<string, string> = {
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(),
},
});
}
+244
View File
@@ -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<string, DayCounter[]>();
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 (
<div>
{/* Month navigation */}
<div className="flex items-center justify-between mb-4">
<button
onClick={prev}
className="flex items-center gap-1 px-3 py-1.5 rounded-lg text-sm text-ctp-subtext1 hover:bg-ctp-surface0 hover:text-ctp-text 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" strokeLinecap="round" strokeLinejoin="round">
<polyline points="15 18 9 12 15 6" />
</svg>
Prev
</button>
<div className="flex items-center gap-2">
<span className="font-semibold text-ctp-text">
{MONTH_NAMES[viewMonth]} {viewYear}
</span>
{!isCurrentMonth && (
<button
onClick={() => { setViewYear(now.getFullYear()); setViewMonth(now.getMonth()); }}
className="text-xs px-2 py-0.5 rounded-full bg-ctp-surface0 text-ctp-subtext1 hover:bg-ctp-surface1 hover:text-ctp-text transition-colors"
>
Today
</button>
)}
</div>
<button
onClick={next}
disabled={isCurrentMonth}
className="flex items-center gap-1 px-3 py-1.5 rounded-lg text-sm text-ctp-subtext1 hover:bg-ctp-surface0 hover:text-ctp-text transition-colors disabled:opacity-30 disabled:pointer-events-none"
>
Next
<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="9 18 15 12 9 6" />
</svg>
</button>
</div>
{/* Day-of-week header */}
<div className="grid grid-cols-7 mb-1">
{DOW.map(d => (
<div key={d} className="text-center text-ctp-overlay0 font-medium" style={{ fontSize: '10px' }}>
{d}
</div>
))}
</div>
{/* Day cells */}
<div className="grid grid-cols-7 gap-px bg-ctp-surface1 rounded-xl overflow-hidden border border-ctp-surface1">
{cells.map((cell, i) =>
cell ? (
<div
key={i}
onClick={() => 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' : ''
}`}
>
<span className={`text-xs font-semibold leading-none mb-0.5 ${
cell.date === todayStr
? 'text-ctp-mauve'
: cell.counters.length > 0
? 'text-ctp-text'
: 'text-ctp-overlay1'
}`}>
{cell.dayNum}
</span>
{cell.counters.slice(0, 3).map(c => (
<div
key={c.counter_id}
className="flex items-center gap-0.5 bg-ctp-surface0 rounded px-1 overflow-hidden"
title={`${c.counter_name}: +${c.increments}`}
>
<span className="text-ctp-subtext1 truncate leading-tight" style={{ fontSize: '9px' }}>
{c.counter_name}
</span>
<span className="text-ctp-mauve font-bold shrink-0 leading-tight" style={{ fontSize: '9px' }}>
+{c.increments}
</span>
</div>
))}
{cell.counters.length > 3 && (
<span className="text-ctp-overlay0 leading-tight" style={{ fontSize: '9px' }}>
+{cell.counters.length - 3} more
</span>
)}
</div>
) : (
<div key={i} className="bg-ctp-mantle min-h-[4.5rem]" />
)
)}
</div>
{/* Day detail modal */}
{selectedDay && (
<div
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60"
onClick={() => setSelectedDay(null)}
>
<div
className="bg-ctp-surface0 rounded-2xl shadow-xl w-full max-w-sm p-6"
onClick={e => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between mb-5">
<h3 className="font-bold text-ctp-text text-lg">
{new Date(selectedDay.date + 'T00:00:00').toLocaleDateString(undefined, {
weekday: 'long', month: 'long', day: 'numeric',
})}
</h3>
<button
onClick={() => setSelectedDay(null)}
className="text-ctp-overlay1 hover:text-ctp-text transition-colors"
aria-label="Close"
>
<svg xmlns="http://www.w3.org/2000/svg" className="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
{/* Counter list */}
{selectedDay.counters.length === 0 ? (
<p className="text-center text-ctp-overlay1 py-4">No activity on this day.</p>
) : (
<div className="space-y-3">
{selectedDay.counters.map(c => (
<div key={c.counter_id} className="flex items-center gap-3 bg-ctp-base rounded-xl p-3">
{c.image_path ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={`/api/uploads/${c.image_path}`}
alt=""
className="w-12 h-12 rounded-lg object-cover shrink-0 bg-ctp-surface1"
/>
) : (
<div className="w-12 h-12 rounded-lg bg-ctp-surface1 shrink-0 flex items-center justify-center text-ctp-overlay0 text-xl">
#
</div>
)}
<div className="flex-1 min-w-0">
<p className="font-medium text-ctp-text truncate">{c.counter_name}</p>
<p className="text-ctp-mauve font-bold text-lg leading-tight">+{c.increments}</p>
</div>
</div>
))}
</div>
)}
{/* Total */}
{selectedDay.counters.length > 1 && (
<p className="mt-4 text-right text-sm text-ctp-subtext1">
Total: <span className="text-ctp-mauve font-bold">
+{selectedDay.counters.reduce((s, c) => s + c.increments, 0)}
</span>
</p>
)}
</div>
</div>
)}
</div>
);
}
+97
View File
@@ -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<string, unknown>;
}
export default function CounterCard({ counter, onIncrement, onDecrement, onEdit, onHistory, onPrefetch, editMode, dragHandleProps }: Props) {
return (
<div
{...dragHandleProps}
className={`bg-ctp-surface0 rounded-2xl shadow-sm ring-1 ring-ctp-surface1 overflow-hidden flex flex-col select-none transition-shadow hover:shadow-lg hover:shadow-ctp-crust${dragHandleProps ? ' cursor-grab active:cursor-grabbing' : ''}`}
>
{/* Title area — click opens edit modal */}
<button
onClick={() => !editMode && onEdit(counter)}
className={`w-full flex items-center justify-center px-4 pt-3 pb-2 h-16 transition-colors ${!editMode ? 'hover:bg-ctp-surface1' : ''}`}
title={editMode ? undefined : 'Edit counter'}
>
<h3 className="font-bold text-ctp-text text-lg leading-tight line-clamp-2 text-center">
{counter.name}
</h3>
</button>
{/* Middle: image — click opens history modal */}
<button
onClick={() => !editMode && onHistory(counter.id)}
onMouseEnter={() => !editMode && onPrefetch?.(counter.id)}
className={`w-full flex-1 flex items-center justify-center transition-colors ${!editMode ? 'hover:bg-ctp-surface1' : ''}`}
title={editMode ? undefined : 'View history'}
>
{counter.image_path ? (
<div className="w-full bg-ctp-surface0 flex items-center justify-center overflow-hidden" style={{ minHeight: '6rem', maxHeight: '9rem' }}>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={`/api/uploads/${counter.image_path}`}
alt={counter.name}
className="max-w-full max-h-36 object-contain py-2"
/>
</div>
) : (
<div className="w-full flex items-center justify-center" style={{ minHeight: '4rem' }}>
<svg xmlns="http://www.w3.org/2000/svg" className="w-5 h-5 text-ctp-overlay0" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<line x1="18" y1="20" x2="18" y2="10" /><line x1="12" y1="20" x2="12" y2="4" /><line x1="6" y1="20" x2="6" y2="14" />
</svg>
</div>
)}
</button>
{/* Counter controls */}
<div className="flex items-center justify-center px-3 py-3 mt-auto">
<div className="flex items-center bg-ctp-surface1 rounded-2xl overflow-hidden">
<button
onClick={() => onDecrement(counter.id)}
disabled={counter.value <= 0}
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"
>
<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>
<span className="text-2xl font-extrabold w-14 text-center tabular-nums text-ctp-text tracking-tight select-none">
{counter.value}
</span>
<button
onClick={() => onIncrement(counter.id)}
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"
>
<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>
</div>
</div>
</div>
);
}
+174
View File
@@ -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<number, HistoryData>;
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<string>): 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<HistoryData | null>(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 (
<div
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm"
onClick={onClose}
>
<div
className="bg-ctp-mantle rounded-2xl shadow-2xl w-full max-w-2xl max-h-[90vh] flex flex-col overflow-hidden"
onClick={e => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center gap-3 px-6 py-4 border-b border-ctp-surface1 shrink-0">
{data?.counter.image_path && (
// eslint-disable-next-line @next/next/no-img-element
<img
src={`/api/uploads/${data.counter.image_path}`}
alt=""
className="w-9 h-9 rounded-lg object-cover bg-ctp-surface0 shrink-0"
/>
)}
<h2 className="text-lg font-bold text-ctp-text flex-1 truncate">
{data?.counter.name ?? '…'}
</h2>
<button
onClick={onClose}
className="text-ctp-overlay1 hover:text-ctp-text transition-colors shrink-0"
aria-label="Close"
>
<svg xmlns="http://www.w3.org/2000/svg" className="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
{/* Body */}
<div className="overflow-y-auto flex-1 px-6 py-5 space-y-6">
{loading && (
<div className="flex items-center justify-center py-16">
<p className="text-ctp-overlay1 animate-pulse">Loading</p>
</div>
)}
{!loading && data && (
<>
{/* Stat cards */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
{stats.map(({ label, value }) => (
<div key={label} className="bg-ctp-base rounded-xl p-3 text-center">
<p className="text-xl font-extrabold text-ctp-mauve tabular-nums">{value}</p>
<p className="text-xs text-ctp-subtext1 mt-0.5">{label}</p>
</div>
))}
</div>
{data.dailyActivity.length === 0 ? (
<div className="text-center py-10 space-y-1">
<p className="text-ctp-overlay1">No activity in the last year.</p>
<p className="text-ctp-overlay0 text-sm">Start tapping + to see history here.</p>
</div>
) : (
<>
{/* 90-day bar chart */}
<div>
<h3 className="text-sm font-bold text-ctp-text mb-3">Last 90 Days</h3>
<HistoryChart data={data.dailyActivity} days={90} />
</div>
{/* Calendar */}
<div>
<h3 className="text-sm font-bold text-ctp-text mb-4">Activity Calendar</h3>
<CalendarView dailyCounters={calendarData} />
</div>
</>
)}
</>
)}
</div>
</div>
</div>
);
}
+239
View File
@@ -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<ReturnType<typeof setTimeout> | null>(null);
const [name, setName] = useState('');
const [value, setValue] = useState(0);
const [groupId, setGroupId] = useState<number | null>(null);
const [imagePath, setImagePath] = useState<string | null>(null);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [uploading, setUploading] = useState(false);
const [error, setError] = useState('');
const nameRef = useRef<HTMLInputElement>(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<HTMLInputElement>) {
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 (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4"
onClick={handleBackdrop}
>
<div className="bg-ctp-mantle rounded-2xl shadow-2xl w-full max-w-md">
<div className="flex items-center justify-between px-6 py-4 border-b border-ctp-surface1">
<h2 className="text-lg font-semibold text-ctp-text">
{initial ? 'Edit Counter' : 'New Counter'}
</h2>
<button onClick={onClose} className="text-ctp-overlay0 hover:text-ctp-text text-xl leading-none">×</button>
</div>
<form onSubmit={handleSubmit} className="px-6 py-4 space-y-4">
{error && (
<p className="text-sm text-ctp-red bg-ctp-red/10 rounded-lg px-3 py-2">{error}</p>
)}
{/* Name */}
<div>
<label className="block text-sm font-medium text-ctp-subtext1 mb-1">Name</label>
<input
ref={nameRef}
type="text"
value={name}
onChange={e => 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"
/>
</div>
{/* Initial value */}
<div>
<label className="block text-sm font-medium text-ctp-subtext1 mb-1">
{initial ? 'Value' : 'Initial Value'}
</label>
<input
type="number"
value={value}
onChange={e => 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"
/>
</div>
{/* Group */}
<div>
<label className="block text-sm font-medium text-ctp-subtext1 mb-1">Group</label>
<select
value={groupId ?? ''}
onChange={e => setGroupId(e.target.value ? Number(e.target.value) : null)}
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"
>
<option value=""> No group </option>
{groups.map(g => (
<option key={g.id} value={g.id}>{g.name}</option>
))}
</select>
</div>
{/* Image upload */}
<div>
<label className="block text-sm font-medium text-ctp-subtext1 mb-1">Photo</label>
{previewUrl && (
<div className="relative w-full mb-2 rounded-lg overflow-hidden bg-ctp-crust flex items-center justify-center" style={{ minHeight: '8rem', maxHeight: '12rem' }}>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={previewUrl} alt="Preview" className="max-w-full max-h-48 object-contain py-2" />
<button
type="button"
onClick={() => { setPreviewUrl(null); setImagePath(null); }}
className="absolute top-1 right-1 bg-ctp-crust/80 text-ctp-text rounded-full w-6 h-6 flex items-center justify-center text-xs hover:bg-ctp-surface0"
>
×
</button>
</div>
)}
<label className={`flex items-center justify-center gap-2 w-full px-3 py-2 rounded-lg border border-dashed text-sm font-medium transition-colors ${
uploading
? 'border-ctp-surface1 text-ctp-overlay0 cursor-wait'
: 'border-ctp-surface2 text-ctp-subtext1 hover:border-ctp-mauve hover:text-ctp-mauve hover:bg-ctp-mauve/5'
}`}>
<input type="file" accept="image/*" onChange={handleFileChange} className="hidden" disabled={uploading} />
<svg xmlns="http://www.w3.org/2000/svg" className="w-4 h-4 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="17 8 12 3 7 8" />
<line x1="12" y1="3" x2="12" y2="15" />
</svg>
{uploading ? 'Uploading…' : previewUrl ? 'Replace photo' : 'Upload photo'}
</label>
</div>
<div className="flex items-center justify-between gap-3 pt-2">
{/* Delete — only shown when editing an existing counter */}
{initial && onDelete ? (
<button
type="button"
onClick={() => {
if (confirmDelete) {
if (deleteTimer.current) clearTimeout(deleteTimer.current);
onDelete(initial.id);
onClose();
} else {
setConfirmDelete(true);
deleteTimer.current = setTimeout(() => setConfirmDelete(false), 3000);
}
}}
className={`px-4 py-2 text-sm rounded-lg font-medium transition-colors ${
confirmDelete
? 'bg-ctp-red hover:bg-ctp-maroon text-ctp-base'
: 'text-ctp-red hover:bg-ctp-red/10 border border-ctp-red/30'
}`}
>
{confirmDelete ? 'Confirm delete' : 'Delete counter'}
</button>
) : <span />}
<div className="flex gap-3">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-sm rounded-lg border border-ctp-surface1 text-ctp-subtext1 hover:bg-ctp-surface0 transition-colors"
>
Cancel
</button>
<button
type="submit"
disabled={uploading}
className="px-4 py-2 text-sm rounded-lg bg-ctp-mauve hover:bg-ctp-lavender disabled:opacity-50 text-ctp-base font-medium transition-colors"
>
{initial ? 'Save Changes' : 'Create Counter'}
</button>
</div>
</div>
</form>
</div>
</div>
);
}
+144
View File
@@ -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<string, unknown>;
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 (
<div className="mb-8">
{/* Section header */}
<div className="flex items-center gap-3 mb-3">
{group && editMode && dragHandleProps && (
<div
{...dragHandleProps}
className="cursor-grab active:cursor-grabbing text-ctp-overlay0 hover:text-ctp-subtext1 transition-colors shrink-0"
title="Drag to reorder group"
>
<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">
<line x1="8" y1="6" x2="21" y2="6" /><line x1="8" y1="12" x2="21" y2="12" /><line x1="8" y1="18" x2="21" y2="18" />
<line x1="3" y1="6" x2="3.01" y2="6" /><line x1="3" y1="12" x2="3.01" y2="12" /><line x1="3" y1="18" x2="3.01" y2="18" />
</svg>
</div>
)}
{group ? (
editingName ? (
<input
autoFocus
value={newName}
onChange={e => 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 ? (
<h2
className="text-lg font-bold text-ctp-text cursor-pointer hover:text-ctp-mauve transition-colors"
title="Click to rename"
onClick={() => { setNewName(group.name); setEditingName(true); }}
>
{group.name}
</h2>
) : (
<h2 className="text-lg font-bold text-ctp-text">{group.name}</h2>
)
)
) : (
<h2 className="text-lg font-bold text-ctp-overlay1">Ungrouped</h2>
)}
{group && editMode && (
<button
onClick={() => {
if (confirmDelete) { onDeleteGroup?.(group.id); }
else {
setConfirmDelete(true);
setTimeout(() => setConfirmDelete(false), 3000);
}
}}
className={`text-xs px-2 py-0.5 rounded-full transition-colors ${
confirmDelete
? 'bg-ctp-red text-ctp-base'
: 'bg-ctp-surface0 text-ctp-overlay1 hover:bg-ctp-red/10 hover:text-ctp-red'
}`}
>
{confirmDelete ? 'Sure?' : 'Delete group'}
</button>
)}
</div>
{/* Drop zone */}
<div
ref={setNodeRef}
className={`grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-3 min-h-[80px] rounded-2xl transition-colors ${
isOver ? 'bg-ctp-mauve/10 ring-2 ring-ctp-mauve' : ''
} ${isEmpty ? 'border-2 border-dashed border-ctp-surface1 p-4' : ''}`}
>
<SortableContext items={counters.map(c => c.id)} strategy={rectSortingStrategy}>
{counters.map(counter => (
<SortableCounter
key={counter.id}
counter={counter}
onIncrement={onIncrement}
onDecrement={onDecrement}
onEdit={onEdit}
onHistory={onHistory}
onPrefetch={onPrefetch}
editMode={editMode}
/>
))}
</SortableContext>
{isEmpty && (
<p className="col-span-full text-center text-sm text-ctp-overlay0 self-center">
Drop counters here
</p>
)}
</div>
</div>
);
}
+105
View File
@@ -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<number | null>(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 (
<div>
<div className="relative">
{/* Tooltip */}
{hoveredDay && hovered !== null && (
<div
className="absolute bottom-0 mb-1 text-xs bg-ctp-surface1 text-ctp-text rounded-lg px-2 py-1 pointer-events-none shadow border border-ctp-surface2 z-10 whitespace-nowrap -translate-x-1/2"
style={{ left: `${Math.min(92, Math.max(8, ((hovered + 0.5) / days) * 100))}%` }}
>
<span className="font-bold text-ctp-mauve">
{hoveredDay.value > 0 ? `+${hoveredDay.value}` : ''}
</span>
<span className="text-ctp-subtext1 ml-1.5">{hoveredDay.date}</span>
</div>
)}
<svg
viewBox={`0 0 100 ${CHART_H}`}
className="w-full h-20"
preserveAspectRatio="none"
style={{ display: 'block' }}
>
{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 (
<rect
key={d.date}
x={x} y={y}
width={w} height={barH}
rx={0.4}
className={d.value > 0 ? 'fill-ctp-mauve' : 'fill-ctp-surface1'}
style={{ opacity: hovered === i ? 0.6 : 1 }}
onMouseEnter={() => setHovered(i)}
onMouseLeave={() => setHovered(null)}
/>
);
})}
</svg>
</div>
{/* Month labels */}
<div className="relative h-4 mt-0.5 select-none">
{monthLabels.map(({ label, pct }) => (
<span
key={`${label}-${pct}`}
className="absolute text-ctp-overlay0 leading-none pointer-events-none"
style={{ left: `${pct}%`, fontSize: '10px' }}
>
{label}
</span>
))}
</div>
</div>
);
}
+41
View File
@@ -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 (
<div ref={setNodeRef} style={style}>
<CounterCard
{...handlers}
counter={counter}
editMode={editMode}
dragHandleProps={editMode ? { ...attributes, ...listeners } : undefined}
/>
</div>
);
}
+45
View File
@@ -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 (
<div ref={setNodeRef} style={style}>
<GroupSection
group={group}
editMode={editMode}
dragHandleProps={editMode ? { ...attributes, ...listeners } : undefined}
{...props}
/>
</div>
);
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 904 B

+12
View File
@@ -0,0 +1,12 @@
@import "tailwindcss";
@import "@catppuccin/tailwindcss/mocha.css";
@theme inline {
--font-sans: var(--font-geist-sans);
}
@layer base {
button, [role="button"], label, select {
cursor: pointer;
}
}
+28
View File
@@ -0,0 +1,28 @@
import type { Metadata } from "next";
import { Geist } from "next/font/google";
import "./globals.css";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "Tally Counter",
description: "Keep track of your tally counters",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html
lang="en"
className={`${geistSans.variable} h-full antialiased`}
>
<body className="min-h-full flex flex-col">{children}</body>
</html>
);
}
+417
View File
@@ -0,0 +1,417 @@
'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() {
const router = useRouter();
const [counters, setCounters] = useState<Counter[]>([]);
const [groups, setGroups] = useState<Group[]>([]);
const [modalOpen, setModalOpen] = useState(false);
const [editingCounter, setEditingCounter] = useState<Counter | null>(null);
const [activeCounter, setActiveCounter] = useState<Counter | null>(null);
const [activeGroup, setActiveGroup] = useState<Group | null>(null);
const [newGroupName, setNewGroupName] = useState('');
const [addingGroup, setAddingGroup] = useState(false);
const [loading, setLoading] = useState(true);
const [historyCounterId, setHistoryCounterId] = useState<number | null>(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<Map<number, HistoryData>>(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 (
<main className="min-h-screen bg-ctp-base px-4 py-8">
<div className="max-w-7xl mx-auto">
<div className="flex items-center justify-between mb-8 flex-wrap gap-4">
<h1 className="text-3xl font-extrabold text-ctp-text tracking-tight">Tally counters</h1>
<div className="flex gap-3 items-center">
{[16, 24, 16, 28].map((w, i) => (
<div key={i} className={`h-9 w-${w} rounded-lg bg-ctp-surface0 animate-pulse`} />
))}
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-3">
{Array.from({ length: 10 }).map((_, i) => (
<div key={i} className="bg-ctp-surface0 rounded-2xl h-52 animate-pulse" />
))}
</div>
</div>
</main>
);
}
return (
<main className="min-h-screen bg-ctp-base px-4 py-8">
<div className="max-w-7xl mx-auto">
{/* Header */}
<div className="flex items-center justify-between mb-8 flex-wrap gap-4">
<h1 className="text-3xl font-extrabold text-ctp-text tracking-tight">Tally counters</h1>
<div className="flex gap-3 flex-wrap items-center">
<Link
href="/stats"
className="flex items-center gap-1.5 px-4 py-2 text-sm rounded-lg border border-ctp-surface1 text-ctp-subtext1 hover:bg-ctp-surface0 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" strokeLinecap="round" strokeLinejoin="round">
<line x1="18" y1="20" x2="18" y2="10" />
<line x1="12" y1="20" x2="12" y2="4" />
<line x1="6" y1="20" x2="6" y2="14" />
</svg>
Stats
</Link>
{addingGroup ? (
<div className="flex gap-2">
<input
autoFocus
value={newGroupName}
onChange={e => 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"
/>
<button onClick={handleCreateGroup} className="px-3 py-2 text-sm rounded-lg bg-ctp-mauve hover:bg-ctp-lavender text-ctp-base font-medium transition-colors">Add</button>
<button onClick={() => { setAddingGroup(false); setNewGroupName(''); }} className="px-3 py-2 text-sm rounded-lg border border-ctp-surface1 text-ctp-subtext1 hover:bg-ctp-surface0 transition-colors">Cancel</button>
</div>
) : (
<button onClick={() => setAddingGroup(true)} className="px-4 py-2 text-sm rounded-lg border border-ctp-surface1 text-ctp-subtext1 hover:bg-ctp-surface0 transition-colors">
+ New Group
</button>
)}
<button
onClick={() => {
if (editMode) {
// Persist all reorder/group changes accumulated during edit mode
setEditMode(false);
fetch('/api/reorder', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
counters: counters.map(c => ({ id: c.id, order_index: c.order_index, group_id: c.group_id })),
groups: groups.map(g => ({ id: g.id, order_index: g.order_index })),
}),
});
} else {
setEditMode(true);
}
}}
className={`px-4 py-2 text-sm rounded-lg border transition-colors ${
editMode
? 'border-ctp-mauve bg-ctp-mauve/10 text-ctp-mauve hover:bg-ctp-mauve/20'
: 'border-ctp-surface1 text-ctp-subtext1 hover:bg-ctp-surface0'
}`}
>
{editMode ? 'Done' : 'Edit'}
</button>
<button
onClick={() => { setEditingCounter(null); setModalOpen(true); }}
className="px-4 py-2 text-sm rounded-lg bg-ctp-mauve hover:bg-ctp-lavender text-ctp-base font-medium transition-colors shadow-sm"
>
+ Add Counter
</button>
</div>
</div>
{/* Board */}
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDragEnd={handleDragEnd}
>
{/* Groups */}
<SortableContext items={groups.map(g => `grp-${g.id}`)} strategy={verticalListSortingStrategy}>
{groups.map(group => (
<SortableGroupSection
key={group.id}
group={group}
counters={counters.filter(c => 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}
/>
))}
</SortableContext>
<GroupSection
group={null}
counters={ungrouped}
onIncrement={handleIncrement}
onDecrement={handleDecrement}
onEdit={c => { setEditingCounter(c); setModalOpen(true); }}
onHistory={setHistoryCounterId}
onPrefetch={handlePrefetch}
editMode={editMode}
/>
<DragOverlay>
{activeCounter && (
<div className="rotate-2 scale-105 shadow-2xl">
<CounterCard
counter={activeCounter}
onIncrement={() => {}}
onDecrement={() => {}}
onEdit={() => {}}
onHistory={() => {}}
/>
</div>
)}
{activeGroup && (
<div className="px-4 py-3 bg-ctp-mantle rounded-xl shadow-2xl ring-1 ring-ctp-mauve opacity-90">
<span className="font-bold text-ctp-text">{activeGroup.name}</span>
</div>
)}
</DragOverlay>
</DndContext>
{counters.length === 0 && (
<div className="text-center mt-20">
<p className="text-ctp-overlay0 text-lg">No counters yet.</p>
<button
onClick={() => setModalOpen(true)}
className="mt-4 px-6 py-3 rounded-xl bg-ctp-mauve hover:bg-ctp-lavender text-ctp-base font-medium transition-colors shadow"
>
Create your first counter
</button>
</div>
)}
</div>
<CounterModal
open={modalOpen}
initial={editingCounter}
groups={groups}
onClose={() => { setModalOpen(false); setEditingCounter(null); }}
onSave={handleSaveCounter}
onDelete={handleDeleteCounter}
/>
<CounterDetailModal
counterId={historyCounterId}
cache={historyCache.current}
onClose={() => setHistoryCounterId(null)}
/>
</main>
);
}
+39
View File
@@ -0,0 +1,39 @@
export default function StatsLoading() {
return (
<div className="min-h-screen bg-ctp-base px-4 py-8">
<div className="max-w-3xl mx-auto">
<div className="flex items-center gap-4 mb-8">
<div className="h-8 w-20 rounded-lg bg-ctp-surface0 animate-pulse" />
<div className="h-9 w-36 rounded-lg bg-ctp-surface0 animate-pulse" />
</div>
<div className="space-y-6">
<div className="bg-ctp-mantle rounded-2xl p-6">
<div className="h-5 w-48 rounded bg-ctp-surface0 animate-pulse mb-5" />
<div className="grid grid-cols-2 gap-4">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="space-y-2">
<div className="flex items-center gap-2">
<div className="w-14 h-14 rounded-xl bg-ctp-surface0 animate-pulse shrink-0" />
<div className="flex-1 space-y-1.5">
<div className="h-3 rounded bg-ctp-surface0 animate-pulse w-3/4" />
<div className="h-4 rounded bg-ctp-surface0 animate-pulse w-1/2" />
</div>
</div>
<div className="h-1.5 rounded-full bg-ctp-surface0 animate-pulse" />
</div>
))}
</div>
</div>
<div className="bg-ctp-mantle rounded-2xl p-6">
<div className="h-5 w-52 rounded bg-ctp-surface0 animate-pulse mb-5" />
<div className="grid grid-cols-7 gap-px">
{Array.from({ length: 35 }).map((_, i) => (
<div key={i} className="h-16 rounded bg-ctp-surface0 animate-pulse" />
))}
</div>
</div>
</div>
</div>
</div>
);
}
+131
View File
@@ -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 (
<main className="min-h-screen bg-ctp-base px-4 py-8">
<div className="max-w-3xl mx-auto">
{/* Header */}
<div className="flex items-center gap-4 mb-8">
<Link
href="/"
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium text-ctp-subtext1 bg-ctp-surface0 hover:bg-ctp-surface1 hover:text-ctp-text 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" strokeLinecap="round" strokeLinejoin="round">
<polyline points="15 18 9 12 15 6" />
</svg>
Back
</Link>
<h1 className="text-3xl font-extrabold text-ctp-text tracking-tight">Statistics</h1>
</div>
{/* Empty state */}
{totalIncrements === 0 && topCounters.length === 0 ? (
<div className="text-center mt-24 space-y-2">
<p className="text-ctp-overlay1 text-lg">No activity recorded yet.</p>
<p className="text-ctp-overlay0 text-sm">Start tapping + on your counters to see stats appear here.</p>
</div>
) : (
<div className="space-y-6">
{/* Top counters */}
{topCounters.length > 0 && (
<section className="bg-ctp-mantle rounded-2xl p-6">
<h2 className="text-base font-bold text-ctp-text mb-5">Most Active Counters</h2>
<div className="grid grid-cols-2 gap-4">
{topCounters.map(counter => (
<div key={counter.id}>
<div className="flex items-center justify-between mb-1.5 gap-2">
<div className="flex items-center gap-2 min-w-0">
{counter.image_path ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={`/api/uploads/${counter.image_path}`}
alt=""
className="w-14 h-14 rounded-xl object-cover shrink-0 bg-ctp-surface0"
/>
) : (
<div className="w-14 h-14 rounded-xl bg-ctp-surface1 shrink-0" />
)}
<span className="text-sm font-medium text-ctp-text truncate">
{counter.name}
</span>
</div>
<span className="text-base font-bold text-ctp-green shrink-0 tabular-nums">
+{counter.increments}
</span>
</div>
<div className="w-full bg-ctp-surface0 rounded-full h-1.5">
<div
className="bg-ctp-mauve h-1.5 rounded-full transition-all"
style={{ width: `${(counter.increments / maxIncrements) * 100}%` }}
/>
</div>
</div>
))}
</div>
</section>
)}
{/* Activity calendar */}
{totalIncrements > 0 && (
<section className="bg-ctp-mantle rounded-2xl p-6">
<div className="flex items-baseline justify-between mb-5">
<h2 className="text-base font-bold text-ctp-text">Activity Last 12 Months</h2>
<span className="text-xs text-ctp-overlay1 tabular-nums">{totalIncrements} total increments</span>
</div>
<CalendarView dailyCounters={dailyCounters} />
</section>
)}
</div>
)}
</div>
</main>
);
}
+14
View File
@@ -0,0 +1,14 @@
services:
app:
build: .
ports:
- "3000:3000"
volumes:
- app_data:/data
environment:
PUID: 1000
PGID: 1000
restart: unless-stopped
volumes:
app_data:
+17
View File
@@ -0,0 +1,17 @@
#!/bin/sh
set -e
PUID=${PUID:-1001}
PGID=${PGID:-1001}
# Only remap if the requested IDs differ from the defaults baked into the image
if [ "$PGID" != "1001" ]; then
groupmod -g "$PGID" nodejs
fi
if [ "$PUID" != "1001" ]; then
usermod -u "$PUID" nextjs
fi
chown -R nextjs:nodejs /data
exec su-exec nextjs "$@"
+18
View File
@@ -0,0 +1,18 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;
+46
View File
@@ -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;
+9
View File
@@ -0,0 +1,9 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
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;
+7154
View File
File diff suppressed because it is too large Load Diff
+32
View File
@@ -0,0 +1,32 @@
{
"name": "tally-counter",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"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/better-sqlite3": "^7.6.13",
"@types/node": "^22",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.2.7",
"tailwindcss": "^4",
"typescript": "^5"
}
}
+7
View File
@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
+34
View File
@@ -0,0 +1,34 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
}