Add category drill-down to item rankings

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:19:42 +01:00
1 parent c812921a2b
commit d8b7871142
8 files changed
+68 -24

No files matched your search

+3 -3
View File
@@ -18,7 +18,7 @@ Statistics pages live under `/stats/*` (`src/frontend/src/views/StatisticsView.t
## My Orders (orderees)
- ✅ Participation over time (orders joined per month) and current streak (consecutive weeks)
- ✅ Favourite items and categories, items repeated most
- ✅ Favourite items and categories, items repeated most (drill-down: all items, by category, or one category)
- ✅ Total and average spend per order (priced menus only)
- ✅ Usual order day / hour
- ✅ Most frequent organizers
@@ -30,7 +30,7 @@ Statistics pages live under `/stats/*` (`src/frontend/src/views/StatisticsView.t
- ✅ 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
- ✅ Popular items and categories across own orders (same category drill-down)
- ✅ 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.
@@ -44,7 +44,7 @@ Statistics pages live under `/stats/*` (`src/frontend/src/views/StatisticsView.t
- ✅ 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
- ✅ Most ordered items site-wide (same category drill-down; `items` in `/api/admin/stats` is capped at 300)
- ✅ 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`.
+4 -3
View File
@@ -242,7 +242,7 @@ def get_site_stats(conn: sqlite3.Connection) -> dict[str, Any]:
).fetchall()
configs = _ConfigCache()
item_counts: Counter[str] = Counter()
item_counts: Counter[tuple[str, 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"
):
@@ -250,7 +250,7 @@ def get_site_stats(conn: sqlite3.Connection) -> dict[str, Any]:
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"])))
item_counts.update((i["category"], 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 = {
@@ -276,6 +276,7 @@ def get_site_stats(conn: sqlite3.Connection) -> dict[str, Any]:
},
"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)],
# Capped, but large enough for per-category drill-downs.
"items": [{"category": cat, "name": n, "count": c} for (cat, n), c in item_counts.most_common(300)],
"storage": storage,
}
@@ -0,0 +1,50 @@
import React, { useMemo, useState } from "react";
import { Card, Select } from "antd";
import type { ItemCount } from "../../lib/types";
import { BarList } from "./charts";
const ALL = "\u0000all";
const BY_CATEGORY = "\u0000categories";
/** Ranked items with a drill-down: all items, the categories themselves, or the items of a single category. */
export default function ItemRanking({ title, items, limit = 8 }: { title: string; items: ItemCount[]; limit?: number }) {
const [view, setView] = useState(ALL);
const categories = useMemo(() => {
const totals = new Map<string, number>();
items.forEach((i) => totals.set(i.category, (totals.get(i.category) ?? 0) + i.count));
return [...totals.entries()].sort((a, b) => b[1] - a[1]);
}, [items]);
const rows = useMemo(() => {
if (view === BY_CATEGORY) {
return categories.map(([label, value]) => ({ label, value }));
}
const inView = view === ALL ? items : items.filter((i) => i.category === view);
return inView
.map((i) => ({ label: view === ALL ? `${i.name} (${i.category})` : i.name, value: i.count }))
.sort((a, b) => b.value - a.value || a.label.localeCompare(b.label));
}, [view, items, categories]);
return (
<Card
size="small"
title={title}
extra={
<Select
size="small"
value={categories.some(([c]) => c === view) || view === ALL || view === BY_CATEGORY ? view : ALL}
onChange={setView}
style={{ minWidth: 150 }}
options={[
{ value: ALL, label: "All items" },
{ value: BY_CATEGORY, label: "By category" },
...categories.map(([category, total]) => ({ value: category, label: `${category} (${total})` })),
]}
/>
}
>
<BarList rows={rows.slice(0, limit)} />
</Card>
);
}
@@ -3,9 +3,10 @@ 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 { average, Bucket, countItems, formatMoney, monthlyBuckets, sum } from "../../lib/stats";
import type { ManagedStatsOrder } from "../../lib/types";
import AsyncContent from "../utils/AsyncContent";
import ItemRanking from "./ItemRanking";
import { BarList, ColumnChart } from "./charts";
import { CardGrid, StatRow } from "./StatCard";
@@ -94,12 +95,7 @@ export default function ManagerStats() {
<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>
<ItemRanking title="Popular items" items={countItems(items)} limit={10} />
<Card size="small" title="Payment status (submissions)">
<BarList
rows={[
@@ -3,9 +3,10 @@ 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 { average, countBy, countItems, currentWeekStreak, formatMoney, hourBuckets, monthlyBuckets, sum, topBuckets, weekdayBuckets } from "../../lib/stats";
import type { MyStatsSubmission } from "../../lib/types";
import AsyncContent from "../utils/AsyncContent";
import ItemRanking from "./ItemRanking";
import { BarList, ColumnChart } from "./charts";
import { CardGrid, StatRow } from "./StatCard";
@@ -59,12 +60,7 @@ export default function MyOrdersStats() {
<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>
<ItemRanking title="Favourite items" items={countItems(items)} />
<Card size="small" title="Usual organizers">
<BarList rows={topBuckets(countBy(submissions, (s) => s.owner_email), 6)} />
</Card>
@@ -4,6 +4,7 @@ import { apiService } from "../../lib/services";
import { useApiRequest } from "../../hooks/useApiRequest";
import type { WeeklyCount } from "../../lib/types";
import AsyncContent from "../utils/AsyncContent";
import ItemRanking from "./ItemRanking";
import { BarList, ColumnChart } from "./charts";
import { CardGrid, StatRow } from "./StatCard";
@@ -59,9 +60,7 @@ export default function SiteStats() {
<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>
<ItemRanking title="Most ordered items" items={stats.items} limit={10} />
<Card size="small" title="Top order managers">
<BarList rows={stats.top_managers.map((u) => ({ label: u.email, value: u.count }))} />
</Card>
Binary file not shown.
+3 -1
View File
@@ -400,6 +400,8 @@ export type AdminOrder = {
};
// Statistics (see docs/statistics.md)
export type ItemCount = { category: string; name: string; count: number };
export type StatsItem = { category: string; name: string };
export type StatsSubmission = {
@@ -446,6 +448,6 @@ export type SiteStats = {
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 }[];
items: ItemCount[];
storage: Record<string, StorageUsage>;
};