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 (#65)
* Fix timezone display issues and support hourly visualization * PR feedback * Remove code * More cleanup
This commit is contained in:
parent
692a8ff31e
commit
a60938cea3
8 changed files with 560 additions and 120 deletions
|
|
@ -7,7 +7,7 @@ import {
|
|||
type RooCodeTelemetryEvent,
|
||||
} from '@roo-code/types';
|
||||
|
||||
import type { TimePeriod } from '@/types';
|
||||
import type { AnyTimePeriod } from '@/types';
|
||||
import { taskSchema } from '@/types/analytics';
|
||||
import { analytics } from '@/lib/server';
|
||||
import { type User, getUsersById } from '@/db/server';
|
||||
|
|
@ -80,7 +80,7 @@ export const getUsage = async ({
|
|||
timePeriod = 90,
|
||||
}: {
|
||||
orgId?: string | null;
|
||||
timePeriod?: TimePeriod;
|
||||
timePeriod?: AnyTimePeriod;
|
||||
}): Promise<UsageRecord> => {
|
||||
if (!orgId) {
|
||||
return {};
|
||||
|
|
@ -134,7 +134,7 @@ export const getDeveloperUsage = async ({
|
|||
timePeriod = 90,
|
||||
}: {
|
||||
orgId?: string | null;
|
||||
timePeriod?: TimePeriod;
|
||||
timePeriod?: AnyTimePeriod;
|
||||
}): Promise<DeveloperUsage[]> => {
|
||||
if (!orgId) {
|
||||
return [];
|
||||
|
|
@ -196,7 +196,7 @@ export const getModelUsage = async ({
|
|||
timePeriod = 90,
|
||||
}: {
|
||||
orgId?: string | null;
|
||||
timePeriod?: TimePeriod;
|
||||
timePeriod?: AnyTimePeriod;
|
||||
}): Promise<ModelUsage[]> => {
|
||||
if (!orgId) {
|
||||
return [];
|
||||
|
|
@ -304,28 +304,28 @@ export const getTasks = async ({
|
|||
};
|
||||
|
||||
/**
|
||||
* getDailyUsageByUser
|
||||
* getHourlyUsageByUser
|
||||
*/
|
||||
|
||||
const dailyUsageByUserSchema = z.object({
|
||||
date: z.string(),
|
||||
const hourlyUsageByUserSchema = z.object({
|
||||
hour_utc: z.string(),
|
||||
userId: z.string(),
|
||||
tasks: z.coerce.number(),
|
||||
tokens: z.coerce.number(),
|
||||
cost: z.coerce.number(),
|
||||
});
|
||||
|
||||
export type DailyUsageByUser = z.infer<typeof dailyUsageByUserSchema> & {
|
||||
export type HourlyUsageByUser = z.infer<typeof hourlyUsageByUserSchema> & {
|
||||
user: User;
|
||||
};
|
||||
|
||||
export const getDailyUsageByUser = async ({
|
||||
export const getHourlyUsageByUser = async ({
|
||||
orgId,
|
||||
timePeriod = 90,
|
||||
}: {
|
||||
orgId?: string | null;
|
||||
timePeriod?: TimePeriod;
|
||||
}): Promise<DailyUsageByUser[]> => {
|
||||
timePeriod?: AnyTimePeriod;
|
||||
}): Promise<HourlyUsageByUser[]> => {
|
||||
if (!orgId) {
|
||||
return [];
|
||||
}
|
||||
|
|
@ -333,7 +333,7 @@ export const getDailyUsageByUser = async ({
|
|||
const results = await analytics.query({
|
||||
query: `
|
||||
SELECT
|
||||
toString(toDate(fromUnixTimestamp(timestamp))) as date,
|
||||
toString(toStartOfHour(fromUnixTimestamp(timestamp))) as hour_utc,
|
||||
userId,
|
||||
SUM(CASE WHEN type = '${TelemetryEventName.TASK_CREATED}' THEN 1 ELSE 0 END) AS tasks,
|
||||
SUM(CASE WHEN type = '${TelemetryEventName.LLM_COMPLETION}' THEN COALESCE(inputTokens, 0) + COALESCE(outputTokens, 0) ELSE 0 END) AS tokens,
|
||||
|
|
@ -344,7 +344,7 @@ export const getDailyUsageByUser = async ({
|
|||
AND timestamp >= toUnixTimestamp(now() - INTERVAL {timePeriod: Int32} DAY)
|
||||
AND type IN ({types: Array(String)})
|
||||
GROUP BY 1, 2
|
||||
ORDER BY date DESC, userId
|
||||
ORDER BY hour_utc DESC, userId
|
||||
`,
|
||||
format: 'JSONEachRow',
|
||||
query_params: {
|
||||
|
|
@ -358,13 +358,13 @@ export const getDailyUsageByUser = async ({
|
|||
},
|
||||
});
|
||||
|
||||
const dailyUsages = z
|
||||
.array(dailyUsageByUserSchema)
|
||||
const hourlyUsages = z
|
||||
.array(hourlyUsageByUserSchema)
|
||||
.parse(await results.json());
|
||||
|
||||
const users = await getUsersById(dailyUsages.map(({ userId }) => userId));
|
||||
const users = await getUsersById(hourlyUsages.map(({ userId }) => userId));
|
||||
|
||||
return dailyUsages
|
||||
return hourlyUsages
|
||||
.map((usage) => ({ ...usage, user: users[usage.userId] }))
|
||||
.filter((usage): usage is DailyUsageByUser => !!usage.user);
|
||||
.filter((usage): usage is HourlyUsageByUser => !!usage.user);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import { ArrowRightIcon } from 'lucide-react';
|
|||
|
||||
import { TelemetryEventName } from '@roo-code/types';
|
||||
|
||||
import { type TimePeriod, timePeriods } from '@/types';
|
||||
import { type TimePeriodConfig, allTimePeriods } from '@/types';
|
||||
import { getUsage } from '@/actions/analytics';
|
||||
import { formatCurrency, formatNumber } from '@/lib/formatters';
|
||||
|
||||
|
|
@ -30,14 +30,28 @@ type MetricType = 'tasks' | 'tokens' | 'cost';
|
|||
export const UsageCard = () => {
|
||||
const t = useTranslations('DashboardIndex');
|
||||
const { orgId } = useAuth();
|
||||
const [timePeriod, setTimePeriod] = useState<TimePeriod>(7);
|
||||
const [selectedPeriod, setSelectedPeriod] = useState<TimePeriodConfig>(
|
||||
allTimePeriods.find((p) => p.value === 7 && p.granularity === 'daily')!,
|
||||
);
|
||||
const [selectedMetric, setSelectedMetric] = useState<MetricType>('tasks');
|
||||
|
||||
const path = usePathname();
|
||||
|
||||
const { data: usage = {}, isPending } = useQuery({
|
||||
queryKey: ['usage', orgId, timePeriod],
|
||||
queryFn: () => getUsage({ orgId, timePeriod }),
|
||||
queryKey: [
|
||||
'usage',
|
||||
orgId,
|
||||
selectedPeriod.value,
|
||||
selectedPeriod.granularity,
|
||||
],
|
||||
queryFn: () =>
|
||||
getUsage({
|
||||
orgId,
|
||||
timePeriod:
|
||||
selectedPeriod.granularity === 'daily'
|
||||
? (selectedPeriod.value as 7 | 30 | 90)
|
||||
: (selectedPeriod.value as 1), // Use 1 day for 24h view
|
||||
}),
|
||||
enabled: !!orgId,
|
||||
});
|
||||
|
||||
|
|
@ -71,17 +85,18 @@ export const UsageCard = () => {
|
|||
<div className="flex flex-row gap-3 items-center">
|
||||
{/* Time period toggles */}
|
||||
<div className="flex flex-row gap-1">
|
||||
{timePeriods.map((period) => (
|
||||
{allTimePeriods.map((periodConfig) => (
|
||||
<button
|
||||
key={period}
|
||||
onClick={() => setTimePeriod(period)}
|
||||
key={`${periodConfig.value}-${periodConfig.granularity}`}
|
||||
onClick={() => setSelectedPeriod(periodConfig)}
|
||||
className={`px-2 py-1 text-xs font-medium rounded transition-colors ${
|
||||
period === timePeriod
|
||||
periodConfig.value === selectedPeriod.value &&
|
||||
periodConfig.granularity === selectedPeriod.granularity
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-muted'
|
||||
}`}
|
||||
>
|
||||
{period}d
|
||||
{periodConfig.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -152,7 +167,7 @@ export const UsageCard = () => {
|
|||
{/* Chart displayed below on usage page */}
|
||||
{path === '/usage' && (
|
||||
<UsageChart
|
||||
timePeriod={timePeriod}
|
||||
timePeriodConfig={selectedPeriod}
|
||||
selectedMetric={selectedMetric}
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
'use client';
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
import React, { useMemo, useEffect, useState } from 'react';
|
||||
import { useAuth } from '@clerk/nextjs';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
|
|
@ -13,15 +13,12 @@ import {
|
|||
ResponsiveContainer,
|
||||
} from 'recharts';
|
||||
|
||||
import type { TimePeriod } from '@/types';
|
||||
import { getDailyUsageByUser } from '@/actions/analytics';
|
||||
import type { TimePeriodConfig } from '@/types';
|
||||
import { getHourlyUsageByUser } from '@/actions/analytics';
|
||||
import { formatNumber } from '@/lib/formatters';
|
||||
import { Skeleton } from '@/components/ui';
|
||||
|
||||
import { aggregateHourlyToDaily } from '@/lib/timezoneUtils';
|
||||
type MetricType = 'tasks' | 'tokens' | 'cost';
|
||||
|
||||
const metricTypes: MetricType[] = ['tasks', 'tokens', 'cost'];
|
||||
|
||||
interface TickProps {
|
||||
x?: number;
|
||||
y?: number;
|
||||
|
|
@ -68,26 +65,140 @@ const generateUserColor = (index: number): string => {
|
|||
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 {
|
||||
timePeriod: TimePeriod;
|
||||
timePeriodConfig: TimePeriodConfig;
|
||||
selectedMetric?: MetricType;
|
||||
}
|
||||
|
||||
// Custom tick components for theme-aware labels
|
||||
const CustomXAxisTick = (props: TickProps) => {
|
||||
const { x, y, payload } = props;
|
||||
const CustomXAxisTick = (props: TickProps & { isHourly?: boolean }) => {
|
||||
const { x, y, payload, isHourly } = props;
|
||||
|
||||
if (!payload?.value) return null;
|
||||
|
||||
// Parse date string as local date to avoid timezone issues
|
||||
const [year, month, day] = payload.value.split('-').map(Number);
|
||||
if (!year || !month || !day) return null;
|
||||
let formattedDate: string;
|
||||
|
||||
const date = new Date(year, month - 1, day); // month is 0-indexed
|
||||
const formattedDate = date.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
});
|
||||
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})`}>
|
||||
|
|
@ -137,18 +248,36 @@ const CustomTooltip = ({
|
|||
payload,
|
||||
label,
|
||||
formatValue,
|
||||
}: TooltipProps) => {
|
||||
isHourly,
|
||||
}: TooltipProps & { isHourly?: boolean }) => {
|
||||
if (active && payload && payload.length && label) {
|
||||
// Parse date string as local date to avoid timezone issues
|
||||
const [year, month, day] = label.split('-').map(Number);
|
||||
if (!year || !month || !day) return null;
|
||||
let formattedDate: string;
|
||||
|
||||
const date = new Date(year, month - 1, day); // month is 0-indexed
|
||||
const formattedDate = date.toLocaleDateString('en-US', {
|
||||
weekday: 'short',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
});
|
||||
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]">
|
||||
|
|
@ -185,62 +314,50 @@ const CustomTooltip = ({
|
|||
};
|
||||
|
||||
export const UsageChart = ({
|
||||
timePeriod,
|
||||
timePeriodConfig,
|
||||
selectedMetric = 'tasks',
|
||||
}: UsageChartProps) => {
|
||||
const { orgId } = useAuth();
|
||||
const [isClient, setIsClient] = useState(false);
|
||||
|
||||
const { data: dailyUsage = [], isPending } = useQuery({
|
||||
queryKey: ['getDailyUsageByUser', orgId, timePeriod],
|
||||
queryFn: () => getDailyUsageByUser({ orgId, timePeriod }),
|
||||
// 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 (!dailyUsage.length) return [];
|
||||
if (!hourlyUsage.length || !isClient) return [];
|
||||
|
||||
// Group data by date
|
||||
const dateGroups = dailyUsage.reduce(
|
||||
(acc, item) => {
|
||||
const date = item.date;
|
||||
if (!acc[date]) {
|
||||
acc[date] = { date, total: 0 };
|
||||
}
|
||||
|
||||
const value = item[selectedMetric];
|
||||
acc[date][item.user.name || item.user.email || 'Unknown'] = value;
|
||||
acc[date].total += value;
|
||||
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, ChartDataPoint>,
|
||||
);
|
||||
|
||||
// Convert to array and sort by date
|
||||
const result = Object.values(dateGroups).sort((a, b) => {
|
||||
// Parse dates as local dates to avoid timezone issues
|
||||
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();
|
||||
});
|
||||
|
||||
return result;
|
||||
}, [dailyUsage, selectedMetric]);
|
||||
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>();
|
||||
dailyUsage.forEach((item) => {
|
||||
hourlyUsage.forEach((item) => {
|
||||
users.add(item.user.name || item.user.email || 'Unknown');
|
||||
});
|
||||
return Array.from(users).sort();
|
||||
}, [dailyUsage]);
|
||||
}, [hourlyUsage]);
|
||||
|
||||
const formatValue = (value: number) => {
|
||||
switch (selectedMetric) {
|
||||
|
|
@ -255,30 +372,34 @@ export const UsageChart = ({
|
|||
}
|
||||
};
|
||||
|
||||
if (isPending) {
|
||||
if (isPending || !isClient) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-wrap gap-2 justify-center sm:justify-start">
|
||||
{metricTypes.map((metric) => (
|
||||
<Skeleton key={metric} className="h-9 w-20 rounded-md" />
|
||||
))}
|
||||
</div>
|
||||
<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="space-y-6 w-full">
|
||||
<div className="flex justify-between items-end h-48 px-4">
|
||||
{Array.from({ length: 7 }).map((_, i) => (
|
||||
<Skeleton
|
||||
key={i}
|
||||
className="w-12 rounded-t-md"
|
||||
style={{ height: `${Math.random() * 60 + 30}%` }}
|
||||
<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 className="flex justify-center gap-4 pt-4">
|
||||
<Skeleton className="h-3 w-20" />
|
||||
<Skeleton className="h-3 w-24" />
|
||||
<Skeleton className="h-3 w-16" />
|
||||
<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>
|
||||
|
|
@ -345,7 +466,11 @@ export const UsageChart = ({
|
|||
dataKey="date"
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tick={<CustomXAxisTick />}
|
||||
tick={
|
||||
<CustomXAxisTick
|
||||
isHourly={timePeriodConfig.granularity === 'hourly'}
|
||||
/>
|
||||
}
|
||||
height={25}
|
||||
/>
|
||||
<YAxis
|
||||
|
|
@ -360,6 +485,7 @@ export const UsageChart = ({
|
|||
<CustomTooltip
|
||||
selectedMetric={selectedMetric}
|
||||
formatValue={formatValue}
|
||||
isHourly={timePeriodConfig.granularity === 'hourly'}
|
||||
/>
|
||||
}
|
||||
cursor={{
|
||||
|
|
|
|||
|
|
@ -3,9 +3,8 @@
|
|||
import { formatNumber, formatCurrency } from '../formatters';
|
||||
|
||||
describe('formatNumber', () => {
|
||||
it('should return empty string for undefined or null values', () => {
|
||||
it('should return empty string for undefined values', () => {
|
||||
expect(formatNumber(undefined)).toBe('');
|
||||
expect(formatNumber(null as unknown as undefined)).toBe('');
|
||||
});
|
||||
|
||||
it('should return "0" for zero', () => {
|
||||
|
|
@ -54,9 +53,8 @@ describe('formatNumber', () => {
|
|||
});
|
||||
|
||||
describe('formatCurrency', () => {
|
||||
it('should return empty string for undefined or null values', () => {
|
||||
it('should return empty string for undefined values', () => {
|
||||
expect(formatCurrency(undefined)).toBe('');
|
||||
expect(formatCurrency(null as unknown as undefined)).toBe('');
|
||||
});
|
||||
|
||||
it('should format USD currency correctly', () => {
|
||||
|
|
|
|||
142
src/lib/__tests__/timezoneUtils.test.ts
Normal file
142
src/lib/__tests__/timezoneUtils.test.ts
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
import { aggregateHourlyToDaily } from '../timezoneUtils';
|
||||
import { HourlyUsageByUser } from '@/actions/analytics/events';
|
||||
|
||||
// Mock timezone to ensure consistent test results
|
||||
const mockTimezone = 'America/New_York';
|
||||
|
||||
// Mock Intl.DateTimeFormat to return consistent timezone
|
||||
Object.defineProperty(Intl, 'DateTimeFormat', {
|
||||
value: () => ({
|
||||
resolvedOptions: () => ({ timeZone: mockTimezone }),
|
||||
}),
|
||||
writable: true,
|
||||
});
|
||||
|
||||
describe('timezoneUtils', () => {
|
||||
describe('aggregateHourlyToDaily', () => {
|
||||
it('should aggregate hourly data to daily data correctly', () => {
|
||||
const mockUser1 = {
|
||||
id: 'user1',
|
||||
orgId: 'org1',
|
||||
orgRole: 'member',
|
||||
name: 'John Doe',
|
||||
email: 'john@example.com',
|
||||
imageUrl: 'https://example.com/avatar1.jpg',
|
||||
entity: {},
|
||||
lastSyncAt: new Date(),
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
const mockUser2 = {
|
||||
id: 'user2',
|
||||
orgId: 'org1',
|
||||
orgRole: 'member',
|
||||
name: 'Jane Smith',
|
||||
email: 'jane@example.com',
|
||||
imageUrl: 'https://example.com/avatar2.jpg',
|
||||
entity: {},
|
||||
lastSyncAt: new Date(),
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
const mockHourlyData: HourlyUsageByUser[] = [
|
||||
{
|
||||
hour_utc: '2025-06-02 14:00:00',
|
||||
userId: 'user1',
|
||||
tasks: 5,
|
||||
tokens: 1000,
|
||||
cost: 0.05,
|
||||
user: mockUser1,
|
||||
},
|
||||
{
|
||||
hour_utc: '2025-06-02 15:00:00',
|
||||
userId: 'user1',
|
||||
tasks: 3,
|
||||
tokens: 500,
|
||||
cost: 0.03,
|
||||
user: mockUser1,
|
||||
},
|
||||
{
|
||||
hour_utc: '2025-06-02 16:00:00',
|
||||
userId: 'user2',
|
||||
tasks: 2,
|
||||
tokens: 300,
|
||||
cost: 0.02,
|
||||
user: mockUser2,
|
||||
},
|
||||
];
|
||||
|
||||
const result = aggregateHourlyToDaily(mockHourlyData, mockTimezone);
|
||||
|
||||
expect(result).toHaveLength(2); // Two users
|
||||
|
||||
const user1Data = result.find((r) => r.userId === 'user1');
|
||||
const user2Data = result.find((r) => r.userId === 'user2');
|
||||
|
||||
expect(user1Data).toEqual({
|
||||
date: '2025-06-02',
|
||||
userId: 'user1',
|
||||
tasks: 8, // 5 + 3
|
||||
tokens: 1500, // 1000 + 500
|
||||
cost: 0.08, // 0.05 + 0.03
|
||||
user: mockUser1,
|
||||
});
|
||||
|
||||
expect(user2Data).toEqual({
|
||||
date: '2025-06-02',
|
||||
userId: 'user2',
|
||||
tasks: 2,
|
||||
tokens: 300,
|
||||
cost: 0.02,
|
||||
user: mockUser2,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle empty data', () => {
|
||||
const result = aggregateHourlyToDaily([]);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle data across multiple days', () => {
|
||||
const mockUser = {
|
||||
id: 'user1',
|
||||
orgId: 'org1',
|
||||
orgRole: 'member',
|
||||
name: 'John Doe',
|
||||
email: 'john@example.com',
|
||||
imageUrl: 'https://example.com/avatar1.jpg',
|
||||
entity: {},
|
||||
lastSyncAt: new Date(),
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
const mockHourlyData: HourlyUsageByUser[] = [
|
||||
{
|
||||
hour_utc: '2025-06-02 23:00:00', // Late UTC might be next day in some timezones
|
||||
userId: 'user1',
|
||||
tasks: 1,
|
||||
tokens: 100,
|
||||
cost: 0.01,
|
||||
user: mockUser,
|
||||
},
|
||||
{
|
||||
hour_utc: '2025-06-03 01:00:00', // Early UTC might be same day in some timezones
|
||||
userId: 'user1',
|
||||
tasks: 2,
|
||||
tokens: 200,
|
||||
cost: 0.02,
|
||||
user: mockUser,
|
||||
},
|
||||
];
|
||||
|
||||
const result = aggregateHourlyToDaily(mockHourlyData, mockTimezone);
|
||||
|
||||
// Should have separate entries for different local dates
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
expect(result.every((r) => r.date)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -53,3 +53,71 @@ export function formatCurrency(
|
|||
|
||||
export const formatTimestamp = (timestamp: number) =>
|
||||
new Date(timestamp * 1000).toLocaleString();
|
||||
|
||||
/**
|
||||
* Convert UTC hour string to user's local date
|
||||
* @param utcHour UTC hour string in format "2025-06-02 14:00:00"
|
||||
* @param userTimezone User's timezone (defaults to browser timezone)
|
||||
* @returns Local date string in YYYY-MM-DD format
|
||||
*/
|
||||
export const convertUTCHourToLocalDate = (
|
||||
utcHour: string,
|
||||
userTimezone?: string,
|
||||
): string => {
|
||||
// Check if we're in a browser environment
|
||||
if (typeof window === 'undefined') {
|
||||
// On server, just return the UTC date part to avoid hydration mismatch
|
||||
return utcHour.split(' ')[0] || '';
|
||||
}
|
||||
|
||||
try {
|
||||
// Parse UTC hour string - handle different formats
|
||||
let utcDate: Date;
|
||||
|
||||
if (utcHour.includes('T')) {
|
||||
// ISO format: "2025-06-02T14:00:00"
|
||||
utcDate = new Date(utcHour + 'Z');
|
||||
} else if (utcHour.includes(' ')) {
|
||||
// Space format: "2025-06-02 14:00:00"
|
||||
utcDate = new Date(utcHour.replace(' ', 'T') + 'Z');
|
||||
} else {
|
||||
// Just date: "2025-06-02"
|
||||
utcDate = new Date(utcHour + 'T00:00:00Z');
|
||||
}
|
||||
|
||||
// Validate the date
|
||||
if (isNaN(utcDate.getTime())) {
|
||||
console.warn('Invalid UTC hour format:', utcHour);
|
||||
return utcHour.split(' ')[0] || utcHour.split('T')[0] || '';
|
||||
}
|
||||
|
||||
// Get user's timezone (default to browser timezone)
|
||||
const timezone =
|
||||
userTimezone || Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
|
||||
// Convert to user's local timezone and get the date part
|
||||
const localDateString = utcDate.toLocaleDateString('en-CA', {
|
||||
timeZone: timezone,
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
});
|
||||
|
||||
return localDateString;
|
||||
} catch (error) {
|
||||
console.warn('Error converting UTC hour to local date:', error, utcHour);
|
||||
// Fallback: return the date part of the input
|
||||
return utcHour.split(' ')[0] || utcHour.split('T')[0] || '';
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get user's current timezone
|
||||
* @returns User's timezone string (e.g., "America/New_York")
|
||||
*/
|
||||
export const getUserTimezone = (): string => {
|
||||
if (typeof window === 'undefined') {
|
||||
return 'UTC'; // Default to UTC on server
|
||||
}
|
||||
return Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
};
|
||||
|
|
|
|||
76
src/lib/timezoneUtils.ts
Normal file
76
src/lib/timezoneUtils.ts
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
import { convertUTCHourToLocalDate, getUserTimezone } from './formatters';
|
||||
import type { HourlyUsageByUser } from '@/actions/analytics/events';
|
||||
|
||||
export type DailyAggregatedUsage = {
|
||||
date: string;
|
||||
userId: string;
|
||||
tasks: number;
|
||||
tokens: number;
|
||||
cost: number;
|
||||
user: {
|
||||
id: string;
|
||||
name?: string | null;
|
||||
email?: string | null;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Aggregate hourly usage data into daily buckets based on user's local timezone
|
||||
* @param hourlyData Array of hourly usage data from server
|
||||
* @param userTimezone Optional timezone override (defaults to browser timezone)
|
||||
* @returns Array of daily aggregated usage data
|
||||
*/
|
||||
export const aggregateHourlyToDaily = (
|
||||
hourlyData: HourlyUsageByUser[],
|
||||
userTimezone?: string,
|
||||
): DailyAggregatedUsage[] => {
|
||||
const timezone = userTimezone || getUserTimezone();
|
||||
|
||||
// Group hourly data by local date and user
|
||||
const dailyGroups: Record<string, Record<string, DailyAggregatedUsage>> = {};
|
||||
|
||||
hourlyData.forEach((hourlyRecord) => {
|
||||
// Convert UTC hour to user's local date
|
||||
const localDate = convertUTCHourToLocalDate(
|
||||
hourlyRecord.hour_utc,
|
||||
timezone,
|
||||
);
|
||||
const userId = hourlyRecord.userId;
|
||||
|
||||
if (!dailyGroups[localDate]) {
|
||||
dailyGroups[localDate] = {};
|
||||
}
|
||||
|
||||
if (!dailyGroups[localDate][userId]) {
|
||||
dailyGroups[localDate][userId] = {
|
||||
date: localDate,
|
||||
userId,
|
||||
tasks: 0,
|
||||
tokens: 0,
|
||||
cost: 0,
|
||||
user: hourlyRecord.user,
|
||||
};
|
||||
}
|
||||
|
||||
// Aggregate the values
|
||||
const dailyRecord = dailyGroups[localDate][userId];
|
||||
dailyRecord.tasks += hourlyRecord.tasks;
|
||||
dailyRecord.tokens += hourlyRecord.tokens;
|
||||
dailyRecord.cost += hourlyRecord.cost;
|
||||
});
|
||||
|
||||
// Flatten the grouped data into an array
|
||||
const result: DailyAggregatedUsage[] = [];
|
||||
Object.values(dailyGroups).forEach((dateGroup) => {
|
||||
Object.values(dateGroup).forEach((dailyRecord) => {
|
||||
result.push(dailyRecord);
|
||||
});
|
||||
});
|
||||
|
||||
// Sort by date descending, then by userId
|
||||
return result.sort((a, b) => {
|
||||
const dateComparison = b.date.localeCompare(a.date);
|
||||
if (dateComparison !== 0) return dateComparison;
|
||||
return a.userId.localeCompare(b.userId);
|
||||
});
|
||||
};
|
||||
|
|
@ -1,3 +1,18 @@
|
|||
export const timePeriods = [7, 30, 90] as const;
|
||||
|
||||
export type TimePeriod = (typeof timePeriods)[number];
|
||||
export type AnyTimePeriod = TimePeriod | 1; // 1 for 24h view, 7|30|90 for daily views
|
||||
export type TimeGranularity = 'hourly' | 'daily';
|
||||
|
||||
export type TimePeriodConfig = {
|
||||
value: AnyTimePeriod;
|
||||
granularity: TimeGranularity;
|
||||
label: string;
|
||||
};
|
||||
|
||||
export const allTimePeriods: TimePeriodConfig[] = [
|
||||
{ value: 1, granularity: 'hourly', label: '24h' },
|
||||
{ value: 7, granularity: 'daily', label: '7d' },
|
||||
{ value: 30, granularity: 'daily', label: '30d' },
|
||||
{ value: 90, granularity: 'daily', label: '90d' },
|
||||
];
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue