From d14c609576b28891284c85d6c296068ba37b0413 Mon Sep 17 00:00:00 2001 From: Richard Zink Date: Mon, 9 Nov 2026 06:22:36 +0100 Subject: [PATCH] Stats: activity heatmap as a rolling window of the last 14 days One row per calendar day (today at the bottom) instead of weekdays aggregated over the selected range. Co-Authored-By: Claude Opus 5.5 --- server/src/store.rs | 12 +++++++----- web/src/components/Charts.tsx | 19 +++++++++---------- web/src/components/Stats.tsx | 34 ++++++++++++++++++++++++++++------ web/src/i18n/de.ts | 2 +- web/src/i18n/en.ts | 2 +- 5 files changed, 46 insertions(+), 23 deletions(-) diff --git a/server/src/store.rs b/server/src/store.rs index 3d90731..4865a44 100644 --- a/server/src/store.rs +++ b/server/src/store.rs @@ -433,7 +433,7 @@ impl Store { |r| agg_row(r, 0), )?; - let grouped = |key_sql: &str, key_name: &str| -> Result> { + let grouped_in = |from: i64, key_sql: &str, key_name: &str| -> Result> { let mut stmt = conn.prepare(&format!( "SELECT {key_sql}, {AGG} FROM usage WHERE ts >= ?1 AND ts < ?2 AND ?3 = ?3 AND (?4 IS NULL OR user_id = ?4) GROUP BY 1 ORDER BY 1" @@ -451,6 +451,7 @@ impl Store { .collect::>>()?; Ok(rows) }; + let grouped = |key_sql: &str, key_name: &str| grouped_in(from, key_sql, key_name); let day_sql = "strftime('%Y-%m-%d', (ts + ?3) / 1000, 'unixepoch')"; let by_day = grouped(day_sql, "day")?; @@ -461,10 +462,11 @@ impl Store { "COALESCE((SELECT username FROM users WHERE users.id = usage.user_id), '(none)')", "user", )?; - // Heatmap: weekday (0 = Sunday) x hour of day. - let heatmap = grouped( - "CAST(strftime('%w', (ts + ?3) / 1000, 'unixepoch') AS INTEGER) * 24 + \ - CAST(strftime('%H', (ts + ?3) / 1000, 'unixepoch') AS INTEGER)", + // Heatmap: rolling window of the last 14 days (local date + hour), + // independent of the selected range. + let heatmap = grouped_in( + to - 15 * 86_400_000, + "strftime('%Y-%m-%d %H', (ts + ?3) / 1000, 'unixepoch')", "slot", )?; let by_day_model = { diff --git a/web/src/components/Charts.tsx b/web/src/components/Charts.tsx index a3e58ef..5c92b91 100644 --- a/web/src/components/Charts.tsx +++ b/web/src/components/Charts.tsx @@ -173,33 +173,32 @@ export function StackedBars({ ); } -/** Weekday × hour heatmap with a 7-step sequential ramp. cells[weekday*24+hour]. */ +/** Day × hour heatmap with a 7-step sequential ramp. cells[row*24+hour]; rows top to bottom. */ export function Heatmap({ cells, - weekdays, + rows, format, - firstDay = 1, }: { cells: number[]; - weekdays: string[]; + /** Short label (axis) and long label (tooltip) per row. */ + rows: { label: string; title: string }[]; format: (v: number) => string; - firstDay?: number; }) { const t = useT(); const [ref, width] = useWidth(); const [tip, setTip] = useState(); - const left = 40; + const left = 64; const top = 18; // Cells stretch with the width but stay at most 26 px tall. const cellW = Math.max(Math.floor((width - left) / 24), 8); const cell = Math.min(cellW, 26); const max = Math.max(...cells, 0); - const order = Array.from({ length: 7 }, (_, i) => (firstDay + i) % 7); + const order = rows.map((_, i) => i); const bin = (v: number) => (v <= 0 || max === 0 ? 0 : Math.min(6, 1 + Math.floor((v / max) * 5.999))); return (
setTip(undefined)}> - + {Array.from({ length: 24 }, (_, h) => h) .filter((h) => h % (cellW >= 22 ? 1 : cellW >= 12 ? 3 : 6) === 0) @@ -210,7 +209,7 @@ export function Heatmap({ ))} {order.map((d, row) => ( - {weekdays[d]} + {rows[d].label} ))} @@ -234,7 +233,7 @@ export function Heatmap({ content: ( <> - {weekdays[d]} {String(h).padStart(2, "0")}:00–{String(h).padStart(2, "0")}:59 + {rows[d].title} {String(h).padStart(2, "0")}:00–{String(h).padStart(2, "0")}:59
{format(v)}
diff --git a/web/src/components/Stats.tsx b/web/src/components/Stats.tsx index 200ce7b..18a3dee 100644 --- a/web/src/components/Stats.tsx +++ b/web/src/components/Stats.tsx @@ -1,7 +1,7 @@ import { Icon } from "./Icon"; import { useMemo, useState } from "react"; import { useFetch } from "../hooks"; -import { fmtCompact, fmtCost, fmtDate, fmtNumber, fmtPercent, getLang, useT, weekdayNames, type Key } from "../i18n"; +import { fmtCompact, fmtCost, fmtDate, fmtNumber, fmtPercent, getLang, useT, type Key } from "../i18n"; import { navigate } from "../router"; import { useStore } from "../store"; import type { User } from "../types"; @@ -19,7 +19,8 @@ interface StatsData { by_provider: Row<"provider">[]; by_agent: Row<"agent">[]; by_user: Row<"user">[]; - heatmap: (Aggregate & { slot: number })[]; + /** Last 14 days: slot = "YYYY-MM-DD HH" (local time). */ + heatmap: (Aggregate & { slot: string })[]; top_sessions: (Aggregate & { session_id: string; title: string })[]; currency: string; from: number; @@ -106,6 +107,8 @@ function AggTable({ rows, keyName, label, currency, link }: { rows: (Aggregate & ); } +const HEAT_DAYS = 14; + export function Stats({ onMenu }: { onMenu: () => void }) { const t = useT(); const [range, setRange] = useState("30"); @@ -146,12 +149,31 @@ export function Stats({ onMenu }: { onMenu: () => void }) { const d = data.by_day.find((x) => x.day === c); return d ? { input: d.input_tokens + d.cache_write_tokens, cache: d.cache_read_tokens, output: d.output_tokens } : {}; }); - const cells = Array(168).fill(0); + // Rolling window: the last 14 days, today at the bottom. + const iso = (d: Date) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; + const heatDays = Array.from({ length: HEAT_DAYS }, (_, i) => { + const d = new Date(); + d.setHours(12, 0, 0, 0); + d.setDate(d.getDate() - (HEAT_DAYS - 1 - i)); + return iso(d); + }); + const rowOf = new Map(heatDays.map((d, i) => [d, i])); + const cells = Array(HEAT_DAYS * 24).fill(0); for (const h of data.heatmap) { - cells[h.slot] = + const [day, hour] = h.slot.split(" "); + const row = rowOf.get(day); + if (row === undefined) continue; + cells[row * 24 + Number(hour)] = heat === "requests" ? h.requests : heat === "cost" ? h.cost : h.input_tokens + h.cache_read_tokens + h.cache_write_tokens + h.output_tokens; } - return { cats, modelSeries, costByDay, tokenSeries, tokensByDay, cells }; + const wd = new Intl.DateTimeFormat(getLang(), { weekday: "short" }); + const dm = new Intl.DateTimeFormat(getLang(), { day: "numeric", month: "numeric" }); + const full = new Intl.DateTimeFormat(getLang(), { weekday: "short", day: "numeric", month: "short" }); + const heatRows = heatDays.map((d) => { + const date = new Date(`${d}T12:00:00`); + return { label: `${wd.format(date)} ${dm.format(date)}`, title: full.format(date) }; + }); + return { cats, modelSeries, costByDay, tokenSeries, tokensByDay, cells, heatRows }; }, [data, heat, t]); const dayLabel = (d: string) => @@ -242,7 +264,7 @@ export function Stats({ onMenu }: { onMenu: () => void }) { - +
)} diff --git a/web/src/i18n/de.ts b/web/src/i18n/de.ts index d5fa4f1..bb20e4b 100644 --- a/web/src/i18n/de.ts +++ b/web/src/i18n/de.ts @@ -715,7 +715,7 @@ export const de: Messages = { "stats.costPerDay": "Kosten pro Tag nach Modell", "stats.tokensPerDay": "Tokens pro Tag", "stats.heatEmpty": "keine Aktivität", - "stats.heatmap": "Aktivität nach Wochentag und Uhrzeit", + "stats.heatmap": "Aktivität der letzten 14 Tage nach Uhrzeit", "stats.byModel": "Nach Modell", "stats.byProvider": "Nach Provider", "stats.byAgent": "Nach Modus", diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index 45468c9..9e96362 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -714,7 +714,7 @@ export const en = { "stats.costPerDay": "Cost per day by model", "stats.tokensPerDay": "Tokens per day", "stats.heatEmpty": "no activity", - "stats.heatmap": "Activity by weekday and hour", + "stats.heatmap": "Activity of the last 14 days by hour", "stats.byModel": "By model", "stats.byProvider": "By provider", "stats.byAgent": "By mode",