From c812921a2b353828f060336ee18465598050f980 Mon Sep 17 00:00:00 2001 From: Simon Gruber Date: Thu, 5 Nov 2026 17:18:05 +0100 Subject: [PATCH] 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 --- docs/statistics.md | 50 ++++ src/backend/app/main.py | 19 ++ src/backend/app/services/stats_service.py | 281 ++++++++++++++++++ .../components/statistics/ManagerStats.tsx | 138 +++++++++ .../components/statistics/MyOrdersStats.tsx | 102 +++++++ .../src/components/statistics/SiteStats.tsx | 93 ++++++ .../src/components/statistics/StatCard.tsx | 23 ++ .../src/components/statistics/charts.tsx | 69 +++++ src/frontend/src/lib/services/index.ts | 2 + src/frontend/src/lib/services/statsService.ts | 8 + src/frontend/src/lib/stats.ts | 68 +++++ src/frontend/src/lib/types.ts | 51 ++++ src/frontend/src/views/StatisticsView.tsx | 57 +--- 13 files changed, 914 insertions(+), 47 deletions(-) create mode 100644 docs/statistics.md create mode 100644 src/backend/app/services/stats_service.py create mode 100644 src/frontend/src/components/statistics/ManagerStats.tsx create mode 100644 src/frontend/src/components/statistics/MyOrdersStats.tsx create mode 100644 src/frontend/src/components/statistics/SiteStats.tsx create mode 100644 src/frontend/src/components/statistics/StatCard.tsx create mode 100644 src/frontend/src/components/statistics/charts.tsx create mode 100644 src/frontend/src/lib/services/statsService.ts create mode 100644 src/frontend/src/lib/stats.ts diff --git a/docs/statistics.md b/docs/statistics.md new file mode 100644 index 0000000..293bcff --- /dev/null +++ b/docs/statistics.md @@ -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/.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`. diff --git a/src/backend/app/main.py b/src/backend/app/main.py index 9d42b1d..988b9ec 100644 --- a/src/backend/app/main.py +++ b/src/backend/app/main.py @@ -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: diff --git a/src/backend/app/services/stats_service.py b/src/backend/app/services/stats_service.py new file mode 100644 index 0000000..f55789a --- /dev/null +++ b/src/backend/app/services/stats_service.py @@ -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 .*, or folders named ) 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, + } diff --git a/src/frontend/src/components/statistics/ManagerStats.tsx b/src/frontend/src/components/statistics/ManagerStats.tsx new file mode 100644 index 0000000..dc054c7 --- /dev/null +++ b/src/frontend/src/components/statistics/ManagerStats.tsx @@ -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(); + 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 ( + + + !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" }, + ]} + /> + + + + o.created_at))} labelEvery={2} /> + + + ({ label: o.title, value: o.submissions.length }))} labelEvery={labelEvery} /> + + + + + + + + + i.name), 10)} /> + + + i.category), 6)} /> + + + + + + + + r.order.id} + pagination={{ pageSize: 8, hideOnSinglePage: true }} + dataSource={[...perOrder].reverse()} + scroll={{ x: true }} + columns={[ + { + title: "Order", + key: "title", + render: (_, r) => navigateTo(`/order/${r.order.id}/admin`)}>{r.order.title}, + }, + { 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) : "โ€“") }, + ]} + /> + + + + ); +} diff --git a/src/frontend/src/components/statistics/MyOrdersStats.tsx b/src/frontend/src/components/statistics/MyOrdersStats.tsx new file mode 100644 index 0000000..fb2050a --- /dev/null +++ b/src/frontend/src/components/statistics/MyOrdersStats.tsx @@ -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) => navigateTo(`/order/${s.order_id}`)}>{s.order_title}, + }, + { 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 ( + Statistics appear once you have submitted to an order.} + > + + s.total ?? 0))), hint: `${open.length} accepted, unpaid` }, + ]} + /> + + + + + + + i.name))} /> + + + i.category), 6)} /> + + + s.owner_email), 6)} /> + + + + + + + + + + +
+ + + Payment history {paid.length} + + } + > +
+ + + + ); +} diff --git a/src/frontend/src/components/statistics/SiteStats.tsx b/src/frontend/src/components/statistics/SiteStats.tsx new file mode 100644 index 0000000..2803a98 --- /dev/null +++ b/src/frontend/src/components/statistics/SiteStats.tsx @@ -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 ( + + {stats && ( + + + + + + + + + + + + + + + + ({ label: i.name, value: i.count }))} /> + + + ({ label: u.email, value: u.count }))} /> + + + ({ label: u.email, value: u.count }))} /> + + + + +
formatBytes(r.bytes) }, + { title: "Orphaned files", dataIndex: "orphan_files", align: "right" }, + { title: "Orphaned size", key: "osize", align: "right", render: (_, r) => formatBytes(r.orphan_bytes) }, + ]} + /> + + + )} + + ); +} diff --git a/src/frontend/src/components/statistics/StatCard.tsx b/src/frontend/src/components/statistics/StatCard.tsx new file mode 100644 index 0000000..9750801 --- /dev/null +++ b/src/frontend/src/components/statistics/StatCard.tsx @@ -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 ( + + {stats.map((s) => ( + + + + {s.hint &&
{s.hint}
} +
+ + ))} + + ); +} + +/** Two-column grid of chart cards that stacks on narrow screens. */ +export function CardGrid({ children }: { children: React.ReactNode }) { + return
{children}
; +} diff --git a/src/frontend/src/components/statistics/charts.tsx b/src/frontend/src/components/statistics/charts.tsx new file mode 100644 index 0000000..f934c4b --- /dev/null +++ b/src/frontend/src/components/statistics/charts.tsx @@ -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 ; + } + + return ( +
+ {data.map((d, i) => ( + +
+
+
+
+ {i % labelEvery === 0 ? d.label : ""} +
+
+ + ))} +
+ ); +} + +/** Horizontal ranked bars. */ +export function BarList({ rows, format = String }: { rows: Bucket[]; format?: (value: number) => string }) { + if (!rows.length) { + return ; + } + const max = Math.max(1, ...rows.map((r) => r.value)); + return ( + + {rows.map((row) => ( +
+
+ + {row.label} + + {format(row.value)} +
+ +
+ ))} +
+ ); +} diff --git a/src/frontend/src/lib/services/index.ts b/src/frontend/src/lib/services/index.ts index 093582f..aeed134 100644 --- a/src/frontend/src/lib/services/index.ts +++ b/src/frontend/src/lib/services/index.ts @@ -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, }; diff --git a/src/frontend/src/lib/services/statsService.ts b/src/frontend/src/lib/services/statsService.ts new file mode 100644 index 0000000..d3debe7 --- /dev/null +++ b/src/frontend/src/lib/services/statsService.ts @@ -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("/api/admin/stats"), +}; diff --git a/src/frontend/src/lib/stats.ts b/src/frontend/src/lib/stats.ts new file mode 100644 index 0000000..a5b21e2 --- /dev/null +++ b/src/frontend/src/lib/stats.ts @@ -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(rows: T[], key: (row: T) => string | null | undefined): Map { + const counts = new Map(); + 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, 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; +} diff --git a/src/frontend/src/lib/types.ts b/src/frontend/src/lib/types.ts index e2e1453..4a27f71 100644 --- a/src/frontend/src/lib/types.ts +++ b/src/frontend/src/lib/types.ts @@ -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; +}; diff --git a/src/frontend/src/views/StatisticsView.tsx b/src/frontend/src/views/StatisticsView.tsx index 5f95995..9c121f8 100644 --- a/src/frontend/src/views/StatisticsView.tsx +++ b/src/frontend/src/views/StatisticsView.tsx @@ -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: , label: "My Orders", icon: , - 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: , label: "Order Management", icon: , - 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: , label: "Site Overview", icon: , 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({ } > - - {current.audience} - - - Planned - {item}} - /> + {current.content}