mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-08-28 05:27:24 +00:00
* Fix timezone display issues and support hourly visualization * PR feedback * Remove code * More cleanup
512 lines
14 KiB
TypeScript
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>
|
|
);
|
|
};
|