From f6f42068465fb688ab853c2d12d84ae025bd0001 Mon Sep 17 00:00:00 2001 From: toxeh Date: Tue, 18 Aug 2026 07:04:17 -0400 Subject: [PATCH] feat(webview): plan-usage indicator component + pace utils Adds the provider-agnostic plan-usage UI for the chat input ContextBar: - planUsagePace.ts: pace coloring (TP = usage %, TT = linear time budget through the reset window), capacity payload normalization into a PlanUsageSnapshot, window resolution/switching (5h/7d) with persisted selection, and reset formatting (short bar label without trailing period + full tooltip datetime). - PlanUsageIndicator.tsx: mini bar + % + window chip + short reset + worst-window dot; tooltip lists all windows and reset times. - context-bar.css: .plan-usage* styles (green/yellow/red pace colors). - Unit tests for the pace utils and the indicator. Co-Authored-By: Claude --- .../ChatInputBox/PlanUsageIndicator.test.tsx | 57 +++ .../ChatInputBox/PlanUsageIndicator.tsx | 195 ++++++++++ .../ChatInputBox/styles/context-bar.css | 128 +++++++ webview/src/utils/planUsagePace.test.ts | 103 +++++ webview/src/utils/planUsagePace.ts | 359 ++++++++++++++++++ 5 files changed, 842 insertions(+) create mode 100644 webview/src/components/ChatInputBox/PlanUsageIndicator.test.tsx create mode 100644 webview/src/components/ChatInputBox/PlanUsageIndicator.tsx create mode 100644 webview/src/utils/planUsagePace.test.ts create mode 100644 webview/src/utils/planUsagePace.ts diff --git a/webview/src/components/ChatInputBox/PlanUsageIndicator.test.tsx b/webview/src/components/ChatInputBox/PlanUsageIndicator.test.tsx new file mode 100644 index 00000000..95083f02 --- /dev/null +++ b/webview/src/components/ChatInputBox/PlanUsageIndicator.test.tsx @@ -0,0 +1,57 @@ +import { describe, expect, it } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { PlanUsageIndicator } from './PlanUsageIndicator'; + +describe('PlanUsageIndicator', () => { + it('shows Usage — when unavailable', () => { + render( + , + ); + expect(screen.getByText(/Usage/)).toBeTruthy(); + }); + + it('renders bar percent and short reset on happy path', () => { + const { container } = render( + , + ); + expect(screen.getByText('47%')).toBeTruthy(); + expect(container.querySelector('.plan-usage-bar')).toBeTruthy(); + expect(container.querySelector('.plan-usage-fill')).toBeTruthy(); + }); + + it('applies pace color class from TP vs TT', () => { + // end far future, start far past → TT high → TP low → green + const far = new Date(); + far.setDate(far.getDate() + 3); + const start = new Date(); + start.setDate(start.getDate() - 4); + const { container } = render( + , + ); + expect(container.querySelector('.pace-green')).toBeTruthy(); + }); + + it('returns null when idle', () => { + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); +}); diff --git a/webview/src/components/ChatInputBox/PlanUsageIndicator.tsx b/webview/src/components/ChatInputBox/PlanUsageIndicator.tsx new file mode 100644 index 00000000..b8d04d9e --- /dev/null +++ b/webview/src/components/ChatInputBox/PlanUsageIndicator.tsx @@ -0,0 +1,195 @@ +import React, { memo, useCallback, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + clampPercent, + formatFullReset, + formatShortReset, + nextWindowId, + paceColor, + readStoredWindowId, + resolveDisplayWindow, + resolveTimeBudget, + windowShortLabel, + worstPaceColor, + writeStoredWindowId, + type PlanUsageSnapshot, +} from '../../utils/planUsagePace'; + +export interface PlanUsageIndicatorProps { + snapshot: PlanUsageSnapshot | null; + status: 'idle' | 'loading' | 'ready' | 'unavailable'; +} + +/** + * Layout D: mini progress bar + % + window switcher + short reset. + * Click the window chip (5h / 7d) to cycle between windows. + */ +export const PlanUsageIndicator: React.FC = memo(({ + snapshot, + status, +}) => { + const { t, i18n } = useTranslation(); + const [windowId, setWindowId] = useState(() => readStoredWindowId()); + + const display = useMemo(() => { + if (!snapshot?.present) return null; + return resolveDisplayWindow(snapshot, windowId); + }, [snapshot, windowId]); + + const windows = snapshot?.windows ?? []; + const canSwitch = windows.length > 1; + + const onCycleWindow = useCallback((e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + if (!canSwitch) return; + const next = nextWindowId(windows, display?.windowId ?? windowId); + if (!next) return; + setWindowId(next); + writeStoredWindowId(next); + }, [canSwitch, windows, display?.windowId, windowId]); + + const present = !!display && typeof display.capacityPct === 'number' && !!snapshot?.present; + const tp = present ? clampPercent(display!.capacityPct) : 0; + const tt = present + ? resolveTimeBudget({ + resetAt: display!.resetAt, + periodStart: snapshot?.periodStart, + periodType: display!.periodType, + }) + : null; + // Bar/% = selected window; trailing dot = worst across all windows. + const color = present ? paceColor(tp, tt) : 'neutral'; + const worstColor = present && snapshot ? worstPaceColor(snapshot) : 'neutral'; + const shortReset = present ? formatShortReset(display!.resetAt, i18n.language) : ''; + const fullReset = present ? formatFullReset(display!.resetAt, i18n.language) : ''; + const winLabel = windowShortLabel(display?.windowId || display?.periodType); + + const tooltip = useMemo(() => { + if (!present) { + return snapshot?.message + || t('chat.planUsage.unavailable', { defaultValue: 'Usage unavailable' }); + } + const pct = Math.round(tp); + const period = display?.periodType || display?.windowId || 'limit'; + const lines: string[] = []; + if (fullReset) { + lines.push( + t('chat.planUsage.tooltipWindowWithReset', { + period, + percent: pct, + value: fullReset, + defaultValue: '{{period}} {{percent}}% · Resets {{value}}', + }), + ); + } else { + lines.push( + t('chat.planUsage.tooltipWindow', { + period, + percent: pct, + defaultValue: '{{period}} {{percent}}%', + }), + ); + } + if (windows.length > 1) { + const others = windows + .map((w) => `${w.id} ${Math.round(w.usedPct)}%`) + .join(' · '); + lines.push(others); + if (worstColor !== color && worstColor !== 'neutral' && worstColor !== 'green') { + lines.push( + t('chat.planUsage.worstHint', { + color: worstColor, + defaultValue: 'Dot shows worst window ({{color}})', + }), + ); + } + lines.push( + t('chat.planUsage.clickToSwitch', { + defaultValue: 'Click period label to switch window', + }), + ); + } + return lines.join('\n'); + }, [present, snapshot?.message, tp, fullReset, display, windows, worstColor, color, t]); + + if (status === 'idle') return null; + + if (!present && status === 'loading') { + return ( +
+ +
+ ); + } + + if (!present) { + return ( +
+ + {t('chat.planUsage.dash', { defaultValue: 'Usage —' })} + +
+ ); + } + + const fillWidth = `${tp}%`; + const rounded = Math.round(tp); + const labelPct = tp > 0 && rounded === 0 ? '<1%' : `${rounded}%`; + + return ( +
+
+
+
+ {labelPct} + {canSwitch || winLabel !== '·' ? ( + + ) : null} + {shortReset ? ( + {shortReset} + ) : null} + {/* Worst pace across all windows — after reset date */} + +
+ ); +}); + +PlanUsageIndicator.displayName = 'PlanUsageIndicator'; diff --git a/webview/src/components/ChatInputBox/styles/context-bar.css b/webview/src/components/ChatInputBox/styles/context-bar.css index 43fb8043..aedc1a5a 100644 --- a/webview/src/components/ChatInputBox/styles/context-bar.css +++ b/webview/src/components/ChatInputBox/styles/context-bar.css @@ -365,3 +365,131 @@ button.context-file-placeholder:focus-visible { } } +/* Plan usage indicator (layout D: mini-bar + % + window + short reset) */ +.plan-usage { + display: flex; + align-items: center; + gap: 5px; + max-width: 200px; + min-width: 0; + padding: 2px 6px; + border-radius: 4px; + color: var(--text-secondary); + font-size: 11px; + line-height: 1; + flex-shrink: 1; + white-space: nowrap; + overflow: hidden; +} + +/* Leading dot = worst pace across all windows (not just selected). */ +.plan-usage-worst-dot { + width: 7px; + height: 7px; + border-radius: 50%; + flex-shrink: 0; + background: var(--text-secondary); + opacity: 0.85; +} +.plan-usage-worst-dot.pace-green { + background: #3ba55d; + opacity: 1; +} +.plan-usage-worst-dot.pace-yellow { + background: #d4a017; + opacity: 1; +} +.plan-usage-worst-dot.pace-red { + background: #e34850; + opacity: 1; +} +.plan-usage-worst-dot.pace-neutral { + background: var(--text-secondary); + opacity: 0.5; +} + +.plan-usage-bar { + width: 36px; + height: 4px; + border-radius: 2px; + background: var(--input-border); + overflow: hidden; + flex-shrink: 0; +} + +.plan-usage-fill { + height: 100%; + border-radius: 2px; + background: var(--text-secondary); + transition: width 0.25s ease, background 0.2s ease; +} + +.plan-usage-pct { + font-variant-numeric: tabular-nums; + font-weight: 500; + flex-shrink: 0; +} + +.plan-usage-reset { + overflow: hidden; + text-overflow: ellipsis; + color: var(--text-secondary); + opacity: 0.9; +} + +/* 5h/7d window switcher */ +.plan-usage-window { + flex-shrink: 0; + margin: 0; + padding: 1px 4px; + border: 1px solid var(--input-border); + border-radius: 3px; + background: transparent; + color: var(--text-secondary); + font-size: 10px; + font-weight: 600; + line-height: 1.2; + letter-spacing: 0.02em; + text-transform: lowercase; + cursor: default; +} +.plan-usage-window.switchable { + cursor: pointer; +} +.plan-usage-window.switchable:hover { + background: var(--button-hover); + color: var(--text-primary); +} +.plan-usage-window:disabled { + opacity: 0.85; +} + +.plan-usage.pace-green .plan-usage-fill { + background: #3ba55d; +} +.plan-usage.pace-green .plan-usage-pct { + color: #3ba55d; +} + +.plan-usage.pace-yellow .plan-usage-fill { + background: #d4a017; +} +.plan-usage.pace-yellow .plan-usage-pct { + color: #d4a017; +} + +.plan-usage.pace-red .plan-usage-fill { + background: #e34850; +} +.plan-usage.pace-red .plan-usage-pct { + color: #e34850; +} + +.plan-usage.unavailable { + opacity: 0.75; +} + +.plan-usage-label { + font-size: 11px; + color: var(--text-secondary); +} diff --git a/webview/src/utils/planUsagePace.test.ts b/webview/src/utils/planUsagePace.test.ts new file mode 100644 index 00000000..7e557bf3 --- /dev/null +++ b/webview/src/utils/planUsagePace.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it } from 'vitest'; +import { + formatShortReset, + nextWindowId, + paceColor, + parseCapacityPayload, + resolveDisplayWindow, + windowShortLabel, + worstPaceColor, +} from './planUsagePace'; + +const claudePayload = { + ok: true, + present: true, + provider: 'claude', + source: 'sdk-rate-limit', + capacity_pct: 92, + reset_at: '2026-08-23T03:00:00Z', + period_type: '5h', + windows: [ + { id: '5h', used_pct: 92, reset_at: '2026-08-23T03:00:00Z', period_type: '5h' }, + { id: '7d', used_pct: 21, reset_at: '2026-08-25T00:00:00Z', period_type: '7d' }, + ], +}; + +describe('parseCapacityPayload', () => { + it('normalizes snake_case windows into a snapshot', () => { + const snap = parseCapacityPayload(claudePayload); + expect(snap.present).toBe(true); + expect(snap.capacityPct).toBe(92); + expect(snap.provider).toBe('claude'); + expect(snap.windows?.map((w) => w.id)).toEqual(['5h', '7d']); + expect(snap.windows?.[1].usedPct).toBe(21); + }); + + it('marks unavailable payloads not present', () => { + const snap = parseCapacityPayload({ present: false, unavailable: true, message: 'no data' }); + expect(snap.present).toBe(false); + expect(snap.message).toBe('no data'); + }); + + it('falls back to the first window when top-level pct is missing', () => { + const snap = parseCapacityPayload({ + windows: [{ id: '5h', used_pct: 42, reset_at: '2026-08-23T03:00:00Z' }], + }); + expect(snap.present).toBe(true); + expect(snap.capacityPct).toBe(42); + expect(snap.periodType).toBeNull(); + }); +}); + +describe('paceColor', () => { + it('green below budget, yellow within +5, red past it, neutral without budget', () => { + expect(paceColor(30, 50)).toBe('green'); + expect(paceColor(52, 50)).toBe('yellow'); + expect(paceColor(56, 50)).toBe('red'); + expect(paceColor(30, null)).toBe('neutral'); + }); +}); + +describe('worstPaceColor', () => { + it('takes the worst window, not the selected one', () => { + // now chosen so 5h is far past its time budget (red) while 7d is far under (green) + const now = new Date('2026-08-22T22:30:00Z'); + const snap = parseCapacityPayload(claudePayload); + expect(worstPaceColor(snap, now)).toBe('red'); + }); +}); + +describe('window switching', () => { + const snap = parseCapacityPayload(claudePayload); + const windows = snap.windows ?? []; + + it('cycles 5h → 7d → 5h', () => { + expect(nextWindowId(windows, '5h')).toBe('7d'); + expect(nextWindowId(windows, '7d')).toBe('5h'); + }); + + it('prefers the stored window id and falls back to top-level binding', () => { + expect(resolveDisplayWindow(snap, '7d')).toMatchObject({ windowId: '7d', capacityPct: 21 }); + expect(resolveDisplayWindow(snap, null).capacityPct).toBe(92); + }); + + it('labels raw ids compactly', () => { + expect(windowShortLabel('5h')).toBe('5h'); + expect(windowShortLabel('7d')).toBe('7d'); + expect(windowShortLabel('WEEKLY')).toBe('7d'); + expect(windowShortLabel(null)).toBe('·'); + }); +}); + +describe('formatShortReset', () => { + it('emits day + short month + 24h time with no trailing period', () => { + const label = formatShortReset('2026-08-23T03:00:00Z', 'en-GB'); + expect(label).not.toMatch(/\.$/); + // timezone-agnostic structure: "23 Aug 03:00" (locale day/time vary by TZ) + expect(label.trim()).toMatch(/^\d{1,2} [A-Za-z]{3,4} \d{2}:\d{2}$/); + }); + + it('returns empty string for missing dates', () => { + expect(formatShortReset(null)).toBe(''); + }); +}); diff --git a/webview/src/utils/planUsagePace.ts b/webview/src/utils/planUsagePace.ts new file mode 100644 index 00000000..92cfbe52 --- /dev/null +++ b/webview/src/utils/planUsagePace.ts @@ -0,0 +1,359 @@ +/** + * Plan-usage pace coloring for the ContextBar usage bar. + * TP = actual usage %, TT = linear time budget through the reset window. + */ + +export type PaceColor = 'green' | 'yellow' | 'red' | 'neutral'; + +/** One rate/billing window from a capacity payload windows[]. */ +export interface CapacityWindow { + id: string; + usedPct: number; + resetAt?: string | null; + periodType?: string | null; +} + +export interface PlanUsageSnapshot { + present: boolean; + /** Usage percent 0–100 (TP) — binding/worst-case at top level. */ + capacityPct?: number; + /** Period end / next reset (ISO or parseable date string). */ + resetAt?: string | null; + /** Period start when known. */ + periodStart?: string | null; + /** 5h | 7d | … */ + periodType?: string | null; + /** All known windows (e.g. 5h/7d). */ + windows?: CapacityWindow[]; + /** Provider from capacity payload (claude | …). */ + provider?: string; + source?: string; + message?: string; +} + +export const PLAN_USAGE_WINDOW_STORAGE_KEY = 'ccgui.planUsage.windowId'; + +const YELLOW_BAND = 5; + +/** Clamp number to [0, 100]. */ +export function clampPercent(n: number): number { + if (!Number.isFinite(n)) return 0; + return Math.max(0, Math.min(100, n)); +} + +/** + * Derive period start from end + period_type when start is missing. + * 5H → end − 5h; 7D/WEEKLY → end − 7d; MONTHLY → end − 30d. + */ +export function derivePeriodStart( + resetAt: Date, + periodType?: string | null, +): Date | null { + // Accept raw enums too, e.g. USAGE_PERIOD_TYPE_WEEKLY from xAI billing. + const t = (periodType || '').toUpperCase(); + if (t === '5H' || t === '5HR' || t.includes('5H')) { + return new Date(resetAt.getTime() - 5 * 60 * 60 * 1000); + } + if (t === '7D' || t === '7DAY' || (t.includes('7D') && !t.includes('WEEK'))) { + return new Date(resetAt.getTime() - 7 * 24 * 60 * 60 * 1000); + } + if (t === 'WEEKLY' || t === 'WEEK' || t.includes('WEEK')) { + return new Date(resetAt.getTime() - 7 * 24 * 60 * 60 * 1000); + } + if (t === 'MONTHLY' || t === 'MONTH' || t.includes('MONTH')) { + return new Date(resetAt.getTime() - 30 * 24 * 60 * 60 * 1000); + } + return null; +} + +export function parseDate(value?: string | null): Date | null { + if (!value || typeof value !== 'string') return null; + const d = new Date(value); + return Number.isFinite(d.getTime()) ? d : null; +} + +/** + * TT = linear time progress through [start, end], 0–100. + * Returns null when window cannot be determined. + */ +export function computeTimeBudgetPercent( + now: Date, + periodStart?: Date | null, + periodEnd?: Date | null, +): number | null { + if (!periodStart || !periodEnd) return null; + const start = periodStart.getTime(); + const end = periodEnd.getTime(); + if (!(end > start)) return null; + const t = now.getTime(); + return clampPercent(((t - start) / (end - start)) * 100); +} + +/** + * Resolve TT from snapshot fields (explicit start or period_type). + */ +export function resolveTimeBudget( + snapshot: Pick, + now: Date = new Date(), +): number | null { + const end = parseDate(snapshot.resetAt ?? null); + if (!end) return null; + let start = parseDate(snapshot.periodStart ?? null); + if (!start) { + start = derivePeriodStart(end, snapshot.periodType); + } + return computeTimeBudgetPercent(now, start, end); +} + +/** + * Pace color: TP < TT green; TT ≤ TP ≤ TT+5 yellow; TP > TT+5 red; no TT → neutral. + */ +export function paceColor(tp: number, tt: number | null): PaceColor { + if (tt === null || !Number.isFinite(tt)) return 'neutral'; + const usage = clampPercent(tp); + const budget = clampPercent(tt); + if (usage < budget) return 'green'; + if (usage <= budget + YELLOW_BAND) return 'yellow'; + return 'red'; +} + +const PACE_RANK: Record = { + neutral: 0, + green: 1, + yellow: 2, + red: 3, +}; + +/** Worse of two pace colors (red > yellow > green > neutral). */ +export function worsePace(a: PaceColor, b: PaceColor): PaceColor { + return PACE_RANK[a] >= PACE_RANK[b] ? a : b; +} + +/** + * Worst pace across all windows (or single top-level snapshot when no windows[]). + * Used for the leading status dot so a green selected window cannot hide a red other. + */ +export function worstPaceColor( + snapshot: PlanUsageSnapshot, + now: Date = new Date(), +): PaceColor { + if (!snapshot.present) return 'neutral'; + const windows = snapshot.windows ?? []; + if (windows.length === 0) { + const tt = resolveTimeBudget(snapshot, now); + return paceColor(snapshot.capacityPct ?? 0, tt); + } + let worst: PaceColor = 'neutral'; + for (const w of windows) { + const tt = resolveTimeBudget( + { + resetAt: w.resetAt, + periodStart: snapshot.periodStart, + periodType: w.periodType ?? w.id, + }, + now, + ); + worst = worsePace(worst, paceColor(w.usedPct, tt)); + } + return worst; +} + +/** + * Compact reset label for the bar: day + short month + 24h time in local TZ. + * Example (Europe/Moscow): "1 Aug 03:00". + */ +export function formatShortReset(resetAt?: string | null, locale?: string): string { + const d = parseDate(resetAt ?? null); + if (!d) return ''; + try { + const datePart = d.toLocaleDateString(locale, { day: 'numeric', month: 'short' }); + const timePart = d.toLocaleTimeString(locale, { + hour: '2-digit', + minute: '2-digit', + hour12: false, + }); + // Some locales still emit 24h with a trailing day-period; strip common noise. + const time = timePart.replace(/\u202f/g, ' ').trim(); + // Drop locale abbreviations' trailing dots (e.g. "июл.") — no period after reset. + const date = datePart.replace(/\./g, '').trim(); + return `${date} ${time} `; + } catch { + const hh = String(d.getHours()).padStart(2, '0'); + const mm = String(d.getMinutes()).padStart(2, '0'); + return `${d.getDate()} ${d.getMonth() + 1} ${hh}:${mm} `; + } +} + +/** Full datetime for tooltip. */ +export function formatFullReset(resetAt?: string | null, locale?: string): string { + const d = parseDate(resetAt ?? null); + if (!d) return ''; + try { + return d.toLocaleString(locale); + } catch { + return d.toISOString(); + } +} + +function parseWindows(raw: unknown): CapacityWindow[] { + if (!Array.isArray(raw)) return []; + const out: CapacityWindow[] = []; + for (const item of raw) { + if (!item || typeof item !== 'object') continue; + const w = item as Record; + const id = typeof w.id === 'string' ? w.id.trim() : ''; + if (!id) continue; + const pctRaw = w.used_pct ?? w.usedPct ?? w.capacity_pct ?? w.capacityPct; + const pct = typeof pctRaw === 'number' ? pctRaw : Number(pctRaw); + if (!Number.isFinite(pct)) continue; + out.push({ + id, + usedPct: clampPercent(pct), + resetAt: + typeof w.reset_at === 'string' + ? w.reset_at + : typeof w.resetAt === 'string' + ? w.resetAt + : null, + periodType: + typeof w.period_type === 'string' + ? w.period_type + : typeof w.periodType === 'string' + ? w.periodType + : id, + }); + } + return out; +} + +/** Normalize a capacity JSON payload (snake_case or camelCase) into a snapshot. */ +export function parseCapacityPayload(data: unknown): PlanUsageSnapshot { + if (!data || typeof data !== 'object') { + return { present: false, message: 'invalid capacity payload' }; + } + const o = data as Record; + if (o.present === false || o.unavailable === true) { + return { + present: false, + message: typeof o.message === 'string' ? o.message : 'capacity unavailable', + source: typeof o.source === 'string' ? o.source : undefined, + }; + } + const windows = parseWindows(o.windows); + const pctRaw = o.capacity_pct ?? o.used_pct ?? o.capacityPct; + const pct = typeof pctRaw === 'number' ? pctRaw : Number(pctRaw); + if (!Number.isFinite(pct) && windows.length === 0) { + return { + present: false, + message: 'capacity missing capacity_pct', + source: typeof o.source === 'string' ? o.source : undefined, + }; + } + const topPct = Number.isFinite(pct) + ? clampPercent(pct) + : windows[0]?.usedPct; + return { + present: true, + capacityPct: topPct, + resetAt: typeof o.reset_at === 'string' ? o.reset_at : typeof o.resetAt === 'string' ? o.resetAt : null, + periodType: typeof o.period_type === 'string' ? o.period_type : typeof o.periodType === 'string' ? o.periodType : null, + periodStart: typeof o.period_start === 'string' ? o.period_start : null, + windows: windows.length > 0 ? windows : undefined, + provider: typeof o.provider === 'string' ? o.provider : undefined, + source: typeof o.source === 'string' ? o.source : 'gateway', + }; +} + +/** Prefer stored window id if present; else binding top-level; else first window. */ +export function resolveDisplayWindow( + snapshot: PlanUsageSnapshot, + preferredWindowId?: string | null, +): { + windowId: string | null; + capacityPct: number; + resetAt: string | null | undefined; + periodType: string | null | undefined; +} { + const windows = snapshot.windows ?? []; + const preferred = (preferredWindowId || '').trim(); + const hit = preferred ? windows.find((w) => w.id === preferred) : undefined; + if (hit) { + return { + windowId: hit.id, + capacityPct: hit.usedPct, + resetAt: hit.resetAt ?? snapshot.resetAt, + periodType: hit.periodType ?? hit.id, + }; + } + // No preferred match: keep binding top-level fields when present + if (typeof snapshot.capacityPct === 'number') { + const bindingId = + windows.find( + (w) => + Math.abs(w.usedPct - snapshot.capacityPct!) < 0.05 + && (w.resetAt === snapshot.resetAt || !snapshot.resetAt), + )?.id + ?? (snapshot.periodType + ? windows.find((w) => + (w.periodType || w.id).toLowerCase().includes(String(snapshot.periodType).toLowerCase()) + || String(snapshot.periodType).toLowerCase().includes((w.periodType || w.id).toLowerCase()), + )?.id + : null) + ?? null; + return { + windowId: bindingId, + capacityPct: snapshot.capacityPct, + resetAt: snapshot.resetAt, + periodType: snapshot.periodType, + }; + } + if (windows[0]) { + return { + windowId: windows[0].id, + capacityPct: windows[0].usedPct, + resetAt: windows[0].resetAt, + periodType: windows[0].periodType ?? windows[0].id, + }; + } + return { windowId: null, capacityPct: 0, resetAt: null, periodType: null }; +} + +/** Compact label for window switcher: weekly→7d, monthly→mo, 5h, 7d. */ +export function windowShortLabel(idOrType?: string | null): string { + const t = (idOrType || '').toLowerCase(); + if (!t) return '·'; + if (t === '5h' || t.includes('5h')) return '5h'; + if (t === '7d' || t === '7day' || t.includes('7d')) return '7d'; + if (t.includes('week')) return '7d'; + if (t.includes('month')) return 'mo'; + return t.length > 4 ? t.slice(0, 4) : t; +} + +export function readStoredWindowId(): string | null { + try { + const v = localStorage.getItem(PLAN_USAGE_WINDOW_STORAGE_KEY); + return v && v.trim() ? v.trim() : null; + } catch { + return null; + } +} + +export function writeStoredWindowId(id: string): void { + try { + localStorage.setItem(PLAN_USAGE_WINDOW_STORAGE_KEY, id); + } catch { + // ignore quota / private mode + } +} + +/** Next window id in list (cycle). If preferred missing, start from first. */ +export function nextWindowId( + windows: CapacityWindow[], + currentId?: string | null, +): string | null { + if (windows.length === 0) return null; + if (windows.length === 1) return windows[0].id; + const idx = windows.findIndex((w) => w.id === currentId); + const next = idx < 0 ? 0 : (idx + 1) % windows.length; + return windows[next].id; +}