Files
multica/packages/views/runtimes/utils.test.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

606 lines
21 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 { describe, it, expect, afterEach, beforeEach, vi } from "vitest";
import { useCustomPricingStore } from "@multica/core/runtimes/custom-pricing-store";
import type { RuntimeUsage } from "@multica/core/types";
import {
addDaysIso,
aggregateByWeek,
aggregateCostByModel,
collectUnmappedModels,
estimateCost,
isModelPriced,
sliceWindow,
todayIso,
weekStartIso,
} from "./utils";
afterEach(() => {
// Reset overrides so tests don't bleed pricing state into one another.
useCustomPricingStore.setState({ pricings: {} });
});
const zeroUsage = {
input_tokens: 0,
output_tokens: 0,
cache_read_tokens: 0,
cache_write_tokens: 0,
};
describe("estimateCost", () => {
it("prices the canonical Anthropic Sonnet 4.6 SKU", () => {
const cost = estimateCost({
...zeroUsage,
model: "claude-sonnet-4-6",
input_tokens: 1_000_000,
output_tokens: 1_000_000,
});
// 1M × $3 input + 1M × $15 output = $18.
expect(cost).toBeCloseTo(18, 5);
});
it("prices a Codex CLI session reporting gpt-5-codex", () => {
const cost = estimateCost({
...zeroUsage,
model: "gpt-5-codex",
input_tokens: 1_000_000,
output_tokens: 1_000_000,
cache_read_tokens: 2_000_000,
});
// 1M × $1.25 + 1M × $10 + 2M × $0.125 = $11.50.
expect(cost).toBeCloseTo(11.5, 5);
});
it("strips dated snapshots before resolving (gpt-5-2025-08-07 → gpt-5)", () => {
const cost = estimateCost({
...zeroUsage,
model: "gpt-5-2025-08-07",
input_tokens: 1_000_000,
});
expect(cost).toBeCloseTo(1.25, 5);
});
it("prices a Copilot session reporting claude-opus-4.7 at the official Opus rate", () => {
// Copilot's `meta.agentMeta.model` is `claude-opus-4.7` (dotted). We
// canonicalize to the dashed catalog key so it hits the maintained $5/$25
// tier instead of falling through to the custom-pricing dialog.
const cost = estimateCost({
...zeroUsage,
model: "claude-opus-4.7",
input_tokens: 1_000_000,
output_tokens: 1_000_000,
});
expect(cost).toBeCloseTo(5 + 25, 5);
});
it("prices the provider-prefixed Anthropic form (anthropic/claude-sonnet-4.6)", () => {
// openclaw / opencode emit `<provider>/<model>`. Same SKU as the
// bare form, must hit the same rate.
const cost = estimateCost({
...zeroUsage,
model: "anthropic/claude-sonnet-4.6",
input_tokens: 1_000_000,
output_tokens: 1_000_000,
});
expect(cost).toBeCloseTo(3 + 15, 5);
});
it("prices the dated dotted Anthropic form (claude-haiku-4.5-20251001)", () => {
// Belt-and-braces: combine all three tolerances (provider prefix not
// present, but dot→dash + date strip both apply).
const cost = estimateCost({
...zeroUsage,
model: "claude-haiku-4.5-20251001",
input_tokens: 1_000_000,
});
expect(cost).toBeCloseTo(1, 5);
});
it("prices the full provider+dotted+dated form (anthropic/claude-opus-4.7-20251001)", () => {
// All three normalization steps must compose: strip `anthropic/`,
// dot→dash on the Claude ID, and trim the date stamp. Pins the
// combined path so a future change to candidate ordering can't
// silently drop one tolerance.
const cost = estimateCost({
...zeroUsage,
model: "anthropic/claude-opus-4.7-20251001",
input_tokens: 1_000_000,
output_tokens: 1_000_000,
});
expect(cost).toBeCloseTo(5 + 25, 5);
});
it("prices each dotted Codex catalog SKU at its own tier, not gpt-5", () => {
// Every dotted minor version is priced independently. The resolver does
// exact-match-after-date-strip (no startsWith fallback), so each row
// must exist on its own.
expect(
estimateCost({ ...zeroUsage, model: "gpt-5.5", input_tokens: 1_000_000 }),
).toBeCloseTo(5, 5);
expect(
estimateCost({ ...zeroUsage, model: "gpt-5.4", output_tokens: 1_000_000 }),
).toBeCloseTo(15, 5);
expect(
estimateCost({
...zeroUsage,
model: "gpt-5.4-mini",
input_tokens: 1_000_000,
output_tokens: 1_000_000,
}),
).toBeCloseTo(0.75 + 4.5, 5);
expect(
estimateCost({
...zeroUsage,
model: "gpt-5.3-codex",
input_tokens: 1_000_000,
output_tokens: 1_000_000,
}),
).toBeCloseTo(1.75 + 14, 5);
});
it("flags catalog SKUs without a published price (gpt-5.5-mini) as unmapped", () => {
// `gpt-5.5-mini` is in the Codex catalog but OpenAI hasn't published a
// public rate. We refuse to absorb it into `gpt-5.5` — the diagnostic
// surfaces it instead so the team knows to add an explicit row.
expect(isModelPriced("gpt-5.5-mini")).toBe(false);
expect(
estimateCost({
...zeroUsage,
model: "gpt-5.5-mini",
input_tokens: 1_000_000,
}),
).toBe(0);
});
it("flags hypothetical future variants as unmapped instead of inheriting a relative's price", () => {
// No exact match → unmapped. Covers both dotted families (`gpt-5.99-codex`)
// and unknown sub-variants (`gpt-5-foo`); both must miss rather than
// silently inherit `gpt-5` pricing.
expect(isModelPriced("gpt-5.99-codex")).toBe(false);
expect(isModelPriced("gpt-5-foo")).toBe(false);
expect(
estimateCost({
...zeroUsage,
model: "gpt-5.99-codex",
input_tokens: 1_000_000,
}),
).toBe(0);
});
it("returns 0 for a genuinely unknown model so the UI can flag it", () => {
expect(
estimateCost({
...zeroUsage,
model: "totally-made-up-model",
input_tokens: 1_000_000,
}),
).toBe(0);
});
});
describe("isModelPriced", () => {
it("recognises both Claude and Codex/GPT families", () => {
expect(isModelPriced("claude-sonnet-4-6")).toBe(true);
expect(isModelPriced("gpt-5-codex")).toBe(true);
expect(isModelPriced("gpt-5-mini")).toBe(true);
expect(isModelPriced("o3")).toBe(true);
expect(isModelPriced("totally-made-up-model")).toBe(false);
});
it("recognises dotted Anthropic IDs as the same SKU as their dashed canonical form", () => {
// GitHub Copilot reports Claude models with dots (`claude-opus-4.7`)
// while Anthropic's own CLIs use dashes (`claude-opus-4-7`). Both must
// hit the same catalog row, otherwise Copilot-routed usage gets bucketed
// as "unmapped" and the user has to type the price in by hand.
expect(isModelPriced("claude-haiku-4.5")).toBe(true);
expect(isModelPriced("claude-sonnet-4.5")).toBe(true);
expect(isModelPriced("claude-sonnet-4.6")).toBe(true);
expect(isModelPriced("claude-opus-4.5")).toBe(true);
expect(isModelPriced("claude-opus-4.6")).toBe(true);
expect(isModelPriced("claude-opus-4.7")).toBe(true);
});
it("recognises provider-prefixed Anthropic IDs (openclaw / opencode form)", () => {
// openclaw / opencode emit `<provider>/<model>` in `meta.agentMeta.model`.
// The provider prefix is routing metadata, not part of the SKU.
expect(isModelPriced("anthropic/claude-opus-4.7")).toBe(true);
expect(isModelPriced("anthropic/claude-sonnet-4-6")).toBe(true);
});
it("still rejects OpenAI dotted variants that don't have their own row", () => {
// The Anthropic dot→dash normalization is scoped to `claude-*` IDs.
// For OpenAI the separator is semantic — `gpt-5.4` is a different SKU
// from a hypothetical `gpt-5-4` — and `gpt-5.5-mini` must still surface
// as unmapped because OpenAI hasn't published its rate.
expect(isModelPriced("gpt-5.5-mini")).toBe(false);
});
});
describe("collectUnmappedModels", () => {
it("only surfaces names that miss every pricing tier", () => {
const rows = [
{ ...zeroUsage, model: "claude-sonnet-4-6" },
{ ...zeroUsage, model: "gpt-5-codex" },
{ ...zeroUsage, model: "fictional-model-x" },
];
expect(collectUnmappedModels(rows)).toEqual(["fictional-model-x"]);
});
});
describe("user-supplied custom pricing", () => {
it("prices a model the maintained catalog doesn't ship", () => {
useCustomPricingStore.getState().setCustomPricing("gpt-5.5-mini", {
input: 1,
output: 4,
cacheRead: 0.1,
cacheWrite: 1,
});
expect(isModelPriced("gpt-5.5-mini")).toBe(true);
expect(
estimateCost({
...zeroUsage,
model: "gpt-5.5-mini",
input_tokens: 1_000_000,
output_tokens: 1_000_000,
}),
).toBeCloseTo(5, 5);
});
it("does NOT shadow the maintained catalog when both define the same model", () => {
// Catalog wins so a user can't accidentally over-charge themselves for
// a model we already track (and so a stale local override doesn't
// silently disagree with what the dashboard shows everyone else).
useCustomPricingStore.getState().setCustomPricing("claude-sonnet-4-6", {
input: 999,
output: 999,
cacheRead: 999,
cacheWrite: 999,
});
expect(
estimateCost({
...zeroUsage,
model: "claude-sonnet-4-6",
input_tokens: 1_000_000,
}),
).toBeCloseTo(3, 5); // maintained input rate, not the 999 override
});
it("falls back to a stripped dated snapshot in the custom store", () => {
useCustomPricingStore.getState().setCustomPricing("brand-new-model", {
input: 2,
output: 8,
cacheRead: 0.2,
cacheWrite: 2,
});
expect(
estimateCost({
...zeroUsage,
model: "brand-new-model-2026-04-01",
input_tokens: 1_000_000,
}),
).toBeCloseTo(2, 5);
});
it("removeCustomPricing clears the override", () => {
const store = useCustomPricingStore.getState();
store.setCustomPricing("gpt-5.5-mini", {
input: 1,
output: 4,
cacheRead: 0.1,
cacheWrite: 1,
});
expect(isModelPriced("gpt-5.5-mini")).toBe(true);
useCustomPricingStore.getState().removeCustomPricing("gpt-5.5-mini");
expect(isModelPriced("gpt-5.5-mini")).toBe(false);
});
it("priced + unpriced models in the same window produce a mixed-cost aggregate", () => {
// The partial-unmapping case: chart renders normally because some
// models are priced, but the unmapped ones silently contribute $0 if
// we don't surface them. Confirm aggregateCostByModel exposes both
// sides so the UI can show a notice for the gap.
const rows = [
{
...zeroUsage,
model: "claude-sonnet-4-6",
input_tokens: 1_000_000,
date: "2026-01-01",
provider: "anthropic",
agent_count: 1,
},
{
...zeroUsage,
model: "fictional-model-x",
input_tokens: 1_000_000,
date: "2026-01-01",
provider: "fictional",
agent_count: 1,
},
];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const byModel = aggregateCostByModel(rows as any);
const sonnet = byModel.find((r) => r.key === "claude-sonnet-4-6");
const fictional = byModel.find((r) => r.key === "fictional-model-x");
expect(sonnet?.cost).toBeCloseTo(3, 5);
expect(fictional?.cost).toBe(0);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
expect(collectUnmappedModels(rows as any)).toEqual(["fictional-model-x"]);
});
it("aggregateCostByModel reflects a newly-saved custom price on re-call with the same input", () => {
// Regression for the memo-dependency bug GPT-Boy flagged: aggregate
// helpers must give different answers before vs after a price save,
// otherwise child components (WhenChart / CostByBlock / ActivityHeatmap)
// that memo on query data alone keep showing pre-save totals.
const rows = [
{
...zeroUsage,
model: "fictional-model-x",
input_tokens: 1_000_000,
date: "2026-01-01",
provider: "fictional",
agent_count: 1,
},
];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const before = aggregateCostByModel(rows as any);
expect(before[0]?.cost).toBe(0);
useCustomPricingStore.getState().setCustomPricing("fictional-model-x", {
input: 2,
output: 8,
cacheRead: 0.2,
cacheWrite: 2,
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const after = aggregateCostByModel(rows as any);
expect(after[0]?.cost).toBeCloseTo(2, 5);
});
});
// ---------------------------------------------------------------------------
// Calendar helpers + weekly aggregation. All of these run on YYYY-MM-DD
// strings (the wire shape of RuntimeUsage.date) and on a runtime-supplied
// IANA timezone — the host browser's tz should never affect the result.
// ---------------------------------------------------------------------------
describe("weekStartIso", () => {
it("returns the Monday of the same ISO week", () => {
// 2026-05-19 is a Tuesday → Monday is 2026-05-18.
expect(weekStartIso("2026-05-19")).toBe("2026-05-18");
});
it("treats Monday as the start of its own week (idempotent)", () => {
expect(weekStartIso("2026-05-18")).toBe("2026-05-18");
});
it("rolls Sunday back to the previous Monday", () => {
// 2026-05-17 is a Sunday → Monday is 2026-05-11.
expect(weekStartIso("2026-05-17")).toBe("2026-05-11");
});
it("crosses month and year boundaries", () => {
// 2026-01-03 is a Saturday → Monday is 2025-12-29.
expect(weekStartIso("2026-01-03")).toBe("2025-12-29");
});
});
describe("addDaysIso", () => {
it("adds across month boundary", () => {
expect(addDaysIso("2026-05-30", 3)).toBe("2026-06-02");
});
it("subtracts across year boundary", () => {
expect(addDaysIso("2026-01-02", -5)).toBe("2025-12-28");
});
});
describe("todayIso", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it("uses the runtime's timezone, not the host's, to decide today", () => {
// 2026-05-19 16:00 UTC. In Asia/Shanghai (UTC+8) it's already 2026-05-20.
// In America/Los_Angeles (UTC-7 on this date) it's still 2026-05-19.
vi.setSystemTime(new Date("2026-05-19T16:00:00Z"));
expect(todayIso("Asia/Shanghai")).toBe("2026-05-20");
expect(todayIso("America/Los_Angeles")).toBe("2026-05-19");
expect(todayIso("UTC")).toBe("2026-05-19");
});
});
describe("sliceWindow (timezone-aware)", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
function makeUsage(date: string): RuntimeUsage {
return {
runtime_id: "r",
date,
provider: "anthropic",
model: "claude-sonnet-4-6",
input_tokens: 0,
output_tokens: 0,
cache_read_tokens: 0,
cache_write_tokens: 0,
};
}
it("cuts the current window at today-in-tz, not today-in-host-utc", () => {
// Host clock is 2026-05-19 23:00 UTC → still May 19 in UTC, May 20 in Shanghai.
// A daily-usage row dated 2026-05-20 (the runtime's "today" in Shanghai)
// should be included in the current window when tz=Asia/Shanghai.
vi.setSystemTime(new Date("2026-05-19T23:00:00Z"));
const usage = [
makeUsage("2026-05-13"),
makeUsage("2026-05-19"),
makeUsage("2026-05-20"),
];
const { filtered } = sliceWindow(usage, 7, "Asia/Shanghai");
expect(filtered.map((u) => u.date)).toEqual([
"2026-05-13",
"2026-05-19",
"2026-05-20",
]);
});
it("returns the immediately prior window of equal length", () => {
vi.setSystemTime(new Date("2026-05-19T12:00:00Z"));
const usage = [
makeUsage("2026-05-01"),
makeUsage("2026-05-08"),
makeUsage("2026-05-15"),
makeUsage("2026-05-19"),
];
const { filtered, prevFiltered } = sliceWindow(usage, 7, "UTC");
expect(filtered.map((u) => u.date)).toEqual(["2026-05-15", "2026-05-19"]);
expect(prevFiltered.map((u) => u.date)).toEqual(["2026-05-08"]);
});
});
describe("aggregateByWeek", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
function makeUsage(
date: string,
input: number,
output: number,
): RuntimeUsage {
return {
runtime_id: "r",
date,
provider: "anthropic",
model: "claude-sonnet-4-6",
input_tokens: input,
output_tokens: output,
cache_read_tokens: 0,
cache_write_tokens: 0,
};
}
it("groups daily rows into Mon-anchored ISO weeks", () => {
// 2026-05-24 is Sunday, so the calendar week containing "today" is
// Mon=05-18..Sun=05-24. With weekCount=2 the window covers weeks
// 2026-05-11 and 2026-05-18 — exactly the two weeks the rows fall in.
vi.setSystemTime(new Date("2026-05-24T12:00:00Z"));
// 2026-05-11 is Mon; 2026-05-17 is Sun (same week).
// 2026-05-18 is Mon (next week).
const rows = [
makeUsage("2026-05-11", 1_000_000, 0),
makeUsage("2026-05-17", 0, 1_000_000),
makeUsage("2026-05-18", 2_000_000, 0),
];
const { weeklyTokens } = aggregateByWeek(rows, "UTC", 2);
expect(weeklyTokens).toHaveLength(2);
expect(weeklyTokens[0]).toMatchObject({
weekStart: "2026-05-11",
weekEnd: "2026-05-17",
input: 1_000_000,
output: 1_000_000,
partial: false,
daysCovered: 7,
});
expect(weeklyTokens[1]).toMatchObject({
weekStart: "2026-05-18",
weekEnd: "2026-05-24",
input: 2_000_000,
partial: false,
daysCovered: 7,
});
});
it("flags the in-progress week as partial with days-elapsed count", () => {
// 2026-05-20 is a Wednesday (Mon=05-18, Sun=05-24).
vi.setSystemTime(new Date("2026-05-20T08:00:00Z"));
const rows = [makeUsage("2026-05-18", 1_000_000, 0)];
const { weeklyTokens } = aggregateByWeek(rows, "UTC", 1);
expect(weeklyTokens[0]).toMatchObject({
weekStart: "2026-05-18",
weekEnd: "2026-05-24",
partial: true,
daysCovered: 3, // Mon, Tue, Wed
});
});
it("sums costs per week using the model pricing table", () => {
// 2026-05-17 sits in the calendar week of 2026-05-11..2026-05-17, so
// weekCount=1 anchors the window on that same week.
vi.setSystemTime(new Date("2026-05-17T12:00:00Z"));
// 1M input × $3 + 1M output × $15 = $18 per row. Two rows in the same
// week (Mon + Wed) → $36 weekly total.
const rows = [
makeUsage("2026-05-11", 1_000_000, 1_000_000),
makeUsage("2026-05-13", 1_000_000, 1_000_000),
];
const { weeklyCostStack } = aggregateByWeek(rows, "UTC", 1);
expect(weeklyCostStack).toHaveLength(1);
expect(weeklyCostStack[0]?.total).toBeCloseTo(36, 2);
});
it("emits trailing calendar weeks pinned to today, dropping older populated weeks", () => {
// Regression for MUL-2382 weekly window scoping:
// before the fix, aggregateByWeek built buckets only for weeks that had
// data and the caller did `.slice(-weekCount)`. With sparse data (an old
// populated week far outside the selected window plus an empty stretch
// closer to today), that slice would surface the OLD populated week
// instead of the trailing in-window weeks. The chart should now show
// exactly the trailing calendar weeks, with the empty in-range weeks
// present as zero-valued buckets rather than disappearing.
vi.setSystemTime(new Date("2026-05-19T12:00:00Z"));
// 30-day window @ 2026-05-19 → 5 trailing weeks (Mon=04-20, 04-27,
// 05-04, 05-11, 05-18). 2026-04-13 (Mon) is one week earlier — outside
// the window. No data in any of the 5 in-range weeks.
const rows = [makeUsage("2026-04-13", 1_000_000, 1_000_000)];
const { weeklyTokens, weeklyCostStack } = aggregateByWeek(rows, "UTC", 5);
expect(weeklyTokens.map((w) => w.weekStart)).toEqual([
"2026-04-20",
"2026-04-27",
"2026-05-04",
"2026-05-11",
"2026-05-18",
]);
// Every in-range week is empty — the old populated week was dropped.
for (const w of weeklyTokens) {
expect(w.input).toBe(0);
expect(w.output).toBe(0);
expect(w.cacheRead).toBe(0);
expect(w.cacheWrite).toBe(0);
}
for (const w of weeklyCostStack) {
expect(w.total).toBe(0);
}
});
it("keeps in-window weeks empty when nearby data sits inside the window", () => {
// Sparse-but-in-range case: only the oldest in-window week has data;
// the remaining trailing weeks must render as empty buckets, not be
// collapsed to a single populated bar.
vi.setSystemTime(new Date("2026-05-19T12:00:00Z"));
const rows = [makeUsage("2026-04-22", 1_000_000, 1_000_000)]; // week of 04-20
const { weeklyTokens } = aggregateByWeek(rows, "UTC", 5);
expect(weeklyTokens).toHaveLength(5);
expect(weeklyTokens[0]).toMatchObject({
weekStart: "2026-04-20",
input: 1_000_000,
output: 1_000_000,
});
for (const w of weeklyTokens.slice(1)) {
expect(w.input).toBe(0);
expect(w.output).toBe(0);
}
});
});