Files
multica/packages/views/dashboard/utils.ts
Jiayuan Zhang 35fc318d68 feat(runtimes): weekly usage dimension + tz-aware aggregation (MUL-2382) (#2822)
* feat(runtimes): weekly usage dimension + tz-aware aggregation (MUL-2382)

Adds a Weekly view to the runtime Usage chart alongside Daily and Hourly,
backed by `aggregateByWeek` on the existing 180-day daily cache (no new
endpoint). Weeks are ISO 8601 Mon–Sun; the in-progress week is rendered at
half opacity and tooltip-labelled "partial · N / 7 days".

Side effects called out in the RFC:

- `sliceWindow` now reads "today" in the runtime's IANA timezone, fixing a
  one-day drift at the window edge when the browser and runtime sit in
  different time zones.
- ActivityHeatmap rows are reordered Mon → Sun to match the rest of the
  Weekly aggregation; "today" is computed in runtime tz so the grid's
  trailing column lines up with the daily rows the backend buckets.

Dimension / period coupling: switching dimension resets the period to that
dimension's default when the active value isn't in its allowed set
(Hourly 7/30, Daily 7/30/90, Weekly 30/90/180).

Unit tests cover weekStart / addDays / tz-aware today, the sliceWindow
boundary, and aggregateByWeek's partial-week math.

Co-authored-by: multica-agent <github@multica.ai>

* fix(runtimes): weekly chart shows trailing calendar weeks (MUL-2382)

aggregateByWeek built one bucket per week-with-data, and the caller
took the last N buckets. With sparse data — old populated weeks plus
empty stretches near today — the slice surfaced the old weeks instead
of the trailing in-window calendar weeks the user selected.

Now aggregateByWeek takes weekCount and emits exactly that many
trailing calendar weeks anchored at today's week in the runtime tz.
Buckets are pre-zeroed so empty in-range weeks render as empty bars;
rows outside the window are dropped.

Co-authored-by: multica-agent <github@multica.ai>

* feat(usage): drop Hourly dim + add Daily/Weekly to workspace dashboard (MUL-2382)

- Remove Hourly from the runtime usage WHEN-chart: segmented control is
  now Daily / Weekly. Drop the HourlyActivityChart component,
  aggregateCostByHour helper, byHour query subscription, and the
  when_tab_hourly i18n key.
- Add the same Daily / Weekly dimension toggle to the workspace-level
  Usage page (dashboard-page.tsx). Time-range linkage matches the runtime
  page: Daily allows 7/30/90 (default 30), Weekly allows 30/90/180
  (default 90); switching dimensions resets `days` when the current value
  isn't in the new dimension's set.
- Reuse `aggregateByWeek` from runtimes/utils for cost / tokens
  (signature relaxed to accept the wider DashboardUsageDaily shape).
  Add `aggregateWeeklyTime` / `aggregateWeeklyTasks` in dashboard/utils
  with identical pre-zeroed trailing-week semantics. Workspace dashboard
  uses the user-chosen timezone (existing TimezoneSelect) as the
  week-boundary tz; runtime page continues to use the runtime's IANA tz.
- New `WeeklyTimeChart` / `WeeklyTasksChart` mirror their daily
  counterparts plus partial-week half-opacity bars and rangeLabel
  tooltips, matching the existing Weekly cost / tokens charts.
- Tests: drop hourly-related setup; add weekly run-time / tasks coverage
  asserting pre-zeroed trailing buckets and the same MUL-2382 sparse
  window-scoping regression we caught on the runtime side.

Co-authored-by: multica-agent <github@multica.ai>

* fix(usage): correct workspace Weekly window + lock tz to UTC (MUL-2382)

Two blocking correctness bugs from Emacs's PR #2822 review:

1. The Weekly chart paints `ceil(days/7)` trailing calendar weeks but the
   API was still asked for exactly `days`. Worst case (today = Sunday on a
   30D request) the leftmost Monday sits 34 days back, so the first week's
   bucket was silently truncated. Over-fetch the per-date queries to
   `weekCount * 7` days when Weekly is active; per-agent rollups stay at
   `days` so the KPI / leaderboard labels keep their advertised window.
   Daily-aggregation surfaces (cost/tokens/time/tasks KPIs and the Daily
   chart) re-scope the over-fetched rows back to `days` so the labels
   stay consistent.

2. The backend dashboard rollup buckets data by UTC `bucket_date` (and the
   raw fallback queries by `DATE(tu.created_at)`, also UTC), but the
   frontend was driving Weekly boundaries from the user-chosen
   `TimezoneSelect`. Near midnight UTC that put cross-boundary rows into
   the wrong calendar week. Lock workspace Weekly to UTC and remove the
   timezone picker from this page; the runtime detail page keeps its own
   `runtime.timezone`-anchored aggregation, which is consistent because
   its rollup is materialized in that runtime's tz.

Verification: pnpm --filter @multica/views test (636 passed),
typecheck clean, lint 0 errors / 13 pre-existing warnings.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-19 04:24:46 +02:00

382 lines
13 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 {
DashboardUsageDaily,
DashboardUsageByAgent,
DashboardAgentRunTime,
DashboardRunTimeDaily,
} from "@multica/core/types";
import {
addDaysIso,
estimateCost,
estimateCostBreakdown,
formatShortDate,
todayIso,
weekStartIso,
type DailyTokenData,
} from "../runtimes/utils";
import type {
DailyTimeData,
DailyTasksData,
WeeklyTimeData,
WeeklyTasksData,
} from "../runtimes/components/charts";
// ---------------------------------------------------------------------------
// Dashboard data aggregations
//
// The workspace dashboard returns the same per-(date, model) and
// per-(agent, model) shapes the runtime page does, so cost math reuses
// `estimateCost` / `estimateCostBreakdown` from the runtimes utils. What
// the runtimes view does with `aggregateByDate` (works on RuntimeUsage,
// which carries a `provider` field) we replicate here with a tighter
// type — fewer optional fields, less conditional logic on the consumer
// side.
// ---------------------------------------------------------------------------
export interface DailyCostStack {
date: string;
label: string;
input: number;
output: number;
cacheWrite: number;
total: number;
}
function formatDateLabel(d: string): string {
// Anchor to local midnight so the formatted label matches the bucket the
// server picked (which is already in workspace time). Pasting the raw
// date as the body of `new Date()` would interpret it as UTC and shift
// by the user's offset.
const date = new Date(d + "T00:00:00");
return `${date.getMonth() + 1}/${date.getDate()}`;
}
// Per-(date, model) rows → 1 row per date with cost broken into the three
// segments the stacked bar chart consumes. Stable sort by date asc so the
// chart x-axis is left-to-right oldest-to-newest.
export function aggregateDailyCost(usage: DashboardUsageDaily[]): DailyCostStack[] {
const map = new Map<string, { input: number; output: number; cacheWrite: number }>();
for (const u of usage) {
const b = estimateCostBreakdown(u);
const entry = map.get(u.date) ?? { input: 0, output: 0, cacheWrite: 0 };
entry.input += b.input;
entry.output += b.output;
entry.cacheWrite += b.cacheWrite;
map.set(u.date, entry);
}
const round = (n: number) => Math.round(n * 100) / 100;
return [...map.entries()]
.sort(([a], [b]) => a.localeCompare(b))
.map(([date, s]) => {
const input = round(s.input);
const output = round(s.output);
const cacheWrite = round(s.cacheWrite);
return {
date,
label: formatDateLabel(date),
input,
output,
cacheWrite,
total: round(input + output + cacheWrite),
};
});
}
// Per-(date, model) rows → 1 row per date with raw token counts split
// across the four chart segments. Independent of pricing — unmapped
// models still contribute here, even if they're excluded from cost.
// Mirrors `aggregateByDate(...).dailyTokens` from the runtimes utils so
// the Tokens chart on the Usage page consumes the same shape as the one
// on the runtime-detail page.
export function aggregateDailyTokens(usage: DashboardUsageDaily[]): DailyTokenData[] {
const map = new Map<
string,
{ input: number; output: number; cacheRead: number; cacheWrite: number }
>();
for (const u of usage) {
const entry = map.get(u.date) ?? {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
};
entry.input += u.input_tokens;
entry.output += u.output_tokens;
entry.cacheRead += u.cache_read_tokens;
entry.cacheWrite += u.cache_write_tokens;
map.set(u.date, entry);
}
return [...map.entries()]
.sort(([a], [b]) => a.localeCompare(b))
.map(([date, t]) => ({
date,
label: formatDateLabel(date),
input: t.input,
output: t.output,
cacheRead: t.cacheRead,
cacheWrite: t.cacheWrite,
}));
}
export interface DashboardTokenTotals {
input: number;
output: number;
cacheRead: number;
cacheWrite: number;
cost: number;
taskCount: number;
}
// Whole-window totals for the KPI tiles. taskCount sums DISTINCT task counts
// per row — these are already collapsed server-side per (date, model), so
// the value can over-count if the same task has tokens in two days; that's
// acceptable for a KPI ("rough volume") and the per-agent run-time card
// gives the precise figure.
export function computeDailyTotals(usage: DashboardUsageDaily[]): DashboardTokenTotals {
return usage.reduce<DashboardTokenTotals>(
(acc, u) => ({
input: acc.input + u.input_tokens,
output: acc.output + u.output_tokens,
cacheRead: acc.cacheRead + u.cache_read_tokens,
cacheWrite: acc.cacheWrite + u.cache_write_tokens,
cost: acc.cost + estimateCost(u),
taskCount: acc.taskCount + u.task_count,
}),
{ input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, taskCount: 0 },
);
}
export interface AgentCostRow {
agentId: string;
tokens: number;
cost: number;
taskCount: number;
}
// Fold per-(agent, model) rows into one row per agent. Cost is the sum
// across this agent's models, which is the figure the user cares about.
// Sort by cost desc so the heaviest spender lands first.
export function aggregateAgentTokens(rows: DashboardUsageByAgent[]): AgentCostRow[] {
const map = new Map<string, AgentCostRow>();
for (const r of rows) {
const entry = map.get(r.agent_id) ?? {
agentId: 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);
}
export interface AgentDashboardRow {
agentId: string;
tokens: number;
cost: number;
seconds: number;
taskCount: number;
}
// Merge per-agent token totals with per-agent run-time totals into one
// row per agent.
//
// taskCount comes from `runTimeRows` when available — that rollup is a
// true per-agent distinct count (`COUNT(*)` on (agent, terminal-task) in
// SQL). The token rollup's per-(agent, model) counts double-count a task
// when it spans multiple models, so we only fall back to it for agents
// with no terminal run yet (in-flight tasks reported tokens but haven't
// completed). Sorted by cost desc, then run time desc.
export function mergeAgentDashboardRows(
tokenRows: AgentCostRow[],
runTimeRows: DashboardAgentRunTime[],
): AgentDashboardRow[] {
const runTimeByAgent = new Map(
runTimeRows.map((r) => [r.agent_id, r] as const),
);
const merged = new Map<string, AgentDashboardRow>();
for (const r of tokenRows) {
const rt = runTimeByAgent.get(r.agentId);
merged.set(r.agentId, {
agentId: r.agentId,
tokens: r.tokens,
cost: r.cost,
seconds: rt?.total_seconds ?? 0,
taskCount: rt ? rt.task_count : r.taskCount,
});
}
// Agents with run-time rows but zero tokens still belong on the list
// (a task that errored before producing usage). Their token columns
// stay at 0.
for (const r of runTimeRows) {
if (merged.has(r.agent_id)) continue;
merged.set(r.agent_id, {
agentId: r.agent_id,
tokens: 0,
cost: 0,
seconds: r.total_seconds,
taskCount: r.task_count,
});
}
return [...merged.values()].sort((a, b) => {
if (b.cost !== a.cost) return b.cost - a.cost;
return b.seconds - a.seconds;
});
}
// ---------------------------------------------------------------------------
// Weekly fold for run-time + tasks. Mirrors `aggregateByWeek` in
// `runtimes/utils.ts` which already covers cost / tokens — same calendar
// week semantics (MonSun anchored at today-in-tz), same pre-zeroed buckets,
// same partial-week metadata. Workspace dashboard uses the user-chosen
// timezone here; the runtime page uses the runtime's IANA tz. Behaviour is
// identical apart from where the tz comes from.
// ---------------------------------------------------------------------------
interface WeekShell {
weekStart: string;
weekEnd: string;
label: string;
rangeLabel: string;
partial: boolean;
daysCovered: number;
}
// Build N trailing calendar week shells anchored at today-in-tz. Each shell
// carries the labels and partial-week metadata the chart components consume;
// downstream aggregators fold their own per-week values onto the matching
// shell.
function buildWeekShells(tz: string, weekCount: number): WeekShell[] {
const count = Math.max(1, Math.floor(weekCount));
const today = todayIso(tz);
const currentWeekStart = weekStartIso(today);
const firstWeekStart = addDaysIso(currentWeekStart, -(count - 1) * 7);
const shells: WeekShell[] = [];
for (let i = 0; i < count; i++) {
const weekStart = addDaysIso(firstWeekStart, i * 7);
const weekEnd = addDaysIso(weekStart, 6);
const partial = today < weekEnd;
// Inclusive count of how many days of this week have actually elapsed.
// Closed weeks sit at 7; the current week reports 1..6.
const clampedToday =
today < weekStart ? weekStart : today < weekEnd ? today : weekEnd;
const elapsed = Math.min(7, Math.max(1, diffDaysIso(weekStart, clampedToday) + 1));
shells.push({
weekStart,
weekEnd,
label: formatShortDate(weekStart),
rangeLabel: `${formatShortDate(weekStart)} ${formatShortDate(weekEnd)}`,
partial,
daysCovered: partial ? elapsed : 7,
});
}
return shells;
}
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);
}
export function aggregateWeeklyTime(
rows: DashboardRunTimeDaily[],
tz: string,
weekCount: number,
): WeeklyTimeData[] {
const shells = buildWeekShells(tz, weekCount);
const totals = new Map<string, number>();
for (const shell of shells) totals.set(shell.weekStart, 0);
for (const r of rows) {
const wkStart = weekStartIso(r.date);
if (!totals.has(wkStart)) continue;
totals.set(wkStart, (totals.get(wkStart) ?? 0) + r.total_seconds);
}
return shells.map((s) => ({ ...s, totalSeconds: totals.get(s.weekStart) ?? 0 }));
}
export function aggregateWeeklyTasks(
rows: DashboardRunTimeDaily[],
tz: string,
weekCount: number,
): WeeklyTasksData[] {
const shells = buildWeekShells(tz, weekCount);
const buckets = new Map<string, { completed: number; failed: number }>();
for (const shell of shells)
buckets.set(shell.weekStart, { completed: 0, failed: 0 });
for (const r of rows) {
const wkStart = weekStartIso(r.date);
const bucket = buckets.get(wkStart);
if (!bucket) continue;
const failed = r.failed_count;
const completed = Math.max(0, r.task_count - failed);
bucket.completed += completed;
bucket.failed += failed;
}
return shells.map((s) => {
const b = buckets.get(s.weekStart) ?? { completed: 0, failed: 0 };
return { ...s, completed: b.completed, failed: b.failed };
});
}
// Per-date run-time rows → one row per date with `totalSeconds` for the
// DailyTimeChart. Sorted ascending so the x-axis reads oldest-to-newest,
// matching the cost / tokens aggregators.
export function aggregateDailyTime(rows: DashboardRunTimeDaily[]): DailyTimeData[] {
return [...rows]
.sort((a, b) => a.date.localeCompare(b.date))
.map((r) => ({
date: r.date,
label: formatDateLabel(r.date),
totalSeconds: r.total_seconds,
}));
}
// Per-date run-time rows → one row per date with `completed` and `failed`
// counts for the DailyTasksChart's stacked bar (failed_count is a subset
// of task_count, so completed = task_count - failed_count).
export function aggregateDailyTasks(rows: DashboardRunTimeDaily[]): DailyTasksData[] {
return [...rows]
.sort((a, b) => a.date.localeCompare(b.date))
.map((r) => {
const failed = r.failed_count;
const completed = Math.max(0, r.task_count - failed);
return {
date: r.date,
label: formatDateLabel(r.date),
completed,
failed,
};
});
}
// Compact human duration: "1h 23m" / "12m 30s" / "45s" / "<1m". Used for
// the dashboard run-time KPI and the per-agent run-time column. Keeps two
// segments max — three segments adds visual noise without precision the
// dashboard actually needs.
export function formatDuration(seconds: number, lessThanMinuteLabel: string): string {
if (seconds < 0 || !Number.isFinite(seconds)) return lessThanMinuteLabel;
if (seconds < 60) {
if (seconds < 1) return lessThanMinuteLabel;
return `${Math.round(seconds)}s`;
}
const totalMinutes = Math.floor(seconds / 60);
const hours = Math.floor(totalMinutes / 60);
const mins = totalMinutes % 60;
if (hours === 0) {
const secs = Math.floor(seconds) % 60;
return secs > 0 ? `${mins}m ${secs}s` : `${mins}m`;
}
if (hours >= 24) {
const days = Math.floor(hours / 24);
const h = hours % 24;
return h > 0 ? `${days}d ${h}h` : `${days}d`;
}
return mins > 0 ? `${hours}h ${mins}m` : `${hours}h`;
}