Commit graph

48 commits

Author SHA1 Message Date
iris
a589f88cd8 admin: fix drink-order reorder race under rapid clicks
BarDrinkEditor derived selected drink order straight from the bar prop,
which only refreshes once a fire-and-forget PATCH's reload() lands. A
second reorder/add/remove click before that round-trip completed
re-derived from the same stale array the first click started from, so
whichever PATCH the server applied last silently won and discarded the
rest.

Track our own in-flight edits in local pendingIds state so back-to-back
clicks chain off each other instead of the lagging prop; it resets to
null (defer to the prop) whenever a fresh bar.drink_ids comes back,
whether that's our own round-trip landing or an edit from elsewhere.
2026-07-31 01:12:20 +02:00
iris
f576fbde3e stats: zero-fill by_hour gaps; fix chart blank labels + unnecessary rebuilds
Closes #52, closes #55. Follow-ups from the #47 review round.

#52: by_hour_of_day was already zero-filled across 0-23 (deliberately, so a
quiet hour doesn't misread as missing data) but by_hour — the continuous
timeline — wasn't. The client renders it on a categorical axis, so closed
hours between two festival nights collapsed to nothing and the last hour of
one night sat directly next to the first hour of the next. Now zero-filled
between the first and last real bucket, same reasoning as by_hour_of_day.

#55, two fixes:
- DayChart's x scale is now explicitly ordinal (distr: 2). The prior default
  (linear) let uPlot's tick generator pick fractional increments on a short
  series, and the index-based label lookup misses on a non-integer tick,
  rendering a blank label.
- The four per-day/per-hour chart data preps in Stats.tsx now produce a
  single memoized {labels, series} object each, instead of building fresh
  labels/series array literals inline in JSX on every render. DayChart's
  effect is keyed on those props by reference, so the old code destroyed
  and recreated every uPlot instance on any unrelated Dashboard re-render
  (e.g. the isAdmin check resolving after data already loaded).

Both sides build/typecheck clean. Manually verified the zero-fill against a
seeded DB with a 3-hour gap between two transactions — by_hour correctly
returned 4 buckets (2 real, 2 zero-filled) in order.
2026-07-30 21:49:22 +02:00
iris
6c2a938b91 stats: add hourly views — continuous timeline + hour-of-day pattern
Closes #45.

Two new server-side aggregates in computeStats(), same JS-bucketing
approach as the existing by_day: by_hour (localHourBucket — literal clock
time, no business-day rollover, feeds a left-to-right timeline) and
by_hour_of_day (localHourOfDay — every day's hour 0-23 summed together,
zero-filled to a full 24-entry axis so a quiet hour reads as zero, not a
missing data point).

Client: two more revenue charts on /stats — 'Umsatz nach Stunde' (timeline)
and 'Umsatz nach Tageszeit' (pattern). Revenue only, not also a tx-count
variant, to keep the page from growing a chart per metric per granularity.

Build clean both sides. Manually verified by_hour_of_day returns all 24
zero-filled buckets against a fresh DB.
2026-07-30 20:14:45 +02:00
iris
cb316d16d1 tablet: fix cart scroll — flex-end + overflow-y:auto doesn't scroll in WebKit
Follow-up to the #39 fix in d274275, which mara reports still doesn't
scroll in practice.

The bug: overflow-y: auto and justify-content: flex-end on the *same* flex
column is a known WebKit/Safari interop gap — the flex-end-justified
overflow gets clipped at the container bounds instead of becoming a
scrollable region, so nothing actually changed from the user's side despite
the CSS being 'correct' per spec. This app targets iOS Safari 12+
(vite.config.ts legacy targets), squarely in the affected range.

Fix: split the concerns onto two elements. .cart-items is now a plain
overflow-y: auto scroll container with no alignment property. The new
inner .cart-items-inner wrapper carries display:flex/flex-direction:column/
justify-content:flex-end, with min-height: 100% (a floor, not a fixed
height) so it still bottom-anchors a short list but grows past 100% and
scrolls normally in the outer container once content overflows it.

Build clean.
2026-07-30 12:17:15 +02:00
iris
1d99881e88 client: standalone /stats page with charts, off the admin dashboard
Closes #40.

