From 6e8f6412a400c74b5c501b1d3a1fbb005f821e37 Mon Sep 17 00:00:00 2001 From: "Ronny.Herrgesell" Date: Tue, 12 May 2026 18:57:52 +0200 Subject: [PATCH 1/3] feat: display cost summary in dashboard stats bar and session cards Add Overall$ row to stats bar showing aggregated costs per time period (today/week/month/last month). Show session-level total cost at the end of the token line on each session card. Costs are formatted with smart precision (2 decimals for >= /bin/zsh.01, 4 decimals for sub-cent amounts). --- src/dashboard/routes/page-route.ts | 2 + src/dashboard/routes/stats-route.ts | 2 + .../services/daily-tokens-service.ts | 6 +++ src/dashboard/templates/formatters.ts | 6 +++ src/dashboard/templates/page-template.ts | 4 +- src/dashboard/templates/session-card.ts | 3 +- src/dashboard/templates/sessions-fragment.ts | 4 +- src/dashboard/templates/stats-bar.ts | 16 +++++-- src/dashboard/templates/styles.ts | 2 + src/db/message/message-repo.ts | 8 ++++ src/db/message/sqlite-message-repo.ts | 32 ++++++++++++++ .../dashboard/daily-tokens-service.test.ts | 6 +++ tests/unit/dashboard/formatters.test.ts | 22 ++++++++++ .../dashboard/maintenance-service.test.ts | 6 +++ tests/unit/dashboard/page-template.test.ts | 11 ++--- tests/unit/dashboard/routes.test.ts | 12 ++++++ tests/unit/dashboard/session-card.test.ts | 12 ++++++ .../dashboard/session-stats-service.test.ts | 6 +++ .../unit/dashboard/sessions-fragment.test.ts | 22 ++++++++-- tests/unit/dashboard/stats-bar.test.ts | 43 +++++++++++++------ tests/unit/handlers.test.ts | 6 +++ 21 files changed, 205 insertions(+), 26 deletions(-) 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/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("