diff --git a/docs/statistics.md b/docs/statistics.md index 293bcff..1003bb9 100644 --- a/docs/statistics.md +++ b/docs/statistics.md @@ -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`. diff --git a/src/backend/app/services/stats_service.py b/src/backend/app/services/stats_service.py index f55789a..4aa515f 100644 --- a/src/backend/app/services/stats_service.py +++ b/src/backend/app/services/stats_service.py @@ -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, } diff --git a/src/frontend/src/components/statistics/ItemRanking.tsx b/src/frontend/src/components/statistics/ItemRanking.tsx new file mode 100644 index 0000000..eb02c5c --- /dev/null +++ b/src/frontend/src/components/statistics/ItemRanking.tsx @@ -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(); + 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 ( + 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})` })), + ]} + /> + } + > + + + ); +} diff --git a/src/frontend/src/components/statistics/ManagerStats.tsx b/src/frontend/src/components/statistics/ManagerStats.tsx index dc054c7..eb504d0 100644 --- a/src/frontend/src/components/statistics/ManagerStats.tsx +++ b/src/frontend/src/components/statistics/ManagerStats.tsx @@ -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() { - - i.name), 10)} /> - - - i.category), 6)} /> - + - - i.name))} /> - - - i.category), 6)} /> - + s.owner_email), 6)} /> diff --git a/src/frontend/src/components/statistics/SiteStats.tsx b/src/frontend/src/components/statistics/SiteStats.tsx index 2803a98..da82870 100644 --- a/src/frontend/src/components/statistics/SiteStats.tsx +++ b/src/frontend/src/components/statistics/SiteStats.tsx @@ -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() { - - ({ label: i.name, value: i.count }))} /> - + ({ label: u.email, value: u.count }))} /> diff --git a/src/frontend/src/lib/stats.ts b/src/frontend/src/lib/stats.ts index a5b21e2..29acc1b 100644 Binary files a/src/frontend/src/lib/stats.ts and b/src/frontend/src/lib/stats.ts differ diff --git a/src/frontend/src/lib/types.ts b/src/frontend/src/lib/types.ts index 4a27f71..035f777 100644 --- a/src/frontend/src/lib/types.ts +++ b/src/frontend/src/lib/types.ts @@ -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; };