fix: localize downstream key trend timestamps (#432)
* fix: localize downstream key trend timestamps * fix: keep downstream trend metadata on empty responses * fix: cursor paginate downstream key trend reads * fix: normalize empty downstream trend timezones * fix: localize windowed downstream trend buckets
This commit is contained in:
@@ -0,0 +1,128 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
|
||||
import DownstreamKeyTrendChart from './DownstreamKeyTrendChart.js';
|
||||
import { formatDateTimeMinuteLocal } from '../../pages/helpers/checkinLogTime.js';
|
||||
|
||||
const vChartSpy = vi.fn();
|
||||
|
||||
vi.mock('@visactor/react-vchart', () => ({
|
||||
VChart: (props: Record<string, unknown>) => {
|
||||
vChartSpy(props);
|
||||
return null;
|
||||
},
|
||||
}));
|
||||
|
||||
function pad2(value: number): string {
|
||||
return String(value).padStart(2, '0');
|
||||
}
|
||||
|
||||
function formatExpectedAxisLabel(raw: string, bucketSeconds: number): string {
|
||||
const parsed = new Date(raw);
|
||||
const month = pad2(parsed.getMonth() + 1);
|
||||
const day = pad2(parsed.getDate());
|
||||
if (bucketSeconds >= 86400) return `${month}/${day}`;
|
||||
return `${month}/${day} ${pad2(parsed.getHours())}:${pad2(parsed.getMinutes())}`;
|
||||
}
|
||||
|
||||
describe('DownstreamKeyTrendChart', () => {
|
||||
const originalDocument = globalThis.document;
|
||||
const originalGetComputedStyle = globalThis.getComputedStyle;
|
||||
const originalMutationObserver = globalThis.MutationObserver;
|
||||
|
||||
beforeEach(() => {
|
||||
vChartSpy.mockClear();
|
||||
globalThis.document = {
|
||||
documentElement: {
|
||||
getAttribute: vi.fn(),
|
||||
},
|
||||
} as unknown as Document;
|
||||
Reflect.deleteProperty(globalThis as typeof globalThis & Record<string, unknown>, 'getComputedStyle');
|
||||
Reflect.deleteProperty(globalThis as typeof globalThis & Record<string, unknown>, 'MutationObserver');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.document = originalDocument;
|
||||
globalThis.getComputedStyle = originalGetComputedStyle;
|
||||
globalThis.MutationObserver = originalMutationObserver;
|
||||
});
|
||||
|
||||
it('formats trend bucket timestamps with local display time', async () => {
|
||||
const bucketStart = '2026-04-05T15:00:00.000Z';
|
||||
let renderer!: ReactTestRenderer;
|
||||
|
||||
await expect(act(async () => {
|
||||
renderer = create(
|
||||
<DownstreamKeyTrendChart
|
||||
bucketSeconds={3600}
|
||||
buckets={[
|
||||
{
|
||||
startUtc: bucketStart,
|
||||
totalRequests: 2,
|
||||
totalTokens: 2128425,
|
||||
totalCost: 2.128425,
|
||||
successRate: 100,
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
})).resolves.toBeUndefined();
|
||||
|
||||
expect(vChartSpy).toHaveBeenCalledTimes(1);
|
||||
const spec = vChartSpy.mock.calls[0]?.[0]?.spec as {
|
||||
data?: Array<{ values?: Array<{ date?: string; tooltipDate?: string }> }>;
|
||||
axes?: Array<{
|
||||
label?: {
|
||||
formatMethod?: (value: string) => string;
|
||||
};
|
||||
}>;
|
||||
tooltip?: {
|
||||
dimension?: {
|
||||
title?: {
|
||||
value?: (datum: Record<string, unknown>) => string;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
const expectedLabel = formatDateTimeMinuteLocal(bucketStart);
|
||||
expect(spec.data?.[0]?.values?.[0]?.tooltipDate).toBe(expectedLabel);
|
||||
expect(spec.tooltip?.dimension?.title?.value?.({ tooltipDate: expectedLabel })).toBe(expectedLabel);
|
||||
expect(spec.axes?.[0]?.label?.formatMethod?.(bucketStart)).toBe(formatExpectedAxisLabel(bucketStart, 3600));
|
||||
|
||||
renderer.unmount();
|
||||
});
|
||||
|
||||
it('uses day-only axis labels for daily buckets', async () => {
|
||||
const bucketStart = '2026-04-05T16:00:00.000Z';
|
||||
let renderer!: ReactTestRenderer;
|
||||
|
||||
await expect(act(async () => {
|
||||
renderer = create(
|
||||
<DownstreamKeyTrendChart
|
||||
bucketSeconds={86400}
|
||||
buckets={[
|
||||
{
|
||||
startUtc: bucketStart,
|
||||
totalRequests: 3,
|
||||
totalTokens: 600,
|
||||
totalCost: 0.06,
|
||||
successRate: 66.7,
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
})).resolves.toBeUndefined();
|
||||
|
||||
const spec = vChartSpy.mock.calls[0]?.[0]?.spec as {
|
||||
axes?: Array<{
|
||||
label?: {
|
||||
formatMethod?: (value: string) => string;
|
||||
};
|
||||
}>;
|
||||
};
|
||||
|
||||
expect(spec.axes?.[0]?.label?.formatMethod?.(bucketStart)).toBe(formatExpectedAxisLabel(bucketStart, 86400));
|
||||
|
||||
renderer.unmount();
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { VChart } from '@visactor/react-vchart';
|
||||
import { formatDateTimeMinuteLocal } from '../../pages/helpers/checkinLogTime.js';
|
||||
|
||||
type Metric = 'tokens' | 'requests' | 'cost';
|
||||
|
||||
@@ -17,12 +18,27 @@ export type DownstreamKeyTrendBucket = {
|
||||
successRate: number | null;
|
||||
};
|
||||
|
||||
function pad2(value: number): string {
|
||||
return String(value).padStart(2, '0');
|
||||
}
|
||||
|
||||
function formatTrendAxisLabel(raw: string, bucketSeconds: number): string {
|
||||
const parsed = new Date(raw);
|
||||
if (Number.isNaN(parsed.getTime())) return raw;
|
||||
const month = pad2(parsed.getMonth() + 1);
|
||||
const day = pad2(parsed.getDate());
|
||||
if (bucketSeconds >= 86400) return `${month}/${day}`;
|
||||
return `${month}/${day} ${pad2(parsed.getHours())}:${pad2(parsed.getMinutes())}`;
|
||||
}
|
||||
|
||||
export default function DownstreamKeyTrendChart({
|
||||
buckets,
|
||||
bucketSeconds = 3600,
|
||||
loading,
|
||||
height = 260,
|
||||
}: {
|
||||
buckets: DownstreamKeyTrendBucket[];
|
||||
bucketSeconds?: number;
|
||||
loading?: boolean;
|
||||
height?: number;
|
||||
}) {
|
||||
@@ -32,13 +48,17 @@ export default function DownstreamKeyTrendChart({
|
||||
if (!Array.isArray(buckets) || buckets.length === 0) return [];
|
||||
return buckets
|
||||
.map((bucket) => {
|
||||
const date = bucket.startUtc || '';
|
||||
const rawDate = (bucket.startUtc || '').trim();
|
||||
const value = metric === 'tokens'
|
||||
? Number(bucket.totalTokens || 0)
|
||||
: (metric === 'requests'
|
||||
? Number(bucket.totalRequests || 0)
|
||||
: Number(bucket.totalCost || 0));
|
||||
return { date, value };
|
||||
return {
|
||||
date: rawDate,
|
||||
tooltipDate: rawDate ? formatDateTimeMinuteLocal(rawDate) : '',
|
||||
value,
|
||||
};
|
||||
})
|
||||
.filter((row) => row.date.length > 0);
|
||||
}, [buckets, metric]);
|
||||
@@ -89,7 +109,10 @@ export default function DownstreamKeyTrendChart({
|
||||
axes: [
|
||||
{
|
||||
orient: 'bottom',
|
||||
label: { style: { fontSize: 11, fill: 'var(--color-text-muted)' } },
|
||||
label: {
|
||||
style: { fontSize: 11, fill: 'var(--color-text-muted)' },
|
||||
formatMethod: (value: string) => formatTrendAxisLabel(String(value || ''), bucketSeconds),
|
||||
},
|
||||
domainLine: { style: { stroke: 'var(--color-border-light)' } },
|
||||
tick: { style: { stroke: 'var(--color-border-light)' } },
|
||||
},
|
||||
@@ -102,7 +125,7 @@ export default function DownstreamKeyTrendChart({
|
||||
],
|
||||
tooltip: {
|
||||
dimension: {
|
||||
title: { value: (datum: Record<string, unknown>) => String(datum?.date || '') },
|
||||
title: { value: (datum: Record<string, unknown>) => String(datum?.tooltipDate || datum?.date || '') },
|
||||
content: [
|
||||
{
|
||||
key: () => METRIC_OPTIONS.find((opt) => opt.key === metric)?.label || 'Value',
|
||||
|
||||
Reference in New Issue
Block a user