Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions src/dashboard/routes/page-route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,15 @@ 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();
return new Response(
renderHTML(
sessions,
summary,
costSummary,
daily,
dailyModel,
toolGroups,
Expand Down
2 changes: 2 additions & 0 deletions src/dashboard/routes/stats-route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
6 changes: 6 additions & 0 deletions src/dashboard/services/daily-tokens-service.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type {
CostSummary,
DailyModelTokens,
TokenSummary,
} from "../../db/message/message-repo";
Expand All @@ -9,6 +10,7 @@ export interface DailyTokensService {
getDailyTokens(): DailyTokens[];
getDailyTokensByModel(): DailyModelTokens[];
getTokenSummary(): TokenSummary;
getCostSummary(): CostSummary;
}

export function createDailyTokensService(repos: Repos): DailyTokensService {
Expand Down Expand Up @@ -39,5 +41,9 @@ export function createDailyTokensService(repos: Repos): DailyTokensService {
getTokenSummary(): TokenSummary {
return repos.messages.getTokenSummary();
},

getCostSummary(): CostSummary {
return repos.messages.getCostSummary();
},
};
}
6 changes: 6 additions & 0 deletions src/dashboard/templates/formatters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
4 changes: 3 additions & 1 deletion src/dashboard/templates/page-template.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type {
CostSummary,
DailyModelTokens,
TokenSummary,
} from "../../db/message/message-repo";
Expand Down Expand Up @@ -83,6 +84,7 @@ export const CLIENT_SCRIPT = `
export function renderHTML(
sessions: SessionStats[],
summary: TokenSummary,
costSummary: CostSummary,
daily: DailyTokens[],
dailyModel: DailyModelTokens[],
toolGroups: ToolGroupSummary[],
Expand All @@ -107,7 +109,7 @@ export function renderHTML(
</div>
</div>
<div id="sessions">
${renderSessionsFragment(sessions, summary, daily, dailyModel, toolGroups, directories, selectedDir)}
${renderSessionsFragment(sessions, summary, costSummary, daily, dailyModel, toolGroups, directories, selectedDir)}
</div>
<script>${CLIENT_SCRIPT}</script>
</body>
Expand Down
3 changes: 2 additions & 1 deletion src/dashboard/templates/session-card.ts
Original file line number Diff line number Diff line change
@@ -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 "";
Expand Down Expand Up @@ -88,6 +88,7 @@ export function renderSessionCard(s: SessionStats): string {
<div class="session-tokens">
<span class="token-label">Tokens:</span>
${sessionTokens}
${s.cost > 0 ? `<span class="mode-cost">${fmtCost(s.cost)}</span>` : ""}
</div>
${agentRows ? `<div class="agents-section"><div class="agents-label">Agents</div>${agentRows}</div>` : ""}
${modeRows ? `<div class="agents-section"><div class="agents-label">Mode</div>${modeRows}</div>` : ""}
Expand Down
4 changes: 3 additions & 1 deletion src/dashboard/templates/sessions-fragment.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type {
CostSummary,
DailyModelTokens,
TokenSummary,
} from "../../db/message/message-repo";
Expand All @@ -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);
Expand Down
16 changes: 13 additions & 3 deletions src/dashboard/templates/stats-bar.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,23 @@
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 `
<div class="stats-bar">
<span class="stats-badge"><span class="mode-badge mode-overall">Overall</span></span>
<span class="stats-pair"><span class="stats-label">Today:</span><span class="stats-value">${fmtCompact(summary.today)}</span></span>
<span class="stats-pair"><span class="stats-label">This Week:</span><span class="stats-value">${fmtCompact(summary.thisWeek)}</span></span>
<span class="stats-pair"><span class="stats-label">This Month:</span><span class="stats-value">${fmtCompact(summary.thisMonth)}</span></span>
<span class="stats-pair"><span class="stats-label">Last Month:</span><span class="stats-value">${fmtCompact(summary.lastMonth)}</span></span>
</div>
<div class="stats-bar">
<span class="stats-badge"><span class="mode-badge mode-cost-overall">Overall$</span></span>
<span class="stats-pair"><span class="stats-label">Today:</span><span class="stats-value cost-value">${fmtCost(costSummary.today)}</span></span>
<span class="stats-pair"><span class="stats-label">This Week:</span><span class="stats-value cost-value">${fmtCost(costSummary.thisWeek)}</span></span>
<span class="stats-pair"><span class="stats-label">This Month:</span><span class="stats-value cost-value">${fmtCost(costSummary.thisMonth)}</span></span>
<span class="stats-pair"><span class="stats-label">Last Month:</span><span class="stats-value cost-value">${fmtCost(costSummary.lastMonth)}</span></span>
</div>`;
}
2 changes: 2 additions & 0 deletions src/dashboard/templates/styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
8 changes: 8 additions & 0 deletions src/db/message/message-repo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
32 changes: 32 additions & 0 deletions src/db/message/sqlite-message-repo.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { Database } from "bun:sqlite";
import type { DailyTokens } from "../shared-types";
import type {
CostSummary,
DailyModelTokens,
MessageData,
MessageRepo,
Expand All @@ -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) {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
}
Expand Down
5 changes: 3 additions & 2 deletions tests/e2e/it_renders_stats_bar.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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);

Expand Down
2 changes: 1 addition & 1 deletion tests/e2e/it_renders_two_column_layout.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});

Expand Down
2 changes: 1 addition & 1 deletion tests/e2e/it_shows_empty_state.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
Expand Down
6 changes: 6 additions & 0 deletions tests/unit/dashboard/daily-tokens-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,12 @@ function makeStubRepos(
getDailyTokensByModel: () => overrides.dailyModel ?? [],
upsert: () => {},
deleteOlderThan: () => 0,
getCostSummary: () => ({
today: 0,
thisWeek: 0,
thisMonth: 0,
lastMonth: 0,
}),
},
toolCalls: {
getAgentCalls: () => [],
Expand Down
22 changes: 22 additions & 0 deletions tests/unit/dashboard/formatters.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
esc,
fmt,
fmtCompact,
fmtCost,
renderTokens,
} from "../../../src/dashboard/templates/formatters";

Expand Down Expand Up @@ -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);
Expand Down
6 changes: 6 additions & 0 deletions tests/unit/dashboard/maintenance-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,12 @@ function makeStubRepos(): Repos {
getDailyTokensByModel: () => [],
upsert: () => {},
deleteOlderThan: () => 0,
getCostSummary: () => ({
today: 0,
thisWeek: 0,
thisMonth: 0,
lastMonth: 0,
}),
},
toolCalls: {
getAgentCalls: () => [],
Expand Down
11 changes: 6 additions & 5 deletions tests/unit/dashboard/page-template.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(/^<!DOCTYPE html>/);
expect(html).toContain("<html");
expect(html).toContain("</html>");
});

test("includes page title", () => {
const html = renderHTML([], summary, [], [], []);
const html = renderHTML([], summary, costSummary, [], [], []);
expect(html).toContain("<title>OpenCode Usage Stats</title>");
});

test("includes CSS styles", () => {
const html = renderHTML([], summary, [], [], []);
const html = renderHTML([], summary, costSummary, [], [], []);
expect(html).toContain("<style>");
expect(html).toContain("session-card");
});

test("includes client-side refresh script", () => {
const html = renderHTML([], summary, [], [], []);
const html = renderHTML([], summary, costSummary, [], [], []);
expect(html).toContain("<script>");
expect(html).toContain("setInterval(refresh, 5000)");
});

test("includes auto-refresh badge", () => {
const html = renderHTML([], summary, [], [], []);
const html = renderHTML([], summary, costSummary, [], [], []);
expect(html).toContain("auto-refresh 5s");
expect(html).toContain("refresh-dot");
});
Expand Down
Loading