From 6bf02574097558b8ceec82b0e77da9ea4ffdccf8 Mon Sep 17 00:00:00 2001 From: "Ronny.Herrgesell" Date: Thu, 25 Jun 2026 14:04:22 +0200 Subject: [PATCH 01/13] docs: add daily cost charts design spec --- .../2026-06-25-daily-cost-charts-design.md | 97 +++++++++++++++++++ 1 file changed, 97 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-25-daily-cost-charts-design.md diff --git a/docs/superpowers/specs/2026-06-25-daily-cost-charts-design.md b/docs/superpowers/specs/2026-06-25-daily-cost-charts-design.md new file mode 100644 index 0000000..4da9c0b --- /dev/null +++ b/docs/superpowers/specs/2026-06-25-daily-cost-charts-design.md @@ -0,0 +1,97 @@ +# Daily Cost Charts + +**Date:** 2026-06-25 +**Status:** Approved + +## Summary + +Add two cost charts to the dashboard left panel, mirroring the existing token charts: + +1. **Daily Cost** — bar chart with 5-day moving average, below "Daily Token Usage" +2. **Daily Cost by Model** — stacked bar chart, below "Daily Token Usage by Model" + +No schema changes. No new types. Reuses `DailyTokens` and `DailyModelTokens` shapes throughout. + +## Data Layer + +### `DailyUsageRepo` (`src/db/daily-usage/`) + +Add method to interface and implementation: + +```ts +getHistoryUntilCost(dayExclusive: string, lookbackDays: number): DailyTokens[] +``` + +SQL reads `cost_total` instead of `tokens_total` from the `daily_usage` table. Same WHERE clause as `getHistoryUntil`. + +### `MessageRepo` + `SqliteMessageRepo` (`src/db/message/`) + +Add two methods: + +```ts +getTodayCost(today: string): DailyTokens +// SELECT ? AS date, COALESCE(SUM(cost), 0) AS total FROM messages WHERE timestamp >= ? AND timestamp < date(?, '+1 day') + +getDailyModelCost(): DailyModelTokens[] +// Same query as getDailyTokensByModel() but SUM(cost) AS total instead of token sum +// Returns { date, model, total } where total is cost in dollars +``` + +### `DailyTokensService` (`src/dashboard/services/daily-tokens-service.ts`) + +Add two methods to interface and implementation: + +```ts +getDailyCost(): DailyTokens[] +// Combines: getHistoryUntilCost(today, 60) + getTodayCost(today) +// Same merge logic as getDailyTokens() + +getDailyModelCost(): DailyModelTokens[] +// Delegates to repos.messages.getDailyModelCost() +``` + +## Template Layer + +### `daily-chart.ts` (`src/dashboard/templates/`) + +Add `renderDailyCostChart(daily: DailyTokens[]): string` + +- Same structure as `renderDailyChart` +- Bar label: `fmtCost(d.total)` compact — show value if `d.total > 0` (e.g. `$0.05`, `$1.23`) +- Tooltip: `${tooltipDate}
${fmtCost(d.total)}` +- 5-day moving average polyline (same logic as token chart) +- Title: `"Daily Cost (last 60 days)"` +- Legend: `"Daily cost"` + `"5-day avg"` + +### `model-chart.ts` (`src/dashboard/templates/`) + +Add `renderDailyModelCostChart(modelData: DailyModelTokens[]): string` + +- Same structure as `renderDailyModelChart` +- Tooltip per model: `${fmtCost(val)}` instead of `${fmt(val)}` +- Stacked bars use same `MODEL_COLORS` +- Title: `"Daily Cost by Model (last 60 days)"` + +## Wiring + +### `sessions-fragment.ts` + +- Accept two new params: `dailyCost: DailyTokens[]`, `dailyModelCost: DailyModelTokens[]` +- Render `renderDailyCostChart(dailyCost)` directly after `renderDailyChart` +- Render `renderDailyModelCostChart(dailyModelCost)` directly after `renderDailyModelChart` + +### `stats-route.ts` + +- Call `dailyTokens.getDailyCost()` and `dailyTokens.getDailyModelCost()` +- Pass to `renderSessionsFragment` + +### `page-template.ts` + +- Add `dailyCost: DailyTokens[]` and `dailyModelCost: DailyModelTokens[]` to `renderHTML` signature +- Pass through to `renderSessionsFragment` + +## Out of Scope + +- No changes to `daily_usage` schema (cost already stored) +- No new DB migrations +- No changes to stats bar From 7d5ce18e0d562ece74e0cc0785f67613b07ed6d9 Mon Sep 17 00:00:00 2001 From: "Ronny.Herrgesell" Date: Thu, 25 Jun 2026 14:10:27 +0200 Subject: [PATCH 02/13] docs: add daily cost charts implementation plan --- .../plans/2026-06-25-daily-cost-charts.md | 1091 +++++++++++++++++ 1 file changed, 1091 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-25-daily-cost-charts.md diff --git a/docs/superpowers/plans/2026-06-25-daily-cost-charts.md b/docs/superpowers/plans/2026-06-25-daily-cost-charts.md new file mode 100644 index 0000000..6656511 --- /dev/null +++ b/docs/superpowers/plans/2026-06-25-daily-cost-charts.md @@ -0,0 +1,1091 @@ +# Daily Cost Charts Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add two cost bar charts to the dashboard left panel — "Daily Cost" (with 5-day avg line) below "Daily Token Usage", and "Daily Cost by Model" below "Daily Token Usage by Model". + +**Architecture:** Extend data layer with two new query methods (cost history from rolled-up `daily_usage.cost_total`, and live model-cost from `messages.cost`), add two render functions mirroring existing token charts, then thread the new data arrays through service → route → template. + +**Tech Stack:** Bun, TypeScript, SQLite (bun:sqlite), plain HTML string templates, Biome linter, `bun test` for unit tests. + +## Global Constraints + +- No new DB migrations — `daily_usage.cost_total` and `messages.cost` already exist. +- No new types — reuse `DailyTokens` (`{ date: string; total: number }`) and `DailyModelTokens` (`{ date: string; model: string; total: number }`) from existing imports. +- `fmtCost(n)` from `src/dashboard/templates/formatters.ts` must be used for all cost display (returns `"$0.00"`, `"$0.0234"`, or `"$1.23"`). +- Follow existing patterns exactly: `bun:test` for tests, `createSqliteRepos` + temp DB for repo integration tests, stub objects for service/template unit tests. +- Run `bun test tests/unit` after every commit to verify no regressions. +- Run `bun x tsc --noEmit` before final commit. + +--- + +### Task 1: Extend DailyUsageRepo with cost history query + +**Files:** +- Modify: `src/db/daily-usage/daily-usage-repo.ts` +- Modify: `src/db/daily-usage/sqlite-daily-usage-repo.ts` +- Modify: `tests/unit/sqlite-daily-usage-repo.test.ts` + +**Interfaces:** +- Produces: `repos.dailyUsage.getHistoryUntilCost(dayExclusive: string, lookbackDays: number): DailyTokens[]` + +- [ ] **Step 1: Write the failing test** + +Add to `tests/unit/sqlite-daily-usage-repo.test.ts` inside the `describe` block, after the existing test: + +```ts +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", 60); + const row = history.find((r) => r.date === "2026-05-01"); + expect(row?.total).toBeCloseTo(0.25); + + repos.close(); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +```bash +bun test tests/unit/sqlite-daily-usage-repo.test.ts +``` + +Expected: FAIL — `getHistoryUntilCost is not a function` + +- [ ] **Step 3: Add method to DailyUsageRepo interface** + +In `src/db/daily-usage/daily-usage-repo.ts`, add after `getHistoryUntil`: + +```ts +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[]; +} +``` + +- [ ] **Step 4: Implement in SqliteDailyUsageRepo** + +In `src/db/daily-usage/sqlite-daily-usage-repo.ts`, add after `getHistoryUntil`: + +```ts +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[]; +} +``` + +- [ ] **Step 5: Run test to verify it passes** + +```bash +bun test tests/unit/sqlite-daily-usage-repo.test.ts +``` + +Expected: PASS (both tests) + +- [ ] **Step 6: Commit** + +```bash +git add src/db/daily-usage/daily-usage-repo.ts src/db/daily-usage/sqlite-daily-usage-repo.ts tests/unit/sqlite-daily-usage-repo.test.ts +git commit -m "feat: add getHistoryUntilCost to DailyUsageRepo" +``` + +--- + +### Task 2: Extend MessageRepo with cost query methods + +**Files:** +- Modify: `src/db/message/message-repo.ts` +- Modify: `src/db/message/sqlite-message-repo.ts` +- Modify: `tests/unit/sqlite-message-repo.test.ts` + +**Interfaces:** +- Consumes: `DailyTokens` from `../shared-types`, `DailyModelTokens` from `./message-repo` +- Produces: + - `repos.messages.getTodayCost(today: string): DailyTokens` + - `repos.messages.getDailyModelCost(): DailyModelTokens[]` + +- [ ] **Step 1: Write the failing tests** + +Add to `tests/unit/sqlite-message-repo.test.ts` inside the `describe` block: + +```ts +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(); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +```bash +bun test tests/unit/sqlite-message-repo.test.ts +``` + +Expected: FAIL — `getTodayCost is not a function` + +- [ ] **Step 3: Add methods to MessageRepo interface** + +In `src/db/message/message-repo.ts`, add two lines to the `MessageRepo` interface: + +```ts +export interface MessageRepo { + upsert(data: MessageData): void; + getModeStats(): ModeRow[]; + getTokenSummary(): TokenSummary; + getCostSummary(): CostSummary; + getTodayTokens(today: string): DailyTokens; + getTodayCost(today: string): DailyTokens; + getDailyTokensByModel(): DailyModelTokens[]; + getDailyModelCost(): DailyModelTokens[]; + deleteOlderThan(cutoffDate: string): number; +} +``` + +- [ ] **Step 4: Implement getTodayCost in SqliteMessageRepo** + +In `src/db/message/sqlite-message-repo.ts`: + +Add `private readonly todayCostStmt;` to the class properties (after `todayTokensStmt`). + +Add to the `constructor` body (after `this.todayTokensStmt = ...`): + +```ts +this.todayCostStmt = this.db.prepare(` + SELECT ? AS date, + COALESCE(SUM(cost), 0) AS total + FROM messages + WHERE timestamp >= ? AND timestamp < date(?, '+1 day') +`); +``` + +Add method after `getTodayTokens`: + +```ts +getTodayCost(today: string): DailyTokens { + return this.todayCostStmt.get(today, today, today) as DailyTokens; +} +``` + +- [ ] **Step 5: Implement getDailyModelCost in SqliteMessageRepo** + +Add method after `getDailyTokensByModel`: + +```ts +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[]; +} +``` + +- [ ] **Step 6: Run tests to verify they pass** + +```bash +bun test tests/unit/sqlite-message-repo.test.ts +``` + +Expected: PASS (all 3 tests) + +- [ ] **Step 7: Commit** + +```bash +git add src/db/message/message-repo.ts src/db/message/sqlite-message-repo.ts tests/unit/sqlite-message-repo.test.ts +git commit -m "feat: add getTodayCost and getDailyModelCost to MessageRepo" +``` + +--- + +### Task 3: Extend DailyTokensService with cost methods + +**Files:** +- Modify: `src/dashboard/services/daily-tokens-service.ts` +- Modify: `tests/unit/dashboard/daily-tokens-service.test.ts` + +**Interfaces:** +- Consumes: `repos.messages.getTodayCost`, `repos.messages.getDailyModelCost`, `repos.dailyUsage.getHistoryUntilCost` (all from Tasks 1–2) +- Produces: + - `dailyTokensService.getDailyCost(): DailyTokens[]` + - `dailyTokensService.getDailyModelCost(): DailyModelTokens[]` + +- [ ] **Step 1: Update stub repos in the test file** + +The stub repos in `tests/unit/dashboard/daily-tokens-service.test.ts` must include the new methods or TypeScript will fail. Update `makeStubRepos` — add `getTodayCost` to the `messages` stub and `getHistoryUntilCost` to the `dailyUsage` stub, plus add an optional `todayCost` and `historyCost` override: + +```ts +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; + thisMonth: number; + lastMonth: number; + }; + dailyModel: { date: string; model: string; total: number }[]; + dailyModelCost: { date: string; model: string; total: number }[]; + }> = {}, +): Repos { + return { + sessions: { + getRootSessions: () => [], + getChildSessions: () => [], + getDistinctDirectories: () => [], + upsert: () => {}, + upsertFull: () => {}, + deleteOrphaned: () => 0, + }, + messages: { + getModeStats: () => [], + getTokenSummary: () => + overrides.tokenSummary ?? { + today: 0, + thisWeek: 0, + thisMonth: 0, + lastMonth: 0, + }, + getTodayTokens: () => + overrides.todayTokens ?? { + 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: () => ({ + today: 0, + thisWeek: 0, + thisMonth: 0, + lastMonth: 0, + }), + }, + toolCalls: { + getAgentCalls: () => [], + getToolUsageSummary: () => [], + insert: () => {}, + deleteOlderThan: () => 0, + }, + dailyUsage: { + recompute: () => {}, + getHistoryUntil: () => overrides.history ?? [], + getHistoryUntilCost: () => overrides.historyCost ?? [], + }, + vacuum: () => {}, + close: () => {}, + }; +} +``` + +- [ ] **Step 2: Write the failing tests** + +Add to `tests/unit/dashboard/daily-tokens-service.test.ts` inside the `describe` block: + +```ts +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); +}); +``` + +- [ ] **Step 3: Run tests to verify they fail** + +```bash +bun test tests/unit/dashboard/daily-tokens-service.test.ts +``` + +Expected: FAIL — `getDailyCost is not a function` + +- [ ] **Step 4: Add methods to DailyTokensService interface** + +In `src/dashboard/services/daily-tokens-service.ts`, update the interface: + +```ts +export interface DailyTokensService { + getDailyTokens(): DailyTokens[]; + getDailyTokensByModel(): DailyModelTokens[]; + getDailyCost(): DailyTokens[]; + getDailyModelCost(): DailyModelTokens[]; + getTokenSummary(): TokenSummary; + getCostSummary(): CostSummary; +} +``` + +- [ ] **Step 5: Implement the new methods** + +In `src/dashboard/services/daily-tokens-service.ts`, add inside the returned object in `createDailyTokensService`: + +```ts +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(); +}, +``` + +- [ ] **Step 6: Run tests to verify they pass** + +```bash +bun test tests/unit/dashboard/daily-tokens-service.test.ts +``` + +Expected: PASS (all 7 tests) + +- [ ] **Step 7: Commit** + +```bash +git add src/dashboard/services/daily-tokens-service.ts tests/unit/dashboard/daily-tokens-service.test.ts +git commit -m "feat: add getDailyCost and getDailyModelCost to DailyTokensService" +``` + +--- + +### Task 4: Add renderDailyCostChart template + +**Files:** +- Modify: `src/dashboard/templates/daily-chart.ts` +- Modify: `tests/unit/dashboard/daily-chart.test.ts` + +**Interfaces:** +- Consumes: `DailyTokens` from `../../db/shared-types`, `fmtCost` from `./formatters` +- Produces: `renderDailyCostChart(daily: DailyTokens[]): string` + +- [ ] **Step 1: Write the failing tests** + +Add to `tests/unit/dashboard/daily-chart.test.ts`: + +```ts +import { renderDailyChart, renderDailyCostChart } from "../../../src/dashboard/templates/daily-chart"; + +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"); + }); +}); +``` + +Note: update the import at the top of the file to include `renderDailyCostChart`. + +- [ ] **Step 2: Run tests to verify they fail** + +```bash +bun test tests/unit/dashboard/daily-chart.test.ts +``` + +Expected: FAIL — `renderDailyCostChart is not a function` + +- [ ] **Step 3: Implement renderDailyCostChart** + +Add to `src/dashboard/templates/daily-chart.ts` (after `renderDailyChart`): + +```ts +import type { DailyTokens } from "../../db/shared-types"; +import { fmt, fmtCost } from "./formatters"; + +// ... existing renderDailyChart ... + +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 +
+
`; +} +``` + +Important: update the import line at the top of `daily-chart.ts` to add `fmtCost`: + +```ts +import { fmt, fmtCost } from "./formatters"; +``` + +- [ ] **Step 4: Run tests to verify they pass** + +```bash +bun test tests/unit/dashboard/daily-chart.test.ts +``` + +Expected: PASS (all 10 tests — 5 existing + 5 new) + +- [ ] **Step 5: Commit** + +```bash +git add src/dashboard/templates/daily-chart.ts tests/unit/dashboard/daily-chart.test.ts +git commit -m "feat: add renderDailyCostChart template" +``` + +--- + +### Task 5: Add renderDailyModelCostChart template + +**Files:** +- Modify: `src/dashboard/templates/model-chart.ts` +- Modify: `tests/unit/dashboard/model-chart.test.ts` + +**Interfaces:** +- Consumes: `DailyModelTokens` from `../../db/message/message-repo`, `esc`, `fmtCost` from `./formatters`, `MODEL_COLORS` (already exported from this file) +- Produces: `renderDailyModelCostChart(modelData: DailyModelTokens[]): string` + +- [ ] **Step 1: Write the failing tests** + +Add to `tests/unit/dashboard/model-chart.test.ts`: + +```ts +import { + MODEL_COLORS, + renderDailyModelChart, + renderDailyModelCostChart, +} from "../../../src/dashboard/templates/model-chart"; + +// ... existing tests ... + +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"); + }); +}); +``` + +Note: update the import at the top to include `renderDailyModelCostChart`. + +- [ ] **Step 2: Run tests to verify they fail** + +```bash +bun test tests/unit/dashboard/model-chart.test.ts +``` + +Expected: FAIL — `renderDailyModelCostChart is not a function` + +- [ ] **Step 3: Implement renderDailyModelCostChart** + +In `src/dashboard/templates/model-chart.ts`, update the import line (keep `fmt` for the existing `renderDailyModelChart`): + +```ts +import { esc, fmt, fmtCost } from "./formatters"; +``` + +Then add after `renderDailyModelChart`: + +```ts +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} +
+
`; +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +```bash +bun test tests/unit/dashboard/model-chart.test.ts +``` + +Expected: PASS (all 9 tests — 4 existing + 5 new) + +- [ ] **Step 5: Commit** + +```bash +git add src/dashboard/templates/model-chart.ts tests/unit/dashboard/model-chart.test.ts +git commit -m "feat: add renderDailyModelCostChart template" +``` + +--- + +### Task 6: Wire cost data through routes and templates + +**Files:** +- Modify: `src/dashboard/templates/sessions-fragment.ts` +- Modify: `src/dashboard/templates/page-template.ts` +- Modify: `src/dashboard/routes/stats-route.ts` +- Modify: `src/dashboard/routes/page-route.ts` +- Modify: `tests/unit/dashboard/sessions-fragment.test.ts` +- Modify: `tests/unit/dashboard/routes.test.ts` +- Modify: `tests/unit/dashboard/page-template.test.ts` + +**Interfaces:** +- Consumes: `renderDailyCostChart` from `./daily-chart`, `renderDailyModelCostChart` from `./model-chart`, `dailyTokens.getDailyCost()`, `dailyTokens.getDailyModelCost()` +- Produces: cost charts rendered in left panel of dashboard + +- [ ] **Step 1: Update stubs in routes.test.ts** + +In `tests/unit/dashboard/routes.test.ts`, update `makeStubDailyTokens`: + +```ts +function makeStubDailyTokens(): DailyTokensService { + return { + getDailyTokens: () => [], + getDailyTokensByModel: () => [], + getDailyCost: () => [], + getDailyModelCost: () => [], + getTokenSummary: () => ({ + today: 0, + thisWeek: 0, + thisMonth: 0, + lastMonth: 0, + }), + getCostSummary: () => ({ + today: 0, + thisWeek: 0, + thisMonth: 0, + lastMonth: 0, + }), + }; +} +``` + +- [ ] **Step 2: Run routes tests to verify they still pass after stub update** + +```bash +bun test tests/unit/dashboard/routes.test.ts +``` + +Expected: TypeScript compilation error until we update the interface in step 3, but once interface is updated all tests pass. + +- [ ] **Step 3: Update sessions-fragment.ts** + +In `src/dashboard/templates/sessions-fragment.ts`: + +Update imports: + +```ts +import { renderDailyCostChart } from "./daily-chart"; +import { renderDailyModelCostChart } from "./model-chart"; +``` + +Update function signature (add two optional params at the end): + +```ts +export function renderSessionsFragment( + sessions: SessionStats[], + summary: TokenSummary, + costSummary: CostSummary, + daily: DailyTokens[], + dailyModel: DailyModelTokens[], + toolGroups: ToolGroupSummary[], + directories: string[] = [], + selectedDir?: string, + dailyCost: DailyTokens[] = [], + dailyModelCost: DailyModelTokens[] = [], +): string { +``` + +Update the render calls and left panel: + +```ts +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 = ` +
+ ${bar} +
+ ${chart} + ${costChart} + ${modelChart} + ${modelCostChart} + ${toolUsage} +
`; +``` + +- [ ] **Step 4: Add tests for sessions-fragment cost charts** + +Add to `tests/unit/dashboard/sessions-fragment.test.ts`: + +```ts +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)"); +}); +``` + +- [ ] **Step 5: Run sessions-fragment tests** + +```bash +bun test tests/unit/dashboard/sessions-fragment.test.ts +``` + +Expected: PASS (all 7 tests) + +- [ ] **Step 6: Update page-template.ts** + +In `src/dashboard/templates/page-template.ts`, update `renderHTML` signature and the `renderSessionsFragment` call: + +```ts +export function renderHTML( + sessions: SessionStats[], + summary: TokenSummary, + costSummary: CostSummary, + daily: DailyTokens[], + dailyModel: DailyModelTokens[], + toolGroups: ToolGroupSummary[], + directories: string[] = [], + selectedDir?: string, + dailyCost: DailyTokens[] = [], + dailyModelCost: DailyModelTokens[] = [], +): string { + return ` +... +
+ ${renderSessionsFragment(sessions, summary, costSummary, daily, dailyModel, toolGroups, directories, selectedDir, dailyCost, dailyModelCost)} +
+...`; +} +``` + +Keep all existing HTML structure intact — only the function signature and the `renderSessionsFragment` call arguments change. + +- [ ] **Step 7: Update stats-route.ts** + +In `src/dashboard/routes/stats-route.ts`, add two lines after `const dailyModel = ...`: + +```ts +const dailyCost = dailyTokens.getDailyCost(); +const dailyModelCost = dailyTokens.getDailyModelCost(); +``` + +Update the `renderSessionsFragment` call: + +```ts +const html = renderSessionsFragment( + sessions, + summary, + costSummary, + daily, + dailyModel, + toolGroups, + directories, + dirFilter, + dailyCost, + dailyModelCost, +); +``` + +- [ ] **Step 8: Update page-route.ts** + +In `src/dashboard/routes/page-route.ts`, add two lines after `const dailyModel = ...`: + +```ts +const dailyCost = dailyTokens.getDailyCost(); +const dailyModelCost = dailyTokens.getDailyModelCost(); +``` + +Update the `renderHTML` call: + +```ts +return new Response( + renderHTML( + sessions, + summary, + costSummary, + daily, + dailyModel, + toolGroups, + directories, + dirFilter, + dailyCost, + dailyModelCost, + ), + { + headers: { "Content-Type": "text/html; charset=utf-8" }, + }, +); +``` + +- [ ] **Step 9: Run full unit test suite** + +```bash +bun test tests/unit +``` + +Expected: All tests pass, no TypeScript errors. + +- [ ] **Step 10: Run typecheck** + +```bash +bun x tsc --noEmit +``` + +Expected: no errors + +- [ ] **Step 11: Commit** + +```bash +git add src/dashboard/templates/sessions-fragment.ts src/dashboard/templates/page-template.ts src/dashboard/routes/stats-route.ts src/dashboard/routes/page-route.ts tests/unit/dashboard/sessions-fragment.test.ts tests/unit/dashboard/routes.test.ts tests/unit/dashboard/page-template.test.ts +git commit -m "feat: wire daily cost charts into dashboard" +``` From f25b5b41ff309f27af9a179b2c400672db2657a9 Mon Sep 17 00:00:00 2001 From: "Ronny.Herrgesell" Date: Thu, 25 Jun 2026 14:14:05 +0200 Subject: [PATCH 03/13] feat: add getHistoryUntilCost to DailyUsageRepo --- src/db/daily-usage/daily-usage-repo.ts | 1 + src/db/daily-usage/sqlite-daily-usage-repo.ts | 12 +++++++++ tests/unit/sqlite-daily-usage-repo.test.ts | 25 ++++++++++++++++++- 3 files changed, 37 insertions(+), 1 deletion(-) diff --git a/src/db/daily-usage/daily-usage-repo.ts b/src/db/daily-usage/daily-usage-repo.ts index ac2697c..47f4f3d 100644 --- a/src/db/daily-usage/daily-usage-repo.ts +++ b/src/db/daily-usage/daily-usage-repo.ts @@ -3,4 +3,5 @@ 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..aa6c0f4 100644 --- a/src/db/daily-usage/sqlite-daily-usage-repo.ts +++ b/src/db/daily-usage/sqlite-daily-usage-repo.ts @@ -77,4 +77,16 @@ 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/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(); + }); }); From 42e4f09cac0f7f0293c133895c65cf6e7414afdb Mon Sep 17 00:00:00 2001 From: "Ronny.Herrgesell" Date: Thu, 25 Jun 2026 14:21:09 +0200 Subject: [PATCH 04/13] feat: add getTodayCost and getDailyModelCost to MessageRepo --- src/db/message/message-repo.ts | 2 + src/db/message/sqlite-message-repo.ts | 26 ++++++++++ tests/unit/sqlite-message-repo.test.ts | 72 ++++++++++++++++++++++++++ 3 files changed, 100 insertions(+) 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/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(); + }); }); From a2eb155721e1ce0db77053a9c373a5ed7941b6ab Mon Sep 17 00:00:00 2001 From: "Ronny.Herrgesell" Date: Thu, 25 Jun 2026 14:44:36 +0200 Subject: [PATCH 05/13] feat: add getDailyCost and getDailyModelCost to DailyTokensService - Add getDailyCost() method that merges today's cost with 60-day history - Add getDailyModelCost() method that delegates to message repo - Update stub repos in tests with new cost methods (getTodayCost, getDailyModelCost, getHistoryUntilCost) - Add 3 new tests: getDailyCost gap filling, cost merging, and model cost delegation Co-Authored-By: Claude Sonnet 4.6 --- .../services/daily-tokens-service.ts | 25 ++++++++++++ .../dashboard/daily-tokens-service.test.ts | 38 +++++++++++++++++++ 2 files changed, 63 insertions(+) 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/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); + }); }); From e4b1a3c3baa439dcc06b693034f26229a05f8e6b Mon Sep 17 00:00:00 2001 From: "Ronny.Herrgesell" Date: Thu, 25 Jun 2026 14:45:46 +0200 Subject: [PATCH 06/13] feat: add renderDailyCostChart template Co-Authored-By: Claude Sonnet 4.6 --- src/dashboard/templates/daily-chart.ts | 62 +++++++++++++++++++++++- tests/unit/dashboard/daily-chart.test.ts | 34 ++++++++++++- 2 files changed, 94 insertions(+), 2 deletions(-) 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/tests/unit/dashboard/daily-chart.test.ts b/tests/unit/dashboard/daily-chart.test.ts index 80db030..e87a600 100644 --- a/tests/unit/dashboard/daily-chart.test.ts +++ b/tests/unit/dashboard/daily-chart.test.ts @@ -1,5 +1,5 @@ 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 +32,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"); + }); +}); From 2bdd6986f38335b99f6f0adff73acfbf7bda9217 Mon Sep 17 00:00:00 2001 From: "Ronny.Herrgesell" Date: Thu, 25 Jun 2026 15:39:19 +0200 Subject: [PATCH 07/13] feat: add renderDailyModelCostChart template --- src/dashboard/templates/model-chart.ts | 90 +++++++++++++++++++++++- tests/unit/dashboard/model-chart.test.ts | 44 ++++++++++++ 2 files changed, 133 insertions(+), 1 deletion(-) diff --git a/src/dashboard/templates/model-chart.ts b/src/dashboard/templates/model-chart.ts index ada0953..ba8da9c 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,91 @@ 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/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"); + }); +}); From 59ec18bc47001900b6fcd87a85079e57076dcf17 Mon Sep 17 00:00:00 2001 From: "Ronny.Herrgesell" Date: Thu, 25 Jun 2026 15:42:20 +0200 Subject: [PATCH 08/13] feat: wire daily cost charts into dashboard Adds getDailyCost/getDailyModelCost calls through routes and templates, rendering cost charts in the left panel alongside token usage charts. Also fixes missing getTodayCost/getDailyModelCost/getHistoryUntilCost stubs in four test files to satisfy the updated MessageRepo/DailyUsageRepo interfaces. Co-Authored-By: Claude Sonnet 4.6 --- src/dashboard/routes/page-route.ts | 4 +++ src/dashboard/routes/stats-route.ts | 4 +++ src/dashboard/templates/page-template.ts | 4 ++- src/dashboard/templates/sessions-fragment.ts | 10 ++++-- .../dashboard/maintenance-service.test.ts | 3 ++ tests/unit/dashboard/routes.test.ts | 5 +++ .../dashboard/session-stats-service.test.ts | 3 ++ .../unit/dashboard/sessions-fragment.test.ts | 34 +++++++++++++++++++ tests/unit/handlers.test.ts | 3 ++ 9 files changed, 67 insertions(+), 3 deletions(-) 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/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..0e61271 100644 --- a/src/dashboard/templates/sessions-fragment.ts +++ b/src/dashboard/templates/sessions-fragment.ts @@ -6,9 +6,9 @@ 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 { renderDailyCostChart, renderDailyChart } 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 +22,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 +37,9 @@ export function renderSessionsFragment( ${bar}
${chart} + ${costChart} ${modelChart} + ${modelCostChart} ${toolUsage} `; 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/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: () => {}, From 219eea2ba34e97463f66a246f44123f3fe18a063 Mon Sep 17 00:00:00 2001 From: "Ronny.Herrgesell" Date: Thu, 25 Jun 2026 15:52:26 +0200 Subject: [PATCH 09/13] style: apply biome formatting fixes --- src/dashboard/templates/model-chart.ts | 4 +++- src/dashboard/templates/sessions-fragment.ts | 7 +++++-- src/db/daily-usage/daily-usage-repo.ts | 5 ++++- src/db/daily-usage/sqlite-daily-usage-repo.ts | 5 ++++- tests/unit/dashboard/daily-chart.test.ts | 5 ++++- 5 files changed, 20 insertions(+), 6 deletions(-) diff --git a/src/dashboard/templates/model-chart.ts b/src/dashboard/templates/model-chart.ts index ba8da9c..9cb8ba6 100644 --- a/src/dashboard/templates/model-chart.ts +++ b/src/dashboard/templates/model-chart.ts @@ -102,7 +102,9 @@ export function renderDailyModelChart(modelData: DailyModelTokens[]): string { `; } -export function renderDailyModelCostChart(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); diff --git a/src/dashboard/templates/sessions-fragment.ts b/src/dashboard/templates/sessions-fragment.ts index 0e61271..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 { renderDailyCostChart, renderDailyChart } from "./daily-chart"; +import { renderDailyChart, renderDailyCostChart } from "./daily-chart"; import { esc } from "./formatters"; -import { renderDailyModelChart, renderDailyModelCostChart } from "./model-chart"; +import { + renderDailyModelChart, + renderDailyModelCostChart, +} from "./model-chart"; import { renderSessionCard } from "./session-card"; import { renderStatsBar } from "./stats-bar"; import { renderToolUsage } from "./tool-usage"; diff --git a/src/db/daily-usage/daily-usage-repo.ts b/src/db/daily-usage/daily-usage-repo.ts index 47f4f3d..78118be 100644 --- a/src/db/daily-usage/daily-usage-repo.ts +++ b/src/db/daily-usage/daily-usage-repo.ts @@ -3,5 +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[]; + 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 aa6c0f4..eafbf2f 100644 --- a/src/db/daily-usage/sqlite-daily-usage-repo.ts +++ b/src/db/daily-usage/sqlite-daily-usage-repo.ts @@ -78,7 +78,10 @@ export class SqliteDailyUsageRepo implements DailyUsageRepo { .all(dayExclusive, `-${lookbackDays} days`) as DailyTokens[]; } - getHistoryUntilCost(dayExclusive: string, lookbackDays: number): DailyTokens[] { + getHistoryUntilCost( + dayExclusive: string, + lookbackDays: number, + ): DailyTokens[] { return this.db .prepare(` SELECT day AS date, cost_total AS total diff --git a/tests/unit/dashboard/daily-chart.test.ts b/tests/unit/dashboard/daily-chart.test.ts index e87a600..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, renderDailyCostChart } from "../../../src/dashboard/templates/daily-chart"; +import { + renderDailyChart, + renderDailyCostChart, +} from "../../../src/dashboard/templates/daily-chart"; describe("renderDailyChart", () => { test("renders 60 chart columns", () => { From 86e93dcb293c521de7035c2be4033de7e64026ad Mon Sep 17 00:00:00 2001 From: "Ronny.Herrgesell" Date: Thu, 25 Jun 2026 16:00:54 +0200 Subject: [PATCH 10/13] fix: update e2e tests for new cost charts (nth index + chart count) --- tests/e2e/it_renders_model_chart.spec.ts | 10 +++++----- tests/e2e/it_renders_two_column_layout.spec.ts | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) 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 ({ From 0482bf3c2529221d7c10a36f9b03f82c1e1336f1 Mon Sep 17 00:00:00 2001 From: "Ronny.Herrgesell" Date: Thu, 25 Jun 2026 16:02:42 +0200 Subject: [PATCH 11/13] chore: add husky pre-commit (biome + tsc) and pre-push (unit tests) hooks --- .husky/pre-commit | 2 ++ .husky/pre-push | 1 + bun.lock | 3 +++ package.json | 4 +++- 4 files changed, 9 insertions(+), 1 deletion(-) create mode 100644 .husky/pre-commit create mode 100644 .husky/pre-push 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" } } From 3b7e3ebfc21692fc9f40fdcbcd625ad655b716d3 Mon Sep 17 00:00:00 2001 From: "Ronny.Herrgesell" Date: Thu, 25 Jun 2026 16:04:08 +0200 Subject: [PATCH 12/13] chore: remove docs folder and ignore docs/superpowers --- .gitignore | 1 + .../plans/2026-06-25-daily-cost-charts.md | 1091 ----------------- .../2026-06-25-daily-cost-charts-design.md | 97 -- 3 files changed, 1 insertion(+), 1188 deletions(-) delete mode 100644 docs/superpowers/plans/2026-06-25-daily-cost-charts.md delete mode 100644 docs/superpowers/specs/2026-06-25-daily-cost-charts-design.md 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/docs/superpowers/plans/2026-06-25-daily-cost-charts.md b/docs/superpowers/plans/2026-06-25-daily-cost-charts.md deleted file mode 100644 index 6656511..0000000 --- a/docs/superpowers/plans/2026-06-25-daily-cost-charts.md +++ /dev/null @@ -1,1091 +0,0 @@ -# Daily Cost Charts Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Add two cost bar charts to the dashboard left panel — "Daily Cost" (with 5-day avg line) below "Daily Token Usage", and "Daily Cost by Model" below "Daily Token Usage by Model". - -**Architecture:** Extend data layer with two new query methods (cost history from rolled-up `daily_usage.cost_total`, and live model-cost from `messages.cost`), add two render functions mirroring existing token charts, then thread the new data arrays through service → route → template. - -**Tech Stack:** Bun, TypeScript, SQLite (bun:sqlite), plain HTML string templates, Biome linter, `bun test` for unit tests. - -## Global Constraints - -- No new DB migrations — `daily_usage.cost_total` and `messages.cost` already exist. -- No new types — reuse `DailyTokens` (`{ date: string; total: number }`) and `DailyModelTokens` (`{ date: string; model: string; total: number }`) from existing imports. -- `fmtCost(n)` from `src/dashboard/templates/formatters.ts` must be used for all cost display (returns `"$0.00"`, `"$0.0234"`, or `"$1.23"`). -- Follow existing patterns exactly: `bun:test` for tests, `createSqliteRepos` + temp DB for repo integration tests, stub objects for service/template unit tests. -- Run `bun test tests/unit` after every commit to verify no regressions. -- Run `bun x tsc --noEmit` before final commit. - ---- - -### Task 1: Extend DailyUsageRepo with cost history query - -**Files:** -- Modify: `src/db/daily-usage/daily-usage-repo.ts` -- Modify: `src/db/daily-usage/sqlite-daily-usage-repo.ts` -- Modify: `tests/unit/sqlite-daily-usage-repo.test.ts` - -**Interfaces:** -- Produces: `repos.dailyUsage.getHistoryUntilCost(dayExclusive: string, lookbackDays: number): DailyTokens[]` - -- [ ] **Step 1: Write the failing test** - -Add to `tests/unit/sqlite-daily-usage-repo.test.ts` inside the `describe` block, after the existing test: - -```ts -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", 60); - const row = history.find((r) => r.date === "2026-05-01"); - expect(row?.total).toBeCloseTo(0.25); - - repos.close(); -}); -``` - -- [ ] **Step 2: Run test to verify it fails** - -```bash -bun test tests/unit/sqlite-daily-usage-repo.test.ts -``` - -Expected: FAIL — `getHistoryUntilCost is not a function` - -- [ ] **Step 3: Add method to DailyUsageRepo interface** - -In `src/db/daily-usage/daily-usage-repo.ts`, add after `getHistoryUntil`: - -```ts -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[]; -} -``` - -- [ ] **Step 4: Implement in SqliteDailyUsageRepo** - -In `src/db/daily-usage/sqlite-daily-usage-repo.ts`, add after `getHistoryUntil`: - -```ts -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[]; -} -``` - -- [ ] **Step 5: Run test to verify it passes** - -```bash -bun test tests/unit/sqlite-daily-usage-repo.test.ts -``` - -Expected: PASS (both tests) - -- [ ] **Step 6: Commit** - -```bash -git add src/db/daily-usage/daily-usage-repo.ts src/db/daily-usage/sqlite-daily-usage-repo.ts tests/unit/sqlite-daily-usage-repo.test.ts -git commit -m "feat: add getHistoryUntilCost to DailyUsageRepo" -``` - ---- - -### Task 2: Extend MessageRepo with cost query methods - -**Files:** -- Modify: `src/db/message/message-repo.ts` -- Modify: `src/db/message/sqlite-message-repo.ts` -- Modify: `tests/unit/sqlite-message-repo.test.ts` - -**Interfaces:** -- Consumes: `DailyTokens` from `../shared-types`, `DailyModelTokens` from `./message-repo` -- Produces: - - `repos.messages.getTodayCost(today: string): DailyTokens` - - `repos.messages.getDailyModelCost(): DailyModelTokens[]` - -- [ ] **Step 1: Write the failing tests** - -Add to `tests/unit/sqlite-message-repo.test.ts` inside the `describe` block: - -```ts -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(); -}); -``` - -- [ ] **Step 2: Run tests to verify they fail** - -```bash -bun test tests/unit/sqlite-message-repo.test.ts -``` - -Expected: FAIL — `getTodayCost is not a function` - -- [ ] **Step 3: Add methods to MessageRepo interface** - -In `src/db/message/message-repo.ts`, add two lines to the `MessageRepo` interface: - -```ts -export interface MessageRepo { - upsert(data: MessageData): void; - getModeStats(): ModeRow[]; - getTokenSummary(): TokenSummary; - getCostSummary(): CostSummary; - getTodayTokens(today: string): DailyTokens; - getTodayCost(today: string): DailyTokens; - getDailyTokensByModel(): DailyModelTokens[]; - getDailyModelCost(): DailyModelTokens[]; - deleteOlderThan(cutoffDate: string): number; -} -``` - -- [ ] **Step 4: Implement getTodayCost in SqliteMessageRepo** - -In `src/db/message/sqlite-message-repo.ts`: - -Add `private readonly todayCostStmt;` to the class properties (after `todayTokensStmt`). - -Add to the `constructor` body (after `this.todayTokensStmt = ...`): - -```ts -this.todayCostStmt = this.db.prepare(` - SELECT ? AS date, - COALESCE(SUM(cost), 0) AS total - FROM messages - WHERE timestamp >= ? AND timestamp < date(?, '+1 day') -`); -``` - -Add method after `getTodayTokens`: - -```ts -getTodayCost(today: string): DailyTokens { - return this.todayCostStmt.get(today, today, today) as DailyTokens; -} -``` - -- [ ] **Step 5: Implement getDailyModelCost in SqliteMessageRepo** - -Add method after `getDailyTokensByModel`: - -```ts -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[]; -} -``` - -- [ ] **Step 6: Run tests to verify they pass** - -```bash -bun test tests/unit/sqlite-message-repo.test.ts -``` - -Expected: PASS (all 3 tests) - -- [ ] **Step 7: Commit** - -```bash -git add src/db/message/message-repo.ts src/db/message/sqlite-message-repo.ts tests/unit/sqlite-message-repo.test.ts -git commit -m "feat: add getTodayCost and getDailyModelCost to MessageRepo" -``` - ---- - -### Task 3: Extend DailyTokensService with cost methods - -**Files:** -- Modify: `src/dashboard/services/daily-tokens-service.ts` -- Modify: `tests/unit/dashboard/daily-tokens-service.test.ts` - -**Interfaces:** -- Consumes: `repos.messages.getTodayCost`, `repos.messages.getDailyModelCost`, `repos.dailyUsage.getHistoryUntilCost` (all from Tasks 1–2) -- Produces: - - `dailyTokensService.getDailyCost(): DailyTokens[]` - - `dailyTokensService.getDailyModelCost(): DailyModelTokens[]` - -- [ ] **Step 1: Update stub repos in the test file** - -The stub repos in `tests/unit/dashboard/daily-tokens-service.test.ts` must include the new methods or TypeScript will fail. Update `makeStubRepos` — add `getTodayCost` to the `messages` stub and `getHistoryUntilCost` to the `dailyUsage` stub, plus add an optional `todayCost` and `historyCost` override: - -```ts -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; - thisMonth: number; - lastMonth: number; - }; - dailyModel: { date: string; model: string; total: number }[]; - dailyModelCost: { date: string; model: string; total: number }[]; - }> = {}, -): Repos { - return { - sessions: { - getRootSessions: () => [], - getChildSessions: () => [], - getDistinctDirectories: () => [], - upsert: () => {}, - upsertFull: () => {}, - deleteOrphaned: () => 0, - }, - messages: { - getModeStats: () => [], - getTokenSummary: () => - overrides.tokenSummary ?? { - today: 0, - thisWeek: 0, - thisMonth: 0, - lastMonth: 0, - }, - getTodayTokens: () => - overrides.todayTokens ?? { - 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: () => ({ - today: 0, - thisWeek: 0, - thisMonth: 0, - lastMonth: 0, - }), - }, - toolCalls: { - getAgentCalls: () => [], - getToolUsageSummary: () => [], - insert: () => {}, - deleteOlderThan: () => 0, - }, - dailyUsage: { - recompute: () => {}, - getHistoryUntil: () => overrides.history ?? [], - getHistoryUntilCost: () => overrides.historyCost ?? [], - }, - vacuum: () => {}, - close: () => {}, - }; -} -``` - -- [ ] **Step 2: Write the failing tests** - -Add to `tests/unit/dashboard/daily-tokens-service.test.ts` inside the `describe` block: - -```ts -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); -}); -``` - -- [ ] **Step 3: Run tests to verify they fail** - -```bash -bun test tests/unit/dashboard/daily-tokens-service.test.ts -``` - -Expected: FAIL — `getDailyCost is not a function` - -- [ ] **Step 4: Add methods to DailyTokensService interface** - -In `src/dashboard/services/daily-tokens-service.ts`, update the interface: - -```ts -export interface DailyTokensService { - getDailyTokens(): DailyTokens[]; - getDailyTokensByModel(): DailyModelTokens[]; - getDailyCost(): DailyTokens[]; - getDailyModelCost(): DailyModelTokens[]; - getTokenSummary(): TokenSummary; - getCostSummary(): CostSummary; -} -``` - -- [ ] **Step 5: Implement the new methods** - -In `src/dashboard/services/daily-tokens-service.ts`, add inside the returned object in `createDailyTokensService`: - -```ts -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(); -}, -``` - -- [ ] **Step 6: Run tests to verify they pass** - -```bash -bun test tests/unit/dashboard/daily-tokens-service.test.ts -``` - -Expected: PASS (all 7 tests) - -- [ ] **Step 7: Commit** - -```bash -git add src/dashboard/services/daily-tokens-service.ts tests/unit/dashboard/daily-tokens-service.test.ts -git commit -m "feat: add getDailyCost and getDailyModelCost to DailyTokensService" -``` - ---- - -### Task 4: Add renderDailyCostChart template - -**Files:** -- Modify: `src/dashboard/templates/daily-chart.ts` -- Modify: `tests/unit/dashboard/daily-chart.test.ts` - -**Interfaces:** -- Consumes: `DailyTokens` from `../../db/shared-types`, `fmtCost` from `./formatters` -- Produces: `renderDailyCostChart(daily: DailyTokens[]): string` - -- [ ] **Step 1: Write the failing tests** - -Add to `tests/unit/dashboard/daily-chart.test.ts`: - -```ts -import { renderDailyChart, renderDailyCostChart } from "../../../src/dashboard/templates/daily-chart"; - -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"); - }); -}); -``` - -Note: update the import at the top of the file to include `renderDailyCostChart`. - -- [ ] **Step 2: Run tests to verify they fail** - -```bash -bun test tests/unit/dashboard/daily-chart.test.ts -``` - -Expected: FAIL — `renderDailyCostChart is not a function` - -- [ ] **Step 3: Implement renderDailyCostChart** - -Add to `src/dashboard/templates/daily-chart.ts` (after `renderDailyChart`): - -```ts -import type { DailyTokens } from "../../db/shared-types"; -import { fmt, fmtCost } from "./formatters"; - -// ... existing renderDailyChart ... - -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 -
-
`; -} -``` - -Important: update the import line at the top of `daily-chart.ts` to add `fmtCost`: - -```ts -import { fmt, fmtCost } from "./formatters"; -``` - -- [ ] **Step 4: Run tests to verify they pass** - -```bash -bun test tests/unit/dashboard/daily-chart.test.ts -``` - -Expected: PASS (all 10 tests — 5 existing + 5 new) - -- [ ] **Step 5: Commit** - -```bash -git add src/dashboard/templates/daily-chart.ts tests/unit/dashboard/daily-chart.test.ts -git commit -m "feat: add renderDailyCostChart template" -``` - ---- - -### Task 5: Add renderDailyModelCostChart template - -**Files:** -- Modify: `src/dashboard/templates/model-chart.ts` -- Modify: `tests/unit/dashboard/model-chart.test.ts` - -**Interfaces:** -- Consumes: `DailyModelTokens` from `../../db/message/message-repo`, `esc`, `fmtCost` from `./formatters`, `MODEL_COLORS` (already exported from this file) -- Produces: `renderDailyModelCostChart(modelData: DailyModelTokens[]): string` - -- [ ] **Step 1: Write the failing tests** - -Add to `tests/unit/dashboard/model-chart.test.ts`: - -```ts -import { - MODEL_COLORS, - renderDailyModelChart, - renderDailyModelCostChart, -} from "../../../src/dashboard/templates/model-chart"; - -// ... existing tests ... - -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"); - }); -}); -``` - -Note: update the import at the top to include `renderDailyModelCostChart`. - -- [ ] **Step 2: Run tests to verify they fail** - -```bash -bun test tests/unit/dashboard/model-chart.test.ts -``` - -Expected: FAIL — `renderDailyModelCostChart is not a function` - -- [ ] **Step 3: Implement renderDailyModelCostChart** - -In `src/dashboard/templates/model-chart.ts`, update the import line (keep `fmt` for the existing `renderDailyModelChart`): - -```ts -import { esc, fmt, fmtCost } from "./formatters"; -``` - -Then add after `renderDailyModelChart`: - -```ts -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} -
-
`; -} -``` - -- [ ] **Step 4: Run tests to verify they pass** - -```bash -bun test tests/unit/dashboard/model-chart.test.ts -``` - -Expected: PASS (all 9 tests — 4 existing + 5 new) - -- [ ] **Step 5: Commit** - -```bash -git add src/dashboard/templates/model-chart.ts tests/unit/dashboard/model-chart.test.ts -git commit -m "feat: add renderDailyModelCostChart template" -``` - ---- - -### Task 6: Wire cost data through routes and templates - -**Files:** -- Modify: `src/dashboard/templates/sessions-fragment.ts` -- Modify: `src/dashboard/templates/page-template.ts` -- Modify: `src/dashboard/routes/stats-route.ts` -- Modify: `src/dashboard/routes/page-route.ts` -- Modify: `tests/unit/dashboard/sessions-fragment.test.ts` -- Modify: `tests/unit/dashboard/routes.test.ts` -- Modify: `tests/unit/dashboard/page-template.test.ts` - -**Interfaces:** -- Consumes: `renderDailyCostChart` from `./daily-chart`, `renderDailyModelCostChart` from `./model-chart`, `dailyTokens.getDailyCost()`, `dailyTokens.getDailyModelCost()` -- Produces: cost charts rendered in left panel of dashboard - -- [ ] **Step 1: Update stubs in routes.test.ts** - -In `tests/unit/dashboard/routes.test.ts`, update `makeStubDailyTokens`: - -```ts -function makeStubDailyTokens(): DailyTokensService { - return { - getDailyTokens: () => [], - getDailyTokensByModel: () => [], - getDailyCost: () => [], - getDailyModelCost: () => [], - getTokenSummary: () => ({ - today: 0, - thisWeek: 0, - thisMonth: 0, - lastMonth: 0, - }), - getCostSummary: () => ({ - today: 0, - thisWeek: 0, - thisMonth: 0, - lastMonth: 0, - }), - }; -} -``` - -- [ ] **Step 2: Run routes tests to verify they still pass after stub update** - -```bash -bun test tests/unit/dashboard/routes.test.ts -``` - -Expected: TypeScript compilation error until we update the interface in step 3, but once interface is updated all tests pass. - -- [ ] **Step 3: Update sessions-fragment.ts** - -In `src/dashboard/templates/sessions-fragment.ts`: - -Update imports: - -```ts -import { renderDailyCostChart } from "./daily-chart"; -import { renderDailyModelCostChart } from "./model-chart"; -``` - -Update function signature (add two optional params at the end): - -```ts -export function renderSessionsFragment( - sessions: SessionStats[], - summary: TokenSummary, - costSummary: CostSummary, - daily: DailyTokens[], - dailyModel: DailyModelTokens[], - toolGroups: ToolGroupSummary[], - directories: string[] = [], - selectedDir?: string, - dailyCost: DailyTokens[] = [], - dailyModelCost: DailyModelTokens[] = [], -): string { -``` - -Update the render calls and left panel: - -```ts -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 = ` -
- ${bar} -
- ${chart} - ${costChart} - ${modelChart} - ${modelCostChart} - ${toolUsage} -
`; -``` - -- [ ] **Step 4: Add tests for sessions-fragment cost charts** - -Add to `tests/unit/dashboard/sessions-fragment.test.ts`: - -```ts -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)"); -}); -``` - -- [ ] **Step 5: Run sessions-fragment tests** - -```bash -bun test tests/unit/dashboard/sessions-fragment.test.ts -``` - -Expected: PASS (all 7 tests) - -- [ ] **Step 6: Update page-template.ts** - -In `src/dashboard/templates/page-template.ts`, update `renderHTML` signature and the `renderSessionsFragment` call: - -```ts -export function renderHTML( - sessions: SessionStats[], - summary: TokenSummary, - costSummary: CostSummary, - daily: DailyTokens[], - dailyModel: DailyModelTokens[], - toolGroups: ToolGroupSummary[], - directories: string[] = [], - selectedDir?: string, - dailyCost: DailyTokens[] = [], - dailyModelCost: DailyModelTokens[] = [], -): string { - return ` -... -
- ${renderSessionsFragment(sessions, summary, costSummary, daily, dailyModel, toolGroups, directories, selectedDir, dailyCost, dailyModelCost)} -
-...`; -} -``` - -Keep all existing HTML structure intact — only the function signature and the `renderSessionsFragment` call arguments change. - -- [ ] **Step 7: Update stats-route.ts** - -In `src/dashboard/routes/stats-route.ts`, add two lines after `const dailyModel = ...`: - -```ts -const dailyCost = dailyTokens.getDailyCost(); -const dailyModelCost = dailyTokens.getDailyModelCost(); -``` - -Update the `renderSessionsFragment` call: - -```ts -const html = renderSessionsFragment( - sessions, - summary, - costSummary, - daily, - dailyModel, - toolGroups, - directories, - dirFilter, - dailyCost, - dailyModelCost, -); -``` - -- [ ] **Step 8: Update page-route.ts** - -In `src/dashboard/routes/page-route.ts`, add two lines after `const dailyModel = ...`: - -```ts -const dailyCost = dailyTokens.getDailyCost(); -const dailyModelCost = dailyTokens.getDailyModelCost(); -``` - -Update the `renderHTML` call: - -```ts -return new Response( - renderHTML( - sessions, - summary, - costSummary, - daily, - dailyModel, - toolGroups, - directories, - dirFilter, - dailyCost, - dailyModelCost, - ), - { - headers: { "Content-Type": "text/html; charset=utf-8" }, - }, -); -``` - -- [ ] **Step 9: Run full unit test suite** - -```bash -bun test tests/unit -``` - -Expected: All tests pass, no TypeScript errors. - -- [ ] **Step 10: Run typecheck** - -```bash -bun x tsc --noEmit -``` - -Expected: no errors - -- [ ] **Step 11: Commit** - -```bash -git add src/dashboard/templates/sessions-fragment.ts src/dashboard/templates/page-template.ts src/dashboard/routes/stats-route.ts src/dashboard/routes/page-route.ts tests/unit/dashboard/sessions-fragment.test.ts tests/unit/dashboard/routes.test.ts tests/unit/dashboard/page-template.test.ts -git commit -m "feat: wire daily cost charts into dashboard" -``` diff --git a/docs/superpowers/specs/2026-06-25-daily-cost-charts-design.md b/docs/superpowers/specs/2026-06-25-daily-cost-charts-design.md deleted file mode 100644 index 4da9c0b..0000000 --- a/docs/superpowers/specs/2026-06-25-daily-cost-charts-design.md +++ /dev/null @@ -1,97 +0,0 @@ -# Daily Cost Charts - -**Date:** 2026-06-25 -**Status:** Approved - -## Summary - -Add two cost charts to the dashboard left panel, mirroring the existing token charts: - -1. **Daily Cost** — bar chart with 5-day moving average, below "Daily Token Usage" -2. **Daily Cost by Model** — stacked bar chart, below "Daily Token Usage by Model" - -No schema changes. No new types. Reuses `DailyTokens` and `DailyModelTokens` shapes throughout. - -## Data Layer - -### `DailyUsageRepo` (`src/db/daily-usage/`) - -Add method to interface and implementation: - -```ts -getHistoryUntilCost(dayExclusive: string, lookbackDays: number): DailyTokens[] -``` - -SQL reads `cost_total` instead of `tokens_total` from the `daily_usage` table. Same WHERE clause as `getHistoryUntil`. - -### `MessageRepo` + `SqliteMessageRepo` (`src/db/message/`) - -Add two methods: - -```ts -getTodayCost(today: string): DailyTokens -// SELECT ? AS date, COALESCE(SUM(cost), 0) AS total FROM messages WHERE timestamp >= ? AND timestamp < date(?, '+1 day') - -getDailyModelCost(): DailyModelTokens[] -// Same query as getDailyTokensByModel() but SUM(cost) AS total instead of token sum -// Returns { date, model, total } where total is cost in dollars -``` - -### `DailyTokensService` (`src/dashboard/services/daily-tokens-service.ts`) - -Add two methods to interface and implementation: - -```ts -getDailyCost(): DailyTokens[] -// Combines: getHistoryUntilCost(today, 60) + getTodayCost(today) -// Same merge logic as getDailyTokens() - -getDailyModelCost(): DailyModelTokens[] -// Delegates to repos.messages.getDailyModelCost() -``` - -## Template Layer - -### `daily-chart.ts` (`src/dashboard/templates/`) - -Add `renderDailyCostChart(daily: DailyTokens[]): string` - -- Same structure as `renderDailyChart` -- Bar label: `fmtCost(d.total)` compact — show value if `d.total > 0` (e.g. `$0.05`, `$1.23`) -- Tooltip: `${tooltipDate}
${fmtCost(d.total)}` -- 5-day moving average polyline (same logic as token chart) -- Title: `"Daily Cost (last 60 days)"` -- Legend: `"Daily cost"` + `"5-day avg"` - -### `model-chart.ts` (`src/dashboard/templates/`) - -Add `renderDailyModelCostChart(modelData: DailyModelTokens[]): string` - -- Same structure as `renderDailyModelChart` -- Tooltip per model: `${fmtCost(val)}` instead of `${fmt(val)}` -- Stacked bars use same `MODEL_COLORS` -- Title: `"Daily Cost by Model (last 60 days)"` - -## Wiring - -### `sessions-fragment.ts` - -- Accept two new params: `dailyCost: DailyTokens[]`, `dailyModelCost: DailyModelTokens[]` -- Render `renderDailyCostChart(dailyCost)` directly after `renderDailyChart` -- Render `renderDailyModelCostChart(dailyModelCost)` directly after `renderDailyModelChart` - -### `stats-route.ts` - -- Call `dailyTokens.getDailyCost()` and `dailyTokens.getDailyModelCost()` -- Pass to `renderSessionsFragment` - -### `page-template.ts` - -- Add `dailyCost: DailyTokens[]` and `dailyModelCost: DailyModelTokens[]` to `renderHTML` signature -- Pass through to `renderSessionsFragment` - -## Out of Scope - -- No changes to `daily_usage` schema (cost already stored) -- No new DB migrations -- No changes to stats bar From e00b06696a1d1da747239001df724a918e39f929 Mon Sep 17 00:00:00 2001 From: "Ronny.Herrgesell" Date: Thu, 25 Jun 2026 16:13:34 +0200 Subject: [PATCH 13/13] fix: add default export to plugin for OpenCode auto-discovery OpenCode expects plugins in ~/.config/opencode/plugins/ to have a default export of shape { server: Plugin } (PluginModule type). Co-Authored-By: Claude Sonnet 4.6 --- src/plugin.ts | 2 ++ 1 file changed, 2 insertions(+) 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 };