diff --git a/.gitignore b/.gitignore index a33c27b..49fe389 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ node_modules/ .DS_Store test-results/ playwright-report/ +docs/superpowers diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100644 index 0000000..c60a88e --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1,2 @@ +bun run check +bun x tsc --noEmit diff --git a/.husky/pre-push b/.husky/pre-push new file mode 100644 index 0000000..a6483d5 --- /dev/null +++ b/.husky/pre-push @@ -0,0 +1 @@ +bun test tests/unit diff --git a/bun.lock b/bun.lock index f239208..f6ac505 100644 --- a/bun.lock +++ b/bun.lock @@ -11,6 +11,7 @@ "@biomejs/biome": "^2.4.15", "@playwright/test": "^1.59.1", "@types/bun": "^1.3.13", + "husky": "^9.1.7", "playwright": "^1.59.1", }, }, @@ -72,6 +73,8 @@ "fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], + "husky": ["husky@9.1.7", "", { "bin": { "husky": "bin.js" } }, "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA=="], + "ini": ["ini@6.0.0", "", {}, "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ=="], "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], diff --git a/package.json b/package.json index a7f1ecd..c847659 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,8 @@ "typecheck": "bun x tsc --noEmit", "test": "bun test tests/unit", "test:unit": "bun test tests/unit", - "test:e2e": "playwright test" + "test:e2e": "playwright test", + "prepare": "husky" }, "dependencies": { "@opencode-ai/plugin": "^1.4.7" @@ -37,6 +38,7 @@ "@biomejs/biome": "^2.4.15", "@playwright/test": "^1.59.1", "@types/bun": "^1.3.13", + "husky": "^9.1.7", "playwright": "^1.59.1" } } diff --git a/src/dashboard/routes/page-route.ts b/src/dashboard/routes/page-route.ts index 8d7a2ef..6daa4ad 100644 --- a/src/dashboard/routes/page-route.ts +++ b/src/dashboard/routes/page-route.ts @@ -23,6 +23,8 @@ export function createPageRoute( const costSummary = dailyTokens.getCostSummary(); const daily = dailyTokens.getDailyTokens(); const dailyModel = dailyTokens.getDailyTokensByModel(); + const dailyCost = dailyTokens.getDailyCost(); + const dailyModelCost = dailyTokens.getDailyModelCost(); const toolGroups = repos.toolCalls.getToolUsageSummary(); return new Response( renderHTML( @@ -34,6 +36,8 @@ export function createPageRoute( toolGroups, directories, dirFilter, + dailyCost, + dailyModelCost, ), { headers: { "Content-Type": "text/html; charset=utf-8" }, diff --git a/src/dashboard/routes/stats-route.ts b/src/dashboard/routes/stats-route.ts index 592cf28..88cd76e 100644 --- a/src/dashboard/routes/stats-route.ts +++ b/src/dashboard/routes/stats-route.ts @@ -46,6 +46,8 @@ export function createStatsRoute( const costSummary = dailyTokens.getCostSummary(); const daily = dailyTokens.getDailyTokens(); const dailyModel = dailyTokens.getDailyTokensByModel(); + const dailyCost = dailyTokens.getDailyCost(); + const dailyModelCost = dailyTokens.getDailyModelCost(); const toolGroups = repos.toolCalls.getToolUsageSummary(); const html = renderSessionsFragment( sessions, @@ -56,6 +58,8 @@ export function createStatsRoute( toolGroups, directories, dirFilter, + dailyCost, + dailyModelCost, ); cache.set(cacheKey, { html, expiry: now + CACHE_TTL_MS }); diff --git a/src/dashboard/services/daily-tokens-service.ts b/src/dashboard/services/daily-tokens-service.ts index 13ab900..4d988a6 100644 --- a/src/dashboard/services/daily-tokens-service.ts +++ b/src/dashboard/services/daily-tokens-service.ts @@ -9,6 +9,8 @@ import type { DailyTokens } from "../../db/shared-types"; export interface DailyTokensService { getDailyTokens(): DailyTokens[]; getDailyTokensByModel(): DailyModelTokens[]; + getDailyCost(): DailyTokens[]; + getDailyModelCost(): DailyModelTokens[]; getTokenSummary(): TokenSummary; getCostSummary(): CostSummary; } @@ -38,6 +40,29 @@ export function createDailyTokensService(repos: Repos): DailyTokensService { return repos.messages.getDailyTokensByModel(); }, + getDailyCost(): DailyTokens[] { + const today = new Date().toISOString().slice(0, 10); + const todayRow = repos.messages.getTodayCost(today); + const historyRows = repos.dailyUsage.getHistoryUntilCost(today, 60); + + const dataMap = new Map(); + for (const row of historyRows) dataMap.set(row.date, row.total); + dataMap.set(todayRow.date, todayRow.total); + + const result: DailyTokens[] = []; + for (let i = 59; i >= 0; i--) { + const d = new Date(); + d.setDate(d.getDate() - i); + const key = d.toISOString().slice(0, 10); + result.push({ date: key, total: dataMap.get(key) ?? 0 }); + } + return result; + }, + + getDailyModelCost(): DailyModelTokens[] { + return repos.messages.getDailyModelCost(); + }, + getTokenSummary(): TokenSummary { return repos.messages.getTokenSummary(); }, diff --git a/src/dashboard/templates/daily-chart.ts b/src/dashboard/templates/daily-chart.ts index 4f5b03c..a28380c 100644 --- a/src/dashboard/templates/daily-chart.ts +++ b/src/dashboard/templates/daily-chart.ts @@ -1,5 +1,5 @@ import type { DailyTokens } from "../../db/shared-types"; -import { fmt } from "./formatters"; +import { fmt, fmtCost } from "./formatters"; export function renderDailyChart(daily: DailyTokens[]): string { const dataMap = new Map(); @@ -61,3 +61,63 @@ export function renderDailyChart(daily: DailyTokens[]): string { `; } + +export function renderDailyCostChart(daily: DailyTokens[]): string { + const dataMap = new Map(); + for (const d of daily) dataMap.set(d.date, d.total); + + const days: { date: string; total: number }[] = []; + for (let i = 59; i >= 0; i--) { + const d = new Date(); + d.setDate(d.getDate() - i); + const key = d.toISOString().slice(0, 10); + days.push({ date: key, total: dataMap.get(key) ?? 0 }); + } + + const max = Math.max(...days.map((d) => d.total)); + + const bars = days + .map((d) => { + const pct = + max > 0 && d.total > 0 + ? Math.max(1, Math.round((d.total / max) * 100)) + : 0; + const dateObj = new Date(`${d.date}T00:00:00`); + const weekday = dateObj.toLocaleDateString("en-US", { weekday: "short" }); + const day = String(dateObj.getDate()).padStart(2, "0"); + const month = dateObj.toLocaleDateString("en-US", { month: "short" }); + const tooltipDate = `${weekday}, ${day} ${month}`; + return ` +
+ ${d.total > 0 ? `
${fmtCost(d.total)}
` : ""} +
+
${tooltipDate}
${fmtCost(d.total)}
+
`; + }) + .join(""); + + const avgPoints: { x: number; y: number }[] = []; + for (let i = 0; i < days.length; i++) { + const window = days.slice(Math.max(0, i - 4), i + 1); + const avg = window.reduce((s, d) => s + d.total, 0) / window.length; + const xPct = ((i + 0.5) / days.length) * 100; + const yPct = max > 0 ? 100 - (avg / max) * 100 : 100; + avgPoints.push({ x: xPct, y: yPct }); + } + const polyline = avgPoints.map((p) => `${p.x},${p.y}`).join(" "); + + return ` +
+
Daily Cost (last 60 days)
+
+ ${bars} + + + +
+
+ Daily cost + 5-day avg +
+
`; +} diff --git a/src/dashboard/templates/model-chart.ts b/src/dashboard/templates/model-chart.ts index ada0953..9cb8ba6 100644 --- a/src/dashboard/templates/model-chart.ts +++ b/src/dashboard/templates/model-chart.ts @@ -1,5 +1,5 @@ import type { DailyModelTokens } from "../../db/message/message-repo"; -import { esc, fmt } from "./formatters"; +import { esc, fmt, fmtCost } from "./formatters"; export const MODEL_COLORS = [ "#58a6ff", @@ -101,3 +101,93 @@ export function renderDailyModelChart(modelData: DailyModelTokens[]): string { `; } + +export function renderDailyModelCostChart( + modelData: DailyModelTokens[], +): string { + const modelTotals = new Map(); + for (const d of modelData) { + modelTotals.set(d.model, (modelTotals.get(d.model) ?? 0) + d.total); + } + const models = [...modelTotals.entries()] + .sort((a, b) => b[1] - a[1]) + .map(([m]) => m); + + const colorMap = new Map(); + for (const [i, m] of models.entries()) { + colorMap.set(m, MODEL_COLORS[i % MODEL_COLORS.length]!); + } + + const dataMap = new Map>(); + for (const d of modelData) { + if (!dataMap.has(d.date)) dataMap.set(d.date, new Map()); + dataMap.get(d.date)?.set(d.model, d.total); + } + + const days: { date: string; byModel: Map; total: number }[] = + []; + for (let i = 59; i >= 0; i--) { + const dt = new Date(); + dt.setDate(dt.getDate() - i); + const key = dt.toISOString().slice(0, 10); + const byModel = dataMap.get(key) ?? new Map(); + const total = [...byModel.values()].reduce((s, v) => s + v, 0); + days.push({ date: key, byModel, total }); + } + + const max = Math.max(...days.map((d) => d.total), 1); + + const bars = days + .map((d) => { + const dateObj = new Date(`${d.date}T00:00:00`); + const weekday = dateObj.toLocaleDateString("en-US", { weekday: "short" }); + const day = String(dateObj.getDate()).padStart(2, "0"); + const month = dateObj.toLocaleDateString("en-US", { month: "short" }); + const tooltipDate = `${weekday}, ${day} ${month}`; + + const segments = models + .map((m) => { + const val = d.byModel.get(m) ?? 0; + if (val === 0) return ""; + const pct = (val / max) * 100; + const color = colorMap.get(m)!; + return `
`; + }) + .join(""); + + const tooltipLines = models + .filter((m) => (d.byModel.get(m) ?? 0) > 0) + .map((m) => { + const color = colorMap.get(m)!; + return ` ${esc(m)}: ${fmtCost(d.byModel.get(m)!)}`; + }) + .join("
"); + + return ` +
+
+ ${segments} +
+
${tooltipDate}
${tooltipLines}
+
`; + }) + .join(""); + + const legend = models + .map((m) => { + const color = colorMap.get(m)!; + return `${esc(m)}`; + }) + .join(""); + + return ` +
+
Daily Cost by Model (last 60 days)
+
+ ${bars} +
+
+ ${legend} +
+
`; +} diff --git a/src/dashboard/templates/page-template.ts b/src/dashboard/templates/page-template.ts index 2bcdeb4..d1f2d9b 100644 --- a/src/dashboard/templates/page-template.ts +++ b/src/dashboard/templates/page-template.ts @@ -90,6 +90,8 @@ export function renderHTML( toolGroups: ToolGroupSummary[], directories: string[] = [], selectedDir?: string, + dailyCost: DailyTokens[] = [], + dailyModelCost: DailyModelTokens[] = [], ): string { return ` @@ -109,7 +111,7 @@ export function renderHTML(
- ${renderSessionsFragment(sessions, summary, costSummary, daily, dailyModel, toolGroups, directories, selectedDir)} + ${renderSessionsFragment(sessions, summary, costSummary, daily, dailyModel, toolGroups, directories, selectedDir, dailyCost, dailyModelCost)}
diff --git a/src/dashboard/templates/sessions-fragment.ts b/src/dashboard/templates/sessions-fragment.ts index df87f17..94e0eec 100644 --- a/src/dashboard/templates/sessions-fragment.ts +++ b/src/dashboard/templates/sessions-fragment.ts @@ -6,9 +6,12 @@ import type { import type { DailyTokens } from "../../db/shared-types"; import type { ToolGroupSummary } from "../../db/tool-call/tool-call-repo"; import type { SessionStats } from "../services/types"; -import { renderDailyChart } from "./daily-chart"; +import { renderDailyChart, renderDailyCostChart } from "./daily-chart"; import { esc } from "./formatters"; -import { renderDailyModelChart } from "./model-chart"; +import { + renderDailyModelChart, + renderDailyModelCostChart, +} from "./model-chart"; import { renderSessionCard } from "./session-card"; import { renderStatsBar } from "./stats-bar"; import { renderToolUsage } from "./tool-usage"; @@ -22,10 +25,14 @@ export function renderSessionsFragment( toolGroups: ToolGroupSummary[], directories: string[] = [], selectedDir?: string, + dailyCost: DailyTokens[] = [], + dailyModelCost: DailyModelTokens[] = [], ): string { const bar = renderStatsBar(summary, costSummary); const chart = renderDailyChart(daily); + const costChart = renderDailyCostChart(dailyCost); const modelChart = renderDailyModelChart(dailyModel); + const modelCostChart = renderDailyModelCostChart(dailyModelCost); const toolUsage = renderToolUsage(toolGroups); const leftPanel = ` @@ -33,7 +40,9 @@ export function renderSessionsFragment( ${bar}
${chart} + ${costChart} ${modelChart} + ${modelCostChart} ${toolUsage} `; diff --git a/src/db/daily-usage/daily-usage-repo.ts b/src/db/daily-usage/daily-usage-repo.ts index ac2697c..78118be 100644 --- a/src/db/daily-usage/daily-usage-repo.ts +++ b/src/db/daily-usage/daily-usage-repo.ts @@ -3,4 +3,8 @@ import type { DailyTokens } from "../shared-types"; export interface DailyUsageRepo { recompute(fromDay: string, toDay: string): void; getHistoryUntil(dayExclusive: string, lookbackDays: number): DailyTokens[]; + getHistoryUntilCost( + dayExclusive: string, + lookbackDays: number, + ): DailyTokens[]; } diff --git a/src/db/daily-usage/sqlite-daily-usage-repo.ts b/src/db/daily-usage/sqlite-daily-usage-repo.ts index f155297..eafbf2f 100644 --- a/src/db/daily-usage/sqlite-daily-usage-repo.ts +++ b/src/db/daily-usage/sqlite-daily-usage-repo.ts @@ -77,4 +77,19 @@ export class SqliteDailyUsageRepo implements DailyUsageRepo { `) .all(dayExclusive, `-${lookbackDays} days`) as DailyTokens[]; } + + getHistoryUntilCost( + dayExclusive: string, + lookbackDays: number, + ): DailyTokens[] { + return this.db + .prepare(` + SELECT day AS date, cost_total AS total + FROM daily_usage + WHERE day < ? + AND day >= date('now', ?) + ORDER BY day ASC + `) + .all(dayExclusive, `-${lookbackDays} days`) as DailyTokens[]; + } } diff --git a/src/db/message/message-repo.ts b/src/db/message/message-repo.ts index 6d76408..b66e69b 100644 --- a/src/db/message/message-repo.ts +++ b/src/db/message/message-repo.ts @@ -54,6 +54,8 @@ export interface MessageRepo { getTokenSummary(): TokenSummary; getCostSummary(): CostSummary; getTodayTokens(today: string): DailyTokens; + getTodayCost(today: string): DailyTokens; getDailyTokensByModel(): DailyModelTokens[]; + getDailyModelCost(): DailyModelTokens[]; deleteOlderThan(cutoffDate: string): number; } diff --git a/src/db/message/sqlite-message-repo.ts b/src/db/message/sqlite-message-repo.ts index 954ea5c..2d08407 100644 --- a/src/db/message/sqlite-message-repo.ts +++ b/src/db/message/sqlite-message-repo.ts @@ -14,6 +14,7 @@ export class SqliteMessageRepo implements MessageRepo { private readonly tokenSummaryStmt; private readonly costSummaryStmt; private readonly todayTokensStmt; + private readonly todayCostStmt; constructor(private readonly db: Database) { this.upsertMessageStmt = this.db.prepare(` @@ -69,6 +70,13 @@ export class SqliteMessageRepo implements MessageRepo { FROM messages WHERE timestamp >= ? AND timestamp < date(?, '+1 day') `); + + this.todayCostStmt = this.db.prepare(` + SELECT ? AS date, + COALESCE(SUM(cost), 0) AS total + FROM messages + WHERE timestamp >= ? AND timestamp < date(?, '+1 day') + `); } upsert(data: MessageData): void { @@ -139,6 +147,10 @@ export class SqliteMessageRepo implements MessageRepo { return this.todayTokensStmt.get(today, today, today) as DailyTokens; } + getTodayCost(today: string): DailyTokens { + return this.todayCostStmt.get(today, today, today) as DailyTokens; + } + getDailyTokensByModel(): DailyModelTokens[] { return this.db .prepare(` @@ -153,6 +165,20 @@ export class SqliteMessageRepo implements MessageRepo { .all() as DailyModelTokens[]; } + getDailyModelCost(): DailyModelTokens[] { + return this.db + .prepare(` + SELECT date(timestamp) AS date, + COALESCE(provider_id, 'unknown') || ' / ' || COALESCE(model_id, 'unknown') AS model, + COALESCE(SUM(cost), 0) AS total + FROM messages + WHERE timestamp >= date('now', '-60 days') + GROUP BY date, model + ORDER BY date ASC + `) + .all() as DailyModelTokens[]; + } + deleteOlderThan(cutoffDate: string): number { const result = this.db .prepare("DELETE FROM messages WHERE timestamp < ?") diff --git a/src/plugin.ts b/src/plugin.ts index e29f027..08e53e4 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -58,3 +58,5 @@ function createUsageStatsPlugin(deps: UsageStatsPluginDeps): Plugin { export const UsageStatsPlugin: Plugin = createUsageStatsPlugin({ createRepos: (dbPath) => createSqliteRepos(dbPath), }); + +export default { server: UsageStatsPlugin }; diff --git a/tests/e2e/it_renders_model_chart.spec.ts b/tests/e2e/it_renders_model_chart.spec.ts index 8763320..af75152 100644 --- a/tests/e2e/it_renders_model_chart.spec.ts +++ b/tests/e2e/it_renders_model_chart.spec.ts @@ -4,8 +4,8 @@ test.describe("model chart", () => { test("renders model chart with title", async ({ page }) => { await page.goto("/"); - // The model chart is the second .daily-chart - const modelChart = page.locator(".daily-chart").nth(1); + // The model chart is the third .daily-chart (token, cost, token-by-model, cost-by-model) + const modelChart = page.locator(".daily-chart").nth(2); await expect(modelChart).toBeVisible(); await expect( modelChart.locator(".chart-title", { @@ -17,7 +17,7 @@ test.describe("model chart", () => { test("renders 60 bar columns", async ({ page }) => { await page.goto("/"); - const modelChart = page.locator(".daily-chart").nth(1); + const modelChart = page.locator(".daily-chart").nth(2); const cols = modelChart.locator(".chart-container .chart-col"); await expect(cols).toHaveCount(60); }); @@ -25,7 +25,7 @@ test.describe("model chart", () => { test("renders stacked bar segments for days with data", async ({ page }) => { await page.goto("/"); - const modelChart = page.locator(".daily-chart").nth(1); + const modelChart = page.locator(".daily-chart").nth(2); const segments = modelChart.locator(".model-bar-seg"); const count = await segments.count(); expect(count).toBeGreaterThan(0); @@ -34,7 +34,7 @@ test.describe("model chart", () => { test("renders legend with seeded model names", async ({ page }) => { await page.goto("/"); - const modelChart = page.locator(".daily-chart").nth(1); + const modelChart = page.locator(".daily-chart").nth(2); const legend = modelChart.locator(".chart-legend"); // We seeded gpt-5.3-codex and claude-sonnet-4 diff --git a/tests/e2e/it_renders_two_column_layout.spec.ts b/tests/e2e/it_renders_two_column_layout.spec.ts index 422085d..af482e6 100644 --- a/tests/e2e/it_renders_two_column_layout.spec.ts +++ b/tests/e2e/it_renders_two_column_layout.spec.ts @@ -14,7 +14,7 @@ test.describe("two-column layout", () => { await expect(leftPanel).toBeVisible(); await expect(leftPanel.locator(".stats-bar").first()).toBeVisible(); - await expect(leftPanel.locator(".daily-chart")).toHaveCount(2); + await expect(leftPanel.locator(".daily-chart")).toHaveCount(4); }); test("right panel contains directory filter and session cards", async ({ diff --git a/tests/unit/dashboard/daily-chart.test.ts b/tests/unit/dashboard/daily-chart.test.ts index 80db030..ee8b11a 100644 --- a/tests/unit/dashboard/daily-chart.test.ts +++ b/tests/unit/dashboard/daily-chart.test.ts @@ -1,5 +1,8 @@ import { describe, expect, test } from "bun:test"; -import { renderDailyChart } from "../../../src/dashboard/templates/daily-chart"; +import { + renderDailyChart, + renderDailyCostChart, +} from "../../../src/dashboard/templates/daily-chart"; describe("renderDailyChart", () => { test("renders 60 chart columns", () => { @@ -32,3 +35,35 @@ describe("renderDailyChart", () => { expect(html).toContain("chart-avg-line"); }); }); + +describe("renderDailyCostChart", () => { + test("renders 60 chart columns", () => { + const html = renderDailyCostChart([]); + const count = (html.match(/class="chart-col"/g) || []).length; + expect(count).toBe(60); + }); + + test("renders title and legend", () => { + const html = renderDailyCostChart([]); + expect(html).toContain("Daily Cost (last 60 days)"); + expect(html).toContain("Daily cost"); + expect(html).toContain("5-day avg"); + }); + + test("renders cost label using fmtCost", () => { + const today = new Date().toISOString().slice(0, 10); + const html = renderDailyCostChart([{ date: today, total: 1.5 }]); + expect(html).toContain("$1.50"); + }); + + test("handles all-zero data without errors", () => { + const html = renderDailyCostChart([{ date: "2025-01-01", total: 0 }]); + expect(html).toContain("chart-container"); + }); + + test("renders rolling average polyline", () => { + const html = renderDailyCostChart([]); + expect(html).toContain("polyline"); + expect(html).toContain("chart-avg-line"); + }); +}); diff --git a/tests/unit/dashboard/daily-tokens-service.test.ts b/tests/unit/dashboard/daily-tokens-service.test.ts index 58df71f..cbbcd05 100644 --- a/tests/unit/dashboard/daily-tokens-service.test.ts +++ b/tests/unit/dashboard/daily-tokens-service.test.ts @@ -5,7 +5,9 @@ import type { Repos } from "../../../src/db/repos"; function makeStubRepos( overrides: Partial<{ todayTokens: { date: string; total: number }; + todayCost: { date: string; total: number }; history: { date: string; total: number }[]; + historyCost: { date: string; total: number }[]; tokenSummary: { today: number; thisWeek: number; @@ -13,6 +15,7 @@ function makeStubRepos( lastMonth: number; }; dailyModel: { date: string; model: string; total: number }[]; + dailyModelCost: { date: string; model: string; total: number }[]; }> = {}, ): Repos { return { @@ -38,7 +41,13 @@ function makeStubRepos( date: new Date().toISOString().slice(0, 10), total: 0, }, + getTodayCost: () => + overrides.todayCost ?? { + date: new Date().toISOString().slice(0, 10), + total: 0, + }, getDailyTokensByModel: () => overrides.dailyModel ?? [], + getDailyModelCost: () => overrides.dailyModelCost ?? [], upsert: () => {}, deleteOlderThan: () => 0, getCostSummary: () => ({ @@ -57,6 +66,7 @@ function makeStubRepos( dailyUsage: { recompute: () => {}, getHistoryUntil: () => overrides.history ?? [], + getHistoryUntilCost: () => overrides.historyCost ?? [], }, vacuum: () => {}, close: () => {}, @@ -102,4 +112,32 @@ describe("DailyTokensService", () => { ); expect(service.getDailyTokensByModel()).toEqual(data); }); + + test("getDailyCost returns 60 days with gap filling", () => { + const service = createDailyTokensService(makeStubRepos()); + const result = service.getDailyCost(); + expect(result).toHaveLength(60); + expect(result[59]!.date).toBe(new Date().toISOString().slice(0, 10)); + }); + + test("getDailyCost merges today cost with history", () => { + const today = new Date().toISOString().slice(0, 10); + const service = createDailyTokensService( + makeStubRepos({ + todayCost: { date: today, total: 0.5 }, + historyCost: [{ date: today, total: 0.1 }], + }), + ); + const result = service.getDailyCost(); + const todayEntry = result.find((d) => d.date === today); + expect(todayEntry!.total).toBeCloseTo(0.5); + }); + + test("getDailyModelCost delegates to repo", () => { + const data = [{ date: "2025-01-01", model: "test", total: 0.05 }]; + const service = createDailyTokensService( + makeStubRepos({ dailyModelCost: data }), + ); + expect(service.getDailyModelCost()).toEqual(data); + }); }); diff --git a/tests/unit/dashboard/maintenance-service.test.ts b/tests/unit/dashboard/maintenance-service.test.ts index 8eb6777..56d16e3 100644 --- a/tests/unit/dashboard/maintenance-service.test.ts +++ b/tests/unit/dashboard/maintenance-service.test.ts @@ -21,7 +21,9 @@ function makeStubRepos(): Repos { lastMonth: 0, }), getTodayTokens: () => ({ date: "2025-01-01", total: 0 }), + getTodayCost: () => ({ date: "2025-01-01", total: 0 }), getDailyTokensByModel: () => [], + getDailyModelCost: () => [], upsert: () => {}, deleteOlderThan: () => 0, getCostSummary: () => ({ @@ -40,6 +42,7 @@ function makeStubRepos(): Repos { dailyUsage: { recompute: () => {}, getHistoryUntil: () => [], + getHistoryUntilCost: () => [], }, vacuum: () => {}, close: () => {}, diff --git a/tests/unit/dashboard/model-chart.test.ts b/tests/unit/dashboard/model-chart.test.ts index 0a6d4ac..8fded56 100644 --- a/tests/unit/dashboard/model-chart.test.ts +++ b/tests/unit/dashboard/model-chart.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test"; import { MODEL_COLORS, renderDailyModelChart, + renderDailyModelCostChart, } from "../../../src/dashboard/templates/model-chart"; describe("renderDailyModelChart", () => { @@ -38,3 +39,46 @@ describe("renderDailyModelChart", () => { expect(html).toContain("test-model"); }); }); + +describe("renderDailyModelCostChart", () => { + test("renders title", () => { + const html = renderDailyModelCostChart([]); + expect(html).toContain("Daily Cost by Model (last 60 days)"); + }); + + test("renders stacked segments for models", () => { + const today = new Date().toISOString().slice(0, 10); + const html = renderDailyModelCostChart([ + { date: today, model: "claude-sonnet", total: 0.5 }, + { date: today, model: "gpt-4o", total: 0.3 }, + ]); + expect(html).toContain("claude-sonnet"); + expect(html).toContain("gpt-4o"); + expect(html).toContain("model-bar-seg"); + }); + + test("uses fmtCost in tooltips", () => { + const today = new Date().toISOString().slice(0, 10); + const html = renderDailyModelCostChart([ + { date: today, model: "test-model", total: 0.05 }, + ]); + expect(html).toContain("$0.05"); + }); + + test("assigns colors from MODEL_COLORS", () => { + const today = new Date().toISOString().slice(0, 10); + const html = renderDailyModelCostChart([ + { date: today, model: "model-a", total: 0.1 }, + ]); + expect(html).toContain(MODEL_COLORS[0]!); + }); + + test("renders legend entries", () => { + const today = new Date().toISOString().slice(0, 10); + const html = renderDailyModelCostChart([ + { date: today, model: "test-model", total: 0.1 }, + ]); + expect(html).toContain("legend-item"); + expect(html).toContain("test-model"); + }); +}); diff --git a/tests/unit/dashboard/routes.test.ts b/tests/unit/dashboard/routes.test.ts index a205060..d8dac7b 100644 --- a/tests/unit/dashboard/routes.test.ts +++ b/tests/unit/dashboard/routes.test.ts @@ -14,6 +14,8 @@ function makeStubDailyTokens(): DailyTokensService { return { getDailyTokens: () => [], getDailyTokensByModel: () => [], + getDailyCost: () => [], + getDailyModelCost: () => [], getTokenSummary: () => ({ today: 0, thisWeek: 0, @@ -48,7 +50,9 @@ function makeStubRepos(): Repos { lastMonth: 0, }), getTodayTokens: () => ({ date: "2025-01-01", total: 0 }), + getTodayCost: () => ({ date: "2025-01-01", total: 0 }), getDailyTokensByModel: () => [], + getDailyModelCost: () => [], getCostSummary: () => ({ today: 0, thisWeek: 0, @@ -67,6 +71,7 @@ function makeStubRepos(): Repos { dailyUsage: { recompute: () => {}, getHistoryUntil: () => [], + getHistoryUntilCost: () => [], }, vacuum: () => {}, close: () => {}, diff --git a/tests/unit/dashboard/session-stats-service.test.ts b/tests/unit/dashboard/session-stats-service.test.ts index 1c38547..14d1fb2 100644 --- a/tests/unit/dashboard/session-stats-service.test.ts +++ b/tests/unit/dashboard/session-stats-service.test.ts @@ -28,7 +28,9 @@ function makeStubRepos( lastMonth: 0, }), getTodayTokens: () => ({ date: "2025-01-01", total: 0 }), + getTodayCost: () => ({ date: "2025-01-01", total: 0 }), getDailyTokensByModel: () => [], + getDailyModelCost: () => [], upsert: () => {}, deleteOlderThan: () => 0, getCostSummary: () => ({ @@ -47,6 +49,7 @@ function makeStubRepos( dailyUsage: { recompute: () => {}, getHistoryUntil: () => [], + getHistoryUntilCost: () => [], }, vacuum: () => {}, close: () => {}, diff --git a/tests/unit/dashboard/sessions-fragment.test.ts b/tests/unit/dashboard/sessions-fragment.test.ts index a87a16a..d085ac0 100644 --- a/tests/unit/dashboard/sessions-fragment.test.ts +++ b/tests/unit/dashboard/sessions-fragment.test.ts @@ -88,4 +88,38 @@ describe("renderSessionsFragment", () => { ); expect(html).toContain('value="/proj/b" selected'); }); + + test("renders daily cost chart", () => { + const today = new Date().toISOString().slice(0, 10); + const html = renderSessionsFragment( + [], + summary, + costSummary, + [], + [], + [], + [], + undefined, + [{ date: today, total: 1.5 }], + [], + ); + expect(html).toContain("Daily Cost (last 60 days)"); + }); + + test("renders daily model cost chart", () => { + const today = new Date().toISOString().slice(0, 10); + const html = renderSessionsFragment( + [], + summary, + costSummary, + [], + [], + [], + [], + undefined, + [], + [{ date: today, model: "test-model", total: 0.1 }], + ); + expect(html).toContain("Daily Cost by Model (last 60 days)"); + }); }); diff --git a/tests/unit/handlers.test.ts b/tests/unit/handlers.test.ts index d3b2509..c626ba7 100644 --- a/tests/unit/handlers.test.ts +++ b/tests/unit/handlers.test.ts @@ -127,7 +127,9 @@ function createReposDouble(opts?: { lastMonth: 0, }), getTodayTokens: (today) => ({ date: today, total: 0 }), + getTodayCost: (today) => ({ date: today, total: 0 }), getDailyTokensByModel: () => [], + getDailyModelCost: () => [], deleteOlderThan: () => 0, getCostSummary: () => ({ today: 0, @@ -145,6 +147,7 @@ function createReposDouble(opts?: { dailyUsage: { recompute: () => {}, getHistoryUntil: () => [], + getHistoryUntilCost: () => [], }, vacuum: () => {}, close: () => {}, diff --git a/tests/unit/sqlite-daily-usage-repo.test.ts b/tests/unit/sqlite-daily-usage-repo.test.ts index b9e350e..818aaf4 100644 --- a/tests/unit/sqlite-daily-usage-repo.test.ts +++ b/tests/unit/sqlite-daily-usage-repo.test.ts @@ -33,10 +33,33 @@ describe("sqlite daily usage repo", () => { db.close(); repos.dailyUsage.recompute("2026-05-01", "2026-05-02"); - const history = repos.dailyUsage.getHistoryUntil("2026-05-03", 60); + const history = repos.dailyUsage.getHistoryUntil("2026-05-03", 365); const row = history.find((r) => r.date === "2026-05-01"); expect(row?.total).toBe(180); repos.close(); }); + + test("getHistoryUntilCost returns cost_total for rolled-up days", () => { + const { dir, dbPath } = createTempDbPath("opencode-usage-stats-repos-"); + cleanupDirs.push(dir); + const repos = createSqliteRepos(dbPath); + const db = new Database(dbPath); + + db.prepare( + "INSERT INTO sessions (session_id, first_seen, last_seen) VALUES (?, ?, ?)", + ).run("s1", "2026-05-01 10:00:00", "2026-05-01 10:00:00"); + db.prepare(` + INSERT INTO messages (session_id, message_id, role, input_tokens, output_tokens, reasoning_tokens, cache_read_tokens, cost, timestamp) + VALUES ('s1', 'm1', 'assistant', 100, 50, 10, 20, 0.25, '2026-05-01 12:00:00') + `).run(); + db.close(); + + repos.dailyUsage.recompute("2026-05-01", "2026-05-02"); + const history = repos.dailyUsage.getHistoryUntilCost("2026-05-03", 365); + const row = history.find((r) => r.date === "2026-05-01"); + expect(row?.total).toBeCloseTo(0.25); + + repos.close(); + }); }); diff --git a/tests/unit/sqlite-message-repo.test.ts b/tests/unit/sqlite-message-repo.test.ts index 66a0a40..c5de6f2 100644 --- a/tests/unit/sqlite-message-repo.test.ts +++ b/tests/unit/sqlite-message-repo.test.ts @@ -79,4 +79,76 @@ describe("sqlite message repo", () => { db.close(); repos.close(); }); + + test("getTodayCost returns sum of cost for today", () => { + const { dir, dbPath } = createTempDbPath("opencode-usage-stats-repos-"); + cleanupDirs.push(dir); + const repos = createSqliteRepos(dbPath); + const today = new Date().toISOString().slice(0, 10); + + repos.messages.upsert({ + sessionId: "s1", + messageId: "m1", + role: "assistant", + modelId: "model-a", + providerId: "prov-a", + inputTokens: 0, + outputTokens: 0, + reasoningTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0, + cost: 0.05, + agent: null, + }); + + const result = repos.messages.getTodayCost(today); + expect(result.date).toBe(today); + expect(result.total).toBeCloseTo(0.05); + + repos.close(); + }); + + test("getDailyModelCost groups cost by date and model", () => { + const { dir, dbPath } = createTempDbPath("opencode-usage-stats-repos-"); + cleanupDirs.push(dir); + const repos = createSqliteRepos(dbPath); + + repos.messages.upsert({ + sessionId: "s1", + messageId: "m1", + role: "assistant", + modelId: "sonnet", + providerId: "anthropic", + inputTokens: 0, + outputTokens: 0, + reasoningTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0, + cost: 0.1, + agent: null, + }); + repos.messages.upsert({ + sessionId: "s1", + messageId: "m2", + role: "assistant", + modelId: "sonnet", + providerId: "anthropic", + inputTokens: 0, + outputTokens: 0, + reasoningTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0, + cost: 0.2, + agent: null, + }); + + const result = repos.messages.getDailyModelCost(); + const today = new Date().toISOString().slice(0, 10); + const row = result.find( + (r) => r.date === today && r.model === "anthropic / sonnet", + ); + expect(row?.total).toBeCloseTo(0.3); + + repos.close(); + }); });