From acee43b673580829b99be4cfe5835bcc922de020 Mon Sep 17 00:00:00 2001 From: "Ronny.Herrgesell" Date: Thu, 2 Jul 2026 16:58:47 +0200 Subject: [PATCH 1/7] feat: add budget_settings table and BudgetRepo Adds BudgetSettings/BudgetRepo interface, SqliteBudgetRepo implementation, migration v3 creating the budget_settings table, wires repo into createSqliteRepos, and updates all test stubs to satisfy the Repos interface. Co-Authored-By: Claude Sonnet 4.6 --- src/db/budget/budget-repo.ts | 10 ++++ src/db/budget/sqlite-budget-repo.ts | 38 +++++++++++++++ src/db/migrations.ts | 11 +++++ src/db/repos.ts | 2 + src/db/sqlite-repository.ts | 2 + .../dashboard/daily-tokens-service.test.ts | 4 ++ .../dashboard/maintenance-service.test.ts | 4 ++ tests/unit/dashboard/routes.test.ts | 4 ++ .../dashboard/session-stats-service.test.ts | 4 ++ tests/unit/handlers.test.ts | 4 ++ tests/unit/sqlite-budget-repo.test.ts | 47 +++++++++++++++++++ 11 files changed, 130 insertions(+) create mode 100644 src/db/budget/budget-repo.ts create mode 100644 src/db/budget/sqlite-budget-repo.ts create mode 100644 tests/unit/sqlite-budget-repo.test.ts diff --git a/src/db/budget/budget-repo.ts b/src/db/budget/budget-repo.ts new file mode 100644 index 0000000..08d33b4 --- /dev/null +++ b/src/db/budget/budget-repo.ts @@ -0,0 +1,10 @@ +export interface BudgetSettings { + amount: number; + workDays: number; + periodStartDay: number; +} + +export interface BudgetRepo { + get(): BudgetSettings | null; + upsert(settings: BudgetSettings): void; +} diff --git a/src/db/budget/sqlite-budget-repo.ts b/src/db/budget/sqlite-budget-repo.ts new file mode 100644 index 0000000..125a764 --- /dev/null +++ b/src/db/budget/sqlite-budget-repo.ts @@ -0,0 +1,38 @@ +import type { Database } from "bun:sqlite"; +import type { BudgetRepo, BudgetSettings } from "./budget-repo"; + +export class SqliteBudgetRepo implements BudgetRepo { + constructor(private readonly db: Database) {} + + get(): BudgetSettings | null { + const row = this.db + .prepare( + "SELECT amount, work_days, period_start_day FROM budget_settings WHERE id = 1", + ) + .get() as { + amount: number; + work_days: number; + period_start_day: number; + } | null; + if (!row) return null; + return { + amount: row.amount, + workDays: row.work_days, + periodStartDay: row.period_start_day, + }; + } + + upsert(settings: BudgetSettings): void { + this.db + .prepare(` + INSERT INTO budget_settings (id, amount, work_days, period_start_day, updated_at) + VALUES (1, ?, ?, ?, datetime('now')) + ON CONFLICT(id) DO UPDATE SET + amount = excluded.amount, + work_days = excluded.work_days, + period_start_day = excluded.period_start_day, + updated_at = excluded.updated_at + `) + .run(settings.amount, settings.workDays, settings.periodStartDay); + } +} diff --git a/src/db/migrations.ts b/src/db/migrations.ts index 7693fc4..5ede83f 100644 --- a/src/db/migrations.ts +++ b/src/db/migrations.ts @@ -97,6 +97,17 @@ export const MIGRATIONS: Migration[] = [ `CREATE INDEX IF NOT EXISTS idx_sessions_last_seen ON sessions(last_seen)`, ); }, + (db) => { + db.run(` + CREATE TABLE IF NOT EXISTS budget_settings ( + id INTEGER PRIMARY KEY DEFAULT 1, + amount REAL NOT NULL, + work_days INTEGER NOT NULL, + period_start_day INTEGER NOT NULL DEFAULT 1, + updated_at TEXT NOT NULL + ) + `); + }, ]; export function getSchemaVersion(): number { diff --git a/src/db/repos.ts b/src/db/repos.ts index 68b2c09..300d136 100644 --- a/src/db/repos.ts +++ b/src/db/repos.ts @@ -1,3 +1,4 @@ +import type { BudgetRepo } from "./budget/budget-repo"; import type { DailyUsageRepo } from "./daily-usage/daily-usage-repo"; import type { MessageRepo } from "./message/message-repo"; import type { SessionRepo } from "./session/session-repo"; @@ -8,6 +9,7 @@ export interface Repos { messages: MessageRepo; toolCalls: ToolCallRepo; dailyUsage: DailyUsageRepo; + budget: BudgetRepo; vacuum(): void; close(): void; } diff --git a/src/db/sqlite-repository.ts b/src/db/sqlite-repository.ts index e7cb732..807be9c 100644 --- a/src/db/sqlite-repository.ts +++ b/src/db/sqlite-repository.ts @@ -1,4 +1,5 @@ import { Database } from "bun:sqlite"; +import { SqliteBudgetRepo } from "./budget/sqlite-budget-repo"; import { SqliteDailyUsageRepo } from "./daily-usage/sqlite-daily-usage-repo"; import { SqliteMessageRepo } from "./message/sqlite-message-repo"; import { getSchemaVersion, migrate } from "./migrations"; @@ -47,6 +48,7 @@ export function createSqliteRepos( messages: new SqliteMessageRepo(db), toolCalls: new SqliteToolCallRepo(db), dailyUsage: new SqliteDailyUsageRepo(db), + budget: new SqliteBudgetRepo(db), vacuum(): void { db.run("VACUUM"); }, diff --git a/tests/unit/dashboard/daily-tokens-service.test.ts b/tests/unit/dashboard/daily-tokens-service.test.ts index cbbcd05..af972df 100644 --- a/tests/unit/dashboard/daily-tokens-service.test.ts +++ b/tests/unit/dashboard/daily-tokens-service.test.ts @@ -68,6 +68,10 @@ function makeStubRepos( getHistoryUntil: () => overrides.history ?? [], getHistoryUntilCost: () => overrides.historyCost ?? [], }, + budget: { + get: () => null, + upsert: () => {}, + }, vacuum: () => {}, close: () => {}, }; diff --git a/tests/unit/dashboard/maintenance-service.test.ts b/tests/unit/dashboard/maintenance-service.test.ts index 56d16e3..06f50e1 100644 --- a/tests/unit/dashboard/maintenance-service.test.ts +++ b/tests/unit/dashboard/maintenance-service.test.ts @@ -44,6 +44,10 @@ function makeStubRepos(): Repos { getHistoryUntil: () => [], getHistoryUntilCost: () => [], }, + budget: { + get: () => null, + upsert: () => {}, + }, vacuum: () => {}, close: () => {}, }; diff --git a/tests/unit/dashboard/routes.test.ts b/tests/unit/dashboard/routes.test.ts index d8dac7b..3e290d3 100644 --- a/tests/unit/dashboard/routes.test.ts +++ b/tests/unit/dashboard/routes.test.ts @@ -73,6 +73,10 @@ function makeStubRepos(): Repos { getHistoryUntil: () => [], getHistoryUntilCost: () => [], }, + budget: { + get: () => null, + upsert: () => {}, + }, vacuum: () => {}, close: () => {}, }; diff --git a/tests/unit/dashboard/session-stats-service.test.ts b/tests/unit/dashboard/session-stats-service.test.ts index 14d1fb2..7a78aa8 100644 --- a/tests/unit/dashboard/session-stats-service.test.ts +++ b/tests/unit/dashboard/session-stats-service.test.ts @@ -51,6 +51,10 @@ function makeStubRepos( getHistoryUntil: () => [], getHistoryUntilCost: () => [], }, + budget: { + get: () => null, + upsert: () => {}, + }, vacuum: () => {}, close: () => {}, }; diff --git a/tests/unit/handlers.test.ts b/tests/unit/handlers.test.ts index c626ba7..768b920 100644 --- a/tests/unit/handlers.test.ts +++ b/tests/unit/handlers.test.ts @@ -149,6 +149,10 @@ function createReposDouble(opts?: { getHistoryUntil: () => [], getHistoryUntilCost: () => [], }, + budget: { + get: () => null, + upsert: () => {}, + }, vacuum: () => {}, close: () => {}, }; diff --git a/tests/unit/sqlite-budget-repo.test.ts b/tests/unit/sqlite-budget-repo.test.ts new file mode 100644 index 0000000..1493e28 --- /dev/null +++ b/tests/unit/sqlite-budget-repo.test.ts @@ -0,0 +1,47 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { createSqliteRepos } from "../../src/db/sqlite-repository"; +import { cleanupTempDir, createTempDbPath } from "./helpers/temp-db"; + +describe("SqliteBudgetRepo", () => { + const cleanupDirs: string[] = []; + + afterEach(() => { + while (cleanupDirs.length > 0) { + const dir = cleanupDirs.pop(); + if (dir) cleanupTempDir(dir); + } + }); + + test("get returns null when no budget set", () => { + const { dir, dbPath } = createTempDbPath("budget-repo-test-"); + cleanupDirs.push(dir); + const repos = createSqliteRepos(dbPath); + expect(repos.budget.get()).toBeNull(); + repos.close(); + }); + + test("upsert then get returns settings", () => { + const { dir, dbPath } = createTempDbPath("budget-repo-test-"); + cleanupDirs.push(dir); + const repos = createSqliteRepos(dbPath); + repos.budget.upsert({ amount: 100, workDays: 62, periodStartDay: 1 }); + const result = repos.budget.get(); + expect(result?.amount).toBe(100); + expect(result?.workDays).toBe(62); + expect(result?.periodStartDay).toBe(1); + repos.close(); + }); + + test("upsert overwrites previous settings", () => { + const { dir, dbPath } = createTempDbPath("budget-repo-test-"); + cleanupDirs.push(dir); + const repos = createSqliteRepos(dbPath); + repos.budget.upsert({ amount: 100, workDays: 62, periodStartDay: 1 }); + repos.budget.upsert({ amount: 200, workDays: 31, periodStartDay: 15 }); + const result = repos.budget.get(); + expect(result?.amount).toBe(200); + expect(result?.workDays).toBe(31); + expect(result?.periodStartDay).toBe(15); + repos.close(); + }); +}); From 1ab04fc7ab4b3876494dca79da8a4fb0820adc4c Mon Sep 17 00:00:00 2001 From: "Ronny.Herrgesell" Date: Thu, 2 Jul 2026 17:02:26 +0200 Subject: [PATCH 2/7] feat: add calcBudgetStatus service Co-Authored-By: Claude Sonnet 4.6 --- src/dashboard/services/budget-service.ts | 60 +++++++++++ tests/unit/dashboard/budget-service.test.ts | 107 ++++++++++++++++++++ 2 files changed, 167 insertions(+) create mode 100644 src/dashboard/services/budget-service.ts create mode 100644 tests/unit/dashboard/budget-service.test.ts diff --git a/src/dashboard/services/budget-service.ts b/src/dashboard/services/budget-service.ts new file mode 100644 index 0000000..534ac3e --- /dev/null +++ b/src/dashboard/services/budget-service.ts @@ -0,0 +1,60 @@ +import type { BudgetSettings } from "../../db/budget/budget-repo"; + +export interface BudgetStatus { + amount: number; + spent: number; + expected: number; + delta: number; + remaining: number; + remainingPct: number; + resetDate: Date; + workDaysTotal: number; + workDaysElapsed: number; +} + +export function calcBudgetStatus( + settings: BudgetSettings, + spentThisMonth: number, + now: Date, +): BudgetStatus { + const year = now.getFullYear(); + const month = now.getMonth(); + const lastDay = new Date(year, month + 1, 0).getDate(); + const startDay = Math.min(settings.periodStartDay, lastDay); + const todayDate = now.getDate(); + + let workDaysTotal = 0; + for (let d = startDay; d <= lastDay; d++) { + if ((settings.workDays >> new Date(year, month, d).getDay()) & 1) + workDaysTotal++; + } + + let workDaysElapsed = 0; + const elapsedEnd = Math.min(todayDate, lastDay + 1); + for (let d = startDay; d < elapsedEnd; d++) { + if ((settings.workDays >> new Date(year, month, d).getDay()) & 1) + workDaysElapsed++; + } + + const expected = + workDaysTotal > 0 ? settings.amount * (workDaysElapsed / workDaysTotal) : 0; + const delta = spentThisMonth - expected; + const remaining = settings.amount - spentThisMonth; + const remainingPct = + settings.amount > 0 + ? Math.max(0, Math.min(100, (remaining / settings.amount) * 100)) + : 0; + const resetDate = new Date(year, month + 1, 1); + + return { + amount: settings.amount, + spent: spentThisMonth, + expected, + delta, + remaining, + remainingPct, + resetDate, + workDaysTotal, + workDaysElapsed, + }; +} diff --git a/tests/unit/dashboard/budget-service.test.ts b/tests/unit/dashboard/budget-service.test.ts new file mode 100644 index 0000000..6d58935 --- /dev/null +++ b/tests/unit/dashboard/budget-service.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, test } from "bun:test"; +import { calcBudgetStatus } from "../../../src/dashboard/services/budget-service"; + +// July 2026: 31 days. July 1 = Wednesday. +// Mon-Fri work days in July: 1,2,3,6,7,8,9,10,13,14,15,16,17,20,21,22,23,24,27,28,29,30,31 = 23 days +const MON_FRI = 62; // 0b0111110 + +describe("calcBudgetStatus", () => { + test("no elapsed work days at period start (day 1)", () => { + // July 1 = day 0 elapsed (today is the first day, elapsed = days before today) + const status = calcBudgetStatus( + { amount: 100, workDays: MON_FRI, periodStartDay: 1 }, + 0, + new Date(2026, 6, 1), // July 1, 2026 + ); + expect(status.workDaysElapsed).toBe(0); + expect(status.expected).toBe(0); + expect(status.delta).toBe(0); + expect(status.workDaysTotal).toBe(23); + }); + + test("prorates correctly mid-month", () => { + // July 3 (Friday): elapsed = Jul 1 (Wed) + Jul 2 (Thu) = 2 work days + const status = calcBudgetStatus( + { amount: 100, workDays: MON_FRI, periodStartDay: 1 }, + 6, + new Date(2026, 6, 3), // July 3 + ); + expect(status.workDaysElapsed).toBe(2); + expect(status.workDaysTotal).toBe(23); + expect(status.expected).toBeCloseTo(100 * (2 / 23), 5); + // spent 6, expected ~8.70 → ahead (delta negative) + expect(status.delta).toBeCloseTo(6 - 100 * (2 / 23), 5); + }); + + test("delta positive when over budget pace", () => { + // July 3: expected ~8.70, spent 20 → delta > 0 + const status = calcBudgetStatus( + { amount: 100, workDays: MON_FRI, periodStartDay: 1 }, + 20, + new Date(2026, 6, 3), + ); + expect(status.delta).toBeGreaterThan(0); + }); + + test("remaining clamps to 0 pct when overspent", () => { + const status = calcBudgetStatus( + { amount: 100, workDays: MON_FRI, periodStartDay: 1 }, + 150, + new Date(2026, 6, 15), + ); + expect(status.remaining).toBe(-50); + expect(status.remainingPct).toBe(0); + }); + + test("remainingPct is 100 when nothing spent", () => { + const status = calcBudgetStatus( + { amount: 100, workDays: MON_FRI, periodStartDay: 1 }, + 0, + new Date(2026, 6, 1), + ); + expect(status.remainingPct).toBe(100); + }); + + test("resetDate is first day of next month", () => { + const status = calcBudgetStatus( + { amount: 100, workDays: MON_FRI, periodStartDay: 1 }, + 50, + new Date(2026, 6, 15), + ); + expect(status.resetDate.getFullYear()).toBe(2026); + expect(status.resetDate.getMonth()).toBe(7); // August = index 7 + expect(status.resetDate.getDate()).toBe(1); + }); + + test("weekend-only work days", () => { + // bit0=Sun=1, bit6=Sat=64 → 65 + // July 6 (Monday): elapsed days before today = Jul 1-5 + // Jul 4=Sat✓, Jul 5=Sun✓ → 2 elapsed weekend days + const status = calcBudgetStatus( + { amount: 100, workDays: 65, periodStartDay: 1 }, + 10, + new Date(2026, 6, 6), + ); + expect(status.workDaysElapsed).toBe(2); + }); + + test("workDaysTotal is 0 when all days disabled", () => { + const status = calcBudgetStatus( + { amount: 100, workDays: 0, periodStartDay: 1 }, + 0, + new Date(2026, 6, 15), + ); + expect(status.workDaysTotal).toBe(0); + expect(status.expected).toBe(0); + }); + + test("periodStartDay after today counts 0 elapsed", () => { + // Period starts July 20, today is July 15 + const status = calcBudgetStatus( + { amount: 100, workDays: MON_FRI, periodStartDay: 20 }, + 0, + new Date(2026, 6, 15), + ); + expect(status.workDaysElapsed).toBe(0); + }); +}); From e21b2eb6192de5128fb966c4713f8c6fd289dc64 Mon Sep 17 00:00:00 2001 From: "Ronny.Herrgesell" Date: Thu, 2 Jul 2026 17:07:37 +0200 Subject: [PATCH 3/7] feat: add budget status bar to dashboard Renders a Budget$ row in the stats bar showing pace badge (ahead/over/on-track), remaining amount+%, and reset date. Wired into both page and stats routes. Co-Authored-By: Claude Sonnet 4.6 --- src/dashboard/routes/page-route.ts | 6 ++ src/dashboard/routes/stats-route.ts | 6 ++ src/dashboard/templates/page-template.ts | 4 +- src/dashboard/templates/sessions-fragment.ts | 6 +- src/dashboard/templates/stats-bar.ts | 36 +++++++++ src/dashboard/templates/styles.ts | 77 +++++++++++++++++++- tests/unit/dashboard/stats-bar.test.ts | 65 ++++++++++++++++- 7 files changed, 196 insertions(+), 4 deletions(-) diff --git a/src/dashboard/routes/page-route.ts b/src/dashboard/routes/page-route.ts index 6daa4ad..a332af1 100644 --- a/src/dashboard/routes/page-route.ts +++ b/src/dashboard/routes/page-route.ts @@ -1,4 +1,5 @@ import type { Repos } from "../../db/repos"; +import { calcBudgetStatus } from "../services/budget-service"; import type { DailyTokensService } from "../services/daily-tokens-service"; import type { SessionStatsService } from "../services/session-stats-service"; import { renderHTML } from "../templates/page-template"; @@ -26,6 +27,10 @@ export function createPageRoute( const dailyCost = dailyTokens.getDailyCost(); const dailyModelCost = dailyTokens.getDailyModelCost(); const toolGroups = repos.toolCalls.getToolUsageSummary(); + const budgetSettings = repos.budget.get(); + const budgetStatus = budgetSettings + ? calcBudgetStatus(budgetSettings, costSummary.thisMonth, new Date()) + : null; return new Response( renderHTML( sessions, @@ -38,6 +43,7 @@ export function createPageRoute( dirFilter, dailyCost, dailyModelCost, + budgetStatus, ), { 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 88cd76e..a35cecb 100644 --- a/src/dashboard/routes/stats-route.ts +++ b/src/dashboard/routes/stats-route.ts @@ -1,4 +1,5 @@ import type { Repos } from "../../db/repos"; +import { calcBudgetStatus } from "../services/budget-service"; import type { DailyTokensService } from "../services/daily-tokens-service"; import type { SessionStatsService } from "../services/session-stats-service"; import { renderSessionsFragment } from "../templates/sessions-fragment"; @@ -49,6 +50,10 @@ export function createStatsRoute( const dailyCost = dailyTokens.getDailyCost(); const dailyModelCost = dailyTokens.getDailyModelCost(); const toolGroups = repos.toolCalls.getToolUsageSummary(); + const budgetSettings = repos.budget.get(); + const budgetStatus = budgetSettings + ? calcBudgetStatus(budgetSettings, costSummary.thisMonth, new Date()) + : null; const html = renderSessionsFragment( sessions, summary, @@ -60,6 +65,7 @@ export function createStatsRoute( dirFilter, dailyCost, dailyModelCost, + budgetStatus, ); 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 d1f2d9b..502c344 100644 --- a/src/dashboard/templates/page-template.ts +++ b/src/dashboard/templates/page-template.ts @@ -5,6 +5,7 @@ import type { } from "../../db/message/message-repo"; import type { DailyTokens } from "../../db/shared-types"; import type { ToolGroupSummary } from "../../db/tool-call/tool-call-repo"; +import type { BudgetStatus } from "../services/budget-service"; import type { SessionStats } from "../services/types"; import { renderSessionsFragment } from "./sessions-fragment"; import { DASHBOARD_CSS } from "./styles"; @@ -92,6 +93,7 @@ export function renderHTML( selectedDir?: string, dailyCost: DailyTokens[] = [], dailyModelCost: DailyModelTokens[] = [], + budgetStatus: BudgetStatus | null = null, ): string { return ` @@ -111,7 +113,7 @@ export function renderHTML(
- ${renderSessionsFragment(sessions, summary, costSummary, daily, dailyModel, toolGroups, directories, selectedDir, dailyCost, dailyModelCost)} + ${renderSessionsFragment(sessions, summary, costSummary, daily, dailyModel, toolGroups, directories, selectedDir, dailyCost, dailyModelCost, budgetStatus)}
diff --git a/src/dashboard/templates/sessions-fragment.ts b/src/dashboard/templates/sessions-fragment.ts index 94e0eec..734a798 100644 --- a/src/dashboard/templates/sessions-fragment.ts +++ b/src/dashboard/templates/sessions-fragment.ts @@ -5,6 +5,7 @@ import type { } from "../../db/message/message-repo"; import type { DailyTokens } from "../../db/shared-types"; import type { ToolGroupSummary } from "../../db/tool-call/tool-call-repo"; +import type { BudgetStatus } from "../services/budget-service"; import type { SessionStats } from "../services/types"; import { renderDailyChart, renderDailyCostChart } from "./daily-chart"; import { esc } from "./formatters"; @@ -13,7 +14,7 @@ import { renderDailyModelCostChart, } from "./model-chart"; import { renderSessionCard } from "./session-card"; -import { renderStatsBar } from "./stats-bar"; +import { renderBudgetBar, renderStatsBar } from "./stats-bar"; import { renderToolUsage } from "./tool-usage"; export function renderSessionsFragment( @@ -27,8 +28,10 @@ export function renderSessionsFragment( selectedDir?: string, dailyCost: DailyTokens[] = [], dailyModelCost: DailyModelTokens[] = [], + budgetStatus: BudgetStatus | null = null, ): string { const bar = renderStatsBar(summary, costSummary); + const budgetBar = renderBudgetBar(budgetStatus); const chart = renderDailyChart(daily); const costChart = renderDailyCostChart(dailyCost); const modelChart = renderDailyModelChart(dailyModel); @@ -38,6 +41,7 @@ export function renderSessionsFragment( const leftPanel = `
${bar} + ${budgetBar}
${chart} ${costChart} diff --git a/src/dashboard/templates/stats-bar.ts b/src/dashboard/templates/stats-bar.ts index 17893d6..5111631 100644 --- a/src/dashboard/templates/stats-bar.ts +++ b/src/dashboard/templates/stats-bar.ts @@ -1,4 +1,5 @@ import type { CostSummary, TokenSummary } from "../../db/message/message-repo"; +import type { BudgetStatus } from "../services/budget-service"; import { fmtCompact, fmtCost } from "./formatters"; export function renderStatsBar( @@ -21,3 +22,38 @@ export function renderStatsBar( Last Month:${fmtCost(costSummary.lastMonth)}
`; } + +export function renderBudgetBar(status: BudgetStatus | null): string { + if (!status) return ""; + + const { delta, expected, remaining, remainingPct, resetDate } = status; + const threshold = expected * 0.02; + const isOnTrack = Math.abs(delta) <= threshold || expected === 0; + const isOver = !isOnTrack && delta > 0; + + const badgeClass = isOnTrack + ? "budget-badge--on-track" + : isOver + ? "budget-badge--over" + : "budget-badge--ahead"; + const badgeText = isOnTrack + ? "● on track" + : isOver + ? `▼ ${fmtCost(Math.abs(delta))} over` + : `▲ ${fmtCost(Math.abs(delta))} ahead`; + + const remainingText = + remaining < 0 ? `-${fmtCost(Math.abs(remaining))}` : fmtCost(remaining); + const resetText = resetDate.toLocaleDateString("en-US", { + month: "short", + day: "numeric", + }); + + return ` +
+ Budget$ + ${badgeText} + ${remainingText} left · ${Math.round(remainingPct)}% + Resets ${resetText} +
`; +} diff --git a/src/dashboard/templates/styles.ts b/src/dashboard/templates/styles.ts index abbcd45..29e77b6 100644 --- a/src/dashboard/templates/styles.ts +++ b/src/dashboard/templates/styles.ts @@ -331,4 +331,79 @@ export const DASHBOARD_CSS = ` @media (max-width: 1000px) { .two-col { flex-direction: column; } .left-panel { position: static; } - }`; + } + .mode-budget { color: #a5d6a7; border-color: #388e3c; } + .budget-badge { + display: inline-block; + border: 1px solid; + border-radius: 4px; + padding: 1px 8px; + font-size: 12px; + white-space: nowrap; + } + .budget-badge--ahead { color: #3fb950; border-color: #238636; } + .budget-badge--over { color: #f0883e; border-color: #d47616; } + .budget-badge--on-track { color: #8b949e; border-color: #30363d; } + dialog#budget-modal { + background: #161b22; + border: 1px solid #30363d; + border-radius: 10px; + padding: 24px; + color: #c9d1d9; + font-family: "SF Mono", "Fira Code", "JetBrains Mono", monospace; + min-width: 360px; + max-width: 480px; + } + dialog#budget-modal::backdrop { + background: rgba(0, 0, 0, 0.6); + } + .modal-title { + font-size: 15px; font-weight: 600; color: #f0f6fc; + margin-bottom: 20px; + } + .modal-field { margin-bottom: 16px; } + .modal-label { + display: block; font-size: 12px; color: #8b949e; + text-transform: uppercase; letter-spacing: 0.5px; + margin-bottom: 6px; + } + .modal-input { + background: #0d1117; border: 1px solid #30363d; + border-radius: 6px; padding: 8px 12px; + color: #f0f6fc; font-family: inherit; font-size: 14px; + width: 100%; + } + .modal-input:focus { outline: none; border-color: #58a6ff; } + .day-toggles { display: flex; gap: 6px; } + .day-toggle { + flex: 1; padding: 6px 0; border-radius: 6px; + border: 1px solid #30363d; background: #0d1117; + color: #484f58; font-family: inherit; font-size: 12px; + cursor: pointer; text-align: center; transition: all 0.15s; + } + .day-toggle.active { + border-color: #1f6feb; background: #1a2d4d; color: #f0f6fc; + } + .modal-hint { font-size: 11px; color: #484f58; margin-top: 6px; } + .modal-actions { + display: flex; justify-content: flex-end; gap: 8px; margin-top: 20px; + } + .btn-cancel { + background: transparent; border: 1px solid #30363d; + border-radius: 6px; padding: 7px 16px; + color: #8b949e; font-family: inherit; font-size: 13px; cursor: pointer; + } + .btn-cancel:hover { border-color: #484f58; color: #c9d1d9; } + .btn-save { + background: #238636; border: 1px solid #2ea043; + border-radius: 6px; padding: 7px 16px; + color: #f0f6fc; font-family: inherit; font-size: 13px; cursor: pointer; + } + .btn-save:hover { background: #2ea043; } + .gear-btn { + background: transparent; border: 1px solid #30363d; + border-radius: 6px; padding: 3px 8px; + color: #8b949e; font-size: 13px; cursor: pointer; + line-height: 1; transition: all 0.15s; + } + .gear-btn:hover { border-color: #484f58; color: #c9d1d9; }`; diff --git a/tests/unit/dashboard/stats-bar.test.ts b/tests/unit/dashboard/stats-bar.test.ts index 296b513..4f514b5 100644 --- a/tests/unit/dashboard/stats-bar.test.ts +++ b/tests/unit/dashboard/stats-bar.test.ts @@ -1,5 +1,68 @@ import { describe, expect, test } from "bun:test"; -import { renderStatsBar } from "../../../src/dashboard/templates/stats-bar"; +import type { BudgetStatus } from "../../../src/dashboard/services/budget-service"; +import { + renderBudgetBar, + renderStatsBar, +} from "../../../src/dashboard/templates/stats-bar"; + +function makeStatus(overrides: Partial = {}): BudgetStatus { + return { + amount: 100, + spent: 50, + expected: 60, + delta: -10, // 10 ahead + remaining: 50, + remainingPct: 50, + resetDate: new Date(2026, 7, 1), // Aug 1 + workDaysTotal: 23, + workDaysElapsed: 14, + ...overrides, + }; +} + +describe("renderBudgetBar", () => { + test("returns empty string when status is null", () => { + expect(renderBudgetBar(null)).toBe(""); + }); + + test("shows ahead badge when delta negative", () => { + const html = renderBudgetBar(makeStatus({ delta: -10, expected: 60 })); + expect(html).toContain("budget-badge--ahead"); + expect(html).toContain("▲"); + expect(html).toContain("ahead"); + }); + + test("shows over badge when delta positive beyond threshold", () => { + const html = renderBudgetBar(makeStatus({ delta: 20, expected: 60 })); + expect(html).toContain("budget-badge--over"); + expect(html).toContain("▼"); + expect(html).toContain("over"); + }); + + test("shows on-track badge when delta within 2% of expected", () => { + const html = renderBudgetBar(makeStatus({ delta: 0.5, expected: 60 })); + expect(html).toContain("budget-badge--on-track"); + expect(html).toContain("on track"); + }); + + test("shows remaining and reset date", () => { + const html = renderBudgetBar( + makeStatus({ + remaining: 50, + remainingPct: 50, + resetDate: new Date(2026, 7, 1), + }), + ); + expect(html).toContain("50%"); + expect(html).toContain("Aug"); + expect(html).toContain("1"); + }); + + test("shows Budget$ label", () => { + const html = renderBudgetBar(makeStatus()); + expect(html).toContain("Budget$"); + }); +}); const zeroCost = { today: 0, thisWeek: 0, thisMonth: 0, lastMonth: 0 }; From 064585b4052ff330fab994b34db1d94106167cb9 Mon Sep 17 00:00:00 2001 From: "Ronny.Herrgesell" Date: Thu, 2 Jul 2026 17:13:21 +0200 Subject: [PATCH 4/7] feat: add GET/POST /api/budget route - Allow async handle() in RouteHandler interface (backward-compatible) - Implement createBudgetRoute: GET 404/200, POST 200/400 with validation - Wire budget route in dashboard/index.ts before stats route - Update routes.test.ts to await handle() calls for TS compatibility Co-Authored-By: Claude Sonnet 4.6 --- src/dashboard/index.ts | 2 + src/dashboard/routes/budget-route.ts | 57 ++++++++++++++ src/dashboard/routes/route-handler.ts | 2 +- tests/unit/dashboard/budget-route.test.ts | 90 +++++++++++++++++++++++ tests/unit/dashboard/routes.test.ts | 24 +++--- 5 files changed, 162 insertions(+), 13 deletions(-) create mode 100644 src/dashboard/routes/budget-route.ts create mode 100644 tests/unit/dashboard/budget-route.test.ts diff --git a/src/dashboard/index.ts b/src/dashboard/index.ts index da1068e..ae49ac5 100644 --- a/src/dashboard/index.ts +++ b/src/dashboard/index.ts @@ -1,6 +1,7 @@ import { join } from "node:path"; import type { Repos } from "../db/repos"; import { createSqliteRepos, gcOldData } from "../db/sqlite-repository"; +import { createBudgetRoute } from "./routes/budget-route"; import { createDirectoriesRoute } from "./routes/directories-route"; import { createPageRoute } from "./routes/page-route"; import { createStatsRoute } from "./routes/stats-route"; @@ -35,6 +36,7 @@ export function createDashboard(deps: DashboardDeps) { const dailyTokens = createDailyTokensService(readRepos); const routes = [ + createBudgetRoute(readRepos, () => deps.createWriteRepos(dbPath)), createStatsRoute(sessionStats, dailyTokens, readRepos), createDirectoriesRoute(sessionStats), createPageRoute(sessionStats, dailyTokens, readRepos), diff --git a/src/dashboard/routes/budget-route.ts b/src/dashboard/routes/budget-route.ts new file mode 100644 index 0000000..1fef66b --- /dev/null +++ b/src/dashboard/routes/budget-route.ts @@ -0,0 +1,57 @@ +import type { BudgetSettings } from "../../db/budget/budget-repo"; +import type { Repos } from "../../db/repos"; +import type { RouteHandler } from "./route-handler"; + +export function createBudgetRoute( + readRepos: Repos, + createWriteRepos: () => Repos, +): RouteHandler { + return { + match(url: URL): boolean { + return url.pathname === "/api/budget"; + }, + + handle(req: Request, _url: URL): Response | Promise { + if (req.method === "GET") { + const settings = readRepos.budget.get(); + if (!settings) return new Response(null, { status: 404 }); + return new Response(JSON.stringify(settings), { + headers: { "Content-Type": "application/json" }, + }); + } + + if (req.method === "POST") { + return req.json().then( + (body: unknown) => { + if ( + typeof body !== "object" || + body === null || + typeof (body as Record).amount !== "number" || + typeof (body as Record).workDays !== "number" || + typeof (body as Record).periodStartDay !== + "number" + ) { + return new Response("Invalid body", { status: 400 }); + } + const settings: BudgetSettings = { + amount: (body as Record).amount as number, + workDays: (body as Record).workDays as number, + periodStartDay: (body as Record) + .periodStartDay as number, + }; + const writeRepos = createWriteRepos(); + try { + writeRepos.budget.upsert(settings); + } finally { + writeRepos.close(); + } + return new Response(null, { status: 200 }); + }, + () => new Response("Invalid JSON", { status: 400 }), + ); + } + + return new Response("Method Not Allowed", { status: 405 }); + }, + }; +} diff --git a/src/dashboard/routes/route-handler.ts b/src/dashboard/routes/route-handler.ts index f40a93b..f4cb5ae 100644 --- a/src/dashboard/routes/route-handler.ts +++ b/src/dashboard/routes/route-handler.ts @@ -1,4 +1,4 @@ export interface RouteHandler { match(url: URL): boolean; - handle(req: Request, url: URL): Response; + handle(req: Request, url: URL): Response | Promise; } diff --git a/tests/unit/dashboard/budget-route.test.ts b/tests/unit/dashboard/budget-route.test.ts new file mode 100644 index 0000000..41ed6d0 --- /dev/null +++ b/tests/unit/dashboard/budget-route.test.ts @@ -0,0 +1,90 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { createBudgetRoute } from "../../../src/dashboard/routes/budget-route"; +import { createSqliteRepos } from "../../../src/db/sqlite-repository"; +import { cleanupTempDir, createTempDbPath } from "../helpers/temp-db"; + +describe("BudgetRoute", () => { + const cleanupDirs: string[] = []; + + afterEach(() => { + while (cleanupDirs.length > 0) { + const dir = cleanupDirs.pop(); + if (dir) cleanupTempDir(dir); + } + }); + + test("match returns true only for /api/budget", () => { + const { dir, dbPath } = createTempDbPath("budget-route-test-"); + cleanupDirs.push(dir); + const repos = createSqliteRepos(dbPath); + const route = createBudgetRoute(repos, () => createSqliteRepos(dbPath)); + expect(route.match(new URL("http://localhost/api/budget"))).toBe(true); + expect(route.match(new URL("http://localhost/api/stats"))).toBe(false); + repos.close(); + }); + + test("GET returns 404 when no budget set", async () => { + const { dir, dbPath } = createTempDbPath("budget-route-test-"); + cleanupDirs.push(dir); + const repos = createSqliteRepos(dbPath); + const route = createBudgetRoute(repos, () => createSqliteRepos(dbPath)); + const res = await route.handle( + new Request("http://localhost/api/budget"), + new URL("http://localhost/api/budget"), + ); + expect(res.status).toBe(404); + repos.close(); + }); + + test("GET returns 200 with JSON after budget set", async () => { + const { dir, dbPath } = createTempDbPath("budget-route-test-"); + cleanupDirs.push(dir); + const repos = createSqliteRepos(dbPath); + repos.budget.upsert({ amount: 100, workDays: 62, periodStartDay: 1 }); + const route = createBudgetRoute(repos, () => createSqliteRepos(dbPath)); + const res = await route.handle( + new Request("http://localhost/api/budget"), + new URL("http://localhost/api/budget"), + ); + expect(res.status).toBe(200); + const body = (await res.json()) as Record; + expect(body.amount).toBe(100); + expect(body.workDays).toBe(62); + repos.close(); + }); + + test("POST saves budget and returns 200", async () => { + const { dir, dbPath } = createTempDbPath("budget-route-test-"); + cleanupDirs.push(dir); + const repos = createSqliteRepos(dbPath); + const route = createBudgetRoute(repos, () => createSqliteRepos(dbPath)); + const res = await route.handle( + new Request("http://localhost/api/budget", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ amount: 150, workDays: 62, periodStartDay: 1 }), + }), + new URL("http://localhost/api/budget"), + ); + expect(res.status).toBe(200); + expect(repos.budget.get()?.amount).toBe(150); + repos.close(); + }); + + test("POST returns 400 on invalid body", async () => { + const { dir, dbPath } = createTempDbPath("budget-route-test-"); + cleanupDirs.push(dir); + const repos = createSqliteRepos(dbPath); + const route = createBudgetRoute(repos, () => createSqliteRepos(dbPath)); + const res = await route.handle( + new Request("http://localhost/api/budget", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ amount: "not-a-number" }), + }), + new URL("http://localhost/api/budget"), + ); + expect(res.status).toBe(400); + repos.close(); + }); +}); diff --git a/tests/unit/dashboard/routes.test.ts b/tests/unit/dashboard/routes.test.ts index 3e290d3..126fe8b 100644 --- a/tests/unit/dashboard/routes.test.ts +++ b/tests/unit/dashboard/routes.test.ts @@ -93,17 +93,17 @@ describe("StatsRoute", () => { expect(route.match(new URL("http://localhost/"))).toBe(false); }); - test("returns HTML content-type", () => { + test("returns HTML content-type", async () => { const route = createStatsRoute( makeStubSessionStats(), makeStubDailyTokens(), makeStubRepos(), ); const req = new Request("http://localhost/api/stats"); - const res = route.handle(req, new URL(req.url)); + const res = await route.handle(req, new URL(req.url)); expect(res.headers.get("Content-Type")).toContain("text/html"); }); - test("returns error HTML on DB failure", () => { + test("returns error HTML on DB failure", async () => { const sessionStats: SessionStatsService = { getSessionStats: () => { throw new Error("DB error"); @@ -118,7 +118,7 @@ describe("StatsRoute", () => { makeStubRepos(), ); const req = new Request("http://localhost/api/stats"); - const res = route.handle(req, new URL(req.url)); + const res = await route.handle(req, new URL(req.url)); expect(res.status).toBe(200); }); @@ -138,11 +138,11 @@ describe("StatsRoute", () => { ); const url = new URL("http://localhost/api/stats"); const req1 = new Request(url.toString()); - const res1 = route.handle(req1, url); + const res1 = await route.handle(req1, url); const body1 = await res1.text(); const req2 = new Request(url.toString()); - const res2 = route.handle(req2, url); + const res2 = await route.handle(req2, url); const body2 = await res2.text(); expect(callCount).toBe(1); @@ -218,12 +218,12 @@ describe("PageRoute", () => { makeStubRepos(), ); const req = new Request("http://localhost/"); - const res = route.handle(req, new URL(req.url)); + const res = await route.handle(req, new URL(req.url)); const body = await res.text(); expect(body).toMatch(/^/); }); - test("returns 500 on DB failure", () => { + test("returns 500 on DB failure", async () => { const sessionStats: SessionStatsService = { getSessionStats: () => { throw new Error("DB error"); @@ -238,7 +238,7 @@ describe("PageRoute", () => { makeStubRepos(), ); const req = new Request("http://localhost/"); - const res = route.handle(req, new URL(req.url)); + const res = await route.handle(req, new URL(req.url)); expect(res.status).toBe(500); }); @@ -257,7 +257,7 @@ describe("PageRoute", () => { makeStubRepos(), ); const req = new Request("http://localhost/?dir=/proj/a"); - const res = route.handle(req, new URL(req.url)); + const res = await route.handle(req, new URL(req.url)); const body = await res.text(); expect(receivedDir).toBe("/proj/a"); expect(body).toContain("selected"); @@ -278,7 +278,7 @@ describe("DirectoriesRoute", () => { }; const route = createDirectoriesRoute(sessionStats); const req = new Request("http://localhost/api/directories"); - const res = route.handle(req, new URL(req.url)); + const res = await route.handle(req, new URL(req.url)); expect(res.headers.get("Content-Type")).toContain("application/json"); const body = await res.json(); expect(body).toEqual(["/proj/a", "/proj/b"]); @@ -293,7 +293,7 @@ describe("DirectoriesRoute", () => { }; const route = createDirectoriesRoute(sessionStats); const req = new Request("http://localhost/api/directories"); - const res = route.handle(req, new URL(req.url)); + const res = await route.handle(req, new URL(req.url)); const body = await res.json(); expect(body).toEqual([]); }); From cace40b3724ac607577e7dcb729bbbfdf0aff685 Mon Sep 17 00:00:00 2001 From: "Ronny.Herrgesell" Date: Thu, 2 Jul 2026 17:20:33 +0200 Subject: [PATCH 5/7] feat: add budget settings modal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gear button in header opens a dialog with amount, work-day toggles (Mo–So, German labels), and period-start-day input. openBudgetModal() loads existing budget via GET /api/budget; saveBudget() POSTs and calls refresh(). Tests assert gear-btn, budget-modal, and day-toggle elements are present in rendered HTML. Co-Authored-By: Claude Sonnet 4.6 --- src/dashboard/templates/page-template.ts | 92 +++++++++++++++++++++- tests/unit/dashboard/page-template.test.ts | 18 +++++ 2 files changed, 109 insertions(+), 1 deletion(-) diff --git a/src/dashboard/templates/page-template.ts b/src/dashboard/templates/page-template.ts index 502c344..3402fe8 100644 --- a/src/dashboard/templates/page-template.ts +++ b/src/dashboard/templates/page-template.ts @@ -80,7 +80,65 @@ export const CLIENT_SCRIPT = ` } } setInterval(refresh, 5000); - attachDirFilter();`; + attachDirFilter(); + + async function openBudgetModal() { + const modal = document.getElementById('budget-modal'); + const toggles = modal.querySelectorAll('.day-toggle'); + + // Set defaults + let workDays = 62; // Mon-Fri + let periodStartDay = 1; + let amount = ''; + + try { + const res = await fetch('/api/budget'); + if (res.ok) { + const data = await res.json(); + workDays = data.workDays; + periodStartDay = data.periodStartDay; + amount = data.amount; + } + } catch {} + + document.getElementById('budget-amount').value = amount; + document.getElementById('budget-start-day').value = periodStartDay; + + toggles.forEach(btn => { + const bit = parseInt(btn.getAttribute('data-bit'), 10); + btn.classList.toggle('active', !!((workDays >> bit) & 1)); + btn.onclick = () => btn.classList.toggle('active'); + }); + + modal.showModal(); + } + + async function saveBudget() { + const modal = document.getElementById('budget-modal'); + const amount = parseFloat(document.getElementById('budget-amount').value); + const periodStartDay = parseInt(document.getElementById('budget-start-day').value, 10); + + if (isNaN(amount) || amount < 0) { + document.getElementById('budget-amount').focus(); + return; + } + + let workDays = 0; + modal.querySelectorAll('.day-toggle.active').forEach(btn => { + const bit = parseInt(btn.getAttribute('data-bit'), 10); + workDays |= (1 << bit); + }); + + try { + await fetch('/api/budget', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ amount, workDays, periodStartDay }), + }); + modal.close(); + refresh(); + } catch {} + }`; export function renderHTML( sessions: SessionStats[], @@ -104,12 +162,44 @@ export function renderHTML( + + + + + + +

OpenCode Usage Stats

auto-refresh 5s +
diff --git a/tests/unit/dashboard/page-template.test.ts b/tests/unit/dashboard/page-template.test.ts index 27aa27f..02f9ba7 100644 --- a/tests/unit/dashboard/page-template.test.ts +++ b/tests/unit/dashboard/page-template.test.ts @@ -34,4 +34,22 @@ describe("renderHTML", () => { expect(html).toContain("auto-refresh 5s"); expect(html).toContain("refresh-dot"); }); + + test("includes gear button in header", () => { + const html = renderHTML([], summary, costSummary, [], [], []); + expect(html).toContain("gear-btn"); + expect(html).toContain("budget-modal"); + }); + + test("includes day toggle buttons with German labels", () => { + const html = renderHTML([], summary, costSummary, [], [], []); + expect(html).toContain("day-toggle"); + expect(html).toContain(">Mo<"); + expect(html).toContain(">Di<"); + expect(html).toContain(">Mi<"); + expect(html).toContain(">Do<"); + expect(html).toContain(">Fr<"); + expect(html).toContain(">Sa<"); + expect(html).toContain(">So<"); + }); }); From d654e9fb606a8ab57a9a52581b423db1b2675cdc Mon Sep 17 00:00:00 2001 From: "Ronny.Herrgesell" Date: Thu, 2 Jul 2026 17:27:26 +0200 Subject: [PATCH 6/7] fix: validate budget route ranges and handle save errors in modal POST /api/budget now rejects amount < 0 or non-finite, workDays > 127, and periodStartDay outside 1-28 with 400. saveBudget() checks res.ok and catches network errors, showing inline error instead of silently closing the modal on failure. Co-Authored-By: Claude Sonnet 4.6 --- src/dashboard/routes/budget-route.ts | 23 ++++++-- src/dashboard/templates/page-template.ts | 16 ++++- tests/unit/dashboard/budget-route.test.ts | 72 +++++++++++++++++++++++ 3 files changed, 105 insertions(+), 6 deletions(-) diff --git a/src/dashboard/routes/budget-route.ts b/src/dashboard/routes/budget-route.ts index 1fef66b..da75cd9 100644 --- a/src/dashboard/routes/budget-route.ts +++ b/src/dashboard/routes/budget-route.ts @@ -33,11 +33,26 @@ export function createBudgetRoute( ) { return new Response("Invalid body", { status: 400 }); } + const amount = (body as Record).amount as number; + const workDays = (body as Record) + .workDays as number; + const periodStartDay = (body as Record) + .periodStartDay as number; + + if (!(amount >= 0 && Number.isFinite(amount))) { + return new Response("Invalid values", { status: 400 }); + } + if (!(workDays >= 0 && workDays <= 127)) { + return new Response("Invalid values", { status: 400 }); + } + if (!(periodStartDay >= 1 && periodStartDay <= 28)) { + return new Response("Invalid values", { status: 400 }); + } + const settings: BudgetSettings = { - amount: (body as Record).amount as number, - workDays: (body as Record).workDays as number, - periodStartDay: (body as Record) - .periodStartDay as number, + amount, + workDays, + periodStartDay, }; const writeRepos = createWriteRepos(); try { diff --git a/src/dashboard/templates/page-template.ts b/src/dashboard/templates/page-template.ts index 3402fe8..f50c076 100644 --- a/src/dashboard/templates/page-template.ts +++ b/src/dashboard/templates/page-template.ts @@ -110,6 +110,8 @@ export const CLIENT_SCRIPT = ` btn.onclick = () => btn.classList.toggle('active'); }); + document.getElementById('budget-error').style.display = 'none'; + document.getElementById('budget-error').textContent = ''; modal.showModal(); } @@ -129,15 +131,24 @@ export const CLIENT_SCRIPT = ` workDays |= (1 << bit); }); + const errorEl = document.getElementById('budget-error'); try { - await fetch('/api/budget', { + const res = await fetch('/api/budget', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ amount, workDays, periodStartDay }), }); + if (!res.ok) { + errorEl.textContent = 'Save failed. Please try again.'; + errorEl.style.display = 'inline'; + return; + } modal.close(); refresh(); - } catch {} + } catch { + errorEl.textContent = 'Save failed. Please try again.'; + errorEl.style.display = 'inline'; + } }`; export function renderHTML( @@ -189,6 +200,7 @@ export function renderHTML(
diff --git a/tests/unit/dashboard/budget-route.test.ts b/tests/unit/dashboard/budget-route.test.ts index 41ed6d0..5ace28c 100644 --- a/tests/unit/dashboard/budget-route.test.ts +++ b/tests/unit/dashboard/budget-route.test.ts @@ -87,4 +87,76 @@ describe("BudgetRoute", () => { expect(res.status).toBe(400); repos.close(); }); + + test("POST returns 400 when amount is negative", async () => { + const { dir, dbPath } = createTempDbPath("budget-route-test-"); + cleanupDirs.push(dir); + const repos = createSqliteRepos(dbPath); + const route = createBudgetRoute(repos, () => createSqliteRepos(dbPath)); + const res = await route.handle( + new Request("http://localhost/api/budget", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ amount: -1, workDays: 62, periodStartDay: 1 }), + }), + new URL("http://localhost/api/budget"), + ); + expect(res.status).toBe(400); + repos.close(); + }); + + test("POST returns 400 when amount is Infinity", async () => { + const { dir, dbPath } = createTempDbPath("budget-route-test-"); + cleanupDirs.push(dir); + const repos = createSqliteRepos(dbPath); + const route = createBudgetRoute(repos, () => createSqliteRepos(dbPath)); + const res = await route.handle( + new Request("http://localhost/api/budget", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + amount: 1e308 * 10, + workDays: 62, + periodStartDay: 1, + }), + }), + new URL("http://localhost/api/budget"), + ); + expect(res.status).toBe(400); + repos.close(); + }); + + test("POST returns 400 when workDays exceeds 7-bit mask", async () => { + const { dir, dbPath } = createTempDbPath("budget-route-test-"); + cleanupDirs.push(dir); + const repos = createSqliteRepos(dbPath); + const route = createBudgetRoute(repos, () => createSqliteRepos(dbPath)); + const res = await route.handle( + new Request("http://localhost/api/budget", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ amount: 100, workDays: 128, periodStartDay: 1 }), + }), + new URL("http://localhost/api/budget"), + ); + expect(res.status).toBe(400); + repos.close(); + }); + + test("POST returns 400 when periodStartDay is 0", async () => { + const { dir, dbPath } = createTempDbPath("budget-route-test-"); + cleanupDirs.push(dir); + const repos = createSqliteRepos(dbPath); + const route = createBudgetRoute(repos, () => createSqliteRepos(dbPath)); + const res = await route.handle( + new Request("http://localhost/api/budget", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ amount: 100, workDays: 62, periodStartDay: 0 }), + }), + new URL("http://localhost/api/budget"), + ); + expect(res.status).toBe(400); + repos.close(); + }); }); From 7a166ebcd95e34f2f28647e25d08446aefe749fb Mon Sep 17 00:00:00 2001 From: "Ronny.Herrgesell" Date: Fri, 3 Jul 2026 09:29:27 +0200 Subject: [PATCH 7/7] fix: budget modal UX improvements - Center modal with margin:auto on dialog element - Replace gear icon with credit-card SVG + "Budget" label - Period row: compact layout (one line) with dynamic hint below showing "ends last day of month" or "ends Nth of next month" - Fix resetDate to use periodStartDay instead of hardcoded day 1 - Add test: periodStartDay=5 produces resetDate on day 5 of next month Co-Authored-By: Claude Sonnet 4.6 --- src/dashboard/services/budget-service.ts | 2 +- src/dashboard/templates/page-template.ts | 23 ++++++++++++++++----- src/dashboard/templates/styles.ts | 11 +++++++--- tests/unit/dashboard/budget-service.test.ts | 13 +++++++++++- 4 files changed, 39 insertions(+), 10 deletions(-) diff --git a/src/dashboard/services/budget-service.ts b/src/dashboard/services/budget-service.ts index 534ac3e..d7daf31 100644 --- a/src/dashboard/services/budget-service.ts +++ b/src/dashboard/services/budget-service.ts @@ -44,7 +44,7 @@ export function calcBudgetStatus( settings.amount > 0 ? Math.max(0, Math.min(100, (remaining / settings.amount) * 100)) : 0; - const resetDate = new Date(year, month + 1, 1); + const resetDate = new Date(year, month + 1, settings.periodStartDay); return { amount: settings.amount, diff --git a/src/dashboard/templates/page-template.ts b/src/dashboard/templates/page-template.ts index f50c076..52ced75 100644 --- a/src/dashboard/templates/page-template.ts +++ b/src/dashboard/templates/page-template.ts @@ -82,6 +82,17 @@ export const CLIENT_SCRIPT = ` setInterval(refresh, 5000); attachDirFilter(); + function ordinal(n) { + const s = ['th','st','nd','rd']; + const v = n % 100; + return n + (s[(v - 20) % 10] || s[v] || s[0]); + } + function updatePeriodHint() { + const day = parseInt(document.getElementById('budget-start-day').value, 10) || 1; + document.getElementById('period-hint').textContent = + day <= 1 ? 'ends last day of month' : 'ends ' + ordinal(day - 1) + ' of next month'; + } + async function openBudgetModal() { const modal = document.getElementById('budget-modal'); const toggles = modal.querySelectorAll('.day-toggle'); @@ -103,6 +114,7 @@ export const CLIENT_SCRIPT = ` document.getElementById('budget-amount').value = amount; document.getElementById('budget-start-day').value = periodStartDay; + updatePeriodHint(); toggles.forEach(btn => { const bit = parseInt(btn.getAttribute('data-bit'), 10); @@ -193,11 +205,12 @@ export function renderHTML(
diff --git a/src/dashboard/templates/styles.ts b/src/dashboard/templates/styles.ts index 29e77b6..5b231b9 100644 --- a/src/dashboard/templates/styles.ts +++ b/src/dashboard/templates/styles.ts @@ -351,8 +351,9 @@ export const DASHBOARD_CSS = ` padding: 24px; color: #c9d1d9; font-family: "SF Mono", "Fira Code", "JetBrains Mono", monospace; - min-width: 360px; + min-width: 380px; max-width: 480px; + margin: auto; } dialog#budget-modal::backdrop { background: rgba(0, 0, 0, 0.6); @@ -385,6 +386,9 @@ export const DASHBOARD_CSS = ` border-color: #1f6feb; background: #1a2d4d; color: #f0f6fc; } .modal-hint { font-size: 11px; color: #484f58; margin-top: 6px; } + .modal-muted { color: #8b949e; font-size: 13px; white-space: nowrap; } + .period-row { display: flex; align-items: center; gap: 8px; margin-bottom: 4px; } + .period-input { width: 60px !important; } .modal-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 20px; } @@ -402,8 +406,9 @@ export const DASHBOARD_CSS = ` .btn-save:hover { background: #2ea043; } .gear-btn { background: transparent; border: 1px solid #30363d; - border-radius: 6px; padding: 3px 8px; - color: #8b949e; font-size: 13px; cursor: pointer; + border-radius: 6px; padding: 3px 10px; + color: #8b949e; font-size: 12px; cursor: pointer; line-height: 1; transition: all 0.15s; + display: flex; align-items: center; gap: 5px; } .gear-btn:hover { border-color: #484f58; color: #c9d1d9; }`; diff --git a/tests/unit/dashboard/budget-service.test.ts b/tests/unit/dashboard/budget-service.test.ts index 6d58935..d8f94d0 100644 --- a/tests/unit/dashboard/budget-service.test.ts +++ b/tests/unit/dashboard/budget-service.test.ts @@ -62,7 +62,7 @@ describe("calcBudgetStatus", () => { expect(status.remainingPct).toBe(100); }); - test("resetDate is first day of next month", () => { + test("resetDate is periodStartDay of next month (start=1)", () => { const status = calcBudgetStatus( { amount: 100, workDays: MON_FRI, periodStartDay: 1 }, 50, @@ -73,6 +73,17 @@ describe("calcBudgetStatus", () => { expect(status.resetDate.getDate()).toBe(1); }); + test("resetDate is periodStartDay of next month (start=5)", () => { + const status = calcBudgetStatus( + { amount: 100, workDays: MON_FRI, periodStartDay: 5 }, + 50, + new Date(2026, 6, 15), + ); + expect(status.resetDate.getFullYear()).toBe(2026); + expect(status.resetDate.getMonth()).toBe(7); + expect(status.resetDate.getDate()).toBe(5); + }); + test("weekend-only work days", () => { // bit0=Sun=1, bit6=Sat=64 → 65 // July 6 (Monday): elapsed days before today = Jul 1-5