From 586241d50a944a28fa3a19b86aa3e6177718f906 Mon Sep 17 00:00:00 2001 From: Pawel Novak Date: Fri, 27 Mar 2026 22:51:06 +0300 Subject: [PATCH 1/6] feat: add token-to-USD pricing model and fix cost calculation Previously calculateMetrics() always returned costUsd=0 (stub). This adds a complete pricing table for all Claude model families (claude-2/instant/3/3.5/3.7/4) with input, output, cache-read and cache-write rates, and wires it into the metrics calculation so every session now reports a real estimated USD cost. - src/main/utils/pricingModel.ts: new module with pricing table and helpers getPricingForModel() (prefix-match) + calculateTokenCost() - src/main/utils/jsonl.ts: replace costUsd=0 stub with per-message cost accumulation using the model field reported in each message --- src/main/utils/jsonl.ts | 20 +++-- src/main/utils/pricingModel.ts | 143 +++++++++++++++++++++++++++++++++ 2 files changed, 158 insertions(+), 5 deletions(-) create mode 100644 src/main/utils/pricingModel.ts diff --git a/src/main/utils/jsonl.ts b/src/main/utils/jsonl.ts index d998438f..6c6220a8 100644 --- a/src/main/utils/jsonl.ts +++ b/src/main/utils/jsonl.ts @@ -11,6 +11,8 @@ import { isCommandOutputContent, sanitizeDisplayContent } from '@shared/utils/co import { createLogger } from '@shared/utils/logger'; import * as readline from 'readline'; +import { calculateTokenCost } from './pricingModel'; + import { SessionContentFilter } from '../services/discovery/SessionContentFilter'; import { LocalFileSystemProvider } from '../services/infrastructure/LocalFileSystemProvider'; import { @@ -271,7 +273,7 @@ export function calculateMetrics(messages: ParsedMessage[]): SessionMetrics { let outputTokens = 0; let cacheReadTokens = 0; let cacheCreationTokens = 0; - const costUsd = 0; + let costUsd = 0; // Get timestamps for duration (loop instead of Math.min/max spread to avoid stack overflow on large sessions) const timestamps = messages.map((m) => m.timestamp.getTime()).filter((t) => !isNaN(t)); @@ -289,10 +291,18 @@ export function calculateMetrics(messages: ParsedMessage[]): SessionMetrics { for (const msg of dedupedMessages) { if (msg.usage) { - inputTokens += msg.usage.input_tokens ?? 0; - outputTokens += msg.usage.output_tokens ?? 0; - cacheReadTokens += msg.usage.cache_read_input_tokens ?? 0; - cacheCreationTokens += msg.usage.cache_creation_input_tokens ?? 0; + const msgInput = msg.usage.input_tokens ?? 0; + const msgOutput = msg.usage.output_tokens ?? 0; + const msgCacheRead = msg.usage.cache_read_input_tokens ?? 0; + const msgCacheCreate = msg.usage.cache_creation_input_tokens ?? 0; + + inputTokens += msgInput; + outputTokens += msgOutput; + cacheReadTokens += msgCacheRead; + cacheCreationTokens += msgCacheCreate; + + // Accumulate per-message cost using the model reported by that message + costUsd += calculateTokenCost(msg.model, msgInput, msgOutput, msgCacheRead, msgCacheCreate); } } diff --git a/src/main/utils/pricingModel.ts b/src/main/utils/pricingModel.ts new file mode 100644 index 00000000..748a5c00 --- /dev/null +++ b/src/main/utils/pricingModel.ts @@ -0,0 +1,143 @@ +/** + * Claude API pricing model. + * + * Provides token-to-USD cost calculation for all Claude model families. + * Prices are in USD per 1,000,000 tokens. + * + * Source: https://www.anthropic.com/api + */ + +export interface ModelPricing { + /** USD per 1M input tokens */ + inputPerMillion: number; + /** USD per 1M output tokens */ + outputPerMillion: number; + /** USD per 1M cache creation tokens (prompt caching write) */ + cacheWritePerMillion: number; + /** USD per 1M cache read tokens (prompt caching read) */ + cacheReadPerMillion: number; +} + +/** + * Pricing table: [model-string-prefix, pricing]. + * Entries are ordered from most-specific to least-specific. + * Matching is done via startsWith or includes on the lowercased model string. + */ +const PRICING_TABLE: Array<[string, ModelPricing]> = [ + // ── Claude 4 ────────────────────────────────────────────────────────── + [ + 'claude-opus-4', + { inputPerMillion: 15.0, outputPerMillion: 75.0, cacheWritePerMillion: 18.75, cacheReadPerMillion: 1.5 }, + ], + [ + 'claude-sonnet-4', + { inputPerMillion: 3.0, outputPerMillion: 15.0, cacheWritePerMillion: 3.75, cacheReadPerMillion: 0.3 }, + ], + [ + 'claude-haiku-4', + { inputPerMillion: 0.8, outputPerMillion: 4.0, cacheWritePerMillion: 1.0, cacheReadPerMillion: 0.08 }, + ], + + // ── Claude 3.7 ──────────────────────────────────────────────────────── + [ + 'claude-3-7-sonnet', + { inputPerMillion: 3.0, outputPerMillion: 15.0, cacheWritePerMillion: 3.75, cacheReadPerMillion: 0.3 }, + ], + + // ── Claude 3.5 ──────────────────────────────────────────────────────── + [ + 'claude-3-5-sonnet', + { inputPerMillion: 3.0, outputPerMillion: 15.0, cacheWritePerMillion: 3.75, cacheReadPerMillion: 0.3 }, + ], + [ + 'claude-3.5-sonnet', + { inputPerMillion: 3.0, outputPerMillion: 15.0, cacheWritePerMillion: 3.75, cacheReadPerMillion: 0.3 }, + ], + [ + 'claude-3-5-haiku', + { inputPerMillion: 0.8, outputPerMillion: 4.0, cacheWritePerMillion: 1.0, cacheReadPerMillion: 0.08 }, + ], + [ + 'claude-3.5-haiku', + { inputPerMillion: 0.8, outputPerMillion: 4.0, cacheWritePerMillion: 1.0, cacheReadPerMillion: 0.08 }, + ], + + // ── Claude 3 ────────────────────────────────────────────────────────── + [ + 'claude-3-opus', + { inputPerMillion: 15.0, outputPerMillion: 75.0, cacheWritePerMillion: 18.75, cacheReadPerMillion: 1.5 }, + ], + [ + 'claude-3-sonnet', + { inputPerMillion: 3.0, outputPerMillion: 15.0, cacheWritePerMillion: 3.75, cacheReadPerMillion: 0.3 }, + ], + [ + 'claude-3-haiku', + { inputPerMillion: 0.25, outputPerMillion: 1.25, cacheWritePerMillion: 0.3, cacheReadPerMillion: 0.03 }, + ], + + // ── Claude 2 ────────────────────────────────────────────────────────── + [ + 'claude-2', + { inputPerMillion: 8.0, outputPerMillion: 24.0, cacheWritePerMillion: 8.0, cacheReadPerMillion: 8.0 }, + ], + [ + 'claude-instant', + { inputPerMillion: 1.63, outputPerMillion: 5.51, cacheWritePerMillion: 1.63, cacheReadPerMillion: 1.63 }, + ], +]; + +/** + * Fallback pricing used for unknown or future models. + * Uses Claude Sonnet 4 pricing as a reasonable mid-tier estimate. + */ +export const FALLBACK_PRICING: ModelPricing = { + inputPerMillion: 3.0, + outputPerMillion: 15.0, + cacheWritePerMillion: 3.75, + cacheReadPerMillion: 0.3, +}; + +/** + * Returns pricing for a given model string. + * Matching is prefix-based and case-insensitive. + * Falls back to Sonnet-tier pricing for unrecognized models. + */ +export function getPricingForModel(model: string | null | undefined): ModelPricing { + if (!model) return FALLBACK_PRICING; + + const lower = model.toLowerCase(); + for (const [prefix, pricing] of PRICING_TABLE) { + if (lower.startsWith(prefix) || lower.includes(prefix)) { + return pricing; + } + } + + return FALLBACK_PRICING; +} + +/** + * Calculates the USD cost for a single API response given token counts and model. + * + * @param model Model string (e.g. "claude-sonnet-4-5-20251022") + * @param inputTokens Prompt / input tokens + * @param outputTokens Completion / output tokens + * @param cacheReadTokens Tokens retrieved from prompt cache + * @param cacheCreationTokens Tokens written to prompt cache + * @returns Cost in USD + */ +export function calculateTokenCost( + model: string | null | undefined, + inputTokens: number, + outputTokens: number, + cacheReadTokens = 0, + cacheCreationTokens = 0 +): number { + const p = getPricingForModel(model); + return ( + (inputTokens / 1_000_000) * p.inputPerMillion + + (outputTokens / 1_000_000) * p.outputPerMillion + + (cacheReadTokens / 1_000_000) * p.cacheReadPerMillion + + (cacheCreationTokens / 1_000_000) * p.cacheWritePerMillion + ); +} From 2b51b7f387d5e4f804fdf535f08c8b98eb6646ab Mon Sep 17 00:00:00 2001 From: Pawel Novak Date: Fri, 27 Mar 2026 22:51:18 +0300 Subject: [PATCH 2/6] feat: add subscription payment tracking to Settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a new Billing tab in Settings where users can record their Claude subscription charges (multiple entries per month supported, e.g. Pro + Max on different dates). Entries are persisted via the existing config:update IPC channel with full validation. - ConfigManager: new SubscriptionsConfig + SubscriptionEntry types, defaults, and merge logic - shared/types: AppConfig and ElectronAPI extended with subscriptions and getUsageStats() - configValidation: subscriptions section with per-entry validation (id, ISO date, plan, positive amountUsd, optional note) - preload: getUsageStats IPC bridge - SettingsTabs: new Billing tab with CreditCard icon - SettingsView: render SubscriptionsSection for billing section - SubscriptionsSection: form UI — add/delete entries, grouped by month with monthly total, locale forced to en-US --- src/main/ipc/configValidation.ts | 41 ++- .../services/infrastructure/ConfigManager.ts | 31 ++ src/preload/index.ts | 4 + src/renderer/api/httpClient.ts | 3 + .../components/settings/SettingsTabs.tsx | 5 +- .../components/settings/SettingsView.tsx | 13 + .../sections/SubscriptionsSection.tsx | 344 ++++++++++++++++++ .../components/settings/sections/index.ts | 1 + src/shared/types/api.ts | 22 ++ src/shared/types/notifications.ts | 13 + 10 files changed, 474 insertions(+), 3 deletions(-) create mode 100644 src/renderer/components/settings/sections/SubscriptionsSection.tsx diff --git a/src/main/ipc/configValidation.ts b/src/main/ipc/configValidation.ts index aa71e865..e3dfe7ef 100644 --- a/src/main/ipc/configValidation.ts +++ b/src/main/ipc/configValidation.ts @@ -34,6 +34,7 @@ export type ConfigUpdateValidationResult = | ValidationSuccess<'display'> | ValidationSuccess<'httpServer'> | ValidationSuccess<'ssh'> + | ValidationSuccess<'subscriptions'> | ValidationFailure; const VALID_SECTIONS = new Set([ @@ -42,6 +43,7 @@ const VALID_SECTIONS = new Set([ 'display', 'httpServer', 'ssh', + 'subscriptions', ]); const MAX_SNOOZE_MINUTES = 24 * 60; @@ -432,6 +434,41 @@ function validateSshSection(data: unknown): ValidationSuccess<'ssh'> | Validatio return { valid: true, section: 'ssh', data: result }; } +function isValidSubscriptionEntry(entry: unknown): boolean { + if (!isPlainObject(entry)) return false; + if (typeof entry.id !== 'string' || entry.id.trim().length === 0) return false; + if (typeof entry.date !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(entry.date)) return false; + if (typeof entry.plan !== 'string' || entry.plan.trim().length === 0) return false; + if (!isFiniteNumber(entry.amountUsd) || (entry.amountUsd as number) <= 0) return false; + if (entry.note !== undefined && typeof entry.note !== 'string') return false; + return true; +} + +function validateSubscriptionsSection( + data: unknown +): ValidationSuccess<'subscriptions'> | ValidationFailure { + if (!isPlainObject(data)) { + return { valid: false, error: 'subscriptions update must be an object' }; + } + + if (!('entries' in data)) { + return { valid: false, error: 'subscriptions.entries is required' }; + } + + if (!Array.isArray(data.entries) || !data.entries.every(isValidSubscriptionEntry)) { + return { + valid: false, + error: 'subscriptions.entries must be a valid subscription entry array', + }; + } + + return { + valid: true, + section: 'subscriptions', + data: { entries: data.entries as AppConfig['subscriptions']['entries'] }, + }; +} + export function validateConfigUpdatePayload( section: unknown, data: unknown @@ -439,7 +476,7 @@ export function validateConfigUpdatePayload( if (typeof section !== 'string' || !VALID_SECTIONS.has(section as ConfigSection)) { return { valid: false, - error: 'Section must be one of: notifications, general, display, httpServer, ssh', + error: 'Section must be one of: notifications, general, display, httpServer, ssh, subscriptions', }; } @@ -454,6 +491,8 @@ export function validateConfigUpdatePayload( return validateHttpServerSection(data); case 'ssh': return validateSshSection(data); + case 'subscriptions': + return validateSubscriptionsSection(data); default: return { valid: false, error: 'Invalid section' }; } diff --git a/src/main/services/infrastructure/ConfigManager.ts b/src/main/services/infrastructure/ConfigManager.ts index 14e3e61b..02d8aef9 100644 --- a/src/main/services/infrastructure/ConfigManager.ts +++ b/src/main/services/infrastructure/ConfigManager.ts @@ -214,6 +214,28 @@ export interface HttpServerConfig { port: number; } +/** + * A single subscription payment entry. + * Users can record multiple payments per month (e.g. Pro then Max upgrade). + */ +export interface SubscriptionEntry { + /** Unique identifier */ + id: string; + /** ISO date string of the payment, e.g. "2026-03-01" */ + date: string; + /** Human-readable plan label: "Pro", "Max", "Team", etc. */ + plan: string; + /** Amount paid in USD */ + amountUsd: number; + /** Optional free-text note */ + note?: string; +} + +export interface SubscriptionsConfig { + /** Recorded subscription payments, oldest first */ + entries: SubscriptionEntry[]; +} + export interface AppConfig { notifications: NotificationConfig; general: GeneralConfig; @@ -221,6 +243,7 @@ export interface AppConfig { sessions: SessionsConfig; ssh: SshPersistConfig; httpServer: HttpServerConfig; + subscriptions: SubscriptionsConfig; } // Config section keys for type-safe updates @@ -272,6 +295,9 @@ const DEFAULT_CONFIG: AppConfig = { enabled: false, port: 3456, }, + subscriptions: { + entries: [], + }, }; function normalizeConfiguredClaudeRootPath(value: unknown): string | null { @@ -461,6 +487,11 @@ export class ConfigManager { ...DEFAULT_CONFIG.httpServer, ...(loaded.httpServer ?? {}), }, + subscriptions: { + ...DEFAULT_CONFIG.subscriptions, + ...(loaded.subscriptions ?? {}), + entries: loaded.subscriptions?.entries ?? DEFAULT_CONFIG.subscriptions.entries, + }, }; } diff --git a/src/preload/index.ts b/src/preload/index.ts index e5d70646..89862879 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -157,6 +157,10 @@ const electronAPI: ElectronAPI = { getWorktreeSessions: (worktreeId: string) => ipcRenderer.invoke('get-worktree-sessions', worktreeId), + // Usage analytics + getUsageStats: (year: number, month: number) => + ipcRenderer.invoke('get-usage-stats', year, month), + // Validation methods validatePath: (relativePath: string, projectPath: string) => ipcRenderer.invoke('validate-path', relativePath, projectPath), diff --git a/src/renderer/api/httpClient.ts b/src/renderer/api/httpClient.ts index 4ce15d64..0d4c1bcf 100644 --- a/src/renderer/api/httpClient.ts +++ b/src/renderer/api/httpClient.ts @@ -270,6 +270,9 @@ export class HttpAPIClient implements ElectronAPI { getWorktreeSessions = (worktreeId: string): Promise => this.get(`/api/worktrees/${encodeURIComponent(worktreeId)}/sessions`); + getUsageStats = (year: number, month: number): Promise => + this.get(`/api/usage-stats?year=${year}&month=${month}`); + // --------------------------------------------------------------------------- // Validation // --------------------------------------------------------------------------- diff --git a/src/renderer/components/settings/SettingsTabs.tsx b/src/renderer/components/settings/SettingsTabs.tsx index 4995d64e..7988af67 100644 --- a/src/renderer/components/settings/SettingsTabs.tsx +++ b/src/renderer/components/settings/SettingsTabs.tsx @@ -1,9 +1,9 @@ import { useMemo, useState } from 'react'; import { isElectronMode } from '@renderer/api'; -import { Bell, HardDrive, Server, Settings, Wrench } from 'lucide-react'; +import { Bell, CreditCard, HardDrive, Server, Settings, Wrench } from 'lucide-react'; -export type SettingsSection = 'general' | 'connection' | 'workspace' | 'notifications' | 'advanced'; +export type SettingsSection = 'general' | 'connection' | 'workspace' | 'notifications' | 'billing' | 'advanced'; interface SettingsTabsProps { activeSection: SettingsSection; @@ -22,6 +22,7 @@ const tabs: TabConfig[] = [ { id: 'connection', label: 'Connection', icon: Server, electronOnly: true }, { id: 'workspace', label: 'Workspaces', icon: HardDrive, electronOnly: true }, { id: 'notifications', label: 'Notifications', icon: Bell }, + { id: 'billing', label: 'Billing', icon: CreditCard }, { id: 'advanced', label: 'Advanced', icon: Wrench }, ]; diff --git a/src/renderer/components/settings/SettingsView.tsx b/src/renderer/components/settings/SettingsView.tsx index ee4f0400..8eaa8a0f 100644 --- a/src/renderer/components/settings/SettingsView.tsx +++ b/src/renderer/components/settings/SettingsView.tsx @@ -14,10 +14,13 @@ import { ConnectionSection, GeneralSection, NotificationsSection, + SubscriptionsSection, WorkspaceSection, } from './sections'; import { type SettingsSection, SettingsTabs } from './SettingsTabs'; +import type { AppConfig } from '@shared/types/notifications'; + export const SettingsView = (): React.JSX.Element | null => { const [activeSection, setActiveSection] = useState('general'); const pendingSettingsSection = useStore((s) => s.pendingSettingsSection); @@ -153,6 +156,16 @@ export const SettingsView = (): React.JSX.Element | null => { /> )} + {activeSection === 'billing' && ( + { + await updateConfig('subscriptions', { entries }); + }} + /> + )} + {activeSection === 'advanced' && ( ['entries'][number]; + +interface NewEntryForm { + date: string; + plan: string; + customPlan: string; + amountUsd: string; + note: string; +} + +interface SubscriptionsSectionProps { + readonly config: AppConfig | null; + readonly saving: boolean; + readonly onSave: (entries: SubscriptionEntry[]) => Promise; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Helpers +// ───────────────────────────────────────────────────────────────────────────── + +function todayIso(): string { + return new Date().toISOString().slice(0, 10); +} + +function formatDate(iso: string): string { + try { + return new Date(iso + 'T00:00:00').toLocaleDateString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + }); + } catch { + return iso; + } +} + +function newId(): string { + return `${Date.now()}-${Math.random().toString(36).slice(2, 7)}`; +} + +// ───────────────────────────────────────────────────────────────────────────── +// SubscriptionsSection +// ───────────────────────────────────────────────────────────────────────────── + +export const SubscriptionsSection = ({ + config, + saving, + onSave, +}: SubscriptionsSectionProps): React.JSX.Element => { + const entries: SubscriptionEntry[] = config?.subscriptions?.entries ?? []; + + const [showForm, setShowForm] = useState(false); + const [deleting, setDeleting] = useState(null); + const [amountError, setAmountError] = useState(null); + const [form, setForm] = useState({ + date: todayIso(), + plan: 'Pro', + customPlan: '', + amountUsd: '', + note: '', + }); + + const effectivePlan = form.plan === 'Custom' ? form.customPlan.trim() : form.plan; + const amountNum = parseFloat(form.amountUsd); + const amountValid = !isNaN(amountNum) && amountNum > 0; + + const handleAdd = useCallback(async () => { + if (saving) return; + if (!amountValid) { + setAmountError('Enter a valid amount greater than 0'); + return; + } + setAmountError(null); + const next: SubscriptionEntry[] = [ + ...entries, + { id: newId(), date: form.date, plan: effectivePlan, amountUsd: amountNum!, note: form.note.trim() || undefined }, + ].sort((a, b) => a.date.localeCompare(b.date)); + await onSave(next); + setAmountError(null); + setForm({ date: todayIso(), plan: 'Pro', customPlan: '', amountUsd: '', note: '' }); + setShowForm(false); + }, [saving, amountValid, entries, form, effectivePlan, amountNum, onSave]); + + const handleDelete = useCallback( + async (id: string) => { + setDeleting(id); + try { + await onSave(entries.filter((e) => e.id !== id)); + } catch (err) { + logger.error('Failed to delete subscription entry:', err); + } finally { + setDeleting(null); + } + }, + [entries, onSave] + ); + + // Group entries by month for display + const grouped = entries.reduce>((acc, e) => { + const key = e.date.slice(0, 7); // "YYYY-MM" + if (!acc[key]) acc[key] = []; + acc[key].push(e); + return acc; + }, {}); + const monthKeys = Object.keys(grouped).sort((a, b) => b.localeCompare(a)); + + return ( +
+ +

