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;
}
/**
* 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';
}
/**
* Single usage window row with progress bar
*/
function UsageWindowRow({ window, viewMode }: UsageWindowRowProps) {
const colorClass = getUsageColorClass(window.percentUsed);
const isRemainingMode = viewMode === 'remaining';
// Display percentage based on view mode, but color always based on actual usage
const displayPercent = isRemainingMode ? window.percentLeft : window.percentUsed;
const headerText = isRemainingMode ? `${window.percentLeft}% remaining` : `${window.percentUsed}% used`;
const footerText = isRemainingMode ? `${window.percentUsed}% used` : `${window.percentLeft}% left`;
// 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}
{footerText}
{window.resetText && (
{window.resetText}
)}
{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.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);
// 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 (
{loading && 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()}
)}
);
}