diff --git a/packages/ui/src/components/layout/Header.tsx b/packages/ui/src/components/layout/Header.tsx index 408af80d..ced619a1 100644 --- a/packages/ui/src/components/layout/Header.tsx +++ b/packages/ui/src/components/layout/Header.tsx @@ -30,11 +30,13 @@ import { cn, getModifierLabel, hasModifier } from '@/lib/utils'; import { useDiffFileCount } from '@/components/views/DiffView'; import { McpDropdown, McpDropdownContent } from '@/components/mcp/McpDropdown'; import { ProviderLogo } from '@/components/ui/ProviderLogo'; -import { formatPercent, formatWindowLabel, QUOTA_PROVIDERS } from '@/lib/quota'; +import { formatPercent, formatWindowLabel, QUOTA_PROVIDERS, calculatePace, calculateExpectedUsagePercent } from '@/lib/quota'; import { UsageProgressBar } from '@/components/sections/usage/UsageProgressBar'; +import { PaceIndicator } from '@/components/sections/usage/PaceIndicator'; import { updateDesktopSettings } from '@/lib/persistence'; import { getAllModelFamilies, + getDisplayModelName, groupModelsByFamily, sortModelFamilies, } from '@/lib/quota/model-families'; @@ -216,6 +218,7 @@ export const Header: React.FC = () => { providerId: string; providerName: string; entries: Array<[string, UsageWindow]>; + error?: string; modelFamilies?: Array<{ familyId: string | null; familyLabel: string; @@ -239,14 +242,15 @@ export const Header: React.FC = () => { providerId: provider.id, providerName: provider.name, entries, + error: (result && !result.ok && result.configured) ? result.error : undefined, }; // Add model families if provider has per-model quotas if (models && Object.keys(models).length > 0) { const providerSelectedModels = selectedModels[provider.id] ?? []; - // hasExplicitSelection = true means user touched the selection (even if empty) - // hasExplicitSelection = false means no preference (key missing) → show all by default - const hasExplicitSelection = provider.id in selectedModels; + // hasExplicitSelection = true means user has selected specific models to show + // If the array exists but is empty, treat as "show all" (user cleared selection) + const hasExplicitSelection = providerSelectedModels.length > 0; const modelGroups = groupModelsByFamily(models, provider.id); const families = getAllModelFamilies(provider.id); const sortedFamilies = sortModelFamilies(families); @@ -310,7 +314,7 @@ export const Header: React.FC = () => { } } - if (entries.length > 0 || (group.modelFamilies && group.modelFamilies.length > 0)) { + if (entries.length > 0 || (group.modelFamilies && group.modelFamilies.length > 0) || group.error) { groups.push(group); } } @@ -826,7 +830,10 @@ export const Header: React.FC = () => {

Instance / Usage / MCP

- +
value={desktopServicesTab} @@ -856,7 +863,7 @@ export const Header: React.FC = () => { {desktopServicesTab === 'usage' && (
-
+
Rate limits @@ -901,7 +908,7 @@ export const Header: React.FC = () => { return ( - + {group.providerName} @@ -912,23 +919,27 @@ export const Header: React.FC = () => { className="cursor-default hover:bg-transparent focus:bg-transparent data-[highlighted]:bg-transparent" onSelect={(event) => event.preventDefault()} > - No rate limits reported. + + {group.error ?? 'No rate limits reported.'} + ) : ( <> - {group.entries.map(([label, window]) => ( + {group.entries.map(([label, window]) => { + const displayPercent = quotaDisplayMode === 'remaining' + ? window.remainingPercent + : window.usedPercent; + const paceInfo = calculatePace(window.usedPercent, window.resetAt, window.windowSeconds, label); + const expectedMarker = paceInfo?.dailyAllocationPercent != null + ? calculateExpectedUsagePercent(paceInfo.elapsedRatio) + : null; + return ( event.preventDefault()} > - {(() => { - const displayPercent = quotaDisplayMode === 'remaining' - ? window.remainingPercent - : window.usedPercent; - return ( - <> {formatWindowLabel(label)} @@ -942,13 +953,21 @@ export const Header: React.FC = () => { {formatPercent(displayPercent) === '-' ? '' : formatPercent(displayPercent)} - - - ); - })()} + + {paceInfo && ( +
+ +
+ )}
- ))} + ); + })} {group.modelFamilies && group.modelFamilies.length > 0 && (
@@ -973,36 +992,42 @@ export const Header: React.FC = () => {
- {family.models.map(([modelName, window]) => ( + {family.models.map(([modelName, window]) => { + const displayPercent = quotaDisplayMode === 'remaining' + ? window.remainingPercent + : window.usedPercent; + // For model-level quotas, use '5h' as typical window label for Google models + const paceInfo = calculatePace(window.usedPercent, window.resetAt, window.windowSeconds, '5h'); + const expectedMarker = paceInfo?.dailyAllocationPercent != null + ? calculateExpectedUsagePercent(paceInfo.elapsedRatio) + : null; + return (
- {modelName} - {(() => { - const displayPercent = quotaDisplayMode === 'remaining' - ? window.remainingPercent - : window.usedPercent; - return ( - - {formatPercent(displayPercent) === '-' ? '' : formatPercent(displayPercent)} - - ); - })()} + {getDisplayModelName(modelName)} + + {formatPercent(displayPercent) === '-' ? '' : formatPercent(displayPercent)} + - {(() => { - const displayPercent = quotaDisplayMode === 'remaining' - ? window.remainingPercent - : window.usedPercent; - return ( - - ); - })()} + + {paceInfo && ( +
+ +
+ )}
- ))} + ); + })}
@@ -1360,14 +1385,23 @@ export const Header: React.FC = () => { )} {rateLimitGroups.map((group) => ( -
+
{group.providerName}
+ {group.entries.length === 0 && (!group.modelFamilies || group.modelFamilies.length === 0) && ( +
+ {group.error ?? 'No rate limits reported.'} +
+ )} {group.entries.map(([label, window]) => { const displayPercent = quotaDisplayMode === 'remaining' ? window.remainingPercent : window.usedPercent; + const paceInfo = calculatePace(window.usedPercent, window.resetAt, window.windowSeconds, label); + const expectedMarker = paceInfo?.dailyAllocationPercent != null + ? calculateExpectedUsagePercent(paceInfo.elapsedRatio) + : null; return (
@@ -1378,7 +1412,17 @@ export const Header: React.FC = () => { {formatPercent(displayPercent)}
- + + {paceInfo && ( +
+ +
+ )}
{window.resetAfterFormatted ?? window.resetAtFormatted ?? ''}
@@ -1414,17 +1458,32 @@ export const Header: React.FC = () => { const displayPercent = quotaDisplayMode === 'remaining' ? window.remainingPercent : window.usedPercent; + // For model-level quotas, use '5h' as typical window label for Google models + const paceInfo = calculatePace(window.usedPercent, window.resetAt, window.windowSeconds, '5h'); + const expectedMarker = paceInfo?.dailyAllocationPercent != null + ? calculateExpectedUsagePercent(paceInfo.elapsedRatio) + : null; return (
- {modelName} + {getDisplayModelName(modelName)} {formatPercent(displayPercent) === '-' ? '' : formatPercent(displayPercent)}
- + + {paceInfo && ( +
+ +
+ )}
); })} diff --git a/packages/ui/src/components/layout/VSCodeLayout.tsx b/packages/ui/src/components/layout/VSCodeLayout.tsx index 2ea58a8c..a221e0eb 100644 --- a/packages/ui/src/components/layout/VSCodeLayout.tsx +++ b/packages/ui/src/components/layout/VSCodeLayout.tsx @@ -17,7 +17,8 @@ import { import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; import { ProviderLogo } from '@/components/ui/ProviderLogo'; import { UsageProgressBar } from '@/components/sections/usage/UsageProgressBar'; -import { formatPercent, formatWindowLabel, QUOTA_PROVIDERS } from '@/lib/quota'; +import { PaceIndicator } from '@/components/sections/usage/PaceIndicator'; +import { formatPercent, formatWindowLabel, QUOTA_PROVIDERS, calculatePace, calculateExpectedUsagePercent } from '@/lib/quota'; import { useQuotaAutoRefresh, useQuotaStore } from '@/stores/useQuotaStore'; import { updateDesktopSettings } from '@/lib/persistence'; import type { UsageWindow } from '@/types'; @@ -447,6 +448,7 @@ const VSCodeHeader: React.FC = ({ title, showBack, onBack, on providerId: string; providerName: string; entries: Array<[string, UsageWindow]>; + error?: string; }> = []; for (const provider of QUOTA_PROVIDERS) { @@ -456,8 +458,9 @@ const VSCodeHeader: React.FC = ({ title, showBack, onBack, on const result = quotaResults.find((entry) => entry.providerId === provider.id); const windows = (result?.usage?.windows ?? {}) as Record; const entries = Object.entries(windows); - if (entries.length > 0) { - groups.push({ providerId: provider.id, providerName: provider.name, entries }); + const error = (result && !result.ok && result.configured) ? result.error : undefined; + if (entries.length > 0 || error) { + groups.push({ providerId: provider.id, providerName: provider.name, entries, error }); } } @@ -527,8 +530,11 @@ const VSCodeHeader: React.FC = ({ title, showBack, onBack, on - -
+ +
Rate limits
@@ -573,9 +579,9 @@ const VSCodeHeader: React.FC = ({ title, showBack, onBack, on
-
- Last updated {formatTime(quotaLastUpdated)} -
+
+
+ Last updated {formatTime(quotaLastUpdated)}
{!hasRateLimits && ( event.preventDefault()}> @@ -584,7 +590,7 @@ const VSCodeHeader: React.FC = ({ title, showBack, onBack, on )} {rateLimitGroups.map((group, index) => ( - + {group.providerName} @@ -594,38 +600,50 @@ const VSCodeHeader: React.FC = ({ title, showBack, onBack, on className="cursor-default" onSelect={(event) => event.preventDefault()} > - No rate limits reported. + + {group.error ?? 'No rate limits reported.'} + ) : ( - group.entries.map(([label, window]) => ( + group.entries.map(([label, window]) => { + const displayPercent = quotaDisplayMode === 'remaining' + ? window.remainingPercent + : window.usedPercent; + const paceInfo = calculatePace(window.usedPercent, window.resetAt, window.windowSeconds, label); + const expectedMarker = paceInfo?.dailyAllocationPercent != null + ? calculateExpectedUsagePercent(paceInfo.elapsedRatio) + : null; + return ( event.preventDefault()} > - {(() => { - const displayPercent = quotaDisplayMode === 'remaining' - ? window.remainingPercent - : window.usedPercent; - return ( - <> {formatWindowLabel(label)} {formatPercent(displayPercent) === '-' ? '' : formatPercent(displayPercent)} - + + {paceInfo && ( +
+ +
+ )} {window.resetAfterFormatted ?? window.resetAtFormatted ?? ''} - - ); - })()}
- )) + ); + }) )} {index < rateLimitGroups.length - 1 && } diff --git a/packages/ui/src/components/sections/usage/PaceIndicator.tsx b/packages/ui/src/components/sections/usage/PaceIndicator.tsx new file mode 100644 index 00000000..31c4be81 --- /dev/null +++ b/packages/ui/src/components/sections/usage/PaceIndicator.tsx @@ -0,0 +1,97 @@ +import React from 'react'; +import { cn } from '@/lib/utils'; +import type { PaceInfo } from '@/lib/quota'; +import { getPaceStatusColor, formatRemainingTime } from '@/lib/quota'; + +interface PaceIndicatorProps { + paceInfo: PaceInfo; + className?: string; + /** Compact mode shows just the status dot and prediction */ + compact?: boolean; +} + +/** + * Visual indicator showing whether usage is on track, slightly fast, or too fast. + * Inspired by opencode-bar's pace visualization. + */ +export const PaceIndicator: React.FC = ({ + paceInfo, + className, + compact = false, +}) => { + const statusColor = getPaceStatusColor(paceInfo.status); + + const statusLabel = React.useMemo(() => { + switch (paceInfo.status) { + case 'on-track': + return 'On track'; + case 'slightly-fast': + return 'Slightly fast'; + case 'too-fast': + return 'Too fast'; + case 'exhausted': + return 'Used up'; + } + }, [paceInfo.status]); + + const predictionTooltip = `Predicted usage at window end based on current pace: ${paceInfo.predictText}`; + + if (compact) { + return ( +
+
+ + {paceInfo.isExhausted ? ( + <>Wait {formatRemainingTime(paceInfo.remainingSeconds)} + ) : ( + <>Pred: {paceInfo.predictText} + )} + +
+ ); + } + + return ( +
+
+ {!paceInfo.isExhausted && ( + + Pace: {paceInfo.paceRateText} + + )} +
+
+ + {paceInfo.isExhausted ? ( + <> + {statusLabel} + · Wait + {formatRemainingTime(paceInfo.remainingSeconds)} + + ) : ( + + Pred: + {paceInfo.predictText} + + )} + +
+
+
+ ); +}; diff --git a/packages/ui/src/components/sections/usage/UsageCard.tsx b/packages/ui/src/components/sections/usage/UsageCard.tsx index 13f69458..a1f916a3 100644 --- a/packages/ui/src/components/sections/usage/UsageCard.tsx +++ b/packages/ui/src/components/sections/usage/UsageCard.tsx @@ -1,7 +1,8 @@ import React from 'react'; import type { UsageWindow } from '@/types'; -import { formatPercent, formatWindowLabel } from '@/lib/quota'; +import { formatPercent, formatWindowLabel, calculatePace, calculateExpectedUsagePercent } from '@/lib/quota'; import { UsageProgressBar } from './UsageProgressBar'; +import { PaceIndicator } from './PaceIndicator'; import { useQuotaStore } from '@/stores/useQuotaStore'; import { Switch } from '@/components/ui/switch'; @@ -29,6 +30,21 @@ export const UsageCard: React.FC = ({ const resetLabel = window.resetAfterFormatted ?? window.resetAtFormatted ?? ''; const windowLabel = formatWindowLabel(title); + // Calculate pace info for the usage window + // Pass the title (window label) to infer windowSeconds when not provided by the API + const paceInfo = React.useMemo(() => { + return calculatePace(window.usedPercent, window.resetAt, window.windowSeconds, title); + }, [window.usedPercent, window.resetAt, window.windowSeconds, title]); + + // Calculate expected marker position for weekly/monthly quotas + const expectedMarkerPercent = React.useMemo(() => { + if (!paceInfo || paceInfo.dailyAllocationPercent === null) { + return null; + } + // Show marker based on elapsed time ratio + return calculateExpectedUsagePercent(paceInfo.elapsedRatio); + }, [paceInfo]); + return (
@@ -52,12 +68,23 @@ export const UsageCard: React.FC = ({
- +
{barLabel}
+ {/* Pace indicator - only shown when we have pace info */} + {paceInfo && ( +
+ +
+ )} +
Resets {resetLabel} diff --git a/packages/ui/src/components/sections/usage/UsagePage.tsx b/packages/ui/src/components/sections/usage/UsagePage.tsx index 675e9baf..14aefcdf 100644 --- a/packages/ui/src/components/sections/usage/UsagePage.tsx +++ b/packages/ui/src/components/sections/usage/UsagePage.tsx @@ -13,7 +13,7 @@ import { } from '@/components/ui/collapsible'; import { RiArrowDownSLine, RiArrowRightSLine } from '@remixicon/react'; import type { UsageWindows, QuotaProviderId } from '@/types'; -import { getAllModelFamilies, sortModelFamilies, groupModelsByFamilyWithGetter } from '@/lib/quota/model-families'; +import { getAllModelFamilies, getDisplayModelName, sortModelFamilies, groupModelsByFamilyWithGetter } from '@/lib/quota/model-families'; const formatTime = (timestamp: number | null) => { if (!timestamp) return '-'; @@ -257,7 +257,7 @@ export const UsagePage: React.FC = () => { { = ({ percent, tonePercent, className }) => { +export const UsageProgressBar: React.FC = ({ + percent, + tonePercent, + className, + expectedMarkerPercent, +}) => { const clamped = clampPercent(percent) ?? 0; const tone = resolveUsageTone(tonePercent ?? percent); + const markerClamped = expectedMarkerPercent != null + ? Math.max(0, Math.min(100, expectedMarkerPercent)) + : null; const fillStyle = tone === 'critical' ? { backgroundColor: 'var(--status-error)' } @@ -19,7 +32,7 @@ export const UsageProgressBar: React.FC = ({ percent, ton : { backgroundColor: 'var(--status-success)' }; return ( -
+
= ({ percent, ton aria-valuemin={0} aria-valuemax={100} /> + {markerClamped != null && markerClamped > 0 && markerClamped < 100 && ( + ); }; diff --git a/packages/ui/src/lib/quota/index.ts b/packages/ui/src/lib/quota/index.ts index 3a9a0895..611121f1 100644 --- a/packages/ui/src/lib/quota/index.ts +++ b/packages/ui/src/lib/quota/index.ts @@ -1,3 +1,14 @@ export { QUOTA_PROVIDERS, QUOTA_PROVIDER_MAP } from './providers'; export type { QuotaProviderMeta } from './providers'; -export { clampPercent, formatPercent, resolveUsageTone, formatWindowLabel } from './utils'; +export { + clampPercent, + formatPercent, + resolveUsageTone, + formatWindowLabel, + calculatePace, + inferWindowSeconds, + getPaceStatusColor, + formatRemainingTime, + calculateExpectedUsagePercent, +} from './utils'; +export type { PaceStatus, PaceInfo } from './utils'; diff --git a/packages/ui/src/lib/quota/model-families.ts b/packages/ui/src/lib/quota/model-families.ts index 3d9faf70..ba68b205 100644 --- a/packages/ui/src/lib/quota/model-families.ts +++ b/packages/ui/src/lib/quota/model-families.ts @@ -7,17 +7,50 @@ export interface ModelFamily { order: number; } +/** + * Strip auth source prefix from model name for display. + * e.g., "gemini/gemini-2.5-flash" -> "gemini-2.5-flash" + * "antigravity/claude-sonnet" -> "claude-sonnet" + */ +export function getDisplayModelName(modelName: string): string { + // Handle prefixes like "gemini/", "antigravity/" + const slashIndex = modelName.indexOf('/'); + if (slashIndex !== -1) { + const prefix = modelName.substring(0, slashIndex); + // Check if it's an auth source prefix + if (prefix === 'gemini' || prefix === 'antigravity') { + return modelName.substring(slashIndex + 1); + } + } + return modelName; +} + +/** + * Get the auth source label from a model name prefix. + * e.g., "gemini/..." -> "Gemini" + * "antigravity/..." -> "Antigravity" + */ +export function getAuthSourceLabel(modelName: string): string | null { + const slashIndex = modelName.indexOf('/'); + if (slashIndex === -1) return null; + + const prefix = modelName.substring(0, slashIndex); + if (prefix === 'gemini') return 'Gemini'; + if (prefix === 'antigravity') return 'Antigravity'; + return null; +} + const GOOGLE_MODEL_FAMILIES: ModelFamily[] = [ { - id: 'gemini', + id: 'gemini-auth', label: 'Gemini', - matcher: (modelName) => modelName.toLowerCase().startsWith('gemini-'), + matcher: (modelName) => modelName.startsWith('gemini/'), order: 1, }, { - id: 'claude', - label: 'Claude', - matcher: (modelName) => modelName.toLowerCase().startsWith('claude-'), + id: 'antigravity-auth', + label: 'Antigravity', + matcher: (modelName) => modelName.startsWith('antigravity/'), order: 2, }, ]; @@ -92,7 +125,8 @@ export function groupModelsByFamilyWithGetter( /** * Get default models for a provider based on simple patterns. - * - Gemini 3.x models (starting with gemini-3-) + * For Google provider with gemini/ and antigravity/ prefixes: + * - Gemini 3.x models * - All Claude models */ export function getDefaultModels( @@ -101,10 +135,12 @@ export function getDefaultModels( ): string[] { return availableModels.filter((model) => { const lower = model.toLowerCase(); + // Handle gemini/ and antigravity/ prefixes + const modelName = lower.includes('/') ? lower.split('/')[1] : lower; // Gemini 3.x - if (lower.startsWith('gemini-3-')) return true; + if (modelName.startsWith('gemini-3-')) return true; // All Claude models - if (lower.startsWith('claude-')) return true; + if (modelName.startsWith('claude-')) return true; return false; }); } diff --git a/packages/ui/src/lib/quota/utils.ts b/packages/ui/src/lib/quota/utils.ts index 31bd7f1b..82077a39 100644 --- a/packages/ui/src/lib/quota/utils.ts +++ b/packages/ui/src/lib/quota/utils.ts @@ -40,3 +40,203 @@ export const formatWindowLabel = (label: string): string => { if (label === 'premium_interactions') return 'Premium interactions'; return label; }; + +/** + * Pace status indicating whether usage is on track, slightly fast, or too fast + */ +export type PaceStatus = 'on-track' | 'slightly-fast' | 'too-fast' | 'exhausted'; + +/** + * Information about the current pace of usage + */ +export interface PaceInfo { + /** Ratio of time elapsed in the window (0-1) */ + elapsedRatio: number; + /** Ratio of quota used (0-1) */ + usageRatio: number; + /** Predicted final usage percentage at end of window */ + predictedFinalPercent: number; + /** Seconds remaining until reset */ + remainingSeconds: number; + /** Whether usage is exhausted (100% used with time remaining) */ + isExhausted: boolean; + /** Elapsed seconds in the window */ + elapsedSeconds: number; + /** Total window duration in seconds */ + totalSeconds: number; + /** Current pace status */ + status: PaceStatus; + /** Per-unit pace rate (e.g., "2.5%/h" or "15%/d") */ + paceRateText: string; + /** Prediction text (e.g., "85%" or "+120%") */ + predictText: string; + /** For weekly quotas: the per-day allocation percentage */ + dailyAllocationPercent: number | null; +} + +/** + * Infer window duration in seconds from a window label. + * Used when the API doesn't provide windowSeconds directly. + */ +export const inferWindowSeconds = (label: string): number | null => { + const normalized = label.toLowerCase().trim(); + + // Exact matches + if (normalized === '5h') return 5 * 3600; + if (normalized === '7d' || normalized === 'weekly' || normalized === '7d-sonnet' || normalized === '7d-opus') return 7 * 86400; + if (normalized === 'monthly') return 30 * 86400; + if (normalized === '24h' || normalized === 'daily') return 86400; + if (normalized === '1h') return 3600; + + // Pattern matches + const hourMatch = normalized.match(/^(\d+)h$/); + if (hourMatch) return parseInt(hourMatch[1], 10) * 3600; + + const dayMatch = normalized.match(/^(\d+)d$/); + if (dayMatch) return parseInt(dayMatch[1], 10) * 86400; + + return null; +}; + +/** + * Calculate pace information for a usage window. + * + * @param usedPercent - Current usage percentage (0-100) + * @param resetAt - Timestamp (ms) when the window resets + * @param windowSeconds - Total window duration in seconds (can be null, will be inferred from label if possible) + * @param windowLabel - Optional label to infer window duration from + * @returns PaceInfo object with pace calculations + */ +export const calculatePace = ( + usedPercent: number | null, + resetAt: number | null, + windowSeconds: number | null, + windowLabel?: string +): PaceInfo | null => { + // Try to infer windowSeconds from label if not provided + let effectiveWindowSeconds = windowSeconds; + if (effectiveWindowSeconds === null && windowLabel) { + effectiveWindowSeconds = inferWindowSeconds(windowLabel); + } + + if (usedPercent === null || resetAt === null || effectiveWindowSeconds === null || effectiveWindowSeconds <= 0) { + return null; + } + + const now = Date.now(); + const remainingSeconds = Math.max(0, (resetAt - now) / 1000); + const elapsedSeconds = Math.max(0, Math.min(effectiveWindowSeconds, effectiveWindowSeconds - remainingSeconds)); + const elapsedRatio = Math.max(0, Math.min(1, elapsedSeconds / effectiveWindowSeconds)); + const usageRatio = usedPercent / 100; + const isExhausted = usedPercent >= 100 && remainingSeconds > 0; + + // Calculate predicted final usage + let predictedFinalPercent: number; + if (elapsedRatio > 0.01) { + predictedFinalPercent = Math.min(999, (usageRatio / elapsedRatio) * 100); + } else { + predictedFinalPercent = usedPercent; + } + + // Determine pace status + let status: PaceStatus; + if (isExhausted) { + status = 'exhausted'; + } else if (usageRatio <= elapsedRatio) { + status = 'on-track'; + } else if (predictedFinalPercent <= 130) { + status = 'slightly-fast'; + } else { + status = 'too-fast'; + } + + // Calculate pace rate text (per hour for < 5 days, per day otherwise) + const usePerDay = effectiveWindowSeconds >= 5 * 24 * 3600; + const unitSeconds = usePerDay ? 86400 : 3600; + const unitSuffix = usePerDay ? 'd' : 'h'; + const totalUnits = effectiveWindowSeconds / unitSeconds; + const elapsedUnits = Math.max(elapsedSeconds / unitSeconds, totalUnits * 0.01); + const pacePercentPerUnit = (usedPercent / elapsedUnits); + const paceRateText = Number.isFinite(pacePercentPerUnit) + ? `${Math.min(999.9, Math.max(0, pacePercentPerUnit)).toFixed(1)}%/${unitSuffix}` + : '-'; + + // Calculate predict text + const predictText = predictedFinalPercent > 100 + ? `+${Math.round(predictedFinalPercent)}%` + : `${Math.round(predictedFinalPercent)}%`; + + // Calculate daily allocation for weekly quotas (7 days = 604800 seconds) + // Also include monthly quotas (roughly 30 days) + let dailyAllocationPercent: number | null = null; + const windowDays = effectiveWindowSeconds / 86400; + if (windowDays >= 7) { + // For a 7-day window, each day should use ~14.3% (100/7) + // For monthly, each day should use ~3.3% (100/30) + dailyAllocationPercent = 100 / windowDays; + } + + return { + elapsedRatio, + usageRatio, + predictedFinalPercent, + remainingSeconds, + isExhausted, + elapsedSeconds, + totalSeconds: effectiveWindowSeconds, + status, + paceRateText, + predictText, + dailyAllocationPercent, + }; +}; + +/** + * Get the color for a pace status (returns CSS variable names) + */ +export const getPaceStatusColor = (status: PaceStatus): string => { + switch (status) { + case 'exhausted': + case 'too-fast': + return 'var(--status-error)'; + case 'slightly-fast': + return 'var(--status-warning)'; + case 'on-track': + return 'var(--status-success)'; + } +}; + +/** + * Format remaining time as a human-readable string + */ +export const formatRemainingTime = (seconds: number): string => { + const totalSeconds = Math.max(0, Math.floor(seconds)); + const totalMinutes = Math.floor(totalSeconds / 60); + const totalHours = Math.floor(totalMinutes / 60); + const days = Math.floor(totalHours / 24); + const hours = totalHours % 24; + const minutes = totalMinutes % 60; + + if (days > 0) { + return `${days}d ${hours}h`; + } + if (totalHours > 0) { + return `${totalHours}h`; + } + if (totalMinutes === 0) { + return '<1m'; + } + return `${minutes}m`; +}; + +/** + * Calculate the marker position for daily allocation on weekly quotas. + * Returns a percentage (0-100) representing where the "expected" usage should be + * based on how much time has elapsed. + * + * @param elapsedRatio - Ratio of time elapsed (0-1) + * @returns Expected usage percentage based on time elapsed + */ +export const calculateExpectedUsagePercent = (elapsedRatio: number): number => { + return Math.min(100, Math.max(0, elapsedRatio * 100)); +};