diff --git a/entrypoints/background.ts b/entrypoints/background.ts index cea650e..bded995 100644 --- a/entrypoints/background.ts +++ b/entrypoints/background.ts @@ -17,6 +17,9 @@ import { storeUsageLimits, appendUsageDelta, getUsageDeltas, + appendUsageBudgetSnapshot, + getUsageBudgetSnapshots, + clearUsageBudgetSnapshots, todayDateString, isoWeekId, RETENTION_DAYS, @@ -417,7 +420,38 @@ export default defineBackground({ limits = { kind: 'unsupported', capturedAt }; } storeUsageLimits(message.organizationId, limits) - .then(() => sendResponse({ ok: true })) + .then(async () => { + // Append a snapshot for the weekly-cap ETA agent (session tier only). + // Skip credit and unsupported: they do not have a weekly rolling window. + if (message.kind === 'session') { + try { + const orgId = message.organizationId; + const weeklyPct = message.sevenDayUtilization; + const sessionPct = message.fiveHourUtilization; + + // Reset detection: a significant drop in weeklyPct signals a + // weekly-cap reset. Stale pre-reset snapshots would skew the ETA + // toward zero; clear them so the projection rebuilds from scratch. + const existing = await getUsageBudgetSnapshots(orgId); + if (existing.length > 0) { + const lastPct = existing[existing.length - 1].weeklyPct; + if (weeklyPct < lastPct - 5) { + await clearUsageBudgetSnapshots(orgId); + } + } + + await appendUsageBudgetSnapshot(orgId, { + timestamp: capturedAt, + weeklyPct, + sessionPct, + }); + } catch (err) { + // Non-critical: ETA simply stays null until next successful append. + console.error('[LCO-ERROR] Failed to append usage budget snapshot:', err); + } + } + sendResponse({ ok: true }); + }) .catch((err) => { console.error('[LCO-ERROR] Failed to store usage limits:', err); sendResponse({ ok: false }); diff --git a/entrypoints/claude-ai.content.ts b/entrypoints/claude-ai.content.ts index 385aa89..4c1455a 100644 --- a/entrypoints/claude-ai.content.ts +++ b/entrypoints/claude-ai.content.ts @@ -5,7 +5,7 @@ import type { LcoBridgeMessage, StoreTokenBatchMessage, StoreMessageLimitMessage, StoreTokenBatchResponse, RecordTurnMessage, FinalizeConversationMessage, SetActiveConvMessage, StoreUsageLimitsMessage, UsageBudgetResult } from '../lib/message-types'; import { LCO_NAMESPACE } from '../lib/message-types'; import { isValidBridgeSchema } from '../lib/bridge-validation'; -import { INITIAL_STATE, applyTokenBatch, applyStreamComplete, applyStorageResponse, applyHealthBroken, applyHealthRecovered, applyMessageLimit, applyRestoredConversation, applyDraftEstimate, clearDraftEstimate, applyUsageBudget } from '../lib/overlay-state'; +import { INITIAL_STATE, applyTokenBatch, applyStreamComplete, applyStorageResponse, applyHealthBroken, applyHealthRecovered, applyMessageLimit, applyRestoredConversation, applyDraftEstimate, clearDraftEstimate, applyUsageBudget, applyWeeklyEta } from '../lib/overlay-state'; import { computeUsageBudget, getTrackedUtilization } from '../lib/usage-budget'; import { parseUsageResponse } from '../lib/usage-limits-parser'; import { computePreSubmitEstimate, MIN_DRAFT_CHARS } from '../lib/pre-submit'; @@ -22,10 +22,12 @@ import type { PromptCharacteristics, DeltaPromptContext } from '../lib/prompt-an import { analyzeDelta } from '../lib/delta-coaching'; import type { DeltaCoachInput } from '../lib/delta-coaching'; import { getContextWindowSize, calculateCost } from '../lib/pricing'; -import { extractConversationId } from '../lib/conversation-store'; +import { extractConversationId, usageBudgetSnapshotsKey } from '../lib/conversation-store'; import type { ConversationRecord } from '../lib/conversation-store'; import { computeHealthScore, computeGrowthRate } from '../lib/health-score'; import { buildHandoffSummary } from '../lib/handoff-summary'; +import { computeWeeklyEta } from '../lib/weekly-cap-eta'; +import type { UsageBudgetSnapshot } from '../lib/weekly-cap-eta'; export default defineContentScript({ matches: ['https://claude.ai/*'], @@ -127,6 +129,24 @@ async function fetchAndStoreUsageLimits(orgId: string): Promise { + try { + const key = usageBudgetSnapshotsKey(orgId); + const data = await browser.storage.local.get(key); + const raw = data[key]; + const snapshots: UsageBudgetSnapshot[] = Array.isArray(raw) ? raw as UsageBudgetSnapshot[] : []; + return computeWeeklyEta(snapshots, Date.now()); + } catch { + return null; + } +} + /** * Build local conversation state from a stored record. * Uses per-turn contextPct values from TurnRecord when available — these are @@ -343,10 +363,16 @@ async function initializeMonitoring(): Promise { // monthly% on Enterprise) as the initial before-snapshot so the // first STREAM_COMPLETE can compute a delta in matching units. // The unsupported variant has nothing to track or display. - fetchAndStoreUsageLimits(currentOrgId).then(budget => { + const orgIdForEta = currentOrgId; + fetchAndStoreUsageLimits(currentOrgId).then(async budget => { if (budget !== null && budget.kind !== 'unsupported') { lastKnownUtilization = getTrackedUtilization(budget); state = applyUsageBudget(state, budget); + if (budget.kind === 'session') { + const eta = await computeEtaForOrg(orgIdForEta); + if (currentOrgId !== orgIdForEta) return; + state = applyWeeklyEta(state, eta); + } overlay.render(state); } }); @@ -529,7 +555,7 @@ async function initializeMonitoring(): Promise { // fetchAndStoreUsageLimits catches all errors internally; it never // throws. The .then() always runs. - fetchAndStoreUsageLimits(orgId).then(budgetAfter => { + fetchAndStoreUsageLimits(orgId).then(async budgetAfter => { // Tracked utilization is tier-aware: 5-hour session % on Pro, // monthly credit % on Enterprise. The unsupported variant has // nothing to track, so we leave the snapshot null and skip @@ -560,6 +586,10 @@ async function initializeMonitoring(): Promise { // Unsupported variants have no UI to render, so they never enter state. if (budgetAfter !== null && budgetAfter.kind !== 'unsupported') { state = applyUsageBudget(state, budgetAfter); + if (budgetAfter.kind === 'session') { + const eta = await computeEtaForOrg(orgId); + state = applyWeeklyEta(state, eta); + } } // Update overlay immediately with the exact session cost for diff --git a/entrypoints/sidepanel/App.tsx b/entrypoints/sidepanel/App.tsx index 4d2fe0f..149ef4d 100644 --- a/entrypoints/sidepanel/App.tsx +++ b/entrypoints/sidepanel/App.tsx @@ -34,6 +34,7 @@ export default function App() { conversations, budget, isClaudeTab, + weeklyEta, loading, } = useDashboardData(); @@ -78,7 +79,7 @@ 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 7602bad..4a2f53d 100644 --- a/entrypoints/sidepanel/components/UsageBudgetCard.tsx +++ b/entrypoints/sidepanel/components/UsageBudgetCard.tsx @@ -21,6 +21,7 @@ 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'; interface Props { budget: UsageBudgetResult | null; @@ -30,6 +31,12 @@ interface Props { * data get the "account type not supported" message. */ isClaudeTab: boolean; + /** + * Weekly-cap ETA from the weekly-cap ETA agent. Null until enough snapshots + * have accumulated, or when usage is flat/declining, or post-reset. + * Session tier only; credit/unsupported cards never receive this. + */ + weeklyEta?: WeeklyEta | null; } // Zone-to-label mapping for the dot and fills. Mirrors the health dot @@ -43,7 +50,7 @@ const ZONE_LABELS: Record = { critical: 'Critical', }; -export default function UsageBudgetCard({ budget, isClaudeTab }: Props) { +export default function UsageBudgetCard({ budget, isClaudeTab, weeklyEta }: 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) { @@ -69,13 +76,13 @@ export default function UsageBudgetCard({ budget, isClaudeTab }: Props) { // Session and credit each get their own render path; the discriminator on // `budget.kind` lets TypeScript narrow into the right field set. return budget.kind === 'session' - ? + ? : ; } // ── Session variant (Pro / Personal / Max) ─────────────────────────────────── -function SessionBudget({ budget }: { budget: UsageBudgetSession }) { +function SessionBudget({ budget, eta }: { budget: UsageBudgetSession; eta: WeeklyEta | null }) { const { sessionPct, weeklyPct, sessionMinutesUntilReset, weeklyResetLabel, zone, statusLabel } = budget; // Clamp to [0, 100] for the bar fill. The API returns 0-100 already, but @@ -124,6 +131,13 @@ function SessionBudget({ budget }: { budget: UsageBudgetSession }) { {Math.round(safeWeeklyPct)}% + {/* ETA projection: shown under the weekly bar when the agent has + enough history to project confidently. Hidden when null (too few + snapshots, flat/declining usage, or immediately after a reset). */} + {eta !== null && ( +

{formatEtaLine(eta)}

+ )} + {/* Reset times */}
Session resets in {resetText} @@ -182,3 +196,19 @@ function formatSessionReset(minutes: number): string { const m = minutes % 60; return m === 0 ? `${h}h` : `${h}h ${m}m`; } + +/** + * Build the one-liner ETA coaching copy for the side-panel card. + * Copy varies by confidence to calibrate the user's expectation of accuracy. + */ +function formatEtaLine(eta: WeeklyEta): string { + const label = formatEtaLabel(eta.etaTimestamp); + switch (eta.confidence) { + case 'high': + return `At this pace, you'll hit your weekly cap by ${label}.`; + case 'medium': + return `Estimated cap: ${label}. Estimate firms up over the next day.`; + case 'low': + return `Estimating: cap by ${label}. Need more data for confidence.`; + } +} diff --git a/entrypoints/sidepanel/hooks/useDashboardData.ts b/entrypoints/sidepanel/hooks/useDashboardData.ts index 59cb10a..ac87162 100644 --- a/entrypoints/sidepanel/hooks/useDashboardData.ts +++ b/entrypoints/sidepanel/hooks/useDashboardData.ts @@ -22,6 +22,7 @@ import { listConversations, getUsageLimits, getUsageDeltas, + getUsageBudgetSnapshots, todayDateString, type DailySummary, type ConversationRecord, @@ -29,6 +30,7 @@ import { import { computeHealthScore, computeGrowthRate, type HealthScore } from '../../../lib/health-score'; import { computeUsageBudget } from '../../../lib/usage-budget'; import { computeTokenEconomics, type TokenEconomicsResult } from '../../../lib/token-economics'; +import { computeWeeklyEta, type WeeklyEta } from '../../../lib/weekly-cap-eta'; import type { UsageBudgetResult } from '../../../lib/message-types'; // ── Constants ───────────────────────────────────────────────────────────────── @@ -102,6 +104,13 @@ export interface DashboardData { * Maps model name to median tokens per 1% of session consumed. */ tokenEconomics: TokenEconomicsResult | null; + /** + * Projected time-to-100% for the weekly usage cap. + * Null until MIN_SNAPSHOTS_FOR_ETA snapshots exist, or when usage is flat + * or declining, or immediately after a weekly reset (snapshots cleared). + * Session tier only (Pro/Max). Always null on Enterprise and unsupported tiers. + */ + weeklyEta: WeeklyEta | null; loading: boolean; } @@ -115,6 +124,7 @@ export function useDashboardData(): DashboardData { const [budget, setBudget] = useState(null); const [isClaudeTab, setIsClaudeTab] = useState(false); const [tokenEconomics, setTokenEconomics] = useState(null); + const [weeklyEta, setWeeklyEta] = useState(null); const [loading, setLoading] = useState(true); // Track current tab ID so we know which activeConv_ key to watch. @@ -124,6 +134,12 @@ export function useDashboardData(): DashboardData { // Ref mirror of isClaudeTab so event listeners (closures) can read the current // value without capturing a stale boolean from the render cycle. const isClaudeTabRef = useRef(false); + // Monotonic counter incremented at the start of every loadWeeklyEta call and + // whenever weeklyEta is explicitly cleared. An in-flight resolution checks that + // its captured ID still matches before calling setWeeklyEta, preventing a stale + // 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); // Sync helper: always update both the React state and the ref together so // they never drift. All code that changes isClaudeTab must use this. @@ -179,6 +195,41 @@ export function useDashboardData(): DashboardData { } }, []); + const loadBudget = useCallback(async () => { + try { + const orgId = orgIdRef.current; + if (!orgId) return; + const limits = await getUsageLimits(orgId); + // Stale-check: the org may have changed while getUsageLimits was in flight + // (account switch, logout, tab change). Applying stale data from the old org + // would overwrite the correct cleared or newly-loaded state. Discard it. + if (orgIdRef.current !== orgId) return; + if (!limits) { + setBudget(null); + return; + } + setBudget(computeUsageBudget(limits, Date.now())); + } catch { + // Dashboard shows nothing rather than crash. + } + }, []); + + const loadWeeklyEta = useCallback(async () => { + const requestId = ++weeklyEtaRequestIdRef.current; + try { + const orgId = orgIdRef.current; + if (!orgId) { + setWeeklyEta(null); + return; + } + const snapshots = await getUsageBudgetSnapshots(orgId); + if (orgIdRef.current !== orgId || weeklyEtaRequestIdRef.current !== requestId) return; + setWeeklyEta(computeWeeklyEta(snapshots, Date.now())); + } catch { + // Non-critical: ETA panel simply stays hidden. + } + }, []); + const loadActiveConversation = useCallback(async (tabId: number) => { try { // Read the active conversation and org ID for this tab from session storage. @@ -206,6 +257,8 @@ export function useDashboardData(): DashboardData { setToday(null); setConversations([]); setBudget(null); + weeklyEtaRequestIdRef.current++; + setWeeklyEta(null); setTokenEconomics(null); } return; @@ -235,32 +288,15 @@ export function useDashboardData(): DashboardData { if (orgChanged) { loadConversations(); loadToday(); + loadBudget(); + loadWeeklyEta(); loadTokenEconomics(); } } catch { setActiveConv(null); setActiveHealth(null); } - }, [loadConversations, loadToday, loadTokenEconomics]); - - const loadBudget = useCallback(async () => { - try { - const orgId = orgIdRef.current; - if (!orgId) return; - const limits = await getUsageLimits(orgId); - // Stale-check: the org may have changed while getUsageLimits was in flight - // (account switch, logout, tab change). Applying stale data from the old org - // would overwrite the correct cleared or newly-loaded state. Discard it. - if (orgIdRef.current !== orgId) return; - if (!limits) { - setBudget(null); - return; - } - setBudget(computeUsageBudget(limits, Date.now())); - } catch { - // Dashboard shows nothing rather than crash. - } - }, []); + }, [loadConversations, loadToday, loadBudget, loadWeeklyEta, loadTokenEconomics]); // ── Initial load ────────────────────────────────────────────────────────── @@ -289,6 +325,7 @@ export function useDashboardData(): DashboardData { await loadToday(); if (onClaude) { await loadBudget(); + loadWeeklyEta(); } // Token economics is non-blocking: fire after the main data loads. // It requires enough delta records to be meaningful (MIN_SAMPLES per model), @@ -298,7 +335,7 @@ export function useDashboardData(): DashboardData { } init(); - }, [loadToday, loadConversations, loadActiveConversation, loadBudget, loadTokenEconomics]); + }, [loadToday, loadConversations, loadActiveConversation, loadBudget, loadWeeklyEta, loadTokenEconomics]); // ── Live subscriptions ──────────────────────────────────────────────────── @@ -343,6 +380,10 @@ export function useDashboardData(): DashboardData { if (hasBudgetChange && isClaudeTabRef.current) { loadBudget(); } + const hasSnapshotChange = keys.some(k => k.startsWith('usageBudgetSnapshots:')); + if (hasSnapshotChange && isClaudeTabRef.current) { + loadWeeklyEta(); + } if (hasDeltaChange) { loadTokenEconomics(); } @@ -378,10 +419,12 @@ export function useDashboardData(): DashboardData { if (onClaude) { loadBudget(); + loadWeeklyEta(); } else { // Explicitly clear budget -- do not show stale data from the previous // Claude tab while the user is on Gmail, GitHub, etc. setBudget(null); + setWeeklyEta(null); } } @@ -405,9 +448,11 @@ export function useDashboardData(): DashboardData { if (onClaude) { // Navigated back to claude.ai: reload live data. loadBudget(); + loadWeeklyEta(); } else { // Navigated away: clear live data immediately. setBudget(null); + setWeeklyEta(null); } } @@ -420,6 +465,7 @@ export function useDashboardData(): DashboardData { // is no active tab to track. The user will need to click another tab. applyIsClaudeTab(false); setBudget(null); + setWeeklyEta(null); } chrome.storage.onChanged.addListener(onStorageChanged); @@ -433,7 +479,7 @@ export function useDashboardData(): DashboardData { chrome.tabs.onUpdated.removeListener(onTabUpdated); chrome.tabs.onRemoved.removeListener(onTabRemoved); }; - }, [loadToday, loadConversations, loadActiveConversation, loadBudget, loadTokenEconomics]); + }, [loadToday, loadConversations, loadActiveConversation, loadBudget, loadWeeklyEta, loadTokenEconomics]); - return { today, activeConv, activeHealth, conversations, budget, isClaudeTab, tokenEconomics, loading }; + return { today, activeConv, activeHealth, conversations, budget, isClaudeTab, tokenEconomics, weeklyEta, loading }; } diff --git a/lib/conversation-store.ts b/lib/conversation-store.ts index 600e41e..cb08d9e 100644 --- a/lib/conversation-store.ts +++ b/lib/conversation-store.ts @@ -5,6 +5,7 @@ import { calculateCost } from './pricing'; import type { UsageLimitsData } from './message-types'; +import type { UsageBudgetSnapshot } from './weekly-cap-eta'; // ── Interfaces ──────────────────────────────────────────────────────────────── @@ -131,6 +132,10 @@ export const CRITICAL_CONTEXT_PCT = 80; // Append-only delta log cap. Oldest records are pruned when this is exceeded. // At ~50 bytes per record, 500 entries is ~25 KB, well within storage.local limits. export const MAX_USAGE_DELTAS = 500; +// Rolling snapshot cap for the weekly-cap ETA agent. +// ~200 records × ~50 bytes ≈ 10 KB, well within storage.local limits. +// 200 hourly snapshots covers ~8 days — more than one full weekly cycle. +export const MAX_USAGE_BUDGET_SNAPSHOTS = 200; // Storage key builders. All keys are scoped to an accountId (organization UUID) // so multiple Claude accounts sharing one browser get isolated data. @@ -148,6 +153,10 @@ function weeklyIndexKey(accountId: string): string { assertAccountId(accountId); function usageLimitsKey(accountId: string): string { assertAccountId(accountId); return `usageLimits:${accountId}`; } // Append-only delta log, capped at MAX_USAGE_DELTAS. Key referenced in claude-ai.content.ts. function usageDeltasKey(accountId: string): string { assertAccountId(accountId); return `usageDeltas:${accountId}`; } +// Rolling snapshot list for the weekly-cap ETA agent, capped at MAX_USAGE_BUDGET_SNAPSHOTS. +// Reset by clearUsageBudgetSnapshots on weekly-cap reset detection in background.ts. +// Exported so content scripts can read the key directly without going through setStorage. +export function usageBudgetSnapshotsKey(accountId: string): string { assertAccountId(accountId); return `usageBudgetSnapshots:${accountId}`; } // Legacy (pre-account-isolation) key builders for read-through migration. function legacyConvKey(convId: string): string { return `conv:${convId}`; } @@ -805,3 +814,59 @@ export async function getUsageDeltas(accountId: string): Promise { const records = data[key]; return Array.isArray(records) ? records as UsageDelta[] : []; } + +// ── Usage Budget Snapshots ──────────────────────────────────────────────────── +// Rolling list of (timestamp, weeklyPct, sessionPct) tuples for the weekly-cap +// ETA agent. One entry per STORE_USAGE_LIMITS (session variant) call. Capped at +// MAX_USAGE_BUDGET_SNAPSHOTS. Cleared on weekly-cap reset detection. + +/** + * Append one snapshot to the per-account rolling snapshot list. + * Prunes the oldest entries when the list exceeds MAX_USAGE_BUDGET_SNAPSHOTS. + * + * @param accountId - Organization UUID + * @param snapshot - Snapshot tuple to append + */ +export async function appendUsageBudgetSnapshot( + accountId: string, + snapshot: UsageBudgetSnapshot, +): Promise { + const key = usageBudgetSnapshotsKey(accountId); + const data = await store().get(key); + const existing = data[key]; + const records: UsageBudgetSnapshot[] = Array.isArray(existing) ? existing as UsageBudgetSnapshot[] : []; + + records.push(snapshot); + + const overflow = records.length - MAX_USAGE_BUDGET_SNAPSHOTS; + if (overflow > 0) { + records.splice(0, overflow); + } + + await store().set({ [key]: records }); +} + +/** + * Read all budget snapshots for an account, oldest first. + * Returns an empty array when no snapshots exist yet. + * + * @param accountId - Organization UUID + */ +export async function getUsageBudgetSnapshots(accountId: string): Promise { + const key = usageBudgetSnapshotsKey(accountId); + const data = await store().get(key); + const records = data[key]; + return Array.isArray(records) ? records as UsageBudgetSnapshot[] : []; +} + +/** + * Clear all budget snapshots for an account. + * Called by the background script on weekly-cap reset detection so no + * stale pre-reset snapshots pollute the ETA projection. + * + * @param accountId - Organization UUID + */ +export async function clearUsageBudgetSnapshots(accountId: string): Promise { + const key = usageBudgetSnapshotsKey(accountId); + await store().set({ [key]: [] }); +} diff --git a/lib/overlay-state.ts b/lib/overlay-state.ts index 9faa339..9983b84 100644 --- a/lib/overlay-state.ts +++ b/lib/overlay-state.ts @@ -8,6 +8,7 @@ import type { TabState, UsageBudgetSession, UsageBudgetCredit } from './message- import type { HealthScore } from './health-score'; import type { ConversationRecord } from './conversation-store'; import type { PreSubmitEstimate } from './pre-submit'; +import type { WeeklyEta } from './weekly-cap-eta'; /** * Renderable budget variants only. The unsupported variant has nothing for @@ -56,6 +57,13 @@ export interface OverlayState { * out at the call site (the overlay has no empty-state UI for it). */ usageBudget: RenderableBudget | null; + /** + * Projected time-to-100% for the weekly usage cap. + * Null until enough snapshots have accumulated (MIN_SNAPSHOTS_FOR_ETA), + * when usage is flat or declining, or immediately after a weekly reset. + * Session tier only: credit (Enterprise) has no weekly rolling window. + */ + weeklyEta: WeeklyEta | null; } export const INITIAL_STATE: Readonly = { @@ -69,6 +77,7 @@ export const INITIAL_STATE: Readonly = { lastDeltaUtilization: null, draftEstimate: null, usageBudget: null, + weeklyEta: null, }; @@ -194,3 +203,12 @@ export function clearDraftEstimate(state: OverlayState): OverlayState { export function applyUsageBudget(state: OverlayState, budget: RenderableBudget): OverlayState { return { ...state, usageBudget: budget }; } + +/** + * Apply or clear the weekly-cap ETA projection. + * Called alongside applyUsageBudget after each usage fetch. + * Null clears the ETA row (flat/declining usage, not enough history, post-reset). + */ +export function applyWeeklyEta(state: OverlayState, eta: WeeklyEta | null): OverlayState { + return { ...state, weeklyEta: eta }; +} diff --git a/lib/weekly-cap-eta.ts b/lib/weekly-cap-eta.ts new file mode 100644 index 0000000..14740dc --- /dev/null +++ b/lib/weekly-cap-eta.ts @@ -0,0 +1,153 @@ +// lib/weekly-cap-eta.ts +// Weekly Cap ETA Agent — projects when a Pro/Max user will exhaust their 7-day +// rolling usage window given a rolling list of timestamped weeklyPct snapshots. +// +// Pure functions only. No DOM refs, no chrome.* calls, no side effects. +// Callers: useDashboardData.ts (side panel), claude-ai.content.ts (overlay). +// Storage managed by: conversation-store.ts (appendUsageBudgetSnapshot etc). +// +// Algorithm: least-squares linear fit on (timestamp, weeklyPct) pairs. +// slope = rate of weekly utilization growth in % per ms +// ETA = (100 - intercept) / slope → Unix ms when projection hits 100% +// R² = 1 − SSres/SStot → goodness-of-fit, drives confidence label + +export interface UsageBudgetSnapshot { + /** Unix ms timestamp of when this snapshot was captured. */ + timestamp: number; + /** 7-day rolling utilization percentage (0-100) at capture time. */ + weeklyPct: number; + /** 5-hour session utilization percentage (0-100) at capture time. */ + sessionPct: number; +} + +export interface WeeklyEta { + /** Unix ms timestamp when the linear projection hits 100%. */ + etaTimestamp: number; + /** Hours until 100% at the current rate. Always > 0 when returned. */ + hoursRemaining: number; + /** + * Signal-quality label: + * high — n ≥ 10 AND R² ≥ 0.9 → show precise ETA + * medium — n ≥ 7 AND R² ≥ 0.7 → show ETA with "estimated" qualifier + * low — n ≥ 5, lower fit → show ETA with "need more data" note + */ + confidence: 'low' | 'medium' | 'high'; +} + +// ── Constants ───────────────────────────────────────────────────────────────── + +/** Minimum snapshots needed to produce any ETA. Fewer → null. */ +export const MIN_SNAPSHOTS_FOR_ETA = 5; + +/** If (max - min) weeklyPct < this threshold over a span >= FLAT_DURATION_MS → flat → null. */ +const FLAT_RANGE_THRESHOLD = 10; + +/** Minimum time span before a small pct range is treated as definitively flat. */ +const FLAT_DURATION_MS = 24 * 60 * 60 * 1000; // 24 hours + +/** ETAs more than one week away are suppressed: the window resets in 7 days anyway. */ +const MAX_ETA_LOOKAHEAD_MS = 7 * 24 * 60 * 60 * 1000; + +// ── Confidence ──────────────────────────────────────────────────────────────── + +function classifyConfidence(n: number, r2: number): 'low' | 'medium' | 'high' { + if (n >= 10 && r2 >= 0.9) return 'high'; + if (n >= 7 && r2 >= 0.7) return 'medium'; + return 'low'; +} + +// ── ETA formatting ───────────────────────────────────────────────────────────── + +/** + * Format a Unix ms ETA as a short day-plus-time label using the browser's + * locale settings. Examples: "Wed 6:30 PM", "Thu 14:00". + * Exported so the card and the overlay can both format consistently. + */ +export function formatEtaLabel(etaTimestamp: number): string { + return new Intl.DateTimeFormat(undefined, { + weekday: 'short', + hour: 'numeric', + minute: '2-digit', + }).format(new Date(etaTimestamp)); +} + +// ── Main agent function ─────────────────────────────────────────────────────── + +/** + * Project when the weekly utilization will reach 100% using a least-squares + * linear fit on the provided snapshot list. + * + * Returns null when: + * - fewer than MIN_SNAPSHOTS_FOR_ETA entries exist + * - the fitted slope is <= 0 (flat or declining usage) + * - the range across a 24h+ span is < FLAT_RANGE_THRESHOLD (definitively flat) + * - the projected ETA is in the past or beyond MAX_ETA_LOOKAHEAD_MS + * + * @param snapshots - Rolling list of usage snapshots (may be unsorted). + * @param now - Current Unix ms timestamp (injectable for testability). + */ +export function computeWeeklyEta( + snapshots: UsageBudgetSnapshot[], + now: number, +): WeeklyEta | null { + if (snapshots.length < MIN_SNAPSHOTS_FOR_ETA) return null; + + // Sort ascending by timestamp for all subsequent math. + const sorted = [...snapshots].sort((a, b) => a.timestamp - b.timestamp); + const n = sorted.length; + + const first = sorted[0]; + const last = sorted[n - 1]; + + // Flatness guard: a long observation window with a tiny utilization range + // indicates no meaningful growth, regardless of numeric slope. + const span = last.timestamp - first.timestamp; + const minPct = Math.min(...sorted.map(s => s.weeklyPct)); + const maxPct = Math.max(...sorted.map(s => s.weeklyPct)); + if (span >= FLAT_DURATION_MS && (maxPct - minPct) < FLAT_RANGE_THRESHOLD) return null; + + // Least-squares linear fit: minimize sum of squared residuals. + // y = slope * x + intercept (x: ms timestamp, y: weeklyPct) + let sumX = 0, sumY = 0, sumXY = 0, sumXX = 0; + for (const s of sorted) { + sumX += s.timestamp; + sumY += s.weeklyPct; + sumXY += s.timestamp * s.weeklyPct; + sumXX += s.timestamp * s.timestamp; + } + + const denom = n * sumXX - sumX * sumX; + if (denom === 0) return null; + + const slope = (n * sumXY - sumX * sumY) / denom; // pct / ms + if (slope <= 0) return null; + + const intercept = (sumY - slope * sumX) / n; + + // Project to 100%: t = (100 - intercept) / slope + const etaTimestamp = (100 - intercept) / slope; + const msRemaining = etaTimestamp - now; + + // Reject past ETAs and far-future projections (cap window resets weekly anyway). + if (msRemaining <= 0 || msRemaining > MAX_ETA_LOOKAHEAD_MS) return null; + + const hoursRemaining = msRemaining / (60 * 60 * 1000); + + // R²: coefficient of determination. Measures how well the linear model fits. + // 1.0 = perfect fit; 0.0 = model explains nothing (noise). + const meanY = sumY / n; + let ssRes = 0; + let ssTot = 0; + for (const s of sorted) { + const predicted = slope * s.timestamp + intercept; + ssRes += (s.weeklyPct - predicted) ** 2; + ssTot += (s.weeklyPct - meanY) ** 2; + } + const r2 = ssTot > 0 ? Math.max(0, 1 - ssRes / ssTot) : 0; + + return { + etaTimestamp, + hoursRemaining, + confidence: classifyConfidence(n, r2), + }; +} diff --git a/tests/unit/conversation-store.test.ts b/tests/unit/conversation-store.test.ts index 1c9329f..75a1c87 100644 --- a/tests/unit/conversation-store.test.ts +++ b/tests/unit/conversation-store.test.ts @@ -22,14 +22,20 @@ import { getUsageLimits, appendUsageDelta, getUsageDeltas, + appendUsageBudgetSnapshot, + getUsageBudgetSnapshots, + clearUsageBudgetSnapshots, + usageBudgetSnapshotsKey, MAX_TURNS_PER_RECORD, MAX_USAGE_DELTAS, + MAX_USAGE_BUDGET_SNAPSHOTS, CRITICAL_CONTEXT_PCT, type StorageArea, type TurnRecord, type ConversationRecord, type UsageDelta, } from '../../lib/conversation-store'; +import type { UsageBudgetSnapshot } from '../../lib/weekly-cap-eta'; import type { UsageLimitsData } from '../../lib/message-types'; const TEST_ORG = 'org-test-123'; @@ -1011,3 +1017,173 @@ describe('recordTurn with deltaUtilization', () => { expect(record?.turns[2].deltaUtilization).toBeNull(); }); }); + +// ── Usage Budget Snapshots ───────────────────────────────────────────────────── +// appendUsageBudgetSnapshot / getUsageBudgetSnapshots / clearUsageBudgetSnapshots +// These feed the weekly-cap ETA agent (lib/weekly-cap-eta.ts). + +describe('usage budget snapshots', () => { + function makeSnap(weeklyPct: number, timestamp = Date.now()): UsageBudgetSnapshot { + return { timestamp, weeklyPct, sessionPct: 10 }; + } + + it('returns an empty array when no snapshots have been appended', async () => { + const result = await getUsageBudgetSnapshots(TEST_ORG); + expect(result).toEqual([]); + }); + + it('appends a single snapshot and reads it back', async () => { + const snap = makeSnap(25, 1_700_000_000_000); + await appendUsageBudgetSnapshot(TEST_ORG, snap); + const result = await getUsageBudgetSnapshots(TEST_ORG); + expect(result).toHaveLength(1); + expect(result[0]).toEqual(snap); + }); + + it('appends multiple snapshots in order', async () => { + await appendUsageBudgetSnapshot(TEST_ORG, makeSnap(10, 1_000)); + await appendUsageBudgetSnapshot(TEST_ORG, makeSnap(20, 2_000)); + await appendUsageBudgetSnapshot(TEST_ORG, makeSnap(30, 3_000)); + const result = await getUsageBudgetSnapshots(TEST_ORG); + expect(result).toHaveLength(3); + expect(result[0].weeklyPct).toBe(10); + expect(result[2].weeklyPct).toBe(30); + }); + + it(`prunes oldest entries when count exceeds MAX_USAGE_BUDGET_SNAPSHOTS (${MAX_USAGE_BUDGET_SNAPSHOTS})`, async () => { + for (let i = 0; i < MAX_USAGE_BUDGET_SNAPSHOTS + 5; i++) { + await appendUsageBudgetSnapshot(TEST_ORG, makeSnap(i, i)); + } + const result = await getUsageBudgetSnapshots(TEST_ORG); + expect(result).toHaveLength(MAX_USAGE_BUDGET_SNAPSHOTS); + // Oldest 5 were pruned; the first remaining has weeklyPct = 5. + expect(result[0].weeklyPct).toBe(5); + }); + + it('clearUsageBudgetSnapshots removes all snapshots for the account', async () => { + await appendUsageBudgetSnapshot(TEST_ORG, makeSnap(40)); + await appendUsageBudgetSnapshot(TEST_ORG, makeSnap(50)); + await clearUsageBudgetSnapshots(TEST_ORG); + const result = await getUsageBudgetSnapshots(TEST_ORG); + expect(result).toEqual([]); + }); + + it('clear followed by append works correctly (post-reset rebuild)', async () => { + await appendUsageBudgetSnapshot(TEST_ORG, makeSnap(80)); + await clearUsageBudgetSnapshots(TEST_ORG); + await appendUsageBudgetSnapshot(TEST_ORG, makeSnap(5, 99_999)); + const result = await getUsageBudgetSnapshots(TEST_ORG); + expect(result).toHaveLength(1); + expect(result[0].weeklyPct).toBe(5); + }); + + it('isolates snapshots between accounts', async () => { + const orgA = 'org-snap-aaa'; + const orgB = 'org-snap-bbb'; + await appendUsageBudgetSnapshot(orgA, makeSnap(30)); + await appendUsageBudgetSnapshot(orgB, makeSnap(70)); + const a = await getUsageBudgetSnapshots(orgA); + const b = await getUsageBudgetSnapshots(orgB); + expect(a[0].weeklyPct).toBe(30); + expect(b[0].weeklyPct).toBe(70); + }); + + it('throws when accountId is empty string', async () => { + await expect( + appendUsageBudgetSnapshot('', makeSnap(10)), + ).rejects.toThrow('[LCO] accountId required for scoped storage key'); + }); +}); + +// ── Snapshot reset-detection invariant ─────────────────────────────────────── +// Background.ts detects a weekly-cap reset when newWeeklyPct < lastPct - 5, +// then calls clearUsageBudgetSnapshots before appending the fresh reading. +// These tests verify the store-level invariants that pattern relies on. + +describe('snapshot reset-detection invariant', () => { + function makeSnap(weeklyPct: number, ts: number): UsageBudgetSnapshot { + return { timestamp: ts, weeklyPct, sessionPct: 10 }; + } + + it('clear-then-append after >5pp drop yields only the new post-reset snapshot', async () => { + // Simulate several pre-reset snapshots. + await appendUsageBudgetSnapshot(TEST_ORG, makeSnap(70, 1_000)); + await appendUsageBudgetSnapshot(TEST_ORG, makeSnap(80, 2_000)); + await appendUsageBudgetSnapshot(TEST_ORG, makeSnap(85, 3_000)); + + // Background detects: newPct (5) < lastPct (85) - 5 = 80 → reset. + const before = await getUsageBudgetSnapshots(TEST_ORG); + const lastPct = before[before.length - 1].weeklyPct; + const newPct = 5; + if (newPct < lastPct - 5) { + await clearUsageBudgetSnapshots(TEST_ORG); + } + await appendUsageBudgetSnapshot(TEST_ORG, makeSnap(newPct, 4_000)); + + const after = await getUsageBudgetSnapshots(TEST_ORG); + expect(after).toHaveLength(1); + expect(after[0].weeklyPct).toBe(5); + }); + + it('a drop of exactly 5pp does NOT trigger a reset (strict < only)', async () => { + // Background condition: newPct < lastPct - 5 (strict less-than). + // A drop of exactly 5 (75 → 70) must NOT clear the list. + await appendUsageBudgetSnapshot(TEST_ORG, makeSnap(70, 1_000)); + await appendUsageBudgetSnapshot(TEST_ORG, makeSnap(75, 2_000)); + + const before = await getUsageBudgetSnapshots(TEST_ORG); + const lastPct = before[before.length - 1].weeklyPct; // 75 + const newPct = 70; // 70 < 75 - 5 = 70 → false (not strictly less) + if (newPct < lastPct - 5) { + await clearUsageBudgetSnapshots(TEST_ORG); + } + await appendUsageBudgetSnapshot(TEST_ORG, makeSnap(newPct, 3_000)); + + const after = await getUsageBudgetSnapshots(TEST_ORG); + expect(after).toHaveLength(3); + }); + + it('normal usage growth (rising weeklyPct) never triggers a reset', async () => { + for (let pct = 10; pct <= 50; pct += 10) { + const before = await getUsageBudgetSnapshots(TEST_ORG); + const lastPct = before.length > 0 ? before[before.length - 1].weeklyPct : -Infinity; + if (pct < lastPct - 5) { + await clearUsageBudgetSnapshots(TEST_ORG); + } + await appendUsageBudgetSnapshot(TEST_ORG, makeSnap(pct, pct * 1_000)); + } + const result = await getUsageBudgetSnapshots(TEST_ORG); + expect(result).toHaveLength(5); + expect(result.map(s => s.weeklyPct)).toEqual([10, 20, 30, 40, 50]); + }); +}); + +// ── usageBudgetSnapshotsKey: format contract ────────────────────────────────── +// The content script reads snapshots directly from chrome.storage.local using +// this exported key builder. If the format changes, the content script silently +// reads empty snapshots. This test pins the format so a rename is a compile error +// (wrong export name) or a test failure (changed format string). + +describe('usageBudgetSnapshotsKey format contract', () => { + it('produces the expected key string for a given orgId', () => { + const orgId = 'abc-123-def'; + expect(usageBudgetSnapshotsKey(orgId)).toBe('usageBudgetSnapshots:abc-123-def'); + }); + + it('throws on empty accountId (mirrors assertAccountId guard)', () => { + expect(() => usageBudgetSnapshotsKey('')).toThrow( + '[LCO] accountId required for scoped storage key', + ); + }); + + it('snapshots stored via appendUsageBudgetSnapshot are readable at usageBudgetSnapshotsKey', async () => { + const orgId = 'key-format-test-org'; + const snap: UsageBudgetSnapshot = { timestamp: 9_999, weeklyPct: 42, sessionPct: 15 }; + await appendUsageBudgetSnapshot(orgId, snap); + // Read back via mock store at the exact key the content script will use. + const key = usageBudgetSnapshotsKey(orgId); + const raw = mockStore._raw[key]; + expect(Array.isArray(raw)).toBe(true); + expect((raw as UsageBudgetSnapshot[])[0].weeklyPct).toBe(42); + }); +}); diff --git a/tests/unit/overlay-weekly-cap.test.ts b/tests/unit/overlay-weekly-cap.test.ts index 1ac7826..c2a40c7 100644 --- a/tests/unit/overlay-weekly-cap.test.ts +++ b/tests/unit/overlay-weekly-cap.test.ts @@ -7,9 +7,10 @@ import { describe, it, expect, beforeEach } from 'vitest'; import { createOverlay } from '../../ui/overlay'; -import { INITIAL_STATE, applyUsageBudget } from '../../lib/overlay-state'; +import { INITIAL_STATE, applyUsageBudget, applyWeeklyEta } from '../../lib/overlay-state'; import { computeUsageBudget, classifyZone } from '../../lib/usage-budget'; import type { UsageLimitsData, UsageBudgetSession, UsageBudgetCredit, BudgetZone } from '../../lib/message-types'; +import type { WeeklyEta } from '../../lib/weekly-cap-eta'; // ── Fixtures ────────────────────────────────────────────────────────────────── @@ -230,3 +231,78 @@ describe('overlay/side-panel weeklyPct invariant', () => { expect(classifyZone(budget.weeklyPct)).toBe('moderate'); }); }); + +// ── Weekly ETA label in overlay (GET-21) ───────────────────────────────────── + +function getEtaEl(shadow: ShadowRoot): HTMLElement | null { + return shadow.querySelector('.lco-weekly-eta'); +} + +function makeEta(etaTimestamp: number, confidence: WeeklyEta['confidence'] = 'high'): WeeklyEta { + return { etaTimestamp, hoursRemaining: 6, confidence }; +} + +describe('overlay ETA label', () => { + it('ETA element is present in the DOM after mount', () => { + const { shadow } = mountOverlay(); + expect(getEtaEl(shadow)).not.toBeNull(); + }); + + it('is hidden when weeklyEta is null', () => { + const { shadow } = mountOverlay(); + expect(getEtaEl(shadow)!.style.display).toBe('none'); + }); + + it('is visible when budget is session AND weeklyEta is non-null', () => { + const { overlay, shadow } = mountOverlay(); + const state = applyWeeklyEta( + applyUsageBudget(INITIAL_STATE, makeBudget(50)), + makeEta(NOW + 6 * 60 * 60 * 1000), + ); + overlay.render(state); + expect(getEtaEl(shadow)!.style.display).not.toBe('none'); + }); + + it('shows a non-empty label containing "at this pace"', () => { + const { overlay, shadow } = mountOverlay(); + const state = applyWeeklyEta( + applyUsageBudget(INITIAL_STATE, makeBudget(50)), + makeEta(NOW + 6 * 60 * 60 * 1000), + ); + overlay.render(state); + expect(getEtaEl(shadow)!.textContent).toMatch(/at this pace/i); + }); + + it('hides when weeklyEta is cleared back to null', () => { + const { overlay, shadow } = mountOverlay(); + overlay.render(applyWeeklyEta( + applyUsageBudget(INITIAL_STATE, makeBudget(50)), + makeEta(NOW + 6 * 60 * 60 * 1000), + )); + overlay.render(applyWeeklyEta( + applyUsageBudget(INITIAL_STATE, makeBudget(50)), + null, + )); + expect(getEtaEl(shadow)!.style.display).toBe('none'); + }); + + it('hides when budget is credit even if weeklyEta is non-null', () => { + const { overlay, shadow } = mountOverlay(); + const credit: UsageBudgetCredit = { + kind: 'credit', + monthlyLimitCents: 50000, + usedCents: 30491, + utilizationPct: 60.982, + currency: 'USD', + resetLabel: 'Resets May 1', + zone: 'moderate', + statusLabel: '$304.91 of $500.00 spent', + }; + const state = applyWeeklyEta( + applyUsageBudget(INITIAL_STATE, credit), + makeEta(NOW + 6 * 60 * 60 * 1000), + ); + overlay.render(state); + expect(getEtaEl(shadow)!.style.display).toBe('none'); + }); +}); diff --git a/tests/unit/usage-budget-card.test.tsx b/tests/unit/usage-budget-card.test.tsx index 0b5622f..c0f205d 100644 --- a/tests/unit/usage-budget-card.test.tsx +++ b/tests/unit/usage-budget-card.test.tsx @@ -17,6 +17,7 @@ import React from 'react'; 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'; // ── Fixtures ───────────────────────────────────────────────────────────────── @@ -149,3 +150,56 @@ describe('UsageBudgetCard — credit variant', () => { expect(screen.queryByText(/Open claude.ai/)).toBeNull(); }); }); + +// ── Weekly ETA (GET-21) ─────────────────────────────────────────────────────── +// The ETA line renders under the weekly bar in the session variant only. +// It is hidden when eta is null, absent on credit/unsupported variants. + +describe('UsageBudgetCard — weekly ETA', () => { + const NOW = new Date('2026-04-09T12:00:00.000Z').getTime(); + // ETA is set to 6 hours from NOW so formatEtaLabel produces a deterministic weekday. + const etaTimestamp = NOW + 6 * 60 * 60 * 1000; + + function makeEta(confidence: WeeklyEta['confidence'] = 'high'): WeeklyEta { + return { etaTimestamp, hoursRemaining: 6, confidence }; + } + + it('renders the ETA line when eta is non-null on the session variant', () => { + render(); + expect(screen.getByText(/At this pace/)).toBeTruthy(); + }); + + it('does not render the ETA line when eta is null', () => { + render(); + expect(screen.queryByText(/At this pace/)).toBeNull(); + expect(screen.queryByText(/Estimating/)).toBeNull(); + expect(screen.queryByText(/Estimated cap/)).toBeNull(); + }); + + it('does not render the ETA line when weeklyEta prop is omitted', () => { + render(); + expect(screen.queryByText(/At this pace/)).toBeNull(); + }); + + it('shows "At this pace" copy for high confidence', () => { + render(); + expect(screen.getByText(/At this pace, you'll hit your weekly cap by/)).toBeTruthy(); + }); + + it('shows "Estimated cap" copy for medium confidence', () => { + render(); + expect(screen.getByText(/Estimated cap:/)).toBeTruthy(); + expect(screen.getByText(/Estimate firms up/)).toBeTruthy(); + }); + + it('shows "Estimating" copy for low confidence', () => { + render(); + expect(screen.getByText(/Estimating:/)).toBeTruthy(); + expect(screen.getByText(/Need more data/)).toBeTruthy(); + }); + + it('does not render ETA on the credit (Enterprise) variant', () => { + render(); + expect(screen.queryByText(/At this pace/)).toBeNull(); + }); +}); diff --git a/tests/unit/weekly-cap-eta.test.ts b/tests/unit/weekly-cap-eta.test.ts new file mode 100644 index 0000000..ebfdae0 --- /dev/null +++ b/tests/unit/weekly-cap-eta.test.ts @@ -0,0 +1,250 @@ +// tests/unit/weekly-cap-eta.test.ts +// Unit tests for the Weekly Cap ETA Agent (lib/weekly-cap-eta.ts). +// +// Covers every acceptance criterion from GET-21: +// AC1: stable rising usage → ETA shown +// AC2: flat or decreasing → null +// AC3: confidence degrades with low sample / poor fit +// AC4: hidden on credit/unsupported (enforced at call site, not tested here) +// AC5: stable-rising, flat, decreasing, low-sample, post-reset test cases +// AC6: no ETA immediately after weekly reset (cleared snapshots → null) + +import { describe, it, expect } from 'vitest'; +import { + computeWeeklyEta, + formatEtaLabel, + MIN_SNAPSHOTS_FOR_ETA, + type UsageBudgetSnapshot, +} from '../../lib/weekly-cap-eta'; + +// ── Time constants ──────────────────────────────────────────────────────────── + +const BASE = new Date('2026-04-07T00:00:00.000Z').getTime(); +const HOUR_MS = 60 * 60 * 1000; +const DAY_MS = 24 * HOUR_MS; + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function snap(timestamp: number, weeklyPct: number, sessionPct = 10): UsageBudgetSnapshot { + return { timestamp, weeklyPct, sessionPct }; +} + +/** + * Build a perfectly-linear rising series: + * starts at startPct, advances by ratePerHour every hour, n points total. + * "now" is placed 1ms after the last snapshot so the ETA is always in the future. + */ +function linearSeries( + startPct: number, + ratePerHour: number, + n: number, + baseMs = BASE, +): UsageBudgetSnapshot[] { + return Array.from({ length: n }, (_, i) => + snap(baseMs + i * HOUR_MS, startPct + i * ratePerHour), + ); +} + +/** "now" is 1ms after the last snapshot of a series built with linearSeries. */ +function nowAfter(n: number, baseMs = BASE): number { + return baseMs + (n - 1) * HOUR_MS + 1; +} + +// ── AC5/AC1: stable-rising series ──────────────────────────────────────────── + +describe('computeWeeklyEta:stable rising usage', () => { + it('returns a non-null result when usage rises at a stable rate', () => { + // 10 snapshots, rising 5%/hr: starts at 20, hits 100 in 16h from start. + const snaps = linearSeries(20, 5, 10); + const now = nowAfter(10); + const result = computeWeeklyEta(snaps, now); + expect(result).not.toBeNull(); + }); + + it('returns hoursRemaining > 0', () => { + const snaps = linearSeries(20, 5, 10); + const now = nowAfter(10); + const result = computeWeeklyEta(snaps, now); + expect(result!.hoursRemaining).toBeGreaterThan(0); + }); + + it('projects ETA accurately for a perfect linear trend', () => { + // 20% at t=0, rising 5%/hr. Hits 100% at t = (100-20)/5 = 16h from base. + const snaps = linearSeries(20, 5, 10); + const now = nowAfter(10); + const result = computeWeeklyEta(snaps, now); + const expectedEta = BASE + 16 * HOUR_MS; + // Allow ±1 minute tolerance for floating-point + expect(Math.abs(result!.etaTimestamp - expectedEta)).toBeLessThan(60_000); + }); + + it('assigns high confidence for a large perfectly-linear series', () => { + const snaps = linearSeries(10, 5, 15); // 15 samples, perfect R² + const now = nowAfter(15); + const result = computeWeeklyEta(snaps, now); + expect(result!.confidence).toBe('high'); + }); + + it('assigns medium confidence for a 7-sample series with R² >= 0.7', () => { + // 7 samples, mostly linear but with small noise + const base = linearSeries(10, 8, 7); + // Add tiny noise to each point to drop R² slightly below 0.9 + const noisy = base.map((s, i) => ({ ...s, weeklyPct: s.weeklyPct + (i % 2 === 0 ? 1 : -1) })); + const now = nowAfter(7); + const result = computeWeeklyEta(noisy, now); + // Result may be null if noise breaks the slope, but when it returns + // confidence must be medium or low (not high) for a noisy 7-sample series. + if (result !== null) { + expect(['medium', 'low']).toContain(result.confidence); + } + }); + + it('assigns low confidence for a 5-sample series with moderate noise', () => { + // Exactly MIN_SNAPSHOTS_FOR_ETA, slight upward trend + const snaps = [ + snap(BASE, 20), + snap(BASE + 1 * HOUR_MS, 25), + snap(BASE + 2 * HOUR_MS, 22), // noise dip + snap(BASE + 3 * HOUR_MS, 30), + snap(BASE + 4 * HOUR_MS, 35), + ]; + const now = BASE + 4 * HOUR_MS + 1; + const result = computeWeeklyEta(snaps, now); + if (result !== null) { + expect(result.confidence).toBe('low'); + } + }); +}); + +// ── AC5/AC2: flat usage ─────────────────────────────────────────────────────── + +describe('computeWeeklyEta:flat usage', () => { + it('returns null when range < 10pp across a 24h+ span', () => { + // 10 snapshots over 25 hours, all clustered around 40% + const snaps = Array.from({ length: 10 }, (_, i) => + snap(BASE + i * (DAY_MS / 9) * 1.04, 40 + (i % 3)), // max-min = 2pp + ); + const now = BASE + 10 * HOUR_MS + 1; + expect(computeWeeklyEta(snaps, now)).toBeNull(); + }); + + it('does not return null for a rising series shorter than 24h', () => { + // 10 snapshots over 10 hours, clear positive slope (3%/hr). + // Span is < 24h so the flatness guard cannot fire; the slope guard + // also cannot fire because the rate is well above zero. + const snaps = Array.from({ length: 10 }, (_, i) => + snap(BASE + i * HOUR_MS, 10 + i * 3), + ); + const result = computeWeeklyEta(snaps, nowAfter(10)); + expect(result).not.toBeNull(); + expect(result!.hoursRemaining).toBeGreaterThan(0); + }); +}); + +// ── AC5/AC2: decreasing usage ───────────────────────────────────────────────── + +describe('computeWeeklyEta:decreasing usage', () => { + it('returns null when weeklyPct is steadily declining', () => { + const snaps = linearSeries(80, -5, 8); // drops from 80 down + const now = nowAfter(8); + expect(computeWeeklyEta(snaps, now)).toBeNull(); + }); + + it('returns null when slope is exactly zero', () => { + const snaps = Array.from({ length: 6 }, (_, i) => + snap(BASE + i * HOUR_MS, 50), // constant + ); + const now = nowAfter(6); + expect(computeWeeklyEta(snaps, now)).toBeNull(); + }); +}); + +// ── AC5: low-sample ─────────────────────────────────────────────────────────── + +describe('computeWeeklyEta:low sample count', () => { + it('returns null with 0 snapshots', () => { + expect(computeWeeklyEta([], BASE)).toBeNull(); + }); + + it('returns null with 1 snapshot', () => { + expect(computeWeeklyEta([snap(BASE, 50)], BASE + 1)).toBeNull(); + }); + + it(`returns null with ${MIN_SNAPSHOTS_FOR_ETA - 1} snapshots`, () => { + const snaps = linearSeries(10, 5, MIN_SNAPSHOTS_FOR_ETA - 1); + expect(computeWeeklyEta(snaps, nowAfter(MIN_SNAPSHOTS_FOR_ETA - 1))).toBeNull(); + }); + + it(`returns non-null with exactly ${MIN_SNAPSHOTS_FOR_ETA} snapshots`, () => { + const snaps = linearSeries(10, 10, MIN_SNAPSHOTS_FOR_ETA); + const now = nowAfter(MIN_SNAPSHOTS_FOR_ETA); + expect(computeWeeklyEta(snaps, now)).not.toBeNull(); + }); +}); + +// ── AC6: post-reset (cleared snapshots) ─────────────────────────────────────── + +describe('computeWeeklyEta:post weekly reset', () => { + it('returns null immediately after reset (0 snapshots after clear)', () => { + // Clearing snapshots is handled by clearUsageBudgetSnapshots in background.ts. + // From the agent's perspective this is identical to the low-sample case. + expect(computeWeeklyEta([], BASE)).toBeNull(); + }); + + it('returns null when only a few post-reset snapshots have accumulated', () => { + const snaps = linearSeries(5, 5, 3); // only 3 after reset + const now = nowAfter(3); + expect(computeWeeklyEta(snaps, now)).toBeNull(); + }); +}); + +// ── ETA boundary guards ─────────────────────────────────────────────────────── + +describe('computeWeeklyEta:ETA boundary guards', () => { + it('returns null when the projected ETA is already in the past', () => { + // Build a series that was "supposed to" hit 100% in the past. + // Shift "now" to be long after the last snapshot. + const snaps = linearSeries(90, 3, 5); + // now = 10 days after base, so the ETA (which hits 100 very quickly) is in the past + const now = BASE + 10 * DAY_MS; + expect(computeWeeklyEta(snaps, now)).toBeNull(); + }); + + it('returns null when the projected ETA is more than 7 days away', () => { + // Very slow rate: 0.01%/hr → 100% in ~(100/0.01)h = 10,000h >> 7 days + const snaps = linearSeries(10, 0.01, 10); + const now = nowAfter(10); + expect(computeWeeklyEta(snaps, now)).toBeNull(); + }); +}); + +// ── formatEtaLabel ──────────────────────────────────────────────────────────── + +describe('formatEtaLabel', () => { + it('returns a non-empty string for any valid timestamp', () => { + const label = formatEtaLabel(BASE + 6 * HOUR_MS); + expect(typeof label).toBe('string'); + expect(label.length).toBeGreaterThan(0); + }); + + it('contains a weekday abbreviation', () => { + const ts = BASE + 6 * HOUR_MS; + const expectedWeekday = new Intl.DateTimeFormat(undefined, { weekday: 'short' }).format(new Date(ts)); + expect(formatEtaLabel(ts)).toContain(expectedWeekday); + }); +}); + +// ── Unsorted input ──────────────────────────────────────────────────────────── + +describe('computeWeeklyEta:unsorted input', () => { + it('produces the same result regardless of input order', () => { + const ordered = linearSeries(10, 5, 10); + const shuffled = [...ordered].reverse(); + const now = nowAfter(10); + const r1 = computeWeeklyEta(ordered, now); + const r2 = computeWeeklyEta(shuffled, now); + expect(r1).not.toBeNull(); + expect(r2).not.toBeNull(); + expect(Math.abs(r1!.etaTimestamp - r2!.etaTimestamp)).toBeLessThan(1); + }); +}); diff --git a/ui/overlay.ts b/ui/overlay.ts index b70d966..459dbad 100644 --- a/ui/overlay.ts +++ b/ui/overlay.ts @@ -10,6 +10,7 @@ import type { ContextSignal } from '../lib/context-intelligence'; import { classifyZone } from '../lib/usage-budget'; import type { PreSubmitEstimate } from '../lib/pre-submit'; import type { AttachmentBreakdownItem } from '../lib/attachment-cost'; +import { formatEtaLabel } from '../lib/weekly-cap-eta'; export interface OverlayHandle { mount(shadow: ShadowRoot): void; @@ -135,6 +136,7 @@ export function createOverlay(): OverlayHandle { let elWeeklyRow: HTMLElement | null = null; let elWeeklyFill: HTMLElement | null = null; let elWeeklyLabel: HTMLElement | null = null; + let elWeeklyEta: HTMLElement | null = null; function mount(shadow: ShadowRoot): void { const style = document.createElement('style'); @@ -345,6 +347,14 @@ export function createOverlay(): OverlayHandle { weeklyRow.appendChild(weeklyLabel); body.appendChild(weeklyRow); + // ETA label: shown below the weekly bar when a projection is available. + // Hidden by default; revealed by render() when weeklyEta is non-null. + const weeklyEta = document.createElement('div'); + weeklyEta.className = 'lco-weekly-eta'; + weeklyEta.style.display = 'none'; + elWeeklyEta = weeklyEta; + body.appendChild(weeklyEta); + // Divider: hidden until first request completes const divider = document.createElement('div'); divider.className = 'lco-divider'; @@ -581,6 +591,18 @@ export function createOverlay(): OverlayHandle { } } + if (elWeeklyEta) { + const eta = state.weeklyEta; + // ETA is only shown on session-tier budget: guard against credit/null budget. + const budgetIsSession = state.usageBudget?.kind === 'session'; + if (eta !== null && budgetIsSession) { + elWeeklyEta.textContent = `~${formatEtaLabel(eta.etaTimestamp)} at this pace`; + elWeeklyEta.style.display = ''; + } else { + elWeeklyEta.style.display = 'none'; + } + } + const sessionVisible = state.session.requestCount > 0; if (elDivider) elDivider.style.display = sessionVisible ? '' : 'none'; if (elSessionRow) elSessionRow.style.display = sessionVisible ? '' : 'none';