From 4d29d0b808a714000e32ebb9b2df93995138d833 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Thu, 1 Oct 2026 14:10:07 -0700 Subject: [PATCH 1/4] fix(dashboards): keep DST fall-back range endpoints and resolve threshold axis from first series --- apps/sim/lib/charts/annotations.test.ts | 24 +++++++++++++++++ apps/sim/lib/charts/annotations.ts | 34 ++++++++++++++++++++----- apps/sim/lib/dashboards/time.test.ts | 10 ++++++++ apps/sim/lib/dashboards/time.ts | 14 ++++++++++ 4 files changed, 76 insertions(+), 6 deletions(-) diff --git a/apps/sim/lib/charts/annotations.test.ts b/apps/sim/lib/charts/annotations.test.ts index 60aafc426bd..14ae2f5be2f 100644 --- a/apps/sim/lib/charts/annotations.test.ts +++ b/apps/sim/lib/charts/annotations.test.ts @@ -116,4 +116,28 @@ 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') + }) }) diff --git a/apps/sim/lib/charts/annotations.ts b/apps/sim/lib/charts/annotations.ts index 79c41a1e2f6..991d1f55165 100644 --- a/apps/sim/lib/charts/annotations.ts +++ b/apps/sim/lib/charts/annotations.ts @@ -27,17 +27,39 @@ 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, resolved through its `*AxisId` or `*AxisIndex`. */ +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 (id !== undefined) { + const axis = axes.find((candidate) => toRecord(candidate).id === 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 (typeof index !== 'number' || (index > 0 && axes[index] === undefined)) + throw new Error(`The first series references a missing ${key} at index ${String(index)}`) + return toRecord(axes[index]) +} + +/** + * 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 +69,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..a1435c1d2be 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,13 @@ 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).toContain('01:30:00 EDT') + expect(text).toContain('01:30:59 EST') + }) }) diff --git a/apps/sim/lib/dashboards/time.ts b/apps/sim/lib/dashboards/time.ts index d3a8dfd9a83..5dec78c62b3 100644 --- a/apps/sim/lib/dashboards/time.ts +++ b/apps/sim/lib/dashboards/time.ts @@ -89,6 +89,20 @@ 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. + const exact = new Intl.DateTimeFormat('en-US', { + timeZone, + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hourCycle: 'h23', + timeZoneName: 'short', + }) + return `${exact.format(from)} – ${exact.format(to)}` + } const sameDay = fromLocal.slice(0, 10) === toLocal.slice(0, 10) return new Intl.DateTimeFormat('en-US', { timeZone, From 8c6498e474081b1b7f5eaf6f7e3f8ce4f5e6e3a1 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Thu, 1 Oct 2026 14:16:28 -0700 Subject: [PATCH 2/4] fix(dashboards): reject invalid first-series axis refs and show exclusive end for sub-minute ranges --- apps/sim/lib/charts/annotations.test.ts | 22 ++++++++++++++++++++++ apps/sim/lib/charts/annotations.ts | 5 +++-- apps/sim/lib/dashboards/time.test.ts | 11 ++++++++++- apps/sim/lib/dashboards/time.ts | 5 +++-- 4 files changed, 38 insertions(+), 5 deletions(-) diff --git a/apps/sim/lib/charts/annotations.test.ts b/apps/sim/lib/charts/annotations.test.ts index 14ae2f5be2f..9312d36be84 100644 --- a/apps/sim/lib/charts/annotations.test.ts +++ b/apps/sim/lib/charts/annotations.test.ts @@ -140,4 +140,26 @@ describe('chart annotations', () => { ) ).toThrow('Thresholds require a value axis') }) + + 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 991d1f55165..bc82528450d 100644 --- a/apps/sim/lib/charts/annotations.ts +++ b/apps/sim/lib/charts/annotations.ts @@ -45,9 +45,10 @@ function seriesAxis( return toRecord(axis) } const index = series[`${key}Index`] ?? 0 - if (typeof index !== 'number' || (index > 0 && axes[index] === undefined)) + 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]) + return toRecord(axes[index as number]) } /** diff --git a/apps/sim/lib/dashboards/time.test.ts b/apps/sim/lib/dashboards/time.test.ts index a1435c1d2be..fb2428901bc 100644 --- a/apps/sim/lib/dashboards/time.test.ts +++ b/apps/sim/lib/dashboards/time.test.ts @@ -55,6 +55,15 @@ describe('dashboard time interactions', () => { 'America/New_York' ) expect(text).toContain('01:30:00 EDT') - expect(text).toContain('01:30:59 EST') + expect(text).toContain('01:31:00 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 UTC – Sep 20, 14:30:01 UTC') }) }) diff --git a/apps/sim/lib/dashboards/time.ts b/apps/sim/lib/dashboards/time.ts index 5dec78c62b3..4f48c7b0f92 100644 --- a/apps/sim/lib/dashboards/time.ts +++ b/apps/sim/lib/dashboards/time.ts @@ -90,7 +90,8 @@ export function dashboardRangeText(range: DashboardTimeRange, timeZone: string): 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. + // Same wall-clock minute (e.g. a DST fall-back repeat): formatRange would collapse both ends, + // so show the exact exclusive end to the second. const exact = new Intl.DateTimeFormat('en-US', { timeZone, month: 'short', @@ -101,7 +102,7 @@ export function dashboardRangeText(range: DashboardTimeRange, timeZone: string): hourCycle: 'h23', timeZoneName: 'short', }) - return `${exact.format(from)} – ${exact.format(to)}` + return `${exact.format(from)} – ${exact.format(new Date(range.to))}` } const sameDay = fromLocal.slice(0, 10) === toLocal.slice(0, 10) return new Intl.DateTimeFormat('en-US', { From a2bbba715c762a67f6238a42bd110aae94899a24 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Thu, 1 Oct 2026 14:27:25 -0700 Subject: [PATCH 3/4] fix(dashboards): show inclusive range end in exact caption and cover id/x-axis threshold lookups --- apps/sim/lib/charts/annotations.test.ts | 25 ++++++++++++++++++++++ apps/sim/lib/dashboards/time.test.ts | 5 ++--- apps/sim/lib/dashboards/time.ts | 28 ++++++++++++++----------- 3 files changed, 43 insertions(+), 15 deletions(-) diff --git a/apps/sim/lib/charts/annotations.test.ts b/apps/sim/lib/charts/annotations.test.ts index 9312d36be84..b75c317a77c 100644 --- a/apps/sim/lib/charts/annotations.test.ts +++ b/apps/sim/lib/charts/annotations.test.ts @@ -141,6 +141,31 @@ describe('chart annotations', () => { ).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('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(() => diff --git a/apps/sim/lib/dashboards/time.test.ts b/apps/sim/lib/dashboards/time.test.ts index fb2428901bc..50f658a83eb 100644 --- a/apps/sim/lib/dashboards/time.test.ts +++ b/apps/sim/lib/dashboards/time.test.ts @@ -54,8 +54,7 @@ describe('dashboard time interactions', () => { { from: '2026-11-01T05:30:00.000Z', to: '2026-11-01T06:31:00.000Z' }, 'America/New_York' ) - expect(text).toContain('01:30:00 EDT') - expect(text).toContain('01:31:00 EST') + 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', () => { @@ -64,6 +63,6 @@ describe('dashboard time interactions', () => { { from: '2026-09-20T14:30:00.000Z', to: '2026-09-20T14:30:01.000Z' }, 'UTC' ) - ).toBe('Sep 20, 14:30:00 UTC – Sep 20, 14:30:01 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 4f48c7b0f92..16012862cb9 100644 --- a/apps/sim/lib/dashboards/time.ts +++ b/apps/sim/lib/dashboards/time.ts @@ -91,18 +91,22 @@ export function dashboardRangeText(range: DashboardTimeRange, timeZone: string): 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 the exact exclusive end to the second. - const exact = new Intl.DateTimeFormat('en-US', { - timeZone, - month: 'short', - day: 'numeric', - hour: '2-digit', - minute: '2-digit', - second: '2-digit', - hourCycle: 'h23', - timeZoneName: 'short', - }) - return `${exact.format(from)} – ${exact.format(new Date(range.to))}` + // 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', { From ff23cf2f76df7b001747ec9e87bd06d04b8010f9 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Thu, 1 Oct 2026 14:33:11 -0700 Subject: [PATCH 4/4] fix(charts): resolve threshold axis by index before id, matching ECharts --- apps/sim/lib/charts/annotations.test.ts | 23 +++++++++++++++++++++++ apps/sim/lib/charts/annotations.ts | 9 ++++++--- 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/apps/sim/lib/charts/annotations.test.ts b/apps/sim/lib/charts/annotations.test.ts index b75c317a77c..280936cb110 100644 --- a/apps/sim/lib/charts/annotations.test.ts +++ b/apps/sim/lib/charts/annotations.test.ts @@ -166,6 +166,29 @@ describe('chart annotations', () => { 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(() => diff --git a/apps/sim/lib/charts/annotations.ts b/apps/sim/lib/charts/annotations.ts index bc82528450d..93da7c9758c 100644 --- a/apps/sim/lib/charts/annotations.ts +++ b/apps/sim/lib/charts/annotations.ts @@ -31,7 +31,10 @@ function firstSeries(option: Record): Record { return toRecord(Array.isArray(option.series) ? option.series[0] : option.series) } -/** The axis the first series is plotted on, resolved through its `*AxisId` or `*AxisIndex`. */ +/** + * 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' @@ -39,8 +42,8 @@ function seriesAxis( const axes = Array.isArray(option[key]) ? (option[key] as unknown[]) : [option[key]] const series = firstSeries(option) const id = series[`${key}Id`] - if (id !== undefined) { - const axis = axes.find((candidate) => toRecord(candidate).id === 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) }