Rework My Orders stats and add responsive UI checks
Build and Push Lunchtime Images (Kaniko) / build-and-push (push) Successful in 1m44s

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 <noreply@anthropic.com>
This commit is contained in:
simandClaude Sonnet 5.5 committed 2026-11-05 23:15:25 +00:00
1 parent 9d80e6d06e
commit a2e4321ac7
15 files changed
+421 -120

No files matched your search

+2 -1
View File
@@ -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`.
+5 -2
View File
@@ -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
+60
View File
@@ -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 <size> <path>: ... 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`.
+35 -17
View File
@@ -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 sizes = flag("sizes", null);
const viewports = resolveViewports(sizes ?? (mobile ? "pixel-7" : "desktop"));
const stamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
const problems = [];
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(
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();
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 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()}`));
const stamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
for (const raw of paths) {
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" : "");
// 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`);
await page.screenshot({ path: file, fullPage });
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})`);
+46
View File
@@ -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<string> | 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();
});
}
});
}
+85
View File
@@ -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) };
});
}
@@ -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 (
<div style={{ overflowX: "auto" }}>
<div ref={scroller} style={{ overflowX: "auto" }}>
<div style={{ display: "flex", gap: 6, minWidth: 640 }}>
<div style={{ display: "grid", gridTemplateRows: "16px repeat(7, 1fr)", gap: 3, fontSize: 11, color: token.colorTextSecondary }}>
<span />
@@ -62,7 +66,7 @@ export function WeekdayHourHeatmap({ grid }: { grid: number[][] }) {
const max = Math.max(1, ...grid.flat());
return (
<div style={{ overflowX: "auto" }}>
<div style={{ display: "grid", gridTemplateColumns: "36px repeat(24, minmax(14px, 1fr))", gap: 3, minWidth: 460, fontSize: 11, color: token.colorTextSecondary }}>
<div style={{ display: "grid", gridTemplateColumns: "30px repeat(24, minmax(0, 1fr))", gap: 2, fontSize: 11, color: token.colorTextSecondary }}>
<span />
{Array.from({ length: 24 }, (_, h) => (
<span key={h} style={{ textAlign: "center", lineHeight: "14px" }}>{h % 3 === 0 ? h : ""}</span>
@@ -72,7 +76,7 @@ export function WeekdayHourHeatmap({ grid }: { grid: number[][] }) {
<span style={{ lineHeight: "20px" }}>{WEEKDAY_NAMES[d].slice(0, 3)}</span>
{row.map((n, h) => (
<Tooltip key={h} title={`${WEEKDAY_NAMES[d]}, ${h}:00–${h}:59: ${n}`}>
<div style={{ height: 20, borderRadius: 3, background: shade(n, max) }} />
<div style={{ height: 20, borderRadius: 2, background: shade(n, max) }} />
</Tooltip>
))}
</React.Fragment>
@@ -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 (
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(170px, 1fr))", gap: 12 }}>
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(132px, 1fr))", gap: 8 }}>
{facts.map((f) => (
<div key={f.label} style={{ background: token.colorFillQuaternary, borderRadius: token.borderRadius, padding: "10px 12px", minWidth: 0 }}>
<div key={f.label} style={{ background: token.colorFillQuaternary, borderRadius: token.borderRadius, padding: "8px 12px", minWidth: 0 }}>
<Typography.Text type="secondary" style={{ fontSize: 12, display: "block" }}>
{f.label}
</Typography.Text>
<div style={{ fontSize: 17, fontWeight: 600, lineHeight: 1.35 }}>{f.value}</div>
<div style={{ fontSize: 16, fontWeight: 600, lineHeight: 1.3 }}>{f.value}</div>
{f.note && (
<Typography.Text type="secondary" style={{ fontSize: 12, display: "block" }}>
{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 <div style={{ fontSize: 20, fontWeight: 600, color: token.colorPrimary, marginBottom: 14 }}>{children}</div>;
return <div style={{ fontSize: 18, fontWeight: 600, color: token.colorPrimary, marginBottom: 10 }}>{children}</div>;
}
@@ -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" },
@@ -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 ? <Tag color="success">Paid</Tag> : <Tag color="warning">Open</Tag>),
},
{
title: "Order",
key: "order",
ellipsis: true,
render: (_: unknown, s: MyStatsSubmission) => <a onClick={() => navigateTo(`/order/${s.order_id}`)}>{s.order_title}</a>,
},
{ 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 <span title={d.toLocaleDateString()}>{d.toLocaleDateString(undefined, { day: "numeric", month: "short", year: d.getFullYear() === new Date().getFullYear() ? undefined : "numeric" })}</span>;
},
},
{
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={<Typography.Text type="secondary">Statistics appear once you have submitted to an order.</Typography.Text>}
>
<PageStack>
<StatRow
stats={[
{ title: "Orders joined", value: submissions.length, delta: { current: submissions.length, previous: previous ? sumIn(joined, previous) : null } },
{ title: "Week streak", value: currentWeekStreak(allTimes), hint: `best: ${longestWeekStreak(allTimes)} ${longestWeekStreak(allTimes) === 1 ? "week" : "weeks"}` },
{
title: "Total spend",
value: formatMoney(sum(priced)),
hint: priced.length < submissions.length ? "priced menus only" : undefined,
delta: { current: sum(priced), previous: previous ? sumIn(spent, previous) : null },
},
{
title: "Average / order",
value: formatMoney(average(priced)),
delta: { current: average(priced), previous: previous ? average(prevPriced.map((s) => s.total as number)) : null },
},
{ title: "Still to pay", value: formatMoney(sum(open.map((s) => s.total ?? 0))), hint: `${open.length} accepted, unpaid` },
]}
/>
<ChartCard title="Your year in lunches" extra="last 12 months">
<CalendarHeatmap timestamps={allTimes} />
</ChartCard>
<ChartCard title="Your lunch profile" extra="all time">
{personality ? <Headline>{personality}</Headline> : <Headline>Join a few more orders to reveal your lunch personality</Headline>}
<ChartCard title="Your lunch profile" extra="all time" hero>
<Headline>{personality ?? "Join a few more orders to reveal your lunch personality"}</Headline>
<FactGrid
facts={[
{ label: "Longest streak", value: `${longestWeekStreak(allTimes)} ${longestWeekStreak(allTimes) === 1 ? "week" : "weeks"}`, note: "in a row" },
{ label: "Orders joined", value: all.length, note: allPriced.length ? `${formatMoney(sum(allPriced))} spent` : undefined },
{
label: "Week streak",
value: `${currentWeekStreak(allTimes)} ${currentWeekStreak(allTimes) === 1 ? "week" : "weeks"}`,
note: `best: ${longestWeekStreak(allTimes)} ${longestWeekStreak(allTimes) === 1 ? "week" : "weeks"}`,
},
allPriced.length > 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() {
]}
/>
<ChartCard title="Your year in lunches" extra="last 12 months">
<CalendarHeatmap timestamps={allTimes} />
</ChartCard>
<CardGrid>
<ItemRanking title="Favourite items" items={countItems(submissions.flatMap((s) => s.items))} />
<ChartCard title="How you compare" extra="anonymous, all participants">
{community && community.participants >= 3 ? (
<>
<BarList
rows={community.distribution.map((d) => ({ label: `${d.label} ${d.label === "1" ? "order" : "orders"}${d.mine ? " ← you" : ""}`, value: d.count }))}
format={(n) => `${n} ${n === 1 ? "person" : "people"}`}
<ChartCard title="Payments" extra={open.length ? `${formatMoney(sum(open.map((s) => s.total ?? 0)))} to pay` : "all settled"}>
<Table
size="small"
pagination={{ pageSize: 6, hideOnSinglePage: true, size: "small" }}
rowKey="order_id"
dataSource={[...open, ...paid].sort((x, y) => Number(x.paid) - Number(y.paid) || y.submitted_at.localeCompare(x.submitted_at))}
columns={paymentColumns}
locale={{ emptyText: "No payments yet" }}
/>
<Typography.Paragraph type="secondary" style={{ margin: "16px 0 0", fontSize: 13 }}>
Average: {community.average_orders} orders per participant.
</Typography.Paragraph>
</>
) : (
<Typography.Text type="secondary">Comparisons appear once a few more people have joined orders.</Typography.Text>
)}
</ChartCard>
</CardGrid>
<ChartCard title="When you order" extra="weekday and hour">
<WeekdayHourHeatmap grid={weekdayHourGrid(times)} />
</ChartCard>
<ChartCard title="Open payments">
<Table size="small" pagination={false} rowKey="order_id" dataSource={open} columns={paymentColumns} locale={{ emptyText: "Nothing to pay" }} />
</ChartCard>
<ChartCard
title={
<>
Payment history <Tag>{paid.length}</Tag>
</>
}
>
<Table
size="small"
pagination={{ pageSize: 5, hideOnSinglePage: true }}
rowKey="order_id"
dataSource={[...paid].reverse()}
columns={paymentColumns}
locale={{ emptyText: "No paid orders yet" }}
/>
</ChartCard>
</PageStack>
</AsyncContent>
);
@@ -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 (
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(130px, 1fr))", gap: GAP }}>
{stats.map((s) => (
<Card key={s.title} size="small" styles={{ body: { padding: "14px 16px" } }}>
<Card key={s.title} size="small" style={{ boxShadow: token.boxShadowTertiary }} styles={{ body: { padding: "12px 14px" } }}>
<Typography.Text type="secondary" style={{ fontSize: 13, display: "block", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }} title={s.title}>
{s.title}
</Typography.Text>
@@ -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 (
<Card
size="small"
title={title}
extra={typeof extra === "string" ? <Typography.Text type="secondary" style={{ fontSize: 12, fontWeight: 400 }}>{extra}</Typography.Text> : 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}
@@ -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 (
<ChartCard title={title} extra={`${range[0].toLocaleDateString()} – ${range[1].toLocaleDateString()}`}>
<CardGrid minWidth={220} balance={false}>
<ChartCard
title={title}
extra={
<span style={{ display: "inline-flex", alignItems: "center", gap: 12 }}>
<Typography.Text type="secondary" style={{ fontSize: 12, fontWeight: 400 }}>
{range[0].toLocaleDateString()} – {range[1].toLocaleDateString()}
</Typography.Text>
<label style={{ display: "inline-flex", alignItems: "center", gap: 6, fontSize: 12, fontWeight: 400, cursor: "pointer" }}>
<Switch size="small" checked={cumulative} onChange={setCumulative} />
Cumulative
</label>
</span>
}
>
<CardGrid minWidth={260} balance={false}>
{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 (
<div key={metric.title} style={{ minWidth: 0 }}>
<Typography.Text type="secondary" style={{ fontSize: 13, display: "block" }}>
<Typography.Text type="secondary" style={{ fontSize: 12, display: "block" }}>
{metric.title}
</Typography.Text>
<div style={{ display: "flex", alignItems: "baseline", gap: 8, margin: "2px 0 12px" }}>
<span style={{ fontSize: 22, fontWeight: 600, lineHeight: 1.3, fontVariantNumeric: "tabular-nums" }}>{format(total)}</span>
<div style={{ display: "flex", alignItems: "baseline", gap: 8, margin: "0 0 2px" }}>
<span style={{ fontSize: 20, fontWeight: 600, lineHeight: 1.3, fontVariantNumeric: "tabular-nums" }}>{format(total)}</span>
<Delta current={total} previous={previous ? sumIn(metric.events, previous) : null} />
</div>
<ColumnChart data={buckets} height={110} labelEvery={Math.max(1, Math.ceil(buckets.length / 4))} format={format} />
<LineChart data={cumulative ? accumulate(buckets) : buckets} height={110} format={format} />
</div>
);
})}
@@ -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({
</Tooltip>
))}
</div>
{/* Labels sit in their own row and may overflow their slot, centred on the bar, so they are never clipped. */}
<div style={{ display: "flex", gap: 3, marginTop: 6, height: 14 }}>
{data.map((d, i) => (
<div key={`${d.label}-${i}`} style={{ flex: 1, minWidth: 0, display: "flex", justifyContent: "center" }}>
<span style={{ flex: "none", fontSize: 11, lineHeight: "14px", whiteSpace: "nowrap", color: token.colorTextSecondary }}>
{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. */}
<div style={{ display: "grid", gridTemplateColumns: `repeat(${data.length}, minmax(0, 1fr))`, columnGap: 3, marginTop: 6, height: 14 }}>
{data.map((d, i) =>
i % labelEvery === 0 ? (
<span
key={`${d.label}-${i}`}
title={d.label}
style={{
gridColumn: labelEvery > 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}
</span>
) : null,
)}
</div>
</div>
</div>
);
}
/** 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<number | null>(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 (
<div style={{ display: "flex", gap: 6, paddingTop: 22 }}>
<div style={{ width: 40, height, display: "flex", flexDirection: "column", justifyContent: "space-between", alignItems: "flex-end", fontSize: 11, lineHeight: 1, color: token.colorTextSecondary, fontVariantNumeric: "tabular-nums" }}>
<span>{format(max)}</span>
<span>{format(0)}</span>
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{ position: "relative", height, borderBottom: `1px solid ${token.colorBorder}`, backgroundImage: `linear-gradient(${token.colorBorderSecondary} 1px, transparent 1px)`, backgroundSize: "100% 50%" }}
onMouseMove={(e) => {
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)}
>
<svg viewBox="0 0 100 100" preserveAspectRatio="none" style={{ width: "100%", height: "100%", display: "block", overflow: "visible" }}>
<path d={`${line} L${x(n - 1)},100 L${x(0)},100 Z`} fill={token.colorPrimary} fillOpacity={0.1} stroke="none" />
<path d={line} fill="none" stroke={token.colorPrimary} strokeWidth={2} strokeLinejoin="round" vectorEffect="non-scaling-stroke" />
</svg>
{hover !== null && (
<>
<div style={{ position: "absolute", left: `${x(hover)}%`, top: 0, bottom: 0, borderLeft: `1px dashed ${token.colorBorder}`, pointerEvents: "none" }} />
<div style={{ position: "absolute", left: `${x(hover)}%`, top: `${y(data[hover].value)}%`, width: 8, height: 8, margin: "-4px 0 0 -4px", borderRadius: "50%", background: token.colorPrimary, boxShadow: `0 0 0 2px ${token.colorBgContainer}`, pointerEvents: "none" }} />
<div style={{ position: "absolute", left: `${x(hover)}%`, top: -8, transform: `translate(${shift(x(hover))}, -100%)`, padding: "2px 8px", fontSize: 12, whiteSpace: "nowrap", borderRadius: token.borderRadiusSM, background: token.colorBgElevated, boxShadow: token.boxShadowSecondary, pointerEvents: "none", zIndex: 1 }}>
{data[hover].label}: <b>{format(data[hover].value)}</b>
</div>
</>
)}
</div>
<div style={{ position: "relative", height: 14, marginTop: 6 }}>
{ticks.map((i) => (
<span key={i} style={{ position: "absolute", left: `${x(i)}%`, transform: `translateX(${shift(x(i))})`, fontSize: 11, lineHeight: "14px", whiteSpace: "nowrap", color: token.colorTextSecondary }}>
{data[i].label}
</span>
))}
</div>
</div>
Binary file not shown.
+18 -2
View File
@@ -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: <ManagerStats />,
label: "Order Management",
shortLabel: "Management",
description: "How your orders perform and who takes part.",
icon: <UserOutlined />,
ownersOnly: true,
@@ -45,6 +49,7 @@ const SECTIONS: SectionDef[] = [
key: "admin",
content: <SiteStats />,
label: "Site Overview",
shortLabel: "Site",
description: "Users, activity and storage across the whole site.",
icon: <CrownOutlined />,
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<RangeState>({ timeframe: "3m", custom: null });
@@ -81,7 +87,17 @@ export default function StatisticsView({
return (
<div style={{ display: "flex", flexWrap: "wrap", gap: GAP, alignItems: "flex-start" }}>
{visible.length > 1 && (
{visible.length > 1 && narrow && (
// Phones: a full-width tab strip instead of a stacked side menu.
<Segmented
block
style={{ flex: "1 1 100%" }}
value={`/stats/${current.key}`}
options={visible.map(({ key, shortLabel, label }) => ({ value: `/stats/${key}`, label: shortLabel ?? label }))}
onChange={(key) => navigateTo(key as string)}
/>
)}
{visible.length > 1 && !narrow && (
<Menu
mode="inline"
selectedKeys={[`/stats/${current.key}`]}