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)
|
## My Orders (orderees)
|
||||||
|
|
||||||
- ✅ Participation over time (orders joined per month) and current streak (consecutive weeks)
|
- ✅ 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)
|
- ✅ Total and average spend per order (priced menus only)
|
||||||
- ✅ Usual order day / hour
|
- ✅ Usual order day / hour
|
||||||
- ✅ Most frequent organizers
|
- ✅ 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)
|
- ✅ Orders created over time; open vs. closed (closed = ordering done)
|
||||||
- ✅ Participants per order (average, trend); repeat vs. new participants
|
- ✅ Participants per order (average, trend); repeat vs. new participants
|
||||||
- ✅ Submission timeline: time from order creation to submission
|
- ✅ 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
|
- ✅ Order volume: items and value per order
|
||||||
- ✅ Payment status: paid / accepted-unpaid / pending
|
- ✅ 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.
|
- 🔲 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
|
- ✅ Unconfirmed accounts, pending email / user-id changes, open registration links
|
||||||
- ✅ Orders per week and share of orders with ≥1 submission
|
- ✅ Orders per week and share of orders with ≥1 submission
|
||||||
- ✅ Top order managers, most active participants
|
- ✅ 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
|
- ✅ 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.
|
- 🔲 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`.
|
- 🔲 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()
|
).fetchall()
|
||||||
|
|
||||||
configs = _ConfigCache()
|
configs = _ConfigCache()
|
||||||
item_counts: Counter[str] = Counter()
|
item_counts: Counter[tuple[str, str]] = Counter()
|
||||||
for row in conn.execute(
|
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"
|
"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 "{}")
|
choices = json.loads(row["choices_json"] or "{}")
|
||||||
except ValueError:
|
except ValueError:
|
||||||
continue
|
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")}
|
tokens = {row[0] for row in conn.execute("SELECT admin_token FROM group_orders")}
|
||||||
storage = {
|
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_managers": [dict(r) for r in top_managers],
|
||||||
"top_participants": [dict(r) for r in top_participants],
|
"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,
|
"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 { apiService } from "../../lib/services";
|
||||||
import { navigateTo } from "../../lib/routing";
|
import { navigateTo } from "../../lib/routing";
|
||||||
import { useApiRequest } from "../../hooks/useApiRequest";
|
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 type { ManagedStatsOrder } from "../../lib/types";
|
||||||
import AsyncContent from "../utils/AsyncContent";
|
import AsyncContent from "../utils/AsyncContent";
|
||||||
|
import ItemRanking from "./ItemRanking";
|
||||||
import { BarList, ColumnChart } from "./charts";
|
import { BarList, ColumnChart } from "./charts";
|
||||||
import { CardGrid, StatRow } from "./StatCard";
|
import { CardGrid, StatRow } from "./StatCard";
|
||||||
|
|
||||||
@@ -94,12 +95,7 @@ export default function ManagerStats() {
|
|||||||
<Card size="small" title="Time to submit after order creation">
|
<Card size="small" title="Time to submit after order creation">
|
||||||
<ColumnChart data={responseTimeBuckets(orders)} />
|
<ColumnChart data={responseTimeBuckets(orders)} />
|
||||||
</Card>
|
</Card>
|
||||||
<Card size="small" title="Popular items">
|
<ItemRanking title="Popular items" items={countItems(items)} limit={10} />
|
||||||
<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)">
|
<Card size="small" title="Payment status (submissions)">
|
||||||
<BarList
|
<BarList
|
||||||
rows={[
|
rows={[
|
||||||
|
|||||||
@@ -3,9 +3,10 @@ import { Card, Space, Table, Tag, Typography } from "antd";
|
|||||||
import { apiService } from "../../lib/services";
|
import { apiService } from "../../lib/services";
|
||||||
import { navigateTo } from "../../lib/routing";
|
import { navigateTo } from "../../lib/routing";
|
||||||
import { useApiRequest } from "../../hooks/useApiRequest";
|
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 type { MyStatsSubmission } from "../../lib/types";
|
||||||
import AsyncContent from "../utils/AsyncContent";
|
import AsyncContent from "../utils/AsyncContent";
|
||||||
|
import ItemRanking from "./ItemRanking";
|
||||||
import { BarList, ColumnChart } from "./charts";
|
import { BarList, ColumnChart } from "./charts";
|
||||||
import { CardGrid, StatRow } from "./StatCard";
|
import { CardGrid, StatRow } from "./StatCard";
|
||||||
|
|
||||||
@@ -59,12 +60,7 @@ export default function MyOrdersStats() {
|
|||||||
<Card size="small" title="Orders per month">
|
<Card size="small" title="Orders per month">
|
||||||
<ColumnChart data={monthlyBuckets(times)} labelEvery={2} />
|
<ColumnChart data={monthlyBuckets(times)} labelEvery={2} />
|
||||||
</Card>
|
</Card>
|
||||||
<Card size="small" title="Favourite items (times ordered)">
|
<ItemRanking title="Favourite items" items={countItems(items)} />
|
||||||
<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">
|
<Card size="small" title="Usual organizers">
|
||||||
<BarList rows={topBuckets(countBy(submissions, (s) => s.owner_email), 6)} />
|
<BarList rows={topBuckets(countBy(submissions, (s) => s.owner_email), 6)} />
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { apiService } from "../../lib/services";
|
|||||||
import { useApiRequest } from "../../hooks/useApiRequest";
|
import { useApiRequest } from "../../hooks/useApiRequest";
|
||||||
import type { WeeklyCount } from "../../lib/types";
|
import type { WeeklyCount } from "../../lib/types";
|
||||||
import AsyncContent from "../utils/AsyncContent";
|
import AsyncContent from "../utils/AsyncContent";
|
||||||
|
import ItemRanking from "./ItemRanking";
|
||||||
import { BarList, ColumnChart } from "./charts";
|
import { BarList, ColumnChart } from "./charts";
|
||||||
import { CardGrid, StatRow } from "./StatCard";
|
import { CardGrid, StatRow } from "./StatCard";
|
||||||
|
|
||||||
@@ -59,9 +60,7 @@ export default function SiteStats() {
|
|||||||
<Card size="small" title="Orders per week">
|
<Card size="small" title="Orders per week">
|
||||||
<ColumnChart data={weekly(stats.orders.per_week)} labelEvery={5} />
|
<ColumnChart data={weekly(stats.orders.per_week)} labelEvery={5} />
|
||||||
</Card>
|
</Card>
|
||||||
<Card size="small" title="Most ordered items">
|
<ItemRanking title="Most ordered items" items={stats.items} limit={10} />
|
||||||
<BarList rows={stats.top_items.map((i) => ({ label: i.name, value: i.count }))} />
|
|
||||||
</Card>
|
|
||||||
<Card size="small" title="Top order managers">
|
<Card size="small" title="Top order managers">
|
||||||
<BarList rows={stats.top_managers.map((u) => ({ label: u.email, value: u.count }))} />
|
<BarList rows={stats.top_managers.map((u) => ({ label: u.email, value: u.count }))} />
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
Binary file not shown.
@@ -400,6 +400,8 @@ export type AdminOrder = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Statistics (see docs/statistics.md)
|
// Statistics (see docs/statistics.md)
|
||||||
|
export type ItemCount = { category: string; name: string; count: number };
|
||||||
|
|
||||||
export type StatsItem = { category: string; name: string };
|
export type StatsItem = { category: string; name: string };
|
||||||
|
|
||||||
export type StatsSubmission = {
|
export type StatsSubmission = {
|
||||||
@@ -446,6 +448,6 @@ export type SiteStats = {
|
|||||||
orders: { total: number; with_submissions: number; per_week: WeeklyCount[] };
|
orders: { total: number; with_submissions: number; per_week: WeeklyCount[] };
|
||||||
top_managers: { email: string; count: number }[];
|
top_managers: { email: string; count: number }[];
|
||||||
top_participants: { email: string; count: number }[];
|
top_participants: { email: string; count: number }[];
|
||||||
top_items: { name: string; count: number }[];
|
items: ItemCount[];
|
||||||
storage: Record<string, StorageUsage>;
|
storage: Record<string, StorageUsage>;
|
||||||
};
|
};
|
||||||
Reference in new issue
Block a user