Files
multica/packages/views/runtimes/utils.ts
YYClaw 614dfae884 MUL-2488 feat(timezone): Scheduling / Viewing two-layer timezone architecture (#2968)
* docs(timezone): add scheduling/viewing timezone architecture RFC

* feat(db): replace daily rollups with task_usage_hourly, add user.timezone

Migrations 100-104: add "user".timezone (Viewing tz), build the UTC
hourly task_usage_hourly rollup with its pipeline, drop the legacy
task_usage_daily / task_usage_dashboard_daily pipelines, and drop the
agent_runtime.timezone column. Report queries now slice day boundaries
at read time by the caller-supplied @tz instead of materialising in a
fixed tz. Regenerate sqlc.

* feat(server): add task_usage_hourly backfill command

Replace the two legacy backfill commands (daily / dashboard_daily) with
a single backfill_task_usage_hourly that loads historical task_usage
into the new UTC hourly rollup, sliced per workspace.

* refactor(server): resolve viewing timezone in report handlers

Report handlers resolve the Viewing tz per request (?tz query param,
then user.timezone, then UTC) and pass it to the hourly-rollup queries.
Drop the UseDailyRollup feature flags and the old raw-scan/daily-rollup
dual paths, remove the /api/usage endpoints, and stop the daemon from
reporting and the runtime handler from accepting host timezone.

* refactor(core): switch report queries to viewing timezone

API client and dashboard/runtime queries send ?tz with each report
request, the user schema/types carry the new timezone field, and the
runtime timezone field/mutation is removed.

* feat(views): add viewing timezone preference and UI

Add the useViewingTimezone hook and a Timezone setting in Preferences;
report charts and the dashboard week boundary follow the viewer tz.
Remove the runtime detail timezone editor and its locale strings.

* fix(test): update fixtures and stabilize tests for timezone refactor

The timezone architecture refactor changed several types without
updating dependent test code:

- RuntimeDevice no longer has a timezone field — drop it from the
  create-agent-dialog runtime fixture.
- User now requires a timezone field — add it to the apps/web mockUser
  fixture.
- The PreferencesTab timezone tests asserted on the async save handler
  (PATCH then store update) with a bare expect, racing the mutation's
  settle callback, and timed out querying the Select's ~600-option IANA
  list on a loaded CI runner. Wrap the assertions in waitFor and extend
  the timeout for those three tests.

* docs(timezone): document self-host migration order and trigger invariant

Add a SELF-HOST UPGRADE ORDER runbook to the backfill command's package
comment: applying migrations 100-104 in a single migrate-up drops the
legacy daily rollups before the hourly backfill runs, leaving dashboards
empty until cron catches up.

Add an INVARIANT comment on trg_atq_dirty_hourly noting that agent_id
must be added to the trigger's OF list if it ever becomes mutable,
otherwise dirty buckets for the old agent_id are silently missed.

* style(runtimes): drop trailing blank line in runtime-detail
2026-05-21 15:33:47 +08:00

767 lines
28 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type {
RuntimeUsage,
RuntimeUsageByAgent,
} from "@multica/core/types";
import { getCustomPricing } from "@multica/core/runtimes/custom-pricing-store";
// ---------------------------------------------------------------------------
// Formatting helpers
// ---------------------------------------------------------------------------
// Compound-unit relative timestamp ("2m 14s ago", "1d 4h ago", "6d 19h ago")
// — gives the user enough precision to tell "just lost" from "long lost"
// at a glance without forcing them to mouse-over for a full timestamp.
export function formatLastSeen(lastSeenAt: string | null): string {
if (!lastSeenAt) return "Never";
const diffMs = Date.now() - new Date(lastSeenAt).getTime();
if (diffMs < 5_000) return "Just now";
const seconds = Math.floor(diffMs / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
if (minutes < 1) return `${seconds}s ago`;
if (hours < 1) {
const s = seconds % 60;
return s > 0 ? `${minutes}m ${s}s ago` : `${minutes}m ago`;
}
if (days < 1) {
const m = minutes % 60;
return m > 0 ? `${hours}h ${m}m ago` : `${hours}h ago`;
}
const h = hours % 24;
return h > 0 ? `${days}d ${h}h ago` : `${days}d ago`;
}
// Turns the back-end's `device_info` string ("MacBook-Pro · darwin-amd64",
// "some-host · linux-amd64") into something humans recognise. We don't have
// hardware model or geo data on the wire today, so we settle for an OS-aware
// rewrite of the GOOS/GOARCH suffix while preserving the hostname.
export function formatDeviceInfo(raw: string | null): string | null {
if (!raw) return null;
const trimmed = raw.trim();
if (!trimmed) return null;
return trimmed
.split(" · ")
.map((part) => prettifyOsArch(part))
.join(" · ");
}
function prettifyOsArch(part: string): string {
const lower = part.toLowerCase();
// Pattern: <os>-<arch>; e.g. darwin-amd64, linux-arm64, windows-amd64.
const match = lower.match(/^(darwin|linux|windows|freebsd|openbsd|netbsd)-(amd64|arm64|386|arm)$/);
if (!match) return part;
const os = match[1] ?? "";
const arch = match[2] ?? "";
const osLabel = OS_LABEL[os] ?? os;
const archLabel = ARCH_LABEL[arch] ?? arch;
return `${osLabel} (${archLabel})`;
}
const OS_LABEL: Record<string, string> = {
darwin: "macOS",
linux: "Linux",
windows: "Windows",
freebsd: "FreeBSD",
openbsd: "OpenBSD",
netbsd: "NetBSD",
};
const ARCH_LABEL: Record<string, string> = {
amd64: "x86_64",
arm64: "arm64",
"386": "x86",
arm: "arm",
};
// Strip leading "v" from version strings — GitHub releases ship `v0.2.17`,
// daemon metadata reports `0.2.15`; normalising lets us compare both.
function stripVersionPrefix(v: string): string {
return v.replace(/^v/, "");
}
// True iff `latest` is strictly newer than `current` by dotted-numeric
// comparison. Non-numeric / missing segments compare as 0 ("0.2" < "0.2.1").
// Used by the runtime-list CLI column to decide whether to surface the ↑
// marker; same logic also lives inline in update-section.tsx for now.
export function isVersionNewer(latest: string, current: string): boolean {
const l = stripVersionPrefix(latest).split(".").map(Number);
const c = stripVersionPrefix(current).split(".").map(Number);
for (let i = 0; i < Math.max(l.length, c.length); i++) {
const lv = l[i] ?? 0;
const cv = c[i] ?? 0;
if (lv > cv) return true;
if (lv < cv) return false;
}
return false;
}
export function formatTokens(n: number): string {
if (n >= 1_000_000) {
const m = n / 1_000_000;
return m % 1 < 0.05 ? `${Math.round(m)}M` : `${m.toFixed(1)}M`;
}
if (n >= 1_000) {
const k = n / 1_000;
return k % 1 < 0.05 ? `${Math.round(k)}K` : `${k.toFixed(1)}K`;
}
return n.toLocaleString();
}
// ---------------------------------------------------------------------------
// Cost estimation
// ---------------------------------------------------------------------------
// Pricing per million tokens (USD). Anthropic figures sourced from
// https://platform.claude.com/docs/en/about-claude/pricing; OpenAI figures
// from https://openai.com/api/pricing — keep in sync when providers release
// new models or adjust prices.
//
// Anthropic's cacheWrite reflects the 5-minute cache TTL (1.25× input); the
// daemon reports cache_creation_input_tokens without TTL metadata, so 5m is
// the safest / cheapest assumption (matches the API default). OpenAI does
// not bill cache writes separately (cached input is just discounted on
// subsequent reads), so cacheWrite mirrors input there.
//
// The resolver matches exact keys after stripping a trailing date snapshot
// (see `resolvePricing` below). It deliberately does NOT do startsWith
// fallbacks: every catalog SKU needs its own row. That keeps unfamiliar
// variants (`gpt-5.5-mini`, hypothetical `gpt-5.4-foo`) from silently
// inheriting the price of a near-named relative; they surface in the
// unmapped diagnostic instead. Mirror new entries in
// `server/pkg/agent/models.go` so the catalog and pricing stay in sync.
const MODEL_PRICING: Record<
string,
{ input: number; output: number; cacheRead: number; cacheWrite: number }
> = {
// -- Anthropic: current generation (4.5+ — Opus dropped from 15/75 to 5/25 here) --
"claude-haiku-4-5": { input: 1, output: 5, cacheRead: 0.10, cacheWrite: 1.25 },
"claude-sonnet-4-5": { input: 3, output: 15, cacheRead: 0.30, cacheWrite: 3.75 },
"claude-sonnet-4-6": { input: 3, output: 15, cacheRead: 0.30, cacheWrite: 3.75 },
"claude-opus-4-5": { input: 5, output: 25, cacheRead: 0.50, cacheWrite: 6.25 },
"claude-opus-4-6": { input: 5, output: 25, cacheRead: 0.50, cacheWrite: 6.25 },
"claude-opus-4-7": { input: 5, output: 25, cacheRead: 0.50, cacheWrite: 6.25 },
// -- Anthropic: pre-4.5 Opus (legacy, still served at original price tier) --
"claude-opus-4-1": { input: 15, output: 75, cacheRead: 1.50, cacheWrite: 18.75 },
"claude-opus-4": { input: 15, output: 75, cacheRead: 1.50, cacheWrite: 18.75 },
// -- Anthropic: Sonnet 4.0 (deprecated; same price as the 4.x family) --
"claude-sonnet-4": { input: 3, output: 15, cacheRead: 0.30, cacheWrite: 3.75 },
// -- Anthropic: older Haiku tier (defensive entry for the rare runtime still on it) --
"claude-haiku-3-5": { input: 0.80, output: 4, cacheRead: 0.08, cacheWrite: 1.00 },
// -- OpenAI: dotted-minor Codex catalog SKUs. Each generation is priced
// independently — no fallback to `gpt-5`. Entries track
// `server/pkg/agent/models.go` (Codex provider list).
"gpt-5.5": { input: 5, output: 30, cacheRead: 0.50, cacheWrite: 5 },
"gpt-5.4-mini": { input: 0.75, output: 4.50, cacheRead: 0.075, cacheWrite: 0.75 },
"gpt-5.4": { input: 2.50, output: 15, cacheRead: 0.25, cacheWrite: 2.50 },
"gpt-5.3-codex": { input: 1.75, output: 14, cacheRead: 0.175, cacheWrite: 1.75 },
// -- OpenAI: GPT-5 family (Codex CLI's default is gpt-5-codex; -codex/-mini/-nano variants priced per OpenAI tiers) --
"gpt-5-codex": { input: 1.25, output: 10, cacheRead: 0.125, cacheWrite: 1.25 },
"gpt-5-mini": { input: 0.25, output: 2, cacheRead: 0.025, cacheWrite: 0.25 },
"gpt-5-nano": { input: 0.05, output: 0.40, cacheRead: 0.005, cacheWrite: 0.05 },
"gpt-5": { input: 1.25, output: 10, cacheRead: 0.125, cacheWrite: 1.25 },
// -- OpenAI: o-series reasoning models --
"o3-mini": { input: 1.10, output: 4.40, cacheRead: 0.55, cacheWrite: 1.10 },
"o3": { input: 2, output: 8, cacheRead: 0.50, cacheWrite: 2 },
"o4-mini": { input: 1.10, output: 4.40, cacheRead: 0.275, cacheWrite: 1.10 },
// -- OpenAI: GPT-4o family (legacy, kept for runtimes still configured against it) --
"gpt-4o-mini": { input: 0.15, output: 0.60, cacheRead: 0.075, cacheWrite: 0.15 },
"gpt-4o": { input: 2.50, output: 10, cacheRead: 1.25, cacheWrite: 2.50 },
};
// Resolve a model string to its pricing tier. Exact match, with three
// tolerances applied in order:
//
// 1. Provider-prefixed IDs (`anthropic/claude-opus-4.7` from openclaw /
// opencode) — the `<provider>/` segment is routing metadata, not part
// of the SKU, so we strip it before lookup.
// 2. Anthropic dot↔dash normalization — Claude Code reports
// `claude-opus-4-7`, GitHub Copilot reports `claude-opus-4.7`. Same
// SKU, two transports. We canonicalize `claude-*` IDs to the dashed
// form Anthropic itself publishes. Scoped to `claude-*` because for
// OpenAI the separator IS semantic (`gpt-5.4` ≠ `gpt-5-4`).
// 3. Trailing dated snapshots (`claude-sonnet-4-5-20250929`,
// `gpt-5-2025-08-07`) — the family is what we price, the date is
// volatile, so we strip a trailing date / "latest" tag.
//
// Anything still unmapped falls back to the user-supplied custom pricing
// store. No startsWith fallback: variants like `gpt-5.5-mini` must have
// their own row to be priced (otherwise they'd inherit `gpt-5.5`).
function resolvePricing(model: string) {
if (!model) return undefined;
for (const candidate of canonicalCandidates(model)) {
const hit = MODEL_PRICING[candidate];
if (hit) return hit;
}
for (const candidate of canonicalCandidates(model)) {
const hit = getCustomPricing(candidate);
if (hit) return hit;
}
return undefined;
}
// Generate the lookup candidates for a model string, in priority order:
// the raw string first (preserves explicit user / catalog spellings),
// then the canonicalized forms. Deduped so we don't repeat lookups.
function canonicalCandidates(model: string): string[] {
const seen = new Set<string>();
const out: string[] = [];
const push = (s: string) => {
if (!s || seen.has(s)) return;
seen.add(s);
out.push(s);
};
const stripDate = (s: string) =>
s.replace(/-(20\d{2}-\d{2}-\d{2}|20\d{6}|latest)$/, "");
const stripProvider = (s: string) => {
const i = s.indexOf("/");
return i > 0 && /^[a-z][a-z0-9_-]*$/i.test(s.slice(0, i)) ? s.slice(i + 1) : s;
};
// Only Anthropic IDs are dot↔dash equivalent. OpenAI separators are
// semantic, so we leave `gpt-5.4` etc. alone.
const canonAnthropic = (s: string) =>
s.startsWith("claude-") ? s.replace(/\./g, "-") : s;
const raw = model;
const noProvider = stripProvider(raw);
const dashed = canonAnthropic(noProvider);
push(raw);
push(noProvider);
push(dashed);
push(stripDate(raw));
push(stripDate(noProvider));
push(stripDate(dashed));
return out;
}
// Cheap predicate for the empty-state diagnostic: which model strings in a
// usage batch failed pricing resolution. Useful when the user is staring at
// "$0.00 / 2M tokens" and wants to know why.
export function isModelPriced(model: string): boolean {
return resolvePricing(model) !== undefined;
}
// Returns the unique, sorted list of model strings present in `rows` that
// don't resolve to a price. Empty when everything's priced or there are no
// rows.
export function collectUnmappedModels(rows: readonly Priceable[]): string[] {
const set = new Set<string>();
for (const r of rows) {
if (r.model && !isModelPriced(r.model)) set.add(r.model);
}
return [...set].sort();
}
// Anything carrying per-model token totals can be priced — RuntimeUsage,
// RuntimeUsageByAgent, RuntimeUsageByHour all share this shape on purpose
// (the back-end keeps the model dimension specifically so the client can
// run this calculation for any aggregation axis).
type Priceable = Pick<
RuntimeUsage,
"model" | "input_tokens" | "output_tokens" | "cache_read_tokens" | "cache_write_tokens"
>;
export function estimateCost(usage: Priceable): number {
const pricing = resolvePricing(usage.model);
if (!pricing) return 0;
return (
(usage.input_tokens * pricing.input +
usage.output_tokens * pricing.output +
usage.cache_read_tokens * pricing.cacheRead +
usage.cache_write_tokens * pricing.cacheWrite) /
1_000_000
);
}
export interface CostBreakdown {
input: number;
output: number;
cacheRead: number;
cacheWrite: number;
}
export function estimateCostBreakdown(usage: Priceable): CostBreakdown {
const pricing = resolvePricing(usage.model);
if (!pricing) {
return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
}
return {
input: (usage.input_tokens * pricing.input) / 1_000_000,
output: (usage.output_tokens * pricing.output) / 1_000_000,
cacheRead: (usage.cache_read_tokens * pricing.cacheRead) / 1_000_000,
cacheWrite: (usage.cache_write_tokens * pricing.cacheWrite) / 1_000_000,
};
}
// Cache savings: what cache *reads* would have cost at full input pricing
// minus what they actually cost at the discounted cache-hit rate. This is a
// reconstruction of "money the cache saved you", not real-world spend.
export function estimateCacheSavings(usage: Priceable): number {
const pricing = resolvePricing(usage.model);
if (!pricing) return 0;
const wouldHaveCost = (usage.cache_read_tokens * pricing.input) / 1_000_000;
const actualCost = (usage.cache_read_tokens * pricing.cacheRead) / 1_000_000;
return wouldHaveCost - actualCost;
}
// ---------------------------------------------------------------------------
// Data aggregation
// ---------------------------------------------------------------------------
export interface DailyTokenData {
date: string;
label: string;
input: number;
output: number;
cacheRead: number;
cacheWrite: number;
}
export interface DailyCostData {
date: string;
label: string;
cost: number;
}
// Stacked variant — splits the daily $ figure into the three components that
// drive billing (cache reads excluded; their cost is tracked separately as
// "savings" since they're typically dominated by the cached-input discount).
export interface DailyCostStackData {
date: string;
label: string;
input: number;
output: number;
cacheWrite: number;
total: number;
}
export interface ModelDistribution {
model: string;
tokens: number;
cost: number;
}
export interface WeeklyTokenData {
weekStart: string;
weekEnd: string;
// X-axis tick — Monday of the week, e.g. "May 12".
label: string;
// Tooltip header — inclusive range, e.g. "May 12 May 18".
rangeLabel: string;
// True when `weekEnd` is in the future (today is mid-week). Surface this
// in the chart so the bar can be drawn at reduced opacity / striped to
// signal "don't read this as a finished week".
partial: boolean;
daysCovered: number;
input: number;
output: number;
cacheRead: number;
cacheWrite: number;
}
export interface WeeklyCostStackData {
weekStart: string;
weekEnd: string;
label: string;
rangeLabel: string;
partial: boolean;
daysCovered: number;
input: number;
output: number;
cacheWrite: number;
total: number;
}
export function aggregateByDate(usage: RuntimeUsage[]): {
dailyTokens: DailyTokenData[];
dailyCost: DailyCostData[];
dailyCostStack: DailyCostStackData[];
modelDist: ModelDistribution[];
} {
const dateMap = new Map<string, Omit<DailyTokenData, "label">>();
const costMap = new Map<string, number>();
const stackMap = new Map<
string,
{ input: number; output: number; cacheWrite: number }
>();
const modelMap = new Map<string, { tokens: number; cost: number }>();
for (const u of usage) {
const existing = dateMap.get(u.date) ?? {
date: u.date,
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
};
existing.input += u.input_tokens;
existing.output += u.output_tokens;
existing.cacheRead += u.cache_read_tokens;
existing.cacheWrite += u.cache_write_tokens;
dateMap.set(u.date, existing);
const dayCost = (costMap.get(u.date) ?? 0) + estimateCost(u);
costMap.set(u.date, dayCost);
const breakdown = estimateCostBreakdown(u);
const stack = stackMap.get(u.date) ?? {
input: 0,
output: 0,
cacheWrite: 0,
};
stack.input += breakdown.input;
stack.output += breakdown.output;
stack.cacheWrite += breakdown.cacheWrite;
stackMap.set(u.date, stack);
const modelName = u.model || u.provider;
const m = modelMap.get(modelName) ?? { tokens: 0, cost: 0 };
m.tokens +=
u.input_tokens + u.output_tokens + u.cache_read_tokens + u.cache_write_tokens;
m.cost += estimateCost(u);
modelMap.set(modelName, m);
}
const formatLabel = (d: string) => {
const date = new Date(d + "T00:00:00");
return `${date.getMonth() + 1}/${date.getDate()}`;
};
const dailyTokens = [...dateMap.values()]
.sort((a, b) => a.date.localeCompare(b.date))
.map((d) => ({ ...d, label: formatLabel(d.date) }));
const dailyCost = [...costMap.entries()]
.sort(([a], [b]) => a.localeCompare(b))
.map(([date, cost]) => ({
date,
label: formatLabel(date),
cost: Math.round(cost * 100) / 100,
}));
const dailyCostStack = [...stackMap.entries()]
.sort(([a], [b]) => a.localeCompare(b))
.map(([date, s]) => {
const round = (n: number) => Math.round(n * 100) / 100;
const input = round(s.input);
const output = round(s.output);
const cacheWrite = round(s.cacheWrite);
return {
date,
label: formatLabel(date),
input,
output,
cacheWrite,
total: round(input + output + cacheWrite),
};
});
const modelDist = [...modelMap.entries()]
.map(([model, data]) => ({ model, ...data }))
.sort((a, b) => b.tokens - a.tokens);
return { dailyTokens, dailyCost, dailyCostStack, modelDist };
}
// Fold daily-grain rows into ISO calendar weeks (MonSun). Reuses the same
// 180-day cache the daily aggregation reads from — no extra request. The
// latest week is flagged `partial` when today (in the runtime's tz) is
// before Sunday, so the chart can render the in-progress bar at half
// opacity instead of letting the user misread "this week" as a dip.
//
// `weekCount` pins the output to exactly that many trailing calendar weeks
// ending at the week that contains today (in `tz`). Buckets are pre-zeroed,
// so sparse data — including weeks with no usage — renders as empty bars
// rather than disappearing. Rows whose week falls outside the window are
// dropped; without this guard `.slice(-weekCount)` on a sparse 180-day
// aggregate would surface old populated weeks instead of the empty
// in-range buckets the user asked for (MUL-2382 weekly window scoping).
// Accepts any row carrying `date` + token counts + the model needed for
// pricing. Both `RuntimeUsage` (runtime detail) and `DashboardUsageDaily`
// (workspace dashboard) match this shape — there's no behavioural difference,
// just slightly different surrounding fields neither aggregator cares about.
type WeeklyAggregable = Pick<
RuntimeUsage,
| "date"
| "model"
| "input_tokens"
| "output_tokens"
| "cache_read_tokens"
| "cache_write_tokens"
>;
export function aggregateByWeek(
usage: readonly WeeklyAggregable[],
tz: string,
weekCount: number,
): {
weeklyTokens: WeeklyTokenData[];
weeklyCostStack: WeeklyCostStackData[];
} {
const count = Math.max(1, Math.floor(weekCount));
const today = todayIso(tz);
const currentWeekStart = weekStartIso(today);
const firstWeekStart = addDaysIso(currentWeekStart, -(count - 1) * 7);
type TokenAgg = Omit<WeeklyTokenData, "label" | "rangeLabel" | "partial" | "daysCovered" | "weekEnd">;
const tokenMap = new Map<string, TokenAgg>();
const stackMap = new Map<string, { input: number; output: number; cacheWrite: number }>();
// Pre-seed every trailing calendar week in the window so sparse / empty
// weeks still render as zero bars instead of being dropped.
for (let i = 0; i < count; i++) {
const wkStart = addDaysIso(firstWeekStart, i * 7);
tokenMap.set(wkStart, {
weekStart: wkStart,
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
});
stackMap.set(wkStart, { input: 0, output: 0, cacheWrite: 0 });
}
for (const u of usage) {
const wkStart = weekStartIso(u.date);
if (wkStart < firstWeekStart || wkStart > currentWeekStart) continue;
const tokens = tokenMap.get(wkStart);
if (!tokens) continue;
tokens.input += u.input_tokens;
tokens.output += u.output_tokens;
tokens.cacheRead += u.cache_read_tokens;
tokens.cacheWrite += u.cache_write_tokens;
const breakdown = estimateCostBreakdown(u);
const stack = stackMap.get(wkStart);
if (!stack) continue;
stack.input += breakdown.input;
stack.output += breakdown.output;
stack.cacheWrite += breakdown.cacheWrite;
}
const decorate = (weekStart: string) => {
const weekEnd = addDaysIso(weekStart, 6);
const partial = today < weekEnd;
// Inclusive count of how many days of this week have actually elapsed.
// Sits at 7 for closed weeks, 1..6 for the current week.
const elapsedDays = Math.min(
7,
Math.max(
1,
// Day index of `today` within [weekStart, weekEnd] + 1.
diffDaysIso(weekStart, today < weekStart ? weekStart : today < weekEnd ? today : weekEnd) + 1,
),
);
return {
weekStart,
weekEnd,
label: formatShortDate(weekStart),
rangeLabel: `${formatShortDate(weekStart)} ${formatShortDate(weekEnd)}`,
partial,
daysCovered: partial ? elapsedDays : 7,
};
};
const weeklyTokens: WeeklyTokenData[] = [...tokenMap.values()]
.sort((a, b) => a.weekStart.localeCompare(b.weekStart))
.map((t) => ({ ...t, ...decorate(t.weekStart) }));
const weeklyCostStack: WeeklyCostStackData[] = [...stackMap.entries()]
.sort(([a], [b]) => a.localeCompare(b))
.map(([weekStart, s]) => {
const round = (n: number) => Math.round(n * 100) / 100;
const input = round(s.input);
const output = round(s.output);
const cacheWrite = round(s.cacheWrite);
return {
...decorate(weekStart),
input,
output,
cacheWrite,
total: round(input + output + cacheWrite),
};
});
return { weeklyTokens, weeklyCostStack };
}
// Slice a daily-grain usage series into the user's selected window AND the
// immediately prior window of equal length. "Today" is read in the runtime's
// timezone so the cutoff lands on the same calendar boundary the backend
// used when bucketing rows — without this the browser/runtime tz gap could
// shift the boundary by a day at the edges (#MUL-2382 sliceWindow tz bug).
export function sliceWindow(
usage: readonly RuntimeUsage[],
days: number,
tz: string,
): { filtered: RuntimeUsage[]; prevFiltered: RuntimeUsage[] } {
const today = todayIso(tz);
const isoCurrent = addDaysIso(today, -days);
const isoPrev = addDaysIso(today, -days * 2);
return {
filtered: usage.filter((u) => u.date >= isoCurrent),
prevFiltered: usage.filter(
(u) => u.date >= isoPrev && u.date < isoCurrent,
),
};
}
function diffDaysIso(from: string, to: string): number {
const [y1, m1, d1] = from.split("-").map(Number);
const [y2, m2, d2] = to.split("-").map(Number);
const a = Date.UTC(y1 ?? 1970, (m1 ?? 1) - 1, d1 ?? 1);
const b = Date.UTC(y2 ?? 1970, (m2 ?? 1) - 1, d2 ?? 1);
return Math.round((b - a) / 86_400_000);
}
// ---------------------------------------------------------------------------
// Calendar helpers — all date math runs on YYYY-MM-DD strings in the
// runtime's IANA timezone. The backend already groups daily usage by
// `start-of-day in runtime tz`, so we keep the entire frontend aggregation
// on the same axis (Daily / Weekly) to avoid one-day drift when the browser
// and runtime sit in different time zones.
// ---------------------------------------------------------------------------
// Today's calendar date (YYYY-MM-DD) in the given IANA timezone. `en-CA`
// gives ISO-shaped output without us having to assemble Intl parts by hand.
export function todayIso(tz: string): string {
return new Intl.DateTimeFormat("en-CA", {
timeZone: tz,
year: "numeric",
month: "2-digit",
day: "2-digit",
}).format(new Date());
}
// Pure date arithmetic on a YYYY-MM-DD string. Uses UTC under the hood so
// DST transitions never shift the result by an hour and round to a
// neighbouring day.
export function addDaysIso(iso: string, days: number): string {
const [y, m, d] = iso.split("-").map(Number);
const dt = new Date(Date.UTC(y ?? 1970, (m ?? 1) - 1, d ?? 1));
dt.setUTCDate(dt.getUTCDate() + days);
return dt.toISOString().slice(0, 10);
}
// Monday-of-week as YYYY-MM-DD. ISO 8601 week-start, matching the heatmap
// and the team's day-to-day "this week" mental model. Pure string math —
// no `new Date()` reads — so it's stable under any host timezone.
export function weekStartIso(iso: string): string {
const [y, m, d] = iso.split("-").map(Number);
const dt = new Date(Date.UTC(y ?? 1970, (m ?? 1) - 1, d ?? 1));
const day = dt.getUTCDay(); // 0 = Sun, 1 = Mon, ..., 6 = Sat
const offset = (day + 6) % 7; // distance back to Monday
dt.setUTCDate(dt.getUTCDate() - offset);
return dt.toISOString().slice(0, 10);
}
// "May 12" — short, locale-aware month/day for a YYYY-MM-DD string. Parsing
// via UTC keeps the displayed day stable regardless of the browser's tz.
export function formatShortDate(iso: string): string {
const [y, m, d] = iso.split("-").map(Number);
const dt = new Date(Date.UTC(y ?? 1970, (m ?? 1) - 1, d ?? 1));
return dt.toLocaleString("en", {
month: "short",
day: "numeric",
timeZone: "UTC",
});
}
// ---------------------------------------------------------------------------
// Cost-by-X aggregations
//
// All three "Cost by …" tabs share the same shape: a sorted list of rows
// where each row carries a key (agent name, model name, or hour-of-day),
// total tokens and total cost. The chart / list components are oblivious
// to which axis they're rendering — they just see {key, tokens, cost}.
// ---------------------------------------------------------------------------
export interface CostByKey {
key: string;
tokens: number;
cost: number;
taskCount: number;
}
// Per-(agent, model) rows → per-agent totals. Cost is summed across all
// models for that agent, then the list is sorted by cost desc so the
// heaviest-spending agent appears first.
export function aggregateCostByAgent(rows: RuntimeUsageByAgent[]): CostByKey[] {
const map = new Map<string, CostByKey>();
for (const r of rows) {
const entry = map.get(r.agent_id) ?? {
key: r.agent_id,
tokens: 0,
cost: 0,
taskCount: 0,
};
entry.tokens +=
r.input_tokens + r.output_tokens + r.cache_read_tokens + r.cache_write_tokens;
entry.cost += estimateCost(r);
entry.taskCount += r.task_count;
map.set(r.agent_id, entry);
}
return [...map.values()].sort((a, b) => b.cost - a.cost);
}
// Per-(date, model) rows → per-model totals (the "By model" tab reuses the
// daily-grain data we already cache, so no extra request is needed).
export function aggregateCostByModel(rows: RuntimeUsage[]): CostByKey[] {
const map = new Map<string, CostByKey>();
for (const r of rows) {
const key = r.model || r.provider || "unknown";
const entry = map.get(key) ?? { key, tokens: 0, cost: 0, taskCount: 0 };
entry.tokens +=
r.input_tokens + r.output_tokens + r.cache_read_tokens + r.cache_write_tokens;
entry.cost += estimateCost(r);
map.set(key, entry);
}
return [...map.values()].sort((a, b) => b.cost - a.cost);
}
// Sum of estimated cost over the trailing window
// [today offsetDays daysBack, today offsetDays).
// `offsetDays = 0, daysBack = 7` → last 7 days.
// `offsetDays = 7, daysBack = 7` → the 7 days *before* the last 7 (the
// "previous" window for the runtime-list ↑/↓ delta).
//
// "Today" is read in `tz` (the viewer's timezone) so the cutoff lands on
// the same calendar boundary the backend used when bucketing rows — the
// rows arrive bucketed in the viewer's tz, so slicing them with the JS
// engine's local tz would shift the window by a day at the edges.
//
// Walks the same daily-grain `RuntimeUsage` rows that `aggregateByDate` uses,
// so the runtime-list cost stays consistent with the runtime-detail KPIs
// (and crucially, hits the same TanStack Query cache key).
export function computeCostInWindow(
rows: readonly RuntimeUsage[],
daysBack: number,
tz: string,
offsetDays: number = 0,
): number {
const today = todayIso(tz);
const isoEnd = addDaysIso(today, -offsetDays);
const isoStart = addDaysIso(today, -offsetDays - daysBack);
let total = 0;
for (const r of rows) {
if (r.date >= isoStart && r.date < isoEnd) total += estimateCost(r);
}
return total;
}
export function pctChange(current: number, previous: number): number | null {
if (previous <= 0) return null;
return Math.round(((current - previous) / previous) * 100);
}