client: fix reset-error handling, undersized qty buttons, chart resize,
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.
This commit is contained in:
parent
a7ec25997f
commit
7a58a4a1bb
7 changed files with 64 additions and 44 deletions
|
|
@ -1,24 +1,6 @@
|
||||||
import { useEffect, useRef, useState } from 'preact/hooks';
|
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||||
import type { Bar, Drink } from '@wutzcalc/shared';
|
import type { Bar, Drink } from '@wutzcalc/shared';
|
||||||
import { formatCents } from '../api';
|
import { errText, formatCents, j } from '../api';
|
||||||
|
|
||||||
async function j<T = any>(res: Response): Promise<T> {
|
|
||||||
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<string> {
|
|
||||||
try {
|
|
||||||
const body = await res.json();
|
|
||||||
return body.detail ?? body.title ?? body.error ?? res.statusText;
|
|
||||||
} catch {
|
|
||||||
return res.statusText;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// `Drink`/`Bar` come from @wutzcalc/shared — the tablet code already did
|
// `Drink`/`Bar` come from @wutzcalc/shared — the tablet code already did
|
||||||
// this, but Admin.tsx used to re-declare its own near-identical copies,
|
// this, but Admin.tsx used to re-declare its own near-identical copies,
|
||||||
|
|
|
||||||
|
|
@ -5,20 +5,37 @@ import type {
|
||||||
CreateTransactionResponse,
|
CreateTransactionResponse,
|
||||||
} from '@wutzcalc/shared';
|
} from '@wutzcalc/shared';
|
||||||
|
|
||||||
async function json<T>(res: Response): Promise<T> {
|
// 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<T = any>(res: Response): Promise<T> {
|
||||||
if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);
|
if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);
|
||||||
return res.json() as Promise<T>;
|
return res.json() as Promise<T>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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<string> {
|
||||||
|
try {
|
||||||
|
const body = await res.json();
|
||||||
|
return body.detail ?? body.title ?? body.error ?? res.statusText;
|
||||||
|
} catch {
|
||||||
|
return res.statusText;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const api = {
|
export const api = {
|
||||||
bars: () => fetch('/api/bars').then(json<Bar[]>),
|
bars: () => fetch('/api/bars').then(j<Bar[]>),
|
||||||
config: (barId: number) => fetch(`/api/config?bar=${barId}`).then(json<BarConfig>),
|
config: (barId: number) => fetch(`/api/config?bar=${barId}`).then(j<BarConfig>),
|
||||||
createTransaction: (body: CreateTransactionRequest) =>
|
createTransaction: (body: CreateTransactionRequest) =>
|
||||||
fetch('/api/transactions', {
|
fetch('/api/transactions', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
}).then(json<CreateTransactionResponse>),
|
}).then(j<CreateTransactionResponse>),
|
||||||
};
|
};
|
||||||
|
|
||||||
export function formatCents(cents: number): string {
|
export function formatCents(cents: number): string {
|
||||||
|
|
|
||||||
|
|
@ -61,5 +61,21 @@ export function DayChart({ labels, series }: { labels: string[]; series: ChartSe
|
||||||
return () => { plotRef.current?.destroy(); plotRef.current = null; };
|
return () => { plotRef.current?.destroy(); plotRef.current = null; };
|
||||||
}, [labels, series]);
|
}, [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 <div ref={holderRef} class="chart" />;
|
return <div ref={holderRef} class="chart" />;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,7 @@
|
||||||
import { useEffect, useMemo, useState } from 'preact/hooks';
|
import { useEffect, useMemo, useState } from 'preact/hooks';
|
||||||
import { formatCents } from '../api';
|
import { errText, formatCents, j } from '../api';
|
||||||
import { DayChart } from './DayChart';
|
import { DayChart } from './DayChart';
|
||||||
|
|
||||||
async function j<T = any>(res: Response): Promise<T> {
|
|
||||||
if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);
|
|
||||||
return res.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function errText(res: Response): Promise<string> {
|
|
||||||
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 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 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 }
|
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 }) {
|
function Dashboard({ data, isAdmin, onReset }: { data: StatsData; isAdmin: boolean; onReset: () => void }) {
|
||||||
async function reset() {
|
async function reset() {
|
||||||
if (!confirm('Wirklich die gesamte Statistik (alle Transaktionen) unwiderruflich löschen?')) return;
|
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();
|
onReset();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -253,13 +253,17 @@ button.danger { background: #5a2a2a; border-color: #7a3a3a; color: #fff; }
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
.qty-btn {
|
.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;
|
flex: 0 0 auto;
|
||||||
width: 28px;
|
width: 44px;
|
||||||
height: 28px;
|
height: 44px;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
font-size: 18px;
|
font-size: 20px;
|
||||||
border-radius: 6px;
|
border-radius: 8px;
|
||||||
}
|
}
|
||||||
.cart-total {
|
.cart-total {
|
||||||
font-size: 56px;
|
font-size: 56px;
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,17 @@ export function App() {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (barId == null) return;
|
if (barId == null) return;
|
||||||
setConfig(null);
|
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]);
|
}, [barId]);
|
||||||
|
|
||||||
function pick(id: number) {
|
function pick(id: number) {
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,10 @@ export function BarPicker({ bars, onPick }: Props) {
|
||||||
<h1>Bar auswählen</h1>
|
<h1>Bar auswählen</h1>
|
||||||
{bars == null ? (
|
{bars == null ? (
|
||||||
<p>Lade…</p>
|
<p>Lade…</p>
|
||||||
|
) : bars.length === 0 ? (
|
||||||
|
// A fresh install (or every bar deleted) otherwise renders nothing
|
||||||
|
// but the header here — indistinguishable from "still loading".
|
||||||
|
<p class="muted">Keine Bars angelegt — bitte im Backoffice anlegen.</p>
|
||||||
) : (
|
) : (
|
||||||
bars.map(b => (
|
bars.map(b => (
|
||||||
<button key={b.id} onClick={() => onPick(b.id)}>
|
<button key={b.id} onClick={() => onPick(b.id)}>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue