Skip to main content

Timezone-Aware Queries

Stream data is always stored in UTC. Many questions, however, are asked in civil (wall-clock) time for a specific place: "what were the values yesterday? this week? last month?" — meaning the local day / week / month boundaries of a chosen time zone, not a UTC day that is shifted against the local one.

This becomes essential the moment one chart compares metering points across zones — a site in Europe/Vienna next to one in Europe/Lisbon. A single UTC bucket boundary cannot represent "yesterday" for both. Timezone-aware queries resolve civil-time ranges and align rollup buckets to a chosen zone, DST-correctly, while storage stays UTC (AB#4190).

Two separate concerns — keep them apart

Computation (query): which UTC window is "yesterday in Europe/Vienna", and which rollup holds that zone's civil days. Display (UI): rendering the chart axis in local time. This page is about computation; display is a rendering concern handled independently.

Where the time zone comes from

Two independent inputs cooperate:

InputSet onRole
Reference time zonethe rollup archive (ReferenceTimeZone) — see Stream Data ArchivesA property of the series' physical location. Makes the stored calendar buckets (CalendarDay / Iso8601Week / CalendarMonth / CalendarYear) DST-correct. Provisioned with the archive.
Query time zonethe query (timeZone)The zone the query is answered in — used to resolve relative ranges and to select the calendar rollup whose civil days match.

Both use IANA names only (Europe/Vienna, Europe/Lisbon) — never fixed offsets, which are wrong twice a year across DST.

Resolving a series query in a zone

The resolution-aware series resolver (streamData.resolveSeriesQuery, and the MCP tool resolve_series_query) accepts two optional inputs:

FieldValuesDefaultMeaning
timeZoneIANA id, e.g. Europe/Vienna(empty ⇒ UTC)The zone calendar rollups are aligned to and selected against.
comparisonPolicyPerQuery / PerSeriesPerQueryHow civil boundaries resolve when a query spans series in different zones.
query {
streamData {
resolveSeriesQuery(input: {
baseArchiveRtId: ""
from: "2026-01-01T00:00:00Z"
to: "2027-01-01T00:00:00Z"
targetPoints: 365
requiredAggregation: SUM
sourcePath: "Amount.Value"
timeZone: "Europe/Vienna"
comparisonPolicy: PerQuery
}) {
archiveRtId
effectiveBucketMs
points
signal
}
}
}

The resolver returns the archive to query and the effective bucket width; you then run the downsampling query against archiveRtId.

Comparison policy

  • PerQuery (default) — one query zone is applied uniformly. "Yesterday" is one civil day in the query zone. A calendar rollup is a valid source only when its stored ReferenceTimeZone matches the query zone; a rollup holding a different zone's civil days is excluded from selection (its buckets are not this query's civil days).
  • PerSeries — each series resolves its own local day in its archive's ReferenceTimeZone ("each site's local yesterday"). Reuses the existing per-source fan-out.
Boundary-consistency invariant

The query window [from, to) and the rollup's bucket boundaries must be computed against the same zone, or the window edges land mid-bucket. Under PerQuery both use the query zone; under PerSeries the range is resolved per series in that series' zone. This is what keeps side-by-side charts from being "off by hours".

Calendar rungs vs. sub-day rungs

  • Civil day and coarser (CalendarDay / Iso8601Week / CalendarMonth / CalendarYear): a rollup carrying a matching ReferenceTimeZone already stores DST-correct local buckets — one stored bucket is one civil unit, read directly.
  • Sub-day, fixed-size (1 h and finer, FixedSize): time-zone-independent. DATE_BIN bins are evenly spaced from a UTC epoch; the zone affects only the axis labels, not the bins. Civil-boundary semantics are a property of calendar-aligned rollups only.

If a civil-day query finds no matching-zone calendar rollup, the resolver returns a truthful signal (ResolutionLimited / NoSuitableRollup) rather than silently mis-aligning — provision a CalendarDay rollup in the required zone to enable it.

DST handling

A local day across a DST transition is 23 h or 25 h, never a fixed 24 h. Bucket boundaries are computed at civil boundaries, so a DST-day bucket is simply wider or narrower — never split, never duplicated. Example, Europe/Vienna:

DayLocal start → endUTC windowLength
2026-03-29 (spring forward)00:00 CET → 00:00 CEST2026-03-28T23:00Z2026-03-29T22:00Z23 h
2026-06-15 (no transition)00:00 CEST → 00:00 CEST2026-06-14T22:00Z2026-06-15T22:00Z24 h
2026-10-25 (fall back)00:00 CEST → 00:00 CET2026-10-24T22:00Z2026-10-25T23:00Z25 h

Client-side relative ranges

Dashboards resolve relative ranges (this year / this month / a picked day) to an absolute [from, to) window before sending them to the backend. That resolution must happen in the chosen IANA zone, not the browser's own zone — otherwise "yesterday in Europe/Lisbon" is wrong when viewed from a Vienna browser.

TimeRangeUtils (shared UI) resolves year / quarter / month / day / custom boundaries for an arbitrary IANA zone, DST-correctly and independent of the browser, using native Intl — no extra dependency. Passing 'local' or 'utc' keeps the historical behaviour.

// Same civil day, two zones → two different UTC windows (one hour apart):
TimeRangeUtils.getDayRange(2026, 5, 15, undefined, undefined, 'Europe/Vienna');
// from 2026-06-14T22:00:00Z (CEST, UTC+2)
TimeRangeUtils.getDayRange(2026, 5, 15, undefined, undefined, 'Europe/Lisbon');
// from 2026-06-14T23:00:00Z (WEST, UTC+1)

Storage is unchanged

Rows stay in UTC; the time zone is a query-time concern only. Nothing is double-bucketed, and no timezone metadata is backfilled onto historical rows.

Status & limitations

  • Backend read-path (resolver timeZone + comparisonPolicy, GraphQL + MCP) and the DST-correct civil-day resolution in TimeRangeUtils are implemented.
  • MeshBoards support an explicit per-board IANA zone end to end: the Time Filter tab offers Local / UTC / Specific time zone (a filterable IANA dropdown), and the chosen zone drives both the civil-day range and, for resolution-aware stream-data widgets, the timeZone sent to resolveSeriesQuery. See the MeshBoard timezone guide.
  • comparisonPolicy defaults to PerQuery; PerSeries is reachable through the GraphQL/MCP API but has no dedicated board toggle yet.
  • Cascade calendar rollups (a rollup sourced from another rollup, e.g. daily→weekly→monthly) are excluded from automatic selection in the current phase, independent of the time zone.