import { describe, expect, test } from "vitest"; import type { Session, Workspace } from "../src/types.js"; import { aggregateDailyDetail, aggregateStats, getActiveSessions, parseRange, parseTzOffset, type AggregateInput, } from "../src/routes/server-stats.js"; // ─── Helpers ─── function makeSession(overrides: Partial = {}): Session { return { id: "s-1", status: "stopped", createdAt: Date.now(), lastActivity: Date.now(), messageCount: 10, tokens: { input: 5000, output: 3000, cacheRead: 0, cacheWrite: 0 }, cost: 0.5, model: "anthropic/claude-sonnet-4-20250514", workspaceId: "ws-1", workspaceName: "coding", ...overrides, }; } function makeWorkspace(overrides: Partial = {}): Workspace { return { id: "ws-1", name: "coding", systemPromptMode: "append", createdAt: Date.now(), updatedAt: Date.now(), ...overrides, }; } const DAY_MS = 24 * 60 * 60 * 1000; // ─── parseRange ─── describe("parseRange", () => { test("defaults to 7 for null", () => { expect(parseRange(null)).toBe(7); }); test("defaults to 7 for empty string", () => { expect(parseRange("")).toBe(7); }); test("accepts 7, 30, 90", () => { expect(parseRange("7")).toBe(7); expect(parseRange("30")).toBe(30); expect(parseRange("90")).toBe(90); }); test("rejects invalid values", () => { expect(parseRange("14")).toBe(7); expect(parseRange("0")).toBe(7); expect(parseRange("365")).toBe(7); expect(parseRange("abc")).toBe(7); expect(parseRange("7abc")).toBe(7); expect(parseRange("-1")).toBe(7); expect(parseRange("7.0")).toBe(7); }); }); // ─── parseTzOffset ─── describe("parseTzOffset", () => { test("defaults to 0 for null", () => { expect(parseTzOffset(null)).toBe(0); }); test("defaults to 0 for empty string", () => { expect(parseTzOffset("")).toBe(0); }); test("parses valid offsets", () => { expect(parseTzOffset("-420")).toBe(-420); // PDT expect(parseTzOffset("540")).toBe(540); // JST expect(parseTzOffset("0")).toBe(0); // UTC expect(parseTzOffset("330")).toBe(330); // IST (+5:30) }); test("clamps to ±840", () => { expect(parseTzOffset("1000")).toBe(840); expect(parseTzOffset("-1000")).toBe(-840); }); test("rounds fractional values", () => { expect(parseTzOffset("-420.5")).toBe(-420); }); test("rejects non-numeric", () => { expect(parseTzOffset("abc")).toBe(0); expect(parseTzOffset("NaN")).toBe(0); }); }); // ─── getActiveSessions ─── describe("getActiveSessions", () => { test("without activeSessionIds, filters to non-stopped, non-error sessions (backward compat)", () => { const sessions = [ makeSession({ id: "s-1", status: "busy" }), makeSession({ id: "s-2", status: "stopped" }), makeSession({ id: "s-3", status: "ready" }), makeSession({ id: "s-4", status: "error" }), makeSession({ id: "s-5", status: "starting" }), makeSession({ id: "s-6", status: "stopping" }), ]; const active = getActiveSessions(sessions); expect(active.map((s) => s.id)).toEqual(["s-1", "s-3", "s-5", "s-6"]); }); test("with activeSessionIds, excludes zombie sessions not in memory", () => { const sessions = [ makeSession({ id: "s-1", status: "busy" }), makeSession({ id: "s-2", status: "stopped" }), makeSession({ id: "s-3", status: "ready" }), makeSession({ id: "s-4", status: "error" }), makeSession({ id: "s-5", status: "starting" }), // zombie — not in active set ]; // Only s-1 and s-3 are genuinely in memory const activeIds = new Set(["s-1", "s-3"]); const active = getActiveSessions(sessions, activeIds); expect(active.map((s) => s.id)).toEqual(["s-1", "s-3"]); }); test("maps fields correctly", () => { const sessions = [ makeSession({ id: "s-1", status: "busy", model: "anthropic/opus", cost: 1.234, name: "my session", firstMessage: "hello from first prompt", thinkingLevel: "high", contextTokens: 50000, contextWindow: 200000, createdAt: 1000, }), ]; const [s] = getActiveSessions(sessions); expect(s).toEqual({ id: "s-1", status: "busy", model: "anthropic/opus", cost: 1.23, name: "my session", firstMessage: "hello from first prompt", workspaceName: "coding", thinkingLevel: "high", contextTokens: 50000, contextWindow: 200000, createdAt: 1000, }); }); test("returns empty for no active sessions", () => { const sessions = [makeSession({ status: "stopped" }), makeSession({ status: "error" })]; expect(getActiveSessions(sessions)).toEqual([]); }); }); // ─── aggregateStats ─── describe("aggregateStats", () => { const now = new Date("2026-03-20T12:00:00Z").getTime(); function aggregate(overrides: Partial = {}): ReturnType { return aggregateStats({ sessions: [], workspaces: [makeWorkspace()], rangeDays: 7, now, ...overrides, }); } test("returns empty for no sessions", () => { const result = aggregate(); expect(result.daily).toEqual([]); expect(result.modelBreakdown).toEqual([]); expect(result.workspaceBreakdown).toEqual([]); expect(result.totals).toEqual({ sessions: 0, cost: 0, tokens: 0 }); }); test("filters sessions outside range", () => { const sessions = [ makeSession({ id: "in-range", createdAt: now - 1 * DAY_MS, cost: 1 }), makeSession({ id: "out-of-range", createdAt: now - 10 * DAY_MS, cost: 5 }), ]; const result = aggregate({ sessions }); expect(result.totals.sessions).toBe(1); expect(result.totals.cost).toBe(1); }); test("includes sessions exactly at cutoff and excludes sessions just before it", () => { const sessions = [ makeSession({ id: "at-cutoff", createdAt: now - 7 * DAY_MS, cost: 1 }), makeSession({ id: "before-cutoff", createdAt: now - 7 * DAY_MS - 1, cost: 100 }), ]; const result = aggregate({ sessions }); expect(result.totals.sessions).toBe(1); expect(result.daily.flatMap((day) => Object.keys(day.byModel))).toContain( "anthropic/claude-sonnet-4-20250514", ); }); test("aggregates daily breakdown", () => { const day1 = new Date("2026-03-18T10:00:00Z").getTime(); const day2 = new Date("2026-03-19T14:00:00Z").getTime(); const sessions = [ makeSession({ id: "s1", createdAt: day1, cost: 1, model: "sonnet", tokens: { input: 1000, output: 500, cacheRead: 0, cacheWrite: 0 }, }), makeSession({ id: "s2", createdAt: day1, cost: 2, model: "opus", tokens: { input: 2000, output: 1000, cacheRead: 0, cacheWrite: 0 }, }), makeSession({ id: "s3", createdAt: day2, cost: 0.5, model: "sonnet", tokens: { input: 500, output: 200, cacheRead: 0, cacheWrite: 0 }, }), ]; const result = aggregate({ sessions }); expect(result.daily).toHaveLength(2); // Day 1 expect(result.daily[0].date).toBe("2026-03-18"); expect(result.daily[0].sessions).toBe(2); expect(result.daily[0].cost).toBe(3); expect(result.daily[0].tokens).toBe(4500); expect(result.daily[0].byModel["sonnet"]).toEqual({ sessions: 1, cost: 1, tokens: 1500 }); expect(result.daily[0].byModel["opus"]).toEqual({ sessions: 1, cost: 2, tokens: 3000 }); // Day 2 expect(result.daily[1].date).toBe("2026-03-19"); expect(result.daily[1].sessions).toBe(1); expect(result.daily[1].cost).toBe(0.5); }); test("daily entries are sorted ascending by date", () => { const sessions = [ makeSession({ id: "s1", createdAt: new Date("2026-03-20T01:00:00Z").getTime(), cost: 1, model: "a", }), makeSession({ id: "s2", createdAt: new Date("2026-03-14T01:00:00Z").getTime(), cost: 1, model: "a", }), makeSession({ id: "s3", createdAt: new Date("2026-03-17T01:00:00Z").getTime(), cost: 1, model: "a", }), ]; const result = aggregate({ sessions }); const dates = result.daily.map((d) => d.date); expect(dates).toEqual(["2026-03-14", "2026-03-17", "2026-03-20"]); }); test("aggregates model breakdown with shares", () => { const sessions = [ makeSession({ id: "s1", createdAt: now - DAY_MS, cost: 3, model: "sonnet", tokens: { input: 1000, output: 1000, cacheRead: 0, cacheWrite: 0 }, }), makeSession({ id: "s2", createdAt: now - DAY_MS, cost: 7, model: "opus", tokens: { input: 5000, output: 2000, cacheRead: 0, cacheWrite: 0 }, }), ]; const result = aggregate({ sessions }); expect(result.modelBreakdown).toHaveLength(2); // Sorted by cost desc → opus first expect(result.modelBreakdown[0]).toEqual({ model: "opus", sessions: 1, cost: 7, tokens: 7000, inputTokens: 5000, cacheRead: 0, cacheWrite: 0, share: 0.7, }); expect(result.modelBreakdown[1]).toEqual({ model: "sonnet", sessions: 1, cost: 3, tokens: 2000, inputTokens: 1000, cacheRead: 0, cacheWrite: 0, share: 0.3, }); }); test("infers cacheWrite from input for OpenAI GPT models when cacheWrite is absent", () => { const sessions = [ makeSession({ id: "s1", createdAt: now - DAY_MS, cost: 5, model: "openai-codex/gpt-5.4", tokens: { input: 1200, output: 300, cacheRead: 5000, cacheWrite: 0 }, }), ]; const result = aggregate({ sessions }); expect(result.modelBreakdown).toHaveLength(1); expect(result.modelBreakdown[0]).toMatchObject({ model: "openai-codex/gpt-5.4", inputTokens: 1200, cacheRead: 5000, // OpenAI Responses-style fallback: write-equivalent = uncached input cacheWrite: 1200, tokens: 6500, }); }); test("prefers reported cacheWrite over inferred value for OpenAI GPT models", () => { const sessions = [ makeSession({ id: "s1", createdAt: now - DAY_MS, cost: 5, model: "openai-codex/gpt-5.4", tokens: { input: 1200, output: 300, cacheRead: 5000, cacheWrite: 77 }, }), ]; const result = aggregate({ sessions }); expect(result.modelBreakdown).toHaveLength(1); expect(result.modelBreakdown[0]?.cacheWrite).toBe(77); }); test("infers cacheWrite from input for DeepSeek models when cacheWrite is absent", () => { const sessions = [ makeSession({ id: "s1", createdAt: now - DAY_MS, cost: 5, model: "deepseek/deepseek-v4-pro", tokens: { input: 1200, output: 300, cacheRead: 5000, cacheWrite: 0 }, }), ]; const result = aggregate({ sessions }); expect(result.modelBreakdown).toHaveLength(1); expect(result.modelBreakdown[0]).toMatchObject({ model: "deepseek/deepseek-v4-pro", inputTokens: 1200, cacheRead: 5000, cacheWrite: 1200, tokens: 6500, }); }); test("infers cacheWrite from input for OpenRouter Kimi models when cacheWrite is absent", () => { const sessions = [ makeSession({ id: "s1", createdAt: now - DAY_MS, cost: 5, model: "openrouter/moonshotai/kimi-k2.6", tokens: { input: 1200, output: 300, cacheRead: 5000, cacheWrite: 0 }, }), ]; const result = aggregate({ sessions }); expect(result.modelBreakdown).toHaveLength(1); expect(result.modelBreakdown[0]).toMatchObject({ model: "openrouter/moonshotai/kimi-k2.6", inputTokens: 1200, cacheRead: 5000, cacheWrite: 1200, tokens: 6500, }); }); test("infers cacheWrite from input for OpenRouter GLM models when cacheWrite is absent", () => { const sessions = [ makeSession({ id: "s1", createdAt: now - DAY_MS, cost: 5, model: "openrouter/z.ai/glm-5.1", tokens: { input: 1200, output: 300, cacheRead: 5000, cacheWrite: 0 }, }), ]; const result = aggregate({ sessions }); expect(result.modelBreakdown).toHaveLength(1); expect(result.modelBreakdown[0]).toMatchObject({ model: "openrouter/z.ai/glm-5.1", inputTokens: 1200, cacheRead: 5000, cacheWrite: 1200, tokens: 6500, }); }); test("emits null cacheWrite for xAI Grok models when the provider has no write counter", () => { const sessions = [ makeSession({ id: "s1", createdAt: now - DAY_MS, cost: 5, model: "xai/grok-4.5", tokens: { input: 2100, output: 400, cacheRead: 35200000, cacheWrite: 0 }, }), ]; const result = aggregate({ sessions }); expect(result.modelBreakdown).toHaveLength(1); expect(result.modelBreakdown[0]).toMatchObject({ model: "xai/grok-4.5", inputTokens: 2100, cacheRead: 35200000, cacheWrite: null, tokens: 35202500, }); }); test("emits null cacheWrite for OpenRouter Grok models when the provider has no write counter", () => { const sessions = [ makeSession({ id: "s1", createdAt: now - DAY_MS, cost: 1, model: "openrouter/x-ai/grok-4.5", tokens: { input: 900, output: 200, cacheRead: 1400000, cacheWrite: 0 }, }), ]; const result = aggregate({ sessions }); expect(result.modelBreakdown).toHaveLength(1); expect(result.modelBreakdown[0]).toMatchObject({ model: "openrouter/x-ai/grok-4.5", inputTokens: 900, cacheRead: 1400000, cacheWrite: null, tokens: 1401100, }); }); test("keeps null cacheWrite when aggregating multiple Grok sessions", () => { const sessions = [ makeSession({ id: "s1", createdAt: now - DAY_MS, cost: 3, model: "xai/grok-4.5", tokens: { input: 1000, output: 100, cacheRead: 10000, cacheWrite: 0 }, }), makeSession({ id: "s2", createdAt: now - DAY_MS, cost: 2, model: "xai/grok-4.5", tokens: { input: 500, output: 50, cacheRead: 8000, cacheWrite: 0 }, }), ]; const result = aggregate({ sessions }); expect(result.modelBreakdown).toHaveLength(1); expect(result.modelBreakdown[0]).toMatchObject({ model: "xai/grok-4.5", inputTokens: 1500, cacheRead: 18000, cacheWrite: null, }); }); test("keeps cache-rate inputs consistent when mixing reported and inferred cache writes", () => { const sessions = [ makeSession({ id: "kimi", createdAt: now - DAY_MS, cost: 3, model: "openrouter/moonshotai/kimi-k2.6", tokens: { input: 200, output: 50, cacheRead: 800, cacheWrite: 0 }, }), makeSession({ id: "glm", createdAt: now - DAY_MS, cost: 2, model: "openrouter/z.ai/glm-5.1", tokens: { input: 100, output: 25, cacheRead: 400, cacheWrite: 40 }, }), ]; const result = aggregate({ sessions }); const kimi = result.modelBreakdown.find((entry) => entry.model.includes("kimi")); const glm = result.modelBreakdown.find((entry) => entry.model.includes("glm")); expect(kimi).toMatchObject({ inputTokens: 200, cacheRead: 800, cacheWrite: 200 }); expect(glm).toMatchObject({ inputTokens: 100, cacheRead: 400, cacheWrite: 40 }); }); test("does not infer cacheWrite for models without an inference rule", () => { const sessions = [ makeSession({ id: "s1", createdAt: now - DAY_MS, cost: 5, model: "anthropic/claude-opus-4-6", tokens: { input: 1200, output: 300, cacheRead: 5000, cacheWrite: 0 }, }), ]; const result = aggregate({ sessions }); expect(result.modelBreakdown).toHaveLength(1); expect(result.modelBreakdown[0]?.cacheWrite).toBe(0); }); test("aggregates workspace breakdown", () => { const workspaces = [ makeWorkspace({ id: "ws-1", name: "coding" }), makeWorkspace({ id: "ws-2", name: "research" }), ]; const sessions = [ makeSession({ id: "s1", createdAt: now - DAY_MS, cost: 5, workspaceId: "ws-1" }), makeSession({ id: "s2", createdAt: now - DAY_MS, cost: 3, workspaceId: "ws-1" }), makeSession({ id: "s3", createdAt: now - DAY_MS, cost: 10, workspaceId: "ws-2" }), ]; const result = aggregate({ sessions, workspaces }); expect(result.workspaceBreakdown).toHaveLength(2); // Sorted by cost desc → ws-2 first expect(result.workspaceBreakdown[0]).toEqual({ id: "ws-2", name: "research", sessions: 1, cost: 10, }); expect(result.workspaceBreakdown[1]).toEqual({ id: "ws-1", name: "coding", sessions: 2, cost: 8, }); }); test("workspace breakdown falls back to id when workspace not found", () => { const sessions = [ makeSession({ id: "s1", createdAt: now - DAY_MS, cost: 1, workspaceId: "deleted-ws" }), ]; const result = aggregate({ sessions, workspaces: [] }); expect(result.workspaceBreakdown[0].name).toBe("deleted-ws"); }); test("sessions without workspaceId grouped as 'unknown'", () => { const sessions = [ makeSession({ id: "s1", createdAt: now - DAY_MS, cost: 2, workspaceId: undefined }), ]; const result = aggregate({ sessions }); expect(result.workspaceBreakdown[0].id).toBe("unknown"); }); test("sessions without model grouped as 'unknown'", () => { const sessions = [ makeSession({ id: "s1", createdAt: now - DAY_MS, cost: 1, model: undefined }), ]; const result = aggregate({ sessions }); expect(result.modelBreakdown[0].model).toBe("unknown"); }); test("treats model ids as opaque case-sensitive values", () => { const sessions = [ makeSession({ id: "s1", createdAt: now - DAY_MS, cost: 1, model: "GPT-5" }), makeSession({ id: "s2", createdAt: now - DAY_MS, cost: 2, model: "gpt-5" }), ]; const result = aggregate({ sessions }); expect(result.modelBreakdown.map((entry) => entry.model).sort()).toEqual(["GPT-5", "gpt-5"]); }); test("totals sum all sessions in range", () => { const sessions = [ makeSession({ id: "s1", createdAt: now - 1 * DAY_MS, cost: 1.5, tokens: { input: 1000, output: 500, cacheRead: 0, cacheWrite: 0 }, }), makeSession({ id: "s2", createdAt: now - 2 * DAY_MS, cost: 2.3, tokens: { input: 2000, output: 1000, cacheRead: 0, cacheWrite: 0 }, }), makeSession({ id: "s3", createdAt: now - 2 * DAY_MS, cost: 0.7, tokens: { input: 500, output: 200, cacheRead: 0, cacheWrite: 0 }, }), ]; const result = aggregate({ sessions }); expect(result.totals).toEqual({ sessions: 3, cost: 4.5, tokens: 5200 }); }); test("rounds floating-point costs in daily and totals", () => { const sessions = [makeSession({ id: "s1", createdAt: now - DAY_MS, cost: 0.1 + 0.2 })]; const result = aggregate({ sessions }); expect(result.daily[0].cost).toBe(0.3); expect(result.totals.cost).toBe(0.3); }); test("handles missing cost and token fields from stale records", () => { const session = { ...makeSession({ id: "stale-record", createdAt: now - DAY_MS }), cost: undefined, tokens: undefined, } as unknown as Session; const result = aggregate({ sessions: [session] }); expect(result.totals).toEqual({ sessions: 1, cost: 0, tokens: 0 }); expect(result.daily[0].tokens).toBe(0); }); test("respects range parameter", () => { const sessions = [ makeSession({ id: "recent", createdAt: now - 5 * DAY_MS, cost: 1 }), makeSession({ id: "older", createdAt: now - 15 * DAY_MS, cost: 2 }), makeSession({ id: "oldest", createdAt: now - 60 * DAY_MS, cost: 3 }), ]; expect(aggregate({ sessions, rangeDays: 7 }).totals.sessions).toBe(1); expect(aggregate({ sessions, rangeDays: 30 }).totals.sessions).toBe(2); expect(aggregate({ sessions, rangeDays: 90 }).totals.sessions).toBe(3); }); test("share is 0 when total cost is 0", () => { const sessions = [makeSession({ id: "s1", createdAt: now - DAY_MS, cost: 0, model: "free" })]; const result = aggregate({ sessions }); expect(result.modelBreakdown[0].share).toBe(0); }); test("tzOffsetMin shifts daily bucketing to local time", () => { // Session at Mar 20 00:30 UTC → still Mar 19 in PDT (UTC-7) const sessions = [ makeSession({ id: "s1", createdAt: new Date("2026-03-20T00:30:00Z").getTime(), cost: 5, model: "sonnet", }), ]; // Without tz: bucketed as Mar 20 const utcResult = aggregate({ sessions }); expect(utcResult.daily[0].date).toBe("2026-03-20"); // With PDT offset: bucketed as Mar 19 const localResult = aggregate({ sessions, tzOffsetMin: -420 }); expect(localResult.daily[0].date).toBe("2026-03-19"); }); }); // ─── aggregateDailyDetail ─── describe("aggregateDailyDetail", () => { const HOUR_MS = 60 * 60 * 1000; const baseDate = new Date("2026-03-21T00:00:00Z").getTime(); test("returns empty results for day with no sessions", () => { const result = aggregateDailyDetail([], "2026-03-21"); expect(result.date).toBe("2026-03-21"); expect(result.totals).toEqual({ sessions: 0, cost: 0, tokens: 0 }); expect(result.hourly).toEqual([]); expect(result.sessions).toEqual([]); }); test("groups sessions by hour correctly", () => { const sessions = [ makeSession({ id: "s-1", createdAt: baseDate + 8.5 * HOUR_MS, cost: 10, tokens: { input: 20000, output: 10000, cacheRead: 0, cacheWrite: 0 }, }), makeSession({ id: "s-2", createdAt: baseDate + 14 * HOUR_MS, cost: 20, tokens: { input: 40000, output: 20000, cacheRead: 0, cacheWrite: 0 }, }), ]; const result = aggregateDailyDetail(sessions, "2026-03-21"); expect(result.hourly).toHaveLength(2); expect(result.hourly[0].hour).toBe(8); expect(result.hourly[0].sessions).toBe(1); expect(result.hourly[0].cost).toBe(10); expect(result.hourly[0].tokens).toBe(30000); expect(result.hourly[1].hour).toBe(14); expect(result.hourly[1].sessions).toBe(1); }); test("includes byModel breakdown per hour", () => { const sessions = [ makeSession({ id: "s-1", createdAt: baseDate + 8.5 * HOUR_MS, cost: 10, model: "anthropic/claude-opus-4-6", tokens: { input: 20000, output: 10000, cacheRead: 0, cacheWrite: 0 }, }), makeSession({ id: "s-2", createdAt: baseDate + 8.75 * HOUR_MS, cost: 5, model: "openai/gpt-5.4", tokens: { input: 10000, output: 5000, cacheRead: 0, cacheWrite: 0 }, }), ]; const result = aggregateDailyDetail(sessions, "2026-03-21"); expect(result.hourly).toHaveLength(1); expect(result.hourly[0].hour).toBe(8); expect(result.hourly[0].byModel["anthropic/claude-opus-4-6"]).toEqual({ sessions: 1, cost: 10, tokens: 30000, }); expect(result.hourly[0].byModel["openai/gpt-5.4"]).toEqual({ sessions: 1, cost: 5, tokens: 15000, }); }); test("returns session list sorted by createdAt", () => { const sessions = [ makeSession({ id: "s-3", createdAt: baseDate + 14 * HOUR_MS, cost: 20 }), makeSession({ id: "s-1", createdAt: baseDate + 8 * HOUR_MS, cost: 10 }), makeSession({ id: "s-2", createdAt: baseDate + 10 * HOUR_MS, cost: 5 }), ]; const result = aggregateDailyDetail(sessions, "2026-03-21"); expect(result.sessions.map((s) => s.id)).toEqual(["s-1", "s-2", "s-3"]); }); test("excludes sessions from other days", () => { const sessions = [ makeSession({ id: "s-today", createdAt: baseDate + 8 * HOUR_MS, cost: 10 }), makeSession({ id: "s-yesterday", createdAt: baseDate - 1 * HOUR_MS, cost: 50, }), makeSession({ id: "s-tomorrow", createdAt: baseDate + 25 * HOUR_MS, cost: 50, }), ]; const result = aggregateDailyDetail(sessions, "2026-03-21"); expect(result.sessions).toHaveLength(1); expect(result.sessions[0].id).toBe("s-today"); expect(result.totals.sessions).toBe(1); expect(result.totals.cost).toBe(10); }); test("handles multiple sessions in same hour", () => { const sessions = [ makeSession({ id: "s-1", createdAt: baseDate + 8.5 * HOUR_MS, cost: 10, model: "anthropic/claude-opus-4-6", tokens: { input: 20000, output: 10000, cacheRead: 0, cacheWrite: 0 }, }), makeSession({ id: "s-2", createdAt: baseDate + 8.75 * HOUR_MS, cost: 5, model: "openai/gpt-5.4", tokens: { input: 10000, output: 5000, cacheRead: 0, cacheWrite: 0 }, }), makeSession({ id: "s-3", createdAt: baseDate + 14 * HOUR_MS, cost: 20, tokens: { input: 40000, output: 20000, cacheRead: 0, cacheWrite: 0 }, }), ]; const result = aggregateDailyDetail(sessions, "2026-03-21"); expect(result.hourly).toHaveLength(2); expect(result.hourly[0].hour).toBe(8); expect(result.hourly[0].sessions).toBe(2); expect(result.hourly[0].cost).toBe(15); expect(result.hourly[0].tokens).toBe(45000); expect(result.hourly[1].hour).toBe(14); expect(result.hourly[1].sessions).toBe(1); expect(result.sessions).toHaveLength(3); expect(result.totals).toEqual({ sessions: 3, cost: 35, tokens: 105000 }); }); test("maps session fields correctly", () => { const sessions = [ makeSession({ id: "s-1", name: "Fix crash in timeline", model: "anthropic/claude-opus-4-6", cost: 10.456, tokens: { input: 20000, output: 10000, cacheRead: 0, cacheWrite: 0 }, createdAt: baseDate + 8 * HOUR_MS, workspaceName: "oppi", status: "stopped", }), ]; const result = aggregateDailyDetail(sessions, "2026-03-21"); expect(result.sessions[0]).toEqual({ id: "s-1", name: "Fix crash in timeline", model: "anthropic/claude-opus-4-6", cost: 10.46, tokens: 30000, createdAt: baseDate + 8 * HOUR_MS, workspaceName: "oppi", status: "stopped", }); }); test("sessions without model grouped as 'unknown' in hourly", () => { const sessions = [ makeSession({ id: "s-1", createdAt: baseDate + 8 * HOUR_MS, cost: 5, model: undefined, tokens: { input: 1000, output: 500, cacheRead: 0, cacheWrite: 0 }, }), ]; const result = aggregateDailyDetail(sessions, "2026-03-21"); expect(result.hourly[0].byModel["unknown"]).toEqual({ sessions: 1, cost: 5, tokens: 1500, }); }); test("tzOffsetMin shifts day boundaries and hourly buckets", () => { // Session at Mar 21 02:00 UTC = Mar 20 19:00 PDT (UTC-7) // Should NOT appear in Mar 21 local day when using PDT offset. const sessions = [ makeSession({ id: "s-utc-morning", createdAt: new Date("2026-03-21T02:00:00Z").getTime(), cost: 10, tokens: { input: 1000, output: 500, cacheRead: 0, cacheWrite: 0 }, }), // Session at Mar 21 10:00 UTC = Mar 21 03:00 PDT // Should appear in Mar 21 local day at hour 3. makeSession({ id: "s-local-morning", createdAt: new Date("2026-03-21T10:00:00Z").getTime(), cost: 20, tokens: { input: 2000, output: 1000, cacheRead: 0, cacheWrite: 0 }, }), ]; const result = aggregateDailyDetail(sessions, "2026-03-21", -420); // Only the 10:00 UTC session falls in Mar 21 PDT expect(result.sessions).toHaveLength(1); expect(result.sessions[0].id).toBe("s-local-morning"); expect(result.totals.sessions).toBe(1); expect(result.totals.cost).toBe(20); // Hourly: should be bucketed at hour 3 (local) expect(result.hourly).toHaveLength(1); expect(result.hourly[0].hour).toBe(3); }); test("tzOffsetMin=0 behaves like UTC (backward compat)", () => { const sessions = [ makeSession({ id: "s-1", createdAt: baseDate + 8 * HOUR_MS, cost: 10, }), ]; const withZero = aggregateDailyDetail(sessions, "2026-03-21", 0); const withoutArg = aggregateDailyDetail(sessions, "2026-03-21"); expect(withZero.sessions).toEqual(withoutArg.sessions); expect(withZero.hourly).toEqual(withoutArg.hourly); expect(withZero.totals).toEqual(withoutArg.totals); }); test("positive tzOffsetMin works (e.g. JST UTC+9)", () => { // Session at Mar 20 23:00 UTC = Mar 21 08:00 JST // Should appear in Mar 21 JST day. const sessions = [ makeSession({ id: "s-jst", createdAt: new Date("2026-03-20T23:00:00Z").getTime(), cost: 15, tokens: { input: 3000, output: 1500, cacheRead: 0, cacheWrite: 0 }, }), ]; const result = aggregateDailyDetail(sessions, "2026-03-21", 540); expect(result.sessions).toHaveLength(1); expect(result.sessions[0].id).toBe("s-jst"); expect(result.hourly).toHaveLength(1); expect(result.hourly[0].hour).toBe(8); // 23:00 UTC + 9h = 08:00 JST }); });