New /stats route (client/stats.html + src/stats/), served by the same
catch-all pattern as /admin. Reuses the admin login (STATS_PUBLIC env var
on the server side decides whether it needs one at all).

Three uPlot charts (daily revenue, daily transaction count, top-5-drink
sold-qty trend) plus the same three tables Admin.tsx used to render inline
— those move here wholesale, Admin.tsx now just links to /stats instead of
fetching /admin/api/stats itself. The 'Statistik zurücksetzen' reset button
moves here too, gated on an actual admin session (checked separately from
whether /api/stats itself succeeded, since STATS_PUBLIC can make that true
for an anonymous viewer).

Chart lib is uPlot (~45kb) per mara's steer not to hand-roll this. Both
client and server build/typecheck clean; manually smoke-tested the auth
gate (401 unauthed, 200 after login) and the /stats route against a fresh
DB.
2026-07-30 11:40:34 +02:00
iris
9c75ce5243 server: /api/stats endpoint + per-day-per-drink trend data
New GET /api/stats — same computeStats() shared with the existing
/admin/api/stats, so the numbers stay identical. Auth is gated by
STATS_PUBLIC (default: requires the admin session, same as every other
/admin/api endpoint) rather than always-open, so a bare deploy doesn't
expose revenue by default.

Also adds per_drink_by_day (day -> per-drink sold qty) alongside the
existing all-time per_drink totals — the trend-over-time data mara asked
for on #40. Same businessDay() JS bucketing as by_day for DST correctness,
same reasoning as the existing comment on that.

Client page for /stats itself is a follow-up commit.
2026-07-30 11:40:34 +02:00
iris
d2742751ba tablet: make pending-transaction cart list scrollable
.cart-items clipped overflow instead of scrolling, so once the cart
had more line items than fit in the 38vh cart panel the extra rows
were just invisible. overflow-y: auto lets it scroll while still
anchoring to the bottom (justify-content: flex-end) when short.

