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).
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:
| Input | Set on | Role |
|---|---|---|
| Reference time zone | the rollup archive (ReferenceTimeZone) — see Stream Data Archives | A property of the series' physical location. Makes the stored calendar buckets (CalendarDay / Iso8601Week / CalendarMonth / CalendarYear) DST-correct. Provisioned with the archive. |
| Query time zone | the 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:
| Field | Values | Default | Meaning |
|---|---|---|---|
timeZone | IANA id, e.g. Europe/Vienna | (empty ⇒ UTC) | The zone calendar rollups are aligned to and selected against. |
comparisonPolicy | PerQuery / PerSeries | PerQuery | How 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 storedReferenceTimeZonematches 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'sReferenceTimeZone("each site's local yesterday"). Reuses the existing per-source fan-out.
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 matchingReferenceTimeZonealready 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_BINbins 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:
| Day | Local start → end | UTC window | Length |
|---|---|---|---|
| 2026-03-29 (spring forward) | 00:00 CET → 00:00 CEST | 2026-03-28T23:00Z → 2026-03-29T22:00Z | 23 h |
| 2026-06-15 (no transition) | 00:00 CEST → 00:00 CEST | 2026-06-14T22:00Z → 2026-06-15T22:00Z | 24 h |
| 2026-10-25 (fall back) | 00:00 CEST → 00:00 CET | 2026-10-24T22:00Z → 2026-10-25T23:00Z | 25 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 inTimeRangeUtilsare 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
timeZonesent toresolveSeriesQuery. See the MeshBoard timezone guide. comparisonPolicydefaults toPerQuery;PerSeriesis 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.