+ Record your Claude subscription charges to track ROI vs. pay-per-token API costs on the dashboard. +

+ + {/* Entry list */} + {monthKeys.length === 0 && !showForm && ( +
+ +

No subscription entries yet

+

+ Add payments to see your ROI vs. API-equivalent cost on the dashboard. +

+
+ )} + + {monthKeys.map((monthKey) => { + const [y, m] = monthKey.split('-'); + const monthLabel = new Date(`${monthKey}-01T00:00:00`).toLocaleDateString('en-US', { + year: 'numeric', + month: 'long', + }); + const monthTotal = grouped[monthKey].reduce((s, e) => s + e.amountUsd, 0); + + return ( +
+ {/* Month header */} +
+ + {monthLabel} + + + ${monthTotal.toFixed(2)} + +
+ + {/* Entries */} +
+ {grouped[monthKey].map((entry) => ( +
+
+ +
+
+ {entry.plan} + {entry.note && ( + {entry.note} + )} +
+ {formatDate(entry.date)} +
+
+ +
+ + ${entry.amountUsd.toFixed(2)} + + +
+
+ ))} +
+
+ ); + })} + + {/* Add entry form */} + {showForm && ( +
+

Add Payment

+ +
+ {/* Date */} +
+ + setForm((f) => ({ ...f, date: e.target.value }))} + className="w-full rounded-sm border border-border bg-surface px-3 py-2 text-sm text-text outline-none focus:border-zinc-500 focus:ring-1 focus:ring-zinc-600/30" + /> +
+ + {/* Amount */} +
+ +
+ $ + { + setAmountError(null); + setForm((f) => ({ ...f, amountUsd: e.target.value })); + }} + className={`w-full rounded-sm border bg-surface py-2 pl-6 pr-3 text-sm text-text outline-none focus:ring-1 focus:ring-zinc-600/30 ${ + amountError ? 'border-red-500/60 focus:border-red-500' : 'border-border focus:border-zinc-500' + }`} + /> +
+ {amountError && ( +

{amountError}

+ )} +
+ + {/* Plan */} +
+ +
+ {PLAN_OPTIONS.map((p) => ( + + ))} + +
+
+ + {/* Custom plan name */} + {form.plan === 'Custom' && ( +
+ + setForm((f) => ({ ...f, customPlan: e.target.value }))} + className="w-full rounded-sm border border-border bg-surface px-3 py-2 text-sm text-text outline-none focus:border-zinc-500 focus:ring-1 focus:ring-zinc-600/30" + /> +
+ )} + + {/* Note (full width) */} +
+ + setForm((f) => ({ ...f, note: e.target.value }))} + className="w-full rounded-sm border border-border bg-surface px-3 py-2 text-sm text-text outline-none focus:border-zinc-500 focus:ring-1 focus:ring-zinc-600/30" + /> +
+
+ +
+ + +
+
+ )} + + {/* Add button */} + {!showForm && ( + + )} +
+ ); +}; diff --git a/src/renderer/components/settings/sections/index.ts b/src/renderer/components/settings/sections/index.ts index 21278419..05c0ac48 100644 --- a/src/renderer/components/settings/sections/index.ts +++ b/src/renderer/components/settings/sections/index.ts @@ -6,4 +6,5 @@ export { AdvancedSection } from './AdvancedSection'; export { ConnectionSection } from './ConnectionSection'; export { GeneralSection } from './GeneralSection'; export { NotificationsSection } from './NotificationsSection'; +export { SubscriptionsSection } from './SubscriptionsSection'; export { WorkspaceSection } from './WorkspaceSection'; diff --git a/src/shared/types/api.ts b/src/shared/types/api.ts index 9822c14f..65d81a78 100644 --- a/src/shared/types/api.ts +++ b/src/shared/types/api.ts @@ -318,6 +318,25 @@ export interface HttpServerAPI { // Main Electron API // ============================================================================= +/** + * Aggregated usage statistics for a calendar month. + * Used for subscription ROI calculations. + */ +export interface UsageStats { + /** Total estimated cost in USD for the period */ + totalCostUsd: number; + /** Total input tokens across all sessions in the period */ + inputTokens: number; + /** Total output tokens across all sessions in the period */ + outputTokens: number; + /** Total cache read tokens */ + cacheReadTokens: number; + /** Total cache creation tokens */ + cacheCreationTokens: number; + /** Number of sessions in the period */ + sessionCount: number; +} + /** * Complete Electron API exposed to the renderer process via preload script. */ @@ -356,6 +375,9 @@ export interface ElectronAPI { getRepositoryGroups: () => Promise; getWorktreeSessions: (worktreeId: string) => Promise; + // Usage analytics + getUsageStats: (year: number, month: number) => Promise; + // Validation methods validatePath: ( relativePath: string, diff --git a/src/shared/types/notifications.ts b/src/shared/types/notifications.ts index 766d7044..d671b93c 100644 --- a/src/shared/types/notifications.ts +++ b/src/shared/types/notifications.ts @@ -315,4 +315,17 @@ export interface AppConfig { /** Port for the HTTP server (default 3456) */ port: number; }; + /** Subscription payment history for ROI tracking */ + subscriptions?: { + entries: Array<{ + id: string; + /** ISO date string, e.g. "2026-03-01" */ + date: string; + /** Plan label: "Pro", "Max", "Team", etc. */ + plan: string; + /** Amount paid in USD */ + amountUsd: number; + note?: string; + }>; + }; } From a9fc9973b46ff162c9ce3e1c59b9a838fae3baf1 Mon Sep 17 00:00:00 2001 From: Pawel Novak Date: Fri, 27 Mar 2026 22:51:26 +0300 Subject: [PATCH 3/6] feat: add subscription ROI block to dashboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Shows a month-level cost comparison at the top of the dashboard: subscription paid vs estimated API-equivalent cost computed from actual token usage across all sessions in the current month. Three stat cards: Paid (subscription), API Equivalent, You Saved (or break-even gap). Progress bar shows API usage as % of sub cost. Links to Settings > Billing when no subscription is configured. - sessions.ts: new get-usage-stats IPC handler — scans all projects, filters sessions by month, aggregates token counts and costUsd - RoiBlock.tsx: dashboard component consuming getUsageStats() IPC and appConfig.subscriptions; all dates formatted as en-US - DashboardView.tsx: mount RoiBlock between search bar and projects --- src/main/ipc/sessions.ts | 69 +++++ .../components/dashboard/DashboardView.tsx | 9 +- .../components/dashboard/RoiBlock.tsx | 250 ++++++++++++++++++ 3 files changed, 327 insertions(+), 1 deletion(-) create mode 100644 src/renderer/components/dashboard/RoiBlock.tsx diff --git a/src/main/ipc/sessions.ts b/src/main/ipc/sessions.ts index e99caa7c..bdb885b9 100644 --- a/src/main/ipc/sessions.ts +++ b/src/main/ipc/sessions.ts @@ -22,9 +22,11 @@ import { type SessionsByIdsOptions, type SessionsPaginationOptions, } from '../types'; +import { calculateMetrics, parseJsonlFile } from '../utils/jsonl'; import { coercePageLimit, validateProjectId, validateSessionId } from './guards'; +import type { UsageStats } from '@shared/types'; import type { ServiceContextRegistry } from '../services'; import type { WaterfallData } from '@shared/types'; @@ -51,6 +53,7 @@ export function registerSessionHandlers(ipcMain: IpcMain): void { ipcMain.handle('get-session-groups', handleGetSessionGroups); ipcMain.handle('get-session-metrics', handleGetSessionMetrics); ipcMain.handle('get-waterfall-data', handleGetWaterfallData); + ipcMain.handle('get-usage-stats', handleGetUsageStats); logger.info('Session handlers registered'); } @@ -66,6 +69,7 @@ export function removeSessionHandlers(ipcMain: IpcMain): void { ipcMain.removeHandler('get-session-groups'); ipcMain.removeHandler('get-session-metrics'); ipcMain.removeHandler('get-waterfall-data'); + ipcMain.removeHandler('get-usage-stats'); logger.info('Session handlers removed'); } @@ -361,3 +365,68 @@ async function handleGetWaterfallData( return null; } } + +/** + * Handler for 'get-usage-stats' IPC call. + * Aggregates token usage and estimated API cost across all sessions + * that started within the specified calendar month (local time). + * + * @param year Full year, e.g. 2026 + * @param month 1-based month, e.g. 3 for March + */ +async function handleGetUsageStats( + _event: IpcMainInvokeEvent, + year: number, + month: number +): Promise { + const empty: UsageStats = { + totalCostUsd: 0, + inputTokens: 0, + outputTokens: 0, + cacheReadTokens: 0, + cacheCreationTokens: 0, + sessionCount: 0, + }; + + try { + const { projectScanner, fsProvider } = registry.getActive(); + + // Date range for the requested month (Unix ms, inclusive) + const start = new Date(year, month - 1, 1).getTime(); + const end = new Date(year, month, 1).getTime(); // exclusive + + // Collect all projects + const projects = await projectScanner.scan(); + + const stats: UsageStats = { ...empty }; + + for (const project of projects) { + const sessions = await projectScanner.listSessions(project.id); + + for (const session of sessions) { + // Use createdAt (unix ms) for date filtering + if (session.createdAt < start || session.createdAt >= end) continue; + + try { + const sessionPath = projectScanner.getSessionPath(project.id, session.id); + const messages = await parseJsonlFile(sessionPath, fsProvider); + const metrics = calculateMetrics(messages); + + stats.inputTokens += metrics.inputTokens; + stats.outputTokens += metrics.outputTokens; + stats.cacheReadTokens += metrics.cacheReadTokens; + stats.cacheCreationTokens += metrics.cacheCreationTokens; + stats.totalCostUsd += metrics.costUsd ?? 0; + stats.sessionCount += 1; + } catch (sessionErr) { + logger.warn(`get-usage-stats: skipping session ${session.id}:`, sessionErr); + } + } + } + + return stats; + } catch (error) { + logger.error(`Error in get-usage-stats for ${year}-${month}:`, error); + return empty; + } +} diff --git a/src/renderer/components/dashboard/DashboardView.tsx b/src/renderer/components/dashboard/DashboardView.tsx index a427bb54..aed27818 100644 --- a/src/renderer/components/dashboard/DashboardView.tsx +++ b/src/renderer/components/dashboard/DashboardView.tsx @@ -21,6 +21,8 @@ import { Command, FolderGit2, FolderOpen, GitBranch, Search, Settings } from 'lu import type { RepositoryGroup } from '@renderer/types/data'; +import { RoiBlock } from './RoiBlock'; + // ============================================================================= // Command Search Input // ============================================================================= @@ -408,10 +410,15 @@ export const DashboardView = (): React.JSX.Element => { {/* Content */}
{/* Command Search */} -
+
+ {/* Subscription ROI block */} +
+ +
+ {/* Section header */}

diff --git a/src/renderer/components/dashboard/RoiBlock.tsx b/src/renderer/components/dashboard/RoiBlock.tsx new file mode 100644 index 00000000..706324fd --- /dev/null +++ b/src/renderer/components/dashboard/RoiBlock.tsx @@ -0,0 +1,250 @@ +/** + * RoiBlock - Subscription ROI summary for the Dashboard. + * + * Shows for the current calendar month: + * • What you paid (subscription) + * • What the same usage would cost on the pay-per-token API + * • Your savings (or how much you need to use to break even) + * + * Data sources: + * - Subscription entries from app config (Settings → Billing) + * - Monthly token usage from the `get-usage-stats` IPC endpoint + */ + +import React, { useCallback, useEffect, useState } from 'react'; + +import { api } from '@renderer/api'; +import { useStore } from '@renderer/store'; +import { createLogger } from '@shared/utils/logger'; +import { ArrowRight, CreditCard, Loader2, TrendingDown, TrendingUp, Zap } from 'lucide-react'; +import { useShallow } from 'zustand/react/shallow'; + +import type { UsageStats } from '@shared/types'; +import type { AppConfig } from '@shared/types/notifications'; + +const logger = createLogger('Component:RoiBlock'); + +// ───────────────────────────────────────────────────────────────────────────── +// Helpers +// ───────────────────────────────────────────────────────────────────────────── + +function formatUsd(value: number): string { + if (value >= 1000) return `$${(value / 1000).toFixed(1)}k`; + if (value >= 100) return `$${value.toFixed(0)}`; + if (value >= 10) return `$${value.toFixed(1)}`; + return `$${value.toFixed(2)}`; +} + +function currentYearMonth(): { year: number; month: number } { + const now = new Date(); + return { year: now.getFullYear(), month: now.getMonth() + 1 }; +} + +function monthLabel(year: number, month: number): string { + return new Date(year, month - 1, 1).toLocaleDateString('en-US', { + year: 'numeric', + month: 'long', + }); +} + +function subscriptionTotalForMonth( + entries: AppConfig['subscriptions'], + year: number, + month: number +): number { + const prefix = `${year}-${String(month).padStart(2, '0')}`; + return (entries?.entries ?? []) + .filter((e) => e.date.startsWith(prefix)) + .reduce((sum, e) => sum + e.amountUsd, 0); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Stat card +// ───────────────────────────────────────────────────────────────────────────── + +interface StatCardProps { + label: string; + value: string; + icon: React.ReactNode; + accent?: 'green' | 'red' | 'neutral'; + subtitle?: string; +} + +const StatCard = ({ label, value, icon, accent = 'neutral', subtitle }: StatCardProps): React.JSX.Element => { + const accentColor = + accent === 'green' + ? 'var(--semantic-success, #22c55e)' + : accent === 'red' + ? 'var(--semantic-error, #ef4444)' + : 'var(--color-text-secondary)'; + + return ( +
+
+ {label} + {icon} +
+
+ + {value} + + {subtitle && ( +

{subtitle}

+ )} +
+
+ ); +}; + +// ───────────────────────────────────────────────────────────────────────────── +// RoiBlock +// ───────────────────────────────────────────────────────────────────────────── + +export const RoiBlock = (): React.JSX.Element | null => { + const { year, month } = currentYearMonth(); + const { appConfig, openSettingsTab } = useStore( + useShallow((s) => ({ + appConfig: s.appConfig as AppConfig | null, + openSettingsTab: s.openSettingsTab, + })) + ); + + const [stats, setStats] = useState(null); + const [loading, setLoading] = useState(false); + + const load = useCallback(async () => { + setLoading(true); + try { + const result = await api.getUsageStats(year, month); + setStats(result); + } catch (err) { + logger.error('Failed to load usage stats:', err); + } finally { + setLoading(false); + } + }, [year, month]); + + useEffect(() => { + void load(); + }, [load]); + + const subPaid = subscriptionTotalForMonth(appConfig?.subscriptions, year, month); + const apiEquiv = stats?.totalCostUsd ?? 0; + const savings = apiEquiv - subPaid; + const hasSubscription = subPaid > 0; + const hasUsage = (stats?.sessionCount ?? 0) > 0; + + // Don't render anything if user hasn't configured subscriptions yet + if (!hasSubscription && !loading) { + return ( +
+
+ + + Add your subscription payments to see ROI vs. API pricing + +
+ +
+ ); + } + + return ( +
+ {/* Section header */} +
+

+ {monthLabel(year, month)} — Subscription ROI +

+ +
+ + {loading ? ( +
+ + Calculating usage… +
+ ) : ( + <> + {/* Three stat cards */} +
+ } + subtitle={ + (appConfig?.subscriptions?.entries ?? []) + .filter((e) => e.date.startsWith(`${year}-${String(month).padStart(2, '0')}`)) + .map((e) => e.plan) + .join(' + ') || undefined + } + /> + } + subtitle={hasUsage ? `${stats!.sessionCount} sessions` : 'No sessions yet'} + /> + = 0 ? 'You saved' : 'Break-even gap'} + value={hasUsage ? formatUsd(Math.abs(savings)) : '—'} + icon={ + savings >= 0 + ? + : + } + accent={!hasUsage ? 'neutral' : savings >= 0 ? 'green' : 'red'} + subtitle={ + hasUsage + ? savings >= 0 + ? `${((savings / subPaid) * 100).toFixed(0)}% return on subscription` + : `Need $${(subPaid - apiEquiv).toFixed(2)} more API usage to break even` + : undefined + } + /> +
+ + {/* Progress bar: API usage vs subscription cost */} + {hasUsage && subPaid > 0 && ( +
+
+
= subPaid ? 'var(--semantic-success, #22c55e)' : 'var(--semantic-warning, #f59e0b)', + }} + /> +
+

