Redesign statistics pages with global timeframe, insights and privacy scoping
Build and Push Lunchtime Images (Kaniko) / build-and-push (push) Successful in 1m55s

- Shared ChartCard/StatRow/CardGrid primitives for a consistent layout; fix
  clipped axis labels, green 100% bars and uneven tiles
- One page-wide timeframe filter; period-over-period deltas on stat tiles;
  per-period activity bars replace cumulative charts
- My Orders: lunch profile (personality, records, adventurousness), calendar
  and weekday x hour heatmaps, anonymous community comparison. /api/stats/me
  no longer returns other users' emails (organizers are numbered per response)
- Order Management: order insights, participant loyalty, median fill time,
  regulars and drop-offs, submission heatmap, top item per weekday
- Site Overview: adoption funnel, weekly retention, order sizes, order value,
  submission heatmap; active users now based on real contributions

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
simandClaude Sonnet 5.5 committed 2026-11-05 22:21:43 +00:00
1 parent b7eb2bb6d0
commit 1df83afab1
16 files changed
+818 -302

No files matched your search

+8 -2
View File
@@ -15,9 +15,15 @@ Statistics pages live under `/stats/*` (`src/frontend/src/views/StatisticsView.t
| `GET /api/stats/managed` | any user (own orders only) | Order Management |
| `GET /api/admin/stats` | site admin | Site Overview |
## Time series
## Timeframe and insights
Every page has an "Over time" panel (`TimeSeriesPanel.tsx`): cumulative charts with a timeframe filter (1W, 1M, 3M, 1Y, YTD, All, custom range). Running totals start at 0 at the start of the timeframe. Site-wide daily counts come from `daily` in `/api/admin/stats`.
- 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`.
- 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.
- Not possible without new tracking: fill rate per order (no invitee list), display names (only emails exist; the local part is shown).
## My Orders (orderees)
+95 -24
View File
@@ -65,6 +65,30 @@ def _submission_fact(row: sqlite3.Row, configs: _ConfigCache) -> dict[str, Any]:
}
def _community(conn: sqlite3.Connection, user_id: str) -> dict[str, Any]:
"""How the user's order count compares to all users who ever submitted (anonymous aggregate)."""
counts = {
row["user_id"]: row["n"]
for row in conn.execute(
"SELECT user_id, COUNT(*) AS n FROM user_order_tokens WHERE submission_token IS NOT NULL GROUP BY user_id"
)
}
mine = counts.get(user_id, 0)
others = [n for uid, n in counts.items() if uid != user_id]
# Anonymous histogram of orders per participant; the frontend marks the bucket the user is in.
bins = [("1", 1, 1), ("2–3", 2, 3), ("4–6", 4, 6), ("7–12", 7, 12), ("13+", 13, 10**9)]
return {
"distribution": [
{"label": label, "count": sum(1 for n in counts.values() if low <= n <= high), "mine": low <= mine <= high}
for label, low, high in bins
],
"participants": len(counts),
"average_orders": round(sum(counts.values()) / len(counts), 1) if counts else 0,
# Share of the other participants with fewer orders than the user (0-100).
"beats_percent": round(100 * sum(1 for n in others if n < mine) / len(others)) if others else 0,
}
def get_my_stats(conn: sqlite3.Connection, user_id: str) -> dict[str, Any]:
"""One fact row per order the user submitted to."""
configs = _ConfigCache()
@@ -81,17 +105,20 @@ def get_my_stats(conn: sqlite3.Connection, user_id: str) -> dict[str, Any]:
""",
(user_id,),
).fetchall()
# Other users never appear in a user's own statistics: organizers are numbered per response, not named.
organizers: dict[str, str] = {}
return {
"community": _community(conn, user_id),
"submissions": [
{
**_submission_fact(row, configs),
"order_title": row["title"],
"order_created_at": row["order_created_at"],
"closed": bool(row["closed"]),
"owner_email": row["owner_email"],
"organizer": organizers.setdefault(row["owner_email"] or row["order_id"], f"organizer-{len(organizers) + 1}"),
}
for row in rows
]
],
}
@@ -189,15 +216,19 @@ def get_site_stats(conn: sqlite3.Connection) -> dict[str, Any]:
"SELECT email, email_confirmed, pending_email, pending_user_id, created_at, updated_at FROM user_profiles"
).fetchall()
registrations = Counter(w for p in profiles if (w := _week_start(p["created_at"] or p["updated_at"])))
last_activity = {
row["user_id"]: row["last"]
for row in conn.execute(
"""
SELECT up.user_id, MAX(up.updated_at, COALESCE((SELECT MAX(o.updated_at) FROM user_order_tokens o
WHERE o.user_id = up.user_id), '')) AS last
FROM user_profiles up
"""
)
# Real activity: users who created an order or submission within the window.
contributor_events = conn.execute(
"""
SELECT uot.user_id, go.created_at AS ts FROM user_order_tokens uot
JOIN group_orders go ON go.id = uot.group_order_id WHERE uot.admin_token IS NOT NULL
UNION ALL
SELECT uot.user_id, s.created_at FROM user_order_tokens uot
JOIN submissions s ON s.submission_token = uot.submission_token
"""
).fetchall()
active = {
days: len({e["user_id"] for e in contributor_events if (e["ts"] or "") >= cutoff})
for days, cutoff in cutoffs.items()
}
users = {
"total": len(profiles),
@@ -209,9 +240,8 @@ def get_site_stats(conn: sqlite3.Connection) -> dict[str, Any]:
"SELECT COUNT(*) FROM account_confirmation_tokens WHERE action = 'register_confirm' AND consumed_at IS NULL AND expires_at > ?",
(now,),
).fetchone()[0],
# Based on the last known activity only (no login log), so this undercounts returning users.
"active_7d": sum(1 for last in last_activity.values() if last >= cutoffs[7]),
"active_30d": sum(1 for last in last_activity.values() if last >= cutoffs[30]),
"active_7d": active[7],
"active_30d": active[30],
}
orders = conn.execute(
@@ -220,20 +250,32 @@ def get_site_stats(conn: sqlite3.Connection) -> dict[str, Any]:
orders_per_week = Counter(w for o in orders if (w := _week_start(o["created_at"])))
# Contributors: users who created an order or a submission, per week (distinct users).
contributor_events = conn.execute(
"""
SELECT uot.user_id, go.created_at AS ts FROM user_order_tokens uot
JOIN group_orders go ON go.id = uot.group_order_id WHERE uot.admin_token IS NOT NULL
UNION ALL
SELECT uot.user_id, s.created_at FROM user_order_tokens uot
JOIN submissions s ON s.submission_token = uot.submission_token
"""
).fetchall()
contributors: dict[str, set[str]] = {}
for event in contributor_events:
if week := _week_start(event["ts"]):
contributors.setdefault(week, set()).add(event["user_id"])
# Retention: of last week's contributors, the share who contributed again this week.
ordered_weeks = [w["week"] for w in _weekly(Counter())]
retention = []
for previous, week in zip(ordered_weeks, ordered_weeks[1:]):
before = contributors.get(previous, set())
retention.append({"week": week, "count": round(100 * len(before & contributors.get(week, set())) / len(before)) if before else 0})
# Funnel: confirmed accounts -> submitted at least 1 / 2 / 5 orders.
per_user = [
row["n"]
for row in conn.execute(
"SELECT COUNT(*) AS n FROM user_order_tokens WHERE submission_token IS NOT NULL GROUP BY user_id"
)
]
funnel = [
{"label": "Confirmed accounts", "count": users["confirmed"]},
{"label": "Submitted an order", "count": sum(1 for n in per_user if n >= 1)},
{"label": "Submitted 2+ orders", "count": sum(1 for n in per_user if n >= 2)},
{"label": "Submitted 5+ orders", "count": sum(1 for n in per_user if n >= 5)},
]
top_managers = conn.execute(
"""
SELECT up.email, COUNT(*) AS count FROM user_order_tokens uot
@@ -253,6 +295,8 @@ def get_site_stats(conn: sqlite3.Connection) -> dict[str, Any]:
configs = _ConfigCache()
item_counts: Counter[tuple[str, str]] = Counter()
total_value = 0.0
priced_submissions = 0
for row in conn.execute(
"SELECT go.admin_token AS order_admin_token, s.choices_json FROM submissions s JOIN group_orders go ON go.id = s.group_order_id"
):
@@ -260,7 +304,18 @@ def get_site_stats(conn: sqlite3.Connection) -> dict[str, Any]:
choices = json.loads(row["choices_json"] or "{}")
except ValueError:
continue
item_counts.update((i["category"], i["name"]) for i in _selected_items(choices, configs.get(row["order_admin_token"])))
config = configs.get(row["order_admin_token"])
item_counts.update((i["category"], i["name"]) for i in _selected_items(choices, config))
total = calculate_estimated_submission_total(choices, config)
if total is not None:
total_value += total
priced_submissions += 1
# Submissions per order, bucketed.
size_bins = [("No submissions", 0, 0), ("1", 1, 1), ("2–3", 2, 3), ("4–6", 4, 6), ("7–10", 7, 10), ("11+", 11, 10**9)]
order_sizes = [
{"label": label, "count": sum(1 for o in orders if low <= o["submissions"] <= high)} for label, low, high in size_bins
]
tokens = {row[0] for row in conn.execute("SELECT admin_token FROM group_orders")}
storage = {
@@ -276,7 +331,21 @@ def get_site_stats(conn: sqlite3.Connection) -> dict[str, Any]:
}
submission_times = [row[0] for row in conn.execute("SELECT created_at FROM submissions")]
# Weekday (Monday first) x hour of day, in UTC.
heatmap = [[0] * 24 for _ in range(7)]
for ts in submission_times:
try:
parsed = datetime.fromisoformat(ts)
except (TypeError, ValueError):
continue
parsed = parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc)
parsed = parsed.astimezone(timezone.utc)
heatmap[parsed.weekday()][parsed.hour] += 1
return {
"submissions_heatmap_utc": heatmap,
"order_sizes": order_sizes,
"value": {"total": round(total_value, 2), "priced_submissions": priced_submissions},
"submissions_total": len(submission_times),
"daily": {
"registrations": _daily([p["created_at"] or p["updated_at"] for p in profiles]),
"orders": _daily([o["created_at"] for o in orders]),
@@ -285,6 +354,8 @@ def get_site_stats(conn: sqlite3.Connection) -> dict[str, Any]:
"users": users,
"registrations_per_week": _weekly(registrations),
"contributors_per_week": _weekly(Counter({w: len(u) for w, u in contributors.items()})),
"funnel": funnel,
"retention_per_week": retention,
"orders": {
"total": len(orders),
"with_submissions": sum(1 for o in orders if o["submissions"]),
@@ -0,0 +1,83 @@
import React from "react";
import { Tooltip, theme } from "antd";
import { addDays, countsByDay, dayKey, startOfDay, WEEKDAY_NAMES } from "../../lib/stats";
/** Cell colour: the theme's primary at an opacity proportional to the value. */
function useShade() {
const { token } = theme.useToken();
return (value: number, max: number) =>
value === 0 ? token.colorFillTertiary : `color-mix(in srgb, ${token.colorPrimary} ${Math.round(25 + (value / max) * 75)}%, transparent)`;
}
/** GitHub-style grid of the last year: one cell per day, columns are weeks (Monday first). */
export function CalendarHeatmap({ timestamps, weeks = 53 }: { timestamps: string[]; weeks?: number }) {
const { token } = theme.useToken();
const shade = useShade();
const counts = countsByDay(timestamps);
const today = startOfDay(new Date());
const first = addDays(today, -((today.getDay() + 6) % 7) - (weeks - 1) * 7);
const days = Array.from({ length: weeks * 7 }, (_, i) => addDays(first, i));
const max = Math.max(1, ...counts.values());
// Label the first week column of each month.
const monthLabels = new Map<number, string>();
for (let w = 0; w < weeks; w++) {
const d = days[w * 7];
if (w < weeks - 2 && (w === 0 || d.getMonth() !== days[(w - 1) * 7].getMonth())) monthLabels.set(w, d.toLocaleDateString(undefined, { month: "short" }));
}
return (
<div 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 />
{["Mon", "", "Wed", "", "Fri", "", ""].map((d, i) => (
<span key={i} style={{ lineHeight: 1, display: "flex", alignItems: "center" }}>{d}</span>
))}
</div>
<div style={{ flex: 1, display: "grid", gridTemplateColumns: `repeat(${weeks}, minmax(0, 1fr))`, gridTemplateRows: "16px repeat(7, auto)", gridAutoFlow: "column", gap: 3 }}>
{Array.from({ length: weeks }, (_, w) => (
<React.Fragment key={w}>
<span style={{ fontSize: 11, lineHeight: "16px", color: token.colorTextSecondary, whiteSpace: "nowrap", overflow: "visible" }}>{monthLabels.get(w) ?? ""}</span>
{days.slice(w * 7, w * 7 + 7).map((d) => {
const n = counts.get(dayKey(d)) ?? 0;
const future = d > today;
return (
<Tooltip key={d.getTime()} title={future ? undefined : `${d.toLocaleDateString()}: ${n} ${n === 1 ? "order" : "orders"}`}>
<div style={{ aspectRatio: "1", borderRadius: 3, background: future ? "transparent" : shade(n, max) }} />
</Tooltip>
);
})}
</React.Fragment>
))}
</div>
</div>
</div>
);
}
/** Weekday (rows) x hour of day (columns). */
export function WeekdayHourHeatmap({ grid }: { grid: number[][] }) {
const { token } = theme.useToken();
const shade = useShade();
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 }}>
<span />
{Array.from({ length: 24 }, (_, h) => (
<span key={h} style={{ textAlign: "center", lineHeight: "14px" }}>{h % 3 === 0 ? h : ""}</span>
))}
{grid.map((row, d) => (
<React.Fragment key={d}>
<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) }} />
</Tooltip>
))}
</React.Fragment>
))}
</div>
</div>
);
}
@@ -0,0 +1,32 @@
import React from "react";
import { Typography, theme } from "antd";
export type Fact = { label: string; value: React.ReactNode; note?: React.ReactNode };
/** Small grid of "fun fact" tiles: a label, a short value and an optional note. */
export function FactGrid({ facts }: { facts: Fact[] }) {
const { token } = theme.useToken();
return (
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(170px, 1fr))", gap: 12 }}>
{facts.map((f) => (
<div key={f.label} style={{ background: token.colorFillQuaternary, borderRadius: token.borderRadius, padding: "10px 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>
{f.note && (
<Typography.Text type="secondary" style={{ fontSize: 12, display: "block" }}>
{f.note}
</Typography.Text>
)}
</div>
))}
</div>
);
}
/** 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>;
}
@@ -1,7 +1,8 @@
import React, { useMemo, useState } from "react";
import { Card, Select } from "antd";
import { Select } from "antd";
import type { ItemCount } from "../../lib/types";
import { BarList } from "./charts";
import { ChartCard } from "./StatCard";
const ALL = "\u0000all";
const BY_CATEGORY = "\u0000categories";
@@ -27,8 +28,7 @@ export default function ItemRanking({ title, items, limit = 8 }: { title: string
}, [view, items, categories]);
return (
<Card
size="small"
<ChartCard
title={title}
extra={
<Select
@@ -45,6 +45,6 @@ export default function ItemRanking({ title, items, limit = 8 }: { title: string
}
>
<BarList rows={rows.slice(0, limit)} />
</Card>
</ChartCard>
);
}
@@ -1,15 +1,32 @@
import React from "react";
import { Card, Space, Table } from "antd";
import { Table, Typography } from "antd";
import { apiService } from "../../lib/services";
import { navigateTo } from "../../lib/routing";
import { useApiRequest } from "../../hooks/useApiRequest";
import { average, Bucket, countItems, formatMoney, monthlyBuckets, sum } from "../../lib/stats";
import {
average,
Bucket,
countItems,
formatDuration,
formatMoney,
inRange,
median,
shortName,
sum,
sumIn,
WEEKDAY_NAMES,
weekdayHourGrid,
weekdayOf,
} from "../../lib/stats";
import type { ManagedStatsOrder } from "../../lib/types";
import AsyncContent from "../utils/AsyncContent";
import ItemRanking from "./ItemRanking";
import TimeSeriesPanel from "./TimeSeriesPanel";
import { WeekdayHourHeatmap } from "./Heatmaps";
import { FactGrid } from "./Highlights";
import { useRange } from "./range";
import { BarList, ColumnChart } from "./charts";
import { CardGrid, StatRow } from "./StatCard";
import { CardGrid, ChartCard, PageStack, StatRow } from "./StatCard";
const load = () => apiService.stats.getManaged();
@@ -48,11 +65,21 @@ function newVsRepeat(orders: ManagedStatsOrder[]): Bucket[] {
export default function ManagerStats() {
const { data, loading, error, run } = useApiRequest(load);
const orders = data?.orders ?? [];
const allOrders = data?.orders ?? [];
const { range, previous } = useRange(allOrders.length ? new Date(allOrders[0].created_at) : null);
// Everything follows the selected timeframe: orders by creation date, submissions by submission date.
const orders = allOrders.filter((o) => inRange(new Date(o.created_at), range));
const allSubmissions = allOrders.flatMap((o) => o.submissions);
const submissions = allSubmissions.filter((s) => inRange(new Date(s.submitted_at), range));
const orderEvents = allOrders.map((o) => ({ date: new Date(o.created_at), value: 1 }));
const submissionEvents = allSubmissions.map((s) => ({ date: new Date(s.submitted_at), value: 1 }));
const valueEvents = allSubmissions.map((s) => ({ date: new Date(s.submitted_at), value: s.total ?? 0 }));
const recent = orders.slice(-15);
const labelEvery = Math.max(1, Math.ceil(recent.length / 5));
const nvr = newVsRepeat(allOrders).filter((_, i) => inRange(new Date(allOrders[i].created_at), range));
const submissions = orders.flatMap((o) => o.submissions);
const items = submissions.flatMap((s) => s.items);
const perOrder = orders.map((order) => ({
order,
@@ -65,51 +92,93 @@ export default function ManagerStats() {
const unpaid = submissions.filter((s) => s.accepted && !s.paid);
const pending = submissions.filter((s) => !s.accepted && !s.paid);
const money = (rows: typeof submissions) => formatMoney(sum(rows.map((s) => s.total ?? 0)));
const fillHours = orders.flatMap((o) => o.submissions.map((s) => (new Date(s.submitted_at).getTime() - new Date(o.created_at).getTime()) / HOUR));
// Regulars: who joined the most of your orders; drifted: joined 2+ orders but none of the latest 3.
const joinedBy = new Map<string, number>();
orders.forEach((o) => new Set(o.submissions.map((s) => s.email)).forEach((e) => joinedBy.set(e, (joinedBy.get(e) ?? 0) + 1)));
const regulars = [...joinedBy.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).slice(0, 6);
const lastOrders = allOrders.slice(-3);
const recentPeople = new Set(lastOrders.flatMap((o) => o.submissions.map((s) => s.email)));
const allJoined = new Map<string, number>();
allOrders.forEach((o) => new Set(o.submissions.map((s) => s.email)).forEach((e) => allJoined.set(e, (allJoined.get(e) ?? 0) + 1)));
const drifted = allOrders.length >= 4 ? [...allJoined.entries()].filter(([e, n]) => n >= 2 && !recentPeople.has(e)).sort((a, b) => b[1] - a[1]).slice(0, 5) : [];
// Loyalty: how many of your orders each person joined, and a few headline facts about your orders.
const loyalty = [
{ label: "1 order", test: (n: number) => n === 1 },
{ label: "2–3 orders", test: (n: number) => n >= 2 && n <= 3 },
{ label: "4–6 orders", test: (n: number) => n >= 4 && n <= 6 },
{ label: "7+ orders", test: (n: number) => n >= 7 },
].map(({ label, test }) => ({ label, value: [...joinedBy.values()].filter(test).length }));
const repeatShare = joinedBy.size ? Math.round((100 * [...joinedBy.values()].filter((n) => n >= 2).length) / joinedBy.size) : 0;
const biggest = [...perOrder].sort((a, b) => b.count - a.count)[0];
const pricedSubs = submissions.filter((s) => s.total !== null);
const avgBasket = pricedSubs.length ? sum(pricedSubs.map((s) => s.total as number)) / pricedSubs.length : null;
const avgItems = submissions.length ? sum(submissions.map((s) => s.items.length)) / submissions.length : 0;
const slowest = fillHours.length ? Math.max(...fillHours) : null;
// Most popular item for each weekday on which people submitted.
const byWeekday = WEEKDAY_NAMES.map((day, d) => {
const subs = submissions.filter((s) => weekdayOf(s.submitted_at) === d);
const top = [...countItems(subs.flatMap((s) => s.items))].sort((a, b) => b.count - a.count || a.name.localeCompare(b.name))[0];
return { day, submissions: subs.length, top };
});
return (
<AsyncContent loading={loading} error={error} onRetry={run} isEmpty={!orders.length}>
<Space direction="vertical" size={12} style={{ width: "100%" }}>
<AsyncContent loading={loading} error={error} onRetry={run} isEmpty={!allOrders.length}>
<PageStack>
<StatRow
stats={[
{
title: "Orders created",
value: orders.length,
hint: `${orders.filter((o) => !o.closed).length} open · ${orders.filter((o) => o.closed).length} closed`,
delta: { current: orders.length, previous: previous ? sumIn(orderEvents, previous) : null },
},
{ title: "Submissions", value: submissions.length, delta: { current: submissions.length, previous: previous ? sumIn(submissionEvents, previous) : null } },
{ title: "Avg. participants", value: average(perOrder.map((p) => p.count)).toFixed(1), hint: `${new Set(submissions.map((s) => s.email)).size} unique people` },
{ title: "Typical fill time", value: fillHours.length ? formatDuration(median(fillHours)) : "–", hint: "median to submit" },
{
title: "Total value",
value: money(submissions),
hint: "priced menus only",
delta: { current: sum(submissions.map((s) => s.total ?? 0)), previous: previous ? sumIn(valueEvents, previous) : null },
},
{ title: "Submissions", value: submissions.length },
{ title: "Avg. participants", value: average(perOrder.map((p) => p.count)).toFixed(1) },
{ title: "Unique participants", value: new Set(submissions.map((s) => s.email)).size },
{ title: "Total value", value: money(submissions), hint: "priced menus only" },
]}
/>
<TimeSeriesPanel
metrics={[
{ title: "Orders created (cumulative)", events: orders.map((o) => ({ date: new Date(o.created_at), value: 1 })) },
{ title: "Submissions received (cumulative)", events: submissions.map((s) => ({ date: new Date(s.submitted_at), value: 1 })) },
{
title: "Value of submissions (cumulative)",
events: submissions.map((s) => ({ date: new Date(s.submitted_at), value: s.total ?? 0 })),
format: formatMoney,
},
{ title: "Orders created", events: orderEvents },
{ title: "Submissions received", events: submissionEvents },
{ title: "Value of submissions", events: valueEvents, format: formatMoney },
]}
/>
<ChartCard title="Order insights">
<FactGrid
facts={[
biggest && { label: "Biggest order", value: `${biggest.count} participants`, note: biggest.order.title },
{ label: "Repeat participants", value: `${repeatShare}%`, note: "joined 2 or more of your orders" },
avgBasket !== null && { label: "Average basket", value: formatMoney(avgBasket), note: "per submission" },
{ label: "Items per submission", value: avgItems.toFixed(1) },
slowest !== null && { label: "Longest wait for a submission", value: formatDuration(slowest), note: "after order creation" },
].filter((f): f is NonNullable<typeof f> => Boolean(f))}
/>
</ChartCard>
<CardGrid>
<Card size="small" title="Orders created per month">
<ColumnChart data={monthlyBuckets(orders.map((o) => o.created_at))} labelEvery={2} />
</Card>
<Card size="small" title="Participants per order (last 15)">
<ChartCard title="Participants per order (last 15)">
<ColumnChart data={recent.map((o) => ({ label: o.title, value: o.submissions.length }))} labelEvery={labelEvery} />
</Card>
<Card size="small" title="New vs. repeat participants (last 15)" extra="light = repeat">
<ColumnChart data={newVsRepeat(orders).slice(-15)} labelEvery={labelEvery} />
</Card>
<Card size="small" title="Time to submit after order creation">
</ChartCard>
<ChartCard title="New vs. repeat participants (last 15)" extra="light = repeat">
<ColumnChart data={nvr.slice(-15)} labelEvery={labelEvery} />
</ChartCard>
<ChartCard title="Time to submit after order creation">
<ColumnChart data={responseTimeBuckets(orders)} />
</Card>
<ItemRanking title="Popular items" items={countItems(items)} limit={10} />
<Card size="small" title="Payment status (submissions)">
</ChartCard>
<ChartCard title="Payment status (submissions)">
<BarList
rows={[
{ label: `Paid (${money(paid)})`, value: paid.length },
@@ -117,10 +186,42 @@ export default function ManagerStats() {
{ label: `Pending (${money(pending)})`, value: pending.length },
]}
/>
</Card>
</ChartCard>
<ItemRanking title="Popular items" items={countItems(items)} limit={10} />
<ChartCard title="Participant loyalty" extra="people by orders joined">
<BarList rows={loyalty} format={(n) => `${n} ${n === 1 ? "person" : "people"}`} />
</ChartCard>
<ChartCard title="Your regulars" extra="orders joined">
<BarList rows={regulars.map(([email, n]) => ({ label: shortName(email), value: n }))} />
{drifted.length > 0 && (
<Typography.Paragraph type="secondary" style={{ margin: "16px 0 0", fontSize: 13 }}>
Haven't joined your last 3 orders: {drifted.map(([email, n]) => `${shortName(email)} (${n}×)`).join(", ")}
</Typography.Paragraph>
)}
</ChartCard>
</CardGrid>
<Card size="small" title="Volume per order">
<ChartCard title="When people submit" extra="weekday and hour">
<WeekdayHourHeatmap grid={weekdayHourGrid(submissions.map((s) => s.submitted_at))} />
</ChartCard>
<ChartCard title="Most popular item by weekday" extra="by submission day">
<Table
size="small"
rowKey="day"
pagination={false}
dataSource={byWeekday.filter((r) => r.submissions > 0)}
locale={{ emptyText: "No submissions in this timeframe" }}
columns={[
{ title: "Day", dataIndex: "day" },
{ title: "Top item", key: "top", render: (_, r) => (r.top ? `${r.top.name} (${r.top.category})` : "–") },
{ title: "Picked", key: "n", align: "right", render: (_, r) => (r.top ? `${r.top.count}×` : "–") },
{ title: "Submissions", dataIndex: "submissions", align: "right" },
]}
/>
</ChartCard>
<ChartCard title="Volume per order">
<Table
size="small"
rowKey={(r) => r.order.id}
@@ -140,8 +241,8 @@ export default function ManagerStats() {
{ title: "Value", key: "value", align: "right", render: (_, r) => (r.value ? formatMoney(r.value) : "–") },
]}
/>
</Card>
</Space>
</ChartCard>
</PageStack>
</AsyncContent>
);
}
@@ -1,15 +1,33 @@
import React from "react";
import { Card, Space, Table, Tag, Typography } from "antd";
import { Table, Tag, Typography } from "antd";
import { apiService } from "../../lib/services";
import { navigateTo } from "../../lib/routing";
import { useApiRequest } from "../../hooks/useApiRequest";
import { average, countBy, countItems, currentWeekStreak, formatMoney, hourBuckets, monthlyBuckets, sum, topBuckets, weekdayBuckets } from "../../lib/stats";
import {
adventurousness,
average,
busiestMonth,
countBy,
countItems,
currentWeekStreak,
formatMoney,
inRange,
longestWeekStreak,
lunchPersonality,
nextMilestone,
sum,
sumIn,
weekdayHourGrid,
} from "../../lib/stats";
import type { MyStatsSubmission } from "../../lib/types";
import AsyncContent from "../utils/AsyncContent";
import { CalendarHeatmap, WeekdayHourHeatmap } from "./Heatmaps";
import { FactGrid, Headline } from "./Highlights";
import ItemRanking from "./ItemRanking";
import { useRange } from "./range";
import TimeSeriesPanel from "./TimeSeriesPanel";
import { BarList, ColumnChart } from "./charts";
import { CardGrid, StatRow } from "./StatCard";
import { BarList } from "./charts";
import { CardGrid, ChartCard, PageStack, StatRow } from "./StatCard";
const load = () => apiService.stats.getMine();
@@ -28,67 +46,130 @@ const paymentColumns = [
},
];
const ordinal = (n: number) => `${n}${["th", "st", "nd", "rd"][n % 100 > 10 && n % 100 < 14 ? 0 : Math.min(n % 10, 4) % 4]}`;
export default function MyOrdersStats() {
const { data, loading, error, run } = useApiRequest(load);
const submissions = data?.submissions ?? [];
const all = data?.submissions ?? [];
const community = data?.community;
const { range, previous } = 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.
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) }));
const joined = events(all, () => 1);
const spent = events(all, (s) => s.total ?? 0);
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 items = submissions.flatMap((s) => s.items);
const open = submissions.filter((s) => s.accepted && !s.paid);
const paid = submissions.filter((s) => s.paid);
const prevPriced = previous ? all.filter((s) => s.total !== null && inRange(new Date(s.submitted_at), previous)) : [];
const open = all.filter((s) => s.accepted && !s.paid);
const paid = all.filter((s) => s.paid);
const personality = lunchPersonality(allTimes, all.flatMap((s) => s.items));
const busiest = busiestMonth(allTimes);
const priciest = [...all].filter((s) => s.total !== null).sort((a, b) => (b.total as number) - (a.total as number))[0];
const adventure = adventurousness(all);
const milestone = nextMilestone(all.length);
// Organizers are anonymous: only counts and shares are shown.
const organizerCounts = [...countBy(all, (s) => s.organizer).values()].sort((a, b) => b - a);
const organizerFact = organizerCounts.length > 1 && {
label: "Organizers",
value: `${organizerCounts.length} different`,
note: `your most frequent one: ${Math.round((organizerCounts[0] / all.length) * 100)}% of your orders`,
};
return (
<AsyncContent
loading={loading}
error={error}
onRetry={run}
isEmpty={!submissions.length}
isEmpty={!all.length}
emptyState={<Typography.Text type="secondary">Statistics appear once you have submitted to an order.</Typography.Text>}
>
<Space direction="vertical" size={12} style={{ width: "100%" }}>
<PageStack>
<StatRow
stats={[
{ title: "Orders joined", value: submissions.length },
{ title: "Week streak", value: currentWeekStreak(times), hint: "consecutive weeks" },
{ title: "Total spend", value: formatMoney(sum(priced)), hint: priced.length < submissions.length ? "priced menus only" : undefined },
{ title: "Average / order", value: formatMoney(average(priced)) },
{ 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 lunch profile" extra="all time">
{personality ? <Headline>{personality}</Headline> : <Headline>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" },
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 && {
label: "Adventurousness",
value: `${adventure.percent}%`,
note: `tried something new in ${adventure.tried} of ${adventure.of} orders`,
},
community && community.participants >= 3 && {
label: "You vs. everyone",
value: community.beats_percent >= 90 ? `Top ${Math.max(1, 100 - community.beats_percent)}%` : `More than ${community.beats_percent}%`,
note: "of participants by orders joined",
},
].filter((f): f is NonNullable<typeof f> => Boolean(f))}
/>
</ChartCard>
<TimeSeriesPanel
metrics={[
{ title: "Orders joined (cumulative)", events: submissions.map((s) => ({ date: new Date(s.submitted_at), value: 1 })) },
{
title: "Money spent (cumulative)",
events: submissions.map((s) => ({ date: new Date(s.submitted_at), value: s.total ?? 0 })),
format: formatMoney,
},
{ title: "Orders joined", events: joined },
{ title: "Money spent", events: spent, format: formatMoney },
]}
/>
<ChartCard title="Your year in lunches" extra="last 12 months">
<CalendarHeatmap timestamps={allTimes} />
</ChartCard>
<CardGrid>
<Card size="small" title="Orders per month">
<ColumnChart data={monthlyBuckets(times)} labelEvery={2} />
</Card>
<ItemRanking title="Favourite items" items={countItems(items)} />
<Card size="small" title="Usual organizers">
<BarList rows={topBuckets(countBy(submissions, (s) => s.owner_email), 6)} />
</Card>
<Card size="small" title="Order day">
<ColumnChart data={weekdayBuckets(times)} />
</Card>
<Card size="small" title="Order hour">
<ColumnChart data={hourBuckets(times)} labelEvery={3} />
</Card>
<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"}`}
/>
<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>
<Card size="small" title="Open payments">
<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" }} />
</Card>
<Card
size="small"
</ChartCard>
<ChartCard
title={
<>
Payment history <Tag>{paid.length}</Tag>
@@ -103,8 +184,8 @@ export default function MyOrdersStats() {
columns={paymentColumns}
locale={{ emptyText: "No paid orders yet" }}
/>
</Card>
</Space>
</ChartCard>
</PageStack>
</AsyncContent>
);
}
@@ -1,20 +1,32 @@
import React from "react";
import { Card, Space, Table } from "antd";
import { Table } from "antd";
import { apiService } from "../../lib/services";
import { useApiRequest } from "../../hooks/useApiRequest";
import { parseDay } from "../../lib/stats";
import { addDays, formatMoney, inRange, parseDay, shortName, sumIn } from "../../lib/stats";
import type { DailyCount, WeeklyCount } from "../../lib/types";
import AsyncContent from "../utils/AsyncContent";
import ItemRanking from "./ItemRanking";
import TimeSeriesPanel from "./TimeSeriesPanel";
import { WeekdayHourHeatmap } from "./Heatmaps";
import { useRange } from "./range";
import { BarList, ColumnChart } from "./charts";
import { CardGrid, StatRow } from "./StatCard";
import { CardGrid, ChartCard, PageStack, StatRow } from "./StatCard";
const load = () => apiService.stats.getSite();
const toEvent = (d: DailyCount) => ({ date: parseDay(d.date), value: d.count });
const weekly = (rows: WeeklyCount[]) => rows.map((r) => ({ label: `Week of ${r.week}`, value: r.count }));
/** Weekly buckets overlapping the range (at least the last 8 weeks, so short timeframes still show a trend). */
const weekly = (rows: WeeklyCount[], range: [Date, Date], format?: (n: number) => string) => {
const shown = rows.filter((r) => inRange(addDays(parseDay(r.week), 6), range) || inRange(parseDay(r.week), range));
return (shown.length >= 8 ? shown : rows.slice(-8)).map((r) => ({
label: parseDay(r.week).toLocaleDateString(undefined, { day: "numeric", month: "short" }),
value: r.count,
}));
};
/** Roughly six x labels regardless of how many weeks there are. */
const every = (rows: unknown[]) => Math.max(1, Math.ceil(rows.length / 6));
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
@@ -32,17 +44,26 @@ export default function SiteStats() {
const { data: stats, loading, error, run } = useApiRequest(load);
const storage = Object.entries(stats?.storage ?? {}).map(([folder, usage]) => ({ folder, ...usage }));
const share = stats?.orders.total ? Math.round((stats.orders.with_submissions / stats.orders.total) * 100) : 0;
const registrations = (stats?.daily.registrations ?? []).map(toEvent);
const orders = (stats?.daily.orders ?? []).map(toEvent);
const submissions = (stats?.daily.submissions ?? []).map(toEvent);
const earliest = [...registrations, ...orders, ...submissions].map((e) => e.date.getTime());
const { range, previous } = useRange(earliest.length ? new Date(Math.min(...earliest)) : null);
const delta = (events: typeof orders) => ({ current: sumIn(events, range), previous: previous ? sumIn(events, previous) : null });
const funnelTop = stats?.funnel[0]?.count || 1;
const contributors = stats ? weekly(stats.contributors_per_week, range) : [];
const retention = stats ? weekly(stats.retention_per_week, range) : [];
return (
<AsyncContent loading={loading} error={error} onRetry={run} isEmpty={!stats}>
{stats && (
<Space direction="vertical" size={12} style={{ width: "100%" }}>
<PageStack>
<StatRow
stats={[
{ title: "Users", value: stats.users.total, hint: `${stats.users.confirmed} confirmed` },
{ title: "Active (7 days)", value: stats.users.active_7d, hint: "by last known activity" },
{ title: "Active (30 days)", value: stats.users.active_30d, hint: "by last known activity" },
{ title: "Orders", value: stats.orders.total, hint: `${share}% with submissions` },
{ title: "Users", value: stats.users.total, hint: `${stats.users.confirmed} confirmed`, delta: delta(registrations) },
{ title: "Active (7 days)", value: stats.users.active_7d, hint: "orders or submissions" },
{ title: "Active (30 days)", value: stats.users.active_30d, hint: "orders or submissions" },
{ title: "Orders", value: stats.orders.total, hint: `${share}% with submissions`, delta: delta(orders) },
]}
/>
<StatRow
@@ -50,38 +71,58 @@ export default function SiteStats() {
{ title: "Unconfirmed accounts", value: stats.users.unconfirmed },
{ title: "Open registration links", value: stats.users.open_registration_links },
{ title: "Pending email changes", value: stats.users.pending_email_change },
{ title: "Pending user-ID changes", value: stats.users.pending_user_id_change },
{ title: "Pending ID changes", value: stats.users.pending_user_id_change },
]}
/>
<StatRow
stats={[
{ title: "Participants / order", value: stats.orders.total ? (stats.submissions_total / stats.orders.total).toFixed(1) : "0.0" },
{ title: "Empty orders", value: stats.orders.total - stats.orders.with_submissions, hint: "no submissions" },
{ title: "Estimated order value", value: formatMoney(stats.value.total), hint: "priced menus only" },
{
title: "Avg. basket",
value: stats.value.priced_submissions ? formatMoney(stats.value.total / stats.value.priced_submissions) : "–",
hint: "per submission",
},
]}
/>
<TimeSeriesPanel
metrics={[
{ title: "Registrations (cumulative)", events: stats.daily.registrations.map(toEvent) },
{ title: "Orders created (cumulative)", events: stats.daily.orders.map(toEvent) },
{ title: "Submissions (cumulative)", events: stats.daily.submissions.map(toEvent) },
{ title: "Registrations", events: registrations },
{ title: "Orders created", events: orders },
{ title: "Submissions", events: submissions },
]}
/>
<CardGrid>
<Card size="small" title="New registrations per week">
<ColumnChart data={weekly(stats.registrations_per_week)} labelEvery={5} />
</Card>
<Card size="small" title="Active contributors per week" extra="created an order or submission">
<ColumnChart data={weekly(stats.contributors_per_week)} labelEvery={5} />
</Card>
<Card size="small" title="Orders per week">
<ColumnChart data={weekly(stats.orders.per_week)} labelEvery={5} />
</Card>
<ChartCard title="Adoption funnel" extra="all time">
<BarList rows={stats.funnel.map((f) => ({ label: `${f.label} (${Math.round((f.count / funnelTop) * 100)}%)`, value: f.count }))} />
</ChartCard>
<ChartCard title="Weekly retention" extra="contributors who came back">
<ColumnChart data={retention} labelEvery={every(retention)} format={(n) => `${n}%`} />
</ChartCard>
<ChartCard title="Order sizes" extra="submissions per order">
<BarList rows={stats.order_sizes.map((o) => ({ label: o.label, value: o.count }))} />
</ChartCard>
<ChartCard title="Weekly contributors" extra="orders or submissions">
<ColumnChart data={contributors} labelEvery={every(contributors)} />
</ChartCard>
<ChartCard title="Top order managers">
<BarList rows={stats.top_managers.map((u) => ({ label: shortName(u.email), value: u.count }))} />
</ChartCard>
<ChartCard title="Most active participants">
<BarList rows={stats.top_participants.map((u) => ({ label: shortName(u.email), value: u.count }))} />
</ChartCard>
<ItemRanking title="Most ordered items" items={stats.items} limit={10} />
<Card size="small" title="Top order managers">
<BarList rows={stats.top_managers.map((u) => ({ label: u.email, value: u.count }))} />
</Card>
<Card size="small" title="Most active participants">
<BarList rows={stats.top_participants.map((u) => ({ label: u.email, value: u.count }))} />
</Card>
</CardGrid>
<Card size="small" title="Storage" extra="orphans belong to deleted orders">
<ChartCard title="When people submit" extra="weekday and hour (UTC), all time">
<WeekdayHourHeatmap grid={stats.submissions_heatmap_utc} />
</ChartCard>
<ChartCard title="Storage" extra="orphans belong to deleted orders">
<Table
size="small"
pagination={false}
@@ -96,8 +137,8 @@ export default function SiteStats() {
{ title: "Orphaned size", key: "osize", align: "right", render: (_, r) => formatBytes(r.orphan_bytes) },
]}
/>
</Card>
</Space>
</ChartCard>
</PageStack>
)}
</AsyncContent>
);
@@ -1,23 +1,90 @@
import React from "react";
import { Card, Col, Row, Statistic } from "antd";
import { Card, Typography, theme } from "antd";
import { percentChange } from "../../lib/stats";
/** Responsive row of headline numbers. */
export function StatRow({ stats }: { stats: { title: string; value: string | number; hint?: string }[] }) {
/** Spacing shared by every block on the statistics pages. */
export const GAP = 16;
/** Vertical stack of page blocks with a uniform gap. */
export function PageStack({ children }: { children: React.ReactNode }) {
return <div style={{ display: "flex", flexDirection: "column", gap: GAP, width: "100%" }}>{children}</div>;
}
/** Row of headline numbers: equal-width, equal-height tiles that always fill the row. */
export type Stat = {
title: string;
value: string | number;
hint?: string;
/** Compares `current` with the previous period and shows the change next to the value. */
delta?: { current: number; previous: number | null };
};
/** "▲ 25%" / "▼ 10%" / "new" badge; renders nothing when there is nothing to compare. */
export function Delta({ current, previous }: { current: number; previous: number | null }) {
const { token } = theme.useToken();
const change = percentChange(current, previous);
if (change === null) return null;
const up = change >= 0;
const color = change === 0 ? token.colorTextSecondary : up ? token.colorSuccess : token.colorError;
const text = change === Infinity ? "new" : `${up ? "▲" : "▼"} ${Math.abs(Math.round(change))}%`;
return (
<Row gutter={[12, 12]}>
{stats.map((s) => (
<Col key={s.title} xs={12} md={8} lg={Math.max(4, Math.floor(24 / stats.length))}>
<Card size="small">
<Statistic title={s.title} value={s.value} />
{s.hint && <div style={{ fontSize: 12, opacity: 0.65 }}>{s.hint}</div>}
</Card>
</Col>
))}
</Row>
<span title="vs. the previous period of the same length" style={{ fontSize: 12, fontWeight: 500, color, whiteSpace: "nowrap" }}>
{text}
</span>
);
}
/** Two-column grid of chart cards that stacks on narrow screens. */
export function CardGrid({ children }: { children: React.ReactNode }) {
return <div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(300px, 1fr))", gap: 12 }}>{children}</div>;
export function StatRow({ stats }: { stats: Stat[] }) {
const { token } = theme.useToken();
const hasHints = stats.some((s) => s.hint);
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" } }}>
<Typography.Text type="secondary" style={{ fontSize: 13, display: "block", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }} title={s.title}>
{s.title}
</Typography.Text>
<div style={{ display: "flex", alignItems: "baseline", justifyContent: "space-between", gap: 8, marginTop: 4 }}>
<span style={{ fontSize: 26, fontWeight: 600, lineHeight: 1.25, color: token.colorText, fontVariantNumeric: "tabular-nums" }}>{s.value}</span>
{s.delta && <Delta {...s.delta} />}
</div>
{hasHints && (
<Typography.Text type="secondary" style={{ fontSize: 12, display: "block", minHeight: 18, marginTop: 2 }}>
{s.hint ?? " "}
</Typography.Text>
)}
</Card>
))}
</div>
);
}
/** Responsive grid of chart cards that stacks on narrow screens. */
export function CardGrid({ children, minWidth = 320, balance = true }: { children: React.ReactNode; minWidth?: number; /** Let an odd last card span the full row (for two-column grids). */ balance?: boolean }) {
return (
<>
{balance && <style>{".stat-card-grid > :last-child:nth-child(odd) { grid-column: 1 / -1; }"}</style>}
<div className={balance ? "stat-card-grid" : undefined} style={{ display: "grid", gridTemplateColumns: `repeat(auto-fit, minmax(min(${minWidth}px, 100%), 1fr))`, gap: GAP }}>
{children}
</div>
</>
);
}
/** 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 }) {
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%" }}
styles={{
header: { minHeight: 52, fontSize: 15 },
body: { padding: 16 },
}}
>
{children}
</Card>
);
}
@@ -1,7 +1,9 @@
import React, { useMemo, useState } from "react";
import { Card, DatePicker, Segmented, Space, Typography, theme } from "antd";
import { cumulativeDaily, resolveRange, SeriesPoint, TimedValue, Timeframe } from "../../lib/stats";
import { CardGrid } from "./StatCard";
import React from "react";
import { Typography } from "antd";
import { Bucket, periodBuckets, sumIn, TimedValue } from "../../lib/stats";
import { ColumnChart } from "./charts";
import { useRange } from "./range";
import { CardGrid, ChartCard, Delta } from "./StatCard";
export type TimeMetric = {
title: string;
@@ -9,100 +11,32 @@ export type TimeMetric = {
format?: (value: number) => string;
};
const TIMEFRAMES: { label: string; value: Timeframe }[] = [
{ label: "1W", value: "1w" },
{ label: "1M", value: "1m" },
{ label: "3M", value: "3m" },
{ label: "1Y", value: "1y" },
{ label: "YTD", value: "ytd" },
{ label: "All", value: "all" },
{ label: "Custom", value: "custom" },
];
const day = (d: Date) => d.toLocaleDateString();
/** Cumulative area chart; hovering shows the value for a day. */
function CumulativeChart({ points, format }: { points: SeriesPoint[]; format: (value: number) => string }) {
const { token } = theme.useToken();
const [hover, setHover] = useState<number | null>(null);
const max = Math.max(1, ...points.map((p) => p.value));
const x = (i: number) => (points.length > 1 ? (i / (points.length - 1)) * 100 : 0);
const y = (v: number) => 100 - (v / max) * 100;
const line = points.map((p, i) => `${x(i)},${y(p.value)}`).join(" ");
const shown = points[hover ?? points.length - 1];
/** Per-period totals (days, weeks or months, depending on the timeframe) for several metrics, each with its change vs. the previous period. */
export default function TimeSeriesPanel({ title = "Activity", metrics }: { title?: string; metrics: TimeMetric[] }) {
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 (
<div>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{shown ? `${day(shown.date)}: ` : ""}
<Typography.Text strong>{format(shown?.value ?? 0)}</Typography.Text>
</Typography.Text>
<div style={{ display: "flex", gap: 6 }}>
<div style={{ display: "flex", flexDirection: "column", justifyContent: "space-between", fontSize: 11, color: token.colorTextSecondary }}>
<span>{format(max)}</span>
<span>{format(0)}</span>
</div>
<svg
viewBox="0 0 100 100"
preserveAspectRatio="none"
style={{ flex: 1, height: 120, display: "block", overflow: "visible" }}
onMouseLeave={() => setHover(null)}
onMouseMove={(e) => {
const box = e.currentTarget.getBoundingClientRect();
setHover(Math.max(0, Math.min(points.length - 1, Math.round(((e.clientX - box.left) / box.width) * (points.length - 1)))));
}}
>
<polygon points={`0,100 ${line} 100,100`} fill={token.colorPrimary} fillOpacity={0.15} />
<polyline points={line} fill="none" stroke={token.colorPrimary} strokeWidth={2} vectorEffect="non-scaling-stroke" />
{hover !== null && points[hover] && (
<line x1={x(hover)} x2={x(hover)} y1={0} y2={100} stroke={token.colorTextSecondary} strokeDasharray="3" vectorEffect="non-scaling-stroke" />
)}
</svg>
</div>
<div style={{ display: "flex", justifyContent: "space-between", fontSize: 11, color: token.colorTextSecondary, marginLeft: 36 }}>
<span>{points[0] && day(points[0].date)}</span>
<span>{points.length > 1 && day(points[points.length - 1].date)}</span>
</div>
</div>
);
}
/** One timeframe filter driving cumulative charts for several metrics. Running totals start at 0 at the beginning of the timeframe. */
export default function TimeSeriesPanel({ title = "Over time", metrics }: { title?: string; metrics: TimeMetric[] }) {
const [timeframe, setTimeframe] = useState<Timeframe>("3m");
const [custom, setCustom] = useState<[Date, Date] | null>(null);
const earliest = useMemo(() => {
const times = metrics.flatMap((m) => m.events.map((e) => e.date.getTime()));
return times.length ? new Date(Math.min(...times)) : null;
}, [metrics]);
const range = resolveRange(timeframe, custom, earliest);
return (
<Card
size="small"
title={title}
extra={
<Space wrap>
{timeframe === "custom" && (
<DatePicker.RangePicker
size="small"
allowClear={false}
onChange={(dates) => setCustom(dates?.[0] && dates?.[1] ? [dates[0].toDate(), dates[1].toDate()] : null)}
/>
)}
<Segmented size="small" options={TIMEFRAMES} value={timeframe} onChange={(value) => setTimeframe(value as Timeframe)} />
</Space>
}
>
<CardGrid>
{metrics.map((metric) => (
<div key={metric.title}>
<Typography.Text strong>{metric.title}</Typography.Text>
<CumulativeChart points={cumulativeDaily(metric.events, range)} format={metric.format ?? String} />
</div>
))}
<ChartCard title={title} extra={`${range[0].toLocaleDateString()} – ${range[1].toLocaleDateString()}`}>
<CardGrid minWidth={220} balance={false}>
{metrics.map((metric) => {
const format = metric.format ?? String;
const total = sumIn(metric.events, range);
const buckets: Bucket[] = periodBuckets(metric.events, range);
return (
<div key={metric.title} style={{ minWidth: 0 }}>
<Typography.Text type="secondary" style={{ fontSize: 13, 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>
<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} />
</div>
);
})}
</CardGrid>
</Card>
</ChartCard>
);
}
@@ -1,11 +1,13 @@
import React from "react";
import { Empty, Progress, Space, Tooltip, Typography, theme } from "antd";
import { Empty, Tooltip, Typography, theme } from "antd";
import type { Bucket } from "../../lib/stats";
const noData = <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="No data yet" style={{ margin: "8px 0" }} />;
/** Vertical columns; `extra` stacks a second, lighter segment on top of `value`. */
export function ColumnChart({
data,
height = 120,
height = 140,
labelEvery = 1,
format = String,
}: {
@@ -17,53 +19,92 @@ export function ColumnChart({
}) {
const { token } = theme.useToken();
const max = Math.max(1, ...data.map((d) => d.value + (d.extra ?? 0)));
if (data.every((d) => d.value + (d.extra ?? 0) === 0)) {
return <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="No data yet" />;
}
if (data.every((d) => d.value + (d.extra ?? 0) === 0)) return noData;
return (
<div style={{ display: "flex", gap: 3, alignItems: "flex-end", height: height + 20 }}>
{data.map((d, i) => (
<Tooltip key={`${d.label}-${i}`} title={`${d.label}: ${format(d.value)}${d.extra ? ` + ${format(d.extra)}` : ""}`}>
<div style={{ flex: 1, minWidth: 0, display: "flex", flexDirection: "column", justifyContent: "flex-end", height: "100%" }}>
<div style={{ height: `${((d.extra ?? 0) / max) * height}px`, background: token.colorPrimaryBorder, borderRadius: "3px 3px 0 0" }} />
<div
style={{
height: `${(d.value / max) * height}px`,
minHeight: d.value ? 2 : 0,
background: token.colorPrimary,
borderRadius: d.extra ? 0 : "3px 3px 0 0",
}}
/>
<div style={{ height: 20, fontSize: 11, textAlign: "center", color: token.colorTextSecondary, overflow: "hidden", whiteSpace: "nowrap" }}>
{i % labelEvery === 0 ? d.label : ""}
<div style={{ display: "flex", gap: 6 }}>
<div
style={{
width: 28,
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={{
display: "flex",
gap: 3,
alignItems: "flex-end",
height,
borderBottom: `1px solid ${token.colorBorder}`,
backgroundImage: `linear-gradient(${token.colorBorderSecondary} 1px, transparent 1px)`,
backgroundSize: "100% 50%",
}}
>
{data.map((d, i) => (
<Tooltip key={`${d.label}-${i}`} title={`${d.label}: ${format(d.value)}${d.extra ? ` + ${format(d.extra)}` : ""}`}>
<div style={{ flex: 1, minWidth: 0, height: "100%", display: "flex", flexDirection: "column", justifyContent: "flex-end" }}>
<div style={{ height: `${((d.extra ?? 0) / max) * 100}%`, background: token.colorPrimaryBorder, borderRadius: "3px 3px 0 0" }} />
<div
style={{
height: `${(d.value / max) * 100}%`,
minHeight: d.value ? 2 : 0,
background: token.colorPrimary,
borderRadius: d.extra ? 0 : "3px 3px 0 0",
}}
/>
</div>
</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 : ""}
</span>
</div>
</div>
</Tooltip>
))}
))}
</div>
</div>
</div>
);
}
/** Horizontal ranked bars. */
/** Horizontal ranked bars (own implementation: Ant's Progress turns green at 100%). */
export function BarList({ rows, format = String }: { rows: Bucket[]; format?: (value: number) => string }) {
if (!rows.length) {
return <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="No data yet" />;
}
const { token } = theme.useToken();
if (!rows.length) return noData;
const max = Math.max(1, ...rows.map((r) => r.value));
return (
<Space direction="vertical" size={6} style={{ width: "100%" }}>
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
{rows.map((row) => (
<div key={row.label}>
<div style={{ display: "flex", justifyContent: "space-between", gap: 8 }}>
<Typography.Text ellipsis style={{ minWidth: 0 }}>
<div style={{ display: "flex", justifyContent: "space-between", gap: 12, marginBottom: 4 }}>
<Typography.Text ellipsis={{ tooltip: row.label }} style={{ minWidth: 0, flex: 1 }}>
{row.label}
</Typography.Text>
<Typography.Text type="secondary">{format(row.value)}</Typography.Text>
<Typography.Text type="secondary" style={{ flex: "none", fontVariantNumeric: "tabular-nums" }}>
{format(row.value)}
</Typography.Text>
</div>
<div style={{ height: 6, borderRadius: 3, background: token.colorFillSecondary }}>
<div style={{ width: `${(row.value / max) * 100}%`, minWidth: row.value ? 4 : 0, height: "100%", borderRadius: 3, background: token.colorPrimary }} />
</div>
<Progress percent={(row.value / max) * 100} showInfo={false} size="small" />
</div>
))}
</Space>
</div>
);
}
@@ -0,0 +1,43 @@
import React, { createContext, useContext, useMemo } from "react";
import { DatePicker, Segmented, Space } from "antd";
import { previousRange, resolveRange, Timeframe } from "../../lib/stats";
export type RangeState = { timeframe: Timeframe; custom: [Date, Date] | null };
const RangeContext = createContext<RangeState>({ timeframe: "3m", custom: null });
export const RangeProvider = RangeContext.Provider;
/** The page-wide timeframe, plus the equally long period before it (null for "All" and while nothing is known). */
export function useRange(earliest: Date | null): { range: [Date, Date]; previous: [Date, Date] | null; timeframe: Timeframe } {
const { timeframe, custom } = useContext(RangeContext);
const earliestMs = earliest?.getTime() ?? null;
return useMemo(() => {
const range = resolveRange(timeframe, custom, earliestMs === null ? null : new Date(earliestMs));
return { range, previous: timeframe === "all" ? null : previousRange(range), timeframe };
}, [timeframe, custom, earliestMs]);
}
const TIMEFRAMES: { label: string; value: Timeframe }[] = [
{ label: "1W", value: "1w" },
{ label: "1M", value: "1m" },
{ label: "3M", value: "3m" },
{ label: "1Y", value: "1y" },
{ label: "YTD", value: "ytd" },
{ label: "All", value: "all" },
{ label: "Custom", value: "custom" },
];
/** One timeframe filter that drives every chart and number on the page. */
export function TimeframeBar({ value, onChange }: { value: RangeState; onChange: (next: RangeState) => void }) {
return (
<Space wrap>
{value.timeframe === "custom" && (
<DatePicker.RangePicker
allowClear={false}
onChange={(dates) => onChange({ ...value, custom: dates?.[0] && dates?.[1] ? [dates[0].toDate(), dates[1].toDate()] : null })}
/>
)}
<Segmented options={TIMEFRAMES} value={value.timeframe} onChange={(timeframe) => onChange({ ...value, timeframe: timeframe as Timeframe })} />
</Space>
);
}
@@ -1,8 +1,8 @@
import { apiFetch } from "../api";
import type { ManagedStatsOrder, MyStatsSubmission, SiteStats } from "../types";
import type { ManagedStatsOrder, MyStatsSubmission, CommunityStats, SiteStats } from "../types";
export const statsService = {
getMine: () => apiFetch<{ submissions: MyStatsSubmission[] }>("/api/stats/me"),
getMine: () => apiFetch<{ submissions: MyStatsSubmission[]; community: CommunityStats }>("/api/stats/me"),
getManaged: () => apiFetch<{ orders: ManagedStatsOrder[] }>("/api/stats/managed"),
getSite: () => apiFetch<SiteStats>("/api/admin/stats"),
};
Binary file not shown.
+17 -1
View File
@@ -414,11 +414,21 @@ export type StatsSubmission = {
items: StatsItem[];
};
export type CommunityStats = {
participants: number;
average_orders: number;
/** Share (0-100) of the other participants with fewer orders. */
beats_percent: number;
/** Anonymous histogram of orders per participant; `mine` marks the viewer's bucket. */
distribution: { label: string; count: number; mine: boolean }[];
};
export type MyStatsSubmission = StatsSubmission & {
order_title: string;
order_created_at: string;
closed: boolean;
owner_email: string | null;
/** Anonymous per-response organizer label ("organizer-1", ...); never an identity. */
organizer: string;
};
export type ManagedStatsOrder = {
@@ -447,6 +457,12 @@ export type SiteStats = {
};
registrations_per_week: WeeklyCount[];
contributors_per_week: WeeklyCount[];
retention_per_week: WeeklyCount[];
submissions_heatmap_utc: number[][];
submissions_total: number;
order_sizes: { label: string; count: number }[];
value: { total: number; priced_submissions: number };
funnel: { label: string; count: number }[];
orders: { total: number; with_submissions: number; per_week: WeeklyCount[] };
top_managers: { email: string; count: number }[];
top_participants: { email: string; count: number }[];
+20 -20
View File
@@ -1,20 +1,22 @@
import React, { useEffect, useState } from "react";
import {
BarChartOutlined,
CrownOutlined,
ShoppingCartOutlined,
UserOutlined,
} from "@ant-design/icons";
import { Card, Menu } from "antd";
import { Menu, 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 { GAP } from "../components/statistics/StatCard";
import { RangeProvider, RangeState, TimeframeBar } from "../components/statistics/range";
type SectionDef = {
key: StatsSection;
label: string;
description: string;
icon: React.ReactNode;
/** Shown only to administrators. */
adminOnly?: boolean;
@@ -28,12 +30,14 @@ const SECTIONS: SectionDef[] = [
key: "orderee",
content: <MyOrdersStats />,
label: "My Orders",
description: "Your participation, spending and payments.",
icon: <ShoppingCartOutlined />,
},
{
key: "manager",
content: <ManagerStats />,
label: "Order Management",
description: "How your orders perform and who takes part.",
icon: <UserOutlined />,
ownersOnly: true,
},
@@ -41,6 +45,7 @@ const SECTIONS: SectionDef[] = [
key: "admin",
content: <SiteStats />,
label: "Site Overview",
description: "Users, activity and storage across the whole site.",
icon: <CrownOutlined />,
adminOnly: true,
},
@@ -55,6 +60,7 @@ export default function StatisticsView({
isAdmin: boolean;
}) {
const [ownsOrders, setOwnsOrders] = useState(false);
const [range, setRange] = useState<RangeState>({ timeframe: "3m", custom: null });
useEffect(() => {
let mounted = true;
@@ -74,14 +80,7 @@ export default function StatisticsView({
const current = visible.find((s) => s.key === section) ?? visible[0];
return (
<div
style={{
display: "flex",
flexWrap: "wrap",
gap: 16,
alignItems: "flex-start",
}}
>
<div style={{ display: "flex", flexWrap: "wrap", gap: GAP, alignItems: "flex-start" }}>
{visible.length > 1 && (
<Menu
mode="inline"
@@ -92,19 +91,20 @@ export default function StatisticsView({
icon,
}))}
onClick={({ key }) => navigateTo(key)}
style={{ flex: "0 0 220px", borderRadius: 12 }}
style={{ flex: "0 0 220px", borderRadius: 12, padding: 4 }}
/>
)}
<div style={{ flex: "1 1 400px", minWidth: 0 }}>
<Card
title={
<>
<BarChartOutlined /> {current.label}
</>
}
>
{current.content}
</Card>
<div style={{ display: "flex", flexWrap: "wrap", justifyContent: "space-between", alignItems: "flex-end", gap: 8, marginBottom: GAP }}>
<div>
<Typography.Title level={3} style={{ margin: "0 0 4px" }}>
{current.label}
</Typography.Title>
<Typography.Text type="secondary">{current.description}</Typography.Text>
</div>
<TimeframeBar value={range} onChange={setRange} />
</div>
<RangeProvider value={range}>{current.content}</RangeProvider>
</div>
</div>
);