From 7a58a4a1bb4f539a1a91925dca44cf019270fd74 Mon Sep 17 00:00:00 2001 From: iris Date: Fri, 31 Jul 2026 11:17:08 +0200 Subject: [PATCH] client: fix reset-error handling, undersized qty buttons, chart resize, dedupe j()/errText(), stale-fetch guard, empty bar-picker state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- client/src/admin/Admin.tsx | 20 +------------------- client/src/api.ts | 25 +++++++++++++++++++++---- client/src/stats/DayChart.tsx | 16 ++++++++++++++++ client/src/stats/Stats.tsx | 19 +++---------------- client/src/styles.css | 12 ++++++++---- client/src/tablet/App.tsx | 12 +++++++++++- client/src/tablet/BarPicker.tsx | 4 ++++ 7 files changed, 64 insertions(+), 44 deletions(-) diff --git a/client/src/admin/Admin.tsx b/client/src/admin/Admin.tsx index c418fae..e3574bd 100644 --- a/client/src/admin/Admin.tsx +++ b/client/src/admin/Admin.tsx @@ -1,24 +1,6 @@ import { useEffect, useRef, useState } from 'preact/hooks'; import type { Bar, Drink } from '@wutzcalc/shared'; -import { formatCents } from '../api'; - -async function j(res: Response): Promise { - if (!res.ok) throw new Error(`${res.status} ${await res.text()}`); - return res.json(); -} - -// Best-effort error message from a failed response. Prefers RFC 7807 -// (application/problem+json) `detail`/`title` — what the admin routes send -// — falls back to the older ad hoc `{ error }` shape (still used by a few -// not-yet-converted endpoints), then plain status text. -async function errText(res: Response): Promise { - try { - const body = await res.json(); - return body.detail ?? body.title ?? body.error ?? res.statusText; - } catch { - return res.statusText; - } -} +import { errText, formatCents, j } from '../api'; // `Drink`/`Bar` come from @wutzcalc/shared — the tablet code already did // this, but Admin.tsx used to re-declare its own near-identical copies, diff --git a/client/src/api.ts b/client/src/api.ts index 21a12d2..84b7095 100644 --- a/client/src/api.ts +++ b/client/src/api.ts @@ -5,20 +5,37 @@ import type { CreateTransactionResponse, } from '@wutzcalc/shared'; -async function json(res: Response): Promise { +// Shared response helpers — Admin.tsx and Stats.tsx used to each declare +// byte-for-byte identical copies of both (Admin.tsx's own comment noted the +// duplication had already caused drift once before); centralized here so +// there's one implementation to keep in sync with the server's error shape. +export async function j(res: Response): Promise { if (!res.ok) throw new Error(`${res.status} ${await res.text()}`); return res.json() as Promise; } +// Best-effort error message from a failed response. Prefers RFC 7807 +// (application/problem+json) `detail`/`title` — what the admin routes send +// — falls back to the older ad hoc `{ error }` shape (still used by a few +// not-yet-converted endpoints), then plain status text. +export async function errText(res: Response): Promise { + try { + const body = await res.json(); + return body.detail ?? body.title ?? body.error ?? res.statusText; + } catch { + return res.statusText; + } +} + export const api = { - bars: () => fetch('/api/bars').then(json), - config: (barId: number) => fetch(`/api/config?bar=${barId}`).then(json), + bars: () => fetch('/api/bars').then(j), + config: (barId: number) => fetch(`/api/config?bar=${barId}`).then(j), createTransaction: (body: CreateTransactionRequest) => fetch('/api/transactions', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), - }).then(json), + }).then(j), }; export function formatCents(cents: number): string { diff --git a/client/src/stats/DayChart.tsx b/client/src/stats/DayChart.tsx index c71608d..51660a5 100644 --- a/client/src/stats/DayChart.tsx +++ b/client/src/stats/DayChart.tsx @@ -61,5 +61,21 @@ export function DayChart({ labels, series }: { labels: string[]; series: ChartSe return () => { plotRef.current?.destroy(); plotRef.current = null; }; }, [labels, series]); + // Width was only ever read once at plot-creation time — no resize/ + // orientation-change handling, minor on desktop but more visible on the + // phone/tablet path into /stats. setSize() is uPlot's cheap resize path + // (no destroy/rebuild), separate from the effect above since it only + // needs to run once for the holder's lifetime, not on every data change. + useEffect(() => { + const holder = holderRef.current; + if (!holder || typeof ResizeObserver === 'undefined') return; + const ro = new ResizeObserver(entries => { + const width = entries[0]?.contentRect.width; + if (width && plotRef.current) plotRef.current.setSize({ width, height: 220 }); + }); + ro.observe(holder); + return () => ro.disconnect(); + }, []); + return
; } diff --git a/client/src/stats/Stats.tsx b/client/src/stats/Stats.tsx index 4f16384..3adb6e8 100644 --- a/client/src/stats/Stats.tsx +++ b/client/src/stats/Stats.tsx @@ -1,21 +1,7 @@ import { useEffect, useMemo, useState } from 'preact/hooks'; -import { formatCents } from '../api'; +import { errText, formatCents, j } from '../api'; import { DayChart } from './DayChart'; -async function j(res: Response): Promise { - if (!res.ok) throw new Error(`${res.status} ${await res.text()}`); - return res.json(); -} - -async function errText(res: Response): Promise { - try { - const body = await res.json(); - return body.detail ?? body.title ?? body.error ?? res.statusText; - } catch { - return res.statusText; - } -} - interface Totals { bar_id: number; bar_name: string; tx_count: number; paid_cents: number; crew_count: number; pfand_returns: number } interface PerDrink { drink_id: number; drink_name: string; sold_qty: number } interface ByDay { day: string; tx_count: number; paid_cents: number; crew_count: number; pfand_returns: number } @@ -128,7 +114,8 @@ function Login({ onAuthed }: { onAuthed: () => void }) { function Dashboard({ data, isAdmin, onReset }: { data: StatsData; isAdmin: boolean; onReset: () => void }) { async function reset() { if (!confirm('Wirklich die gesamte Statistik (alle Transaktionen) unwiderruflich löschen?')) return; - await fetch('/admin/api/stats/reset', { method: 'POST' }); + const res = await fetch('/admin/api/stats/reset', { method: 'POST' }); + if (!res.ok) { alert(`Fehler: ${await errText(res)}`); return; } onReset(); } diff --git a/client/src/styles.css b/client/src/styles.css index 0e26255..86658b2 100644 --- a/client/src/styles.css +++ b/client/src/styles.css @@ -253,13 +253,17 @@ button.danger { background: #5a2a2a; border-color: #7a3a3a; color: #fff; } gap: 8px; } .qty-btn { + /* 44x44 is the conventional minimum touch target — this sits directly + against plain text in a packed row, and is the one interactive + control in the money path at a fast-moving bar counter, so it's worth + the extra row height. */ flex: 0 0 auto; - width: 28px; - height: 28px; + width: 44px; + height: 44px; padding: 0; line-height: 1; - font-size: 18px; - border-radius: 6px; + font-size: 20px; + border-radius: 8px; } .cart-total { font-size: 56px; diff --git a/client/src/tablet/App.tsx b/client/src/tablet/App.tsx index d6964ad..411e628 100644 --- a/client/src/tablet/App.tsx +++ b/client/src/tablet/App.tsx @@ -24,7 +24,17 @@ export function App() { useEffect(() => { if (barId == null) return; setConfig(null); - api.config(barId).then(setConfig).catch(e => setError(String(e))); + // Guards against an out-of-order resolution if barId changes again + // while this fetch is still in flight (e.g. a future "cancel loading" + // control) — currently unreachable since nothing triggers a second + // barId change mid-fetch today, but cheap to close now rather than + // leave as a defensive gap the next loading-screen change could make + // live. + let cancelled = false; + api.config(barId) + .then(c => { if (!cancelled) setConfig(c); }) + .catch(e => { if (!cancelled) setError(String(e)); }); + return () => { cancelled = true; }; }, [barId]); function pick(id: number) { diff --git a/client/src/tablet/BarPicker.tsx b/client/src/tablet/BarPicker.tsx index 5192d7c..fd81400 100644 --- a/client/src/tablet/BarPicker.tsx +++ b/client/src/tablet/BarPicker.tsx @@ -21,6 +21,10 @@ export function BarPicker({ bars, onPick }: Props) {

Bar auswählen

{bars == null ? (

Lade…

+ ) : bars.length === 0 ? ( + // A fresh install (or every bar deleted) otherwise renders nothing + // but the header here — indistinguishable from "still loading". +

Keine Bars angelegt — bitte im Backoffice anlegen.

) : ( bars.map(b => (