From a9f4b7822c715a5353f9c0bbd54ec07df355af28 Mon Sep 17 00:00:00 2001 From: Devanshu Rajesh Chicholikar Date: Thu, 30 Apr 2026 22:31:12 -0400 Subject: [PATCH 1/2] feat(spend): enterprise month-end trajectory + per-conversation attribution [GET-22] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Credit-tier (Enterprise) card now projects month-end spend and surfaces the top 3 most expensive conversations of the current month. Session and unsupported variants are unchanged. Pure agent in lib/spend-trajectory.ts: projectMonthEnd adds a 7-day rolling burn rate to the exact currentUsedCents from the Anthropic endpoint, with a distinct-day floor of 7 and three confidence tiers (high/medium/low) based on distinct cost-bearing days and CV of daily totals. aggregateByConversation groups deltas by conversationId and ranks descending by total cost. Shared formatter formatCurrencyCents extracted into lib/format.ts so the agent and the side-panel card share one cached Intl.NumberFormat per currency. lib/usage-budget.ts now routes through the shared helper. Side-panel hook loadSpendTrajectory wired into init, tab activation, tab URL change, org switch, logout, budget-write, and delta-write paths with the same request-id stale-check pattern as the weekly-cap ETA loader. UsageBudgetCard credit variant adds a trajectory line under the spend status with confidence-tiered copy and a "need more data" placeholder, and an expandable
block listing top spenders with subjects joined from the History list (no extra storage reads). Tests: 28 cases for the agent (boundaries, post-reset, clock-skew safety, reconciliation invariant against ConversationRecord.turns, sub-cent precision, future-timestamp filter), 8 cases for the credit-tier card render path. 1759 → 1795 tests on main. --- entrypoints/sidepanel/App.tsx | 11 +- .../sidepanel/components/UsageBudgetCard.tsx | 137 ++++++- entrypoints/sidepanel/dashboard.css | 93 +++++ .../sidepanel/hooks/useDashboardData.ts | 112 +++++- lib/format.ts | 40 ++ lib/spend-trajectory.ts | 247 ++++++++++++ lib/usage-budget.ts | 43 +- tests/unit/spend-trajectory.test.ts | 367 ++++++++++++++++++ tests/unit/usage-budget-card.test.tsx | 189 +++++++++ 9 files changed, 1189 insertions(+), 50 deletions(-) create mode 100644 lib/spend-trajectory.ts create mode 100644 tests/unit/spend-trajectory.test.ts diff --git a/entrypoints/sidepanel/App.tsx b/entrypoints/sidepanel/App.tsx index 149ef4d..34de027 100644 --- a/entrypoints/sidepanel/App.tsx +++ b/entrypoints/sidepanel/App.tsx @@ -35,6 +35,8 @@ export default function App() { budget, isClaudeTab, weeklyEta, + spendTrajectory, + topSpendConversations, loading, } = useDashboardData(); @@ -79,7 +81,14 @@ export default function App() { tier variant (session/credit/unsupported) and chooses the empty-state copy from `isClaudeTab`. */} - + diff --git a/entrypoints/sidepanel/components/UsageBudgetCard.tsx b/entrypoints/sidepanel/components/UsageBudgetCard.tsx index 4a2f53d..b587f01 100644 --- a/entrypoints/sidepanel/components/UsageBudgetCard.tsx +++ b/entrypoints/sidepanel/components/UsageBudgetCard.tsx @@ -22,6 +22,9 @@ import React from 'react'; import type { UsageBudgetResult, UsageBudgetSession, UsageBudgetCredit, BudgetZone } from '../../../lib/message-types'; import { classifyZone } from '../../../lib/usage-budget'; import { formatEtaLabel, type WeeklyEta } from '../../../lib/weekly-cap-eta'; +import { formatCurrencyCents } from '../../../lib/format'; +import type { SpendTrajectory, ConversationSpend } from '../../../lib/spend-trajectory'; +import type { ConversationRecord } from '../../../lib/conversation-store'; interface Props { budget: UsageBudgetResult | null; @@ -37,6 +40,23 @@ interface Props { * Session tier only; credit/unsupported cards never receive this. */ weeklyEta?: WeeklyEta | null; + /** + * Month-end spend projection. Credit-tier only; null on session/unsupported + * tiers and until at least 7 distinct cost-bearing days have accumulated + * in the current month. + */ + spendTrajectory?: SpendTrajectory | null; + /** + * Top conversations of the current month, ranked descending by total cost. + * Credit-tier only; empty array everywhere else. + */ + topSpendConversations?: ConversationSpend[]; + /** + * Recent conversations from the History panel. Used to join conversationId + * from `topSpendConversations` to a human-readable subject without an + * additional storage read. + */ + conversations?: ConversationRecord[]; } // Zone-to-label mapping for the dot and fills. Mirrors the health dot @@ -50,7 +70,14 @@ const ZONE_LABELS: Record = { critical: 'Critical', }; -export default function UsageBudgetCard({ budget, isClaudeTab, weeklyEta }: Props) { +export default function UsageBudgetCard({ + budget, + isClaudeTab, + weeklyEta, + spendTrajectory, + topSpendConversations, + conversations, +}: Props) { // No data at all + the user is on a tab where we cannot fetch any. // Surface the obvious next action rather than a silent empty card. if (!budget && !isClaudeTab) { @@ -77,7 +104,12 @@ export default function UsageBudgetCard({ budget, isClaudeTab, weeklyEta }: Prop // `budget.kind` lets TypeScript narrow into the right field set. return budget.kind === 'session' ? - : ; + : ; } // ── Session variant (Pro / Personal / Max) ─────────────────────────────────── @@ -149,8 +181,18 @@ function SessionBudget({ budget, eta }: { budget: UsageBudgetSession; eta: Weekl // ── Credit variant (Enterprise) ────────────────────────────────────────────── -function CreditBudget({ budget }: { budget: UsageBudgetCredit }) { - const { utilizationPct, zone, statusLabel, resetLabel } = budget; +function CreditBudget({ + budget, + trajectory, + topSpenders, + conversations, +}: { + budget: UsageBudgetCredit; + trajectory: SpendTrajectory | null; + topSpenders: ConversationSpend[]; + conversations: ConversationRecord[]; +}) { + const { utilizationPct, zone, statusLabel, resetLabel, currency, monthlyLimitCents } = budget; const safePct = Math.min(Math.max(utilizationPct, 0), 100); return ( @@ -167,6 +209,13 @@ function CreditBudget({ budget }: { budget: UsageBudgetCredit }) { {/* Primary status line: "$304.91 of $500.00 spent" */}

{statusLabel}

+ {/* Trajectory line: confidence-tiered projection, or a "need more data" + placeholder when fewer than 7 cost-bearing days have accumulated this + month. Hidden in the empty-state when there is nothing to say. */} +

+ {formatTrajectoryLine(trajectory, currency, monthlyLimitCents)} +

+ {/* Single monthly spend bar */}
Monthly @@ -183,6 +232,50 @@ function CreditBudget({ budget }: { budget: UsageBudgetCredit }) {
{resetLabel}
+ + {/* Top spenders: native
for native expand/collapse without + extra state. Hidden when there is nothing to rank yet. */} + {topSpenders.length > 0 && ( +
+ + Top conversations this month + +
+ {topSpenders.map((spender) => ( + + ))} +
+
+ )} +
+ ); +} + +function SpenderRow({ + spender, + conversations, + currency, +}: { + spender: ConversationSpend; + conversations: ConversationRecord[]; + currency: string; +}) { + const match = conversations.find((c) => c.id === spender.conversationId); + const subject = match?.dna?.subject || 'Untitled conversation'; + return ( +
+ {subject} + + {formatCurrencyCents(spender.totalCostCents, currency)} + + + {spender.turnCount} turn{spender.turnCount === 1 ? '' : 's'} +
); } @@ -212,3 +305,39 @@ function formatEtaLine(eta: WeeklyEta): string { return `Estimating: cap by ${label}. Need more data for confidence.`; } } + +/** + * Build the credit-tier trajectory line. Copy varies by confidence to set the + * user's expectation; a null trajectory becomes the "need more data" placeholder + * so the layout never collapses while the agent waits for enough samples. + */ +function formatTrajectoryLine( + trajectory: SpendTrajectory | null, + currency: string, + monthlyLimitCents: number, +): string { + if (!trajectory) { + return 'Need 7+ days of usage before we can project month-end.'; + } + const projected = formatCurrencyCents(trajectory.projectedSpentCents, currency); + const limit = formatCurrencyCents(monthlyLimitCents, currency); + const monthEnd = formatMonthEndLabel(trajectory.daysRemaining); + switch (trajectory.confidence) { + case 'high': + return `On track for ${projected} of ${limit} by ${monthEnd}.`; + case 'medium': + return `Estimated month-end: ${projected} of ${limit}. Estimate firms up over the next week.`; + case 'low': + return `Estimating: ${projected} of ${limit} by ${monthEnd}. Need more data for confidence.`; + } +} + +/** "Apr 30" / "May 1": the user-facing label for the projection target. */ +function formatMonthEndLabel(daysRemaining: number): string { + const target = new Date(); + target.setDate(target.getDate() + daysRemaining); + return new Intl.DateTimeFormat(undefined, { + month: 'short', + day: 'numeric', + }).format(target); +} diff --git a/entrypoints/sidepanel/dashboard.css b/entrypoints/sidepanel/dashboard.css index e6167a9..42dd065 100644 --- a/entrypoints/sidepanel/dashboard.css +++ b/entrypoints/sidepanel/dashboard.css @@ -733,6 +733,89 @@ body { color: var(--lco-text-muted); } +/* Trajectory line on the credit card: "On track for $312 of $500 by Apr 30." + Sits directly under .lco-dash-budget-status, smaller and muted to keep + the primary status line as the hero. The "need more data" placeholder uses + the same class — italics give it a softer, less load-bearing voice. */ +.lco-dash-budget-trajectory { + margin: -8px 0 12px; + font-size: 12px; + color: var(--lco-text-muted); + line-height: 1.4; + font-variant-numeric: tabular-nums; +} + +/* Top-spenders expandable list. Mirrors the History row pattern visually + without inheriting its hover/animation behavior — these rows are summary + data, not navigable items. */ +.lco-dash-budget-spenders { + margin-top: 10px; + border-top: 1px solid var(--lco-border); + padding-top: 8px; +} + +.lco-dash-budget-spenders-summary { + cursor: pointer; + font-size: 11px; + color: var(--lco-text-muted); + list-style: none; + user-select: none; + padding: 2px 0; +} + +.lco-dash-budget-spenders-summary::-webkit-details-marker { + display: none; +} + +.lco-dash-budget-spenders-summary::before { + content: '▸'; + display: inline-block; + margin-right: 6px; + transition: transform 0.15s ease; +} + +.lco-dash-budget-spenders[open] .lco-dash-budget-spenders-summary::before { + transform: rotate(90deg); +} + +.lco-dash-budget-spenders-list { + display: flex; + flex-direction: column; + gap: 4px; + margin-top: 6px; +} + +.lco-dash-budget-spender { + display: grid; + grid-template-columns: 1fr auto auto; + align-items: baseline; + gap: 8px; + padding: 6px 8px; + border-radius: var(--lco-radius); + background: var(--lco-bg-card); + border: 1px solid var(--lco-border); + font-size: 12px; +} + +.lco-dash-budget-spender-subject { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--lco-text); +} + +.lco-dash-budget-spender-cost { + color: var(--lco-text); + font-family: var(--lco-font-mono); + font-variant-numeric: tabular-nums; + font-size: 11px; +} + +.lco-dash-budget-spender-turns { + color: var(--lco-text-muted); + font-size: 11px; +} + /* ── Conversation list ──────────────────────────────────────────────────────── */ .lco-dash-convlist { @@ -1212,3 +1295,13 @@ body { :root[data-density='compact'] .lco-dash-budget-resets { margin-top: 4px; } +:root[data-density='compact'] .lco-dash-budget-trajectory { + margin: -6px 0 8px; +} +:root[data-density='compact'] .lco-dash-budget-spenders { + margin-top: 6px; + padding-top: 6px; +} +:root[data-density='compact'] .lco-dash-budget-spender { + padding: 4px 6px; +} diff --git a/entrypoints/sidepanel/hooks/useDashboardData.ts b/entrypoints/sidepanel/hooks/useDashboardData.ts index ac87162..d731e85 100644 --- a/entrypoints/sidepanel/hooks/useDashboardData.ts +++ b/entrypoints/sidepanel/hooks/useDashboardData.ts @@ -31,6 +31,13 @@ import { computeHealthScore, computeGrowthRate, type HealthScore } from '../../. import { computeUsageBudget } from '../../../lib/usage-budget'; import { computeTokenEconomics, type TokenEconomicsResult } from '../../../lib/token-economics'; import { computeWeeklyEta, type WeeklyEta } from '../../../lib/weekly-cap-eta'; +import { + projectMonthEnd, + aggregateByConversation, + startOfMonth, + type SpendTrajectory, + type ConversationSpend, +} from '../../../lib/spend-trajectory'; import type { UsageBudgetResult } from '../../../lib/message-types'; // ── Constants ───────────────────────────────────────────────────────────────── @@ -42,6 +49,9 @@ const CLAUDE_DOMAIN = 'claude.ai'; // Maximum number of past conversations to load into the History panel. const CONVERSATION_LIMIT = 20; +// Number of top spenders to surface on the credit-tier card. +const TOP_SPENDERS_LIMIT = 3; + // ── Tab URL gate ────────────────────────────────────────────────────────────── /** @@ -111,6 +121,19 @@ export interface DashboardData { * Session tier only (Pro/Max). Always null on Enterprise and unsupported tiers. */ weeklyEta: WeeklyEta | null; + /** + * Projected month-end spend for credit-tier (Enterprise) accounts. + * Null until at least MIN_DISTINCT_DAYS_FOR_PROJECTION distinct cost-bearing + * days have accumulated in the current month, or when the current tier is + * not 'credit'. Drives the "On track for $X of $Y by …" line on the card. + */ + spendTrajectory: SpendTrajectory | null; + /** + * Top conversations of the current month, ranked descending by total cost. + * Empty array when no credit-tier deltas exist yet, or on session/unsupported + * tiers. Capped at TOP_SPENDERS_LIMIT entries. + */ + topSpendConversations: ConversationSpend[]; loading: boolean; } @@ -125,6 +148,8 @@ export function useDashboardData(): DashboardData { const [isClaudeTab, setIsClaudeTab] = useState(false); const [tokenEconomics, setTokenEconomics] = useState(null); const [weeklyEta, setWeeklyEta] = useState(null); + const [spendTrajectory, setSpendTrajectory] = useState(null); + const [topSpendConversations, setTopSpendConversations] = useState([]); const [loading, setLoading] = useState(true); // Track current tab ID so we know which activeConv_ key to watch. @@ -140,6 +165,8 @@ export function useDashboardData(): DashboardData { // response from overwriting a later explicit clear (e.g., same org, two rapid // tab switches where orgId ends up equal but requests are from different cycles). const weeklyEtaRequestIdRef = useRef(0); + // Same pattern for the credit-tier spend trajectory loader. + const spendTrajectoryRequestIdRef = useRef(0); // Sync helper: always update both the React state and the ref together so // they never drift. All code that changes isClaudeTab must use this. @@ -230,6 +257,48 @@ export function useDashboardData(): DashboardData { } }, []); + const loadSpendTrajectory = useCallback(async () => { + const requestId = ++spendTrajectoryRequestIdRef.current; + try { + const orgId = orgIdRef.current; + if (!orgId) { + setSpendTrajectory(null); + setTopSpendConversations([]); + return; + } + + // Read the typed usage record so we can branch on tier without + // depending on the React-state `budget` (which closures here + // would capture stale). Trajectory only renders for credit tier; + // anything else clears both fields. + const limits = await getUsageLimits(orgId); + if (orgIdRef.current !== orgId || spendTrajectoryRequestIdRef.current !== requestId) return; + if (!limits || limits.kind !== 'credit') { + setSpendTrajectory(null); + setTopSpendConversations([]); + return; + } + + const deltas = await getUsageDeltas(orgId); + if (orgIdRef.current !== orgId || spendTrajectoryRequestIdRef.current !== requestId) return; + + const now = Date.now(); + const trajectory = projectMonthEnd( + deltas, + now, + limits.monthlyLimitCents, + limits.usedCents, + ); + const topSpenders = aggregateByConversation(deltas, startOfMonth(now)) + .slice(0, TOP_SPENDERS_LIMIT); + + setSpendTrajectory(trajectory); + setTopSpendConversations(topSpenders); + } catch { + // Non-critical: trajectory and top-spenders simply stay hidden. + } + }, []); + const loadActiveConversation = useCallback(async (tabId: number) => { try { // Read the active conversation and org ID for this tab from session storage. @@ -259,6 +328,9 @@ export function useDashboardData(): DashboardData { setBudget(null); weeklyEtaRequestIdRef.current++; setWeeklyEta(null); + spendTrajectoryRequestIdRef.current++; + setSpendTrajectory(null); + setTopSpendConversations([]); setTokenEconomics(null); } return; @@ -290,13 +362,14 @@ export function useDashboardData(): DashboardData { loadToday(); loadBudget(); loadWeeklyEta(); + loadSpendTrajectory(); loadTokenEconomics(); } } catch { setActiveConv(null); setActiveHealth(null); } - }, [loadConversations, loadToday, loadBudget, loadWeeklyEta, loadTokenEconomics]); + }, [loadConversations, loadToday, loadBudget, loadWeeklyEta, loadSpendTrajectory, loadTokenEconomics]); // ── Initial load ────────────────────────────────────────────────────────── @@ -326,6 +399,7 @@ export function useDashboardData(): DashboardData { if (onClaude) { await loadBudget(); loadWeeklyEta(); + loadSpendTrajectory(); } // Token economics is non-blocking: fire after the main data loads. // It requires enough delta records to be meaningful (MIN_SAMPLES per model), @@ -335,7 +409,7 @@ export function useDashboardData(): DashboardData { } init(); - }, [loadToday, loadConversations, loadActiveConversation, loadBudget, loadWeeklyEta, loadTokenEconomics]); + }, [loadToday, loadConversations, loadActiveConversation, loadBudget, loadWeeklyEta, loadSpendTrajectory, loadTokenEconomics]); // ── Live subscriptions ──────────────────────────────────────────────────── @@ -379,6 +453,9 @@ export function useDashboardData(): DashboardData { // this check the budget card would silently re-populate on a non-Claude tab. if (hasBudgetChange && isClaudeTabRef.current) { loadBudget(); + // Trajectory depends on the current usedCents/monthlyLimitCents + // from the same usageLimits record, so refresh it together. + loadSpendTrajectory(); } const hasSnapshotChange = keys.some(k => k.startsWith('usageBudgetSnapshots:')); if (hasSnapshotChange && isClaudeTabRef.current) { @@ -386,6 +463,11 @@ export function useDashboardData(): DashboardData { } if (hasDeltaChange) { loadTokenEconomics(); + // Each new turn moves the burn rate and the per-conversation + // ranking; refresh the trajectory so the credit card stays live. + if (isClaudeTabRef.current) { + loadSpendTrajectory(); + } } } @@ -420,11 +502,14 @@ export function useDashboardData(): DashboardData { if (onClaude) { loadBudget(); loadWeeklyEta(); + loadSpendTrajectory(); } else { // Explicitly clear budget -- do not show stale data from the previous // Claude tab while the user is on Gmail, GitHub, etc. setBudget(null); setWeeklyEta(null); + setSpendTrajectory(null); + setTopSpendConversations([]); } } @@ -449,10 +534,13 @@ export function useDashboardData(): DashboardData { // Navigated back to claude.ai: reload live data. loadBudget(); loadWeeklyEta(); + loadSpendTrajectory(); } else { // Navigated away: clear live data immediately. setBudget(null); setWeeklyEta(null); + setSpendTrajectory(null); + setTopSpendConversations([]); } } @@ -466,6 +554,8 @@ export function useDashboardData(): DashboardData { applyIsClaudeTab(false); setBudget(null); setWeeklyEta(null); + setSpendTrajectory(null); + setTopSpendConversations([]); } chrome.storage.onChanged.addListener(onStorageChanged); @@ -479,7 +569,19 @@ export function useDashboardData(): DashboardData { chrome.tabs.onUpdated.removeListener(onTabUpdated); chrome.tabs.onRemoved.removeListener(onTabRemoved); }; - }, [loadToday, loadConversations, loadActiveConversation, loadBudget, loadWeeklyEta, loadTokenEconomics]); - - return { today, activeConv, activeHealth, conversations, budget, isClaudeTab, tokenEconomics, weeklyEta, loading }; + }, [loadToday, loadConversations, loadActiveConversation, loadBudget, loadWeeklyEta, loadSpendTrajectory, loadTokenEconomics]); + + return { + today, + activeConv, + activeHealth, + conversations, + budget, + isClaudeTab, + tokenEconomics, + weeklyEta, + spendTrajectory, + topSpendConversations, + loading, + }; } diff --git a/lib/format.ts b/lib/format.ts index e42af02..21fdb6b 100644 --- a/lib/format.ts +++ b/lib/format.ts @@ -67,6 +67,46 @@ export function formatApiRateCost( return `≈${base}`; // session, unsupported, or unknown } +// `Intl.NumberFormat` constructors are not free; on a hot card render we would +// build several per call (status line, projection line, each spender row). +// Cache by currency code: the same code is reused across renders for one +// account, and the cache is bounded by the number of currencies the endpoint +// actually returns (one per account). `null` marks codes Intl rejected so we +// do not retry the constructor. +const currencyFormatterCache = new Map(); + +function currencyFormatter(currency: string): Intl.NumberFormat | null { + if (currencyFormatterCache.has(currency)) { + return currencyFormatterCache.get(currency) ?? null; + } + try { + const fmt = new Intl.NumberFormat(undefined, { + style: 'currency', + currency, + currencyDisplay: 'symbol', + }); + currencyFormatterCache.set(currency, fmt); + return fmt; + } catch { + currencyFormatterCache.set(currency, null); + return null; + } +} + +/** + * Format an integer cent amount in the given currency. + * 30491 USD → "$304.91". Falls back to a portable "USD 304.91" form for any + * currency code Intl cannot resolve, so callers never render NaN or an empty + * string. Single source of truth for cent-to-currency formatting; both the + * Usage Budget agent and the side-panel card route through here so display + * stays consistent. + */ +export function formatCurrencyCents(cents: number, currency: string): string { + const amount = cents / 100; + const fmt = currencyFormatter(currency); + return fmt ? fmt.format(amount) : `${currency} ${amount.toFixed(2)}`; +} + /** * Format a model identifier for human display. * "claude-sonnet-4-6" -> "Sonnet 4.6" diff --git a/lib/spend-trajectory.ts b/lib/spend-trajectory.ts new file mode 100644 index 0000000..58c4021 --- /dev/null +++ b/lib/spend-trajectory.ts @@ -0,0 +1,247 @@ +// lib/spend-trajectory.ts +// Spend Trajectory Agent — projects month-end spend for credit-tier (Enterprise) +// accounts and ranks the most expensive conversations of the current month. +// +// Pure functions only. No DOM refs, no chrome.* calls, no side effects. +// Callers: useDashboardData.ts (side panel, credit-tier render path only). +// Storage owned by: conversation-store.ts (appendUsageDelta etc). +// +// Why this exists: +// Anthropic restructured Enterprise pricing on 2026-04-16: bundled tokens +// were removed from the seat fee and usage now bills at API rates separately. +// The Admin API does not expose per-conversation breakdowns and requires an +// admin key individuals may not have. Saar projects month-end spend from the +// locally-tracked delta log instead, and ranks conversations by cost. +// +// Two exports: +// projectMonthEnd — additive projection on top of the exact +// currentUsedCents from the Anthropic endpoint. +// aggregateByConversation — pure grouping; ranked descending by cost. + +import type { UsageDelta } from './conversation-store'; + +// ── Types ───────────────────────────────────────────────────────────────────── + +export interface SpendTrajectory { + /** Projected month-end spend in cents (currentUsedCents + extrapolated remainder). */ + projectedSpentCents: number; + /** projectedSpentCents as a percentage of monthlyLimitCents. May exceed 100. */ + projectedUtilizationPct: number; + /** Calendar days from `capturedAt` to the first of next month. Floored at 0. */ + daysRemaining: number; + /** + * Signal-quality label, drives copy variation in the card: + * high — n ≥ 14 distinct cost-bearing days AND CV < 0.3 + * medium — n ≥ 10 distinct cost-bearing days + * low — n ≥ 7 distinct cost-bearing days + * Below 7 distinct days projectMonthEnd returns null entirely + * (the card renders a "need more data" placeholder). + */ + confidence: 'low' | 'medium' | 'high'; +} + +export interface ConversationSpend { + conversationId: string; + /** Sum of delta costs for the conversation, rounded to integer cents at the boundary. */ + totalCostCents: number; + /** Count of cost-bearing delta records contributing to totalCostCents. */ + turnCount: number; +} + +// ── Constants ───────────────────────────────────────────────────────────────── + +/** Minimum distinct cost-bearing days in the current month before projecting. */ +export const MIN_DISTINCT_DAYS_FOR_PROJECTION = 7; + +/** Distinct-day thresholds for the confidence classifier. */ +const HIGH_CONF_DISTINCT_DAYS = 14; +const MEDIUM_CONF_DISTINCT_DAYS = 10; + +/** Coefficient-of-variation ceiling for high-confidence promotion. */ +const HIGH_CONF_CV_MAX = 0.3; + +const MS_PER_DAY = 24 * 60 * 60 * 1000; + +// ── Calendar helpers ────────────────────────────────────────────────────────── + +/** + * Unix ms timestamp at the start of the calendar month containing `now`. + * Local timezone, matching the user's perception of the "monthly" reset. + */ +export function startOfMonth(now: number): number { + const d = new Date(now); + return new Date(d.getFullYear(), d.getMonth(), 1, 0, 0, 0, 0).getTime(); +} + +/** + * Unix ms timestamp at the start of the next calendar month after `now`. + * Used as the projection target: Anthropic resets Enterprise credit pools + * on the first of each month. + */ +export function startOfNextMonth(now: number): number { + const d = new Date(now); + return new Date(d.getFullYear(), d.getMonth() + 1, 1, 0, 0, 0, 0).getTime(); +} + +/** + * Calendar days remaining from `now` to the first of next month. + * Counts whole days; a fractional remainder still counts as one day. + * Floored at 0 — never negative even if the clock skews past month boundary. + */ +export function daysUntilNextMonth(now: number): number { + const target = startOfNextMonth(now); + const diffMs = target - now; + if (diffMs <= 0) return 0; + return Math.ceil(diffMs / MS_PER_DAY); +} + +/** YYYY-MM-DD in local time for grouping deltas into distinct calendar days. */ +function dateKey(timestamp: number): string { + const d = new Date(timestamp); + const y = d.getFullYear(); + const m = String(d.getMonth() + 1).padStart(2, '0'); + const day = String(d.getDate()).padStart(2, '0'); + return `${y}-${m}-${day}`; +} + +// ── projectMonthEnd ─────────────────────────────────────────────────────────── + +/** + * Project month-end spend by adding a 7-day rolling burn-rate extrapolation + * to the exact `currentUsedCents` reported by Anthropic. + * + * Why additive on top of the endpoint rather than recomputed from deltas: + * The endpoint figure is exact (matches the user's Settings > Usage page). + * The local delta log only sees turns Saar observed; using it as the base + * would underreport spend for users who chat without the extension active. + * Projection of the remainder uses deltas as a rate proxy only. + * + * Returns null when: + * - fewer than MIN_DISTINCT_DAYS_FOR_PROJECTION distinct cost-bearing days + * in the current calendar month (drives the "need more data" UI) + * + * @param deltas Append-only delta log for the account, oldest first. + * @param capturedAt Unix ms timestamp the projection is anchored to. + * @param monthlyLimitCents Monthly credit limit in integer cents (from endpoint). + * @param currentUsedCents Current month's exact spend in integer cents (from endpoint). + */ +export function projectMonthEnd( + deltas: UsageDelta[], + capturedAt: number, + monthlyLimitCents: number, + currentUsedCents: number, +): SpendTrajectory | null { + if (monthlyLimitCents <= 0) return null; + + const monthStart = startOfMonth(capturedAt); + + // Bucket current-month, cost-bearing deltas by calendar day. + // Sums are kept in dollars (delta.cost units) to preserve sub-cent + // precision; conversion to integer cents happens once at the end. + const dailyTotalsDollars = new Map(); + for (const delta of deltas) { + if (delta.timestamp < monthStart || delta.timestamp > capturedAt) continue; + if (delta.cost === null) continue; + const key = dateKey(delta.timestamp); + dailyTotalsDollars.set(key, (dailyTotalsDollars.get(key) ?? 0) + delta.cost); + } + + const distinctDays = dailyTotalsDollars.size; + if (distinctDays < MIN_DISTINCT_DAYS_FOR_PROJECTION) return null; + + const daysRemaining = daysUntilNextMonth(capturedAt); + + // 7-day rolling burn rate: sum of the last 7 calendar days of deltas, + // window (capturedAt - 7d, capturedAt], divided by 7. Days with zero usage + // in that window count as zero (lowers the mean), which is the honest + // behavior for a user who pauses Saar. The left edge is strict so a + // delta at exactly seven days ago does not double-count into a window + // that already includes its same-day successor. + const sevenDaysAgo = capturedAt - 7 * MS_PER_DAY; + let trailingSevenDayDollars = 0; + for (const delta of deltas) { + if (delta.timestamp <= sevenDaysAgo || delta.timestamp > capturedAt) continue; + if (delta.cost === null) continue; + trailingSevenDayDollars += delta.cost; + } + const dailyBurnDollars = trailingSevenDayDollars / 7; + const dailyBurnCents = dailyBurnDollars * 100; + + const projectedRemainderCents = dailyBurnCents * daysRemaining; + const projectedSpentCents = Math.round(currentUsedCents + projectedRemainderCents); + const projectedUtilizationPct = (projectedSpentCents / monthlyLimitCents) * 100; + + // Confidence: distinct-day count gates the tier; coefficient of variation + // of daily totals decides high vs medium when both day-count thresholds + // are met. CV is std-dev / mean; a tight CV means the daily burns cluster, + // which justifies the firmer "On track for…" copy. + const confidence = classifyConfidence(Array.from(dailyTotalsDollars.values())); + + return { + projectedSpentCents, + projectedUtilizationPct, + daysRemaining, + confidence, + }; +} + +function classifyConfidence(dailyTotals: number[]): 'low' | 'medium' | 'high' { + const n = dailyTotals.length; + if (n >= HIGH_CONF_DISTINCT_DAYS) { + const cv = coefficientOfVariation(dailyTotals); + if (cv < HIGH_CONF_CV_MAX) return 'high'; + return 'medium'; + } + if (n >= MEDIUM_CONF_DISTINCT_DAYS) return 'medium'; + return 'low'; +} + +function coefficientOfVariation(values: number[]): number { + if (values.length === 0) return Infinity; + const mean = values.reduce((a, b) => a + b, 0) / values.length; + if (mean === 0) return Infinity; + const variance = values.reduce((acc, v) => acc + (v - mean) ** 2, 0) / values.length; + return Math.sqrt(variance) / mean; +} + +// ── aggregateByConversation ─────────────────────────────────────────────────── + +/** + * Group cost-bearing deltas by conversation and sort descending by total cost. + * Used by the credit-tier card to surface the most expensive conversations of + * the current month. + * + * @param deltas Append-only delta log for the account. + * @param sinceTimestamp Inclusive lower bound (Unix ms). Pass `startOfMonth(now)` + * to scope to the current calendar month; pass 0 for + * all-time. Deltas with timestamp < sinceTimestamp are + * excluded. + */ +export function aggregateByConversation( + deltas: UsageDelta[], + sinceTimestamp: number, +): ConversationSpend[] { + // Sum costs in dollars to preserve sub-cent precision; round once at output. + const totals = new Map(); + + for (const delta of deltas) { + if (delta.timestamp < sinceTimestamp) continue; + if (delta.cost === null) continue; + const entry = totals.get(delta.conversationId) ?? { dollars: 0, turns: 0 }; + entry.dollars += delta.cost; + entry.turns += 1; + totals.set(delta.conversationId, entry); + } + + const out: ConversationSpend[] = []; + for (const [conversationId, { dollars, turns }] of totals) { + out.push({ + conversationId, + totalCostCents: Math.round(dollars * 100), + turnCount: turns, + }); + } + + out.sort((a, b) => b.totalCostCents - a.totalCostCents); + return out; +} diff --git a/lib/usage-budget.ts b/lib/usage-budget.ts index 98fa0dc..4471f1d 100644 --- a/lib/usage-budget.ts +++ b/lib/usage-budget.ts @@ -29,6 +29,7 @@ import type { UsageBudgetCredit, BudgetZone, } from './message-types'; +import { formatCurrencyCents } from './format'; // ── Zone classification ─────────────────────────────────────────────────────── @@ -115,44 +116,6 @@ function buildSessionStatusLabel(sessionPct: number, sessionMinutes: number, zon // ── Credit-tier helpers ────────────────────────────────────────────────────── -// `Intl.NumberFormat` constructors are not free; on a hot card render we would -// build two per call (used + monthly). Cache by currency code: the same code -// is reused across renders for one account, and the cache is bounded by the -// number of currencies Anthropic actually returns (one per account, ever). -// `null` marks codes Intl rejected so we do not retry the constructor. -const currencyFormatterCache = new Map(); - -function currencyFormatter(currency: string): Intl.NumberFormat | null { - if (currencyFormatterCache.has(currency)) { - return currencyFormatterCache.get(currency) ?? null; - } - try { - const fmt = new Intl.NumberFormat(undefined, { - style: 'currency', - currency, - currencyDisplay: 'symbol', - }); - currencyFormatterCache.set(currency, fmt); - return fmt; - } catch { - // Unknown currency code: cache the failure so we do not retry. - currencyFormatterCache.set(currency, null); - return null; - } -} - -/** - * Format an integer cent amount in the given currency. - * 30491 USD → "$304.91". Falls back to a portable "USD 304.91" form for any - * currency code Intl cannot resolve, so the card never shows NaN or an empty - * string. - */ -function formatCents(cents: number, currency: string): string { - const amount = cents / 100; - const fmt = currencyFormatter(currency); - return fmt ? fmt.format(amount) : `${currency} ${amount.toFixed(2)}`; -} - /** * "Resets May 1": first day of the next calendar month, locale-formatted. * Anthropic resets Enterprise credit pools on the first of the month, so the @@ -210,8 +173,8 @@ export function computeUsageBudget(limits: UsageLimitsData, now: number): UsageB if (limits.kind === 'credit') { const zone = classifyZone(limits.utilizationPct); - const spent = formatCents(limits.usedCents, limits.currency); - const total = formatCents(limits.monthlyLimitCents, limits.currency); + const spent = formatCurrencyCents(limits.usedCents, limits.currency); + const total = formatCurrencyCents(limits.monthlyLimitCents, limits.currency); const result: UsageBudgetCredit = { kind: 'credit', monthlyLimitCents: limits.monthlyLimitCents, diff --git a/tests/unit/spend-trajectory.test.ts b/tests/unit/spend-trajectory.test.ts new file mode 100644 index 0000000..9ec819e --- /dev/null +++ b/tests/unit/spend-trajectory.test.ts @@ -0,0 +1,367 @@ +// tests/unit/spend-trajectory.test.ts +// Unit tests for the Spend Trajectory Agent (lib/spend-trajectory.ts). +// +// Covers GET-22 acceptance criteria: +// - Stable burn → projection with high/medium confidence +// - Variable burn → projection with degraded confidence +// - < MIN_DISTINCT_DAYS_FOR_PROJECTION distinct days → null ("need more data") +// - Post-reset (deltas from previous month excluded) +// - Per-conversation cost reconciles with summed turn costs +// - Top-N ranking is descending by total cost + +import { describe, it, expect } from 'vitest'; +import { + projectMonthEnd, + aggregateByConversation, + startOfMonth, + startOfNextMonth, + daysUntilNextMonth, + MIN_DISTINCT_DAYS_FOR_PROJECTION, + type SpendTrajectory, +} from '../../lib/spend-trajectory'; +import type { UsageDelta, ConversationRecord, TurnRecord } from '../../lib/conversation-store'; + +// ── Time constants ──────────────────────────────────────────────────────────── + +// Mid-month anchor: April 15 2026, 12:00 local time. +// Sits comfortably mid-month so day-counting tests do not bump month boundaries. +const APRIL_15 = new Date(2026, 3, 15, 12, 0, 0, 0).getTime(); +const DAY_MS = 24 * 60 * 60 * 1000; + +// ── Builders ────────────────────────────────────────────────────────────────── + +function delta( + timestamp: number, + cost: number | null, + conversationId = 'conv-A', + model = 'claude-sonnet-4-6', +): UsageDelta { + return { + conversationId, + model, + inputTokens: 1000, + outputTokens: 500, + deltaUtilization: 0.5, + cost, + timestamp, + }; +} + +/** + * Build N daily deltas, one per day, each `dailyCostDollars` apart. + * `now` is the anchor; deltas are placed at noon on each of the prior N days. + */ +function dailyDeltas( + nDays: number, + dailyCostDollars: number, + now: number, + conversationId = 'conv-A', +): UsageDelta[] { + const out: UsageDelta[] = []; + for (let i = 0; i < nDays; i++) { + // Place at noon of (now - i) days. i=0 = same day as now. + const ts = now - i * DAY_MS; + out.push(delta(ts, dailyCostDollars, conversationId)); + } + return out; +} + +// ── Calendar helpers ────────────────────────────────────────────────────────── + +describe('startOfMonth', () => { + it('returns first millisecond of the local-time month', () => { + const expected = new Date(2026, 3, 1, 0, 0, 0, 0).getTime(); + expect(startOfMonth(APRIL_15)).toBe(expected); + }); + + it('handles month boundaries: midnight on the 1st maps to itself', () => { + const firstOfMay = new Date(2026, 4, 1, 0, 0, 0, 0).getTime(); + expect(startOfMonth(firstOfMay)).toBe(firstOfMay); + }); +}); + +describe('startOfNextMonth', () => { + it('rolls forward to the 1st of next month', () => { + const expected = new Date(2026, 4, 1, 0, 0, 0, 0).getTime(); + expect(startOfNextMonth(APRIL_15)).toBe(expected); + }); + + it('handles December rollover into January of the next year', () => { + const dec15 = new Date(2026, 11, 15, 12, 0, 0, 0).getTime(); + const expected = new Date(2027, 0, 1, 0, 0, 0, 0).getTime(); + expect(startOfNextMonth(dec15)).toBe(expected); + }); +}); + +describe('daysUntilNextMonth', () => { + it('returns the calendar-day count to the 1st of next month', () => { + // April 15 noon → May 1 midnight = 15 days, 12 hours → 16 (ceil). + expect(daysUntilNextMonth(APRIL_15)).toBe(16); + }); + + it('returns the full new-month length at midnight on the 1st', () => { + // May 1 00:00:00 → next-month boundary is June 1 → 31 days remaining. + const firstOfMay = new Date(2026, 4, 1, 0, 0, 0, 0).getTime(); + expect(daysUntilNextMonth(firstOfMay)).toBe(31); + }); + + it('returns 1 when the last day has fractional time remaining', () => { + // April 30 at 23:00 → May 1 00:00 = 1 hour, ceil → 1 day. + const apr30LateNight = new Date(2026, 3, 30, 23, 0, 0, 0).getTime(); + expect(daysUntilNextMonth(apr30LateNight)).toBe(1); + }); +}); + +// ── projectMonthEnd ─────────────────────────────────────────────────────────── + +describe('projectMonthEnd', () => { + it('returns null below the distinct-day floor', () => { + const deltas = dailyDeltas(MIN_DISTINCT_DAYS_FOR_PROJECTION - 1, 10, APRIL_15); + const result = projectMonthEnd(deltas, APRIL_15, 50000, 5000); + expect(result).toBeNull(); + }); + + it('returns null when monthlyLimitCents is zero or negative', () => { + const deltas = dailyDeltas(14, 10, APRIL_15); + expect(projectMonthEnd(deltas, APRIL_15, 0, 5000)).toBeNull(); + expect(projectMonthEnd(deltas, APRIL_15, -100, 5000)).toBeNull(); + }); + + it('returns null on the last second of the month (no days remaining)', () => { + const lastSecond = new Date(2026, 3, 30, 23, 59, 59, 999).getTime(); + const deltas = dailyDeltas(14, 10, lastSecond); + expect(daysUntilNextMonth(lastSecond)).toBe(1); + // 1 day remaining is fine; null only fires when we are exactly at month boundary. + expect(projectMonthEnd(deltas, lastSecond, 50000, 5000)).not.toBeNull(); + }); + + it('projects additively on top of currentUsedCents', () => { + // 14 days, $10/day. Trailing-7-day sum = $70, daily = $10. + // 16 days remaining from APRIL_15 noon → projected remainder = 16 × $10 = $160 = 16000c. + // Base usedCents = 5000c → projected = 21000c. + const deltas = dailyDeltas(14, 10, APRIL_15); + const result = projectMonthEnd(deltas, APRIL_15, 50000, 5000); + expect(result).not.toBeNull(); + expect(result!.projectedSpentCents).toBe(21000); + expect(result!.projectedUtilizationPct).toBeCloseTo((21000 / 50000) * 100, 5); + expect(result!.daysRemaining).toBe(16); + }); + + it('flags high confidence on stable, dense usage', () => { + // 14 distinct days, identical $10 per day → CV = 0 → high. + const deltas = dailyDeltas(14, 10, APRIL_15); + const result = projectMonthEnd(deltas, APRIL_15, 50000, 5000); + expect(result?.confidence).toBe('high'); + }); + + it('downgrades to medium when daily totals are highly variable', () => { + // 14 distinct days, alternating $1 / $50 → CV well above 0.3. + const out: UsageDelta[] = []; + for (let i = 0; i < 14; i++) { + out.push(delta(APRIL_15 - i * DAY_MS, i % 2 === 0 ? 1 : 50)); + } + const result = projectMonthEnd(out, APRIL_15, 50000, 5000); + expect(result?.confidence).toBe('medium'); + }); + + it('flags medium confidence on 10-13 distinct days', () => { + const deltas = dailyDeltas(10, 10, APRIL_15); + const result = projectMonthEnd(deltas, APRIL_15, 50000, 5000); + expect(result?.confidence).toBe('medium'); + }); + + it('flags low confidence on 7-9 distinct days', () => { + const deltas = dailyDeltas(7, 10, APRIL_15); + const result = projectMonthEnd(deltas, APRIL_15, 50000, 5000); + expect(result?.confidence).toBe('low'); + }); + + it('excludes deltas from the previous calendar month (post-reset guard)', () => { + // 14 days of $10/day in March + 0 days in April → null after reset. + const out: UsageDelta[] = []; + const march15 = new Date(2026, 2, 15, 12, 0, 0, 0).getTime(); + for (let i = 0; i < 14; i++) { + out.push(delta(march15 - i * DAY_MS, 10)); + } + // Anchor on April 2 (post-reset): March deltas should not project into April. + const apr2 = new Date(2026, 3, 2, 12, 0, 0, 0).getTime(); + const result = projectMonthEnd(out, apr2, 50000, 0); + expect(result).toBeNull(); + }); + + it('ignores cost=null deltas when counting distinct cost-bearing days', () => { + // 12 days populated: 6 with cost=10, 6 with cost=null. Distinct + // cost-bearing days = 6, below the floor → null. + const out: UsageDelta[] = []; + for (let i = 0; i < 12; i++) { + out.push(delta(APRIL_15 - i * DAY_MS, i % 2 === 0 ? 10 : null)); + } + const result = projectMonthEnd(out, APRIL_15, 50000, 5000); + expect(result).toBeNull(); + }); + + it('projects above 100% utilization when on track to exceed cap', () => { + // 14 days × $50/day → daily burn = $50. 16 days remaining → +$800 = 80000c. + // Base 30000c + 80000c = 110000c against 50000c cap → 220% projected. + const deltas = dailyDeltas(14, 50, APRIL_15); + const result = projectMonthEnd(deltas, APRIL_15, 50000, 30000); + expect(result?.projectedUtilizationPct).toBeGreaterThan(100); + }); + + it('ignores deltas with timestamps in the future (clock skew safety)', () => { + // 14 normal days plus 7 deltas timestamped one day after capturedAt. + // The future deltas would otherwise inflate the trailing 7-day burn + // and the distinct-day count; both must be excluded. + const out: UsageDelta[] = []; + for (let i = 0; i < 14; i++) out.push(delta(APRIL_15 - i * DAY_MS, 10)); + const tomorrow = APRIL_15 + DAY_MS; + for (let i = 0; i < 7; i++) out.push(delta(tomorrow + i * 1000, 999)); + + const withSkew = projectMonthEnd(out, APRIL_15, 50000, 5000); + const baseline = projectMonthEnd( + out.filter((d) => d.timestamp <= APRIL_15), + APRIL_15, + 50000, + 5000, + ); + expect(withSkew).toEqual(baseline); + }); + + it('uses last 7 days only for the burn rate (older deltas lower the recent mean)', () => { + // 7 recent days at $20/day, 14 prior days at $1/day. Distinct days = 21 (high tier). + // Trailing-7-day sum = $140 → daily = $20. Remainder = 16 × $20 = $320 = 32000c. + // With currentUsedCents=0 → projected = 32000c. + const out: UsageDelta[] = []; + for (let i = 0; i < 7; i++) out.push(delta(APRIL_15 - i * DAY_MS, 20, 'recent')); + for (let i = 7; i < 21; i++) out.push(delta(APRIL_15 - i * DAY_MS, 1, 'older')); + const result = projectMonthEnd(out, APRIL_15, 100000, 0); + expect(result).not.toBeNull(); + expect(result!.projectedSpentCents).toBe(32000); + }); +}); + +// ── aggregateByConversation ─────────────────────────────────────────────────── + +describe('aggregateByConversation', () => { + it('returns an empty array when the delta log is empty', () => { + expect(aggregateByConversation([], APRIL_15 - 30 * DAY_MS)).toEqual([]); + }); + + it('groups by conversationId and sums cost in cents', () => { + const deltas = [ + delta(APRIL_15, 0.10, 'conv-A'), + delta(APRIL_15, 0.20, 'conv-A'), + delta(APRIL_15, 0.30, 'conv-B'), + ]; + const result = aggregateByConversation(deltas, 0); + expect(result).toEqual([ + { conversationId: 'conv-A', totalCostCents: 30, turnCount: 2 }, + { conversationId: 'conv-B', totalCostCents: 30, turnCount: 1 }, + ]); + }); + + it('sorts descending by totalCostCents', () => { + const deltas = [ + delta(APRIL_15, 0.10, 'cheap'), + delta(APRIL_15, 5.00, 'expensive'), + delta(APRIL_15, 1.00, 'middle'), + ]; + const ranked = aggregateByConversation(deltas, 0); + expect(ranked.map((r) => r.conversationId)).toEqual(['expensive', 'middle', 'cheap']); + }); + + it('filters deltas before sinceTimestamp', () => { + const monthStart = startOfMonth(APRIL_15); + const lastMonth = monthStart - DAY_MS; + const deltas = [ + delta(lastMonth, 1.00, 'old'), + delta(APRIL_15, 0.50, 'current'), + ]; + const ranked = aggregateByConversation(deltas, monthStart); + expect(ranked).toEqual([ + { conversationId: 'current', totalCostCents: 50, turnCount: 1 }, + ]); + }); + + it('skips cost=null deltas without crashing or counting them', () => { + const deltas = [ + delta(APRIL_15, null, 'conv-A'), + delta(APRIL_15, 0.50, 'conv-A'), + delta(APRIL_15, null, 'conv-B'), + ]; + const ranked = aggregateByConversation(deltas, 0); + expect(ranked).toEqual([ + { conversationId: 'conv-A', totalCostCents: 50, turnCount: 1 }, + ]); + }); + + it('preserves sub-cent precision until the boundary rounding', () => { + // 100 turns × $0.0023 = $0.23 = 23c. If we rounded each turn we'd see 0. + const deltas = Array.from({ length: 100 }, () => + delta(APRIL_15, 0.0023, 'conv-precise'), + ); + const [entry] = aggregateByConversation(deltas, 0); + expect(entry.totalCostCents).toBe(23); + expect(entry.turnCount).toBe(100); + }); + + // AC: per-conversation cost matches sum of cost values in ConversationRecord.turns. + it('reconciles with sum of cost across ConversationRecord.turns for the same conversation', () => { + // Synthesize matched delta + turn pairs for one conversation. + // For each turn the delta carries the same `cost` value, simulating + // the in-production invariant that background.ts writes both records + // with identical cost figures. + const turnCosts = [0.12, 0.34, 0.56, 0.78, 0.91]; + const turns: TurnRecord[] = turnCosts.map((cost, i) => ({ + turnNumber: i + 1, + inputTokens: 1000, + outputTokens: 500, + model: 'claude-sonnet-4-6', + contextPct: 10 + i, + cost, + completedAt: APRIL_15 + i * 1000, + deltaUtilization: 0.5, + })); + const deltas: UsageDelta[] = turnCosts.map((cost, i) => ({ + conversationId: 'conv-recon', + model: 'claude-sonnet-4-6', + inputTokens: 1000, + outputTokens: 500, + deltaUtilization: 0.5, + cost, + timestamp: APRIL_15 + i * 1000, + })); + + const turnSumDollars = turns.reduce((acc, t) => acc + (t.cost ?? 0), 0); + const expectedCents = Math.round(turnSumDollars * 100); + + const [aggregated] = aggregateByConversation(deltas, 0); + expect(aggregated.totalCostCents).toBe(expectedCents); + expect(aggregated.turnCount).toBe(turns.length); + + // ConversationRecord shape only constructed to make the invariant + // explicit in the test body; it is not passed to the agent. + const record: Pick = { + id: 'conv-recon', + turns, + }; + expect(record.turns.length).toBe(aggregated.turnCount); + }); +}); + +// ── Type guard: SpendTrajectory shape never returns NaN/Infinity ────────────── + +describe('SpendTrajectory invariants', () => { + it('produces finite, non-negative values across the supported confidence tiers', () => { + for (const days of [7, 10, 14, 20]) { + const deltas = dailyDeltas(days, 5, APRIL_15); + const result = projectMonthEnd(deltas, APRIL_15, 50000, 1000) as SpendTrajectory; + expect(result).not.toBeNull(); + expect(Number.isFinite(result.projectedSpentCents)).toBe(true); + expect(Number.isFinite(result.projectedUtilizationPct)).toBe(true); + expect(result.projectedSpentCents).toBeGreaterThanOrEqual(0); + expect(result.projectedUtilizationPct).toBeGreaterThanOrEqual(0); + expect(result.daysRemaining).toBeGreaterThanOrEqual(0); + } + }); +}); diff --git a/tests/unit/usage-budget-card.test.tsx b/tests/unit/usage-budget-card.test.tsx index c0f205d..3ff571f 100644 --- a/tests/unit/usage-budget-card.test.tsx +++ b/tests/unit/usage-budget-card.test.tsx @@ -18,6 +18,8 @@ import { render, screen } from '@testing-library/react'; import UsageBudgetCard from '../../entrypoints/sidepanel/components/UsageBudgetCard'; import type { UsageBudgetSession, UsageBudgetCredit, UsageBudgetResult } from '../../lib/message-types'; import type { WeeklyEta } from '../../lib/weekly-cap-eta'; +import type { SpendTrajectory, ConversationSpend } from '../../lib/spend-trajectory'; +import type { ConversationRecord } from '../../lib/conversation-store'; // ── Fixtures ───────────────────────────────────────────────────────────────── @@ -203,3 +205,190 @@ describe('UsageBudgetCard — weekly ETA', () => { expect(screen.queryByText(/At this pace/)).toBeNull(); }); }); + +// ── Spend trajectory + top spenders (GET-22) ───────────────────────────────── +// The trajectory line and the expandable top-spenders section render on the +// credit variant only. They are absent on session and unsupported variants. + +describe('UsageBudgetCard — spend trajectory (credit variant)', () => { + function makeTrajectory(overrides: Partial = {}): SpendTrajectory { + return { + projectedSpentCents: 31200, + projectedUtilizationPct: 62.4, + daysRemaining: 16, + confidence: 'high', + ...overrides, + }; + } + + function makeSpenders(): ConversationSpend[] { + return [ + { conversationId: 'conv-A', totalCostCents: 9412, turnCount: 18 }, + { conversationId: 'conv-B', totalCostCents: 4280, turnCount: 11 }, + { conversationId: 'conv-C', totalCostCents: 2150, turnCount: 6 }, + ]; + } + + function makeConversations(): ConversationRecord[] { + const baseTurn = { + turnNumber: 1, + inputTokens: 0, + outputTokens: 0, + model: 'claude-sonnet-4-6', + contextPct: 0, + cost: 0, + completedAt: 0, + }; + return [ + { + id: 'conv-A', + startedAt: 0, + lastActiveAt: 0, + finalized: false, + turnCount: 18, + totalInputTokens: 0, + totalOutputTokens: 0, + peakContextPct: 0, + lastContextPct: 0, + model: 'claude-sonnet-4-6', + estimatedCost: 94.12, + turns: [baseTurn], + dna: { subject: 'Refactor auth middleware', lastContext: '', hints: [] }, + _v: 1, + }, + { + id: 'conv-B', + startedAt: 0, + lastActiveAt: 0, + finalized: false, + turnCount: 11, + totalInputTokens: 0, + totalOutputTokens: 0, + peakContextPct: 0, + lastContextPct: 0, + model: 'claude-sonnet-4-6', + estimatedCost: 42.80, + turns: [baseTurn], + dna: { subject: 'Q2 hiring plan draft', lastContext: '', hints: [] }, + _v: 1, + }, + ]; + } + + it('renders "Need 7+ days" placeholder when trajectory is null', () => { + render( + , + ); + expect(screen.getByText(/Need 7\+ days of usage/)).toBeTruthy(); + }); + + it('renders high-confidence "On track" copy with projected and limit amounts', () => { + render( + , + ); + expect(screen.getByText(/On track for \$312\.00 of \$500\.00 by/)).toBeTruthy(); + }); + + it('renders medium-confidence copy with the firm-up qualifier', () => { + render( + , + ); + expect(screen.getByText(/Estimated month-end:/)).toBeTruthy(); + expect(screen.getByText(/Estimate firms up over the next week/)).toBeTruthy(); + }); + + it('renders low-confidence copy with the "need more data" hedge', () => { + render( + , + ); + expect(screen.getByText(/^Estimating:/)).toBeTruthy(); + expect(screen.getByText(/Need more data for confidence/)).toBeTruthy(); + }); + + it('renders top spenders with subjects looked up from the conversations list', () => { + render( + , + ); + expect(screen.getByText('Top conversations this month')).toBeTruthy(); + expect(screen.getByText('Refactor auth middleware')).toBeTruthy(); + expect(screen.getByText('Q2 hiring plan draft')).toBeTruthy(); + // conv-C has no matching ConversationRecord in the join: fallback subject. + expect(screen.getByText('Untitled conversation')).toBeTruthy(); + }); + + it('renders cost and turn count for each spender', () => { + render( + , + ); + expect(screen.getByText('$94.12')).toBeTruthy(); + expect(screen.getByText('$42.80')).toBeTruthy(); + expect(screen.getByText('$21.50')).toBeTruthy(); + expect(screen.getByText('18 turns')).toBeTruthy(); + expect(screen.getByText('11 turns')).toBeTruthy(); + expect(screen.getByText('6 turns')).toBeTruthy(); + }); + + it('hides the top-spenders section when the list is empty', () => { + render( + , + ); + expect(screen.queryByText('Top conversations this month')).toBeNull(); + }); + + it('does not render trajectory copy on the session variant', () => { + render( + , + ); + expect(screen.queryByText(/On track for/)).toBeNull(); + expect(screen.queryByText(/Need 7\+ days of usage/)).toBeNull(); + expect(screen.queryByText('Top conversations this month')).toBeNull(); + }); +}); From e01e6685620d28022305709e097a8686298c42e2 Mon Sep 17 00:00:00 2001 From: Devanshu Rajesh Chicholikar Date: Thu, 30 Apr 2026 23:03:56 -0400 Subject: [PATCH 2/2] fix(spend): address CodeRabbit review findings [GET-22] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six fixes from the CodeRabbit pass on PR #59: 1. Resolve top-spender subjects per ID via getConversation rather than joining against the History list. The History list is capped at 20, so an older top spender was rendering as "Untitled conversation" even when a record existed in storage. New TopSpender type carries the resolved subject so the card never has to do a lookup. 2. aggregateByConversation now accepts an upper-bound timestamp; the hook passes Date.now() so future-timestamped deltas (clock skew) cannot distort the ranking. Mirrors the projectMonthEnd safety guard. 3. Off-Claude clear paths in useDashboardData now bump weeklyEtaRequestIdRef and spendTrajectoryRequestIdRef before clearing state so any in-flight loaders cannot repopulate the card after the user leaves claude.ai. 4. Disclosure summary on the top-spenders details element gains a :focus-visible outline. The native marker is hidden, so without this keyboard users had no focus indicator. 5. Em dashes purged from comments and docstrings in lib/spend-trajectory.ts. 6. Test name corrected: "returns null on the last second of the month" actually asserts non-null behavior; renamed to "still projects on the last second of the month (1 day remaining)". New tests: aggregateByConversation upper-bound clock-skew guard. Test count: 1795 → 1796. --- entrypoints/sidepanel/App.tsx | 1 - .../sidepanel/components/UsageBudgetCard.tsx | 29 ++------ entrypoints/sidepanel/dashboard.css | 9 +++ .../sidepanel/hooks/useDashboardData.ts | 47 +++++++++--- lib/spend-trajectory.ts | 33 +++++++-- tests/unit/spend-trajectory.test.ts | 17 ++++- tests/unit/usage-budget-card.test.tsx | 74 +++++-------------- 7 files changed, 111 insertions(+), 99 deletions(-) diff --git a/entrypoints/sidepanel/App.tsx b/entrypoints/sidepanel/App.tsx index 34de027..e440a6f 100644 --- a/entrypoints/sidepanel/App.tsx +++ b/entrypoints/sidepanel/App.tsx @@ -87,7 +87,6 @@ export default function App() { weeklyEta={weeklyEta} spendTrajectory={spendTrajectory} topSpendConversations={topSpendConversations} - conversations={conversations} />
diff --git a/entrypoints/sidepanel/components/UsageBudgetCard.tsx b/entrypoints/sidepanel/components/UsageBudgetCard.tsx index b587f01..eb7f7a1 100644 --- a/entrypoints/sidepanel/components/UsageBudgetCard.tsx +++ b/entrypoints/sidepanel/components/UsageBudgetCard.tsx @@ -23,8 +23,7 @@ import type { UsageBudgetResult, UsageBudgetSession, UsageBudgetCredit, BudgetZo import { classifyZone } from '../../../lib/usage-budget'; import { formatEtaLabel, type WeeklyEta } from '../../../lib/weekly-cap-eta'; import { formatCurrencyCents } from '../../../lib/format'; -import type { SpendTrajectory, ConversationSpend } from '../../../lib/spend-trajectory'; -import type { ConversationRecord } from '../../../lib/conversation-store'; +import type { SpendTrajectory, TopSpender } from '../../../lib/spend-trajectory'; interface Props { budget: UsageBudgetResult | null; @@ -47,16 +46,11 @@ interface Props { */ spendTrajectory?: SpendTrajectory | null; /** - * Top conversations of the current month, ranked descending by total cost. + * Top conversations of the current month, ranked descending by total cost, + * with subjects already resolved by the dashboard hook. * Credit-tier only; empty array everywhere else. */ - topSpendConversations?: ConversationSpend[]; - /** - * Recent conversations from the History panel. Used to join conversationId - * from `topSpendConversations` to a human-readable subject without an - * additional storage read. - */ - conversations?: ConversationRecord[]; + topSpendConversations?: TopSpender[]; } // Zone-to-label mapping for the dot and fills. Mirrors the health dot @@ -76,7 +70,6 @@ export default function UsageBudgetCard({ weeklyEta, spendTrajectory, topSpendConversations, - conversations, }: Props) { // No data at all + the user is on a tab where we cannot fetch any. // Surface the obvious next action rather than a silent empty card. @@ -108,7 +101,6 @@ export default function UsageBudgetCard({ budget={budget} trajectory={spendTrajectory ?? null} topSpenders={topSpendConversations ?? []} - conversations={conversations ?? []} />; } @@ -185,12 +177,10 @@ function CreditBudget({ budget, trajectory, topSpenders, - conversations, }: { budget: UsageBudgetCredit; trajectory: SpendTrajectory | null; - topSpenders: ConversationSpend[]; - conversations: ConversationRecord[]; + topSpenders: TopSpender[]; }) { const { utilizationPct, zone, statusLabel, resetLabel, currency, monthlyLimitCents } = budget; const safePct = Math.min(Math.max(utilizationPct, 0), 100); @@ -245,7 +235,6 @@ function CreditBudget({ ))} @@ -258,18 +247,14 @@ function CreditBudget({ function SpenderRow({ spender, - conversations, currency, }: { - spender: ConversationSpend; - conversations: ConversationRecord[]; + spender: TopSpender; currency: string; }) { - const match = conversations.find((c) => c.id === spender.conversationId); - const subject = match?.dna?.subject || 'Untitled conversation'; return (
- {subject} + {spender.subject} {formatCurrencyCents(spender.totalCostCents, currency)} diff --git a/entrypoints/sidepanel/dashboard.css b/entrypoints/sidepanel/dashboard.css index 42dd065..8176570 100644 --- a/entrypoints/sidepanel/dashboard.css +++ b/entrypoints/sidepanel/dashboard.css @@ -767,6 +767,15 @@ body { display: none; } +/* Visible keyboard focus state. The native disclosure marker is hidden above, + so without this rule keyboard users have no indicator that the summary has + focus. Border-radius matches the row pattern. */ +.lco-dash-budget-spenders-summary:focus-visible { + outline: 2px solid var(--lco-focus-ring, var(--lco-text)); + outline-offset: 2px; + border-radius: 3px; +} + .lco-dash-budget-spenders-summary::before { content: '▸'; display: inline-block; diff --git a/entrypoints/sidepanel/hooks/useDashboardData.ts b/entrypoints/sidepanel/hooks/useDashboardData.ts index d731e85..dc4791e 100644 --- a/entrypoints/sidepanel/hooks/useDashboardData.ts +++ b/entrypoints/sidepanel/hooks/useDashboardData.ts @@ -36,7 +36,7 @@ import { aggregateByConversation, startOfMonth, type SpendTrajectory, - type ConversationSpend, + type TopSpender, } from '../../../lib/spend-trajectory'; import type { UsageBudgetResult } from '../../../lib/message-types'; @@ -129,11 +129,13 @@ export interface DashboardData { */ spendTrajectory: SpendTrajectory | null; /** - * Top conversations of the current month, ranked descending by total cost. - * Empty array when no credit-tier deltas exist yet, or on session/unsupported - * tiers. Capped at TOP_SPENDERS_LIMIT entries. + * Top conversations of the current month, ranked descending by total cost, + * with their human-readable subjects already resolved from storage so the + * card never has to join against the truncated History list. Empty array + * on session/unsupported tiers and when no credit-tier deltas exist yet. + * Capped at TOP_SPENDERS_LIMIT entries. */ - topSpendConversations: ConversationSpend[]; + topSpendConversations: TopSpender[]; loading: boolean; } @@ -149,7 +151,7 @@ export function useDashboardData(): DashboardData { const [tokenEconomics, setTokenEconomics] = useState(null); const [weeklyEta, setWeeklyEta] = useState(null); const [spendTrajectory, setSpendTrajectory] = useState(null); - const [topSpendConversations, setTopSpendConversations] = useState([]); + const [topSpendConversations, setTopSpendConversations] = useState([]); const [loading, setLoading] = useState(true); // Track current tab ID so we know which activeConv_ key to watch. @@ -289,9 +291,23 @@ export function useDashboardData(): DashboardData { limits.monthlyLimitCents, limits.usedCents, ); - const topSpenders = aggregateByConversation(deltas, startOfMonth(now)) + const ranked = aggregateByConversation(deltas, startOfMonth(now), now) .slice(0, TOP_SPENDERS_LIMIT); + // Resolve subjects per conversationId via direct getConversation + // calls, not via the History list (which is capped at CONVERSATION_LIMIT + // and could miss an older top spender). Parallel reads; the result + // is ordered to match `ranked` since Promise.all preserves index. + const records = await Promise.all( + ranked.map((spender) => getConversation(orgId, spender.conversationId)), + ); + if (orgIdRef.current !== orgId || spendTrajectoryRequestIdRef.current !== requestId) return; + + const topSpenders: TopSpender[] = ranked.map((spender, i) => ({ + ...spender, + subject: records[i]?.dna?.subject || 'Untitled conversation', + })); + setSpendTrajectory(trajectory); setTopSpendConversations(topSpenders); } catch { @@ -504,8 +520,12 @@ export function useDashboardData(): DashboardData { loadWeeklyEta(); loadSpendTrajectory(); } else { - // Explicitly clear budget -- do not show stale data from the previous - // Claude tab while the user is on Gmail, GitHub, etc. + // Explicitly clear live data -- do not show stale numbers from the + // previous Claude tab while the user is on Gmail, GitHub, etc. + // Bump the trajectory and ETA request IDs first so any in-flight + // resolutions land in the past and skip their setState calls. + weeklyEtaRequestIdRef.current++; + spendTrajectoryRequestIdRef.current++; setBudget(null); setWeeklyEta(null); setSpendTrajectory(null); @@ -536,7 +556,10 @@ export function useDashboardData(): DashboardData { loadWeeklyEta(); loadSpendTrajectory(); } else { - // Navigated away: clear live data immediately. + // Navigated away: clear live data immediately. Same request-id bump + // as onTabActivated to invalidate any in-flight loaders. + weeklyEtaRequestIdRef.current++; + spendTrajectoryRequestIdRef.current++; setBudget(null); setWeeklyEta(null); setSpendTrajectory(null); @@ -552,6 +575,10 @@ export function useDashboardData(): DashboardData { // The closed tab was on Claude; mark the panel as not-Claude since there // is no active tab to track. The user will need to click another tab. applyIsClaudeTab(false); + // Same request-id bump as the other clear paths so any in-flight + // loaders cannot repopulate the card after the tab is gone. + weeklyEtaRequestIdRef.current++; + spendTrajectoryRequestIdRef.current++; setBudget(null); setWeeklyEta(null); setSpendTrajectory(null); diff --git a/lib/spend-trajectory.ts b/lib/spend-trajectory.ts index 58c4021..5eeb073 100644 --- a/lib/spend-trajectory.ts +++ b/lib/spend-trajectory.ts @@ -1,5 +1,5 @@ // lib/spend-trajectory.ts -// Spend Trajectory Agent — projects month-end spend for credit-tier (Enterprise) +// Spend Trajectory Agent: projects month-end spend for credit-tier (Enterprise) // accounts and ranks the most expensive conversations of the current month. // // Pure functions only. No DOM refs, no chrome.* calls, no side effects. @@ -14,9 +14,9 @@ // locally-tracked delta log instead, and ranks conversations by cost. // // Two exports: -// projectMonthEnd — additive projection on top of the exact -// currentUsedCents from the Anthropic endpoint. -// aggregateByConversation — pure grouping; ranked descending by cost. +// projectMonthEnd: additive projection on top of the exact +// currentUsedCents from the Anthropic endpoint. +// aggregateByConversation: pure grouping; ranked descending by cost. import type { UsageDelta } from './conversation-store'; @@ -31,9 +31,9 @@ export interface SpendTrajectory { daysRemaining: number; /** * Signal-quality label, drives copy variation in the card: - * high — n ≥ 14 distinct cost-bearing days AND CV < 0.3 - * medium — n ≥ 10 distinct cost-bearing days - * low — n ≥ 7 distinct cost-bearing days + * high: n ≥ 14 distinct cost-bearing days AND CV < 0.3 + * medium: n ≥ 10 distinct cost-bearing days + * low: n ≥ 7 distinct cost-bearing days * Below 7 distinct days projectMonthEnd returns null entirely * (the card renders a "need more data" placeholder). */ @@ -48,6 +48,17 @@ export interface ConversationSpend { turnCount: number; } +/** + * UI-facing variant of ConversationSpend that carries the human-readable subject + * resolved from the conversation record. The agent never produces this directly; + * the dashboard hook fetches the matching ConversationRecord for each top + * spender so the card can render a stable label even when the spender is older + * than the History list's truncation window. + */ +export interface TopSpender extends ConversationSpend { + subject: string; +} + // ── Constants ───────────────────────────────────────────────────────────────── /** Minimum distinct cost-bearing days in the current month before projecting. */ @@ -86,7 +97,7 @@ export function startOfNextMonth(now: number): number { /** * Calendar days remaining from `now` to the first of next month. * Counts whole days; a fractional remainder still counts as one day. - * Floored at 0 — never negative even if the clock skews past month boundary. + * Floored at 0; never negative even if the clock skews past month boundary. */ export function daysUntilNextMonth(now: number): number { const target = startOfNextMonth(now); @@ -216,16 +227,22 @@ function coefficientOfVariation(values: number[]): number { * to scope to the current calendar month; pass 0 for * all-time. Deltas with timestamp < sinceTimestamp are * excluded. + * @param untilTimestamp Inclusive upper bound (Unix ms). Pass `Date.now()` so + * future-timestamped deltas (clock skew, replay) cannot + * skew the ranking. Defaults to +Infinity for callers + * that have already filtered or do not care. */ export function aggregateByConversation( deltas: UsageDelta[], sinceTimestamp: number, + untilTimestamp: number = Number.POSITIVE_INFINITY, ): ConversationSpend[] { // Sum costs in dollars to preserve sub-cent precision; round once at output. const totals = new Map(); for (const delta of deltas) { if (delta.timestamp < sinceTimestamp) continue; + if (delta.timestamp > untilTimestamp) continue; if (delta.cost === null) continue; const entry = totals.get(delta.conversationId) ?? { dollars: 0, turns: 0 }; entry.dollars += delta.cost; diff --git a/tests/unit/spend-trajectory.test.ts b/tests/unit/spend-trajectory.test.ts index 9ec819e..d113227 100644 --- a/tests/unit/spend-trajectory.test.ts +++ b/tests/unit/spend-trajectory.test.ts @@ -127,11 +127,12 @@ describe('projectMonthEnd', () => { expect(projectMonthEnd(deltas, APRIL_15, -100, 5000)).toBeNull(); }); - it('returns null on the last second of the month (no days remaining)', () => { + it('still projects on the last second of the month (1 day remaining)', () => { const lastSecond = new Date(2026, 3, 30, 23, 59, 59, 999).getTime(); const deltas = dailyDeltas(14, 10, lastSecond); expect(daysUntilNextMonth(lastSecond)).toBe(1); - // 1 day remaining is fine; null only fires when we are exactly at month boundary. + // 1 day remaining is enough: the projection adds a small remainder onto + // currentUsedCents and returns a non-null trajectory. expect(projectMonthEnd(deltas, lastSecond, 50000, 5000)).not.toBeNull(); }); @@ -283,6 +284,18 @@ describe('aggregateByConversation', () => { ]); }); + it('filters deltas after untilTimestamp (clock-skew safety)', () => { + const future = APRIL_15 + DAY_MS; + const deltas = [ + delta(APRIL_15, 1.00, 'now'), + delta(future, 99.00, 'future'), + ]; + const ranked = aggregateByConversation(deltas, 0, APRIL_15); + expect(ranked).toEqual([ + { conversationId: 'now', totalCostCents: 100, turnCount: 1 }, + ]); + }); + it('skips cost=null deltas without crashing or counting them', () => { const deltas = [ delta(APRIL_15, null, 'conv-A'), diff --git a/tests/unit/usage-budget-card.test.tsx b/tests/unit/usage-budget-card.test.tsx index 3ff571f..d20817d 100644 --- a/tests/unit/usage-budget-card.test.tsx +++ b/tests/unit/usage-budget-card.test.tsx @@ -18,8 +18,7 @@ import { render, screen } from '@testing-library/react'; import UsageBudgetCard from '../../entrypoints/sidepanel/components/UsageBudgetCard'; import type { UsageBudgetSession, UsageBudgetCredit, UsageBudgetResult } from '../../lib/message-types'; import type { WeeklyEta } from '../../lib/weekly-cap-eta'; -import type { SpendTrajectory, ConversationSpend } from '../../lib/spend-trajectory'; -import type { ConversationRecord } from '../../lib/conversation-store'; +import type { SpendTrajectory, TopSpender } from '../../lib/spend-trajectory'; // ── Fixtures ───────────────────────────────────────────────────────────────── @@ -221,56 +220,26 @@ describe('UsageBudgetCard — spend trajectory (credit variant)', () => { }; } - function makeSpenders(): ConversationSpend[] { - return [ - { conversationId: 'conv-A', totalCostCents: 9412, turnCount: 18 }, - { conversationId: 'conv-B', totalCostCents: 4280, turnCount: 11 }, - { conversationId: 'conv-C', totalCostCents: 2150, turnCount: 6 }, - ]; - } - - function makeConversations(): ConversationRecord[] { - const baseTurn = { - turnNumber: 1, - inputTokens: 0, - outputTokens: 0, - model: 'claude-sonnet-4-6', - contextPct: 0, - cost: 0, - completedAt: 0, - }; + function makeSpenders(): TopSpender[] { return [ { - id: 'conv-A', - startedAt: 0, - lastActiveAt: 0, - finalized: false, + conversationId: 'conv-A', + totalCostCents: 9412, turnCount: 18, - totalInputTokens: 0, - totalOutputTokens: 0, - peakContextPct: 0, - lastContextPct: 0, - model: 'claude-sonnet-4-6', - estimatedCost: 94.12, - turns: [baseTurn], - dna: { subject: 'Refactor auth middleware', lastContext: '', hints: [] }, - _v: 1, + subject: 'Refactor auth middleware', }, { - id: 'conv-B', - startedAt: 0, - lastActiveAt: 0, - finalized: false, + conversationId: 'conv-B', + totalCostCents: 4280, turnCount: 11, - totalInputTokens: 0, - totalOutputTokens: 0, - peakContextPct: 0, - lastContextPct: 0, - model: 'claude-sonnet-4-6', - estimatedCost: 42.80, - turns: [baseTurn], - dna: { subject: 'Q2 hiring plan draft', lastContext: '', hints: [] }, - _v: 1, + subject: 'Q2 hiring plan draft', + }, + { + conversationId: 'conv-C', + totalCostCents: 2150, + turnCount: 6, + // Subject fallback used by the hook when getConversation returns null. + subject: 'Untitled conversation', }, ]; } @@ -282,7 +251,6 @@ describe('UsageBudgetCard — spend trajectory (credit variant)', () => { isClaudeTab={true} spendTrajectory={null} topSpendConversations={[]} - conversations={[]} />, ); expect(screen.getByText(/Need 7\+ days of usage/)).toBeTruthy(); @@ -295,7 +263,6 @@ describe('UsageBudgetCard — spend trajectory (credit variant)', () => { isClaudeTab={true} spendTrajectory={makeTrajectory({ confidence: 'high' })} topSpendConversations={[]} - conversations={[]} />, ); expect(screen.getByText(/On track for \$312\.00 of \$500\.00 by/)).toBeTruthy(); @@ -308,7 +275,6 @@ describe('UsageBudgetCard — spend trajectory (credit variant)', () => { isClaudeTab={true} spendTrajectory={makeTrajectory({ confidence: 'medium' })} topSpendConversations={[]} - conversations={[]} />, ); expect(screen.getByText(/Estimated month-end:/)).toBeTruthy(); @@ -322,27 +288,26 @@ describe('UsageBudgetCard — spend trajectory (credit variant)', () => { isClaudeTab={true} spendTrajectory={makeTrajectory({ confidence: 'low' })} topSpendConversations={[]} - conversations={[]} />, ); expect(screen.getByText(/^Estimating:/)).toBeTruthy(); expect(screen.getByText(/Need more data for confidence/)).toBeTruthy(); }); - it('renders top spenders with subjects looked up from the conversations list', () => { + it('renders top spenders using the pre-resolved subject on each entry', () => { render( , ); expect(screen.getByText('Top conversations this month')).toBeTruthy(); expect(screen.getByText('Refactor auth middleware')).toBeTruthy(); expect(screen.getByText('Q2 hiring plan draft')).toBeTruthy(); - // conv-C has no matching ConversationRecord in the join: fallback subject. + // conv-C carries the fallback subject set by the hook when getConversation + // returned null, so the card never has to fall back on its own. expect(screen.getByText('Untitled conversation')).toBeTruthy(); }); @@ -353,7 +318,6 @@ describe('UsageBudgetCard — spend trajectory (credit variant)', () => { isClaudeTab={true} spendTrajectory={makeTrajectory()} topSpendConversations={makeSpenders()} - conversations={makeConversations()} />, ); expect(screen.getByText('$94.12')).toBeTruthy(); @@ -371,7 +335,6 @@ describe('UsageBudgetCard — spend trajectory (credit variant)', () => { isClaudeTab={true} spendTrajectory={makeTrajectory()} topSpendConversations={[]} - conversations={[]} />, ); expect(screen.queryByText('Top conversations this month')).toBeNull(); @@ -384,7 +347,6 @@ describe('UsageBudgetCard — spend trajectory (credit variant)', () => { isClaudeTab={true} spendTrajectory={makeTrajectory()} topSpendConversations={makeSpenders()} - conversations={makeConversations()} />, ); expect(screen.queryByText(/On track for/)).toBeNull();