Repository navigation
Dashboard time-range control: token-based From/To picker in the filter bar #335
Description
Summary
Add a compound time-range control to the Dashboard filter bar for any pair of date-like filters that forms a time-range group: a closed trigger showing the resolved absolute range, and a popover with token-based From/To editors (relative expressions or absolute datetimes), a live resolved preview per field, a contextual column of per-field relative-time constants, a Recently used range list, and an explicit Apply that commits both bounds atomically in one execution wave.
Design proposal (authoritative for layout and interaction): https://claude.ai/design/p/019dd2ff-4ef3-7e55-ab35-4a3fecb6ae5c?file=Filters+Panel.html&via=share (Filters Panel.html in the design project; see also the screenshot in the comment below).
Revision note. This issue previously specified a Grafana-clone header control (absolute-only editors, whole-range "Last X minutes" quick presets, Back/Forward buttons, zoom-out, group selector, timezone disclosure). The design proposal and the infrastructure that has since landed (#169 relative-time grammar, #360 filter source pipeline, #189/PR #364 multiselect primitives) replace that model. Key deltas:
- From/To accept relative-time tokens, not just absolutes — quick ranges become per-field constants that fill one bound, instead of presets that overwrite both.
- Relative ranges stay live: the committed value is the token (exactly like today's individual relative-time fields), re-resolved per execution wave. The old "resolve presets to frozen absolutes" and the old non-goal "no moving ranges" are both reversed.
- Back/Forward buttons and zoom-out are dropped — "Recently used" in the popover covers going back; Add synchronized Dashboard time-range groups, crosshairs, and drag-to-zoom #334's drag-to-select covers zooming in.
- The control lives in the filter bar (a "Time" section left of the dimension filters), not next to Refresh in the header.
- Multiple groups render one control each — no active-group selector or session "active group" state.
- No server-timezone disclosure UI: no server-timezone infrastructure exists; the existing UTC "server time" preview convention (
core/relative-time.tsformatPreview) is kept unchanged.
What already exists (build on it, don't duplicate)
src/core/relative-time.ts— the complete Grafana-grammar resolver (Relative time values for date/time variables —-1h,now-7d,now/d+ preset dropdown #169):parseRelativeExpr,resolveInstant,resolveRelativeValue,formatPreview,isDateLikeType(Date/Date32/DateTime/DateTime64, Nullable-unwrapped). Pure;nowMsis injected. Do not add a second grammar or timestamp formatter.src/ui/relative-time-field.ts— the current single-bound date field: token input + preset combobox + live UTC preview. Today a from/to pair renders as two of these, independent and uncoordinated.src/ui/filter-bar.ts/src/ui/multi-select-field.ts(Dashboard query-backed filters: searchable multiselect with Apply #189, PR feat(#189): searchable multiselect for query-backed Dashboard filters with Apply #364) — the shared bar and the dialog-popover pattern:role="dialog"/aria-modal, Tab focus trap, Escape + focus return to trigger, Apply/Cancel footer with Apply-closes-before-commit and no-op detection, polite live region,aria-busyloading state.src/dashboard/application/dashboard-viewer-session.ts— per-filter commits (applyFilter) plus the internal multi-parameter corecommitAndRerun(changed[])→ onerunAffectedWaveover resolved targets (Dashboard query-backed filters: searchable multiselect with Apply #189). No public batch commit exists yet — this issue adds one.asb:dashFilterspersistence (dashboard-filter-store.ts) — committed values (including tokens) already persist per dashboard/filter; nothing new needed for value persistence.
Time-range group model
Introduce a pure resolver (new src/core/ module) that derives zero or more groups from the dashboard's filters:
interface DashboardTimeRangeGroup {
key: string; // derived from resolved filter identities, not labels
fromFilterId: string;
toFilterId: string;
}A group forms only when both filters resolve to scalar date-like parameter contracts across their executable consumers — reuse the consumer-resolution approach of core/filter-selection.ts (#189); conflicting or unresolved contracts mean no group and no degradation of the ordinary per-filter fields. Group formation is runtime-only and never writes to the dashboard document.
Pair discovery is a seam with two sources:
- Preferred: saved-query
timeRangesmetadata resolved to dashboard filter pairs, as specced in Add synchronized Dashboard time-range groups, crosshairs, and drag-to-zoom #334. If Add synchronized Dashboard time-range groups, crosshairs, and drag-to-zoom #334 lands first, consume its resolution. - Interim (only if this issue lands first): conservative runtime inference over the dashboard's filter parameters using Add synchronized Dashboard time-range groups, crosshairs, and drag-to-zoom #334's recognized name-pair table (
from/to,from_time/to_time,start/end,start_time/end_time, case-insensitive; neverstart/stop). Ambiguity (a parameter matching two pairs) forms no group. This inference must sit behind the same resolver seam so Add synchronized Dashboard time-range groups, crosshairs, and drag-to-zoom #334's metadata replaces the source without touching the UI.
Multiple groups may coexist; each renders its own control. Filters not in any group keep their existing fields.
Filter-bar presentation
When a group resolves, the bar renders one compound control in place of the pair's two individual fields, in a "Time" section ahead of the remaining filters (per the design: Time | <control> | Filters | <pills…>).
Closed-trigger states:
- both bounds committed and resolvable → the resolved absolute range, e.g.
2026-07-20 12:35:45 → 2026-07-21 12:35:45, formatted with the existingformatPreviewUTC convention and resolved against the most recent execution wave's sharednow(re-render per wave; no ticking timers). The accessible name includes the raw committed tokens as well as the resolved range. - either bound inactive/empty → a neutral "Not set" state.
- a committed value that no longer parses (hand-edited persistence, imports) → raw text with error styling; opening the popover shows the field-level diagnostic.
The trigger follows the #364 pattern (aria-haspopup="dialog", aria-expanded, open-state styling). The control works in edit and read-only dashboard modes and must remain reachable in the wrapped/narrow bar.
Popover
Two columns plus a footer (per the design):
┌─────────────────────┬──────────────────────────┐
│ From [ -1d ▾] │ Recently used │
│ = 2026-07-20 12:35 │ -1d → now │
│ To [ now ▾] │ 2026-07-19 → -1h │
│ = 2026-07-21 12:35 │ … │
├─────────────────────┴──────────────────────────┤
│ [Cancel] [Apply] │
└─────────────────────────────────────────────────┘
From/To fields (left column)
- Free-text inputs seeded from the pair's committed values; editing is staged until Apply.
- Accept exactly what the existing pipeline accepts: a relative-time expression (
now,-1d,now-15m,now/d, …) or an absolute value valid for the bound's declared type — viaparseRelativeExpr+ the existing per-type validation. No new syntax. - A live resolved line under each field (
= 2026-07-21 12:35:45,formatPreviewconvention, one shared previewnowfor both fields); unparseable input shows error styling plus a field-specific diagnostic in place of the resolved line. - Each field has a caret button toggling the constants column for that field.
Right column — contextual
- No field active: Recently used ranges for this group — token pairs, most-recent-first; clicking one applies immediately (same commit path as Apply) and closes the popover.
- A field active (focused or caret-toggled): a constants list for that field — relative tokens with plain-language labels (
now· current time,-5m,-15m,-30m,-1h,-3h,-6h,-12h,-1d,-2d,-7d,-30d,-1M,-90d; align/extendRELATIVE_TIME_PRESETS). Typing in the field filters the list; selecting a constant fills the field (staged — it does not apply). An empty match shows a hint that absolute datetimes are accepted.
Footer
- Cancel discards the draft. Apply is disabled while either field fails to resolve or the resolved instants give
from > to(equal instants are permitted); when enabled it commits both bounds. - Applying a draft identical to the committed pair (after trim) closes the popover with no wave and no recents entry.
Popover chrome (dialog role, focus trap, Escape, backdrop click, focus return, placement under the trigger, viewport clamping) must come from a shared primitive extracted from ui/multi-select-field.ts — this popover is the second consumer of that pattern, which is exactly the extraction threshold of CLAUDE.md hard rule 5. Do not copy the trap/lifecycle code.
Value and execution semantics
-
The committed value of each bound is the entered text itself — token or absolute — flowing through the unchanged existing pipeline (
resolveVarValues/prepareFilterSourcewith the wave's sharedwallNowMs,serializeParamValue). Relative ranges therefore move: Refresh or any later wave re-resolvesnow. Absolute values stay fixed. This matches how individual relative-time fields behave today. -
Add a public batch commit to the viewer session, e.g.
session.applyFilters(entries: Array<{ filterId: string; value: string; active: boolean }>): void
routing through the existing
commitAndRerun([fromParam, toParam])so both filters update and activate atomically and dependent tiles rerun in exactly one affected wave (union of both parameters' resolved targets, existing stale-wave supersession). Validation of both bounds happens before either commits; a failure leaves both untouched. Add synchronized Dashboard time-range groups, crosshairs, and drag-to-zoom #334's drag-to-select must commit through this same method. -
The control mutates runtime filter state only — never saved queries, dashboard documents, layouts, workspace revision, or exports. Committed values persist via the existing
asb:dashFiltersbag with no schema change.
Recently used ranges
- Session-scoped, per group
key; not persisted in v1 (a later change may persist them following thecore/recent-values.tspattern, keyed by type pair). - On each successful commit that changes the pair, push the outgoing committed pair; dedupe by exact token pair; most-recent-first; bounded (~6).
- Failed or no-op applies push nothing.
Accessibility
- Trigger: dialog-pattern ARIA, accessible name carrying both the tokens and the resolved range.
- Popover: labelled From/To fields; resolved-preview/diagnostic lines associated via
aria-describedby; constants and recents keyboard-reachable; Tab trap, Escape, focus return per the shared primitive. - A successful commit announces the new range in the existing polite live region; a validation failure is announced without changing the committed range.
Tests
Core (100%) — group resolver: both discovery sources, contract gating, ambiguity → no group, multiple independent groups, key stability; draft validation: token/absolute forms per type, from > to rejection at resolved instants, equal instants permitted, shared preview now; recents: push-on-change, dedupe, bound, no-op exclusion.
Session — applyFilters atomicity (either-fails → neither commits), both filters active after commit, exactly one wave over the union of resolved targets, no-op detection, stale-wave supersession unchanged.
UI (unit, happy-dom) — trigger states (resolved/not-set/error) and per-wave label re-resolution; popover column switching (recents ↔ constants), constants filtering and staged fill, recents immediate-apply, Cancel/Apply/disabled-Apply, focus trap and focus return, live-region announcements; pair replaced in the bar while non-group filters keep existing fields.
Real browser — light/dark, narrow viewport (bar wraps, popover clamps to viewport), edit and read-only modes, an actual two-bound commit producing one visible refresh of dependent tiles.
Acceptance criteria
- A resolvable date-like filter pair renders one compound range control in the filter bar, replacing its two individual fields; non-group filters are unaffected.
- Multiple independent groups render one control each.
- From/To accept relative tokens and absolute values through the existing grammar/serialization pipeline, with live resolved previews and field-level diagnostics.
- Per-field constants and group-scoped recently-used ranges behave as specified (staged fill vs immediate apply).
- Apply commits both bounds atomically via a new public batch-commit session API and triggers exactly one execution wave; failures and no-ops leave state and history untouched.
- Committed relative ranges re-resolve on subsequent waves; the closed trigger shows the wave-resolved absolute range without timers.
- Popover chrome is a shared primitive extracted from the feat(#189): searchable multiselect for query-backed Dashboard filters with Apply #364 multiselect, consumed by both.
- No dashboard-document, saved-query, or schema mutations; values persist through the existing filter bag.
- Unit coverage per the repo gate and real-browser checks pass.
Non-goals
- Back/Forward history buttons and zoom-out (recents + Add synchronized Dashboard time-range groups, crosshairs, and drag-to-zoom #334 drag-to-select cover these; revisit only with evidence).
- Whole-range quick presets that set both bounds at once.
- A group selector or session "active group" state.
- Server-timezone fetch, disclosure, switching, or browser-timezone conversion — the UTC "server time" preview convention is unchanged.
- Persisted recents (v1 is session-only, by design).
- Saved-query
timeRangesmetadata, crosshairs, drag-to-select (Add synchronized Dashboard time-range groups, crosshairs, and drag-to-zoom #334). - Auto-refresh interval controls; URL-encoded/shareable ranges; calendar/date-picker widgets; changes to the relative-time grammar.
Activity
[-]Add Grafana-style Dashboard time-range controls[/-][+]Dashboard time-range control: token-based From/To picker in the filter bar[/+]BorisTyshkevich commentedon Jul 22, 2026
Owner decisions pinned during implementation (PR #376), recorded for #334 and future revisits:
from/to,from_time/to_time,start/end,start_time/end_time, case-insensitive exact) — no extended variants, no two-date-filters heuristic. Dashboards with other parameter names keep separate fields until Add synchronized Dashboard time-range groups, crosshairs, and drag-to-zoom #334'stimeRangesmetadata lands (the resolver seam is in place).YYYY-MM-DD,YYYY-MM-DD HH:MM[:SS[.fff]], the ISOTvariants, bare-date-at-midnight for DateTime types, epoch digits (1–10 = seconds, 13 = ms) for DateTime types, years ≥ 1900; anything else marks the field invalid and disables Apply. Identical-draft Apply commits nothing (no publish, no wave, no recents entry).Also shipped under this issue: the one-
wallNow()-snapshot-per-wave fix (DashboardViewState.waveWallNowMs) — review foundrefresh()previously took several independent clock readings per wave.fix(#335): Apply on an inactive pair with unchanged values commits — …
Merge main (#369 explicit Table view on Dashboard) into #335 time-ran…
feat(#335): compound Dashboard time-range control — token-based From/…
feat(#447): inferred Dashboard Variables, replacing the curated filte…
feat(Altinity#447): option-SQL contract, UNION ALL compiler and posit…
feat(Altinity#447): wire the variable controls and add the editor's T…
feat(Altinity#537): collapse the tile head into a ⋯ menu, atomic dele…