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/stats/managed` | any user (own orders only) | Order Management |
|
||||||
| `GET /api/admin/stats` | site admin | Site Overview |
|
| `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)
|
## 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)
|
||||||
|
|||||||
@@ -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]
|
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]:
|
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."""
|
"""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
|
total = files = orphan_files = orphan_bytes = 0
|
||||||
@@ -265,7 +275,13 @@ def get_site_stats(conn: sqlite3.Connection) -> dict[str, Any]:
|
|||||||
}.items()
|
}.items()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
submission_times = [row[0] for row in conn.execute("SELECT created_at FROM submissions")]
|
||||||
return {
|
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,
|
"users": users,
|
||||||
"registrations_per_week": _weekly(registrations),
|
"registrations_per_week": _weekly(registrations),
|
||||||
"contributors_per_week": _weekly(Counter({w: len(u) for w, u in contributors.items()})),
|
"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 type { ManagedStatsOrder } from "../../lib/types";
|
||||||
import AsyncContent from "../utils/AsyncContent";
|
import AsyncContent from "../utils/AsyncContent";
|
||||||
import ItemRanking from "./ItemRanking";
|
import ItemRanking from "./ItemRanking";
|
||||||
|
import TimeSeriesPanel from "./TimeSeriesPanel";
|
||||||
import { BarList, ColumnChart } from "./charts";
|
import { BarList, ColumnChart } from "./charts";
|
||||||
import { CardGrid, StatRow } from "./StatCard";
|
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>
|
<CardGrid>
|
||||||
<Card size="small" title="Orders created per month">
|
<Card size="small" title="Orders created per month">
|
||||||
<ColumnChart data={monthlyBuckets(orders.map((o) => o.created_at))} labelEvery={2} />
|
<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 type { MyStatsSubmission } from "../../lib/types";
|
||||||
import AsyncContent from "../utils/AsyncContent";
|
import AsyncContent from "../utils/AsyncContent";
|
||||||
import ItemRanking from "./ItemRanking";
|
import ItemRanking from "./ItemRanking";
|
||||||
|
import TimeSeriesPanel from "./TimeSeriesPanel";
|
||||||
import { BarList, ColumnChart } from "./charts";
|
import { BarList, ColumnChart } from "./charts";
|
||||||
import { CardGrid, StatRow } from "./StatCard";
|
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>
|
<CardGrid>
|
||||||
<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} />
|
||||||
|
|||||||
@@ -2,14 +2,18 @@ import React from "react";
|
|||||||
import { Card, Space, Table } from "antd";
|
import { Card, Space, Table } from "antd";
|
||||||
import { apiService } from "../../lib/services";
|
import { apiService } from "../../lib/services";
|
||||||
import { useApiRequest } from "../../hooks/useApiRequest";
|
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 AsyncContent from "../utils/AsyncContent";
|
||||||
import ItemRanking from "./ItemRanking";
|
import ItemRanking from "./ItemRanking";
|
||||||
|
import TimeSeriesPanel from "./TimeSeriesPanel";
|
||||||
import { BarList, ColumnChart } from "./charts";
|
import { BarList, ColumnChart } from "./charts";
|
||||||
import { CardGrid, StatRow } from "./StatCard";
|
import { CardGrid, StatRow } from "./StatCard";
|
||||||
|
|
||||||
const load = () => apiService.stats.getSite();
|
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 }));
|
const weekly = (rows: WeeklyCount[]) => rows.map((r) => ({ label: `Week of ${r.week}`, value: r.count }));
|
||||||
|
|
||||||
function formatBytes(bytes: number): string {
|
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>
|
<CardGrid>
|
||||||
<Card size="small" title="New registrations per week">
|
<Card size="small" title="New registrations per week">
|
||||||
<ColumnChart data={weekly(stats.registrations_per_week)} labelEvery={5} />
|
<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 })[];
|
submissions: (StatsSubmission & { email: string })[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type DailyCount = { date: string; count: number };
|
||||||
export type WeeklyCount = { week: 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 StorageUsage = { files: number; bytes: number; orphan_files: number; orphan_bytes: number };
|
||||||
|
|
||||||
export type SiteStats = {
|
export type SiteStats = {
|
||||||
|
daily: { registrations: DailyCount[]; orders: DailyCount[]; submissions: DailyCount[] };
|
||||||
users: {
|
users: {
|
||||||
total: number;
|
total: number;
|
||||||
confirmed: number;
|
confirmed: number;
|
||||||
|
|||||||
Reference in new issue
Block a user