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 <noreply@anthropic.com>
This commit is contained in:
toxeh
2026-08-18 07:17:09 -04:00
co-authored by Claude
parent 2e29100591
commit f6f4206846
5 changed files with 842 additions and 0 deletions
@@ -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(
<PlanUsageIndicator
status="unavailable"
snapshot={{ present: false, message: 'down' }}
/>,
);
expect(screen.getByText(/Usage/)).toBeTruthy();
});
it('renders bar percent and short reset on happy path', () => {
const { container } = render(
<PlanUsageIndicator
status="ready"
snapshot={{
present: true,
capacityPct: 47,
resetAt: '2026-07-28T00:00:00Z',
periodType: '7d',
}}
/>,
);
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(
<PlanUsageIndicator
status="ready"
snapshot={{
present: true,
capacityPct: 10,
resetAt: far.toISOString(),
periodStart: start.toISOString(),
}}
/>,
);
expect(container.querySelector('.pace-green')).toBeTruthy();
});
it('returns null when idle', () => {
const { container } = render(<PlanUsageIndicator status="idle" snapshot={null} />);
expect(container.firstChild).toBeNull();
});
});
@@ -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<PlanUsageIndicatorProps> = memo(({
snapshot,
status,
}) => {
const { t, i18n } = useTranslation();
const [windowId, setWindowId] = useState<string | null>(() => 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 (
<div
className="plan-usage loading has-tooltip"
data-tooltip={t('chat.planUsage.loading', { defaultValue: 'Loading usage…' })}
aria-label={t('chat.planUsage.loading', { defaultValue: 'Loading usage…' })}
>
<span className="plan-usage-label"></span>
</div>
);
}
if (!present) {
return (
<div
className="plan-usage unavailable has-tooltip"
data-tooltip={tooltip}
aria-label={tooltip}
>
<span className="plan-usage-label">
{t('chat.planUsage.dash', { defaultValue: 'Usage —' })}
</span>
</div>
);
}
const fillWidth = `${tp}%`;
const rounded = Math.round(tp);
const labelPct = tp > 0 && rounded === 0 ? '<1%' : `${rounded}%`;
return (
<div
className={`plan-usage pace-${color} has-tooltip`}
data-tooltip={tooltip}
aria-label={tooltip}
>
<div className="plan-usage-bar" aria-hidden>
<div className="plan-usage-fill" style={{ width: fillWidth }} />
</div>
<span className="plan-usage-pct">{labelPct}</span>
{canSwitch || winLabel !== '·' ? (
<button
type="button"
className={`plan-usage-window${canSwitch ? ' switchable' : ''}`}
onClick={onCycleWindow}
disabled={!canSwitch}
title={
canSwitch
? t('chat.planUsage.clickToSwitch', {
defaultValue: 'Click to switch between windows',
})
: undefined
}
>
{winLabel}
</button>
) : null}
{shortReset ? (
<span className="plan-usage-reset">{shortReset}</span>
) : null}
{/* Worst pace across all windows — after reset date */}
<span
className={`plan-usage-worst-dot pace-${worstColor}`}
aria-hidden
title={
worstColor !== 'neutral'
? t('chat.planUsage.worstDot', {
color: worstColor,
defaultValue: 'Worst window: {{color}}',
})
: undefined
}
/>
</div>
);
});
PlanUsageIndicator.displayName = 'PlanUsageIndicator';
@@ -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);
}
+103
View File
@@ -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('');
});
});
+359
View File
@@ -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 0100 (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], 0100.
* 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<PlanUsageSnapshot, 'resetAt' | 'periodStart' | 'periodType'>,
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<PaceColor, number> = {
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<string, unknown>;
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<string, unknown>;
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;
}