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.
This commit is contained in:
parent
9c75ce5243
commit
1d99881e88
10 changed files with 332 additions and 75 deletions
|
|
@ -11,7 +11,8 @@
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@wutzcalc/shared": "workspace:*",
|
"@wutzcalc/shared": "workspace:*",
|
||||||
"preact": "^10.22.0"
|
"preact": "^10.22.0",
|
||||||
|
"uplot": "^1.6.31"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@preact/preset-vite": "^2.8.2",
|
"@preact/preset-vite": "^2.8.2",
|
||||||
|
|
|
||||||
|
|
@ -27,9 +27,6 @@ async function errText(res: Response): Promise<string> {
|
||||||
// same wire field). BarRow/stats shapes below are admin-only — not part
|
// same wire field). BarRow/stats shapes below are admin-only — not part
|
||||||
// of the wire contract the tablet also consumes — so they stay local.
|
// of the wire contract the tablet also consumes — so they stay local.
|
||||||
interface BarRow extends Bar { drink_ids: number[] }
|
interface BarRow extends Bar { drink_ids: 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 ByDay { day: string; tx_count: number; paid_cents: number; crew_count: number; pfand_returns: number }
|
|
||||||
|
|
||||||
export function Admin() {
|
export function Admin() {
|
||||||
const [authed, setAuthed] = useState<boolean | null>(null);
|
const [authed, setAuthed] = useState<boolean | null>(null);
|
||||||
|
|
@ -100,7 +97,10 @@ function Dashboard({ onLogout }: { onLogout: () => void }) {
|
||||||
Abmelden
|
Abmelden
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<Stats />
|
<div class="row" style="justify-content: space-between; align-items: center">
|
||||||
|
<h2 style="margin:0">Statistik</h2>
|
||||||
|
<a href="/stats">Statistik ansehen →</a>
|
||||||
|
</div>
|
||||||
<Exports />
|
<Exports />
|
||||||
<Drinks drinks={drinks} reload={reloadDrinks} />
|
<Drinks drinks={drinks} reload={reloadDrinks} />
|
||||||
<Bars drinks={drinks} />
|
<Bars drinks={drinks} />
|
||||||
|
|
@ -108,76 +108,6 @@ function Dashboard({ onLogout }: { onLogout: () => void }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function fmtDay(day: string): string {
|
|
||||||
const [y, m, d] = day.split('-');
|
|
||||||
return `${d}.${m}.${y}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function Stats() {
|
|
||||||
const [data, setData] = useState<{ totals: Totals[]; per_drink: PerDrink[]; by_day: ByDay[] } | null>(null);
|
|
||||||
function reload() {
|
|
||||||
fetch('/admin/api/stats').then(j).then(setData).catch(e => alert(`Fehler beim Laden: ${e}`));
|
|
||||||
}
|
|
||||||
useEffect(reload, []);
|
|
||||||
|
|
||||||
async function reset() {
|
|
||||||
if (!confirm('Wirklich die gesamte Statistik (alle Transaktionen) unwiderruflich löschen?')) return;
|
|
||||||
await fetch('/admin/api/stats/reset', { method: 'POST' });
|
|
||||||
reload();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!data) return <p>Lade Statistik…</p>;
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div class="row" style="justify-content: space-between">
|
|
||||||
<h2 style="margin:0">Umsatz nach Tagen</h2>
|
|
||||||
<button class="danger" onClick={reset}>Statistik zurücksetzen</button>
|
|
||||||
</div>
|
|
||||||
<table>
|
|
||||||
<thead><tr><th>Tag</th><th>Transaktionen</th><th>Bezahlt</th><th>Crew-Transaktionen</th><th>Pfand zurück</th></tr></thead>
|
|
||||||
<tbody>
|
|
||||||
{data.by_day.length === 0 && <tr><td colSpan={5} class="muted">Noch keine Verkäufe</td></tr>}
|
|
||||||
{data.by_day.map(d => (
|
|
||||||
<tr key={d.day}>
|
|
||||||
<td>{fmtDay(d.day)}</td>
|
|
||||||
<td>{d.tx_count}</td>
|
|
||||||
<td>{formatCents(d.paid_cents)}</td>
|
|
||||||
<td>{d.crew_count}</td>
|
|
||||||
<td>{d.pfand_returns}</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
<h2>Umsatz pro Bar</h2>
|
|
||||||
<table>
|
|
||||||
<thead><tr><th>Bar</th><th>Transaktionen</th><th>Bezahlt</th><th>Crew-Transaktionen</th><th>Pfand zurück</th></tr></thead>
|
|
||||||
<tbody>
|
|
||||||
{data.totals.map(t => (
|
|
||||||
<tr key={t.bar_id}>
|
|
||||||
<td>{t.bar_name}</td>
|
|
||||||
<td>{t.tx_count}</td>
|
|
||||||
<td>{formatCents(t.paid_cents)}</td>
|
|
||||||
<td>{t.crew_count}</td>
|
|
||||||
<td>{t.pfand_returns}</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
<h2>Getränke</h2>
|
|
||||||
<table>
|
|
||||||
<thead><tr><th>Getränk</th><th>Verkauft</th></tr></thead>
|
|
||||||
<tbody>
|
|
||||||
{data.per_drink.map(d => (
|
|
||||||
<tr key={d.drink_id}>
|
|
||||||
<td>{d.drink_name}</td>
|
|
||||||
<td>{d.sold_qty ?? 0}</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Exports() {
|
function Exports() {
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
60
client/src/stats/DayChart.tsx
Normal file
60
client/src/stats/DayChart.tsx
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
import { useEffect, useRef } from 'preact/hooks';
|
||||||
|
import uPlot from 'uplot';
|
||||||
|
import 'uplot/dist/uPlot.min.css';
|
||||||
|
|
||||||
|
// A handful of distinguishable line colors, theme-independent (uPlot draws
|
||||||
|
// on canvas, so it can't pick up CSS custom properties the way the rest of
|
||||||
|
// the app's chrome does) — enough for the "top 5 drinks" case, cycles if a
|
||||||
|
// caller ever passes more series.
|
||||||
|
const COLORS = ['#7aa2f7', '#f7768e', '#9ece6a', '#e0af68', '#bb9af7'];
|
||||||
|
|
||||||
|
export interface ChartSeries { name: string; values: number[] }
|
||||||
|
|
||||||
|
// Thin uPlot wrapper: x-axis is just sequential day labels (business days
|
||||||
|
// with zero sales are already dropped server-side, so evenly-spaced ticks
|
||||||
|
// read better than gapped real dates would) rather than real timestamps —
|
||||||
|
// avoids re-deriving a Date from the server's businessDay() string, which
|
||||||
|
// would need the same DST-aware timezone handling admin.ts already
|
||||||
|
// centralizes rather than a second copy in the browser.
|
||||||
|
export function DayChart({ labels, series }: { labels: string[]; series: ChartSeries[] }) {
|
||||||
|
const holderRef = useRef<HTMLDivElement>(null);
|
||||||
|
const plotRef = useRef<uPlot | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const holder = holderRef.current;
|
||||||
|
if (!holder) return;
|
||||||
|
|
||||||
|
const xs = labels.map((_, i) => i);
|
||||||
|
const data: uPlot.AlignedData = [xs, ...series.map(s => s.values)];
|
||||||
|
|
||||||
|
const opts: uPlot.Options = {
|
||||||
|
width: holder.clientWidth || 600,
|
||||||
|
height: 220,
|
||||||
|
legend: { show: series.length > 1 },
|
||||||
|
cursor: { drag: { x: false, y: false } },
|
||||||
|
scales: { x: { time: false } },
|
||||||
|
axes: [
|
||||||
|
{ stroke: '#888', grid: { stroke: '#333' }, values: (_u, vals) => vals.map(v => labels[v] ?? '') },
|
||||||
|
{ stroke: '#888', grid: { stroke: '#333' } },
|
||||||
|
],
|
||||||
|
series: [
|
||||||
|
{},
|
||||||
|
...series.map((s, i) => ({
|
||||||
|
label: s.name,
|
||||||
|
stroke: COLORS[i % COLORS.length],
|
||||||
|
width: 2,
|
||||||
|
points: { show: labels.length <= 14 },
|
||||||
|
})),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
plotRef.current = new uPlot(opts, data, holder);
|
||||||
|
// Re-create on every data change rather than plot.setData: these charts
|
||||||
|
// redraw once per page load / manual refresh, not on a hot path, so
|
||||||
|
// the simplicity of "just rebuild it" outweighs the incremental-update
|
||||||
|
// complexity setData would otherwise save.
|
||||||
|
return () => { plotRef.current?.destroy(); plotRef.current = null; };
|
||||||
|
}, [labels, series]);
|
||||||
|
|
||||||
|
return <div ref={holderRef} class="chart" />;
|
||||||
|
}
|
||||||
219
client/src/stats/Stats.tsx
Normal file
219
client/src/stats/Stats.tsx
Normal file
|
|
@ -0,0 +1,219 @@
|
||||||
|
import { useEffect, useMemo, useState } from 'preact/hooks';
|
||||||
|
import { formatCents } from '../api';
|
||||||
|
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 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 PerDrinkByDay { day: string; drinks: PerDrink[] }
|
||||||
|
interface StatsData { totals: Totals[]; per_drink: PerDrink[]; by_day: ByDay[]; per_drink_by_day: PerDrinkByDay[] }
|
||||||
|
|
||||||
|
function fmtDay(day: string): string {
|
||||||
|
const [y, m, d] = day.split('-');
|
||||||
|
return `${d}.${m}.${y}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shorter, no-year form for chart axis labels — the table above already
|
||||||
|
// carries the full date, and a year suffix on every tick just crowds a
|
||||||
|
// narrow axis for what's realistically a single-event date range anyway.
|
||||||
|
function fmtDayShort(day: string): string {
|
||||||
|
const [, m, d] = day.split('-');
|
||||||
|
return `${d}.${m}.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auth here isn't a full <Admin/>-style flow — GET /api/stats itself is the
|
||||||
|
// source of truth for whether a login is needed (STATS_PUBLIC can make it
|
||||||
|
// open with no session at all), so we just try the fetch and fall back to
|
||||||
|
// the same admin login form on a 401, rather than pre-checking /admin/api/me
|
||||||
|
// like Admin.tsx does.
|
||||||
|
export function Stats() {
|
||||||
|
const [data, setData] = useState<StatsData | null>(null);
|
||||||
|
const [needsLogin, setNeedsLogin] = useState(false);
|
||||||
|
const [err, setErr] = useState<string | null>(null);
|
||||||
|
// Separate from "did /api/stats succeed" — STATS_PUBLIC can make that
|
||||||
|
// true with no session at all, but "Statistik zurücksetzen" deletes
|
||||||
|
// every transaction and must stay gated on an actual admin login
|
||||||
|
// regardless of STATS_PUBLIC (the server enforces this too; this is just
|
||||||
|
// to not show a button that always 401s for a public/anonymous viewer).
|
||||||
|
const [isAdmin, setIsAdmin] = useState(false);
|
||||||
|
|
||||||
|
function reload() {
|
||||||
|
setErr(null);
|
||||||
|
fetch('/admin/api/me').then(j).then(r => setIsAdmin(!!r.authed)).catch(() => setIsAdmin(false));
|
||||||
|
fetch('/api/stats')
|
||||||
|
.then(async res => {
|
||||||
|
if (res.status === 401) { setNeedsLogin(true); return; }
|
||||||
|
if (!res.ok) throw new Error(await errText(res));
|
||||||
|
setNeedsLogin(false);
|
||||||
|
setData(await res.json());
|
||||||
|
})
|
||||||
|
.catch(e => setErr(String(e)));
|
||||||
|
}
|
||||||
|
useEffect(reload, []);
|
||||||
|
|
||||||
|
if (needsLogin) return <Login onAuthed={reload} />;
|
||||||
|
if (err) return <div class="admin"><p style="color:var(--danger)">{err}</p></div>;
|
||||||
|
if (!data) return <div class="admin"><p>Lade Statistik…</p></div>;
|
||||||
|
return <Dashboard data={data} isAdmin={isAdmin} onReset={reload} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Login({ onAuthed }: { onAuthed: () => void }) {
|
||||||
|
const [pw, setPw] = useState('');
|
||||||
|
const [err, setErr] = useState<string | null>(null);
|
||||||
|
|
||||||
|
async function submit(e: Event) {
|
||||||
|
e.preventDefault();
|
||||||
|
setErr(null);
|
||||||
|
try {
|
||||||
|
await fetch('/admin/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ password: pw }),
|
||||||
|
}).then(j);
|
||||||
|
onAuthed();
|
||||||
|
} catch (e: any) {
|
||||||
|
setErr(String(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form class="admin" onSubmit={submit}>
|
||||||
|
<div class="login">
|
||||||
|
<h1>Statistik</h1>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
placeholder="Passwort"
|
||||||
|
value={pw}
|
||||||
|
onInput={(e: any) => setPw(e.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
{err && <div style="color:var(--danger)">{err}</div>}
|
||||||
|
<button type="submit">Anmelden</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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' });
|
||||||
|
onReset();
|
||||||
|
}
|
||||||
|
|
||||||
|
// by_day/per_drink_by_day come pre-sorted from the server (by_day newest
|
||||||
|
// first for the table below, per_drink_by_day oldest first for the
|
||||||
|
// chart's left-to-right reading order) — no re-sort needed here.
|
||||||
|
const revenueSeries = useMemo(
|
||||||
|
() => [...data.by_day].reverse().map(d => ({ x: fmtDayShort(d.day), y: d.paid_cents / 100 })),
|
||||||
|
[data.by_day]
|
||||||
|
);
|
||||||
|
const txSeries = useMemo(
|
||||||
|
() => [...data.by_day].reverse().map(d => ({ x: fmtDayShort(d.day), y: d.tx_count })),
|
||||||
|
[data.by_day]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Top 5 drinks by all-time volume — a per-drink-per-day line chart with
|
||||||
|
// every drink on it gets unreadable past a handful of series, and the
|
||||||
|
// long tail is rarely what "how's it developing" is actually asking
|
||||||
|
// about.
|
||||||
|
const topDrinkIds = useMemo(
|
||||||
|
() => data.per_drink.slice(0, 5).map(d => d.drink_id),
|
||||||
|
[data.per_drink]
|
||||||
|
);
|
||||||
|
const drinkTrend = useMemo(() => {
|
||||||
|
const names = new Map(data.per_drink.map(d => [d.drink_id, d.drink_name]));
|
||||||
|
const labels = data.per_drink_by_day.map(d => fmtDayShort(d.day));
|
||||||
|
const series = topDrinkIds.map(id => ({
|
||||||
|
name: names.get(id) ?? `#${id}`,
|
||||||
|
values: data.per_drink_by_day.map(day => day.drinks.find(x => x.drink_id === id)?.sold_qty ?? 0),
|
||||||
|
}));
|
||||||
|
return { labels, series };
|
||||||
|
}, [data.per_drink_by_day, topDrinkIds]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="admin">
|
||||||
|
<div class="row" style="justify-content: space-between">
|
||||||
|
<h1 style="margin:0">Statistik</h1>
|
||||||
|
<div class="row" style="gap: 8px">
|
||||||
|
{isAdmin && <button class="danger" onClick={reset}>Statistik zurücksetzen</button>}
|
||||||
|
<a href="/admin">← Backoffice</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>Umsatz pro Tag</h2>
|
||||||
|
{revenueSeries.length === 0
|
||||||
|
? <p class="muted">Noch keine Verkäufe</p>
|
||||||
|
: <DayChart labels={revenueSeries.map(p => p.x)} series={[{ name: 'Umsatz (€)', values: revenueSeries.map(p => p.y) }]} />}
|
||||||
|
|
||||||
|
<h2>Transaktionen pro Tag</h2>
|
||||||
|
{txSeries.length === 0
|
||||||
|
? <p class="muted">Noch keine Verkäufe</p>
|
||||||
|
: <DayChart labels={txSeries.map(p => p.x)} series={[{ name: 'Transaktionen', values: txSeries.map(p => p.y) }]} />}
|
||||||
|
|
||||||
|
<h2>Top-Getränke im Verlauf</h2>
|
||||||
|
{drinkTrend.series.length === 0
|
||||||
|
? <p class="muted">Noch keine Verkäufe</p>
|
||||||
|
: <DayChart labels={drinkTrend.labels} series={drinkTrend.series} />}
|
||||||
|
|
||||||
|
<h2>Umsatz nach Tagen</h2>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Tag</th><th>Transaktionen</th><th>Bezahlt</th><th>Crew-Transaktionen</th><th>Pfand zurück</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{data.by_day.length === 0 && <tr><td colSpan={5} class="muted">Noch keine Verkäufe</td></tr>}
|
||||||
|
{data.by_day.map(d => (
|
||||||
|
<tr key={d.day}>
|
||||||
|
<td>{fmtDay(d.day)}</td>
|
||||||
|
<td>{d.tx_count}</td>
|
||||||
|
<td>{formatCents(d.paid_cents)}</td>
|
||||||
|
<td>{d.crew_count}</td>
|
||||||
|
<td>{d.pfand_returns}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h2>Umsatz pro Bar</h2>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Bar</th><th>Transaktionen</th><th>Bezahlt</th><th>Crew-Transaktionen</th><th>Pfand zurück</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{data.totals.map(t => (
|
||||||
|
<tr key={t.bar_id}>
|
||||||
|
<td>{t.bar_name}</td>
|
||||||
|
<td>{t.tx_count}</td>
|
||||||
|
<td>{formatCents(t.paid_cents)}</td>
|
||||||
|
<td>{t.crew_count}</td>
|
||||||
|
<td>{t.pfand_returns}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h2>Getränke (gesamt)</h2>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Getränk</th><th>Verkauft</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{data.per_drink.map(d => (
|
||||||
|
<tr key={d.drink_id}>
|
||||||
|
<td>{d.drink_name}</td>
|
||||||
|
<td>{d.sold_qty ?? 0}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
6
client/src/stats/main.tsx
Normal file
6
client/src/stats/main.tsx
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
import { render } from 'preact';
|
||||||
|
import { Stats } from './Stats';
|
||||||
|
import { applyTheme } from '../api';
|
||||||
|
|
||||||
|
applyTheme();
|
||||||
|
render(<Stats />, document.getElementById('app')!);
|
||||||
|
|
@ -287,6 +287,13 @@ button.danger { background: #5a2a2a; border-color: #7a3a3a; color: #fff; }
|
||||||
.admin .row { display: flex; gap: 8px; align-items: center; margin-bottom: 8px; flex-wrap: wrap; }
|
.admin .row { display: flex; gap: 8px; align-items: center; margin-bottom: 8px; flex-wrap: wrap; }
|
||||||
.admin .muted { opacity: 0.6; }
|
.admin .muted { opacity: 0.6; }
|
||||||
.admin .login { max-width: 320px; margin: 80px auto; display: flex; flex-direction: column; gap: 12px; }
|
.admin .login { max-width: 320px; margin: 80px auto; display: flex; flex-direction: column; gap: 12px; }
|
||||||
|
.admin .chart {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border-soft);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 12px 12px 4px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
.dnd-list { list-style: none; padding: 0; margin: 0 0 8px; }
|
.dnd-list { list-style: none; padding: 0; margin: 0 0 8px; }
|
||||||
.dnd-item {
|
.dnd-item {
|
||||||
|
|
|
||||||
21
client/stats.html
Normal file
21
client/stats.html
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>wutzcalc — Statistik</title>
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<script>
|
||||||
|
// Set the theme before first paint to avoid a flash of the wrong colors.
|
||||||
|
document.documentElement.setAttribute(
|
||||||
|
'data-theme',
|
||||||
|
localStorage.getItem('wutz.theme') === 'light' ? 'light' : 'dark'
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
<link rel="stylesheet" href="/src/styles.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/stats/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -25,6 +25,7 @@ export default defineConfig({
|
||||||
input: {
|
input: {
|
||||||
main: resolve(__dirname, 'index.html'),
|
main: resolve(__dirname, 'index.html'),
|
||||||
admin: resolve(__dirname, 'admin.html'),
|
admin: resolve(__dirname, 'admin.html'),
|
||||||
|
stats: resolve(__dirname, 'stats.html'),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
8
pnpm-lock.yaml
generated
8
pnpm-lock.yaml
generated
|
|
@ -16,6 +16,9 @@ importers:
|
||||||
preact:
|
preact:
|
||||||
specifier: ^10.22.0
|
specifier: ^10.22.0
|
||||||
version: 10.29.2
|
version: 10.29.2
|
||||||
|
uplot:
|
||||||
|
specifier: ^1.6.31
|
||||||
|
version: 1.6.32
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@preact/preset-vite':
|
'@preact/preset-vite':
|
||||||
specifier: ^2.8.2
|
specifier: ^2.8.2
|
||||||
|
|
@ -1865,6 +1868,9 @@ packages:
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
browserslist: '>= 4.21.0'
|
browserslist: '>= 4.21.0'
|
||||||
|
|
||||||
|
uplot@1.6.32:
|
||||||
|
resolution: {integrity: sha512-KIMVnG68zvu5XXUbC4LQEPnhwOxBuLyW1AHtpm6IKTXImkbLgkMy+jabjLgSLMasNuGGzQm/ep3tOkyTxpiQIw==}
|
||||||
|
|
||||||
util-deprecate@1.0.2:
|
util-deprecate@1.0.2:
|
||||||
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
|
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
|
||||||
|
|
||||||
|
|
@ -3766,6 +3772,8 @@ snapshots:
|
||||||
escalade: 3.2.0
|
escalade: 3.2.0
|
||||||
picocolors: 1.1.1
|
picocolors: 1.1.1
|
||||||
|
|
||||||
|
uplot@1.6.32: {}
|
||||||
|
|
||||||
util-deprecate@1.0.2: {}
|
util-deprecate@1.0.2: {}
|
||||||
|
|
||||||
vite-prerender-plugin@0.5.13(vite@5.4.21(@types/node@20.19.41)(terser@5.47.1)):
|
vite-prerender-plugin@0.5.13(vite@5.4.21(@types/node@20.19.41)(terser@5.47.1)):
|
||||||
|
|
|
||||||
|
|
@ -105,6 +105,10 @@ if (existsSync(clientDist)) {
|
||||||
reply.sendFile('admin.html');
|
reply.sendFile('admin.html');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (req.url.startsWith('/stats')) {
|
||||||
|
reply.sendFile('stats.html');
|
||||||
|
return;
|
||||||
|
}
|
||||||
reply.sendFile('index.html');
|
reply.sendFile('index.html');
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue