Skip to content

Commit 4d29d0b

Browse files
fix(dashboards): keep DST fall-back range endpoints and resolve threshold axis from first series
1 parent 491ab88 commit 4d29d0b

4 files changed

Lines changed: 76 additions & 6 deletions

File tree

‎apps/sim/lib/charts/annotations.test.ts‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,4 +116,28 @@ describe('chart annotations', () => {
116116
)
117117
).toThrow('Use highlights and thresholds instead of markArea or markLine on the series')
118118
})
119+
120+
it('measures thresholds on the axes the first series is plotted on', () => {
121+
const option = applyChartAnnotations(
122+
{
123+
xAxis: { type: 'time' },
124+
yAxis: [{ type: 'category' }, { id: 'latency', type: 'value' }],
125+
series: [{ type: 'line', yAxisIndex: 1 }],
126+
},
127+
{ thresholds: [{ value: 5 }] },
128+
palette
129+
)
130+
expect((option.series as Series[])[0].markLine).toMatchObject({ data: [{ yAxis: 5 }] })
131+
expect(() =>
132+
applyChartAnnotations(
133+
{
134+
xAxis: { type: 'time' },
135+
yAxis: [{ type: 'value' }, { id: 'stage', type: 'category' }],
136+
series: [{ type: 'line', yAxisId: 'stage' }],
137+
},
138+
{ thresholds: [{ value: 5 }] },
139+
palette
140+
)
141+
).toThrow('Thresholds require a value axis')
142+
})
119143
})

‎apps/sim/lib/charts/annotations.ts‎

Lines changed: 28 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -27,17 +27,39 @@ export interface ChartAnnotations {
2727

2828
const BAND_OPACITY = 0.08
2929

30-
function firstAxis(axis: unknown): Record<string, unknown> {
31-
return toRecord(Array.isArray(axis) ? axis[0] : axis)
30+
function firstSeries(option: Record<string, unknown>): Record<string, unknown> {
31+
return toRecord(Array.isArray(option.series) ? option.series[0] : option.series)
3232
}
3333

34-
/** The axis a threshold is measured on; ECharts defaults an unspecified yAxis to a value axis. */
34+
/** The axis the first series is plotted on, resolved through its `*AxisId` or `*AxisIndex`. */
35+
function seriesAxis(
36+
option: Record<string, unknown>,
37+
key: 'xAxis' | 'yAxis'
38+
): Record<string, unknown> {
39+
const axes = Array.isArray(option[key]) ? (option[key] as unknown[]) : [option[key]]
40+
const series = firstSeries(option)
41+
const id = series[`${key}Id`]
42+
if (id !== undefined) {
43+
const axis = axes.find((candidate) => toRecord(candidate).id === id)
44+
if (axis === undefined) throw new Error(`The first series references a missing ${key} "${id}"`)
45+
return toRecord(axis)
46+
}
47+
const index = series[`${key}Index`] ?? 0
48+
if (typeof index !== 'number' || (index > 0 && axes[index] === undefined))
49+
throw new Error(`The first series references a missing ${key} at index ${String(index)}`)
50+
return toRecord(axes[index])
51+
}
52+
53+
/**
54+
* The axis a threshold is measured on, among the axes the first series is plotted on; ECharts
55+
* defaults an unspecified yAxis to a value axis.
56+
*/
3557
export function valueAxisKey(option: Record<string, unknown>): 'xAxis' | 'yAxis' {
3658
if (option.xAxis === undefined && option.yAxis === undefined)
3759
throw new Error('Thresholds require a value axis')
38-
const y = firstAxis(option.yAxis)
60+
const y = seriesAxis(option, 'yAxis')
3961
if (y.type === undefined || y.type === 'value' || y.type === 'log') return 'yAxis'
40-
const x = firstAxis(option.xAxis)
62+
const x = seriesAxis(option, 'xAxis')
4163
if (x.type === 'value' || x.type === 'log') return 'xAxis'
4264
throw new Error('Thresholds require a value axis')
4365
}
@@ -47,7 +69,7 @@ export function valueAxisKey(option: Record<string, unknown>): 'xAxis' | 'yAxis'
4769
* y axis is inverted (as horizontal bar charts usually are).
4870
*/
4971
function verticalTop(option: Record<string, unknown>): 'start' | 'end' {
50-
return firstAxis(option.yAxis).inverse === true ? 'start' : 'end'
72+
return seriesAxis(option, 'yAxis').inverse === true ? 'start' : 'end'
5173
}
5274

5375
/** A chart annotations can draw on: a first series with no hand-written marks to collide with. */

‎apps/sim/lib/dashboards/time.test.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'
22
import {
33
dashboardAxisFormatter,
44
dashboardRangeFromCalendar,
5+
dashboardRangeText,
56
dashboardTimeLabel,
67
dashboardZoomRange,
78
parseDashboardCustomRange,
@@ -47,4 +48,13 @@ describe('dashboard time interactions', () => {
4748
expect(dashboardTimeLabel(stamp, 'America/Los_Angeles')).toContain('PDT')
4849
expect(dashboardTimeLabel('2026-12-20T02:30:00Z', 'America/Los_Angeles')).toContain('PST')
4950
})
51+
52+
it('keeps both ends of a range that repeats the same wall-clock minute across a DST fall-back', () => {
53+
const text = dashboardRangeText(
54+
{ from: '2026-11-01T05:30:00.000Z', to: '2026-11-01T06:31:00.000Z' },
55+
'America/New_York'
56+
)
57+
expect(text).toContain('01:30:00 EDT')
58+
expect(text).toContain('01:30:59 EST')
59+
})
5060
})

‎apps/sim/lib/dashboards/time.ts‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,20 @@ export function dashboardRangeText(range: DashboardTimeRange, timeZone: string):
8989
const to = new Date(Date.parse(range.to) - 1)
9090
const fromLocal = zonedWallClock(from, timeZone)
9191
const toLocal = zonedWallClock(to, timeZone)
92+
if (fromLocal === toLocal) {
93+
// Same wall-clock minute (e.g. a DST fall-back repeat): formatRange would collapse both ends.
94+
const exact = new Intl.DateTimeFormat('en-US', {
95+
timeZone,
96+
month: 'short',
97+
day: 'numeric',
98+
hour: '2-digit',
99+
minute: '2-digit',
100+
second: '2-digit',
101+
hourCycle: 'h23',
102+
timeZoneName: 'short',
103+
})
104+
return `${exact.format(from)} – ${exact.format(to)}`
105+
}
92106
const sameDay = fromLocal.slice(0, 10) === toLocal.slice(0, 10)
93107
return new Intl.DateTimeFormat('en-US', {
94108
timeZone,

0 commit comments

Comments
 (0)