Add cumulative time series with timeframe filter to all statistics pages
Build and Push Lunchtime Images (Kaniko) / build-and-push (push) Successful in 1m46s
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:
1 parent
d8b7871142
commit
c4aa7f00c2
8 files changed
+168
-1
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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.
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user