units.ts from #30 formats quantities. It does not format instants, and a time series axis is nothing but instants. That is missing on purpose — it needs a timezone, and Launchpad currently has two incompatible ideas of what a timezone is.
The conflict
src/lib/dashboard/model.ts — timezone: 'utc' | 'browser' | <IANA name>, matching Grafana, which is what an imported dashboard carries.
src/lib/stores/timezone.ts — 'utc' | 'local', a two-state toggle in the header, persisted as arc-timezone, and already wired through the logs and instance pages.
These are not the same type and the overlap is partial: 'browser' and 'local' mean the same thing under different names, and the store has no way to express America/Montevideo. A dashboard saved with an explicit IANA zone and then viewed through the existing toggle silently renders in the wrong one — off by hours, with no error.
Decide this before writing the formatter, not after. The recommendation is that the dashboard model wins, the store widens to 'utc' | 'browser' | string, 'local' migrates to 'browser' on read, and the header toggle becomes a picker on dashboard routes while staying a toggle elsewhere. But that is a call to make deliberately — it touches every existing consumer of the store.
The formatter
timeAxisFormatter(range: { from: number; to: number }, timezone: string): (ms: number) => string
- Tick granularity follows the span, not the tick count. A 5-minute window wants
15:04:05; a 3-day window wants Mar 12 15:00; a year wants Mar 2026. Rendering a full ISO string at every tick is how axes become unreadable.
- One format for the whole axis. Mixed granularity across ticks stops reading as a scale — the same rule
formatAxisTicks already follows for quantities.
- Context goes in the axis label, not in every tick. The year appears once.
- Tooltips get full precision including the zone, since that is where someone copies a timestamp out of.
Traps
Intl.DateTimeFormat with a timeZone option is the only correct way to do this. Adding an offset to a Date is wrong across a DST boundary, and a dashboard spanning one is exactly when someone is looking.
- Construct the
Intl.DateTimeFormat once per axis, not per tick. It is one of the more expensive objects in the platform; building it inside a 60-tick loop on every redraw is measurable.
- An invalid IANA name from an imported dashboard makes
Intl.DateTimeFormat throw, which takes the panel down. Validate at parse or catch at construction — do not let it reach a draw call.
- uPlot's default x unit is seconds unless
opts.ms is set; frame.ts produces milliseconds. Whatever wires this needs to agree on one.
Scope
The reconciliation, src/lib/dashboard/time.ts + tests, and migrating the existing store consumers. Blocks the time series panel (#36) and the shared panel chrome (#34).
units.tsfrom #30 formats quantities. It does not format instants, and a time series axis is nothing but instants. That is missing on purpose — it needs a timezone, and Launchpad currently has two incompatible ideas of what a timezone is.The conflict
src/lib/dashboard/model.ts—timezone: 'utc' | 'browser' | <IANA name>, matching Grafana, which is what an imported dashboard carries.src/lib/stores/timezone.ts—'utc' | 'local', a two-state toggle in the header, persisted asarc-timezone, and already wired through the logs and instance pages.These are not the same type and the overlap is partial:
'browser'and'local'mean the same thing under different names, and the store has no way to expressAmerica/Montevideo. A dashboard saved with an explicit IANA zone and then viewed through the existing toggle silently renders in the wrong one — off by hours, with no error.Decide this before writing the formatter, not after. The recommendation is that the dashboard model wins, the store widens to
'utc' | 'browser' | string,'local'migrates to'browser'on read, and the header toggle becomes a picker on dashboard routes while staying a toggle elsewhere. But that is a call to make deliberately — it touches every existing consumer of the store.The formatter
15:04:05; a 3-day window wantsMar 12 15:00; a year wantsMar 2026. Rendering a full ISO string at every tick is how axes become unreadable.formatAxisTicksalready follows for quantities.Traps
Intl.DateTimeFormatwith atimeZoneoption is the only correct way to do this. Adding an offset to aDateis wrong across a DST boundary, and a dashboard spanning one is exactly when someone is looking.Intl.DateTimeFormatonce per axis, not per tick. It is one of the more expensive objects in the platform; building it inside a 60-tick loop on every redraw is measurable.Intl.DateTimeFormatthrow, which takes the panel down. Validate at parse or catch at construction — do not let it reach a draw call.opts.msis set;frame.tsproduces milliseconds. Whatever wires this needs to agree on one.Scope
The reconciliation,
src/lib/dashboard/time.ts+ tests, and migrating the existing store consumers. Blocks the time series panel (#36) and the shared panel chrome (#34).