feat(KB-039): restore deleted files — UsageIndicator, useUsageData hook and tests

This commit is contained in:
gsxdsm
2026-03-29 21:49:46 -07:00
parent 2a63e994de
commit 4bafa5a076
4 changed files with 485 additions and 0 deletions

View File

@@ -164,6 +164,34 @@ export function fetchModels(): Promise<ModelInfo[]> {
return api<ModelInfo[]>("/models");
}
// --- Usage API ---
/** Usage window for a provider (e.g., "Session (5h)", "Weekly") */
export interface UsageWindow {
label: string;
percentUsed: number; // 0-100
percentLeft: number; // 0-100
resetText: string | null; // e.g., "resets in 2h"
resetMs?: number; // ms until reset
windowDurationMs?: number; // total window length
}
/** Provider usage data */
export interface ProviderUsage {
name: string;
icon: string; // emoji
status: "ok" | "error" | "no-auth";
error?: string;
plan?: string | null;
email?: string | null;
windows: UsageWindow[];
}
/** Fetch usage data from all configured AI providers */
export function fetchUsageData(): Promise<{ providers: ProviderUsage[] }> {
return api<{ providers: ProviderUsage[] }>("/usage");
}
// --- Auth API ---
/** OAuth provider with current authentication status */

View File

@@ -0,0 +1,233 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { X, RefreshCw, Activity } from "lucide-react";
import type { ProviderUsage, UsageWindow } from "../api";
import { useUsageData } from "../hooks/useUsageData";
interface UsageIndicatorProps {
isOpen: boolean;
onClose: () => void;
}
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";
}
function UsageWindowRow({ window }: { window: UsageWindow }) {
const colorClass = getUsageColorClass(window.percentUsed);
return (
<div className="usage-window">
<div className="usage-window-header">
<span className="usage-window-label">{window.label}</span>
<span className="usage-window-percentage">{window.percentUsed}% used</span>
</div>
<div className="usage-progress-bar">
<div
className={`usage-progress-fill ${colorClass}`}
style={{ width: `${window.percentUsed}%` }}
role="progressbar"
aria-valuenow={window.percentUsed}
aria-valuemin={0}
aria-valuemax={100}
aria-label={`${window.label} usage: ${window.percentUsed}%`}
/>
</div>
<div className="usage-window-footer">
<span className="usage-window-left">{window.percentLeft}% left</span>
{window.resetText && (
<span className="usage-window-reset">{window.resetText}</span>
)}
</div>
</div>
);
}
function ProviderCard({ provider }: { provider: ProviderUsage }) {
const getStatusBadge = () => {
switch (provider.status) {
case "ok":
return (
<span className="usage-status-badge usage-status-badge--connected">
Connected
</span>
);
case "error":
return (
<span className="usage-status-badge usage-status-badge--error">
Error
</span>
);
case "no-auth":
default:
return (
<span className="usage-status-badge usage-status-badge--not-configured">
Not configured
</span>
);
}
};
return (
<div className="usage-provider" data-provider={provider.name} data-status={provider.status}>
<div className="usage-provider-header">
<div className="usage-provider-info">
<span className="usage-provider-icon" role="img" aria-label={provider.name}>
{provider.icon}
</span>
<span className="usage-provider-name">{provider.name}</span>
</div>
{getStatusBadge()}
</div>
{provider.error && (
<div className="usage-provider-error">
{provider.error}
</div>
)}
{(provider.plan || provider.email) && (
<div className="usage-provider-meta">
{provider.plan && <span className="usage-provider-plan">{provider.plan}</span>}
{provider.email && <span className="usage-provider-email">{provider.email}</span>}
</div>
)}
{provider.windows.length > 0 ? (
<div className="usage-provider-windows">
{provider.windows.map((window, index) => (
<UsageWindowRow key={`${provider.name}-${window.label}-${index}`} window={window} />
))}
</div>
) : provider.status === "ok" ? (
<div className="usage-provider-empty">No usage data available</div>
) : null}
</div>
);
}
function UsageSkeleton() {
return (
<div className="usage-skeleton">
{[1, 2, 3].map((i) => (
<div key={i} className="usage-skeleton-provider">
<div className="usage-skeleton-header">
<div className="usage-skeleton-icon" />
<div className="usage-skeleton-name" />
<div className="usage-skeleton-badge" />
</div>
<div className="usage-skeleton-bar" />
<div className="usage-skeleton-text" />
</div>
))}
</div>
);
}
export function UsageIndicator({ isOpen, onClose }: UsageIndicatorProps) {
const { providers, loading, error, lastUpdated, refresh } = useUsageData({
autoRefresh: isOpen,
});
const [isRefreshing, setIsRefreshing] = useState(false);
const contentRef = useRef<HTMLDivElement>(null);
const handleRefresh = useCallback(async () => {
setIsRefreshing(true);
await refresh();
setIsRefreshing(false);
}, [refresh]);
useEffect(() => {
if (!isOpen) return;
const handleKey = (e: KeyboardEvent) => {
if (e.key === "Escape") {
onClose();
}
};
document.addEventListener("keydown", handleKey);
return () => document.removeEventListener("keydown", handleKey);
}, [isOpen, onClose]);
const handleOverlayClick = useCallback(
(e: React.MouseEvent) => {
if (e.target === e.currentTarget) {
onClose();
}
},
[onClose]
);
if (!isOpen) return null;
return (
<div className="modal-overlay open" onClick={handleOverlayClick} data-testid="usage-modal-overlay">
<div className="modal usage-modal" data-testid="usage-modal">
<div className="modal-header">
<div className="usage-header">
<Activity size={18} className="usage-header-icon" />
<h3>Usage</h3>
</div>
<button
className="modal-close"
onClick={onClose}
aria-label="Close usage modal"
data-testid="usage-modal-close"
>
<X size={20} />
</button>
</div>
<div className="usage-content" ref={contentRef}>
{loading && providers.length === 0 ? (
<UsageSkeleton />
) : error && providers.length === 0 ? (
<div className="usage-error">
<p>Failed to load usage data</p>
<p className="usage-error-message">{error}</p>
<button className="btn btn-sm" onClick={handleRefresh}>
Retry
</button>
</div>
) : providers.length === 0 ? (
<div className="usage-empty">
<p>No AI providers configured</p>
<p className="usage-empty-hint">
Configure authentication in Settings to see usage data.
</p>
</div>
) : (
<div className="usage-providers">
{providers.map((provider) => (
<ProviderCard key={provider.name} provider={provider} />
))}
</div>
)}
</div>
<div className="modal-actions usage-actions">
<div className="usage-last-updated">
{lastUpdated && (
<span>Last updated: {lastUpdated.toLocaleTimeString()}</span>
)}
</div>
<button
className="btn btn-sm"
onClick={handleRefresh}
disabled={loading || isRefreshing}
data-testid="usage-refresh-btn"
>
<RefreshCw size={14} className={isRefreshing ? "spin" : ""} />
Refresh
</button>
<button className="btn btn-primary btn-sm" onClick={onClose}>
Close
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,108 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderHook, waitFor } from "@testing-library/react";
import { useUsageData } from "./useUsageData";
import * as api from "../api";
describe("useUsageData", () => {
const mockFetchUsageData = vi.spyOn(api, "fetchUsageData");
beforeEach(() => {
mockFetchUsageData.mockClear();
});
it("fetches data on initial mount", async () => {
const mockData = {
providers: [
{
name: "Claude",
icon: "🟠",
status: "ok" as const,
windows: [],
},
],
};
mockFetchUsageData.mockResolvedValue(mockData);
const { result } = renderHook(() => useUsageData({ autoRefresh: false }));
// Should be loading initially
expect(result.current.loading).toBe(true);
expect(result.current.providers).toEqual([]);
// Wait for data to load
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.providers).toEqual(mockData.providers);
expect(result.current.error).toBeNull();
expect(result.current.lastUpdated).toBeInstanceOf(Date);
});
it("handles fetch errors", async () => {
mockFetchUsageData.mockRejectedValue(new Error("Network error"));
const { result } = renderHook(() => useUsageData({ autoRefresh: false }));
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.error).toBe("Network error");
expect(result.current.providers).toEqual([]);
});
it("manual refresh fetches new data", async () => {
const mockData1 = {
providers: [{ name: "Claude", icon: "🟠", status: "ok" as const, windows: [] }],
};
const mockData2 = {
providers: [{ name: "Codex", icon: "🟢", status: "ok" as const, windows: [] }],
};
mockFetchUsageData
.mockResolvedValueOnce(mockData1)
.mockResolvedValueOnce(mockData2);
const { result } = renderHook(() => useUsageData({ autoRefresh: false }));
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.providers).toEqual(mockData1.providers);
// Manual refresh
await result.current.refresh();
await waitFor(() => expect(result.current.providers).toEqual(mockData2.providers));
});
it("clears error on successful manual refresh after error", async () => {
mockFetchUsageData
.mockRejectedValueOnce(new Error("Network error"))
.mockResolvedValueOnce({
providers: [{ name: "Claude", icon: "🟠", status: "ok" as const, windows: [] }],
});
const { result } = renderHook(() => useUsageData({ autoRefresh: false }));
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.error).toBe("Network error");
// Manual refresh
await result.current.refresh();
await waitFor(() => expect(result.current.error).toBeNull());
expect(result.current.providers).toHaveLength(1);
});
it("exports the correct interface", () => {
expect(typeof useUsageData).toBe("function");
});
it("returns expected default values before first fetch", () => {
mockFetchUsageData.mockImplementation(() => new Promise(() => {})); // Never resolves
const { result } = renderHook(() => useUsageData({ autoRefresh: false }));
expect(result.current.providers).toEqual([]);
expect(result.current.loading).toBe(true);
expect(result.current.error).toBeNull();
expect(result.current.lastUpdated).toBeNull();
expect(typeof result.current.refresh).toBe("function");
});
});

