Implement statistics pages for orderees, order managers and site admins

Per-user pages get fact rows from /api/stats/me and /api/stats/managed and
aggregate client-side; the site overview is aggregated by /api/admin/stats.
Items that need new data are tracked as TODOs in docs/statistics.md.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Simon GruberandClaude Sonnet 5.5 committed 2026-11-05 17:18:05 +01:00
1 parent 70bd7642d4
commit c812921a2b
13 files changed
+914 -47

No files matched your search

+50
View File
@@ -0,0 +1,50 @@
# Statistics
Statistics pages live under `/stats/*` (`src/frontend/src/views/StatisticsView.tsx`). Status: ✅ done, 🔲 TODO (needs backend changes, listed under each item).
## Architecture
- Backend (`services/stats_service.py`) returns compact **fact rows**, not aggregates, for the per-user pages. Aggregation (months, weekdays, hours) happens in the frontend (`lib/stats.ts`) so buckets use the viewer's timezone.
- The site overview is aggregated in the backend (`GET /api/admin/stats`) because it spans all users and needs files on disk.
- Item names/prices come from the per-order config files (`data/configs/<admin_token>.json`), resolved in the backend.
- Estimated totals only exist for orders whose menu items have prices.
| Endpoint | Auth | Used by |
|---|---|---|
| `GET /api/stats/me` | any user | My Orders |
| `GET /api/stats/managed` | any user (own orders only) | Order Management |
| `GET /api/admin/stats` | site admin | Site Overview |
## My Orders (orderees)
- ✅ Participation over time (orders joined per month) and current streak (consecutive weeks)
- ✅ Favourite items and categories, items repeated most
- ✅ Total and average spend per order (priced menus only)
- ✅ Usual order day / hour
- ✅ Most frequent organizers
- ✅ Open payments (accepted, unpaid) and payment history
- 🔲 How early you submit before the deadline — orders have no deadline. Backend: add `group_orders.deadline` (nullable ISO text) + create/update API + UI field; expose it in `/api/stats/me`.
## Order Management
- ✅ Orders created over time; open vs. closed (closed = ordering done)
- ✅ Participants per order (average, trend); repeat vs. new participants
- ✅ Submission timeline: time from order creation to submission
- ✅ Popular items and categories across own orders
- ✅ Order volume: items and value per order
- ✅ Payment status: paid / accepted-unpaid / pending
- 🔲 Average time to payment — only the current `paid` flag is stored. Backend: add `submissions.paid_at` (and `accepted_at`), set on status updates, backfill NULL.
- 🔲 Participants who are invited but rarely submit — there is no invite/participant list. Backend: `order_invites` table (order, email) or a per-owner contact list.
- 🔲 Submission timeline relative to the deadline — needs the deadline (see above).
## Site Overview (admins)
- ✅ Active contributors per week (users who created an order or submission), new registrations per week
- ✅ Users with recent activity (7 / 30 days, from last known activity — approximation)
- ✅ Unconfirmed accounts, pending email / user-id changes, open registration links
- ✅ Orders per week and share of orders with ≥1 submission
- ✅ Top order managers, most active participants
- ✅ Most ordered items site-wide
- ✅ Storage usage per data folder and orphaned files
- 🔲 Real daily/weekly/monthly active users — only creation timestamps are recorded. Backend: `user_profiles.last_seen_at` updated (throttled) in `get_existing_user_id`, or an `activity_log` table.
- 🔲 Email volume and failures — `email_service` does not log sends. Backend: `email_log` table (kind, recipient hash, status, error, created_at) written by `email_service`.
+19
View File
@@ -35,6 +35,7 @@ from .services.account_service import (
upsert_user_profile_email,
)
from .services.common_service import clean_image_url, clean_user_email, clean_user_id, ensure_email_not_privileged, is_site_admin, now_iso
from .services.stats_service import get_managed_stats, get_my_stats, get_site_stats
from .services.config_service import get_email_settings, load_config
from .services.email_service import (
send_email_change_new_email_confirmation,
@@ -651,6 +652,24 @@ def admin_list_orders(_admin_id: str = Depends(require_site_admin)) -> dict[str,
return {"orders": list_orders(conn)}
@app.get("/api/admin/stats")
def admin_stats(_admin_id: str = Depends(require_site_admin)) -> dict[str, Any]:
with get_connection() as conn:
return get_site_stats(conn)
@app.get("/api/stats/me")
def my_stats(user_id: str = Depends(get_existing_user_id)) -> dict[str, Any]:
with get_connection() as conn:
return get_my_stats(conn, user_id)
@app.get("/api/stats/managed")
def managed_stats(user_id: str = Depends(get_existing_user_id)) -> dict[str, Any]:
with get_connection() as conn:
return get_managed_stats(conn, user_id)
@app.get("/api/admin/registration-links")
def admin_list_registration_links(_admin_id: str = Depends(require_site_admin)) -> dict[str, Any]:
with get_connection() as conn:
+281
View File
@@ -0,0 +1,281 @@
"""Statistics. Per-user pages get compact fact rows (the frontend buckets them in the viewer's timezone);
the site overview is aggregated here because it spans all users and files on disk."""
import json
import sqlite3
from collections import Counter
from datetime import datetime, timedelta, timezone
from typing import Any
from .common_service import now_iso
from .order_file_service import (
CONFIGS_DIR,
DESCRIPTIONS_DIR,
IMAGES_DIR,
NOTIFICATIONS_DIR,
PAYMENTS_DIR,
TEMPLATES_DIR,
load_order_config,
)
from .submission_service import calculate_estimated_submission_total
OWNER_EMAIL_SQL = """
(SELECT up.email FROM user_order_tokens o
JOIN user_profiles up ON up.user_id = o.user_id
WHERE o.group_order_id = go.id AND o.admin_token = go.admin_token
ORDER BY o.created_at LIMIT 1)
"""
WEEKS_SHOWN = 26
TOP_N = 10
def _selected_items(choices: dict[str, Any], config: dict[str, Any]) -> list[dict[str, str]]:
"""Selected menu items as {category, name}; category is the label from the order config."""
items: list[dict[str, str]] = []
for category in config.get("categories") or []:
value = choices.get(str(category.get("id", "")).strip())
names = value if isinstance(value, list) else [value]
label = str(category.get("label") or category.get("id") or "")
items.extend({"category": label, "name": str(n).strip()} for n in names if isinstance(n, str) and n.strip())
return items
class _ConfigCache:
def __init__(self) -> None:
self._configs: dict[str, dict[str, Any]] = {}
def get(self, admin_token: str) -> dict[str, Any]:
if admin_token not in self._configs:
self._configs[admin_token] = load_order_config(admin_token)
return self._configs[admin_token]
def _submission_fact(row: sqlite3.Row, configs: _ConfigCache) -> dict[str, Any]:
config = configs.get(row["order_admin_token"])
try:
choices = json.loads(row["choices_json"] or "{}")
except ValueError:
choices = {}
return {
"order_id": row["order_id"],
"submitted_at": row["submitted_at"],
"accepted": bool(row["accepted"]),
"paid": bool(row["paid"]),
"total": calculate_estimated_submission_total(choices, config),
"items": _selected_items(choices, config),
}
def get_my_stats(conn: sqlite3.Connection, user_id: str) -> dict[str, Any]:
"""One fact row per order the user submitted to."""
configs = _ConfigCache()
rows = conn.execute(
f"""
SELECT go.id AS order_id, go.title, go.admin_token AS order_admin_token, go.closed,
go.created_at AS order_created_at, {OWNER_EMAIL_SQL} AS owner_email,
s.created_at AS submitted_at, s.accepted, s.paid, s.choices_json
FROM user_order_tokens uot
JOIN submissions s ON s.submission_token = uot.submission_token
JOIN group_orders go ON go.id = uot.group_order_id
WHERE uot.user_id = ?
ORDER BY s.created_at
""",
(user_id,),
).fetchall()
return {
"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"],
}
for row in rows
]
}
def get_managed_stats(conn: sqlite3.Connection, user_id: str) -> dict[str, Any]:
"""The user's own orders, each with one fact row per submission (empty list if they own none)."""
configs = _ConfigCache()
orders = conn.execute(
"""
SELECT go.id, go.title, go.closed, go.created_at
FROM user_order_tokens uot JOIN group_orders go ON go.id = uot.group_order_id
WHERE uot.user_id = ? AND uot.admin_token IS NOT NULL
ORDER BY go.created_at
""",
(user_id,),
).fetchall()
result = []
for order in orders:
rows = conn.execute(
"""
SELECT go.id AS order_id, go.admin_token AS order_admin_token, lower(trim(s.email)) AS email,
s.created_at AS submitted_at, s.accepted, s.paid, s.choices_json
FROM submissions s JOIN group_orders go ON go.id = s.group_order_id
WHERE go.id = ?
ORDER BY s.created_at
""",
(order["id"],),
).fetchall()
result.append(
{
"id": order["id"],
"title": order["title"],
"closed": bool(order["closed"]),
"created_at": order["created_at"],
"submissions": [{**_submission_fact(row, configs), "email": row["email"]} for row in rows],
}
)
return {"orders": result}
def _week_start(timestamp: str | None) -> str | None:
"""Monday (UTC) of the week containing the ISO timestamp, as YYYY-MM-DD."""
if not timestamp:
return None
try:
parsed = datetime.fromisoformat(timestamp)
except ValueError:
return None
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
day = parsed.astimezone(timezone.utc).date()
return (day - timedelta(days=day.weekday())).isoformat()
def _weekly(counter: Counter[str]) -> list[dict[str, Any]]:
"""Last WEEKS_SHOWN weeks including empty ones, oldest first."""
today = datetime.now(timezone.utc).date()
monday = today - timedelta(days=today.weekday())
weeks = [(monday - timedelta(weeks=i)).isoformat() for i in reversed(range(WEEKS_SHOWN))]
return [{"week": week, "count": counter.get(week, 0)} for week in weeks]
def _dir_usage(path: Any, known_tokens: set[str]) -> dict[str, Any]:
"""Size/file count of a data folder; entries (files named <token>.*, or folders named <token>) of deleted orders are orphans."""
total = files = orphan_files = orphan_bytes = 0
for entry in path.iterdir():
if entry.is_dir():
sizes = [f.stat().st_size for f in entry.rglob("*") if f.is_file()]
else:
sizes = [entry.stat().st_size]
size, count = sum(sizes), len(sizes)
total += size
files += count
if (entry.name if entry.is_dir() else entry.stem) not in known_tokens:
orphan_files += count
orphan_bytes += size
return {"files": files, "bytes": total, "orphan_files": orphan_files, "orphan_bytes": orphan_bytes}
def get_site_stats(conn: sqlite3.Connection) -> dict[str, Any]:
now = now_iso()
cutoffs = {days: (datetime.now(timezone.utc) - timedelta(days=days)).isoformat() for days in (7, 30)}
profiles = conn.execute(
"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
"""
)
}
users = {
"total": len(profiles),
"confirmed": sum(1 for p in profiles if p["email_confirmed"]),
"unconfirmed": sum(1 for p in profiles if not p["email_confirmed"]),
"pending_email_change": sum(1 for p in profiles if p["pending_email"]),
"pending_user_id_change": sum(1 for p in profiles if p["pending_user_id"]),
"open_registration_links": conn.execute(
"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]),
}
orders = conn.execute(
"SELECT go.id, go.created_at, (SELECT COUNT(*) FROM submissions s WHERE s.group_order_id = go.id) AS submissions FROM group_orders go"
).fetchall()
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"])
top_managers = conn.execute(
"""
SELECT up.email, COUNT(*) AS count FROM user_order_tokens uot
JOIN user_profiles up ON up.user_id = uot.user_id
WHERE uot.admin_token IS NOT NULL GROUP BY uot.user_id ORDER BY count DESC, up.email LIMIT ?
""",
(TOP_N,),
).fetchall()
top_participants = conn.execute(
"""
SELECT up.email, COUNT(*) AS count FROM user_order_tokens uot
JOIN user_profiles up ON up.user_id = uot.user_id
WHERE uot.submission_token IS NOT NULL GROUP BY uot.user_id ORDER BY count DESC, up.email LIMIT ?
""",
(TOP_N,),
).fetchall()
configs = _ConfigCache()
item_counts: Counter[str] = Counter()
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"
):
try:
choices = json.loads(row["choices_json"] or "{}")
except ValueError:
continue
item_counts.update(i["name"] for i in _selected_items(choices, configs.get(row["order_admin_token"])))
tokens = {row[0] for row in conn.execute("SELECT admin_token FROM group_orders")}
storage = {
name: _dir_usage(path, tokens)
for name, path in {
"images": IMAGES_DIR,
"configs": CONFIGS_DIR,
"descriptions": DESCRIPTIONS_DIR,
"templates": TEMPLATES_DIR,
"payments": PAYMENTS_DIR,
"notifications": NOTIFICATIONS_DIR,
}.items()
}
return {
"users": users,
"registrations_per_week": _weekly(registrations),
"contributors_per_week": _weekly(Counter({w: len(u) for w, u in contributors.items()})),
"orders": {
"total": len(orders),
"with_submissions": sum(1 for o in orders if o["submissions"]),
"per_week": _weekly(orders_per_week),
},
"top_managers": [dict(r) for r in top_managers],
"top_participants": [dict(r) for r in top_participants],
"top_items": [{"name": n, "count": c} for n, c in item_counts.most_common(15)],
"storage": storage,
}
@@ -0,0 +1,138 @@
import React from "react";
import { Card, Space, Table } from "antd";
import { apiService } from "../../lib/services";
import { navigateTo } from "../../lib/routing";
import { useApiRequest } from "../../hooks/useApiRequest";
import { average, Bucket, countBy, formatMoney, monthlyBuckets, sum, topBuckets } from "../../lib/stats";
import type { ManagedStatsOrder } from "../../lib/types";
import AsyncContent from "../utils/AsyncContent";
import { BarList, ColumnChart } from "./charts";
import { CardGrid, StatRow } from "./StatCard";
const load = () => apiService.stats.getManaged();
const HOUR = 3_600_000;
const RESPONSE_BINS: { label: string; maxHours: number }[] = [
{ label: "<1h", maxHours: 1 },
{ label: "1–3h", maxHours: 3 },
{ label: "3–12h", maxHours: 12 },
{ label: "12–24h", maxHours: 24 },
{ label: "1–3d", maxHours: 72 },
{ label: ">3d", maxHours: Infinity },
];
/** How long after the order was created people submitted. */
function responseTimeBuckets(orders: ManagedStatsOrder[]): Bucket[] {
const buckets = RESPONSE_BINS.map(({ label }) => ({ label, value: 0 }));
for (const order of orders) {
for (const s of order.submissions) {
const hours = (new Date(s.submitted_at).getTime() - new Date(order.created_at).getTime()) / HOUR;
buckets[RESPONSE_BINS.findIndex((bin) => hours < bin.maxHours)].value++;
}
}
return buckets;
}
/** Per order (oldest first): participants not seen in any earlier own order (new) vs. already seen (repeat, stacked). */
function newVsRepeat(orders: ManagedStatsOrder[]): Bucket[] {
const seen = new Set<string>();
return orders.map((order) => {
const participants = new Set(order.submissions.map((s) => s.email));
const repeat = [...participants].filter((p) => seen.has(p)).length;
participants.forEach((p) => seen.add(p));
return { label: order.title, value: participants.size - repeat, extra: repeat };
});
}
export default function ManagerStats() {
const { data, loading, error, run } = useApiRequest(load);
const orders = data?.orders ?? [];
const recent = orders.slice(-15);
const labelEvery = Math.max(1, Math.ceil(recent.length / 5));
const submissions = orders.flatMap((o) => o.submissions);
const items = submissions.flatMap((s) => s.items);
const perOrder = orders.map((order) => ({
order,
count: order.submissions.length,
items: sum(order.submissions.map((s) => s.items.length)),
value: sum(order.submissions.map((s) => s.total ?? 0)),
paid: order.submissions.filter((s) => s.paid).length,
}));
const paid = submissions.filter((s) => s.paid);
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)));
return (
<AsyncContent loading={loading} error={error} onRetry={run} isEmpty={!orders.length}>
<Space direction="vertical" size={12} style={{ width: "100%" }}>
<StatRow
stats={[
{
title: "Orders created",
value: orders.length,
hint: `${orders.filter((o) => !o.closed).length} open · ${orders.filter((o) => o.closed).length} closed`,
},
{ 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" },
]}
/>
<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)">
<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">
<ColumnChart data={responseTimeBuckets(orders)} />
</Card>
<Card size="small" title="Popular items">
<BarList rows={topBuckets(countBy(items, (i) => i.name), 10)} />
</Card>
<Card size="small" title="Popular categories">
<BarList rows={topBuckets(countBy(items, (i) => i.category), 6)} />
</Card>
<Card size="small" title="Payment status (submissions)">
<BarList
rows={[
{ label: `Paid (${money(paid)})`, value: paid.length },
{ label: `Accepted, unpaid (${money(unpaid)})`, value: unpaid.length },
{ label: `Pending (${money(pending)})`, value: pending.length },
]}
/>
</Card>
</CardGrid>
<Card size="small" title="Volume per order">
<Table
size="small"
rowKey={(r) => r.order.id}
pagination={{ pageSize: 8, hideOnSinglePage: true }}
dataSource={[...perOrder].reverse()}
scroll={{ x: true }}
columns={[
{
title: "Order",
key: "title",
render: (_, r) => <a onClick={() => navigateTo(`/order/${r.order.id}/admin`)}>{r.order.title}</a>,
},
{ title: "Date", key: "date", render: (_, r) => new Date(r.order.created_at).toLocaleDateString() },
{ title: "Submissions", dataIndex: "count", align: "right" },
{ title: "Items", dataIndex: "items", align: "right" },
{ title: "Paid", key: "paid", align: "right", render: (_, r) => `${r.paid}/${r.count}` },
{ title: "Value", key: "value", align: "right", render: (_, r) => (r.value ? formatMoney(r.value) : "–") },
]}
/>
</Card>
</Space>
</AsyncContent>
);
}
@@ -0,0 +1,102 @@
import React from "react";
import { Card, Space, Table, Tag, Typography } from "antd";
import { apiService } from "../../lib/services";
import { navigateTo } from "../../lib/routing";
import { useApiRequest } from "../../hooks/useApiRequest";
import { average, countBy, currentWeekStreak, formatMoney, hourBuckets, monthlyBuckets, sum, topBuckets, weekdayBuckets } from "../../lib/stats";
import type { MyStatsSubmission } from "../../lib/types";
import AsyncContent from "../utils/AsyncContent";
import { BarList, ColumnChart } from "./charts";
import { CardGrid, StatRow } from "./StatCard";
const load = () => apiService.stats.getMine();
const paymentColumns = [
{
title: "Order",
key: "order",
render: (_: unknown, s: MyStatsSubmission) => <a onClick={() => navigateTo(`/order/${s.order_id}`)}>{s.order_title}</a>,
},
{ title: "Date", key: "date", render: (_: unknown, s: MyStatsSubmission) => new Date(s.submitted_at).toLocaleDateString() },
{
title: "Amount",
key: "amount",
align: "right" as const,
render: (_: unknown, s: MyStatsSubmission) => (s.total === null ? "–" : formatMoney(s.total)),
},
];
export default function MyOrdersStats() {
const { data, loading, error, run } = useApiRequest(load);
const submissions = data?.submissions ?? [];
const times = submissions.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);
return (
<AsyncContent
loading={loading}
error={error}
onRetry={run}
isEmpty={!submissions.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%" }}>
<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: "Still to pay", value: formatMoney(sum(open.map((s) => s.total ?? 0))), hint: `${open.length} accepted, unpaid` },
]}
/>
<CardGrid>
<Card size="small" title="Orders per month">
<ColumnChart data={monthlyBuckets(times)} labelEvery={2} />
</Card>
<Card size="small" title="Favourite items (times ordered)">
<BarList rows={topBuckets(countBy(items, (i) => i.name))} />
</Card>
<Card size="small" title="Favourite categories">
<BarList rows={topBuckets(countBy(items, (i) => i.category), 6)} />
</Card>
<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>
</CardGrid>
<Card size="small" title="Open payments">
<Table size="small" pagination={false} rowKey="order_id" dataSource={open} columns={paymentColumns} locale={{ emptyText: "Nothing to pay" }} />
</Card>
<Card
size="small"
title={
<>
Payment history <Tag>{paid.length}</Tag>
</>
}
>
<Table
size="small"
pagination={{ pageSize: 5, hideOnSinglePage: true }}
rowKey="order_id"
dataSource={[...paid].reverse()}
columns={paymentColumns}
locale={{ emptyText: "No paid orders yet" }}
/>
</Card>
</Space>
</AsyncContent>
);
}
@@ -0,0 +1,93 @@
import React from "react";
import { Card, Space, Table } from "antd";
import { apiService } from "../../lib/services";
import { useApiRequest } from "../../hooks/useApiRequest";
import type { WeeklyCount } from "../../lib/types";
import AsyncContent from "../utils/AsyncContent";
import { BarList, ColumnChart } from "./charts";
import { CardGrid, StatRow } from "./StatCard";
const load = () => apiService.stats.getSite();
const weekly = (rows: WeeklyCount[]) => rows.map((r) => ({ label: `Week of ${r.week}`, value: r.count }));
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
const units = ["KB", "MB", "GB"];
let value = bytes / 1024;
let unit = 0;
while (value >= 1024 && unit < units.length - 1) {
value /= 1024;
unit++;
}
return `${value.toFixed(1)} ${units[unit]}`;
}
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;
return (
<AsyncContent loading={loading} error={error} onRetry={run} isEmpty={!stats}>
{stats && (
<Space direction="vertical" size={12} style={{ width: "100%" }}>
<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` },
]}
/>
<StatRow
stats={[
{ 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 },
]}
/>
<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>
<Card size="small" title="Most ordered items">
<BarList rows={stats.top_items.map((i) => ({ label: i.name, value: i.count }))} />
</Card>
<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">
<Table
size="small"
pagination={false}
rowKey="folder"
dataSource={storage}
scroll={{ x: true }}
columns={[
{ title: "Folder", dataIndex: "folder" },
{ title: "Files", dataIndex: "files", align: "right" },
{ title: "Size", key: "size", align: "right", render: (_, r) => formatBytes(r.bytes) },
{ title: "Orphaned files", dataIndex: "orphan_files", align: "right" },
{ title: "Orphaned size", key: "osize", align: "right", render: (_, r) => formatBytes(r.orphan_bytes) },
]}
/>
</Card>
</Space>
)}
</AsyncContent>
);
}
@@ -0,0 +1,23 @@
import React from "react";
import { Card, Col, Row, Statistic } from "antd";
/** Responsive row of headline numbers. */
export function StatRow({ stats }: { stats: { title: string; value: string | number; hint?: string }[] }) {
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>
);
}
/** 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>;
}
@@ -0,0 +1,69 @@
import React from "react";
import { Empty, Progress, Space, Tooltip, Typography, theme } from "antd";
import type { Bucket } from "../../lib/stats";
/** Vertical columns; `extra` stacks a second, lighter segment on top of `value`. */
export function ColumnChart({
data,
height = 120,
labelEvery = 1,
format = String,
}: {
data: Bucket[];
height?: number;
/** Show only every n-th x label (for dense charts). */
labelEvery?: number;
format?: (value: number) => string;
}) {
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" />;
}
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>
</div>
</Tooltip>
))}
</div>
);
}
/** Horizontal ranked bars. */
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 max = Math.max(1, ...rows.map((r) => r.value));
return (
<Space direction="vertical" size={6} style={{ width: "100%" }}>
{rows.map((row) => (
<div key={row.label}>
<div style={{ display: "flex", justifyContent: "space-between", gap: 8 }}>
<Typography.Text ellipsis style={{ minWidth: 0 }}>
{row.label}
</Typography.Text>
<Typography.Text type="secondary">{format(row.value)}</Typography.Text>
</div>
<Progress percent={(row.value / max) * 100} showInfo={false} size="small" />
</div>
))}
</Space>
);
}
+2
View File
@@ -3,6 +3,7 @@ import { adminService } from "./adminService";
import { configService } from "./configService";
import { ordersService } from "./ordersService";
import { profileService } from "./profileService";
import { statsService } from "./statsService";
import { submissionsService } from "./submissionsService";
export const apiService = {
@@ -11,5 +12,6 @@ export const apiService = {
config: configService,
orders: ordersService,
profile: profileService,
stats: statsService,
submissions: submissionsService,
};
@@ -0,0 +1,8 @@
import { apiFetch } from "../api";
import type { ManagedStatsOrder, MyStatsSubmission, SiteStats } from "../types";
export const statsService = {
getMine: () => apiFetch<{ submissions: MyStatsSubmission[] }>("/api/stats/me"),
getManaged: () => apiFetch<{ orders: ManagedStatsOrder[] }>("/api/stats/managed"),
getSite: () => apiFetch<SiteStats>("/api/admin/stats"),
};
+68
View File
@@ -0,0 +1,68 @@
/** Small aggregation helpers for the statistics pages. Buckets use the viewer's local timezone. */
export type Bucket = { label: string; value: number; extra?: number };
export const formatMoney = (value: number) => `€${value.toFixed(2)}`;
export const sum = (values: number[]) => values.reduce((a, b) => a + b, 0);
export const average = (values: number[]) => (values.length ? sum(values) / values.length : 0);
export function countBy<T>(rows: T[], key: (row: T) => string | null | undefined): Map<string, number> {
const counts = new Map<string, number>();
for (const row of rows) {
const k = key(row);
if (k) counts.set(k, (counts.get(k) ?? 0) + 1);
}
return counts;
}
export function topBuckets(counts: Map<string, number>, limit = 8): Bucket[] {
return [...counts.entries()]
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
.slice(0, limit)
.map(([label, value]) => ({ label, value }));
}
const pad = (n: number) => String(n).padStart(2, "0");
const monthKey = (d: Date) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}`;
/** One bucket per month for the last `months` months (oldest first), including empty ones. */
export function monthlyBuckets(timestamps: string[], months = 12): Bucket[] {
const counts = countBy(timestamps, (t) => monthKey(new Date(t)));
const now = new Date();
return Array.from({ length: months }, (_, i) => {
const d = new Date(now.getFullYear(), now.getMonth() - (months - 1 - i), 1);
return { label: d.toLocaleDateString(undefined, { month: "short", year: "2-digit" }), value: counts.get(monthKey(d)) ?? 0 };
});
}
export function weekdayBuckets(timestamps: string[]): Bucket[] {
const counts = new Array(7).fill(0);
for (const t of timestamps) counts[(new Date(t).getDay() + 6) % 7]++; // Monday first
return ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"].map((label, i) => ({ label, value: counts[i] }));
}
export function hourBuckets(timestamps: string[]): Bucket[] {
const counts = new Array(24).fill(0);
for (const t of timestamps) counts[new Date(t).getHours()]++;
return counts.map((value, hour) => ({ label: String(hour), value }));
}
/** Monday of the week containing `d`, as a day number (days since epoch, local). */
function weekIndex(d: Date): number {
const monday = new Date(d.getFullYear(), d.getMonth(), d.getDate() - ((d.getDay() + 6) % 7));
return Math.round(Date.UTC(monday.getFullYear(), monday.getMonth(), monday.getDate()) / 604_800_000);
}
/** Consecutive weeks (ending this week, or last week if this one has none yet) with at least one timestamp. */
export function currentWeekStreak(timestamps: string[]): number {
const weeks = new Set(timestamps.map((t) => weekIndex(new Date(t))));
let week = weekIndex(new Date());
if (!weeks.has(week)) week--;
let streak = 0;
while (weeks.has(week)) {
streak++;
week--;
}
return streak;
}
+51
View File
@@ -398,3 +398,54 @@ export type AdminOrder = {
created_at: string;
submissions: number;
};
// Statistics (see docs/statistics.md)
export type StatsItem = { category: string; name: string };
export type StatsSubmission = {
order_id: string;
submitted_at: string;
accepted: boolean;
paid: boolean;
/** Estimated total; null when the menu has no prices. */
total: number | null;
items: StatsItem[];
};
export type MyStatsSubmission = StatsSubmission & {
order_title: string;
order_created_at: string;
closed: boolean;
owner_email: string | null;
};
export type ManagedStatsOrder = {
id: string;
title: string;
closed: boolean;
created_at: string;
submissions: (StatsSubmission & { email: string })[];
};
export type WeeklyCount = { week: string; count: number };
export type StorageUsage = { files: number; bytes: number; orphan_files: number; orphan_bytes: number };
export type SiteStats = {
users: {
total: number;
confirmed: number;
unconfirmed: number;
pending_email_change: number;
pending_user_id_change: number;
open_registration_links: number;
active_7d: number;
active_30d: number;
};
registrations_per_week: WeeklyCount[];
contributors_per_week: WeeklyCount[];
orders: { total: number; with_submissions: number; per_week: WeeklyCount[] };
top_managers: { email: string; count: number }[];
top_participants: { email: string; count: number }[];
top_items: { name: string; count: number }[];
storage: Record<string, StorageUsage>;
};
+10 -47
View File
@@ -5,73 +5,48 @@ import {
ShoppingCartOutlined,
UserOutlined,
} from "@ant-design/icons";
import { Card, Empty, List, Menu, Typography } from "antd";
import { Card, Menu } 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";
type SectionDef = {
key: StatsSection;
label: string;
icon: React.ReactNode;
audience: string;
/** Shown only to administrators. */
adminOnly?: boolean;
/** Shown only to users who own at least one order. */
ownersOnly?: boolean;
/** Planned statistics, shown on the placeholder page until they are implemented. */
planned: string[];
content: React.ReactNode;
};
const SECTIONS: SectionDef[] = [
{
key: "orderee",
content: <MyOrdersStats />,
label: "My Orders",
icon: <ShoppingCartOutlined />,
audience: "For everyone who submits to group orders.",
planned: [
"Participation over time: orders joined per month, current streak",
"Favourite items and categories, plus items you always repeat",
"Total and average spend per order (where prices are configured)",
"Your usual order day and how early you submit before the deadline",
"Most frequent order groups and organizers",
"Open payments: what you still owe, and payment history",
],
},
{
key: "manager",
content: <ManagerStats />,
label: "Order Management",
icon: <UserOutlined />,
audience: "For users who create and run group orders.",
ownersOnly: true,
planned: [
"Orders created over time, and how many reached the ordering stage",
"Participants per order: average, trend, repeat vs. new participants",
"Submission timeline: when people respond relative to the deadline",
"Most popular items and categories across your orders",
"Order volume: total items and value per order",
"Payment status: paid vs. open, average time to payment",
"Participants who are invited but rarely submit",
],
},
{
key: "admin",
content: <SiteStats />,
label: "Site Overview",
icon: <CrownOutlined />,
adminOnly: true,
audience: "For site administrators.",
planned: [
"Active users (daily/weekly/monthly) and new registrations",
"Unconfirmed accounts and pending email-change/migration requests",
"Orders per week, and the share with at least one submission",
"Top order managers and most active participants",
"Most ordered items across the site",
"Storage usage: uploaded images, payment files, orphaned data",
"Email volume and failures",
],
},
];
/** Placeholder statistics pages; each section lists the statistics planned for it. */
/** Statistics pages; planned and open items are tracked in docs/statistics.md. */
export default function StatisticsView({
section,
isAdmin,
@@ -128,19 +103,7 @@ export default function StatisticsView({
</>
}
>
<Typography.Paragraph type="secondary">
{current.audience}
</Typography.Paragraph>
<Empty
description="Statistics are coming soon."
image={Empty.PRESENTED_IMAGE_SIMPLE}
/>
<Typography.Text strong>Planned</Typography.Text>
<List
size="small"
dataSource={current.planned}
renderItem={(item) => <List.Item>{item}</List.Item>}
/>
{current.content}
</Card>
</div>
</div>