= {
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();
+ });
+});