import { useState, useEffect, useCallback, useRef } from "react"; import type { CSSProperties, DragEvent } from "react"; import { X, RefreshCw, Activity, TrendingUp, CheckCircle, AlertTriangle, Eye, GripVertical, ChevronUp, ChevronDown } from "lucide-react"; import type { ProviderUsage, UsageWindow } from "../api"; import { useUsageData } from "../hooks/useUsageData"; import { ProviderIcon } from "./ProviderIcon"; import { getScopedItem, setScopedItem } from "../utils/projectStorage"; import "./UsageIndicator.css"; interface UsageIndicatorProps { isOpen: boolean; onClose: () => void; projectId?: string; anchorRect?: DOMRect | null; } /** * Format an ISO 8601 timestamp into a user-friendly absolute time string. * * Formatting tiers (applied consistently for all providers): * - Today: "2:30 PM" * - Next 7 days: "Tue 2:30 PM" (weekday + time) * - Beyond 7 days: "Jan 15, 2:30 PM" * * Day difference is computed from calendar midnight boundaries rather than * raw millisecond division to avoid time-of-day rounding artifacts that could * cause inconsistent formatting (e.g., showing "Apr 6" instead of "Sun 2:30 PM" * for a reset that is just under 7 days away). * * 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. */ 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; } // Compute calendar-day distance using midnight boundaries. // This avoids floating-point rounding from raw millisecond division // that can cause off-by-one day counts depending on time-of-day. const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()); const startOfTarget = new Date(date.getFullYear(), date.getMonth(), date.getDate()); const calendarDaysUntil = Math.round( (startOfTarget.getTime() - startOfToday.getTime()) / (24 * 60 * 60 * 1000) ); // Within the next 7 calendar days — show short weekday + time if (calendarDaysUntil >= 1 && calendarDaysUntil <= 7) { const weekday = date.toLocaleDateString(undefined, { weekday: "short" }); return `${weekday} ${timeStr}`; } // Beyond 7 days — 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"; } const HIDDEN_WINDOWS_STORAGE_KEY = "kb-usage-hidden-windows"; const MODAL_SIZE_STORAGE_KEY = "kb-usage-modal-size"; const PROVIDER_ORDER_KEY = "kb-usage-provider-order"; interface ModalSize { width: number; height: number; } function getSavedModalSize(projectId: string | undefined): ModalSize | null { const stored = getScopedItem(MODAL_SIZE_STORAGE_KEY, projectId); if (!stored) return null; try { const parsed = JSON.parse(stored); if ( parsed && typeof parsed.width === "number" && typeof parsed.height === "number" && parsed.width > 0 && parsed.height > 0 ) { return { width: parsed.width, height: parsed.height }; } } catch { // ignore } return null; } function getHiddenWindows(projectId: string | undefined): Record { const stored = getScopedItem(HIDDEN_WINDOWS_STORAGE_KEY, projectId); if (!stored) { return {}; } try { const parsed = JSON.parse(stored); if (!parsed || typeof parsed !== "object") { return {}; } return Object.entries(parsed).reduce>((acc, [provider, labels]) => { if (Array.isArray(labels)) { const validLabels = labels.filter((label): label is string => typeof label === "string"); if (validLabels.length > 0) { acc[provider] = validLabels; } } return acc; }, {}); } catch { return {}; } } function setHiddenWindows(hidden: Record, projectId: string | undefined): void { setScopedItem(HIDDEN_WINDOWS_STORAGE_KEY, JSON.stringify(hidden), projectId); } function getProviderOrder(projectId: string | undefined): string[] { const stored = getScopedItem(PROVIDER_ORDER_KEY, projectId); if (!stored) { return []; } try { const parsed = JSON.parse(stored); if (!Array.isArray(parsed)) { return []; } return parsed.filter((name): name is string => typeof name === "string"); } catch { return []; } } function setProviderOrder(names: string[], projectId: string | undefined): void { setScopedItem(PROVIDER_ORDER_KEY, JSON.stringify(names), projectId); } function isWindowHidden( providerName: string, windowLabel: string, hidden: Record ): boolean { return hidden[providerName]?.includes(windowLabel) ?? false; } interface UsageWindowRowProps { window: UsageWindow; viewMode: 'used' | 'remaining'; isHidden: boolean; onToggleHidden: () => void; } /** * Single usage window row with progress bar */ function UsageWindowRow({ window, viewMode, isHidden, onToggleHidden }: 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}
{!isHidden && {headerText}} {!isHidden && ( )}
{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'; hiddenWindows: Record; onToggleWindow: (providerName: string, windowLabel: string) => void; onShowAllHidden: (providerName: string) => void; isDragging: boolean; isDragOver: boolean; dragOverPosition: "before" | "after" | null; onDragStart: (e: DragEvent) => void; onDragOver: (e: DragEvent) => void; onDragLeave: () => void; onDrop: (e: DragEvent) => void; onDragEnd: () => void; isTouchReorderMode: boolean; canMoveUp: boolean; canMoveDown: boolean; onMoveUp: () => void; onMoveDown: () => void; } /** * 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') || normalized.includes('antigravity')) { return 'google'; } if (normalized.includes('ollama')) { return 'ollama'; } if (normalized.includes('minimax')) { return 'minimax'; } if (normalized.includes('zai') || normalized.includes('zhipu')) { return 'zai'; } if (normalized.includes('kimi') || normalized.includes('moonshot')) { return 'kimi'; } if (normalized.includes('bedrock') || normalized.includes('amazon')) { return 'bedrock'; } if (normalized.includes('xai') || normalized.includes('grok')) { return 'xai'; } if (normalized.includes('opencode')) { return 'opencode'; } if (normalized.includes('copilot') || normalized === 'github copilot') { return 'github-copilot'; } // 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, hiddenWindows, onToggleWindow, onShowAllHidden, isDragging, isDragOver, dragOverPosition, onDragStart, onDragOver, onDragLeave, onDrop, onDragEnd, isTouchReorderMode, canMoveUp, canMoveDown, onMoveUp, onMoveDown, }: ProviderCardProps) { const hiddenCount = hiddenWindows[provider.name]?.length ?? 0; const getStatusBadge = () => { switch (provider.status) { case "ok": return null; case "error": return ( Error ); case "no-auth": default: return ( Not configured ); } }; return (
{provider.name} {hiddenCount > 0 && ( )}
{isTouchReorderMode && (
)} {getStatusBadge()}
{provider.error && (
{provider.error}
)} {provider.plan && (
{provider.plan}
)} {provider.windows.length > 0 ? (
{provider.windows.map((window, index) => { const hidden = isWindowHidden(provider.name, window.label, hiddenWindows); return ( onToggleWindow(provider.name, window.label)} /> ); })}
) : 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, projectId, anchorRect }: UsageIndicatorProps) { const { providers, loading, error, lastUpdated, refresh } = useUsageData({ autoRefresh: isOpen, // Only poll when modal is open }); const [isRefreshing, setIsRefreshing] = useState(false); const [isDesktopViewport, setIsDesktopViewport] = useState(() => typeof window !== "undefined" ? window.innerWidth >= 769 : false ); const [isTouchReorderMode, setIsTouchReorderMode] = useState(() => { if (typeof window === "undefined") return false; return window.matchMedia?.("(pointer: coarse)").matches ?? false; }); const [viewMode, setViewMode] = useState<'used' | 'remaining'>('used'); const [hiddenWindows, setHiddenWindowsState] = useState>(() => getHiddenWindows(projectId) ); const [providerOrder, setProviderOrderState] = useState(() => getProviderOrder(projectId)); const [draggingProvider, setDraggingProvider] = useState(null); const [dragOverProvider, setDragOverProvider] = useState(null); const [dragOverPosition, setDragOverPosition] = useState<"before" | "after" | null>(null); const contentRef = useRef(null); const modalRef = useRef(null); const wasOpenRef = useRef(isOpen); const hasCompletedInitialFetchRef = useRef(false); const [savedSize, setSavedSize] = useState(() => getSavedModalSize(projectId)); useEffect(() => { setSavedSize(getSavedModalSize(projectId)); }, [projectId]); // Persist user resizes via ResizeObserver (debounced). useEffect(() => { if (!isOpen || !isDesktopViewport) return; const el = modalRef.current; if (!el || typeof ResizeObserver === "undefined") return; let timer: ReturnType | null = null; const observer = new ResizeObserver((entries) => { const entry = entries[0]; if (!entry) return; const { width, height } = entry.contentRect; if (width <= 0 || height <= 0) return; if (timer) clearTimeout(timer); timer = setTimeout(() => { setScopedItem( MODAL_SIZE_STORAGE_KEY, JSON.stringify({ width: Math.round(width), height: Math.round(height) }), projectId ); }, 250); }); observer.observe(el); return () => { if (timer) clearTimeout(timer); observer.disconnect(); }; }, [isOpen, isDesktopViewport, projectId]); // 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]); useEffect(() => { if (typeof window === "undefined") { return; } const touchMedia = window.matchMedia?.("(pointer: coarse)"); const handleResize = () => { setIsDesktopViewport(window.innerWidth >= 769); setIsTouchReorderMode(touchMedia?.matches ?? false); }; handleResize(); window.addEventListener("resize", handleResize); touchMedia?.addEventListener?.("change", handleResize); return () => { window.removeEventListener("resize", handleResize); touchMedia?.removeEventListener?.("change", handleResize); }; }, []); // 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 = getScopedItem("kb-usage-view-mode", projectId); if (savedMode === "used" || savedMode === "remaining") { setViewMode(savedMode); return; } setViewMode("used"); }, [projectId]); // Persist view mode to localStorage when it changes const handleViewModeChange = useCallback((mode: "used" | "remaining") => { setViewMode(mode); setScopedItem("kb-usage-view-mode", mode, projectId); }, [projectId]); useEffect(() => { setHiddenWindowsState(getHiddenWindows(projectId)); setProviderOrderState(getProviderOrder(projectId)); }, [projectId]); useEffect(() => { setHiddenWindows(hiddenWindows, projectId); }, [hiddenWindows, projectId]); const handleToggleWindow = useCallback((providerName: string, windowLabel: string) => { setHiddenWindowsState((previous) => { if (isWindowHidden(providerName, windowLabel, previous)) { const remaining = (previous[providerName] ?? []).filter((label) => label !== windowLabel); if (remaining.length === 0) { const { [providerName]: _removed, ...rest } = previous; return rest; } return { ...previous, [providerName]: remaining, }; } return { ...previous, [providerName]: [...(previous[providerName] ?? []), windowLabel], }; }); }, []); const handleShowAllHidden = useCallback((providerName: string) => { setHiddenWindowsState((previous) => { if (!previous[providerName]) { return previous; } const { [providerName]: _removed, ...rest } = previous; return rest; }); }, []); const reorderProviders = useCallback( (rawProviders: ProviderUsage[]): ProviderUsage[] => { if (providerOrder.length === 0) { return rawProviders; } const providerMap = new Map(rawProviders.map((provider) => [provider.name, provider])); const orderedProviders: ProviderUsage[] = []; for (const name of providerOrder) { const provider = providerMap.get(name); if (provider) { orderedProviders.push(provider); providerMap.delete(name); } } return [...orderedProviders, ...providerMap.values()]; }, [providerOrder] ); const handleProviderDragStart = useCallback((event: DragEvent, name: string) => { event.dataTransfer.effectAllowed = "move"; event.dataTransfer.setData("text/plain", name); setDraggingProvider(name); }, []); const handleProviderDragOver = useCallback((event: DragEvent, name: string) => { if (!draggingProvider || draggingProvider === name) { return; } event.preventDefault(); event.dataTransfer.dropEffect = "move"; const { top, height } = event.currentTarget.getBoundingClientRect(); const midpoint = top + height / 2; const position: "before" | "after" = event.clientY < midpoint ? "before" : "after"; setDragOverProvider(name); setDragOverPosition(position); }, [draggingProvider]); const handleProviderDrop = useCallback((event: DragEvent, name: string) => { event.preventDefault(); const draggedName = draggingProvider ?? event.dataTransfer.getData("text/plain"); if (!draggedName || draggedName === name) { setDragOverProvider(null); setDragOverPosition(null); return; } const currentOrder = reorderProviders(providers).map((provider) => provider.name); const filteredOrder = currentOrder.filter((providerName) => providerName !== draggedName); const targetIndex = filteredOrder.indexOf(name); if (targetIndex < 0) { setDragOverProvider(null); setDragOverPosition(null); return; } const insertIndex = dragOverPosition === "after" ? targetIndex + 1 : targetIndex; filteredOrder.splice(insertIndex, 0, draggedName); setProviderOrder(filteredOrder, projectId); setProviderOrderState(filteredOrder); setDragOverProvider(null); setDragOverPosition(null); }, [draggingProvider, dragOverPosition, projectId, providers, reorderProviders]); const handleProviderDragEnd = useCallback(() => { setDraggingProvider(null); setDragOverProvider(null); setDragOverPosition(null); }, []); const moveProviderByOffset = useCallback((providerName: string, direction: -1 | 1) => { const currentOrder = reorderProviders(providers).map((provider) => provider.name); const currentIndex = currentOrder.indexOf(providerName); const targetIndex = currentIndex + direction; if (currentIndex < 0 || targetIndex < 0 || targetIndex >= currentOrder.length) { return; } const nextOrder = [...currentOrder]; const [moved] = nextOrder.splice(currentIndex, 1); nextOrder.splice(targetIndex, 0, moved); setProviderOrder(nextOrder, projectId); setProviderOrderState(nextOrder); }, [projectId, providers, reorderProviders]); // 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; const showDesktopPopover = Boolean(anchorRect && isDesktopViewport); const desktopGap = 8; const maxTopPadding = 12; const defaultPopoverWidth = 420; const popoverWidth = savedSize?.width ?? defaultPopoverWidth; const desktopTop = showDesktopPopover ? Math.min((anchorRect?.bottom ?? 0) + desktopGap, window.innerHeight - maxTopPadding) : undefined; // Anchor popover so its right edge aligns with the anchor button's right edge, // but use `left` positioning so native resize (bottom-right handle) feels natural. const desktopLeft = showDesktopPopover ? Math.max(8, (anchorRect?.right ?? 0) - popoverWidth) : undefined; const sizeStyle: CSSProperties = isDesktopViewport && savedSize ? { width: savedSize.width, height: savedSize.height } : {}; const orderedProviders = reorderProviders(providers); const usageContent = (

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.

) : (
{orderedProviders.map((provider, index) => ( handleProviderDragStart(event, provider.name)} onDragOver={(event) => handleProviderDragOver(event, provider.name)} onDragLeave={() => { if (dragOverProvider === provider.name) { setDragOverProvider(null); setDragOverPosition(null); } }} onDrop={(event) => handleProviderDrop(event, provider.name)} onDragEnd={handleProviderDragEnd} isTouchReorderMode={isTouchReorderMode} canMoveUp={index > 0} canMoveDown={index < orderedProviders.length - 1} onMoveUp={() => moveProviderByOffset(provider.name, -1)} onMoveDown={() => moveProviderByOffset(provider.name, 1)} /> ))}
)}
{lastUpdated && ( Last updated: {lastUpdated.toLocaleTimeString()} )}
); if (showDesktopPopover) { return ( <>
{usageContent} ); } return (
{usageContent}
); }