From c4aa7f00c25e114a26cdeadd44dfc41a3e880470 Mon Sep 17 00:00:00 2001 From: Simon Gruber Date: Thu, 5 Nov 2026 17:24:43 +0100 Subject: [PATCH] Add cumulative time series with timeframe filter to all statistics pages Co-Authored-By: Claude Sonnet 5.5 --- docs/statistics.md | 4 + src/backend/app/services/stats_service.py | 16 +++ .../components/statistics/ManagerStats.tsx | 13 +++ .../components/statistics/MyOrdersStats.tsx | 12 ++ .../src/components/statistics/SiteStats.tsx | 14 ++- .../components/statistics/TimeSeriesPanel.tsx | 108 ++++++++++++++++++ src/frontend/src/lib/stats.ts | Bin 3425 -> 5580 bytes src/frontend/src/lib/types.ts | 2 + 8 files changed, 168 insertions(+), 1 deletion(-) create mode 100644 src/frontend/src/components/statistics/TimeSeriesPanel.tsx diff --git a/docs/statistics.md b/docs/statistics.md index 1003bb9..99fbf33 100644 --- a/docs/statistics.md +++ b/docs/statistics.md @@ -15,6 +15,10 @@ Statistics pages live under `/stats/*` (`src/frontend/src/views/StatisticsView.t | `GET /api/stats/managed` | any user (own orders only) | Order Management | | `GET /api/admin/stats` | site admin | Site Overview | +## Time series + +Every page has an "Over time" panel (`TimeSeriesPanel.tsx`): cumulative charts with a timeframe filter (1W, 1M, 3M, 1Y, YTD, All, custom range). Running totals start at 0 at the start of the timeframe. Site-wide daily counts come from `daily` in `/api/admin/stats`. + ## My Orders (orderees) - ✅ Participation over time (orders joined per month) and current streak (consecutive weeks) diff --git a/src/backend/app/services/stats_service.py b/src/backend/app/services/stats_service.py index 4aa515f..6ff958d 100644 --- a/src/backend/app/services/stats_service.py +++ b/src/backend/app/services/stats_service.py @@ -154,6 +154,16 @@ def _weekly(counter: Counter[str]) -> list[dict[str, Any]]: return [{"week": week, "count": counter.get(week, 0)} for week in weeks] +def _daily(timestamps: list[str | None]) -> list[dict[str, Any]]: + """Events per UTC day for the whole history, oldest first (the frontend cuts out the selected timeframe).""" + days: Counter[str] = Counter() + for timestamp in timestamps: + week = _week_start(timestamp) # validates the timestamp; the day itself is the first 10 chars + if week and timestamp: + days[timestamp[:10]] += 1 + return [{"date": day, "count": count} for day, count in sorted(days.items())] + + def _dir_usage(path: Any, known_tokens: set[str]) -> dict[str, Any]: """Size/file count of a data folder; entries (files named .*, or folders named ) of deleted orders are orphans.""" total = files = orphan_files = orphan_bytes = 0 @@ -265,7 +275,13 @@ def get_site_stats(conn: sqlite3.Connection) -> dict[str, Any]: }.items() } + submission_times = [row[0] for row in conn.execute("SELECT created_at FROM submissions")] return { + "daily": { + "registrations": _daily([p["created_at"] or p["updated_at"] for p in profiles]), + "orders": _daily([o["created_at"] for o in orders]), + "submissions": _daily(submission_times), + }, "users": users, "registrations_per_week": _weekly(registrations), "contributors_per_week": _weekly(Counter({w: len(u) for w, u in contributors.items()})), diff --git a/src/frontend/src/components/statistics/ManagerStats.tsx b/src/frontend/src/components/statistics/ManagerStats.tsx index eb504d0..02c8849 100644 --- a/src/frontend/src/components/statistics/ManagerStats.tsx +++ b/src/frontend/src/components/statistics/ManagerStats.tsx @@ -7,6 +7,7 @@ import { average, Bucket, countItems, formatMoney, monthlyBuckets, sum } from ". import type { ManagedStatsOrder } from "../../lib/types"; import AsyncContent from "../utils/AsyncContent"; import ItemRanking from "./ItemRanking"; +import TimeSeriesPanel from "./TimeSeriesPanel"; import { BarList, ColumnChart } from "./charts"; import { CardGrid, StatRow } from "./StatCard"; @@ -82,6 +83,18 @@ export default function ManagerStats() { ]} /> + ({ date: new Date(o.created_at), value: 1 })) }, + { title: "Submissions received (cumulative)", events: submissions.map((s) => ({ date: new Date(s.submitted_at), value: 1 })) }, + { + title: "Value of submissions (cumulative)", + events: submissions.map((s) => ({ date: new Date(s.submitted_at), value: s.total ?? 0 })), + format: formatMoney, + }, + ]} + /> + o.created_at))} labelEvery={2} /> diff --git a/src/frontend/src/components/statistics/MyOrdersStats.tsx b/src/frontend/src/components/statistics/MyOrdersStats.tsx index f7bb858..1163d8b 100644 --- a/src/frontend/src/components/statistics/MyOrdersStats.tsx +++ b/src/frontend/src/components/statistics/MyOrdersStats.tsx @@ -7,6 +7,7 @@ import { average, countBy, countItems, currentWeekStreak, formatMoney, hourBucke import type { MyStatsSubmission } from "../../lib/types"; import AsyncContent from "../utils/AsyncContent"; import ItemRanking from "./ItemRanking"; +import TimeSeriesPanel from "./TimeSeriesPanel"; import { BarList, ColumnChart } from "./charts"; import { CardGrid, StatRow } from "./StatCard"; @@ -56,6 +57,17 @@ export default function MyOrdersStats() { ]} /> + ({ date: new Date(s.submitted_at), value: 1 })) }, + { + title: "Money spent (cumulative)", + events: submissions.map((s) => ({ date: new Date(s.submitted_at), value: s.total ?? 0 })), + format: formatMoney, + }, + ]} + /> + diff --git a/src/frontend/src/components/statistics/SiteStats.tsx b/src/frontend/src/components/statistics/SiteStats.tsx index da82870..fdbcc69 100644 --- a/src/frontend/src/components/statistics/SiteStats.tsx +++ b/src/frontend/src/components/statistics/SiteStats.tsx @@ -2,14 +2,18 @@ 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 { parseDay } from "../../lib/stats"; +import type { DailyCount, WeeklyCount } from "../../lib/types"; import AsyncContent from "../utils/AsyncContent"; import ItemRanking from "./ItemRanking"; +import TimeSeriesPanel from "./TimeSeriesPanel"; import { BarList, ColumnChart } from "./charts"; import { CardGrid, StatRow } from "./StatCard"; const load = () => apiService.stats.getSite(); +const toEvent = (d: DailyCount) => ({ date: parseDay(d.date), value: d.count }); + const weekly = (rows: WeeklyCount[]) => rows.map((r) => ({ label: `Week of ${r.week}`, value: r.count })); function formatBytes(bytes: number): string { @@ -50,6 +54,14 @@ export default function SiteStats() { ]} /> + + diff --git a/src/frontend/src/components/statistics/TimeSeriesPanel.tsx b/src/frontend/src/components/statistics/TimeSeriesPanel.tsx new file mode 100644 index 0000000..617483f --- /dev/null +++ b/src/frontend/src/components/statistics/TimeSeriesPanel.tsx @@ -0,0 +1,108 @@ +import React, { useMemo, useState } from "react"; +import { Card, DatePicker, Segmented, Space, Typography, theme } from "antd"; +import { cumulativeDaily, resolveRange, SeriesPoint, TimedValue, Timeframe } from "../../lib/stats"; +import { CardGrid } from "./StatCard"; + +export type TimeMetric = { + title: string; + events: TimedValue[]; + format?: (value: number) => string; +}; + +const TIMEFRAMES: { label: string; value: Timeframe }[] = [ + { label: "1W", value: "1w" }, + { label: "1M", value: "1m" }, + { label: "3M", value: "3m" }, + { label: "1Y", value: "1y" }, + { label: "YTD", value: "ytd" }, + { label: "All", value: "all" }, + { label: "Custom", value: "custom" }, +]; + +const day = (d: Date) => d.toLocaleDateString(); + +/** Cumulative area chart; hovering shows the value for a day. */ +function CumulativeChart({ points, format }: { points: SeriesPoint[]; format: (value: number) => string }) { + const { token } = theme.useToken(); + const [hover, setHover] = useState(null); + const max = Math.max(1, ...points.map((p) => p.value)); + const x = (i: number) => (points.length > 1 ? (i / (points.length - 1)) * 100 : 0); + const y = (v: number) => 100 - (v / max) * 100; + const line = points.map((p, i) => `${x(i)},${y(p.value)}`).join(" "); + const shown = points[hover ?? points.length - 1]; + + return ( +
+ + {shown ? `${day(shown.date)}: ` : ""} + {format(shown?.value ?? 0)} + +
+
+ {format(max)} + {format(0)} +
+ setHover(null)} + onMouseMove={(e) => { + const box = e.currentTarget.getBoundingClientRect(); + setHover(Math.max(0, Math.min(points.length - 1, Math.round(((e.clientX - box.left) / box.width) * (points.length - 1))))); + }} + > + + + {hover !== null && points[hover] && ( + + )} + +
+
+ {points[0] && day(points[0].date)} + {points.length > 1 && day(points[points.length - 1].date)} +
+
+ ); +} + +/** One timeframe filter driving cumulative charts for several metrics. Running totals start at 0 at the beginning of the timeframe. */ +export default function TimeSeriesPanel({ title = "Over time", metrics }: { title?: string; metrics: TimeMetric[] }) { + const [timeframe, setTimeframe] = useState("3m"); + const [custom, setCustom] = useState<[Date, Date] | null>(null); + + const earliest = useMemo(() => { + const times = metrics.flatMap((m) => m.events.map((e) => e.date.getTime())); + return times.length ? new Date(Math.min(...times)) : null; + }, [metrics]); + const range = resolveRange(timeframe, custom, earliest); + + return ( + + {timeframe === "custom" && ( + setCustom(dates?.[0] && dates?.[1] ? [dates[0].toDate(), dates[1].toDate()] : null)} + /> + )} + setTimeframe(value as Timeframe)} /> + + } + > + + {metrics.map((metric) => ( +
+ {metric.title} + +
+ ))} +
+
+ ); +} diff --git a/src/frontend/src/lib/stats.ts b/src/frontend/src/lib/stats.ts index 29acc1b1172fe991f3839e98a423242494a8c490..84d70e0e9a17dfa7c8dc92e81155b7259a8b0bde 100644 GIT binary patch delta 2070 zcmaJ?;cDYX5Qfq}HSG(u6b92DQX>0eQYhGU!+|N3lDnfVg~K&uBdudmX=Svlq-xIS zA?`)`Abp9xNxzwuY}q|Gglu+aXLjbB`9A&g`QMj+sn42${$pE|j$FOf^h=uSb*XYq za~e*bhV%|gF3YuFCbjHpm&mHJOxC!vuE>Y8|3piERawR1o+(jIPicX_GkWCHDVa*; z_qwFrvE$FWOtpPeq{f~1>>LMiVXPzTROw!?7pjI!kobo}nt%5``Q+KaC3-~KBrDWWTPoJ1$aQed zXeda?HA@O`ZZv6CW~jrFe$>W|>EcBP&$_8h?9#$escn%x>YtQ((7`_5^w5cjJhoF> za(a;fxI$x0m1W4NeZ$w=9)~BFp9e%9C08JO0Ms1d3XFb|pg{s}2x7;U2 zJEEKc>+Kne&dONBooF9V9%G!pN2+fTDF|sq60(VCZ*98(lMmlB`G&wEm|xOFGPF;r zi#K$DDMyJbo?>hv{Nn|3jE>nYK2I>S!s_a zt8hir1LE2ELNbi<3cdvA27AznB%?)tb3yTQ(r^tI8n6{Xlh zNO-zaG+e@Yz9P-anAALA;+)Euxo*>dolHtgHZ`%PJ8`(9KvrS!Rd~eMj<9i+SDA89 zg@sD9I`CmUVZGQbSJ6oVeddCjkhd>7eOs<%c0D^$!2QNjm2s7E@&-a;gKbzn;Q%^UHX{Ps2v_{6KNPxP`jtZ$aGFi zVNIqXa02F>u9!xWDxWg`^L{f0&u}J&Z|5wa{{Q(wCg5`{5)mcfXHTGU;P