import { useState, useEffect, useCallback, useRef } from "react"; import { X, RefreshCw, Activity, TrendingUp, CheckCircle, AlertTriangle } from "lucide-react"; import type { ProviderUsage, UsageWindow } from "../api"; import { useUsageData } from "../hooks/useUsageData"; import { ProviderIcon } from "./ProviderIcon"; interface UsageIndicatorProps { isOpen: boolean; onClose: () => void; } /** * Format an ISO 8601 timestamp into a user-friendly absolute time string. * Shows time like "2:30 PM" for today, "Tue 2:30 PM" for this week, * or "Jan 15, 2:30 PM" for later dates. * * Used by UsageWindowRow to display the absolute reset time next to the * relative "resets in X" text when the backend provides a canonical resetAt * timestamp. Currently populated only for Claude session/windows where the * reset timestamp is available from the API or CLI fallback parser. */ function formatResetAt(isoTimestamp: string): string { const date = new Date(isoTimestamp); const now = new Date(); const timeStr = date.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit", hour12: true, }); const isToday = date.toDateString() === now.toDateString(); if (isToday) { return timeStr; } // Check if within the next 7 days — show short weekday const daysUntil = Math.round( (date.getTime() - now.getTime()) / (24 * 60 * 60 * 1000) ); if (daysUntil > 0 && daysUntil <= 6) { const weekday = date.toLocaleDateString(undefined, { weekday: "short" }); return `${weekday} ${timeStr}`; } // Beyond a week — show full date const dateStr = date.toLocaleDateString(undefined, { month: "short", day: "numeric", }); return `${dateStr}, ${timeStr}`; } /** * Get color class for usage percentage * - >90%: high (red/error color) * - >70%: medium (yellow/triage color) * - <=70%: low (green/success color) */ function getUsageColorClass(percentUsed: number): string { if (percentUsed > 90) return "usage-progress-fill--high"; if (percentUsed > 70) return "usage-progress-fill--medium"; return "usage-progress-fill--low"; } interface UsageWindowRowProps { window: UsageWindow; viewMode: 'used' | 'remaining'; providerName: string; } /** * Single usage window row with progress bar */ function UsageWindowRow({ window, viewMode, providerName }: UsageWindowRowProps) { const colorClass = getUsageColorClass(window.percentUsed); const isRemainingMode = viewMode === 'remaining'; // Display percentage based on view mode, but color always based on actual usage // Round percentages for cleaner display const displayPercent = Math.round(isRemainingMode ? window.percentLeft : window.percentUsed); const headerText = isRemainingMode ? `${Math.round(window.percentLeft)}% remaining` : `${Math.round(window.percentUsed)}% used`; const footerText = isRemainingMode ? `${Math.round(window.percentUsed)}% used` : `${Math.round(window.percentLeft)}% left`; // If resetText is null but resetAt exists, generate relative text from resetAt as a fallback let displayResetText = window.resetText; if (!displayResetText && window.resetAt) { const msLeft = new Date(window.resetAt).getTime() - Date.now(); if (msLeft > 0) { const hours = Math.floor(msLeft / (60 * 60 * 1000)); const days = Math.floor(hours / 24); const remHours = hours % 24; if (days > 0 && remHours > 0) { displayResetText = `resets in ${days}d ${remHours}h`; } else if (days > 0) { displayResetText = `resets in ${days}d`; } else if (hours > 0) { displayResetText = `resets in ${hours}h`; } else { const mins = Math.floor(msLeft / (60 * 1000)); displayResetText = `resets in ${mins}m`; } } } // Use pace from backend if available (for weekly windows) const pace = window.pace; const shouldShowPace = pace !== undefined; // Marker position for pace indicator (shows elapsed time position on progress bar) let markerPosition = 0; if (shouldShowPace) { markerPosition = isRemainingMode ? (100 - pace.percentElapsed) : pace.percentElapsed; } // Determine pace display status const isAhead = pace?.status === "ahead"; const isBehind = pace?.status === "behind"; const isOnTrack = pace?.status === "on-track"; return (
{window.label} {headerText}
{shouldShowPace && (
{footerText} {/* Reset group: shows relative text ("resets in 2h") and, when available, the absolute reset time derived from the canonical resetAt timestamp. The absolute time is populated by the backend for Claude session/windows where the reset timestamp is known. Other providers will only show the relative text unless they also provide resetAt. */} {displayResetText && ( {displayResetText} )} {/* Absolute reset timestamp: shown for all windows when resetAt is available. */} {window.resetAt && ( {formatResetAt(window.resetAt)} )}
{shouldShowPace && (
{isAhead && ( <> {pace.message} )} {isBehind && ( <> {pace.message} )} {isOnTrack && ( <> {pace.message} )}
)}
); } interface ProviderCardProps { provider: ProviderUsage; viewMode: 'used' | 'remaining'; } /** * Map provider names to ProviderIcon provider keys */ function getProviderIconKey(providerName: string): string { const normalized = providerName.toLowerCase(); // Map common provider names to their icon keys if (normalized.includes('claude') || normalized.includes('anthropic')) { return 'anthropic'; } if (normalized.includes('codex') || normalized.includes('openai') || normalized.includes('gpt')) { return 'openai'; } if (normalized.includes('gemini') || normalized.includes('google')) { return 'google'; } if (normalized.includes('ollama')) { return 'ollama'; } // Return the original name as fallback (ProviderIcon will show a default icon) return providerName; } /** * Provider card showing status and usage windows */ function ProviderCard({ provider, viewMode }: ProviderCardProps) { const getStatusBadge = () => { switch (provider.status) { case "ok": return ( Connected ); case "error": return ( Error ); case "no-auth": default: return ( Not configured ); } }; return (
{provider.name}
{getStatusBadge()}
{provider.error && (
{provider.error}
)} {(provider.plan || provider.email) && (
{provider.plan && {provider.plan}} {provider.email && {provider.email}}
)} {provider.windows.length > 0 ? (
{provider.windows.map((window, index) => ( ))}
) : provider.status === "ok" ? (
No usage data available
) : null}
); } /** * Loading skeleton for usage providers */ function UsageSkeleton() { return (
{[1, 2, 3].map((i) => (
))}
); } /** * Usage Indicator Modal * * Displays AI provider subscription usage across multiple providers. * Shows hourly and weekly usage windows with percentage bars, * reset timers, and pace indicators. */ export function UsageIndicator({ isOpen, onClose }: UsageIndicatorProps) { const { providers, loading, error, lastUpdated, refresh } = useUsageData({ autoRefresh: isOpen, // Only poll when modal is open }); const [isRefreshing, setIsRefreshing] = useState(false); const [viewMode, setViewMode] = useState<'used' | 'remaining'>('used'); const contentRef = useRef(null); const wasOpenRef = useRef(isOpen); const hasCompletedInitialFetchRef = useRef(false); // Reset initial fetch flag when modal closes to show skeleton on next open useEffect(() => { if (!isOpen) { hasCompletedInitialFetchRef.current = false; } }, [isOpen]); // Track when initial fetch completes (providers are populated) useEffect(() => { if (providers.length > 0) { hasCompletedInitialFetchRef.current = true; } }, [providers.length]); // Trigger refresh when modal opens (isOpen transitions from false to true) useEffect(() => { // Only refresh when transitioning from closed to open if (!wasOpenRef.current && isOpen) { // Skip if data is fresh (within 5 seconds) to avoid duplicate requests if (!lastUpdated || Date.now() - lastUpdated.getTime() > 5000) { refresh(); } } // Update ref for next render wasOpenRef.current = isOpen; }, [isOpen, lastUpdated, refresh]); // Load view mode preference from localStorage on mount useEffect(() => { const savedMode = localStorage.getItem('kb-usage-view-mode'); if (savedMode === 'used' || savedMode === 'remaining') { setViewMode(savedMode); } }, []); // Persist view mode to localStorage when it changes const handleViewModeChange = useCallback((mode: 'used' | 'remaining') => { setViewMode(mode); localStorage.setItem('kb-usage-view-mode', mode); }, []); // Handle manual refresh const handleRefresh = useCallback(async () => { setIsRefreshing(true); await refresh(); setIsRefreshing(false); }, [refresh]); // Close on Escape key useEffect(() => { if (!isOpen) return; const handleKey = (e: KeyboardEvent) => { if (e.key === "Escape") { onClose(); } }; document.addEventListener("keydown", handleKey); return () => document.removeEventListener("keydown", handleKey); }, [isOpen, onClose]); // Close on overlay click const handleOverlayClick = useCallback( (e: React.MouseEvent) => { if (e.target === e.currentTarget) { onClose(); } }, [onClose] ); if (!isOpen) return null; return (

Usage

{(loading || (!hasCompletedInitialFetchRef.current && !error)) && providers.length === 0 ? ( ) : error && providers.length === 0 ? (

Failed to load usage data

{error}

) : providers.length === 0 ? (

No AI providers configured

Configure authentication in Settings to see usage data.

) : (
{providers.map((provider) => ( ))}
)}
{lastUpdated && ( Last updated: {lastUpdated.toLocaleTimeString()} )}
); }