mirror of
https://github.com/zhukunpenglinyutong/jetbrains-cc-gui.git
synced 2026-09-17 17:38:27 +08:00
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:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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('');
|
||||
});
|
||||
});
|
||||
@@ -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<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;
|
||||
}
|
||||
Reference in New Issue
Block a user