diff --git a/entrypoints/sidepanel/App.tsx b/entrypoints/sidepanel/App.tsx index 149ef4d..e440a6f 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,13 @@ 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..eb7f7a1 100644 --- a/entrypoints/sidepanel/components/UsageBudgetCard.tsx +++ b/entrypoints/sidepanel/components/UsageBudgetCard.tsx @@ -22,6 +22,8 @@ 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, TopSpender } from '../../../lib/spend-trajectory'; interface Props { budget: UsageBudgetResult | null; @@ -37,6 +39,18 @@ 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, + * with subjects already resolved by the dashboard hook. + * Credit-tier only; empty array everywhere else. + */ + topSpendConversations?: TopSpender[]; } // Zone-to-label mapping for the dot and fills. Mirrors the health dot @@ -50,7 +64,13 @@ const ZONE_LABELS: Record = { critical: 'Critical', }; -export default function UsageBudgetCard({ budget, isClaudeTab, weeklyEta }: Props) { +export default function UsageBudgetCard({ + budget, + isClaudeTab, + weeklyEta, + spendTrajectory, + topSpendConversations, +}: 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 +97,11 @@ 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 +173,16 @@ 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, +}: { + budget: UsageBudgetCredit; + trajectory: SpendTrajectory | null; + topSpenders: TopSpender[]; +}) { + const { utilizationPct, zone, statusLabel, resetLabel, currency, monthlyLimitCents } = budget; const safePct = Math.min(Math.max(utilizationPct, 0), 100); return ( @@ -167,6 +199,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 +222,45 @@ 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, + currency, +}: { + spender: TopSpender; + currency: string; +}) { + return ( +
+ {spender.subject} + + {formatCurrencyCents(spender.totalCostCents, currency)} + + + {spender.turnCount} turn{spender.turnCount === 1 ? '' : 's'} +
); } @@ -212,3 +290,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..8176570 100644 --- a/entrypoints/sidepanel/dashboard.css +++ b/entrypoints/sidepanel/dashboard.css @@ -733,6 +733,98 @@ 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; +} + +/* 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; + 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 +1304,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..dc4791e 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 TopSpender, +} 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,21 @@ 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, + * 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: TopSpender[]; loading: boolean; } @@ -125,6 +150,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 +167,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 +259,62 @@ 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 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 { + // 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 +344,9 @@ export function useDashboardData(): DashboardData { setBudget(null); weeklyEtaRequestIdRef.current++; setWeeklyEta(null); + spendTrajectoryRequestIdRef.current++; + setSpendTrajectory(null); + setTopSpendConversations([]); setTokenEconomics(null); } return; @@ -290,13 +378,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 +415,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 +425,7 @@ export function useDashboardData(): DashboardData { } init(); - }, [loadToday, loadConversations, loadActiveConversation, loadBudget, loadWeeklyEta, loadTokenEconomics]); + }, [loadToday, loadConversations, loadActiveConversation, loadBudget, loadWeeklyEta, loadSpendTrajectory, loadTokenEconomics]); // ── Live subscriptions ──────────────────────────────────────────────────── @@ -379,6 +469,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 +479,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 +518,18 @@ 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. + // 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); + setTopSpendConversations([]); } } @@ -449,10 +554,16 @@ export function useDashboardData(): DashboardData { // Navigated back to claude.ai: reload live data. loadBudget(); 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); + setTopSpendConversations([]); } } @@ -464,8 +575,14 @@ 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); + setTopSpendConversations([]); } chrome.storage.onChanged.addListener(onStorageChanged); @@ -479,7 +596,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..5eeb073 --- /dev/null +++ b/lib/spend-trajectory.ts @@ -0,0 +1,264 @@ +// 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; +} + +/** + * 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. */ +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. + * @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; + 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..d113227 --- /dev/null +++ b/tests/unit/spend-trajectory.test.ts @@ -0,0 +1,380 @@ +// 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('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 enough: the projection adds a small remainder onto + // currentUsedCents and returns a non-null trajectory. + 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('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'), + 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..d20817d 100644 --- a/tests/unit/usage-budget-card.test.tsx +++ b/tests/unit/usage-budget-card.test.tsx @@ -18,6 +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, TopSpender } from '../../lib/spend-trajectory'; // ── Fixtures ───────────────────────────────────────────────────────────────── @@ -203,3 +204,153 @@ 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(): TopSpender[] { + return [ + { + conversationId: 'conv-A', + totalCostCents: 9412, + turnCount: 18, + subject: 'Refactor auth middleware', + }, + { + conversationId: 'conv-B', + totalCostCents: 4280, + turnCount: 11, + 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', + }, + ]; + } + + 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 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 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(); + }); + + 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(); + }); +});