Closes #39.
2026-07-30 09:08:08 +02:00
iris
3b900cb910 tablet: derive drink-tile row floor from content, not a hardcoded px guess
Per review: don't hardcode pixel values. grid-auto-rows: minmax(min-content, 1fr)
— the floor is each row's own natural content height (name + price + Pfand at
whatever font-size is active), not a manually guessed number. Same mechanism
naturally covers the <=480px breakpoint's smaller fonts too, so the separate
--tile-min-height custom property + its mobile override are gone — one rule,
no magic numbers on either side.
2026-07-30 09:03:40 +02:00
iris
c946ef86a6 tablet: size drink grid rows to content instead of a fixed 5-row budget
Per review: remove the assumption that exactly 5 rows fill the visible area.
grid-auto-rows: minmax(96px, 1fr) — rows are at least 96px (the touch-target
size used elsewhere in this file), equal height, and stretch evenly to fill
leftover space when there's room; when there isn't (more rows than fit), every
row stays at the 96px floor and the grid scrolls instead of shrinking rows to
squeeze them in. Smaller floor (80px) in the <=480px media query to match the
already-smaller mobile font sizes there.
2026-07-30 09:03:40 +02:00
iris
71c3712cbf tablet: a bit more breathing room in drink tiles
Name/price/Pfand were packed tight (2px gap before Pfand, 1.15 line-height on
the name) — small bump to margins + line-height so tiles read less cramped
regardless of how many rows are on screen. Independent of the row-count
question raised on the issue (grid-auto-rows sizing for 5 rows vs however many
drinks are actually configured) — that's a separate, bigger call pending
confirmation of what device the screenshot was taken on.
2026-07-30 09:03:40 +02:00
iris
bec4a0080c public.ts: convert error responses to RFC 7807, matching admin.ts
Follow-up from #25's review feedback — that PR converted admin.ts + the global
error handler to application/problem+json (sendProblem/ProblemDetails), left
public.ts (the tablet-facing /api/* routes) on the old ad hoc { error } shape to
stay scoped to what #25 was actually fixing. Same conversion here, no functional
changes — every reply.code(N).send({ error }) becomes sendProblem(req, reply, N,
title, detail). Client already degrades gracefully either way (errText() in
Admin.tsx checks detail/title first, falls back to .error), so this was purely
consistency cleanup, not urgent.

typecheck+build clean.
2026-07-30 01:16:00 +02:00
iris
59eb54d9fb server: generic 500s, fix duplicate/invalid-id error handling
All admin API error responses (and the global error handler) now emit
RFC 7807 application/problem+json bodies (type/title/status/detail)
instead of the ad hoc { error: string } shape, per review feedback on
this PR. Scoped to admin.ts + the global handler in index.ts, since
that's what this PR already touches; public.ts's routes still use the
old shape pending a follow-up.
2026-07-30 00:15:09 +02:00
iris
da8a815677 admin: session cookie is a random per-login token, not the password itself
The session cookie value was the admin password, replayed on every
request — one sniffed request on the LAN yields the actual shared
secret, not just a session, and logout only cleared the browser's
copy since the value (the password) stays valid forever.

Mint a random token on successful login, hold valid tokens in an
in-memory Set, set that as the cookie, and delete it from the set on
logout — logout now actually revokes the session. A server restart
naturally invalidates all sessions too (fine for this single-process
deploy).

Also compare the login password with a constant-time digest
comparison instead of ===, hygiene rather than a practical fix given
the existing shared-password/no-rate-limit threat model, but a small
change while touching this code.
2026-07-29 22:15:29 +02:00
iris
7fc4c1912c admin: share one drinks list between Drinks and Bars instead of two independent copies
Drinks and Bars each fetched + held their own copy of the drinks list. Adding
(or editing/archiving) a drink only updated Drinks's own copy — Bars's
BarDrinkEditor kept rendering the stale pre-change snapshot until a full page
reload re-mounted everything, so a newly added drink didn't show up in the
'add to bar' list without a manual refresh.

Lifted the drinks list + its reload fn into Dashboard, passed down as props to
both Drinks and Bars.
2026-07-29 21:20:36 +02:00
iris
b07c7c3602 dev: vite proxy for '/admin' swallows the admin page itself, not just its API calls
Fixes the reported /admin MIME-type / NS_ERROR_CORRUPTED_CONTENT breakage on the
Vite dev server. The proxy matched by string prefix, so '/admin' also caught
'/admin' and '/admin.html' (the page requests), forwarding them to the backend
instead of letting Vite serve its own dev-mode HTML. In dev the backend only has
a stale production build (or none) to answer with, so the page loaded referencing
hashed prod asset paths that don't exist in Vite's dev module graph — Vite's dev
server then served its SPA-fallback HTML for those asset requests instead of JS.

Narrowed the proxy to the three backend-owned endpoints under /admin
(api/login/logout) so the bare page routes go through Vite's own dev serving.
2026-07-29 20:57:55 +02:00
iris
9b82cd54c4 backend polish: stats LEFT JOIN, CSV formula injection, trustProxy, validated env vars, dead code
- Stats: bars table is now the LEFT side of the join to transactions,
  so a bar with zero sales still gets a zero row instead of vanishing
  from the totals table until its first sale (indistinguishable from
  a deleted bar). by_day stays JS-computed on purpose — a SQL rewrite
  would trade DST-aware timezone handling for a fixed-hour-offset
  'localtime' expression that's wrong on DST transition nights, to
  fix a cost the original review noted is 'fine today'. Not worth
  that trade for a money-adjacent report; left a comment explaining
  why.
- CSV export: cells starting with =/+/-/@ are now prefixed with '
  before quoting, closing a formula-injection path (an admin-entered
  drink/bar name like =HYPERLINK(...) would otherwise execute when
  the export is opened in Excel/LibreOffice).
- server/index.ts: PORT is now parsed and range-checked instead of a
  bare Number(...) (an unparseable value silently became NaN, and
  Fastify listens on a random free port for that); ADMIN_PASSWORD
  missing now warns at boot instead of only surfacing as a 500 at
  the first login attempt; new WUTZ_TRUST_PROXY env flag (off by
  default) so req.ip can actually reflect the real client behind a
  reverse proxy, documented in the README alongside the other env
  vars.
- time.ts: WUTZ_DAY_CUTOFF_HOUR gets the same parse+range-check
  treatment, for the same reason (a typo used to silently disable
  the business-day rollback with no error).
- shared/src/index.ts: Drink.archived is now typed 0 | 1, matching
  what SQLite actually returns (was boolean, which only worked by
  accident since 0 is falsy); removed TransactionRecord/
  TransactionItemRecord, declared but never returned by any route —
  leftovers from a planned endpoint that was never built.

Verified: pnpm --filter server|client typecheck/build all clean;
also ran the built server with a bad PORT and no ADMIN_PASSWORD to
confirm both warnings fire and the port falls back correctly.
2026-07-29 20:56:32 +02:00
iris
fb02cc0f94 admin: reuse @wutzcalc/shared's Drink/Bar types instead of re-declaring them
Admin.tsx defined its own local Drink/BarRow-adjacent interfaces
rather than importing from @wutzcalc/shared, even though the tablet
code in the same package already does (App.tsx, Sale.tsx,
BarPicker.tsx). The two had already drifted: the local Drink.archived
was typed number while the shared package's was boolean, despite
describing the exact same wire field. No runtime bug (JS doesn't
enforce it, and the code only does truthiness checks), but a
maintainability smell.

BarRow now extends the shared Bar type instead of duplicating its
fields; Drink is imported directly. Totals/PerDrink/ByDay stay local
since they're admin-only stats shapes, not part of the wire contract
the tablet also consumes.
2026-07-29 20:36:36 +02:00
iris
058c77524a tablet error screen can recover from a stale bar_id; admin drink reorder gets a non-drag fallback
- App.tsx: the error screen (shown when api.config(barId) fails, e.g.
  an admin deleted/renamed the bar while this tablet was offline)
  only offered 'Neu laden', which re-reads the same stale localStorage
  bar_id and fails again — a permanent stuck loop with no in-app fix.
  Added a 'Bar wechseln' button reusing the existing changeBar()
  logic, which now also clears the error state.
- Admin.tsx BarDrinkEditor: the drag-and-drop reorder is a
  mouse-oriented API that doesn't fire on touch-only input and has no
  keyboard equivalent. Added ▲/▼ buttons alongside the drag handle so
  reordering works regardless of input method.
2026-07-29 20:33:57 +02:00
iris
59f6041a16 admin: surface silent failures, guard against double-submit on add
- Drinks.add()/patch() now check res.ok and alert() the error text,
  matching the pattern every other mutator in this file already
  used (Drinks.del(), everything in Bars) — previously a rejected
  name/price change just re-fetched the (unchanged) list with no
  indication anything went wrong.
- Stats.reload(), Drinks.reload(), Bars.reload() now .catch() a
  failed fetch/non-ok response instead of leaving an unhandled
  promise rejection — a network hiccup used to leave the section
  stuck on 'Lade…' forever with no visible error.
- Drinks 'Hinzufügen' and Bars 'Bar hinzufügen' are now disabled
  while their POST is in flight, mirroring the submitting guard
  Sale.tsx already uses — a fast double-tap could otherwise fire two
  requests before the first reload() resolved.
2026-07-29 20:32:55 +02:00
iris
2a3097217e snapshot the Pfand rate used for returns, like every other money value already is
Line items correctly snapshot both unit_price_cents and
pfand_cents_per_unit, so a later price change doesn't rewrite history.
Returns didn't get the same treatment: transactions.pfand_returns
stored a bare count, and the euro value came from the bar's
pfand_cents at that moment but was never recorded — change a bar's
deposit mid-event and no historical refund amount could be
recomputed from the database.

Writing returns as transaction_items rows (the schema's is_return
column) doesn't fit cleanly: a Pfand return isn't tied to a specific
drink, but transaction_items.drink_id is NOT NULL. Add
transactions.pfand_cents_at_sale instead — same snapshot idea, at
the transaction level where pfand_returns already lives. Also added
to the transactions CSV export so the recovered value is actually
visible somewhere in the app, not just reachable via raw SQL.
2026-07-29 20:26:52 +02:00
iris
76a1b51597 validate money-adjacent inputs: bound qty/pfand_returns, validate price_cents/pfand_cents everywhere
- POST /api/transactions: pfand_returns is now rejected with 400 if
  non-integer or negative instead of silently coerced via
  Math.max(0, Math.floor(x)) (which turned a non-numeric value into
  NaN and slipped past the empty-transaction guard). Both
  pfand_returns and per-line qty are capped at a generous but bounded
  999; items.length capped at 100.
- Admin routes: price_cents/pfand_cents are validated (integer,
  0..100000 EUR) on all four write paths — POST/PATCH drinks and
  POST/PATCH bars. Previously only POST drinks checked
  Number.isInteger with no bound; the other three had no check at
  all, so a bad value (float, string, negative) could reach SQLite
  directly.
2026-07-29 20:23:10 +02:00
iris
e52a0469c8 fix retried submissions double-booking a sale
The server dedupes on client_uuid, but the tablet minted a fresh uuid
on every confirm() call — including retries after a timeout/dropped
connection, exactly the case the idempotency key exists to guard
against. The dedup check never fired on a real retry, so a flaky-Wi-Fi
resend could book the same sale twice.

Client: generate one uuid per pending cart (a ref, lazily created),
reuse it across retries of the same submission, reset it only when
the cart is cleared (success or cancel) so the next cart gets its own
id.

Server: the existing-row dedup check and the insert straddled the
db.transaction() boundary, so a genuine UNIQUE-violation race would
have surfaced as a raw 500 instead of the idempotent response. Catch
that specific violation and fall back to re-reading the row.
2026-07-29 20:16:39 +02:00
iris
32e3af3e23 readme: document WUTZ_SERVER_PORT for running dev:client against a non-default dev:server port 2026-07-29 20:07:59 +02:00
iris
a5698c62bc dev: make the client's dev-server proxy target configurable via WUTZ_SERVER_PORT
vite.config.ts hardcoded the /api, /admin, /healthz proxy targets to
http://localhost:3000 — the server's default port — so there was no
way to run dev:client against a dev:server started on a different
port (PORT=<n> pnpm dev:server) without editing the config file.

Read the port from WUTZ_SERVER_PORT (same default of 3000 as the
server's own PORT env var) and build the proxy target once.
2026-07-29 20:06:38 +02:00
iris
b5735c270f tablet: +/- buttons on cart lines, long-press drink for 1-5 picker
Cart entries (drink lines and Pfand-zurück) now have +/- buttons next
to the quantity instead of only accumulating via repeated taps;
decrementing to 0 removes the line. Long-pressing a drink tile opens
a small overlay with buttons 1-5 to add that many at once — a plain
tap still adds one. The long-press timer is cancelled on pointerup/
leave/cancel, and the click that follows a fired long-press is
swallowed so it doesn't also add a plain 1x.
2026-07-29 18:33:51 +02:00
mara
8f5457dff2 Merge pull request 'fix(client): responsive phone layout + clip overflowing drink names' (#4) from fix/3-phone-layout into main
Reviewed-on: https://forge.pr1ma.darkest.space/mara/wutzcalc/pulls/4
2026-06-29 19:45:44 +02:00
iris
982f6a4c5f fix(client): responsive phone layout + clip overflowing drink names
On narrow phone screens the tablet UI had two problems:
- Drink button text could overflow the tile (no overflow: hidden / word-break),
  causing garbled text spill visible in the issue screenshot.
- All font sizes and the cart height were tablet-sized (22px drinks, 56px total,
  38vh cart, 64px action buttons); no phone overrides existed beyond the 2-column
  grid switch.

Fixes:
- .drink: add overflow: hidden so text never bleeds outside the tile.
- .drink .name: word-break + overflow-wrap so long names wrap rather than clip.
- .topbar .bar-name: nowrap + text-overflow: ellipsis so a long bar name
  truncates cleanly rather than wrapping/overflowing.
- @media (max-width: 480px): scale down topbar (20px), drink tiles (17px),
  pfand label (12px), cart height (34vh), cart total (38px), action buttons
  (52px / 18px) to fit a portrait phone comfortably.

Closes #3.
2026-06-29 19:40:17 +02:00
iris
f2c5577f00 client(tablet): equal-size drink tiles + scroll past 5 rows (#1)
User feedback (#1): drink buttons all the same size in a 3-column grid with
any number of rows; scroll when more than ~5 rows of drinks exist; entry
overview + sum stay visible. Device: iPad mini 2 (portrait).

The grid used `grid-auto-rows: 1fr` + `overflow: hidden`, which divided the
available height across however many drinks there were — tiles shrank as the
catalog grew and it never scrolled (the known UX/scaling item in TODO.md).

Now: `grid-auto-rows: calc((100% - 4 * var(--gap)) / 5)` — the row height is
DERIVED so exactly five equal rows fill the visible grid area (no hardcoded
tile pixel size), with `align-content: start` + `overflow-y: auto` (+ iOS
momentum scroll). Tiles are a consistent size regardless of count, ~5 rows
show, the rest scroll. The cart stays pinned below (unchanged) so the overview
+ sum remain permanently visible.

CSS-only.
2026-06-17 20:41:14 +02:00
müde
3bfb853d0b docs: logo, security warning, emoji headings
- embed favicon.svg as a logo in the README
- add a prominent "no security — trusted networks only" callout
- sprinkle emoji through README/TODO/PLAN/NOTES headings; fix NOTES typo
2026-06-14 23:17:56 +02:00
müde
cca3077b7a review fixes: light-mode contrast, tz robustness, deploy copy
- admin: maroon buttons use .danger class (white text); replace hardcoded
  #333/#ff8a8a/drop-target colors with theme vars so light mode is legible
- time.ts: force hourCycle h23 (no 24:00 artifact), drop dead 24-guard
- Makefile/README: rsync with excludes instead of `cp -a .` so .git and the
  dev DB don't ship to /opt; add rsync to deps
2026-06-14 22:55:29 +02:00
müde
6a0d30c8b5 add svg favicon + toolbar logo; set theme before paint
- favicon.svg (beer mug), linked from both entry points and shown in
  the tablet topbar next to the bar name
- inline data-theme in <head> kills the flash of dark on light-mode load
- hide the "Pfand zurück" tile when the bar has no Pfand
2026-06-14 22:55:15 +02:00
müde
df513fda68 admin: unify response error extraction into errText helper
removes a double-body-read in the delete handlers and makes the four
alert sites consistent.
2026-06-14 22:30:49 +02:00
müde
ac6a50197e add Makefile with make install for fedora deploy
builds and installs the systemd service from a fresh checkout; substitutes
real node path / prefix into the unit. README documents it as the quick path.
2026-06-14 22:26:10 +02:00
müde
179a231b58 deploy: add systemd unit + env template, document service setup
also document WUTZ_TZ / WUTZ_DAY_CUTOFF_HOUR env vars
2026-06-14 22:00:13 +02:00
müde
817a560bd6 readme: add fedora setup 2026-06-14 21:58:35 +02:00
müde
2a43b59947 tablet: split price/pfand, light mode, bigger bold text
- show drink price and Pfand separately on tiles and in cart
- add light/dark toggle on the bar-picker page (persisted)
- theme via CSS variables, applied on load for tablet + admin
- larger, bolder text throughout; much bigger total sum
2026-06-14 21:57:52 +02:00
müde
40cdc98f82 admin: fix timestamps, per-day stats, reset, delete drinks/bars
- store created_at as UTC ISO; display/group in Europe/Berlin
- stats grouped by business day (sales night past midnight, 5am cutoff)
- add "Statistik zurücksetzen"
- allow deleting drinks/tresen (refused if referenced by sales)
- CSV exports local wall-clock time
2026-06-14 21:55:51 +02:00
müde
22577a0a65 add repo url to readme, package.json, flake 2026-05-19 18:39:52 +02:00
müde
1a2f184be3 readme: warn that all code is AI-generated 2026-05-19 18:29:31 +02:00
müde
fae555585f readme: add debian/ubuntu and windows setup 2026-05-19 18:29:12 +02:00
müde
58027d636a todo: note many-drinks tablet layout case 2026-05-19 18:23:49 +02:00
müde
1f8f377ac6 gitignore: drop local claude settings 2026-05-19 18:23:03 +02:00
müde
a0babc2cfa admin: drag-and-drop drink reordering per bar 2026-05-19 18:22:49 +02:00
müde
4904ff0032 admin: reorder drinks per bar 2026-05-19 18:19:26 +02:00
müde
02c7e9b5fd admin: add and rename bars 2026-05-19 18:18:10 +02:00
müde
1442fbae75 tablet: fit grid and cart on screen, no scrolling 2026-05-19 18:14:14 +02:00
müde
e0898bea22 scaffold festival drink tracker (pnpm workspace, Fastify + SQLite, Preact tablet UI, admin) 2026-05-19 18:12:01 +02:00
müde
cf33e6ea04 initial reqs 2026-05-19 17:37:51 +02:00