Add category drill-down to item rankings
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
c812921a2b
commit
d8b7871142
8 files changed
+68
-24
No files matched your search
+3
-3
@@ -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`.
|
||||
@@ -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.
@@ -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>;
|
||||
};
|
||||
Reference in new issue
Block a user