Skip to content

Commit 4215d76

Browse files
feat(schedule-details): add pure metrics chart scales (SLICE-9.1 / PR09b)
Introduce unit-tested time-to-pixel scale helpers with future gutter padding right of now, ready for axes and series in PR09c–d. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 34fa517 commit 4215d76

4 files changed

Lines changed: 337 additions & 0 deletions

File tree

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
import {
2+
CHART_DEFAULT_PAST_WINDOW_MS,
3+
CHART_FUTURE_GUTTER_MS,
4+
CHART_MIN_DOMAIN_SPAN_MS,
5+
CHART_SIDE_PADDING_PX,
6+
} from '../schedule-detail-metrics-chart.constants';
7+
import {
8+
createMetricsChartXScale,
9+
pixelToTimeMs,
10+
resolveMetricsChartPixelRange,
11+
resolveMetricsChartTimeDomain,
12+
timeMsToPixel,
13+
} from '../schedule-detail-metrics-chart-scales';
14+
15+
const mockNowMs = new Date('2024-06-15T12:00:00Z').getTime();
16+
17+
describe(resolveMetricsChartTimeDomain.name, () => {
18+
it('returns a default past window ending with future gutter when timestamps are empty', () => {
19+
const domain = resolveMetricsChartTimeDomain({
20+
timestampsMs: [],
21+
nowMs: mockNowMs,
22+
});
23+
24+
expect(domain).toEqual({
25+
minMs: mockNowMs - CHART_DEFAULT_PAST_WINDOW_MS,
26+
maxMs: mockNowMs + CHART_FUTURE_GUTTER_MS,
27+
});
28+
});
29+
30+
it('extends the domain max using next execution plus future gutter', () => {
31+
const nextExecutionMs = mockNowMs + 15 * 60_000;
32+
33+
const domain = resolveMetricsChartTimeDomain({
34+
timestampsMs: [mockNowMs - 2 * 60 * 60_000],
35+
nowMs: mockNowMs,
36+
nextExecutionMs,
37+
});
38+
39+
expect(domain).toEqual({
40+
minMs: mockNowMs - 2 * 60 * 60_000,
41+
maxMs: nextExecutionMs + CHART_FUTURE_GUTTER_MS,
42+
});
43+
});
44+
45+
it('pads the domain right of now even when all timestamps are in the past', () => {
46+
const domain = resolveMetricsChartTimeDomain({
47+
timestampsMs: [mockNowMs - 3 * 60 * 60_000, mockNowMs - 60_000],
48+
nowMs: mockNowMs,
49+
});
50+
51+
expect(domain?.maxMs).toBe(mockNowMs + CHART_FUTURE_GUTTER_MS);
52+
expect(domain?.minMs).toBe(mockNowMs - 3 * 60 * 60_000);
53+
});
54+
55+
it('anchors a single timestamp at now and pads the future gutter to the right', () => {
56+
const domain = resolveMetricsChartTimeDomain({
57+
timestampsMs: [mockNowMs],
58+
nowMs: mockNowMs,
59+
});
60+
61+
expect(domain).toEqual({
62+
minMs: mockNowMs,
63+
maxMs: mockNowMs + CHART_FUTURE_GUTTER_MS,
64+
});
65+
});
66+
67+
it('expands a collapsed domain to the minimum span when span would otherwise be zero', () => {
68+
const pointMs = mockNowMs - 60_000;
69+
70+
const domain = resolveMetricsChartTimeDomain({
71+
timestampsMs: [pointMs, pointMs],
72+
nowMs: pointMs,
73+
nextExecutionMs: pointMs,
74+
});
75+
76+
expect(domain?.maxMs! - domain?.minMs!).toBeGreaterThanOrEqual(
77+
CHART_MIN_DOMAIN_SPAN_MS
78+
);
79+
});
80+
81+
it('returns null when nowMs is not finite', () => {
82+
expect(
83+
resolveMetricsChartTimeDomain({
84+
timestampsMs: [mockNowMs],
85+
nowMs: Number.NaN,
86+
})
87+
).toBeNull();
88+
});
89+
90+
it('ignores non-finite timestamps', () => {
91+
const domain = resolveMetricsChartTimeDomain({
92+
timestampsMs: [Number.NaN, mockNowMs - 60_000],
93+
nowMs: mockNowMs,
94+
});
95+
96+
expect(domain?.minMs).toBe(mockNowMs - 60_000);
97+
expect(domain?.maxMs).toBe(mockNowMs + CHART_FUTURE_GUTTER_MS);
98+
});
99+
});
100+
101+
describe(resolveMetricsChartPixelRange.name, () => {
102+
it('maps chart width to a padded drawable pixel range', () => {
103+
expect(
104+
resolveMetricsChartPixelRange({
105+
widthPx: 800,
106+
sidePaddingPx: CHART_SIDE_PADDING_PX,
107+
})
108+
).toEqual({
109+
startPx: CHART_SIDE_PADDING_PX,
110+
endPx: 800 - CHART_SIDE_PADDING_PX,
111+
});
112+
});
113+
114+
it('returns null when width is zero or negative', () => {
115+
expect(resolveMetricsChartPixelRange({ widthPx: 0 })).toBeNull();
116+
expect(resolveMetricsChartPixelRange({ widthPx: -10 })).toBeNull();
117+
});
118+
119+
it('returns null when side padding consumes the full width', () => {
120+
expect(
121+
resolveMetricsChartPixelRange({
122+
widthPx: CHART_SIDE_PADDING_PX * 2,
123+
sidePaddingPx: CHART_SIDE_PADDING_PX,
124+
})
125+
).toBeNull();
126+
});
127+
});
128+
129+
describe('metrics chart x scale', () => {
130+
const domain = {
131+
minMs: mockNowMs - 60 * 60_000,
132+
maxMs: mockNowMs + CHART_FUTURE_GUTTER_MS,
133+
};
134+
const range = {
135+
startPx: CHART_SIDE_PADDING_PX,
136+
endPx: 776,
137+
};
138+
139+
it('maps domain endpoints to pixel range', () => {
140+
const scale = createMetricsChartXScale({ domain, range });
141+
142+
expect(scale).not.toBeNull();
143+
expect(timeMsToPixel(domain.minMs, scale!)).toBe(range.startPx);
144+
expect(timeMsToPixel(domain.maxMs, scale!)).toBe(range.endPx);
145+
expect(timeMsToPixel(mockNowMs, scale!)).toBeGreaterThan(range.startPx);
146+
expect(timeMsToPixel(mockNowMs, scale!)).toBeLessThan(range.endPx);
147+
});
148+
149+
it('inverts pixel positions back to timestamps', () => {
150+
const scale = createMetricsChartXScale({ domain, range })!;
151+
152+
expect(pixelToTimeMs(range.startPx, scale)).toBe(domain.minMs);
153+
expect(pixelToTimeMs(range.endPx, scale)).toBe(domain.maxMs);
154+
});
155+
156+
it('returns null for invalid domain or range', () => {
157+
expect(
158+
createMetricsChartXScale({
159+
domain: { minMs: mockNowMs, maxMs: mockNowMs },
160+
range,
161+
})
162+
).toBeNull();
163+
164+
expect(
165+
createMetricsChartXScale({
166+
domain,
167+
range: { startPx: 100, endPx: 100 },
168+
})
169+
).toBeNull();
170+
});
171+
});
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
import { scaleLinear } from '@visx/scale';
2+
import { type ScaleLinear } from 'd3-scale';
3+
4+
import {
5+
CHART_DEFAULT_PAST_WINDOW_MS,
6+
CHART_FUTURE_GUTTER_MS,
7+
CHART_MIN_DOMAIN_SPAN_MS,
8+
CHART_SIDE_PADDING_PX,
9+
} from './schedule-detail-metrics-chart.constants';
10+
import {
11+
type CreateMetricsChartXScaleParams,
12+
type MetricsChartPixelRange,
13+
type MetricsChartTimeDomain,
14+
type ResolveMetricsChartPixelRangeParams,
15+
type ResolveMetricsChartTimeDomainParams,
16+
} from './schedule-detail-metrics-chart.types';
17+
18+
type MetricsChartXScale = ScaleLinear<number, number, never>;
19+
20+
export function resolveMetricsChartTimeDomain({
21+
timestampsMs,
22+
nowMs,
23+
nextExecutionMs,
24+
}: ResolveMetricsChartTimeDomainParams): MetricsChartTimeDomain | null {
25+
if (!Number.isFinite(nowMs)) {
26+
return null;
27+
}
28+
29+
const validTimestampsMs = timestampsMs.filter(Number.isFinite);
30+
31+
if (validTimestampsMs.length === 0 && nextExecutionMs == null) {
32+
return {
33+
minMs: nowMs - CHART_DEFAULT_PAST_WINDOW_MS,
34+
maxMs: nowMs + CHART_FUTURE_GUTTER_MS,
35+
};
36+
}
37+
38+
const dataMinMs =
39+
validTimestampsMs.length > 0
40+
? Math.min(...validTimestampsMs)
41+
: nowMs;
42+
const dataMaxMs =
43+
validTimestampsMs.length > 0
44+
? Math.max(...validTimestampsMs)
45+
: nowMs;
46+
47+
let minMs = Math.min(dataMinMs, nowMs);
48+
let maxMs = Math.max(dataMaxMs, nowMs, nowMs + CHART_FUTURE_GUTTER_MS);
49+
50+
if (
51+
nextExecutionMs != null &&
52+
Number.isFinite(nextExecutionMs) &&
53+
nextExecutionMs > nowMs
54+
) {
55+
maxMs = Math.max(maxMs, nextExecutionMs + CHART_FUTURE_GUTTER_MS);
56+
}
57+
58+
if (maxMs <= minMs) {
59+
maxMs = minMs + CHART_MIN_DOMAIN_SPAN_MS;
60+
} else if (maxMs - minMs < CHART_MIN_DOMAIN_SPAN_MS) {
61+
const centerMs = (minMs + maxMs) / 2;
62+
minMs = centerMs - CHART_MIN_DOMAIN_SPAN_MS / 2;
63+
maxMs = centerMs + CHART_MIN_DOMAIN_SPAN_MS / 2;
64+
}
65+
66+
return { minMs, maxMs };
67+
}
68+
69+
export function resolveMetricsChartPixelRange({
70+
widthPx,
71+
sidePaddingPx = CHART_SIDE_PADDING_PX,
72+
}: ResolveMetricsChartPixelRangeParams): MetricsChartPixelRange | null {
73+
if (!Number.isFinite(widthPx) || widthPx <= 0) {
74+
return null;
75+
}
76+
77+
const drawableWidthPx = widthPx - sidePaddingPx * 2;
78+
79+
if (drawableWidthPx <= 0) {
80+
return null;
81+
}
82+
83+
return {
84+
startPx: sidePaddingPx,
85+
endPx: sidePaddingPx + drawableWidthPx,
86+
};
87+
}
88+
89+
export function createMetricsChartXScale({
90+
domain,
91+
range,
92+
}: CreateMetricsChartXScaleParams): MetricsChartXScale | null {
93+
if (
94+
!Number.isFinite(domain.minMs) ||
95+
!Number.isFinite(domain.maxMs) ||
96+
domain.maxMs <= domain.minMs
97+
) {
98+
return null;
99+
}
100+
101+
if (
102+
!Number.isFinite(range.startPx) ||
103+
!Number.isFinite(range.endPx) ||
104+
range.endPx <= range.startPx
105+
) {
106+
return null;
107+
}
108+
109+
return scaleLinear<number>({
110+
domain: [domain.minMs, domain.maxMs],
111+
range: [range.startPx, range.endPx],
112+
clamp: true,
113+
});
114+
}
115+
116+
export function timeMsToPixel(
117+
timeMs: number,
118+
scale: MetricsChartXScale
119+
): number {
120+
return scale(timeMs);
121+
}
122+
123+
export function pixelToTimeMs(
124+
pixel: number,
125+
scale: MetricsChartXScale
126+
): number {
127+
return scale.invert(pixel);
128+
}

