diff --git a/apps/sim/lib/charts/annotations.test.ts b/apps/sim/lib/charts/annotations.test.ts index 60aafc426bd..280936cb110 100644 --- a/apps/sim/lib/charts/annotations.test.ts +++ b/apps/sim/lib/charts/annotations.test.ts @@ -116,4 +116,98 @@ describe('chart annotations', () => { ) ).toThrow('Use highlights and thresholds instead of markArea or markLine on the series') }) + + it('measures thresholds on the axes the first series is plotted on', () => { + const option = applyChartAnnotations( + { + xAxis: { type: 'time' }, + yAxis: [{ type: 'category' }, { id: 'latency', type: 'value' }], + series: [{ type: 'line', yAxisIndex: 1 }], + }, + { thresholds: [{ value: 5 }] }, + palette + ) + expect((option.series as Series[])[0].markLine).toMatchObject({ data: [{ yAxis: 5 }] }) + expect(() => + applyChartAnnotations( + { + xAxis: { type: 'time' }, + yAxis: [{ type: 'value' }, { id: 'stage', type: 'category' }], + series: [{ type: 'line', yAxisId: 'stage' }], + }, + { thresholds: [{ value: 5 }] }, + palette + ) + ).toThrow('Thresholds require a value axis') + }) + + it('resolves a value axis selected by id, including a value x-axis on horizontal bars', () => { + const byId = applyChartAnnotations( + { + xAxis: { type: 'time' }, + yAxis: [{ type: 'category' }, { id: 'latency', type: 'value' }], + series: [{ type: 'line', yAxisId: 'latency' }], + }, + { thresholds: [{ value: 5 }] }, + palette + ) + expect((byId.series as Series[])[0].markLine).toMatchObject({ data: [{ yAxis: 5 }] }) + const horizontal = applyChartAnnotations( + { + xAxis: [{ type: 'category' }, { id: 'count', type: 'value' }], + yAxis: [{ type: 'value' }, { id: 'stage', type: 'category', inverse: true }], + series: [{ type: 'bar', xAxisId: 'count', yAxisIndex: 1 }], + }, + { thresholds: [{ value: 5, label: 'Limit' }] }, + palette + ) + const [first, labels] = horizontal.series as Series[] + expect(first.markLine).toMatchObject({ data: [{ xAxis: 5 }] }) + expect(labels.markLine).toMatchObject({ data: [{ xAxis: 5, label: { position: 'start' } }] }) + }) + + it('prefers the axis index over the axis id, as ECharts does', () => { + const option = applyChartAnnotations( + { + xAxis: { type: 'time' }, + yAxis: [ + { id: 'stage', type: 'category' }, + { id: 7, type: 'value' }, + ], + series: [{ type: 'line', yAxisIndex: 1, yAxisId: 'stage' }], + }, + { thresholds: [{ value: 5 }] }, + palette + ) + expect((option.series as Series[])[0].markLine).toMatchObject({ data: [{ yAxis: 5 }] }) + expect(() => + applyChartAnnotations( + { ...option, series: [{ type: 'line', yAxisId: '7' }] }, + { thresholds: [{ value: 5 }] }, + palette + ) + ).not.toThrow() + }) + + it('rejects a first series that references an axis the chart does not define', () => { + for (const reference of [{ yAxisIndex: 1 }, { yAxisIndex: -1 }, { yAxisIndex: 0.5 }]) + expect(() => + applyChartAnnotations( + { + xAxis: { type: 'time' }, + yAxis: { type: 'value' }, + series: [{ type: 'line', ...reference }], + }, + { thresholds: [{ value: 5 }] }, + palette + ) + ).toThrow('The first series references a missing yAxis') + expect(() => + applyChartAnnotations( + { xAxis: { type: 'time' }, yAxis: [], series: [{ type: 'line' }] }, + { thresholds: [{ value: 5 }] }, + palette + ) + ).toThrow('The first series references a missing yAxis') + }) }) diff --git a/apps/sim/lib/charts/annotations.ts b/apps/sim/lib/charts/annotations.ts index 79c41a1e2f6..93da7c9758c 100644 --- a/apps/sim/lib/charts/annotations.ts +++ b/apps/sim/lib/charts/annotations.ts @@ -27,17 +27,43 @@ export interface ChartAnnotations { const BAND_OPACITY = 0.08 -function firstAxis(axis: unknown): Record { - return toRecord(Array.isArray(axis) ? axis[0] : axis) +function firstSeries(option: Record): Record { + return toRecord(Array.isArray(option.series) ? option.series[0] : option.series) } -/** The axis a threshold is measured on; ECharts defaults an unspecified yAxis to a value axis. */ +/** + * The axis the first series is plotted on. Like ECharts, `*AxisIndex` wins over `*AxisId`, and ids + * match across string and number. + */ +function seriesAxis( + option: Record, + key: 'xAxis' | 'yAxis' +): Record { + const axes = Array.isArray(option[key]) ? (option[key] as unknown[]) : [option[key]] + const series = firstSeries(option) + const id = series[`${key}Id`] + if (series[`${key}Index`] === undefined && id !== undefined) { + const axis = axes.find((candidate) => String(toRecord(candidate).id) === String(id)) + if (axis === undefined) throw new Error(`The first series references a missing ${key} "${id}"`) + return toRecord(axis) + } + const index = series[`${key}Index`] ?? 0 + if (option[key] === undefined && index === 0) return {} + if (!Number.isInteger(index) || axes[index as number] === undefined) + throw new Error(`The first series references a missing ${key} at index ${String(index)}`) + return toRecord(axes[index as number]) +} + +/** + * The axis a threshold is measured on, among the axes the first series is plotted on; ECharts + * defaults an unspecified yAxis to a value axis. + */ export function valueAxisKey(option: Record): 'xAxis' | 'yAxis' { if (option.xAxis === undefined && option.yAxis === undefined) throw new Error('Thresholds require a value axis') - const y = firstAxis(option.yAxis) + const y = seriesAxis(option, 'yAxis') if (y.type === undefined || y.type === 'value' || y.type === 'log') return 'yAxis' - const x = firstAxis(option.xAxis) + const x = seriesAxis(option, 'xAxis') if (x.type === 'value' || x.type === 'log') return 'xAxis' throw new Error('Thresholds require a value axis') } @@ -47,7 +73,7 @@ export function valueAxisKey(option: Record): 'xAxis' | 'yAxis' * y axis is inverted (as horizontal bar charts usually are). */ function verticalTop(option: Record): 'start' | 'end' { - return firstAxis(option.yAxis).inverse === true ? 'start' : 'end' + return seriesAxis(option, 'yAxis').inverse === true ? 'start' : 'end' } /** A chart annotations can draw on: a first series with no hand-written marks to collide with. */ diff --git a/apps/sim/lib/dashboards/time.test.ts b/apps/sim/lib/dashboards/time.test.ts index 04e91090222..50f658a83eb 100644 --- a/apps/sim/lib/dashboards/time.test.ts +++ b/apps/sim/lib/dashboards/time.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest' import { dashboardAxisFormatter, dashboardRangeFromCalendar, + dashboardRangeText, dashboardTimeLabel, dashboardZoomRange, parseDashboardCustomRange, @@ -47,4 +48,21 @@ describe('dashboard time interactions', () => { expect(dashboardTimeLabel(stamp, 'America/Los_Angeles')).toContain('PDT') expect(dashboardTimeLabel('2026-12-20T02:30:00Z', 'America/Los_Angeles')).toContain('PST') }) + + it('keeps both ends of a range that repeats the same wall-clock minute across a DST fall-back', () => { + const text = dashboardRangeText( + { from: '2026-11-01T05:30:00.000Z', to: '2026-11-01T06:31:00.000Z' }, + 'America/New_York' + ) + expect(text).toBe('Nov 1, 01:30:00 EDT – Nov 1, 01:30:59 EST') + }) + + it('shows distinct endpoints for a sub-minute zoom range', () => { + expect( + dashboardRangeText( + { from: '2026-09-20T14:30:00.000Z', to: '2026-09-20T14:30:01.000Z' }, + 'UTC' + ) + ).toBe('Sep 20, 14:30:00.000 UTC – Sep 20, 14:30:00.999 UTC') + }) }) diff --git a/apps/sim/lib/dashboards/time.ts b/apps/sim/lib/dashboards/time.ts index d3a8dfd9a83..16012862cb9 100644 --- a/apps/sim/lib/dashboards/time.ts +++ b/apps/sim/lib/dashboards/time.ts @@ -89,6 +89,25 @@ export function dashboardRangeText(range: DashboardTimeRange, timeZone: string): const to = new Date(Date.parse(range.to) - 1) const fromLocal = zonedWallClock(from, timeZone) const toLocal = zonedWallClock(to, timeZone) + if (fromLocal === toLocal) { + // Same wall-clock minute (e.g. a DST fall-back repeat): formatRange would collapse both ends, + // so show each inclusive end exactly, down to milliseconds if seconds still collide. + const exact = (fractionalSecondDigits?: 3) => + new Intl.DateTimeFormat('en-US', { + timeZone, + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + fractionalSecondDigits, + hourCycle: 'h23', + timeZoneName: 'short', + }) + const seconds = exact() + const format = seconds.format(from) === seconds.format(to) ? exact(3) : seconds + return `${format.format(from)} – ${format.format(to)}` + } const sameDay = fromLocal.slice(0, 10) === toLocal.slice(0, 10) return new Intl.DateTimeFormat('en-US', { timeZone,