diff --git a/ROADMAP.md b/ROADMAP.md index 6be4af6..1cbb110 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -4,6 +4,7 @@ ## Completed +- [x] Display cost summary in dashboard stats bar (Overall$) and session cards - [x] Add comprehensive end-to-end tests for dashboard - [x] Auto-set npm version from release tag in CI publish workflow - [x] Optimize dashboard refresh performance (>500ms to ~140ms) diff --git a/src/dashboard/routes/page-route.ts b/src/dashboard/routes/page-route.ts index 3b24652..8d7a2ef 100644 --- a/src/dashboard/routes/page-route.ts +++ b/src/dashboard/routes/page-route.ts @@ -20,6 +20,7 @@ export function createPageRoute( const directories = sessionStats.getDistinctDirectories(); const sessions = sessionStats.getSessionStats(dirFilter); const summary = dailyTokens.getTokenSummary(); + const costSummary = dailyTokens.getCostSummary(); const daily = dailyTokens.getDailyTokens(); const dailyModel = dailyTokens.getDailyTokensByModel(); const toolGroups = repos.toolCalls.getToolUsageSummary(); @@ -27,6 +28,7 @@ export function createPageRoute( renderHTML( sessions, summary, + costSummary, daily, dailyModel, toolGroups, diff --git a/src/dashboard/routes/stats-route.ts b/src/dashboard/routes/stats-route.ts index 7c181e2..592cf28 100644 --- a/src/dashboard/routes/stats-route.ts +++ b/src/dashboard/routes/stats-route.ts @@ -43,12 +43,14 @@ export function createStatsRoute( const directories = sessionStats.getDistinctDirectories(); const sessions = sessionStats.getSessionStats(dirFilter); const summary = dailyTokens.getTokenSummary(); + const costSummary = dailyTokens.getCostSummary(); const daily = dailyTokens.getDailyTokens(); const dailyModel = dailyTokens.getDailyTokensByModel(); const toolGroups = repos.toolCalls.getToolUsageSummary(); const html = renderSessionsFragment( sessions, summary, + costSummary, daily, dailyModel, toolGroups, diff --git a/src/dashboard/services/daily-tokens-service.ts b/src/dashboard/services/daily-tokens-service.ts index ef15b0b..13ab900 100644 --- a/src/dashboard/services/daily-tokens-service.ts +++ b/src/dashboard/services/daily-tokens-service.ts @@ -1,4 +1,5 @@ import type { + CostSummary, DailyModelTokens, TokenSummary, } from "../../db/message/message-repo"; @@ -9,6 +10,7 @@ export interface DailyTokensService { getDailyTokens(): DailyTokens[]; getDailyTokensByModel(): DailyModelTokens[]; getTokenSummary(): TokenSummary; + getCostSummary(): CostSummary; } export function createDailyTokensService(repos: Repos): DailyTokensService { @@ -39,5 +41,9 @@ export function createDailyTokensService(repos: Repos): DailyTokensService { getTokenSummary(): TokenSummary { return repos.messages.getTokenSummary(); }, + + getCostSummary(): CostSummary { + return repos.messages.getCostSummary(); + }, }; } diff --git a/src/dashboard/templates/formatters.ts b/src/dashboard/templates/formatters.ts index da13b9e..9259342 100644 --- a/src/dashboard/templates/formatters.ts +++ b/src/dashboard/templates/formatters.ts @@ -22,6 +22,12 @@ export function fmt(n: number): string { return n.toLocaleString("de-DE"); } +export function fmtCost(n: number): string { + if (n <= 0) return "$0.00"; + if (n < 0.01) return `$${n.toFixed(4)}`; + return `$${n.toFixed(2)}`; +} + export function renderTokens( input: number, cache: number, diff --git a/src/dashboard/templates/page-template.ts b/src/dashboard/templates/page-template.ts index 967ea59..2bcdeb4 100644 --- a/src/dashboard/templates/page-template.ts +++ b/src/dashboard/templates/page-template.ts @@ -1,4 +1,5 @@ import type { + CostSummary, DailyModelTokens, TokenSummary, } from "../../db/message/message-repo"; @@ -83,6 +84,7 @@ export const CLIENT_SCRIPT = ` export function renderHTML( sessions: SessionStats[], summary: TokenSummary, + costSummary: CostSummary, daily: DailyTokens[], dailyModel: DailyModelTokens[], toolGroups: ToolGroupSummary[], @@ -107,7 +109,7 @@ export function renderHTML(
- ${renderSessionsFragment(sessions, summary, daily, dailyModel, toolGroups, directories, selectedDir)} + ${renderSessionsFragment(sessions, summary, costSummary, daily, dailyModel, toolGroups, directories, selectedDir)}
diff --git a/src/dashboard/templates/session-card.ts b/src/dashboard/templates/session-card.ts index b52cc88..f795a62 100644 --- a/src/dashboard/templates/session-card.ts +++ b/src/dashboard/templates/session-card.ts @@ -1,5 +1,5 @@ import type { SessionStats } from "../services/types"; -import { esc, renderTokens } from "./formatters"; +import { esc, fmtCost, renderTokens } from "./formatters"; function recencyClass(lastSeen: string | null | undefined): string { if (!lastSeen) return ""; @@ -88,6 +88,7 @@ export function renderSessionCard(s: SessionStats): string {
Tokens: ${sessionTokens} + ${s.cost > 0 ? `${fmtCost(s.cost)}` : ""}
${agentRows ? `
Agents
${agentRows}
` : ""} ${modeRows ? `
Mode
${modeRows}
` : ""} diff --git a/src/dashboard/templates/sessions-fragment.ts b/src/dashboard/templates/sessions-fragment.ts index 3969e2f..df87f17 100644 --- a/src/dashboard/templates/sessions-fragment.ts +++ b/src/dashboard/templates/sessions-fragment.ts @@ -1,4 +1,5 @@ import type { + CostSummary, DailyModelTokens, TokenSummary, } from "../../db/message/message-repo"; @@ -15,13 +16,14 @@ import { renderToolUsage } from "./tool-usage"; export function renderSessionsFragment( sessions: SessionStats[], summary: TokenSummary, + costSummary: CostSummary, daily: DailyTokens[], dailyModel: DailyModelTokens[], toolGroups: ToolGroupSummary[], directories: string[] = [], selectedDir?: string, ): string { - const bar = renderStatsBar(summary); + const bar = renderStatsBar(summary, costSummary); const chart = renderDailyChart(daily); const modelChart = renderDailyModelChart(dailyModel); const toolUsage = renderToolUsage(toolGroups); diff --git a/src/dashboard/templates/stats-bar.ts b/src/dashboard/templates/stats-bar.ts index 0021f20..17893d6 100644 --- a/src/dashboard/templates/stats-bar.ts +++ b/src/dashboard/templates/stats-bar.ts @@ -1,7 +1,10 @@ -import type { TokenSummary } from "../../db/message/message-repo"; -import { fmtCompact } from "./formatters"; +import type { CostSummary, TokenSummary } from "../../db/message/message-repo"; +import { fmtCompact, fmtCost } from "./formatters"; -export function renderStatsBar(summary: TokenSummary): string { +export function renderStatsBar( + summary: TokenSummary, + costSummary: CostSummary, +): string { return `
Overall @@ -9,5 +12,12 @@ export function renderStatsBar(summary: TokenSummary): string { This Week:${fmtCompact(summary.thisWeek)} This Month:${fmtCompact(summary.thisMonth)} Last Month:${fmtCompact(summary.lastMonth)} +
+
+ Overall$ + Today:${fmtCost(costSummary.today)} + This Week:${fmtCost(costSummary.thisWeek)} + This Month:${fmtCost(costSummary.thisMonth)} + Last Month:${fmtCost(costSummary.lastMonth)}
`; } diff --git a/src/dashboard/templates/styles.ts b/src/dashboard/templates/styles.ts index 6618b42..abbcd45 100644 --- a/src/dashboard/templates/styles.ts +++ b/src/dashboard/templates/styles.ts @@ -159,6 +159,8 @@ export const DASHBOARD_CSS = ` margin: 16px 0; } .mode-overall { color: #58a6ff; border-color: #1f6feb; } + .mode-cost-overall { color: #f0883e; border-color: #d18616; } + .cost-value { color: #f0883e; } .tool-usage-section { margin-bottom: 8px; } .tool-group { margin-bottom: 12px; diff --git a/src/db/message/message-repo.ts b/src/db/message/message-repo.ts index 8941f1d..6d76408 100644 --- a/src/db/message/message-repo.ts +++ b/src/db/message/message-repo.ts @@ -41,10 +41,18 @@ export interface DailyModelTokens { total: number; } +export interface CostSummary { + today: number; + thisWeek: number; + thisMonth: number; + lastMonth: number; +} + export interface MessageRepo { upsert(data: MessageData): void; getModeStats(): ModeRow[]; getTokenSummary(): TokenSummary; + getCostSummary(): CostSummary; getTodayTokens(today: string): DailyTokens; getDailyTokensByModel(): DailyModelTokens[]; deleteOlderThan(cutoffDate: string): number; diff --git a/src/db/message/sqlite-message-repo.ts b/src/db/message/sqlite-message-repo.ts index cbdd3a2..954ea5c 100644 --- a/src/db/message/sqlite-message-repo.ts +++ b/src/db/message/sqlite-message-repo.ts @@ -1,6 +1,7 @@ import type { Database } from "bun:sqlite"; import type { DailyTokens } from "../shared-types"; import type { + CostSummary, DailyModelTokens, MessageData, MessageRepo, @@ -11,6 +12,7 @@ import type { export class SqliteMessageRepo implements MessageRepo { private readonly upsertMessageStmt; private readonly tokenSummaryStmt; + private readonly costSummaryStmt; private readonly todayTokensStmt; constructor(private readonly db: Database) { @@ -46,6 +48,21 @@ export class SqliteMessageRepo implements MessageRepo { WHERE timestamp >= date('now', 'start of month', '-1 month') `); + this.costSummaryStmt = this.db.prepare(` + SELECT + COALESCE(SUM(CASE WHEN timestamp >= date('now') AND timestamp < date('now', '+1 day') + THEN cost END), 0) AS today, + COALESCE(SUM(CASE WHEN timestamp >= date('now', 'weekday 1', '-7 days') + THEN cost END), 0) AS this_week, + COALESCE(SUM(CASE WHEN timestamp >= date('now', 'start of month') + THEN cost END), 0) AS this_month, + COALESCE(SUM(CASE WHEN timestamp >= date('now', 'start of month', '-1 month') + AND timestamp < date('now', 'start of month') + THEN cost END), 0) AS last_month + FROM messages + WHERE timestamp >= date('now', 'start of month', '-1 month') + `); + this.todayTokensStmt = this.db.prepare(` SELECT ? AS date, COALESCE(SUM(input_tokens + cache_read_tokens + output_tokens + reasoning_tokens), 0) AS total @@ -103,6 +120,21 @@ export class SqliteMessageRepo implements MessageRepo { }; } + getCostSummary(): CostSummary { + const row = this.costSummaryStmt.get() as { + today: number; + this_week: number; + this_month: number; + last_month: number; + }; + return { + today: Number(row.today), + thisWeek: Number(row.this_week), + thisMonth: Number(row.this_month), + lastMonth: Number(row.last_month), + }; + } + getTodayTokens(today: string): DailyTokens { return this.todayTokensStmt.get(today, today, today) as DailyTokens; } diff --git a/tests/e2e/it_renders_stats_bar.spec.ts b/tests/e2e/it_renders_stats_bar.spec.ts index d7d8f71..16c7e82 100644 --- a/tests/e2e/it_renders_stats_bar.spec.ts +++ b/tests/e2e/it_renders_stats_bar.spec.ts @@ -4,7 +4,7 @@ test.describe("stats bar", () => { test("renders stats bar with token summary periods", async ({ page }) => { await page.goto("/"); - const statsBar = page.locator(".stats-bar"); + const statsBar = page.locator(".stats-bar").first(); await expect(statsBar).toBeVisible(); await expect( @@ -24,7 +24,8 @@ test.describe("stats bar", () => { test("displays non-zero token values for seeded data", async ({ page }) => { await page.goto("/"); - const values = page.locator(".stats-bar .stats-value"); + const tokenBar = page.locator(".stats-bar").first(); + const values = tokenBar.locator(".stats-value"); const count = await values.count(); expect(count).toBe(4); diff --git a/tests/e2e/it_renders_two_column_layout.spec.ts b/tests/e2e/it_renders_two_column_layout.spec.ts index 181cb4b..422085d 100644 --- a/tests/e2e/it_renders_two_column_layout.spec.ts +++ b/tests/e2e/it_renders_two_column_layout.spec.ts @@ -13,7 +13,7 @@ test.describe("two-column layout", () => { const leftPanel = page.locator(".left-panel"); await expect(leftPanel).toBeVisible(); - await expect(leftPanel.locator(".stats-bar")).toBeVisible(); + await expect(leftPanel.locator(".stats-bar").first()).toBeVisible(); await expect(leftPanel.locator(".daily-chart")).toHaveCount(2); }); diff --git a/tests/e2e/it_shows_empty_state.spec.ts b/tests/e2e/it_shows_empty_state.spec.ts index a9887d7..b94ddda 100644 --- a/tests/e2e/it_shows_empty_state.spec.ts +++ b/tests/e2e/it_shows_empty_state.spec.ts @@ -78,7 +78,7 @@ test.describe("empty state", () => { await page.goto("/"); - await expect(page.locator(".stats-bar")).toBeVisible(); + await expect(page.locator(".stats-bar").first()).toBeVisible(); await context.close(); }); diff --git a/tests/unit/dashboard/daily-tokens-service.test.ts b/tests/unit/dashboard/daily-tokens-service.test.ts index 3f2db53..58df71f 100644 --- a/tests/unit/dashboard/daily-tokens-service.test.ts +++ b/tests/unit/dashboard/daily-tokens-service.test.ts @@ -41,6 +41,12 @@ function makeStubRepos( getDailyTokensByModel: () => overrides.dailyModel ?? [], upsert: () => {}, deleteOlderThan: () => 0, + getCostSummary: () => ({ + today: 0, + thisWeek: 0, + thisMonth: 0, + lastMonth: 0, + }), }, toolCalls: { getAgentCalls: () => [], diff --git a/tests/unit/dashboard/formatters.test.ts b/tests/unit/dashboard/formatters.test.ts index acc2fda..8dad15f 100644 --- a/tests/unit/dashboard/formatters.test.ts +++ b/tests/unit/dashboard/formatters.test.ts @@ -3,6 +3,7 @@ import { esc, fmt, fmtCompact, + fmtCost, renderTokens, } from "../../../src/dashboard/templates/formatters"; @@ -45,6 +46,27 @@ describe("formatters", () => { }); }); + describe("fmtCost", () => { + test("formats zero as $0.00", () => { + expect(fmtCost(0)).toBe("$0.00"); + }); + + test("formats sub-cent values with 4 decimals", () => { + expect(fmtCost(0.0042)).toBe("$0.0042"); + expect(fmtCost(0.0099)).toBe("$0.0099"); + }); + + test("formats normal values with 2 decimals", () => { + expect(fmtCost(5.54)).toBe("$5.54"); + expect(fmtCost(0.01)).toBe("$0.01"); + expect(fmtCost(123.4)).toBe("$123.40"); + }); + + test("formats negative values as $0.00", () => { + expect(fmtCost(-1)).toBe("$0.00"); + }); + }); + describe("renderTokens", () => { test("includes cache and reasoning when present", () => { const html = renderTokens(1000, 500, 250, 100); diff --git a/tests/unit/dashboard/maintenance-service.test.ts b/tests/unit/dashboard/maintenance-service.test.ts index 738faa1..8eb6777 100644 --- a/tests/unit/dashboard/maintenance-service.test.ts +++ b/tests/unit/dashboard/maintenance-service.test.ts @@ -24,6 +24,12 @@ function makeStubRepos(): Repos { getDailyTokensByModel: () => [], upsert: () => {}, deleteOlderThan: () => 0, + getCostSummary: () => ({ + today: 0, + thisWeek: 0, + thisMonth: 0, + lastMonth: 0, + }), }, toolCalls: { getAgentCalls: () => [], diff --git a/tests/unit/dashboard/page-template.test.ts b/tests/unit/dashboard/page-template.test.ts index f0d86cf..27aa27f 100644 --- a/tests/unit/dashboard/page-template.test.ts +++ b/tests/unit/dashboard/page-template.test.ts @@ -3,33 +3,34 @@ import { renderHTML } from "../../../src/dashboard/templates/page-template"; describe("renderHTML", () => { const summary = { today: 0, thisWeek: 0, thisMonth: 0, lastMonth: 0 }; + const costSummary = { today: 0, thisWeek: 0, thisMonth: 0, lastMonth: 0 }; test("returns full HTML document with doctype", () => { - const html = renderHTML([], summary, [], [], []); + const html = renderHTML([], summary, costSummary, [], [], []); expect(html).toMatch(/^/); expect(html).toContain(""); }); test("includes page title", () => { - const html = renderHTML([], summary, [], [], []); + const html = renderHTML([], summary, costSummary, [], [], []); expect(html).toContain("OpenCode Usage Stats"); }); test("includes CSS styles", () => { - const html = renderHTML([], summary, [], [], []); + const html = renderHTML([], summary, costSummary, [], [], []); expect(html).toContain("