src/views/schedule-page/schedule-detail-metrics-chart/schedule-detail-metrics-chart.constants.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,15 @@ export const CHART_EMPTY_STATE_MESSAGE = 'No chart data available yet';
1212
export const CHART_REGION_ARIA_LABEL = 'Schedule metrics chart';
1313

1414
export const CHART_TOOLBAR_ARIA_LABEL = 'Chart controls';
15+
16+
/** Minimum time span when domain collapses to a single timestamp (ms). */
17+
export const CHART_MIN_DOMAIN_SPAN_MS = 5 * 60_000;
18+
19+
/** Default past window when no run timestamps are available (ms). */
20+
export const CHART_DEFAULT_PAST_WINDOW_MS = 6 * 60 * 60_000;
21+
22+
/** Padding to the right of `now` reserved for upcoming expected executions (ms). */
23+
export const CHART_FUTURE_GUTTER_MS = 30 * 60_000;
24+
25+
/** Horizontal inset applied to the chart drawable area (px). */
26+
export const CHART_SIDE_PADDING_PX = 24;

src/views/schedule-page/schedule-detail-metrics-chart/schedule-detail-metrics-chart.types.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,29 @@ import { type SchedulePageTabsParams } from '@/views/schedule-page/schedule-page
33
export type Props = {
44
params: SchedulePageTabsParams;
55
};
6+
7+
export type MetricsChartTimeDomain = {
8+
minMs: number;
9+
maxMs: number;
10+
};
11+
12+
export type MetricsChartPixelRange = {
13+
startPx: number;
14+
endPx: number;
15+
};
16+
17+
export type ResolveMetricsChartTimeDomainParams = {
18+
timestampsMs: number[];
19+
nowMs: number;
20+
nextExecutionMs?: number | null;
21+
};
22+
23+
export type ResolveMetricsChartPixelRangeParams = {
24+
widthPx: number;
25+
sidePaddingPx?: number;
26+
};
27+
28+
export type CreateMetricsChartXScaleParams = {
29+
domain: MetricsChartTimeDomain;
30+
range: MetricsChartPixelRange;
31+
};

0 commit comments

Comments
 (0)