From 1df83afab19afa272e55c4a6a2fd732b000fb4e1 Mon Sep 17 00:00:00 2001 From: Simon Gruber Date: Thu, 5 Nov 2026 22:21:43 +0000 Subject: [PATCH] Redesign statistics pages with global timeframe, insights and privacy scoping - 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 --- docs/statistics.md | 10 +- src/backend/app/services/stats_service.py | 119 +++++++++--- .../src/components/statistics/Heatmaps.tsx | 83 +++++++++ .../src/components/statistics/Highlights.tsx | 32 ++++ .../src/components/statistics/ItemRanking.tsx | 8 +- .../components/statistics/ManagerStats.tsx | 169 ++++++++++++++---- .../components/statistics/MyOrdersStats.tsx | 159 ++++++++++++---- .../src/components/statistics/SiteStats.tsx | 103 +++++++---- .../src/components/statistics/StatCard.tsx | 99 ++++++++-- .../components/statistics/TimeSeriesPanel.tsx | 126 ++++--------- .../src/components/statistics/charts.tsx | 107 +++++++---- .../src/components/statistics/range.tsx | 43 +++++ src/frontend/src/lib/services/statsService.ts | 4 +- src/frontend/src/lib/stats.ts | Bin 5580 -> 12885 bytes src/frontend/src/lib/types.ts | 18 +- src/frontend/src/views/StatisticsView.tsx | 40 ++--- 16 files changed, 818 insertions(+), 302 deletions(-) create mode 100644 src/frontend/src/components/statistics/Heatmaps.tsx create mode 100644 src/frontend/src/components/statistics/Highlights.tsx create mode 100644 src/frontend/src/components/statistics/range.tsx diff --git a/docs/statistics.md b/docs/statistics.md index 99fbf33..d59b831 100644 --- a/docs/statistics.md +++ b/docs/statistics.md @@ -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) diff --git a/src/backend/app/services/stats_service.py b/src/backend/app/services/stats_service.py index 6ff958d..4b0d120 100644 --- a/src/backend/app/services/stats_service.py +++ b/src/backend/app/services/stats_service.py @@ -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"]), diff --git a/src/frontend/src/components/statistics/Heatmaps.tsx b/src/frontend/src/components/statistics/Heatmaps.tsx new file mode 100644 index 0000000..a1184ee --- /dev/null +++ b/src/frontend/src/components/statistics/Heatmaps.tsx @@ -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(); + 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 ( +
+
+
+ + {["Mon", "", "Wed", "", "Fri", "", ""].map((d, i) => ( + {d} + ))} +
+
+ {Array.from({ length: weeks }, (_, w) => ( + + {monthLabels.get(w) ?? ""} + {days.slice(w * 7, w * 7 + 7).map((d) => { + const n = counts.get(dayKey(d)) ?? 0; + const future = d > today; + return ( + +
+ + ); + })} + + ))} +
+
+
+ ); +} + +/** 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 ( +
+
+ + {Array.from({ length: 24 }, (_, h) => ( + {h % 3 === 0 ? h : ""} + ))} + {grid.map((row, d) => ( + + {WEEKDAY_NAMES[d].slice(0, 3)} + {row.map((n, h) => ( + +
+ + ))} + + ))} +
+
+ ); +} diff --git a/src/frontend/src/components/statistics/Highlights.tsx b/src/frontend/src/components/statistics/Highlights.tsx new file mode 100644 index 0000000..3018c2a --- /dev/null +++ b/src/frontend/src/components/statistics/Highlights.tsx @@ -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 ( +
+ {facts.map((f) => ( +
+ + {f.label} + +
{f.value}
+ {f.note && ( + + {f.note} + + )} +
+ ))} +
+ ); +} + +/** Big one-line headline, e.g. the lunch personality. */ +export function Headline({ children }: { children: React.ReactNode }) { + const { token } = theme.useToken(); + return
{children}
; +} diff --git a/src/frontend/src/components/statistics/ItemRanking.tsx b/src/frontend/src/components/statistics/ItemRanking.tsx index eb02c5c..4a7db5f 100644 --- a/src/frontend/src/components/statistics/ItemRanking.tsx +++ b/src/frontend/src/components/statistics/ItemRanking.tsx @@ -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 ( - - + ); } diff --git a/src/frontend/src/components/statistics/ManagerStats.tsx b/src/frontend/src/components/statistics/ManagerStats.tsx index 02c8849..eff3d80 100644 --- a/src/frontend/src/components/statistics/ManagerStats.tsx +++ b/src/frontend/src/components/statistics/ManagerStats.tsx @@ -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(); + 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(); + 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 ( - - + + !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" }, ]} /> ({ 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 }, ]} /> + + => Boolean(f))} + /> + + - - o.created_at))} labelEvery={2} /> - - + ({ label: o.title, value: o.submissions.length }))} labelEvery={labelEvery} /> - - - - - + + + + + - - - + + - + + + + `${n} ${n === 1 ? "person" : "people"}`} /> + + + ({ label: shortName(email), value: n }))} /> + {drifted.length > 0 && ( + + Haven't joined your last 3 orders: {drifted.map(([email, n]) => `${shortName(email)} (${n}×)`).join(", ")} + + )} + - + + s.submitted_at))} /> + + + + 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" }, + ]} + /> + + +
r.order.id} @@ -140,8 +241,8 @@ export default function ManagerStats() { { title: "Value", key: "value", align: "right", render: (_, r) => (r.value ? formatMoney(r.value) : "–") }, ]} /> - - + + ); } diff --git a/src/frontend/src/components/statistics/MyOrdersStats.tsx b/src/frontend/src/components/statistics/MyOrdersStats.tsx index 1163d8b..ac7b97f 100644 --- a/src/frontend/src/components/statistics/MyOrdersStats.tsx +++ b/src/frontend/src/components/statistics/MyOrdersStats.tsx @@ -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 ( Statistics appear once you have submitted to an order.} > - + s.total as number)) : null }, + }, { title: "Still to pay", value: formatMoney(sum(open.map((s) => s.total ?? 0))), hint: `${open.length} accepted, unpaid` }, ]} /> + + {personality ? {personality} : Join a few more orders to reveal your lunch personality} + = 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 => Boolean(f))} + /> + + ({ 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 }, ]} /> + + + + - - - - - - s.owner_email), 6)} /> - - - - - - - + s.items))} /> + + {community && community.participants >= 3 ? ( + <> + ({ label: `${d.label} ${d.label === "1" ? "order" : "orders"}${d.mine ? " ← you" : ""}`, value: d.count }))} + format={(n) => `${n} ${n === 1 ? "person" : "people"}`} + /> + + Average: {community.average_orders} orders per participant. + + + ) : ( + Comparisons appear once a few more people have joined orders. + )} + - + + + + +
- - + Payment history {paid.length} @@ -103,8 +184,8 @@ export default function MyOrdersStats() { columns={paymentColumns} locale={{ emptyText: "No paid orders yet" }} /> - - + + ); } diff --git a/src/frontend/src/components/statistics/SiteStats.tsx b/src/frontend/src/components/statistics/SiteStats.tsx index fdbcc69..82d72fd 100644 --- a/src/frontend/src/components/statistics/SiteStats.tsx +++ b/src/frontend/src/components/statistics/SiteStats.tsx @@ -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 ( {stats && ( - + + + - - - - - - - - - + + ({ label: `${f.label} (${Math.round((f.count / funnelTop) * 100)}%)`, value: f.count }))} /> + + + `${n}%`} /> + + + ({ label: o.label, value: o.count }))} /> + + + + + + ({ label: shortName(u.email), value: u.count }))} /> + + + ({ label: shortName(u.email), value: u.count }))} /> + - - ({ label: u.email, value: u.count }))} /> - - - ({ label: u.email, value: u.count }))} /> - - + + + + +
formatBytes(r.orphan_bytes) }, ]} /> - - + + )} ); diff --git a/src/frontend/src/components/statistics/StatCard.tsx b/src/frontend/src/components/statistics/StatCard.tsx index 9750801..89d56fe 100644 --- a/src/frontend/src/components/statistics/StatCard.tsx +++ b/src/frontend/src/components/statistics/StatCard.tsx @@ -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
{children}
; +} + +/** 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 ( - - {stats.map((s) => ( - - - - {s.hint &&
{s.hint}
} -
- - ))} - + + {text} + ); } -/** Two-column grid of chart cards that stacks on narrow screens. */ -export function CardGrid({ children }: { children: React.ReactNode }) { - return
{children}
; +export function StatRow({ stats }: { stats: Stat[] }) { + const { token } = theme.useToken(); + const hasHints = stats.some((s) => s.hint); + return ( +
+ {stats.map((s) => ( + + + {s.title} + +
+ {s.value} + {s.delta && } +
+ {hasHints && ( + + {s.hint ?? " "} + + )} +
+ ))} +
+ ); +} + +/** 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 && } +
+ {children} +
+ + ); +} + +/** 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 ( + {extra} : extra} + style={{ height: "100%" }} + styles={{ + header: { minHeight: 52, fontSize: 15 }, + body: { padding: 16 }, + }} + > + {children} + + ); } diff --git a/src/frontend/src/components/statistics/TimeSeriesPanel.tsx b/src/frontend/src/components/statistics/TimeSeriesPanel.tsx index 617483f..17f8330 100644 --- a/src/frontend/src/components/statistics/TimeSeriesPanel.tsx +++ b/src/frontend/src/components/statistics/TimeSeriesPanel.tsx @@ -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(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 ( -
- - {shown ? `${day(shown.date)}: ` : ""} - {format(shown?.value ?? 0)} - -
-
- {format(max)} - {format(0)} -
- 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))))); - }} - > - - - {hover !== null && points[hover] && ( - - )} - -
-
- {points[0] && day(points[0].date)} - {points.length > 1 && day(points[points.length - 1].date)} -
-
- ); -} - -/** 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("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 ( - - {timeframe === "custom" && ( - setCustom(dates?.[0] && dates?.[1] ? [dates[0].toDate(), dates[1].toDate()] : null)} - /> - )} - setTimeframe(value as Timeframe)} /> - - } - > - - {metrics.map((metric) => ( -
- {metric.title} - -
- ))} + + + {metrics.map((metric) => { + const format = metric.format ?? String; + const total = sumIn(metric.events, range); + const buckets: Bucket[] = periodBuckets(metric.events, range); + return ( +
+ + {metric.title} + +
+ {format(total)} + +
+ +
+ ); + })}
-
+ ); } diff --git a/src/frontend/src/components/statistics/charts.tsx b/src/frontend/src/components/statistics/charts.tsx index f934c4b..0fdbbb6 100644 --- a/src/frontend/src/components/statistics/charts.tsx +++ b/src/frontend/src/components/statistics/charts.tsx @@ -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 = ; + /** 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 ; - } + if (data.every((d) => d.value + (d.extra ?? 0) === 0)) return noData; return ( -
- {data.map((d, i) => ( - -
-
-
-
- {i % labelEvery === 0 ? d.label : ""} +
+
+ {format(max)} + {format(0)} +
+
+
+ {data.map((d, i) => ( + +
+
+
+
+ + ))} +
+ {/* Labels sit in their own row and may overflow their slot, centred on the bar, so they are never clipped. */} +
+ {data.map((d, i) => ( +
+ + {i % labelEvery === 0 ? d.label : ""} +
-
- - ))} + ))} +
+
); } -/** 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 ; - } + const { token } = theme.useToken(); + if (!rows.length) return noData; const max = Math.max(1, ...rows.map((r) => r.value)); return ( - +
{rows.map((row) => (
-
- +
+ {row.label} - {format(row.value)} + + {format(row.value)} + +
+
+
-
))} - +
); } diff --git a/src/frontend/src/components/statistics/range.tsx b/src/frontend/src/components/statistics/range.tsx new file mode 100644 index 0000000..7c28461 --- /dev/null +++ b/src/frontend/src/components/statistics/range.tsx @@ -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({ 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 ( + + {value.timeframe === "custom" && ( + onChange({ ...value, custom: dates?.[0] && dates?.[1] ? [dates[0].toDate(), dates[1].toDate()] : null })} + /> + )} + onChange({ ...value, timeframe: timeframe as Timeframe })} /> + + ); +} diff --git a/src/frontend/src/lib/services/statsService.ts b/src/frontend/src/lib/services/statsService.ts index d3debe7..17b79d9 100644 --- a/src/frontend/src/lib/services/statsService.ts +++ b/src/frontend/src/lib/services/statsService.ts @@ -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("/api/admin/stats"), }; diff --git a/src/frontend/src/lib/stats.ts b/src/frontend/src/lib/stats.ts index 84d70e0e9a17dfa7c8dc92e81155b7259a8b0bde..dc411a235f373e155525cf0db21a4a0e0bdbbfc4 100644 GIT binary patch literal 12885 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 delta 7 Ocmcbbaz=Z@8BqWbmjl88 diff --git a/src/frontend/src/lib/types.ts b/src/frontend/src/lib/types.ts index 312dab5..a499287 100644 --- a/src/frontend/src/lib/types.ts +++ b/src/frontend/src/lib/types.ts @@ -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 }[]; diff --git a/src/frontend/src/views/StatisticsView.tsx b/src/frontend/src/views/StatisticsView.tsx index 9c121f8..e669df4 100644 --- a/src/frontend/src/views/StatisticsView.tsx +++ b/src/frontend/src/views/StatisticsView.tsx @@ -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: , label: "My Orders", + description: "Your participation, spending and payments.", icon: , }, { key: "manager", content: , label: "Order Management", + description: "How your orders perform and who takes part.", icon: , ownersOnly: true, }, @@ -41,6 +45,7 @@ const SECTIONS: SectionDef[] = [ key: "admin", content: , label: "Site Overview", + description: "Users, activity and storage across the whole site.", icon: , adminOnly: true, }, @@ -55,6 +60,7 @@ export default function StatisticsView({ isAdmin: boolean; }) { const [ownsOrders, setOwnsOrders] = useState(false); + const [range, setRange] = useState({ 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 ( -
+
{visible.length > 1 && ( navigateTo(key)} - style={{ flex: "0 0 220px", borderRadius: 12 }} + style={{ flex: "0 0 220px", borderRadius: 12, padding: 4 }} /> )}
- - {current.label} - - } - > - {current.content} - +
+
+ + {current.label} + + {current.description} +
+ +
+ {current.content}
);