Add cumulative time series with timeframe filter to all statistics pages
Build and Push Lunchtime Images (Kaniko) / build-and-push (push) Successful in 1m46s

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:24:43 +01:00
1 parent d8b7871142
commit c4aa7f00c2
8 files changed
+168 -1

No files matched your search

+4
View File
@@ -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)
+16
View File
@@ -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 <token>.*, or folders named <token>) 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()})),
@@ -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() {
]}
/>
<TimeSeriesPanel
metrics={[
{ title: "Orders created (cumulative)", events: orders.map((o) => ({ 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,
},
]}
/>
<CardGrid>
<Card size="small" title="Orders created per month">
<ColumnChart data={monthlyBuckets(orders.map((o) => o.created_at))} labelEvery={2} />
@@ -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() {
]}
/>
<TimeSeriesPanel
metrics={[
{ title: "Orders joined (cumulative)", events: submissions.map((s) => ({ 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,
},
]}
/>
<CardGrid>
<Card size="small" title="Orders per month">
<ColumnChart data={monthlyBuckets(times)} labelEvery={2} />
@@ -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() {
]}
/>
<TimeSeriesPanel
metrics={[
{ title: "Registrations (cumulative)", events: stats.daily.registrations.map(toEvent) },
{ title: "Orders created (cumulative)", events: stats.daily.orders.map(toEvent) },
{ title: "Submissions (cumulative)", events: stats.daily.submissions.map(toEvent) },
]}
/>
<CardGrid>
<Card size="small" title="New registrations per week">
<ColumnChart data={weekly(stats.registrations_per_week)} labelEvery={5} />
@@ -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<number | null>(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 (
<div>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{shown ? `${day(shown.date)}: ` : ""}
<Typography.Text strong>{format(shown?.value ?? 0)}</Typography.Text>
</Typography.Text>
<div style={{ display: "flex", gap: 6 }}>
<div style={{ display: "flex", flexDirection: "column", justifyContent: "space-between", fontSize: 11, color: token.colorTextSecondary }}>
<span>{format(max)}</span>
<span>{format(0)}</span>
</div>
<svg
viewBox="0 0 100 100"
preserveAspectRatio="none"
style={{ flex: 1, height: 120, display: "block", overflow: "visible" }}
onMouseLeave={() => 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)))));
}}
>
<polygon points={`0,100 ${line} 100,100`} fill={token.colorPrimary} fillOpacity={0.15} />
<polyline points={line} fill="none" stroke={token.colorPrimary} strokeWidth={2} vectorEffect="non-scaling-stroke" />
{hover !== null && points[hover] && (
<line x1={x(hover)} x2={x(hover)} y1={0} y2={100} stroke={token.colorTextSecondary} strokeDasharray="3" vectorEffect="non-scaling-stroke" />
)}
</svg>
</div>
<div style={{ display: "flex", justifyContent: "space-between", fontSize: 11, color: token.colorTextSecondary, marginLeft: 36 }}>
<span>{points[0] && day(points[0].date)}</span>
<span>{points.length > 1 && day(points[points.length - 1].date)}</span>
</div>
</div>
);
}
/** 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<Timeframe>("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 (
<Card
size="small"
title={title}
extra={
<Space wrap>
{timeframe === "custom" && (
<DatePicker.RangePicker
size="small"
allowClear={false}
onChange={(dates) => setCustom(dates?.[0] && dates?.[1] ? [dates[0].toDate(), dates[1].toDate()] : null)}
/>
)}
<Segmented size="small" options={TIMEFRAMES} value={timeframe} onChange={(value) => setTimeframe(value as Timeframe)} />
</Space>
}
>
<CardGrid>
{metrics.map((metric) => (
<div key={metric.title}>
<Typography.Text strong>{metric.title}</Typography.Text>
<CumulativeChart points={cumulativeDaily(metric.events, range)} format={metric.format ?? String} />
</div>
))}
</CardGrid>
</Card>
);
}
Binary file not shown.
+2
View File
@@ -429,10 +429,12 @@ export type ManagedStatsOrder = {
submissions: (StatsSubmission & { email: string })[];
};
export type DailyCount = { date: string; count: number };
export type WeeklyCount = { week: string; count: number };
export type StorageUsage = { files: number; bytes: number; orphan_files: number; orphan_bytes: number };
export type SiteStats = {
daily: { registrations: DailyCount[]; orders: DailyCount[]; submissions: DailyCount[] };
users: {
total: number;
confirmed: number;