mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-11 12:09:03 +00:00
Align snapshot handling and UI contracts with the current analytics payloads.
229 lines
6.5 KiB
TypeScript
229 lines
6.5 KiB
TypeScript
/**
|
|
* Usage Trend Chart Component
|
|
*
|
|
* Displays usage trends over time with tokens and cost.
|
|
* Supports daily, hourly, and monthly granularity with interactive tooltips.
|
|
* Respects privacy mode to blur sensitive data.
|
|
*/
|
|
|
|
import { useMemo } from 'react';
|
|
import {
|
|
XAxis,
|
|
YAxis,
|
|
CartesianGrid,
|
|
Tooltip,
|
|
ResponsiveContainer,
|
|
Area,
|
|
AreaChart,
|
|
} from 'recharts';
|
|
import { format } from 'date-fns';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { Skeleton } from '@/components/ui/skeleton';
|
|
import { cn } from '@/lib/utils';
|
|
import type { DailyUsage, HourlyUsage } from '@/hooks/use-usage';
|
|
import { usePrivacy, PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context';
|
|
// TODO i18n: import { useTranslation } from 'react-i18next'; when keys are ready
|
|
|
|
type ChartData = DailyUsage | HourlyUsage;
|
|
|
|
interface UsageTrendChartProps {
|
|
data: ChartData[];
|
|
isLoading?: boolean;
|
|
granularity?: 'daily' | 'monthly' | 'hourly';
|
|
className?: string;
|
|
}
|
|
|
|
export function UsageTrendChart({
|
|
data,
|
|
isLoading,
|
|
granularity = 'daily',
|
|
className,
|
|
}: UsageTrendChartProps) {
|
|
const { privacyMode } = usePrivacy();
|
|
const { t } = useTranslation();
|
|
|
|
const chartData = useMemo(() => {
|
|
if (!data || data.length === 0) return [];
|
|
|
|
// For hourly data, already sorted ascending from API
|
|
const sortedData = data;
|
|
|
|
return sortedData.map((item) => {
|
|
// Handle hourly vs daily data format
|
|
const timeKey = 'hour' in item ? item.hour : (item as DailyUsage).date;
|
|
return {
|
|
...item,
|
|
dateFormatted: formatTime(timeKey, granularity),
|
|
costRounded: Number(item.cost.toFixed(4)),
|
|
};
|
|
});
|
|
}, [data, granularity]);
|
|
|
|
if (isLoading) {
|
|
return <Skeleton className={cn('h-full w-full', className)} />;
|
|
}
|
|
|
|
if (!data || data.length === 0) {
|
|
return (
|
|
<div className={cn('h-full flex items-center justify-center', className)}>
|
|
<p className="text-muted-foreground">
|
|
{granularity === 'hourly' ? t('analytics.noDailyUsage') : t('analytics.noUsageData')}
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Custom tick component for privacy-aware axis labels
|
|
const PrivacyTick = ({
|
|
x,
|
|
y,
|
|
payload,
|
|
isRight,
|
|
}: {
|
|
x: number;
|
|
y: number;
|
|
payload: { value: string | number };
|
|
isRight?: boolean;
|
|
}) => {
|
|
const displayValue = isRight ? `$${payload.value}` : formatNumber(Number(payload.value));
|
|
|
|
return (
|
|
<text
|
|
x={x}
|
|
y={y}
|
|
dy={4}
|
|
textAnchor={isRight ? 'start' : 'end'}
|
|
fontSize={12}
|
|
fill="currentColor"
|
|
className={cn('fill-muted-foreground', privacyMode && 'blur-[4px]')}
|
|
>
|
|
{displayValue}
|
|
</text>
|
|
);
|
|
};
|
|
|
|
return (
|
|
<div className={cn('w-full h-full', className)}>
|
|
<ResponsiveContainer width="100%" height="100%">
|
|
<AreaChart data={chartData} margin={{ top: 5, right: 30, left: 20, bottom: 5 }}>
|
|
<defs>
|
|
<linearGradient id="tokenGradient" x1="0" y1="0" x2="0" y2="1">
|
|
<stop offset="5%" stopColor="#0080FF" stopOpacity={0.8} />
|
|
<stop offset="95%" stopColor="#0080FF" stopOpacity={0.1} />
|
|
</linearGradient>
|
|
<linearGradient id="costGradient" x1="0" y1="0" x2="0" y2="1">
|
|
<stop offset="5%" stopColor="#00C49F" stopOpacity={0.8} />
|
|
<stop offset="95%" stopColor="#00C49F" stopOpacity={0.1} />
|
|
</linearGradient>
|
|
</defs>
|
|
|
|
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
|
|
|
|
<XAxis
|
|
dataKey="dateFormatted"
|
|
tick={{ fontSize: 12 }}
|
|
tickLine={false}
|
|
axisLine={{ className: 'stroke-muted' }}
|
|
/>
|
|
|
|
<YAxis
|
|
yAxisId="left"
|
|
orientation="left"
|
|
tick={(props) => <PrivacyTick {...props} isRight={false} />}
|
|
tickLine={false}
|
|
axisLine={{ className: 'stroke-muted' }}
|
|
/>
|
|
|
|
<YAxis
|
|
yAxisId="right"
|
|
orientation="right"
|
|
tick={(props) => <PrivacyTick {...props} isRight={true} />}
|
|
tickLine={false}
|
|
axisLine={{ className: 'stroke-muted' }}
|
|
/>
|
|
|
|
<Tooltip
|
|
content={({ active, payload, label }) => {
|
|
if (!active || !payload || !payload.length) return null;
|
|
|
|
return (
|
|
<div className="rounded-lg border bg-background p-3 shadow-lg">
|
|
<p className="font-medium mb-2">{label}</p>
|
|
{payload.map((entry, index) => (
|
|
<p
|
|
key={index}
|
|
className={cn('text-sm', privacyMode && PRIVACY_BLUR_CLASS)}
|
|
style={{ color: entry.color }}
|
|
>
|
|
{entry.name}:{' '}
|
|
{entry.name === 'Tokens'
|
|
? formatNumber(Number(entry.value) || 0)
|
|
: `$${entry.value}`}
|
|
</p>
|
|
))}
|
|
</div>
|
|
);
|
|
}}
|
|
/>
|
|
|
|
<Area
|
|
yAxisId="left"
|
|
type="monotone"
|
|
dataKey="tokens"
|
|
stroke="#0080FF"
|
|
strokeWidth={2}
|
|
fillOpacity={1}
|
|
fill="url(#tokenGradient)"
|
|
name="Tokens"
|
|
/>
|
|
|
|
<Area
|
|
yAxisId="right"
|
|
type="monotone"
|
|
dataKey="costRounded"
|
|
stroke="#00C49F"
|
|
strokeWidth={2}
|
|
fillOpacity={1}
|
|
fill="url(#costGradient)"
|
|
name="Cost"
|
|
/>
|
|
</AreaChart>
|
|
</ResponsiveContainer>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Helper functions
|
|
function formatTime(timeStr: string, granularity: 'daily' | 'monthly' | 'hourly'): string {
|
|
if (granularity === 'hourly') {
|
|
// Format: "YYYY-MM-DD HH:00" -> convert UTC to local time -> "HH:00"
|
|
// Parse as UTC and format in local timezone
|
|
const [datePart, timePart] = timeStr.split(' ');
|
|
if (datePart && timePart) {
|
|
// Create date in UTC: "2025-12-12 20:00" -> "2025-12-12T20:00:00Z"
|
|
const utcDate = new Date(`${datePart}T${timePart}:00Z`);
|
|
return format(utcDate, 'HH:mm');
|
|
}
|
|
return timeStr;
|
|
}
|
|
|
|
const date = new Date(timeStr);
|
|
|
|
if (granularity === 'monthly') {
|
|
return format(date, 'MMM yyyy');
|
|
}
|
|
|
|
// For daily, show shorter format
|
|
return format(date, 'MMM dd');
|
|
}
|
|
|
|
function formatNumber(num: number): string {
|
|
if (num >= 1000000) {
|
|
return `${(num / 1000000).toFixed(1)}M`;
|
|
}
|
|
if (num >= 1000) {
|
|
return `${(num / 1000).toFixed(1)}K`;
|
|
}
|
|
return num.toLocaleString();
|
|
}
|