From a2e4321ac76be52e12ae10dd309480aac5afc60d Mon Sep 17 00:00:00 2001 From: Simon Gruber Date: Thu, 5 Nov 2026 23:15:25 +0000 Subject: [PATCH] Rework My Orders stats and add responsive UI checks My Orders: year calendar and lunch profile first, chips and "How you compare" removed, open and paid payments merged into one table (open first, then newest). Activity is now a line chart with a cumulative switch and finer buckets (daily up to ~3 months, weekly up to ~13). Cards get subtle elevation and tighter spacing. Responsive: phone tab strip instead of the side menu, heatmap fits narrow screens, grouped column-chart labels are truncated, the manager weekday table scrolls instead of overflowing. Tooling: viewports.mjs (presets, overflow detector), shoot.mjs --sizes and --select, tests/responsive.ui.spec.ts, docs/ui-testing.md. Co-Authored-By: Claude Sonnet 5.5 --- CLAUDE.md | 3 +- docs/statistics.md | 7 +- docs/ui-testing.md | 60 ++++++++++ src/e2e/shoot.mjs | 76 +++++++----- src/e2e/tests/responsive.ui.spec.ts | 46 +++++++ src/e2e/viewports.mjs | 85 +++++++++++++ .../src/components/statistics/Heatmaps.tsx | 10 +- .../src/components/statistics/Highlights.tsx | 8 +- .../components/statistics/ManagerStats.tsx | 1 + .../components/statistics/MyOrdersStats.tsx | 112 +++++++----------- .../src/components/statistics/StatCard.tsx | 13 +- .../components/statistics/TimeSeriesPanel.tsx | 42 +++++-- .../src/components/statistics/charts.tsx | 86 ++++++++++++-- src/frontend/src/lib/stats.ts | Bin 12885 -> 12888 bytes src/frontend/src/views/StatisticsView.tsx | 20 +++- 15 files changed, 435 insertions(+), 134 deletions(-) create mode 100644 docs/ui-testing.md create mode 100644 src/e2e/tests/responsive.ui.spec.ts create mode 100644 src/e2e/viewports.mjs diff --git a/CLAUDE.md b/CLAUDE.md index 2ff3a42..3684a84 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -33,11 +33,12 @@ The native stack uses `src/dev/config.yaml` (via the `CONFIG_PATH` env var) inst ```bash cd src/e2e npx playwright test # everything; --project=api or --project=ui to narrow down -node shoot.mjs / /create "{order}" "{admin}" /stats [--as=user|admin|anon] [--dark] [--mobile] [--full] +node shoot.mjs / /create "{order}" "{admin}" /stats [--as=user|admin|anon] [--dark] [--mobile] [--full] [--sizes=check|all|WxH,...] [--select=css] ``` - Logging in uses the real flow: `lib.mjs` `signIn(email?)` registers (or migrates, for an existing email), reads the confirmation mail from Mailpit's API and confirms it, giving back a user ID. In the browser, a user is "logged in" by setting localStorage `lunchtime_token` before load (`loginAs` in `fixtures.ts`). Fixtures: `user` (fresh per test), `admin` (the ADMIN_EMAILS account), `userPage`. - `lib.mjs` also has `api()` (fetch with `X-User-Id`), `createOrder()` (with `SAMPLE_MENU`) and `submit()`. Use them to seed data instead of clicking through the UI. +- **After any layout change** run the responsive checks (phone, tablet, desktop; overflow detection; `tests/responsive.ui.spec.ts`): see `docs/ui-testing.md`. `shoot.mjs --sizes=...` exits 1 and prints `OVERFLOW` when a page is wider than its viewport. - `shoot.mjs` prints PNG paths (default dir `src/e2e/screenshots/`, gitignored) plus console errors and failed requests. `{order}`/`{admin}` seed an order that has a menu and two submissions. Use it to view the UI after visual changes. - Ant Design Select: its `role=option` nodes are hidden a11y helpers, so click `.ant-select-item-option` instead. - Playwright is pinned to 1.65.0 to match the Chromium build in `PLAYWRIGHT_BROWSERS_PATH`. If they diverge, `npx playwright install chromium`. diff --git a/docs/statistics.md b/docs/statistics.md index d59b831..80d6304 100644 --- a/docs/statistics.md +++ b/docs/statistics.md @@ -19,7 +19,7 @@ Statistics pages live under `/stats/*` (`src/frontend/src/views/StatisticsView.t - One page-wide timeframe filter (`range.tsx`: 1W, 1M, 3M, 1Y, YTD, All, custom) drives every number and chart, except the all-time blocks labelled as such (lunch profile, calendar, payments, funnel). - Stat tiles show the change vs. the previous period of equal length (`Delta`); "All" has no comparison. -- The "Activity" panel (`TimeSeriesPanel.tsx`) shows per-period totals (days / weeks / months depending on the range) instead of cumulative running totals. Site-wide daily counts come from `daily` in `/api/admin/stats`. +- The "Activity" panel (`TimeSeriesPanel.tsx`) draws line charts of per-period totals (days up to ~3 months, weeks up to ~13 months, months beyond; `periodBuckets` in `lib/stats.ts`) with a "Cumulative" switch (off by default) for running totals. Site-wide daily counts come from `daily` in `/api/admin/stats`. - Personal insights (lunch personality, records, adventurousness, calendar and weekday x hour heatmaps) are computed in `lib/stats.ts` from the fact rows. `community` in `/api/stats/me` (participant count, average, percentile) is the only extra aggregate, from `user_order_tokens`. - Site "active users" now means users who created an order or submission in the window (was: last profile update). The funnel and weekly retention use the same data. - **Privacy model:** My Orders only ever contains the viewer's own data plus anonymous aggregates (percentile, orders-per-participant histogram, organizer counts/shares). `/api/stats/me` returns no emails; organizers are numbered per response. Order Management is scoped to the viewer's own orders (their participants are visible, as in the order admin view). Only Site Overview (admin) sees site-wide data and names. @@ -27,12 +27,15 @@ Statistics pages live under `/stats/*` (`src/frontend/src/views/StatisticsView.t ## My Orders (orderees) +Layout: year calendar, lunch profile (all-time facts: orders, streak, average, busiest month, milestone, organizers, adventurousness, rank), activity, favourite items + payments, weekday x hour. + + - ✅ Participation over time (orders joined per month) and current streak (consecutive weeks) - ✅ Favourite items and categories, items repeated most (drill-down: all items, by category, or one category) - ✅ Total and average spend per order (priced menus only) - ✅ Usual order day / hour - ✅ Most frequent organizers -- ✅ Open payments (accepted, unpaid) and payment history +- ✅ Payments: one table of open (accepted, unpaid) and paid orders, open first, then newest first; status filter - 🔲 How early you submit before the deadline — orders have no deadline. Backend: add `group_orders.deadline` (nullable ISO text) + create/update API + UI field; expose it in `/api/stats/me`. ## Order Management diff --git a/docs/ui-testing.md b/docs/ui-testing.md new file mode 100644 index 0000000..955f113 --- /dev/null +++ b/docs/ui-testing.md @@ -0,0 +1,60 @@ +# UI checks: does it look right on phones and desktops? + +Tools live in `src/e2e/`. They need the dev stack (`src/dev/devenv.sh start`, app on :8020). + +| Tool | What it is for | +|---|---| +| `viewports.mjs` | Shared presets (`iphone-se` 375x667, `android-small` 360x740, `iphone-14` 390x844, `pixel-7` 412x915, `ipad` 768x1024, `laptop` 1280x800, `desktop` 1440x900, `fhd` 1920x1080), groups and `findHorizontalOverflow(page)` | +| `shoot.mjs` | Screenshots in any viewport, reports console errors, failed requests and **horizontal overflow** (exit code 1) | +| `tests/responsive.ui.spec.ts` | Regression test: no page overflows its viewport (home, create, three stats pages, order, order admin) | + +## The process after any layout change + +1. **Typecheck:** `cd src/frontend && npx tsc --noEmit`. +2. **Automatic overflow check, all sizes**, for the pages you touched (needs a user with data, `--as=admin` has the most): + ```bash + cd src/e2e + node shoot.mjs /stats/orderee /stats/manager /stats/admin --as=admin --sizes=all # light + node shoot.mjs /stats/orderee /stats/manager /stats/admin --as=admin --sizes=all --dark # dark + ``` + Any `WARN OVERFLOW : ... offenders` line is a bug: the page scrolls sideways on that device. The offenders are the elements that stick out. Fix: wrap tables in `scroll={{ x: true }}`, give flex children `minWidth: 0`, use `minmax(min(Npx, 100%), 1fr)` in grids. +3. **Look at it.** Overflow is only one kind of bug. Shoot the extremes and read the PNGs: + ```bash + node shoot.mjs /stats/orderee --as=admin --sizes=iphone-se,android-small,fhd --full + ``` + - Smallest phone (360 / 375): truncated or overlapping labels, tall single-column stacks, controls wrapping ("Stat us"), things that need sideways scrolling without a hint. + - Tablet (768): where the layout switches; the awkward in-between. + - Widest desktop (1920): content stays centred and readable, nothing stretched. + - Dark mode on at least one phone and one desktop size. +4. **Zoom on a single widget** instead of reading a 14000px full-page shot: + ```bash + node shoot.mjs /stats/manager --as=admin --sizes=android-small,desktop --select='.ant-card:has-text("Participants per order")' + ``` +5. **Run the regression spec** (`check` = iphone-se, pixel-7, ipad, desktop; `RESPONSIVE_SIZES=all` for all eight): + ```bash + npx playwright test --project=ui responsive + RESPONSIVE_SIZES=all npx playwright test --project=ui responsive + ``` + Many parallel page loads can be slow on the Vite dev server; if you see timeouts add `--workers=2`. + +## Getting data worth looking at + +A fresh user has no data and shows only the empty state. Options: + +- `--as=admin` in the dev stack usually has history. `--seed` or `{order}` / `{admin}` in the path create an order with two submissions. +- The API can only create things "now". For charts over time, backdate rows directly in `src/backend/data/burger_orders.db` (`submissions.created_at`, `accepted`, `paid`). Remember the original values and **restore them afterwards**. + +## Gotchas + +- `--sizes` takes preset names, groups (`phones`, `tablets`, `desktops`, `check`, `all`) or `WxH`. A name is never both a group and a preset. +- `--mobile` still means the Pixel 7 preset; `--sizes` supersedes it. +- `findHorizontalOverflow` ignores elements inside an intentional scroller (`overflow-x: auto|scroll|hidden|clip`) and fixed overlays, so heatmaps that scroll on purpose are not reported. That also means a clipped-but-unhinted scroller is **not** caught: look at the screenshots. +- A pre-existing React warning (`useForm` not connected to a Form) appears once per page load in every shoot; it is not caused by the stats pages. +- In this repo's shell, `grep` is a wrapper that honours `.gitignore`-style ignore files and silently skips some source files (for example `lib/stats.ts`). Use `command grep` when a search result looks suspiciously empty. + +## Conventions the stats pages follow (so they pass) + +- Cards come from `ChartCard`; grids from `CardGrid` (`minmax(min(Npx, 100%), 1fr)`). +- Wide content scrolls inside its own container (tables: `scroll={{ x: true }}`; the year calendar opens scrolled to the latest week) or shrinks to fit (the weekday x hour heatmap). +- Column-chart labels are truncated per group of slots (`labelEvery`); the full text is in the tooltip. +- Phones (<= 640px) get a tab strip (`Segmented`) instead of the side menu in `StatisticsView.tsx`. diff --git a/src/e2e/shoot.mjs b/src/e2e/shoot.mjs index 2c9aaa6..da26616 100644 --- a/src/e2e/shoot.mjs +++ b/src/e2e/shoot.mjs @@ -9,15 +9,21 @@ // --seed Create a sample order (with menu + a submission) first; implied by {order}/{admin} // --dark Dark theme (sets the app's theme preference) // --mobile Phone viewport (Pixel 7); default is 1440x900 desktop +// --sizes=list Shoot every listed viewport instead: preset names (see viewports.mjs), groups +// (phones|tablets|desktops|check|all) or WxH, comma-separated, e.g. --sizes=check // --full Full-page screenshot instead of viewport only +// --select=css Screenshot only the first element matching this Playwright selector, e.g. +// --select='.ant-card:has-text("Payments")' (implies a tight crop; combine with --sizes) // --wait=ms Extra settle time after network idle (default 300) // --out=dir Output directory (default: screenshots/) // -// Prints one PNG path per line. Also reports console errors and failed requests, which are worth checking. +// Prints one PNG path per line. Also reports console errors and failed requests, which are worth checking, +// and "OVERFLOW" lines (plus exit code 1) when a page is wider than its viewport (see findHorizontalOverflow). import { mkdirSync } from "node:fs"; import { resolve } from "node:path"; import { chromium, devices } from "@playwright/test"; import { ADMIN_EMAIL, BASE_URL, USER_ID_STORAGE_KEY, createOrder, signIn, submit } from "./lib.mjs"; +import { contextOptions, findHorizontalOverflow, resolveViewports } from "./viewports.mjs"; const args = process.argv.slice(2); const flag = (name, fallback) => { @@ -32,6 +38,7 @@ const as = flag("as", "user"); const dark = !!flag("dark", false); const mobile = !!flag("mobile", false); const fullPage = !!flag("full", false); +const select = flag("select", null); const settleMs = Number(flag("wait", 300)); const outDir = resolve(flag("out", "screenshots")); const needsOrder = !!flag("seed", false) || paths.some((p) => p.includes("{order}") || p.includes("{admin}")); @@ -48,35 +55,46 @@ if (needsOrder) { await submit(account.userId, order.id, { burger: "Veggie", sides: [] }); } -const browser = await chromium.launch(); -const context = await browser.newContext({ - ...(mobile ? devices["Pixel 7"] : { viewport: { width: 1440, height: 900 } }), - colorScheme: dark ? "dark" : "light", - baseURL: BASE_URL, -}); -await context.addInitScript( - ([key, userId, theme]) => { - if (userId) localStorage.setItem(key, userId); - localStorage.setItem("lunchtime_theme_mode", theme); - }, - [USER_ID_STORAGE_KEY, account?.userId ?? "", dark ? "dark" : "light"], -); - -const page = await context.newPage(); -const problems = []; -page.on("console", (m) => m.type() === "error" && problems.push(`console: ${m.text()}`)); -page.on("pageerror", (e) => problems.push(`pageerror: ${e.message}`)); -page.on("response", (r) => r.status() >= 400 && problems.push(`HTTP ${r.status()} ${r.url()}`)); - +const sizes = flag("sizes", null); +const viewports = resolveViewports(sizes ?? (mobile ? "pixel-7" : "desktop")); const stamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19); -for (const raw of paths) { - const path = raw.replace("{order}", `/order/${order?.id}`).replace("{admin}", `/order/${order?.id}/admin`).replace(/^\/\//, "/"); - await page.goto(path, { waitUntil: "networkidle" }); - await page.waitForTimeout(settleMs); - const slug = (raw.replace(/[^a-z0-9]+/gi, "_").replace(/^_|_$/g, "") || "home") + (dark ? "-dark" : "") + (mobile ? "-mobile" : ""); - const file = resolve(outDir, `${stamp}-${slug}.png`); - await page.screenshot({ path: file, fullPage }); - console.log(file); +const problems = []; +const browser = await chromium.launch(); + +for (const vp of viewports) { + const context = await browser.newContext({ ...contextOptions(vp), colorScheme: dark ? "dark" : "light", baseURL: BASE_URL }); + await context.addInitScript( + ([key, userId, theme]) => { + if (userId) localStorage.setItem(key, userId); + localStorage.setItem("lunchtime_theme_mode", theme); + }, + [USER_ID_STORAGE_KEY, account?.userId ?? "", dark ? "dark" : "light"], + ); + + const page = await context.newPage(); + page.on("console", (m) => m.type() === "error" && problems.push(`console: ${m.text()}`)); + page.on("pageerror", (e) => problems.push(`pageerror: ${e.message}`)); + page.on("response", (r) => r.status() >= 400 && problems.push(`HTTP ${r.status()} ${r.url()}`)); + + for (const raw of paths) { + const path = raw.replace("{order}", `/order/${order?.id}`).replace("{admin}", `/order/${order?.id}/admin`).replace(/^\/\//, "/"); + await page.goto(path, { waitUntil: "networkidle" }); + await page.waitForTimeout(settleMs); + // Keep the old "-mobile" suffix for --mobile; named sizes get their own name. + const suffix = sizes ? `-${vp.name}` : mobile ? "-mobile" : ""; + const slug = (raw.replace(/[^a-z0-9]+/gi, "_").replace(/^_|_$/g, "") || "home") + (dark ? "-dark" : "") + suffix + (select ? "-el" : ""); + const file = resolve(outDir, `${stamp}-${slug}.png`); + if (select) await page.locator(select).first().screenshot({ path: file }); + else await page.screenshot({ path: file, fullPage }); + console.log(file); + + const overflow = await findHorizontalOverflow(page); + if (overflow) { + process.exitCode = 1; + problems.push(`OVERFLOW ${vp.name} ${vp.width}x${vp.height} ${raw}: page ${overflow.scrollWidth}px wide in a ${overflow.clientWidth}px viewport; ${overflow.offenders.join(" | ")}`); + } + } + await context.close(); } if (account) console.error(`logged in as ${account.email} (${account.userId})`); diff --git a/src/e2e/tests/responsive.ui.spec.ts b/src/e2e/tests/responsive.ui.spec.ts new file mode 100644 index 0000000..600d1c6 --- /dev/null +++ b/src/e2e/tests/responsive.ui.spec.ts @@ -0,0 +1,46 @@ +import { test, expect, loginAs } from "../fixtures"; +import { createOrder, signIn, submit } from "../lib.mjs"; +import { findHorizontalOverflow, resolveViewports, contextOptions } from "../viewports.mjs"; + +// Layout regression check: no page may be wider than its viewport (that makes the whole page scroll sideways on a phone). +// RESPONSIVE_SIZES widens the matrix, e.g. RESPONSIVE_SIZES=all npx playwright test --project=ui responsive. +// Many parallel page loads against the Vite dev server can be slow; give each check room. +test.describe.configure({ timeout: 90_000 }); + +const viewports = resolveViewports(process.env.RESPONSIVE_SIZES || "check"); + +// One seeded order (menu + two submissions) per worker, so the statistics pages have data to lay out. +let seeded: Promise | undefined; +const seedOrder = (ownerId: string) => + (seeded ??= (async () => { + const order = await createOrder(ownerId); + await submit((await signIn()).userId, order.id, { burger: "Cheeseburger", sides: ["Fries", "Side salad"] }); + await submit(ownerId, order.id, { burger: "Veggie", sides: [] }); + return order.id as string; + })()); + +const routes = [ + ["home", "/"], + ["create", "/create"], + ["stats: my orders", "/stats/orderee"], + ["stats: order management", "/stats/manager"], + ["stats: site overview", "/stats/admin"], + ["order", "/order/{id}"], + ["order admin", "/order/{id}/admin"], +] as const; + +for (const vp of viewports) { + test.describe(`${vp.name} ${vp.width}x${vp.height}`, () => { + test.use(contextOptions(vp)); + + for (const [name, route] of routes) { + test(`${name} fits the screen`, async ({ page, admin }) => { + const orderId = await seedOrder(admin.userId); + await loginAs(page, admin); + await page.goto(route.replace("{id}", orderId), { waitUntil: "networkidle" }); + const overflow = await findHorizontalOverflow(page); + expect(overflow, JSON.stringify(overflow, null, 2)).toBeNull(); + }); + } + }); +} diff --git a/src/e2e/viewports.mjs b/src/e2e/viewports.mjs new file mode 100644 index 0000000..4194b6b --- /dev/null +++ b/src/e2e/viewports.mjs @@ -0,0 +1,85 @@ +// Shared viewport presets and layout checks for screenshots (shoot.mjs) and responsive tests. +// +// import { VIEWPORTS, resolveViewports, contextOptions, findHorizontalOverflow } from "./viewports.mjs"; + +/** Common real-world resolutions (CSS px). `mobile` turns on touch + mobile emulation. */ +export const VIEWPORTS = { + "iphone-se": { width: 375, height: 667, dpr: 2, mobile: true }, // smallest iPhone still in use + "android-small": { width: 360, height: 740, dpr: 3, mobile: true }, // the most common Android width + "iphone-14": { width: 390, height: 844, dpr: 3, mobile: true }, + "pixel-7": { width: 412, height: 915, dpr: 2.625, mobile: true }, + ipad: { width: 768, height: 1024, dpr: 2, mobile: true }, // tablet portrait: where the layout switches + laptop: { width: 1280, height: 800, dpr: 1, mobile: false }, + desktop: { width: 1440, height: 900, dpr: 1, mobile: false }, + fhd: { width: 1920, height: 1080, dpr: 1, mobile: false }, +}; + +/** Groups for `--sizes=`; "check" is the short list worth looking at after any layout change. */ +export const GROUPS = { + phones: ["iphone-se", "android-small", "iphone-14", "pixel-7"], + tablets: ["ipad"], + desktops: ["laptop", "desktop", "fhd"], + check: ["iphone-se", "pixel-7", "ipad", "desktop"], + all: Object.keys(VIEWPORTS), +}; + +/** + * Turns "check", "phones,desktops", "iphone-se,800x600" or "all" into [{ name, width, height, dpr, mobile }]. + * "WxH" is a custom size (mobile emulation below 600px wide). + */ +export function resolveViewports(spec) { + const out = []; + for (const part of String(spec).split(",").map((s) => s.trim()).filter(Boolean)) { + const names = GROUPS[part] ?? [part]; + for (const name of names) { + const custom = /^(\d+)x(\d+)$/.exec(name); + if (custom) { + const [width, height] = [Number(custom[1]), Number(custom[2])]; + out.push({ name, width, height, dpr: 1, mobile: width < 600 }); + } else if (VIEWPORTS[name]) { + out.push({ name, ...VIEWPORTS[name] }); + } else { + throw new Error(`Unknown viewport "${name}". Presets: ${Object.keys(VIEWPORTS).join(", ")}; groups: ${Object.keys(GROUPS).join(", ")}; or WxH.`); + } + } + } + return out; +} + +/** Playwright browser-context options for a viewport. */ +export const contextOptions = (vp) => ({ + viewport: { width: vp.width, height: vp.height }, + deviceScaleFactor: vp.dpr, + isMobile: vp.mobile, + hasTouch: vp.mobile, +}); + +/** + * Finds horizontal overflow: content wider than the viewport that the user would have to scroll the whole page to see. + * Elements inside an intentional scroller or clipped container (overflow-x: auto/scroll/hidden/clip) are ignored, as are fixed overlays. + * Returns null when the page fits, otherwise { scrollWidth, clientWidth, offenders: ["div.ant-card (right 412 > 390): text…"] }. + */ +export async function findHorizontalOverflow(page) { + return page.evaluate(() => { + const clientWidth = document.documentElement.clientWidth; + const scrollWidth = document.documentElement.scrollWidth; + const clipped = (el) => { + for (let p = el.parentElement; p && p !== document.documentElement; p = p.parentElement) { + const style = getComputedStyle(p); + if (style.position === "fixed") return true; + if (p !== document.body && ["auto", "scroll", "hidden", "clip"].includes(style.overflowX)) return true; + } + return false; + }; + const offenders = []; + for (const el of document.body.querySelectorAll("*")) { + const box = el.getBoundingClientRect(); + if (box.width === 0 || box.height === 0 || box.right <= clientWidth + 1) continue; + if (getComputedStyle(el).position === "fixed" || clipped(el)) continue; + const cls = typeof el.className === "string" && el.className ? "." + el.className.trim().split(/\s+/).slice(0, 2).join(".") : ""; + offenders.push(`${el.tagName.toLowerCase()}${cls} (right ${Math.round(box.right)} > ${clientWidth}): ${(el.textContent || "").trim().slice(0, 40)}`); + } + if (scrollWidth <= clientWidth + 1 && !offenders.length) return null; + return { scrollWidth, clientWidth, offenders: offenders.slice(0, 5) }; + }); +} diff --git a/src/frontend/src/components/statistics/Heatmaps.tsx b/src/frontend/src/components/statistics/Heatmaps.tsx index a1184ee..2777a10 100644 --- a/src/frontend/src/components/statistics/Heatmaps.tsx +++ b/src/frontend/src/components/statistics/Heatmaps.tsx @@ -13,6 +13,10 @@ function useShade() { export function CalendarHeatmap({ timestamps, weeks = 53 }: { timestamps: string[]; weeks?: number }) { const { token } = theme.useToken(); const shade = useShade(); + // Narrow screens scroll horizontally: start at the most recent weeks. + const scroller = React.useCallback((el: HTMLDivElement | null) => { + if (el) el.scrollLeft = el.scrollWidth; + }, []); const counts = countsByDay(timestamps); const today = startOfDay(new Date()); const first = addDays(today, -((today.getDay() + 6) % 7) - (weeks - 1) * 7); @@ -26,7 +30,7 @@ export function CalendarHeatmap({ timestamps, weeks = 53 }: { timestamps: string } return ( -
+
@@ -62,7 +66,7 @@ export function WeekdayHourHeatmap({ grid }: { grid: number[][] }) { const max = Math.max(1, ...grid.flat()); return (
-
+
{Array.from({ length: 24 }, (_, h) => ( {h % 3 === 0 ? h : ""} @@ -72,7 +76,7 @@ export function WeekdayHourHeatmap({ grid }: { grid: number[][] }) { {WEEKDAY_NAMES[d].slice(0, 3)} {row.map((n, h) => ( -
+
))} diff --git a/src/frontend/src/components/statistics/Highlights.tsx b/src/frontend/src/components/statistics/Highlights.tsx index 3018c2a..e6df96d 100644 --- a/src/frontend/src/components/statistics/Highlights.tsx +++ b/src/frontend/src/components/statistics/Highlights.tsx @@ -7,13 +7,13 @@ export type Fact = { label: string; value: React.ReactNode; note?: React.ReactNo export function FactGrid({ facts }: { facts: Fact[] }) { const { token } = theme.useToken(); return ( -
+
{facts.map((f) => ( -
+
{f.label} -
{f.value}
+
{f.value}
{f.note && ( {f.note} @@ -28,5 +28,5 @@ export function FactGrid({ facts }: { facts: Fact[] }) { /** Big one-line headline, e.g. the lunch personality. */ export function Headline({ children }: { children: React.ReactNode }) { const { token } = theme.useToken(); - return
{children}
; + return
{children}
; } diff --git a/src/frontend/src/components/statistics/ManagerStats.tsx b/src/frontend/src/components/statistics/ManagerStats.tsx index eff3d80..4102b65 100644 --- a/src/frontend/src/components/statistics/ManagerStats.tsx +++ b/src/frontend/src/components/statistics/ManagerStats.tsx @@ -211,6 +211,7 @@ export default function ManagerStats() { rowKey="day" pagination={false} dataSource={byWeekday.filter((r) => r.submissions > 0)} + scroll={{ x: true }} locale={{ emptyText: "No submissions in this timeframe" }} columns={[ { title: "Day", dataIndex: "day" }, diff --git a/src/frontend/src/components/statistics/MyOrdersStats.tsx b/src/frontend/src/components/statistics/MyOrdersStats.tsx index ac7b97f..ee1e0ad 100644 --- a/src/frontend/src/components/statistics/MyOrdersStats.tsx +++ b/src/frontend/src/components/statistics/MyOrdersStats.tsx @@ -16,7 +16,6 @@ import { lunchPersonality, nextMilestone, sum, - sumIn, weekdayHourGrid, } from "../../lib/stats"; import type { MyStatsSubmission } from "../../lib/types"; @@ -26,22 +25,39 @@ import { FactGrid, Headline } from "./Highlights"; import ItemRanking from "./ItemRanking"; import { useRange } from "./range"; import TimeSeriesPanel from "./TimeSeriesPanel"; -import { BarList } from "./charts"; -import { CardGrid, ChartCard, PageStack, StatRow } from "./StatCard"; +import { CardGrid, ChartCard, PageStack } from "./StatCard"; const load = () => apiService.stats.getMine(); const paymentColumns = [ + { + title: "Status", + key: "status", + width: 84, + filters: [ + { text: "Open", value: "open" }, + { text: "Paid", value: "paid" }, + ], + onFilter: (value: React.Key | boolean, s: MyStatsSubmission) => (s.paid ? "paid" : "open") === value, + render: (_: unknown, s: MyStatsSubmission) => (s.paid ? Paid : Open), + }, { title: "Order", key: "order", + ellipsis: true, render: (_: unknown, s: MyStatsSubmission) => navigateTo(`/order/${s.order_id}`)}>{s.order_title}, }, - { title: "Date", key: "date", render: (_: unknown, s: MyStatsSubmission) => new Date(s.submitted_at).toLocaleDateString() }, + { title: "Date", key: "date", width: 76, + render: (_: unknown, s: MyStatsSubmission) => { + const d = new Date(s.submitted_at); + return {d.toLocaleDateString(undefined, { day: "numeric", month: "short", year: d.getFullYear() === new Date().getFullYear() ? undefined : "numeric" })}; + }, + }, { title: "Amount", key: "amount", align: "right" as const, + width: 72, render: (_: unknown, s: MyStatsSubmission) => (s.total === null ? "–" : formatMoney(s.total)), }, ]; @@ -52,9 +68,9 @@ export default function MyOrdersStats() { const { data, loading, error, run } = useApiRequest(load); const all = data?.submissions ?? []; const community = data?.community; - const { range, previous } = useRange(all.length ? new Date(all[0].submitted_at) : null); + const { range } = useRange(all.length ? new Date(all[0].submitted_at) : null); - // Everything below the highlights follows the selected timeframe; records, calendar and payments are all-time. + // Activity, favourite items and the weekday/hour grid follow the selected timeframe; the profile, calendar and payments are all-time. const submissions = all.filter((s) => inRange(new Date(s.submitted_at), range)); const events = (rows: MyStatsSubmission[], value: (s: MyStatsSubmission) => number) => rows.map((s) => ({ date: new Date(s.submitted_at), value: value(s) })); @@ -63,8 +79,7 @@ export default function MyOrdersStats() { const times = submissions.map((s) => s.submitted_at); const allTimes = all.map((s) => s.submitted_at); - const priced = submissions.filter((s) => s.total !== null).map((s) => s.total as number); - const prevPriced = previous ? all.filter((s) => s.total !== null && inRange(new Date(s.submitted_at), previous)) : []; + const allPriced = all.filter((s) => s.total !== null).map((s) => s.total as number); const open = all.filter((s) => s.accepted && !s.paid); const paid = all.filter((s) => s.paid); @@ -90,32 +105,22 @@ export default function MyOrdersStats() { emptyState={Statistics appear once you have submitted to an order.} > - s.total as number)) : null }, - }, - { title: "Still to pay", value: formatMoney(sum(open.map((s) => s.total ?? 0))), hint: `${open.length} accepted, unpaid` }, - ]} - /> + + + - - {personality ? {personality} : Join a few more orders to reveal your lunch personality} + + {personality ?? "Join a few more orders to reveal your lunch personality"} 0 && { label: "Average / order", value: formatMoney(average(allPriced)), note: priciest && `priciest: ${formatMoney(priciest.total as number)}` }, busiest && { label: "Busiest month", value: busiest.label, note: `${busiest.count} ${busiest.count === 1 ? "order" : "orders"}` }, - priciest && { label: "Priciest order", value: formatMoney(priciest.total as number), note: priciest.order_title }, { label: "Next milestone", value: `${ordinal(milestone)} order`, note: `${milestone - all.length} to go` }, organizerFact && organizerFact, adventure && { @@ -139,52 +144,23 @@ export default function MyOrdersStats() { ]} /> - - - - s.items))} /> - - {community && community.participants >= 3 ? ( - <> - ({ label: `${d.label} ${d.label === "1" ? "order" : "orders"}${d.mine ? " ← you" : ""}`, value: d.count }))} - format={(n) => `${n} ${n === 1 ? "person" : "people"}`} - /> - - Average: {community.average_orders} orders per participant. - - - ) : ( - Comparisons appear once a few more people have joined orders. - )} + s.total ?? 0)))} to pay` : "all settled"}> + Number(x.paid) - Number(y.paid) || y.submitted_at.localeCompare(x.submitted_at))} + columns={paymentColumns} + locale={{ emptyText: "No payments yet" }} + /> - - -
- - - Payment history {paid.length} - - } - > -
- ); diff --git a/src/frontend/src/components/statistics/StatCard.tsx b/src/frontend/src/components/statistics/StatCard.tsx index 89d56fe..5599246 100644 --- a/src/frontend/src/components/statistics/StatCard.tsx +++ b/src/frontend/src/components/statistics/StatCard.tsx @@ -3,7 +3,7 @@ import { Card, Typography, theme } from "antd"; import { percentChange } from "../../lib/stats"; /** Spacing shared by every block on the statistics pages. */ -export const GAP = 16; +export const GAP = 12; /** Vertical stack of page blocks with a uniform gap. */ export function PageStack({ children }: { children: React.ReactNode }) { @@ -40,7 +40,7 @@ export function StatRow({ stats }: { stats: Stat[] }) { return (
{stats.map((s) => ( - + {s.title} @@ -72,16 +72,17 @@ export function CardGrid({ children, minWidth = 320, balance = true }: { childre } /** The one card style used for every chart, ranking and table: fixed header height, full cell height. */ -export function ChartCard({ title, extra, children }: { title: React.ReactNode; extra?: React.ReactNode; children: React.ReactNode }) { +export function ChartCard({ title, extra, hero, children }: { title: React.ReactNode; extra?: React.ReactNode; /** Slightly higher elevation, for the one block that should stand out. */ hero?: boolean; children: React.ReactNode }) { + const { token } = theme.useToken(); return ( {extra} : extra} - style={{ height: "100%" }} + style={{ height: "100%", boxShadow: hero ? token.boxShadowSecondary : token.boxShadowTertiary }} styles={{ - header: { minHeight: 52, fontSize: 15 }, - body: { padding: 16 }, + header: { minHeight: 42, padding: "0 14px", fontSize: 14 }, + body: { padding: 14 }, }} > {children} diff --git a/src/frontend/src/components/statistics/TimeSeriesPanel.tsx b/src/frontend/src/components/statistics/TimeSeriesPanel.tsx index 17f8330..7c63b4e 100644 --- a/src/frontend/src/components/statistics/TimeSeriesPanel.tsx +++ b/src/frontend/src/components/statistics/TimeSeriesPanel.tsx @@ -1,7 +1,7 @@ -import React from "react"; -import { Typography } from "antd"; +import React, { useState } from "react"; +import { Switch, Typography } from "antd"; import { Bucket, periodBuckets, sumIn, TimedValue } from "../../lib/stats"; -import { ColumnChart } from "./charts"; +import { LineChart } from "./charts"; import { useRange } from "./range"; import { CardGrid, ChartCard, Delta } from "./StatCard"; @@ -11,28 +11,48 @@ export type TimeMetric = { format?: (value: number) => string; }; -/** Per-period totals (days, weeks or months, depending on the timeframe) for several metrics, each with its change vs. the previous period. */ +/** Running total of the buckets, starting at 0 at the beginning of the range. */ +const accumulate = (buckets: Bucket[]): Bucket[] => { + let total = 0; + return buckets.map((b) => ({ ...b, value: (total += b.value) })); +}; + +/** Per-period values (days, weeks or months, depending on the timeframe) as line charts, optionally cumulative, each with its change vs. the previous period. */ export default function TimeSeriesPanel({ title = "Activity", metrics }: { title?: string; metrics: TimeMetric[] }) { + const [cumulative, setCumulative] = useState(false); const earliestMs = Math.min(Infinity, ...metrics.flatMap((m) => m.events.map((e) => e.date.getTime()))); const { range, previous } = useRange(Number.isFinite(earliestMs) ? new Date(earliestMs) : null); return ( - - + + + {range[0].toLocaleDateString()} – {range[1].toLocaleDateString()} + + + + } + > + {metrics.map((metric) => { const format = metric.format ?? String; const total = sumIn(metric.events, range); - const buckets: Bucket[] = periodBuckets(metric.events, range); + const buckets = periodBuckets(metric.events, range); return (
- + {metric.title} -
- {format(total)} +
+ {format(total)}
- +
); })} diff --git a/src/frontend/src/components/statistics/charts.tsx b/src/frontend/src/components/statistics/charts.tsx index 0fdbbb6..f6b7009 100644 --- a/src/frontend/src/components/statistics/charts.tsx +++ b/src/frontend/src/components/statistics/charts.tsx @@ -1,4 +1,4 @@ -import React from "react"; +import React, { useState } from "react"; import { Empty, Tooltip, Typography, theme } from "antd"; import type { Bucket } from "../../lib/stats"; @@ -68,14 +68,84 @@ export function ColumnChart({ ))}
- {/* Labels sit in their own row and may overflow their slot, centred on the bar, so they are never clipped. */} -
- {data.map((d, i) => ( -
- - {i % labelEvery === 0 ? d.label : ""} + {/* Labels sit in their own row. With every n-th label shown, each one spans its n slots and is truncated there, so long names never collide. */} +
+ {data.map((d, i) => + i % labelEvery === 0 ? ( + 1 ? `${i + 1} / span ${labelEvery}` : undefined, + fontSize: 11, + lineHeight: "14px", + textAlign: "center", + ...(labelEvery === 1 && { display: "flex", justifyContent: "center" }), + whiteSpace: "nowrap", + // One slot per label (labelEvery = 1) may overflow its slot, centred on the bar; wider groups truncate. + overflow: labelEvery > 1 ? "hidden" : "visible", + textOverflow: "ellipsis", + color: token.colorTextSecondary, + }} + > + {d.label} -
+ ) : null, + )} +
+
+
+ ); +} + +/** Smooth-enough line with a soft area fill and a hover read-out; one point per bucket, so dense series stay legible. */ +export function LineChart({ data, height = 120, format = String }: { data: Bucket[]; height?: number; format?: (value: number) => string }) { + const { token } = theme.useToken(); + const [hover, setHover] = useState(null); + const n = data.length; + const max = Math.max(1, ...data.map((d) => d.value)); + if (data.every((d) => d.value === 0)) return noData; + + const x = (i: number) => (n > 1 ? (i / (n - 1)) * 100 : 50); + const y = (v: number) => 100 - (v / max) * 100; + const line = data.map((d, i) => `${i ? "L" : "M"}${x(i)},${y(d.value)}`).join(" "); + const ticks = Array.from(new Set(Array.from({ length: Math.min(n, 4) }, (_, k) => Math.round((k * (n - 1)) / Math.max(1, Math.min(n, 4) - 1))))); + const shift = (pct: number) => (pct > 75 ? "-100%" : pct < 25 ? "0" : "-50%"); + + return ( +
+
+ {format(max)} + {format(0)} +
+
+
{ + const box = e.currentTarget.getBoundingClientRect(); + setHover(Math.min(n - 1, Math.max(0, Math.round(((e.clientX - box.left) / box.width) * (n - 1))))); + }} + onMouseLeave={() => setHover(null)} + > + + + + + {hover !== null && ( + <> +
+
+
+ {data[hover].label}: {format(data[hover].value)} +
+ + )} +
+
+ {ticks.map((i) => ( + + {data[i].label} + ))}
diff --git a/src/frontend/src/lib/stats.ts b/src/frontend/src/lib/stats.ts index dc411a235f373e155525cf0db21a4a0e0bdbbfc4..8c092d59c04c84c194a695a05b88d850fdc410f2 100644 GIT binary patch delta 60 zcmcbbawBEKQ%Nafh1~qSl8j)%g*vm%|0P2inT!oLhf8ZRG8tNI JZj!ks3IH9m5>)^I diff --git a/src/frontend/src/views/StatisticsView.tsx b/src/frontend/src/views/StatisticsView.tsx index e669df4..f1d15e5 100644 --- a/src/frontend/src/views/StatisticsView.tsx +++ b/src/frontend/src/views/StatisticsView.tsx @@ -4,18 +4,21 @@ import { ShoppingCartOutlined, UserOutlined, } from "@ant-design/icons"; -import { Menu, Typography } from "antd"; +import { Menu, Segmented, Typography } from "antd"; import { navigateTo, StatsSection } from "../lib/routing"; import ManagerStats from "../components/statistics/ManagerStats"; import MyOrdersStats from "../components/statistics/MyOrdersStats"; import SiteStats from "../components/statistics/SiteStats"; import { apiService } from "../lib/services"; +import { useMediaQuery } from "../hooks/useMediaQuery"; import { GAP } from "../components/statistics/StatCard"; import { RangeProvider, RangeState, TimeframeBar } from "../components/statistics/range"; type SectionDef = { key: StatsSection; label: string; + /** Compact label for the phone tab strip. */ + shortLabel?: string; description: string; icon: React.ReactNode; /** Shown only to administrators. */ @@ -37,6 +40,7 @@ const SECTIONS: SectionDef[] = [ key: "manager", content: , label: "Order Management", + shortLabel: "Management", description: "How your orders perform and who takes part.", icon: , ownersOnly: true, @@ -45,6 +49,7 @@ const SECTIONS: SectionDef[] = [ key: "admin", content: , label: "Site Overview", + shortLabel: "Site", description: "Users, activity and storage across the whole site.", icon: , adminOnly: true, @@ -59,6 +64,7 @@ export default function StatisticsView({ section: StatsSection; isAdmin: boolean; }) { + const narrow = useMediaQuery("(max-width: 640px)"); const [ownsOrders, setOwnsOrders] = useState(false); const [range, setRange] = useState({ timeframe: "3m", custom: null }); @@ -81,7 +87,17 @@ export default function StatisticsView({ return (
- {visible.length > 1 && ( + {visible.length > 1 && narrow && ( + // Phones: a full-width tab strip instead of a stacked side menu. + ({ value: `/stats/${key}`, label: shortLabel ?? label }))} + onChange={(key) => navigateTo(key as string)} + /> + )} + {visible.length > 1 && !narrow && (