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 <noreply@anthropic.com>
This commit is contained in:
1 parent
01c1484033
commit
d14c609576
5 files changed
+46
-23
No files matched your search
+7
-5
@@ -433,7 +433,7 @@ impl Store {
|
|||||||
|r| agg_row(r, 0),
|
|r| agg_row(r, 0),
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
let grouped = |key_sql: &str, key_name: &str| -> Result<Vec<Value>> {
|
let grouped_in = |from: i64, key_sql: &str, key_name: &str| -> Result<Vec<Value>> {
|
||||||
let mut stmt = conn.prepare(&format!(
|
let mut stmt = conn.prepare(&format!(
|
||||||
"SELECT {key_sql}, {AGG} FROM usage WHERE ts >= ?1 AND ts < ?2 AND ?3 = ?3
|
"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"
|
AND (?4 IS NULL OR user_id = ?4) GROUP BY 1 ORDER BY 1"
|
||||||
@@ -451,6 +451,7 @@ impl Store {
|
|||||||
.collect::<rusqlite::Result<Vec<_>>>()?;
|
.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||||
Ok(rows)
|
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 day_sql = "strftime('%Y-%m-%d', (ts + ?3) / 1000, 'unixepoch')";
|
||||||
let by_day = grouped(day_sql, "day")?;
|
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)')",
|
"COALESCE((SELECT username FROM users WHERE users.id = usage.user_id), '(none)')",
|
||||||
"user",
|
"user",
|
||||||
)?;
|
)?;
|
||||||
// Heatmap: weekday (0 = Sunday) x hour of day.
|
// Heatmap: rolling window of the last 14 days (local date + hour),
|
||||||
let heatmap = grouped(
|
// independent of the selected range.
|
||||||
"CAST(strftime('%w', (ts + ?3) / 1000, 'unixepoch') AS INTEGER) * 24 + \
|
let heatmap = grouped_in(
|
||||||
CAST(strftime('%H', (ts + ?3) / 1000, 'unixepoch') AS INTEGER)",
|
to - 15 * 86_400_000,
|
||||||
|
"strftime('%Y-%m-%d %H', (ts + ?3) / 1000, 'unixepoch')",
|
||||||
"slot",
|
"slot",
|
||||||
)?;
|
)?;
|
||||||
let by_day_model = {
|
let by_day_model = {
|
||||||
|
|||||||
@@ -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({
|
export function Heatmap({
|
||||||
cells,
|
cells,
|
||||||
weekdays,
|
rows,
|
||||||
format,
|
format,
|
||||||
firstDay = 1,
|
|
||||||
}: {
|
}: {
|
||||||
cells: number[];
|
cells: number[];
|
||||||
weekdays: string[];
|
/** Short label (axis) and long label (tooltip) per row. */
|
||||||
|
rows: { label: string; title: string }[];
|
||||||
format: (v: number) => string;
|
format: (v: number) => string;
|
||||||
firstDay?: number;
|
|
||||||
}) {
|
}) {
|
||||||
const t = useT();
|
const t = useT();
|
||||||
const [ref, width] = useWidth<HTMLDivElement>();
|
const [ref, width] = useWidth<HTMLDivElement>();
|
||||||
const [tip, setTip] = useState<Tip>();
|
const [tip, setTip] = useState<Tip>();
|
||||||
const left = 40;
|
const left = 64;
|
||||||
const top = 18;
|
const top = 18;
|
||||||
// Cells stretch with the width but stay at most 26 px tall.
|
// Cells stretch with the width but stay at most 26 px tall.
|
||||||
const cellW = Math.max(Math.floor((width - left) / 24), 8);
|
const cellW = Math.max(Math.floor((width - left) / 24), 8);
|
||||||
const cell = Math.min(cellW, 26);
|
const cell = Math.min(cellW, 26);
|
||||||
const max = Math.max(...cells, 0);
|
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)));
|
const bin = (v: number) => (v <= 0 || max === 0 ? 0 : Math.min(6, 1 + Math.floor((v / max) * 5.999)));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="chart" ref={ref} onMouseLeave={() => setTip(undefined)}>
|
<div className="chart" ref={ref} onMouseLeave={() => setTip(undefined)}>
|
||||||
<svg height={top + cell * 7 + 4} role="img" aria-label={t("stats.heatmap")}>
|
<svg height={top + cell * rows.length + 4} role="img" aria-label={t("stats.heatmap")}>
|
||||||
<g className="axis">
|
<g className="axis">
|
||||||
{Array.from({ length: 24 }, (_, h) => h)
|
{Array.from({ length: 24 }, (_, h) => h)
|
||||||
.filter((h) => h % (cellW >= 22 ? 1 : cellW >= 12 ? 3 : 6) === 0)
|
.filter((h) => h % (cellW >= 22 ? 1 : cellW >= 12 ? 3 : 6) === 0)
|
||||||
@@ -210,7 +209,7 @@ export function Heatmap({
|
|||||||
))}
|
))}
|
||||||
{order.map((d, row) => (
|
{order.map((d, row) => (
|
||||||
<text key={d} x={left - 6} y={top + row * cell + cell / 2 + 4} textAnchor="end">
|
<text key={d} x={left - 6} y={top + row * cell + cell / 2 + 4} textAnchor="end">
|
||||||
{weekdays[d]}
|
{rows[d].label}
|
||||||
</text>
|
</text>
|
||||||
))}
|
))}
|
||||||
</g>
|
</g>
|
||||||
@@ -234,7 +233,7 @@ export function Heatmap({
|
|||||||
content: (
|
content: (
|
||||||
<>
|
<>
|
||||||
<b>
|
<b>
|
||||||
{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
|
||||||
</b>
|
</b>
|
||||||
<div>{format(v)}</div>
|
<div>{format(v)}</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Icon } from "./Icon";
|
import { Icon } from "./Icon";
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { useFetch } from "../hooks";
|
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 { navigate } from "../router";
|
||||||
import { useStore } from "../store";
|
import { useStore } from "../store";
|
||||||
import type { User } from "../types";
|
import type { User } from "../types";
|
||||||
@@ -19,7 +19,8 @@ interface StatsData {
|
|||||||
by_provider: Row<"provider">[];
|
by_provider: Row<"provider">[];
|
||||||
by_agent: Row<"agent">[];
|
by_agent: Row<"agent">[];
|
||||||
by_user: Row<"user">[];
|
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 })[];
|
top_sessions: (Aggregate & { session_id: string; title: string })[];
|
||||||
currency: string;
|
currency: string;
|
||||||
from: number;
|
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 }) {
|
export function Stats({ onMenu }: { onMenu: () => void }) {
|
||||||
const t = useT();
|
const t = useT();
|
||||||
const [range, setRange] = useState("30");
|
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);
|
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 } : {};
|
return d ? { input: d.input_tokens + d.cache_write_tokens, cache: d.cache_read_tokens, output: d.output_tokens } : {};
|
||||||
});
|
});
|
||||||
const cells = Array<number>(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<number>(HEAT_DAYS * 24).fill(0);
|
||||||
for (const h of data.heatmap) {
|
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;
|
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]);
|
}, [data, heat, t]);
|
||||||
|
|
||||||
const dayLabel = (d: string) =>
|
const dayLabel = (d: string) =>
|
||||||
@@ -242,7 +264,7 @@ export function Stats({ onMenu }: { onMenu: () => void }) {
|
|||||||
<option value="cost">{t("stats.cost")}</option>
|
<option value="cost">{t("stats.cost")}</option>
|
||||||
</select>
|
</select>
|
||||||
</h3>
|
</h3>
|
||||||
<Heatmap cells={view.cells} weekdays={weekdayNames()} format={heatFormat} />
|
<Heatmap cells={view.cells} rows={view.heatRows} format={heatFormat} />
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
+1
-1
@@ -715,7 +715,7 @@ export const de: Messages = {
|
|||||||
"stats.costPerDay": "Kosten pro Tag nach Modell",
|
"stats.costPerDay": "Kosten pro Tag nach Modell",
|
||||||
"stats.tokensPerDay": "Tokens pro Tag",
|
"stats.tokensPerDay": "Tokens pro Tag",
|
||||||
"stats.heatEmpty": "keine Aktivität",
|
"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.byModel": "Nach Modell",
|
||||||
"stats.byProvider": "Nach Provider",
|
"stats.byProvider": "Nach Provider",
|
||||||
"stats.byAgent": "Nach Modus",
|
"stats.byAgent": "Nach Modus",
|
||||||
|
|||||||
+1
-1
@@ -714,7 +714,7 @@ export const en = {
|
|||||||
"stats.costPerDay": "Cost per day by model",
|
"stats.costPerDay": "Cost per day by model",
|
||||||
"stats.tokensPerDay": "Tokens per day",
|
"stats.tokensPerDay": "Tokens per day",
|
||||||
"stats.heatEmpty": "no activity",
|
"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.byModel": "By model",
|
||||||
"stats.byProvider": "By provider",
|
"stats.byProvider": "By provider",
|
||||||
"stats.byAgent": "By mode",
|
"stats.byAgent": "By mode",
|
||||||
|
|||||||
Reference in new issue
Block a user