Per feedback on #74 — fixed-px padding would silently need re-tuning
again if .picker-count's font-size ever changes. em is relative to
this rule's own font-size, so the fix scales with it automatically.
.picker-count inherited the generic button's 12px/16px padding, which
left ~16px of content width per grid cell — enough for a single digit
but not the two-character "-1".."-5" remove labels added in #72,
which overflowed into the neighbouring cell and got visually clipped
by its opaque background. Tightened to horizontal-only padding (12px
4px), leaving vertical spacing/centering untouched.
Fixes the rest of #70 - mara's answer: "when there are already drinks
in cart, i want to be able to remove them - so show negative numbers
there too."
Second row of -1..-5 buttons appears in the count picker only when the
long-pressed drink already has cart quantity (an empty-cart drink keeps
the original add-only picker - nothing to remove). Routed through
incLine (the qty-stepper's own clamp-at-zero-and-drop-the-line logic)
rather than addDrink's raw qty+n, so over-removing (e.g. -5 on a qty-3
line) drops the line cleanly instead of going negative - verified this
specific clamping behavior with a standalone reproduction of the logic
before trusting it. Remove buttons get the existing button.danger
styling (red), matching the app's established negative/removal
convention (Pfand-return tile, cart-line.return).
pnpm --filter client typecheck and build both clean.
Fixes half of #70 - the long-tap/count-picker interaction.
Pointer Events (onPointerDown/Up/Cancel) only shipped in Safari 13/iOS
13. The same class of old-enough-WebKit kiosk device #66/#68 are
already about doesn't fire them at all, so the long-press timer never
starts there even though plain clicks (universally supported) keep
working - which matches the report exactly: add-one-by-tapping works
on the tablet, the long-press count picker doesn't, and both work fine
on a laptop's modern browser.
Added onTouchStart/onTouchEnd/onTouchCancel alongside the existing
pointer handlers, wired to the same start/cancel functions - safe on
browsers that fire both pointer and touch events for one touch, since
starting/cancelling the timer is already idempotent.
Not touching the second half of the report (negative amounts for
cart-only drinks) - genuinely ambiguous relayed text with several
plausible readings that imply different UI, asked mara for
clarification on the issue instead of guessing.
pnpm --filter client typecheck and build both clean.
Fixes#68.
- Bar switcher and stats page get a low-contrast "wutzcalc <git-sha>"
footer at the bottom (VersionFooter component, shared).
- Admin panel gets a fuller Diagnose section: version, user agent,
screen resolution + device pixel ratio, viewport size, browser
language.
Version is the short git commit sha, baked in at build time via
vite.config.ts define (falls back to "dev" if .git is unavailable at
build time, e.g. a tarball/CI-artifact deploy) - matches how deploys
actually run (pnpm build from a git checkout, per
deploy/wutzcalc.service), but stays defensive rather than failing the
build.
pnpm --filter client typecheck and build both clean.
Fixes hyperhive-facing report (relayed via #66): scrolling broken on an
old iPad running an ancient WebKit build inside a kiosk browser
wrapper (WebFrame Pro 3.7.3).
.grid already carries -webkit-overflow-scrolling: touch (from #39,
the same class of old-WebKit scroll bug), but .cart-items - the other
touch-scrollable region, added in that same #39 follow-up - never got
it. On modern browsers this is a no-op; on old enough iOS WebKit,
touch gestures do not reliably scroll an overflow:auto container
without the explicit hint, which reads identically to a fully broken
scroll from the user's side.
Could not reproduce on the actual reported device (no access to it),
so flagging on the issue that this is the most concrete finding from
a code read, not a confirmed root-cause fix - checked for JS-level
touch/scroll blocking (none found) and viewport meta oddities
(user-scalable=no on index.html, standard for kiosk apps, not a
plausible cause on its own).
dedupe j()/errText(), stale-fetch guard, empty bar-picker state
Six small, mostly-unrelated items from the #47 review round (#57):
1. 'Statistik zurücksetzen' now checks res.ok and shows the server's
error instead of silently re-rendering the un-reset data on a 401/500
with no indication anything went wrong.
2. DayChart now tracks its container width via ResizeObserver and calls
uPlot's setSize() on change — previously read once at plot-creation
time, so a resize/orientation-change (more visible on the phone/
tablet path into /stats) left the chart the wrong width.
3. .qty-btn (cart +/- buttons) grown from 28x28px to 44x44px, the
conventional minimum touch target — was the one conspicuously small
interactive control in the money path, at a fast-moving bar counter.
4. Tablet's bar-config fetch (App.tsx) now guards against an
out-of-order resolution if barId changes again while a previous
api.config() call is still in flight. Currently unreachable (nothing
triggers a second barId change mid-fetch today) but closes the gap
before the next 'add a cancel button to the loading screen' change
could make it live.
5. j()/errText() were duplicated byte-for-byte between Admin.tsx and
Stats.tsx (Admin.tsx's own comment noted this exact duplication had
already caused drift once before) — centralized in api.ts, both files
now import instead of redeclaring.
6. Tablet's bar picker now shows an explicit empty-state message when
zero bars exist (fresh install, or every bar deleted), previously
indistinguishable from 'still loading'.
Closes#57. Verified: tsc --noEmit and vite build both clean.
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.
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.
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.
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.
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.
.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.
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.
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.
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.
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.
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.
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.
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.
- 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.
- 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.
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.
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.
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.
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.
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.
- 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
- 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
- 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
- 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