import { formatDateTimeForPreference, formatTimeForPreference } from '@/lib/timeFormat'; import type { TimeFormatPreference } from '@/stores/useUIStore'; export const clampPercent = (value: number | null): number | null => { if (typeof value !== 'number' || !Number.isFinite(value)) { return null; } return Math.max(0, Math.min(100, Math.round(value))); }; export const formatPercent = (value: number | null): string => { if (typeof value !== 'number' || !Number.isFinite(value)) { return '-'; } return `${Math.round(value)}%`; }; export const formatQuotaValueLabel = ( valueLabel: string | null | undefined, percent: number | null, ): string => { return valueLabel ?? formatPercent(percent); }; export const formatQuotaResetLabel = ( resetAt: number | null, fallback?: string | null, timeFormatPreference: TimeFormatPreference = 'auto', ): string => { if (!resetAt) { return fallback ?? ''; } try { const resetDate = new Date(resetAt); if (!Number.isFinite(resetDate.getTime())) { return fallback ?? ''; } const now = new Date(); const isToday = resetDate.toDateString() === now.toDateString(); if (isToday) { return formatTimeForPreference(resetDate, timeFormatPreference, { fallback: fallback ?? '' }); } return formatDateTimeForPreference(resetDate, timeFormatPreference, { month: 'short', day: 'numeric', weekday: 'short', hour: 'numeric', minute: '2-digit', }); } catch { return fallback ?? ''; } }; export const resolveUsageTone = (percent: number | null): 'safe' | 'warn' | 'critical' => { if (percent === null) { return 'safe'; } if (percent >= 80) { return 'critical'; } if (percent >= 50) { return 'warn'; } return 'safe'; }; export const formatWindowLabel = (label: string): string => { if (label === '5h') return '5-Hour'; if (label === '7d') return '7-Day Limit'; if (label === '7d-sonnet') return '7-Day Sonnet Limit'; if (label === '7d-opus') return '7-Day Opus Limit'; if (label === 'weekly') return 'Weekly Limit'; if (label === 'daily') return 'Daily'; if (label === 'monthly') return 'Monthly Limit'; if (label === 'credits') return 'Credits'; if (label === 'credits_balance') return 'Credits Balance'; if (label === 'session') return 'Session'; if (label === 'premium') return 'Premium Interactions'; if (label === 'chat') return 'Chat Requests'; if (label === 'completions') return 'Completions'; 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)); };