Roo-Code/src/components/usage/UsageChart.tsx
Matt Rubens a60938cea3
Fix timezone display issues and support hourly visualization (#65)
* Fix timezone display issues and support hourly visualization

* PR feedback

* Remove code

* More cleanup
2025-06-02 11:13:52 -04:00

512 lines
14 KiB
TypeScript

'use client';
import React, { useMemo, useEffect, useState } from 'react';
import { useAuth } from '@clerk/nextjs';
import { useQuery } from '@tanstack/react-query';
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
} from 'recharts';
import type { TimePeriodConfig } from '@/types';
import { getHourlyUsageByUser } from '@/actions/analytics';
import { formatNumber } from '@/lib/formatters';
import { aggregateHourlyToDaily } from '@/lib/timezoneUtils';
type MetricType = 'tasks' | 'tokens' | 'cost';
interface TickProps {
x?: number;
y?: number;
payload?: {
value: string;
};
formatValue?: (value: number) => string;
}
interface TooltipEntry {
dataKey: string;
value: number;
color: string;
}
interface TooltipProps {
active?: boolean;
payload?: TooltipEntry[];
label?: string;
selectedMetric?: MetricType;
formatValue?: (value: number) => string;
}
interface ChartDataPoint {
date: string;
total: number;
[userKey: string]: string | number;
}
// Elegant color palette inspired by modern design systems
const generateUserColor = (index: number): string => {
const colors = [
'#3B82F6', // Blue
'#10B981', // Emerald
'#8B5CF6', // Violet
'#F59E0B', // Amber
'#EF4444', // Red
'#06B6D4', // Cyan
'#84CC16', // Lime
'#EC4899', // Pink
'#6366F1', // Indigo
'#14B8A6', // Teal
];
return colors[index % colors.length]!;
};
// Helper function to process hourly data for chart display
const processHourlyDataForChart = (
hourlyData: Array<{
hour_utc: string;
userId: string;
tasks: number;
tokens: number;
cost: number;
user: { name?: string | null; email?: string | null };
}>,
selectedMetric: MetricType,
) => {
// Group by UTC hour and user (display conversion happens in chart components)
const hourGroups: Record<string, ChartDataPoint> = {};
hourlyData.forEach((item) => {
// Use UTC hour as-is, conversion to local time happens in display components
const utcHour = item.hour_utc;
let isoHour: string;
try {
// Convert to ISO format for consistent handling
if (utcHour.includes('T')) {
isoHour = utcHour + 'Z';
} else {
isoHour = utcHour.replace(' ', 'T') + 'Z';
}
// Validate the date format
const testDate = new Date(isoHour);
if (isNaN(testDate.getTime())) {
console.warn('Invalid UTC hour format:', utcHour);
return;
}
} catch (error) {
console.warn('Error processing UTC hour:', error, utcHour);
return;
}
if (!hourGroups[isoHour]) {
hourGroups[isoHour] = { date: isoHour, total: 0 };
}
const value = item[selectedMetric];
const userName = item.user.name || item.user.email || 'Unknown';
const hourGroup = hourGroups[isoHour];
if (hourGroup) {
hourGroup[userName] = value;
hourGroup.total += value;
}
});
// Convert to array and sort by hour
return Object.values(hourGroups).sort((a, b) => {
return new Date(a.date).getTime() - new Date(b.date).getTime();
});
};
// Helper function to process daily data for chart display
const processDailyDataForChart = (
dailyData: Array<{
date: string;
userId: string;
tasks: number;
tokens: number;
cost: number;
user: { name?: string | null; email?: string | null };
}>,
selectedMetric: MetricType,
) => {
// Group by date and user
const dateGroups: Record<string, ChartDataPoint> = {};
dailyData.forEach((item) => {
const date = item.date;
if (!dateGroups[date]) {
dateGroups[date] = { date, total: 0 };
}
const value = item[selectedMetric];
dateGroups[date][item.user.name || item.user.email || 'Unknown'] = value;
dateGroups[date].total += value;
});
// Convert to array and sort by date
return Object.values(dateGroups).sort((a, b) => {
const [yearA, monthA, dayA] = a.date.split('-').map(Number);
const [yearB, monthB, dayB] = b.date.split('-').map(Number);
if (!yearA || !monthA || !dayA || !yearB || !monthB || !dayB) {
return 0;
}
const dateA = new Date(yearA, monthA - 1, dayA);
const dateB = new Date(yearB, monthB - 1, dayB);
return dateA.getTime() - dateB.getTime();
});
};
interface UsageChartProps {
timePeriodConfig: TimePeriodConfig;
selectedMetric?: MetricType;
}
// Custom tick components for theme-aware labels
const CustomXAxisTick = (props: TickProps & { isHourly?: boolean }) => {
const { x, y, payload, isHourly } = props;
if (!payload?.value) return null;
let formattedDate: string;
if (isHourly) {
// For hourly data, show hour format
const date = new Date(payload.value);
if (isNaN(date.getTime())) return null;
// Convert UTC time to local time for display
formattedDate = date.toLocaleTimeString('en-US', {
hour: 'numeric',
hour12: true,
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
});
} else {
// For daily data, show date format
const [year, month, day] = payload.value.split('-').map(Number);
if (!year || !month || !day) return null;
const date = new Date(year, month - 1, day); // month is 0-indexed
formattedDate = date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
});
}
return (
<g transform={`translate(${x},${y})`}>
<text
x={0}
y={0}
dy={16}
textAnchor="middle"
className="fill-muted-foreground text-xs font-medium"
>
{formattedDate}
</text>
</g>
);
};
const CustomYAxisTick = (props: TickProps) => {
const { x, y, payload, formatValue } = props;
if (!payload?.value) return null;
const value =
typeof payload.value === 'string'
? parseFloat(payload.value)
: payload.value;
return (
<g transform={`translate(${x},${y})`}>
<text
x={0}
y={0}
dy={4}
textAnchor="end"
className="fill-muted-foreground text-xs font-medium"
>
{formatValue && typeof value === 'number'
? formatValue(value)
: payload.value}
</text>
</g>
);
};
// Custom tooltip component
const CustomTooltip = ({
active,
payload,
label,
formatValue,
isHourly,
}: TooltipProps & { isHourly?: boolean }) => {
if (active && payload && payload.length && label) {
let formattedDate: string;
if (isHourly) {
// For hourly data, label is an ISO datetime string
const date = new Date(label);
if (isNaN(date.getTime())) return null;
formattedDate = date.toLocaleString('en-US', {
weekday: 'short',
month: 'short',
day: 'numeric',
hour: 'numeric',
hour12: true,
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
});
} else {
// For daily data, label is a date string "YYYY-MM-DD"
const [year, month, day] = label.split('-').map(Number);
if (!year || !month || !day) return null;
const date = new Date(year, month - 1, day); // month is 0-indexed
formattedDate = date.toLocaleDateString('en-US', {
weekday: 'short',
month: 'short',
day: 'numeric',
});
}
return (
<div className="bg-popover border border-border rounded-lg shadow-lg p-3 min-w-[200px]">
<p className="text-sm font-medium text-foreground mb-2">
{formattedDate}
</p>
<div className="space-y-1">
{payload.map((entry, index) => (
<div
key={index}
className="flex items-center justify-between gap-3"
>
<div className="flex items-center gap-2">
<div
className="w-3 h-3 rounded-sm"
style={{ backgroundColor: entry.color }}
/>
<span className="text-xs text-muted-foreground">
{entry.dataKey}
</span>
</div>
<span className="text-xs font-medium text-foreground">
{typeof entry.value === 'number' && formatValue
? formatValue(entry.value)
: entry.value}
</span>
</div>
))}
</div>
</div>
);
}
return null;
};
export const UsageChart = ({
timePeriodConfig,
selectedMetric = 'tasks',
}: UsageChartProps) => {
const { orgId } = useAuth();
const [isClient, setIsClient] = useState(false);
// Ensure we only run timezone-dependent code on the client
useEffect(() => {
setIsClient(true);
}, []);
const { data: hourlyUsage = [], isPending } = useQuery({
queryKey: [
'getHourlyUsageByUser',
orgId,
timePeriodConfig.value,
timePeriodConfig.granularity,
],
queryFn: () =>
getHourlyUsageByUser({ orgId, timePeriod: timePeriodConfig.value }),
enabled: !!orgId,
});
// Process data based on granularity
const chartData = useMemo(() => {
if (!hourlyUsage.length || !isClient) return [];
if (timePeriodConfig.granularity === 'hourly') {
// For hourly view, show hourly data directly
return processHourlyDataForChart(hourlyUsage, selectedMetric);
} else {
// For daily view, aggregate hourly data to daily
const dailyUsage = aggregateHourlyToDaily(hourlyUsage);
return processDailyDataForChart(dailyUsage, selectedMetric);
}
}, [hourlyUsage, isClient, timePeriodConfig.granularity, selectedMetric]);
const uniqueUsers = useMemo(() => {
const users = new Set<string>();
hourlyUsage.forEach((item) => {
users.add(item.user.name || item.user.email || 'Unknown');
});
return Array.from(users).sort();
}, [hourlyUsage]);
const formatValue = (value: number) => {
switch (selectedMetric) {
case 'cost':
return value < 1 ? `$${value.toFixed(2)}` : `$${value.toFixed(0)}`;
case 'tokens':
return value >= 1000000
? `${(value / 1000000).toFixed(1)}M`
: formatNumber(value);
default:
return value.toString();
}
};
if (isPending || !isClient) {
return (
<div className="space-y-6">
<div className="h-72 w-full rounded-lg border bg-card p-3">
<div className="h-full w-full flex items-center justify-center">
<div className="text-center space-y-3">
<div className="w-12 h-12 mx-auto rounded-full bg-muted/20 flex items-center justify-center">
<svg
className="w-6 h-6 text-muted-foreground/50 animate-spin"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
</svg>
</div>
<div>
<p className="text-sm font-medium text-foreground">
Loading chart data...
</p>
<p className="text-xs text-muted-foreground">
Processing timezone data
</p>
</div>
</div>
</div>
</div>
</div>
);
}
if (!chartData.length) {
return (
<div className="space-y-6">
<div className="h-72 w-full rounded-lg border bg-card p-3 flex items-center justify-center">
<div className="text-center space-y-3">
<div className="w-12 h-12 mx-auto rounded-full bg-muted/20 flex items-center justify-center">
<svg
className="w-6 h-6 text-muted-foreground/50"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"
/>
</svg>
</div>
<div>
<p className="text-sm font-medium text-foreground">
No data available
</p>
<p className="text-xs text-muted-foreground">
No usage data found for the selected period
</p>
</div>
</div>
</div>
</div>
);
}
return (
<div className="space-y-6">
<div className="h-72 w-full rounded-lg border bg-card p-3">
<ResponsiveContainer width="100%" height="100%">
<BarChart
data={chartData}
margin={{
top: 5,
right: 5,
left: 0,
bottom: 5,
}}
barCategoryGap="10%"
>
<CartesianGrid
strokeDasharray="3 3"
stroke="hsl(var(--border))"
opacity={0.3}
vertical={false}
/>
<XAxis
dataKey="date"
axisLine={false}
tickLine={false}
tick={
<CustomXAxisTick
isHourly={timePeriodConfig.granularity === 'hourly'}
/>
}
height={25}
/>
<YAxis
tickFormatter={formatValue}
axisLine={false}
tickLine={false}
tick={<CustomYAxisTick formatValue={formatValue} />}
width={55}
/>
<Tooltip
content={
<CustomTooltip
selectedMetric={selectedMetric}
formatValue={formatValue}
isHourly={timePeriodConfig.granularity === 'hourly'}
/>
}
cursor={{
fill: 'hsl(var(--muted))',
opacity: 0.1,
}}
/>
{uniqueUsers.map((user, index) => (
<Bar
key={user}
dataKey={user}
stackId="usage"
fill={generateUserColor(index)}
radius={
index === uniqueUsers.length - 1 ? [4, 4, 0, 0] : [0, 0, 0, 0]
}
/>
))}
</BarChart>
</ResponsiveContainer>
</div>
</div>
);
};