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:
1 parent
70bd7642d4
commit
c812921a2b
13 files changed
+914
-47
No files matched your search
@@ -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`.
|
||||
@@ -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:
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"),
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>;
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user