View File

@@ -0,0 +1,116 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { fetchUsageData, type ProviderUsage } from "../api";
interface UsageDataState {
providers: ProviderUsage[];
loading: boolean;
error: string | null;
lastUpdated: Date | null;
}
interface UseUsageDataOptions {
/** Auto-refresh interval in ms (default: 30 seconds) */
pollInterval?: number;
/** Whether to auto-refresh (default: true) */
autoRefresh?: boolean;
}
/**
* Hook for fetching and polling provider usage data.
*
* Features:
* - Initial fetch on mount
* - Auto-refresh every 30 seconds when enabled
* - Manual refresh capability
* - Loading and error states
* - Cleanup on unmount
*/
export function useUsageData(options: UseUsageDataOptions = {}) {
const { pollInterval = 30_000, autoRefresh = true } = options;
const [state, setState] = useState<UsageDataState>({
providers: [],
loading: true,
error: null,
lastUpdated: null,
});
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
const abortRef = useRef<AbortController | null>(null);
const fetchData = useCallback(async (isManual = false) => {
// Cancel any in-flight request
if (abortRef.current) {
abortRef.current.abort();
}
abortRef.current = new AbortController();
if (isManual) {
setState((prev) => ({ ...prev, loading: true, error: null }));
}
try {
const { providers } = await fetchUsageData();
setState({
providers,
loading: false,
error: null,
lastUpdated: new Date(),
});
} catch (err: any) {
// Don't update state if the request was aborted
if (err.name === "AbortError") return;
setState((prev) => ({
...prev,
loading: false,
error: err.message || "Failed to fetch usage data",
}));
}
}, []);
// Initial fetch
useEffect(() => {
fetchData();
}, [fetchData]);
// Auto-refresh
useEffect(() => {
if (!autoRefresh) return;
pollRef.current = setInterval(() => {
fetchData(false);
}, pollInterval);
return () => {
if (pollRef.current) {
clearInterval(pollRef.current);
pollRef.current = null;
}
};
}, [autoRefresh, pollInterval, fetchData]);
// Cleanup on unmount
useEffect(() => {
return () => {
if (abortRef.current) {
abortRef.current.abort();
}
if (pollRef.current) {
clearInterval(pollRef.current);
}
};
}, []);
const refresh = useCallback(() => {
return fetchData(true);
}, [fetchData]);
return {
providers: state.providers,
loading: state.loading,
error: state.error,
lastUpdated: state.lastUpdated,
refresh,
};
}