6.8 KiB
6.8 KiB
wutzcalc — Implementation Plan
Festival drink-sale tracker. 3 bars, ~10k attendees over 1 week, old iPads (iOS 12), local network only, central linux server.
Decisions (locked for v1)
- Stack: Preact + Vite (client), Node + Fastify (server), SQLite (better-sqlite3).
- Deployment: single Node process serves API + static client + opens one
.dbfile. - Sync model: online-only. Confirm requires server reachable on LAN.
- Auth: none on tablets in v1 (trusted LAN);
/admingated by a shared password env var. - Tablet identity: server records source IP per transaction (
client_ip) to attribute transactions to a tablet. Assumes static DHCP leases or stable IPs on the festival LAN. - Drink catalog: shared across bars; each bar picks which drinks it serves.
- Pfand: single global pfand value per bar; added on top of drink price.
- Pfandrückgabe: dedicated minus/return buttons per drink; endbetrag may go negative.
- Crew/freebies: a separate "Bestätigen (Crew)" confirm button — logs transaction with
crew=true, paid amount = 0, items still recorded. - Undo: only before confirming, fully local (edit the current cart). No undo of confirmed transactions in v1.
- iOS 12 target: Vite build with
legacyplugin → ES2017 + polyfills. No CSS features past Safari 12.
Architecture
┌──────────────┐ HTTP/JSON ┌────────────────────────┐
│ Tablet (PWA) │ ───────────────► │ Node + Fastify │
│ Preact SPA │ │ /api/... │
│ per-bar UI │ │ /admin (pw-gated) │
└──────────────┘ │ static client assets │
│ ───────────────────── │
│ better-sqlite3 ── wutz.db │
└────────────────────────┘
- Single port, single binary-ish (
node server.js). Systemd unit for autostart. - SQLite WAL mode for concurrent reads from dashboard while writes happen.
Data model (SQLite)
bars(id, name, pfand_cents)
drinks(id, name, price_cents, archived)
bar_drinks(bar_id, drink_id, sort_order) -- which drinks each bar sells
transactions(
id, bar_id, created_at, total_cents,
crew INTEGER NOT NULL DEFAULT 0,
client_ip TEXT, -- which tablet (per-bar device id)
client_uuid TEXT UNIQUE -- idempotency from tablet
)
transaction_items(
id, transaction_id, drink_id, qty, unit_price_cents, pfand_cents_per_unit,
is_return INTEGER NOT NULL DEFAULT 0 -- pfandrückgabe line
)
settings(key, value) -- admin password hash etc.
- All money in integer cents.
client_uuidmakes confirm idempotent against double-tap / retry.- Returns stored as separate line items with
is_return=1and negative contribution to total.
API surface
GET /api/config?bar=<id>→ bar + its drinks + pfand value.GET /api/bars→ list of bars (for tablet first-run picker).POST /api/transactions→ body:{ client_uuid, bar_id, crew, items: [{drink_id, qty, is_return}] }. Server recomputes prices from current catalog, captures source IP (req.ip, trust proxy off) asclient_ip, stores, returns{id, total_cents}.GET /admin→ password form → session cookie.GET /admin/api/stats→ totals per bar, per drink, crew vs paid.GET /admin/api/export.csv?what=transactions|items→ CSV download.POST /admin/api/drinks/PATCH/DELETE→ catalog config.POST /admin/api/bars/:id→ pfand + drink selection.
Server is the source of truth for prices — tablet sends drink IDs + qty only.
Client (tablet) UX
- First run: pick which bar this tablet is. Stored in
localStorage. - Main screen: grid of large buttons, one per drink served at this bar.
- Tap → adds 1 to cart.
- Long-press / "−" sub-button → returns 1 pfand for that drink.
- Cart strip: running total (price + pfand − returns), per-line qty.
- Actions:
Abbrechen(clear cart),Bestätigen(send, crew=false),Bestätigen (Crew)(send, crew=true, paid=0). - All cart edits are local until confirm — undo = remove from cart.
- Tap targets ≥ 64px, no hover states, no small text.
Backoffice (v1)
/adminlogin (single password from envADMIN_PASSWORD).- Pages:
- Drinks: CRUD table (name, price, archived).
- Bars: per bar — pfand value + checklist of drinks served + sort order.
- Stats: totals per bar, top drinks, crew freebie count/value.
- Export: CSV of transactions and transaction_items.
Repo layout
wutzcalc/
├── server/
│ ├── src/
│ │ ├── index.ts # fastify bootstrap, static serving
│ │ ├── db.ts # better-sqlite3 + migrations
│ │ ├── routes/
│ │ │ ├── public.ts # /api/* (tablets)
│ │ │ └── admin.ts # /admin/*
│ │ └── csv.ts
│ └── migrations/
├── client/ # Preact + Vite
│ ├── src/
│ │ ├── main.tsx
│ │ ├── pages/{Bar,Setup}.tsx
│ │ ├── components/{DrinkButton,Cart}.tsx
│ │ └── api.ts
│ └── vite.config.ts # @vitejs/plugin-legacy → iOS 12 target
├── admin/ # could share Preact app or be plain HTML forms
├── package.json # workspaces
├── README.md
├── NOTES.md
├── TODO.md
└── PLAN.md
(Admin can be a second entry in the same Vite build, or server-rendered HTML — leaning toward second Vite entry for consistency.)
Implementation order
- Repo scaffold: pnpm workspaces, Fastify server skeleton, Vite client skeleton, shared types package.
- SQLite schema + migrations + seed (3 bars, a few drinks).
/api/config+/api/transactionswith idempotency onclient_uuid.- Tablet UI: bar picker → drink grid → cart → confirm/cancel/crew-confirm.
- Admin password gate + drinks/bars config UI.
- Stats endpoint + minimal dashboard.
- CSV export.
- Legacy build verification on an iOS 12 Safari (or BrowserStack equivalent).
- Systemd unit + README deploy instructions + backup cron note.
Open risks
- iOS 12 Safari is the main constraint — need to verify Preact + legacy plugin output actually runs there early (after step 1, before building more).
- Local network reliability: if the WiFi at the venue is flaky, online-only model bites. Tracked in
TODO.md(offline-capable client). - SQLite single-writer is fine at ~10k attendees / week, but document WAL mode and a backup cron.