diff --git a/frontend/src/components/analytics/TrendBarChart.tsx b/frontend/src/components/analytics/TrendBarChart.tsx index 1e3eecf..73fdb1e 100644 --- a/frontend/src/components/analytics/TrendBarChart.tsx +++ b/frontend/src/components/analytics/TrendBarChart.tsx @@ -1,10 +1,10 @@ import { AnalyticsRange, AnalyticsTrendPoint } from "@/types"; -import { formatCurrency } from "@/lib/utils"; +import { formatCurrency, parseUtcTimestamp } from "@/lib/utils"; import { Card } from "../ui/card"; const formatAxisLabel = (value: string, range: AnalyticsRange): string => { - const date = new Date(value); - if (Number.isNaN(date.getTime())) { + const date = parseUtcTimestamp(value); + if (!date) { return value; } diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts index 6850c0d..6ce6dff 100644 --- a/frontend/src/lib/utils.ts +++ b/frontend/src/lib/utils.ts @@ -5,6 +5,25 @@ export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) } +const TIMESTAMP_TIMEZONE_PATTERN = /(Z|[+-]\d{2}:\d{2})$/i + +export function parseUtcTimestamp(value: string): Date | null { + const rawValue = value.trim() + if (!rawValue) { + return null + } + + const normalizedValue = rawValue.includes('T') + ? rawValue + : rawValue.replace(' ', 'T') + const candidate = TIMESTAMP_TIMEZONE_PATTERN.test(normalizedValue) + ? normalizedValue + : `${normalizedValue}Z` + const date = new Date(candidate) + + return Number.isNaN(date.getTime()) ? null : date +} + export function formatCurrency(value: number): string { return `$${(value / 1000000).toFixed(2)}` } diff --git a/frontend/src/pages/UsageLogs.tsx b/frontend/src/pages/UsageLogs.tsx index c6a2d38..53704fe 100644 --- a/frontend/src/pages/UsageLogs.tsx +++ b/frontend/src/pages/UsageLogs.tsx @@ -10,7 +10,7 @@ import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } f import { Input } from "@/components/ui/input"; import { Select } from "@/components/ui/select"; import { readScopedCache, writeScopedCache } from "@/lib/local-cache"; -import { cn, formatCurrency } from "@/lib/utils"; +import { cn, formatCurrency, parseUtcTimestamp } from "@/lib/utils"; import { Eye, RefreshCw, RotateCcw, Search } from "lucide-react"; type UsageLogFilterState = { @@ -89,8 +89,8 @@ const hydrateUsageLogFilters = ( }; const formatDateTime = (value: string): string => { - const date = new Date(value); - if (Number.isNaN(date.getTime())) { + const date = parseUtcTimestamp(value); + if (!date) { return value; } diff --git a/src/analytics/query.ts b/src/analytics/query.ts index ee5b033..f04d75b 100644 --- a/src/analytics/query.ts +++ b/src/analytics/query.ts @@ -235,6 +235,25 @@ const toText = (value: unknown): string => { return typeof value === "string" ? value : ""; }; +const TIMESTAMP_TIMEZONE_PATTERN = /(Z|[+-]\d{2}:\d{2})$/i; + +const normalizeAnalyticsTimestamp = (value: unknown): string => { + const rawValue = toText(value).trim(); + if (!rawValue) { + return ""; + } + + const normalizedValue = rawValue.includes("T") + ? rawValue + : rawValue.replace(" ", "T"); + const candidate = TIMESTAMP_TIMEZONE_PATTERN.test(normalizedValue) + ? normalizedValue + : `${normalizedValue}Z`; + const date = new Date(candidate); + + return Number.isNaN(date.getTime()) ? rawValue : date.toISOString(); +}; + const escapeSqlString = (value: string): string => { return value.replace(/'/g, "''"); }; @@ -785,7 +804,7 @@ LIMIT ${limit} sampled: true, compatibilityWarning, items: rows.map((row) => ({ - timestamp: toText(row.timestamp), + timestamp: normalizeAnalyticsTimestamp(row.timestamp), routeId: toText(row.route_id), tokenName: toText(row.token_name), channelKey: toText(row.channel_key), @@ -970,7 +989,7 @@ OFFSET ${offset} hasPrevPage: totalPages > 0 && page > 1, hasNextPage: totalPages > 0 && page < totalPages, items: rows.map((row) => ({ - timestamp: toText(row.timestamp), + timestamp: normalizeAnalyticsTimestamp(row.timestamp), routeId: toText(row.route_id), tokenName: toText(row.token_name), channelKey: toText(row.channel_key),