+ {((apiEquiv / subPaid) * 100).toFixed(0)}% of subscription cost covered by API usage +

+
+ )} + + )} +
+ ); +}; From 3eef28c583235297810c6a03a4d3351ac0a84816 Mon Sep 17 00:00:00 2001 From: Pawel Novak Date: Fri, 27 Mar 2026 22:51:32 +0300 Subject: [PATCH 4/6] test: add tests for pricingModel and subscriptions config validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - pricingModel.test.ts: covers getPricingForModel (known models, prefix matching, opus>haiku ordering, unknown fallback) and calculateTokenCost (zero, input-only, output-only, cache tokens, output>input cost, linear scaling) - configValidation.test.ts: adds subscriptions describe block — valid empty/single/multi entries, optional note, rejects missing id, bad date format, zero/negative amount, non-string note --- test/main/ipc/configValidation.test.ts | 88 ++++++++++++++++++++++++ test/main/utils/pricingModel.test.ts | 94 ++++++++++++++++++++++++++ 2 files changed, 182 insertions(+) create mode 100644 test/main/utils/pricingModel.test.ts diff --git a/test/main/ipc/configValidation.test.ts b/test/main/ipc/configValidation.test.ts index 4dcb9714..43fcfe97 100644 --- a/test/main/ipc/configValidation.test.ts +++ b/test/main/ipc/configValidation.test.ts @@ -132,4 +132,92 @@ describe('configValidation', () => { }); } }); + + describe('subscriptions section', () => { + const validEntry = { + id: 'entry-1', + date: '2026-03-05', + plan: 'Pro', + amountUsd: 20, + }; + + it('accepts valid subscriptions update with empty entries', () => { + const result = validateConfigUpdatePayload('subscriptions', { entries: [] }); + expect(result.valid).toBe(true); + if (result.valid) { + expect(result.section).toBe('subscriptions'); + expect(result.data).toEqual({ entries: [] }); + } + }); + + it('accepts valid subscriptions update with one entry', () => { + const result = validateConfigUpdatePayload('subscriptions', { entries: [validEntry] }); + expect(result.valid).toBe(true); + if (result.valid) { + expect(result.data.entries).toHaveLength(1); + expect(result.data.entries?.[0]).toMatchObject(validEntry); + } + }); + + it('accepts entry with optional note field', () => { + const entryWithNote = { ...validEntry, note: 'annual plan charge' }; + const result = validateConfigUpdatePayload('subscriptions', { entries: [entryWithNote] }); + expect(result.valid).toBe(true); + }); + + it('accepts multiple entries in the same month', () => { + const entries = [ + validEntry, + { id: 'entry-2', date: '2026-03-27', plan: 'Max', amountUsd: 100 }, + ]; + const result = validateConfigUpdatePayload('subscriptions', { entries }); + expect(result.valid).toBe(true); + if (result.valid) { + expect(result.data.entries).toHaveLength(2); + } + }); + + it('rejects subscriptions update that is not an object', () => { + const result = validateConfigUpdatePayload('subscriptions', 'bad'); + expect(result.valid).toBe(false); + }); + + it('rejects subscriptions update without entries field', () => { + const result = validateConfigUpdatePayload('subscriptions', { something: [] }); + expect(result.valid).toBe(false); + if (!result.valid) { + expect(result.error).toContain('entries'); + } + }); + + it('rejects entry with missing id', () => { + const bad = { date: '2026-03-05', plan: 'Pro', amountUsd: 20 }; + const result = validateConfigUpdatePayload('subscriptions', { entries: [bad] }); + expect(result.valid).toBe(false); + }); + + it('rejects entry with invalid date format', () => { + const bad = { ...validEntry, date: '03-05-2026' }; + const result = validateConfigUpdatePayload('subscriptions', { entries: [bad] }); + expect(result.valid).toBe(false); + }); + + it('rejects entry with zero amount', () => { + const bad = { ...validEntry, amountUsd: 0 }; + const result = validateConfigUpdatePayload('subscriptions', { entries: [bad] }); + expect(result.valid).toBe(false); + }); + + it('rejects entry with negative amount', () => { + const bad = { ...validEntry, amountUsd: -5 }; + const result = validateConfigUpdatePayload('subscriptions', { entries: [bad] }); + expect(result.valid).toBe(false); + }); + + it('rejects entry with non-string note', () => { + const bad = { ...validEntry, note: 123 }; + const result = validateConfigUpdatePayload('subscriptions', { entries: [bad] }); + expect(result.valid).toBe(false); + }); + }); }); diff --git a/test/main/utils/pricingModel.test.ts b/test/main/utils/pricingModel.test.ts new file mode 100644 index 00000000..11c03b76 --- /dev/null +++ b/test/main/utils/pricingModel.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from 'vitest'; + +import { + calculateTokenCost, + getPricingForModel, +} from '../../../src/main/utils/pricingModel'; + +describe('pricingModel', () => { + describe('getPricingForModel', () => { + it('returns pricing for claude-sonnet-4-5', () => { + const pricing = getPricingForModel('claude-sonnet-4-5'); + expect(pricing.inputPerMillion).toBeGreaterThan(0); + expect(pricing.outputPerMillion).toBeGreaterThan(0); + }); + + it('returns pricing for claude-haiku-4-5', () => { + const pricing = getPricingForModel('claude-haiku-4-5-20251001'); + expect(pricing.inputPerMillion).toBeGreaterThan(0); + expect(pricing.outputPerMillion).toBeGreaterThan(0); + }); + + it('returns pricing for claude-opus-4 prefix', () => { + const pricing = getPricingForModel('claude-opus-4-5'); + expect(pricing.inputPerMillion).toBeGreaterThan(0); + expect(pricing.outputPerMillion).toBeGreaterThan(0); + }); + + it('matches sonnet family pricing for versioned model names', () => { + const p1 = getPricingForModel('claude-sonnet-4-5'); + const p2 = getPricingForModel('claude-sonnet-4-5-20251022'); + expect(p1).toEqual(p2); + }); + + it('returns fallback pricing for completely unknown model', () => { + const pricing = getPricingForModel('unknown-model-xyz'); + expect(pricing.inputPerMillion).toBeGreaterThan(0); + expect(pricing.outputPerMillion).toBeGreaterThan(0); + }); + + it('opus costs more than haiku per token', () => { + const opus = getPricingForModel('claude-opus-4-5'); + const haiku = getPricingForModel('claude-haiku-4-5'); + expect(opus.outputPerMillion).toBeGreaterThan(haiku.outputPerMillion); + }); + }); + + describe('calculateTokenCost', () => { + it('returns 0 for zero tokens', () => { + const cost = calculateTokenCost('claude-sonnet-4-5', 0, 0); + expect(cost).toBe(0); + }); + + it('calculates cost for input tokens only', () => { + const pricing = getPricingForModel('claude-sonnet-4-5'); + const cost = calculateTokenCost('claude-sonnet-4-5', 1_000_000, 0); + expect(cost).toBeCloseTo(pricing.inputPerMillion, 6); + }); + + it('calculates cost for output tokens only', () => { + const pricing = getPricingForModel('claude-sonnet-4-5'); + const cost = calculateTokenCost('claude-sonnet-4-5', 0, 1_000_000); + expect(cost).toBeCloseTo(pricing.outputPerMillion, 6); + }); + + it('includes cache read and cache creation costs', () => { + const pricingWithCache = getPricingForModel('claude-sonnet-4-5'); + const withoutCache = calculateTokenCost('claude-sonnet-4-5', 100_000, 10_000); + const withCache = calculateTokenCost( + 'claude-sonnet-4-5', + 100_000, + 10_000, + 50_000, + 25_000 + ); + if (pricingWithCache.cacheReadPerMillion > 0 || pricingWithCache.cacheWritePerMillion > 0) { + expect(withCache).toBeGreaterThan(withoutCache); + } else { + expect(withCache).toBe(withoutCache); + } + }); + + it('output costs more than same-count input tokens', () => { + const inputCost = calculateTokenCost('claude-sonnet-4-5', 1_000_000, 0); + const outputCost = calculateTokenCost('claude-sonnet-4-5', 0, 1_000_000); + expect(outputCost).toBeGreaterThan(inputCost); + }); + + it('scales linearly with token count', () => { + const cost1M = calculateTokenCost('claude-sonnet-4-5', 1_000_000, 0); + const cost2M = calculateTokenCost('claude-sonnet-4-5', 2_000_000, 0); + expect(cost2M).toBeCloseTo(cost1M * 2, 6); + }); + }); +}); From c75edf43d3523390fd15e3f836c271f0d1561623 Mon Sep 17 00:00:00 2001 From: Pawel Novak Date: Fri, 27 Mar 2026 23:11:00 +0300 Subject: [PATCH 5/6] fix: resolve all lint errors ahead of PR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - pricingModel.ts: Array → T[] (array-type rule) - notifications.ts: Array → T[] (array-type rule) - configValidation.ts: remove unnecessary type assertion on amountUsd - sessions.ts: fix import sort order, merge duplicate @shared/types imports - jsonl.ts: move ./pricingModel import into relative-imports group - DashboardView.tsx: fix import sort (logger const was between imports) - RoiBlock.tsx: use @renderer/types/data for AppConfig (matches store), remove unnecessary as-cast on s.appConfig - SettingsView.tsx: remove unused AppConfig import - httpClient.ts: import UsageStats at top, remove inline import() type - SubscriptionsSection.tsx: remove unused api/useStore imports, replace Math.random() with crypto.randomUUID(), wrap entries in useMemo to stabilise useCallback deps, remove unused y/m vars, add htmlFor/id pairs on all form labels (a11y), remove ! assertion --- src/main/ipc/configValidation.ts | 2 +- src/main/ipc/sessions.ts | 3 +- src/main/utils/jsonl.ts | 3 +- src/main/utils/pricingModel.ts | 2 +- src/renderer/api/httpClient.ts | 3 +- .../components/dashboard/DashboardView.tsx | 4 +-- .../components/dashboard/RoiBlock.tsx | 4 +-- .../components/settings/SettingsView.tsx | 1 - .../sections/SubscriptionsSection.tsx | 28 +++++++++++-------- src/shared/types/notifications.ts | 4 +-- 10 files changed, 28 insertions(+), 26 deletions(-) diff --git a/src/main/ipc/configValidation.ts b/src/main/ipc/configValidation.ts index e3dfe7ef..f20ed6aa 100644 --- a/src/main/ipc/configValidation.ts +++ b/src/main/ipc/configValidation.ts @@ -439,7 +439,7 @@ function isValidSubscriptionEntry(entry: unknown): boolean { if (typeof entry.id !== 'string' || entry.id.trim().length === 0) return false; if (typeof entry.date !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(entry.date)) return false; if (typeof entry.plan !== 'string' || entry.plan.trim().length === 0) return false; - if (!isFiniteNumber(entry.amountUsd) || (entry.amountUsd as number) <= 0) return false; + if (!isFiniteNumber(entry.amountUsd) || entry.amountUsd <= 0) return false; if (entry.note !== undefined && typeof entry.note !== 'string') return false; return true; } diff --git a/src/main/ipc/sessions.ts b/src/main/ipc/sessions.ts index bdb885b9..a2eaa1bc 100644 --- a/src/main/ipc/sessions.ts +++ b/src/main/ipc/sessions.ts @@ -26,9 +26,8 @@ import { calculateMetrics, parseJsonlFile } from '../utils/jsonl'; import { coercePageLimit, validateProjectId, validateSessionId } from './guards'; -import type { UsageStats } from '@shared/types'; import type { ServiceContextRegistry } from '../services'; -import type { WaterfallData } from '@shared/types'; +import type { UsageStats, WaterfallData } from '@shared/types'; const logger = createLogger('IPC:sessions'); diff --git a/src/main/utils/jsonl.ts b/src/main/utils/jsonl.ts index 6c6220a8..3e9c6135 100644 --- a/src/main/utils/jsonl.ts +++ b/src/main/utils/jsonl.ts @@ -11,8 +11,6 @@ import { isCommandOutputContent, sanitizeDisplayContent } from '@shared/utils/co import { createLogger } from '@shared/utils/logger'; import * as readline from 'readline'; -import { calculateTokenCost } from './pricingModel'; - import { SessionContentFilter } from '../services/discovery/SessionContentFilter'; import { LocalFileSystemProvider } from '../services/infrastructure/LocalFileSystemProvider'; import { @@ -30,6 +28,7 @@ import { } from '../types'; // Import from extracted modules +import { calculateTokenCost } from './pricingModel'; import { extractToolCalls, extractToolResults } from './toolExtraction'; import type { FileSystemProvider } from '../services/infrastructure/FileSystemProvider'; diff --git a/src/main/utils/pricingModel.ts b/src/main/utils/pricingModel.ts index 748a5c00..d18bde5d 100644 --- a/src/main/utils/pricingModel.ts +++ b/src/main/utils/pricingModel.ts @@ -23,7 +23,7 @@ export interface ModelPricing { * Entries are ordered from most-specific to least-specific. * Matching is done via startsWith or includes on the lowercased model string. */ -const PRICING_TABLE: Array<[string, ModelPricing]> = [ +const PRICING_TABLE: [string, ModelPricing][] = [ // ── Claude 4 ────────────────────────────────────────────────────────── [ 'claude-opus-4', diff --git a/src/renderer/api/httpClient.ts b/src/renderer/api/httpClient.ts index 0d4c1bcf..96ee58c9 100644 --- a/src/renderer/api/httpClient.ts +++ b/src/renderer/api/httpClient.ts @@ -38,6 +38,7 @@ import type { SubagentDetail, TriggerTestResult, UpdaterAPI, + UsageStats, WaterfallData, WslClaudeRootCandidate, } from '@shared/types'; @@ -270,7 +271,7 @@ export class HttpAPIClient implements ElectronAPI { getWorktreeSessions = (worktreeId: string): Promise => this.get(`/api/worktrees/${encodeURIComponent(worktreeId)}/sessions`); - getUsageStats = (year: number, month: number): Promise => + getUsageStats = (year: number, month: number): Promise => this.get(`/api/usage-stats?year=${year}&month=${month}`); // --------------------------------------------------------------------------- diff --git a/src/renderer/components/dashboard/DashboardView.tsx b/src/renderer/components/dashboard/DashboardView.tsx index aed27818..0eaa427c 100644 --- a/src/renderer/components/dashboard/DashboardView.tsx +++ b/src/renderer/components/dashboard/DashboardView.tsx @@ -13,11 +13,11 @@ import { api } from '@renderer/api'; import { useStore } from '@renderer/store'; import { formatShortcut } from '@renderer/utils/stringUtils'; import { createLogger } from '@shared/utils/logger'; +import { formatDistanceToNow } from 'date-fns'; +import { Command, FolderGit2, FolderOpen, GitBranch, Search, Settings } from 'lucide-react'; import { useShallow } from 'zustand/react/shallow'; const logger = createLogger('Component:DashboardView'); -import { formatDistanceToNow } from 'date-fns'; -import { Command, FolderGit2, FolderOpen, GitBranch, Search, Settings } from 'lucide-react'; import type { RepositoryGroup } from '@renderer/types/data'; diff --git a/src/renderer/components/dashboard/RoiBlock.tsx b/src/renderer/components/dashboard/RoiBlock.tsx index 706324fd..ab804a5f 100644 --- a/src/renderer/components/dashboard/RoiBlock.tsx +++ b/src/renderer/components/dashboard/RoiBlock.tsx @@ -19,8 +19,8 @@ import { createLogger } from '@shared/utils/logger'; import { ArrowRight, CreditCard, Loader2, TrendingDown, TrendingUp, Zap } from 'lucide-react'; import { useShallow } from 'zustand/react/shallow'; +import type { AppConfig } from '@renderer/types/data'; import type { UsageStats } from '@shared/types'; -import type { AppConfig } from '@shared/types/notifications'; const logger = createLogger('Component:RoiBlock'); @@ -109,7 +109,7 @@ export const RoiBlock = (): React.JSX.Element | null => { const { year, month } = currentYearMonth(); const { appConfig, openSettingsTab } = useStore( useShallow((s) => ({ - appConfig: s.appConfig as AppConfig | null, + appConfig: s.appConfig, openSettingsTab: s.openSettingsTab, })) ); diff --git a/src/renderer/components/settings/SettingsView.tsx b/src/renderer/components/settings/SettingsView.tsx index 8eaa8a0f..7cad37e2 100644 --- a/src/renderer/components/settings/SettingsView.tsx +++ b/src/renderer/components/settings/SettingsView.tsx @@ -19,7 +19,6 @@ import { } from './sections'; import { type SettingsSection, SettingsTabs } from './SettingsTabs'; -import type { AppConfig } from '@shared/types/notifications'; export const SettingsView = (): React.JSX.Element | null => { const [activeSection, setActiveSection] = useState('general'); diff --git a/src/renderer/components/settings/sections/SubscriptionsSection.tsx b/src/renderer/components/settings/sections/SubscriptionsSection.tsx index 60ffce46..e8572fe2 100644 --- a/src/renderer/components/settings/sections/SubscriptionsSection.tsx +++ b/src/renderer/components/settings/sections/SubscriptionsSection.tsx @@ -5,10 +5,8 @@ * The data is stored in the local config file and used by the Dashboard ROI block. */ -import { useCallback, useState } from 'react'; +import { useCallback, useMemo, useState } from 'react'; -import { api } from '@renderer/api'; -import { useStore } from '@renderer/store'; import { createLogger } from '@shared/utils/logger'; import { Calendar, DollarSign, Plus, Trash2 } from 'lucide-react'; @@ -58,7 +56,7 @@ function formatDate(iso: string): string { } function newId(): string { - return `${Date.now()}-${Math.random().toString(36).slice(2, 7)}`; + return crypto.randomUUID(); } // ───────────────────────────────────────────────────────────────────────────── @@ -70,7 +68,10 @@ export const SubscriptionsSection = ({ saving, onSave, }: SubscriptionsSectionProps): React.JSX.Element => { - const entries: SubscriptionEntry[] = config?.subscriptions?.entries ?? []; + const entries: SubscriptionEntry[] = useMemo( + () => config?.subscriptions?.entries ?? [], + [config?.subscriptions?.entries] + ); const [showForm, setShowForm] = useState(false); const [deleting, setDeleting] = useState(null); @@ -96,7 +97,7 @@ export const SubscriptionsSection = ({ setAmountError(null); const next: SubscriptionEntry[] = [ ...entries, - { id: newId(), date: form.date, plan: effectivePlan, amountUsd: amountNum!, note: form.note.trim() || undefined }, + { id: newId(), date: form.date, plan: effectivePlan, amountUsd: amountNum, note: form.note.trim() || undefined }, ].sort((a, b) => a.date.localeCompare(b.date)); await onSave(next); setAmountError(null); @@ -148,7 +149,6 @@ export const SubscriptionsSection = ({ )} {monthKeys.map((monthKey) => { - const [y, m] = monthKey.split('-'); const monthLabel = new Date(`${monthKey}-01T00:00:00`).toLocaleDateString('en-US', { year: 'numeric', month: 'long', @@ -220,8 +220,9 @@ export const SubscriptionsSection = ({
{/* Date */}
- + setForm((f) => ({ ...f, date: e.target.value }))} @@ -231,10 +232,11 @@ export const SubscriptionsSection = ({ {/* Amount */}
- +
$ - +

Plan

{PLAN_OPTIONS.map((p) => (