Skip to content

feat(#335): compound Dashboard time-range control — token-based From/To picker in the filter bar - #376

Merged
BorisTyshkevich merged 9 commits into
mainfrom
feat/time-range-control-335
Jul 22, 2026
Merged

feat(#335): compound Dashboard time-range control — token-based From/To picker in the filter bar#376
BorisTyshkevich merged 9 commits into
mainfrom
feat/time-range-control-335

Conversation

@BorisTyshkevich

Copy link
Copy Markdown
Collaborator

What & why

Closes #335 — the Dashboard filter bar gains a compound time-range control: any pair of scalar date-like filters matching #334's recognized name-pair table (from/to, from_time/to_time, start/end, start_time/end_time, case-insensitive) renders as one control in a Time section, replacing the pair's two independent fields. This is the migration from separate From/To time filters to a single coordinated range.

  • Closed trigger shows the range resolved against the most recent execution wave's shared clock (no timers); raw tokens live in the accessible name. Not set / error states per the issue.
  • Popover (design: Filters Panel.html) stages token-or-absolute From/To edits with live resolved previews against one shared now, per-field relative-time constants (typing filters; selecting stages), group-scoped session-only Recently used ranges (immediate apply), and Apply gated on both bounds resolving with from ≤ to (equal permitted).
  • Atomic batch commit: new public DashboardViewerSession.applyFilters(entries) — validate-all-then-mutate, one publish, one commitAndRerun wave over the union of both parameters' resolved targets; unknown/duplicate ids or an identical draft commit nothing. Add synchronized Dashboard time-range groups, crosshairs, and drag-to-zoom #334's drag-to-select will commit through this same API.
  • Group resolution is pure (core/time-range.ts), gated on consumer contracts via the Dashboard query-backed filters: searchable multiselect with Apply #189 resolveFilterSelection machinery (isDateLikeType, scalar, zero diagnostics); curated/source-backed filters never group. Pair discovery sits behind a seam so Add synchronized Dashboard time-range groups, crosshairs, and drag-to-zoom #334's timeRanges metadata replaces the interim name inference without UI changes.
  • Absolute-bound validation (new parseAbsoluteInstant in core/relative-time.ts): preview formats, ISO-T variants, epoch digits for DateTime types, real calendar checks, years ≥ 1900. No second grammar/formatter.
  • Shared popover primitive (ui/popover.ts openAnchoredDialog) extracted from the feat(#189): searchable multiselect for query-backed Dashboard filters with Apply #364 multiselect at the rule-5 second-consumer threshold — the multiselect migrated behavior-preserving with its 717-line spec untouched byte-for-byte; fixedAnchor gained an opt-in pure viewport clamp. The filter bar's two parallel handle maps were unified into one FieldHandle map with generalized popover hooks (openPopoverKey/focusedFieldKey/focusFieldTrigger).
  • Bug fix: each execution wave now captures one wallNow() snapshot (published as DashboardViewState.waveWallNowMs) and threads it through every relative-token resolution — previously one refresh took several independent readings, so now/-1d in different sub-phases could resolve seconds apart.

Owner decisions recorded on the issue: strict #334 name table only; no un-set affordance in v1; curated date filters excluded from grouping; strict-but-generous absolute syntax.

Verification: 163 unit files / 4825 tests green (per-file gate: new core+UI files at 100%); e2e 38/38 on chromium+webkit (real viewer session: pair replacement, staged editing, disabled-Apply, exactly-one-wave commit, recents, Escape/backdrop/focus, light/dark, 360 px clamping); three independent review passes (spec compliance, correctness/concurrency, quality/a11y) — all findings fixed in a205295.

Checklist

  • npm test passes (the per-file coverage gate is non-negotiable)
  • Tests added/updated in the same change as the code
  • npm run build succeeds (single-file dist/sql.html)
  • Layers kept honest: pure logic in src/core/, network in src/net/ (injected fetch), DOM in src/ui/
  • No new runtime dependency (or it's a deliberate, justified addition — see CONTRIBUTING)
  • README / CHANGELOG.md ([Unreleased]) updated if behavior or the deployed surface changed
  • Reconciled affected tracked work (roadmap Roadmap to 1.0.0 #68, the issue body, ADR/CHANGELOG) if this change reshaped it

🤖 Generated with Claude Code

BorisTyshkevich and others added 7 commits July 22, 2026 10:36
…absolute-instant parser, recents

- src/core/time-range.ts: inferTimeRangePairs (#334 interim name-pair table,
  curated filters excluded, ambiguity drops), resolveTimeRangeGroups gated on
  scalar date-like consumer contracts via resolveFilterSelection,
  validateTimeRangeDraft (one shared preview now, from<=to at resolved
  instants, equal permitted), pushRecentRange (dedupe, cap 6)
- src/core/relative-time.ts: parseAbsoluteInstant (strict, generous syntax:
  preview formats, ISO-T, epoch digits for DateTime; UTC convention);
  formatPreviewInstant exported for reuse
- src/ui/relative-time-field.ts: TIME_RANGE_CONSTANTS (design's 14 tokens),
  filterTokenList generalization; RELATIVE_TIME_PRESETS bit-identical

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…-clock snapshot per wave

- applyFilters(entries): atomic multi-filter commit — unknown/duplicate id or
  zero-change call is a whole-call no-op; one publish, one commitAndRerun over
  the changed parameters (single wave over the union of resolved targets)
- readonly timeRangeGroups: resolved once at construction via
  core/time-range.ts, after #189 source-fallback resolution (curated excluded)
- DashboardViewState.waveWallNowMs: each wave entry point captures one
  deps.wallNow() and threads it through prepareBatch/filter-source waves —
  fixes the latent bug where one refresh resolved relative tokens against
  multiple instants; getFilterField keystroke validation keeps live wall-now

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… the multiselect

- src/ui/popover.ts: openAnchoredDialog owns the generic dialog chrome
  (overlay/backdrop, role/aria-modal, Escape, Tab trap with per-press
  recompute, fixedAnchor placement, aria-expanded, teardown + focus return
  with skipFocus); documents the close-before-commit rule (#364)
- multi-select-field.ts migrated behavior-preserving; its spec untouched
  byte-for-byte as the regression proof; busy state/live region stay content
- dom.ts fixedAnchor: optional pure right-edge clamp (panelW + viewportW)
  for the upcoming time-range popover; existing callers unchanged

Second consumer lands in the next commit (CLAUDE.md rule 5 threshold).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…pover

- src/ui/time-range-field.ts: dialog-pattern trigger (resolved range label,
  Not set, error states; aria-label carries tokens + resolved range;
  refreshLabel(nowMs) for per-wave re-resolution), popover via the shared
  openAnchoredDialog primitive (second consumer): staged From/To editors with
  one shared preview now, per-field constants column with typing filter,
  group recents with immediate apply, Cancel/Apply footer gated by
  validateTimeRangeDraft, identical-draft Apply closes without commit
- .trf-* styles on existing theme tokens (light+dark)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…oard

- filter-bar.ts: Time section (one control per group, grouped params
  suppressed) ahead of the remaining filters; the two parallel handle maps
  unified into one FieldHandle map with opaque keys (param | group:key);
  multiselect-specific hooks generalized (openPopoverKey/focusedFieldKey/
  focusFieldTrigger); refreshTimeRangeLabels(nowMs)
- dashboard.ts: timeRange assembly from session.timeRangeGroups + published
  filter state; shell-owned session-scoped recents (outgoing pair, no-op and
  first-commit excluded); onApplyTimeRange -> session.applyFilters (both
  bounds, one wave) + polite live-region announcement; per-wave trigger label
  re-resolution off waveWallNowMs without a bar rebuild; read-only mode uses
  the same path

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…refreshTile wave snapshot, year floor, NUL hygiene

- time-range-field: a constant pick re-focuses the field input (the click
  detaches the picked button); polite sr-only region announces validation
  failures (per-field + range), deduped, cleared on valid
- dashboard-viewer-session: refreshTile is a wave of one — publishes its own
  waveWallNowMs and binds the tile against it (was an untethered wallNow())
- relative-time: calendar years below 1900 rejected (no ClickHouse date type
  reaches lower; keeps Date.UTC's 0-99 remap unreachable)
- time-range-field.test.ts: literal NUL byte replaced with the \u0000 escape
  (file was binary-classified by git); stale hook name in a multiselect comment

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- tests/e2e/time-range.html/.spec.js: real viewer session + real buildFilterBar
  through the dashboard glue — group resolution, pair replacement, staged
  editing, disabled-Apply gating, exactly-one-wave batch commit, recents
  immediate apply, Escape/backdrop/focus-return, light/dark, 360px clamping
  (chromium+webkit: 38 passed)
- dashboard-mobile fixture: compound control in the narrow-bar layout checks
- CHANGELOG [Unreleased]: #335 feature entry + the wave-snapshot fix

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
BorisTyshkevich and others added 2 commits July 22, 2026 14:08
…activation is the change (merge-gate review)

The identical-draft no-op compared only the two text values; with a
committed-but-inactive pair (clearFilter keeps the typed value and only
flips active off, and defaultValue+defaultActive:false is authorable) the
popover seeded valid text and Apply silently closed without committing,
leaving no UI path to activate the range. The no-op now additionally
requires the pair to already be active; the session's own changed-set
comparison treats the active flip as a real change and runs one wave.
Recents stay clean: the outgoing pair is only pushed when it was active.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@BorisTyshkevich
BorisTyshkevich merged commit 1e2dd3d into main Jul 22, 2026
10 checks passed
BorisTyshkevich added a commit that referenced this pull request Jul 22, 2026
…n never silently clear

Review blocker: a Library star/rename on a dirty linked tab stamped the
newest persisted token onto a tab whose draft was based on older content;
a later refresh then classified it noop and (with the previous noop
auto-clear) quietly made a stale draft ordinarily saveable — silently
overwriting the other tab's change. The same masking also applied when the
star/rename happened BEFORE any refresh flagged the conflict at all.

- patchSavedSpec advances a linked tab's lastCommittedQueryToken ONLY when
  the tab's baseline matched the pre-patch latest entry; a lagging tab
  keeps its old token so the classifier keeps seeing the divergence
- a lagging DIRTY tab discovered at patch time is flagged 'conflict'
  immediately (the tab's own commit makes the workspace token current, so
  the next refresh would no-op and the classifier would never run)
- the reconcile noop branch NEVER auto-clears an existing conflict — the
  flag survives until Reload-saved-version or Keep-my-draft succeeds
  (kept counted in the refresh summary)
- secondary: patchSavedSpec aborts on an externally-deleted target return
  deletedExternally; Library star/rename toast and trigger
  refreshWorkspaceFromStore so the dead row reconciles immediately;
  Save-button/tab-badge repaint after Library patches
- regression tests: the reported scenario (conflict → star/rename →
  unrelated external change → refresh keeps conflict), the pre-refresh
  masking variant, baseline-not-advanced unit test, noop-keeps-conflict
  (replacing the previous opposite-direction test), deletedExternally
  surfacing

Also merges origin/main (time-range control #376, dep bumps) — clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UDsUDSPoDYa1M1rbpgdG3f
BorisTyshkevich added a commit that referenced this pull request Jul 22, 2026
…nvalidate stale tabs (#377)

* feat(#343): centralize workspace projection + cross-tab invalidation in mutateWorkspace

Step 1-2 of the cross-tab consistency plan:
- new pure src/workspace/workspace-sync.ts: workspaceToken/queryToken/
  queriesChanged + reconcileLinkedTabs linked-tab classifier
  (adopt/conflict/detach/orphan/noop), 100% covered
- app.mutateWorkspace transform now returns { candidate, data? }; on a
  successful commit the primitive itself projects via
  applyCommittedWorkspace exactly once, records the snapshot token, and
  posts one { type: 'workspace-changed', sourceTabId, workspaceId }
  invalidation (never the workspace body)
- new env seams: broadcastChannel(name) factory (capability-or-null) and
  documentVisible(); per-tab sourceTabId; inbound messages ignore self and
  route to the onExternalWorkspaceChange hook (wired in a later step)
- file-menu commitWorkspace and dashboard settleCommand stop projecting on
  success (route caches + rollback preserved)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UDsUDSPoDYa1M1rbpgdG3f

* feat(#343): Workbench saved-query mutations become transforms over the latest workspace

Step 3: create/save-linked/star/rename/delete no longer commit candidates
built from stale app.state.

- state.ts planners (createSavedQuery, commitSavedQuery, patchSavedSpec,
  renameSaved, toggleFavorite, deleteSaved) fold their change into the
  latest committed aggregate inside app.mutateWorkspace's transform; the
  CommitWorkspace seam and buildWorkspaceCandidate are gone
- save-linked and spec patches resolve their target by id against
  latest.queries and abort (never recreate) when it was deleted externally
- star keeps the dual-write semantics but derives tile membership from
  latest.dashboard, expressed as an explicit desired membership
- saved-query-service takes deps.mutateWorkspace instead of a raw
  workspace.commit; saved-history and the app.ts create/commit call sites
  drop their serializeWrite wrappers (the primitive serializes)
- new tests/unit/cross-tab-consistency.test.ts: two createApp() instances
  over one shared fake IndexedDB cover the read-before-write matrix

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UDsUDSPoDYa1M1rbpgdG3f

* feat(#343): cross-tab refresh, coalesced invalidation, and linked-tab conflict resolution

Steps 4-5: stale tabs now learn about external commits and reconcile.

- app.refreshWorkspaceFromStore() runs inside the same serializeWrite
  queue as mutations; token compare (workspaceToken vs lastCommittedToken)
  makes unchanged reloads a no-op and prevents projecting an older read
  over a newer local commit; failed loads warn non-destructively, retry on
  next activation, and never wedge the queue
- channel pokes, window focus, and visibilitychange (when visible)
  coalesce through one refreshPending flag into a single queued refresh;
  the fallback works when BroadcastChannel is absent
- per-tab lastCommittedQueryToken recorded at every sync point
  (open-from-saved/create/save/adopt, gap-filled at projection);
  reconcileLinkedTabsToLatest applies the classifier: clean tabs adopt the
  external version, dirty tabs keep their draft and enter an explicit
  conflict state, deleted+clean detaches, deleted+dirty becomes an
  unsaved draft flagged deleted-elsewhere (Save-as-new only)
- new src/ui/conflict-resolution.ts: two-action chooser (Reload saved
  version / Keep my draft with explicit confirm); Save on a conflicted
  tab routes here and never silently overwrites

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UDsUDSPoDYa1M1rbpgdG3f

* feat(#343): editable Dashboard rebuilds on external workspace changes

Step 6: the Dashboard route reacts to cross-tab invalidation.

- renderDashboard registers app.onWorkspaceExternallyChanged per render;
  an editable route rebuilds its viewer session from committed truth
  through the shared rebuildRouteFromCommitted() funnel (also used by
  settleCommand's membership-restore path, so the two can never
  double-render); rebuilds defer while command descriptors are pending
  and coalesce via the rebuilding flag
- a full-route rebuild re-reads both the Dashboard document and the query
  collection, so an external query-only change rebuilds even a
  byte-identical document; the rebuild never commits
- detached/read-only Dashboard views ignore primary-workspace
  invalidation at both layers: app.dashboardReadOnly short-circuits the
  cross-tab refresh (never projecting the primary aggregate over a
  detached snapshot) and the route hook no-ops
- per-Dashboard filter values survive the rebuild via the existing
  KEYS.dashFilters store (covered by test)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UDsUDSPoDYa1M1rbpgdG3f

* fix(#343): surface externally-deleted saves, clear stale conflict flags (review)

Two-reviewer pass findings applied (no blockers found):

- a linked Save whose target was deleted in another tab no longer fails
  silently: commitSavedQuery flags the abort (deletedExternally), the
  service maps it to reason 'deleted', and commitLinkedQuery toasts +
  triggers refreshWorkspaceFromStore so the tab gets its orphan/detach
  treatment immediately instead of waiting for the next activation; the
  vanished-query guard in reloadSavedVersion refreshes the same way
- a reconcile that classifies a still-linked tab as noop clears a stale
  'conflict' flag (divergence disappeared); the 'deleted' badge on
  orphaned tabs is deliberately kept until the user acts
- create/delete failure narrowing uses the outcome union instead of an
  as-cast; documented the conservative-skip window during a Dashboard
  route rebuild
- CHANGELOG [Unreleased] entry for #343

Live-verified in a real two-tab browser session (broadcast refresh,
conflict flagging with draft preservation, Reload saved version, Keep my
draft behind explicit Overwrite, clean-tab adopt).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UDsUDSPoDYa1M1rbpgdG3f

* fix(#343): conflict-token lifecycle — a flagged or unseen conflict can never silently clear

Review blocker: a Library star/rename on a dirty linked tab stamped the
newest persisted token onto a tab whose draft was based on older content;
a later refresh then classified it noop and (with the previous noop
auto-clear) quietly made a stale draft ordinarily saveable — silently
overwriting the other tab's change. The same masking also applied when the
star/rename happened BEFORE any refresh flagged the conflict at all.

- patchSavedSpec advances a linked tab's lastCommittedQueryToken ONLY when
  the tab's baseline matched the pre-patch latest entry; a lagging tab
  keeps its old token so the classifier keeps seeing the divergence
- a lagging DIRTY tab discovered at patch time is flagged 'conflict'
  immediately (the tab's own commit makes the workspace token current, so
  the next refresh would no-op and the classifier would never run)
- the reconcile noop branch NEVER auto-clears an existing conflict — the
  flag survives until Reload-saved-version or Keep-my-draft succeeds
  (kept counted in the refresh summary)
- secondary: patchSavedSpec aborts on an externally-deleted target return
  deletedExternally; Library star/rename toast and trigger
  refreshWorkspaceFromStore so the dead row reconciles immediately;
  Save-button/tab-badge repaint after Library patches
- regression tests: the reported scenario (conflict → star/rename →
  unrelated external change → refresh keeps conflict), the pre-refresh
  masking variant, baseline-not-advanced unit test, noop-keeps-conflict
  (replacing the previous opposite-direction test), deletedExternally
  surfacing

Also merges origin/main (time-range control #376, dep bumps) — clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UDsUDSPoDYa1M1rbpgdG3f

* fix(#343): clean lagging tab adopts committed truth at patch time (review blocker 2)

A clean linked tab whose baseline lagged latest (external change, missed
poke) could be left silently stale by a Library star/rename: the patch
folded into latest correctly, but the tab kept its old SQL/Spec, the
tab's own commit made the workspace token current, and the promised
adopt-on-next-refresh never ran (refresh no-ops at the workspace level).
An ordinary edit+Save from that tab would then overwrite the external
change without ever seeing a conflict.

patchSavedSpec post-apply now implements the full three-case table:
- baseline matched pre-patch latest -> apply local metadata patch,
  advance the baseline token
- baseline lagged + dirty tab -> preserve the draft, flag/keep conflict
- baseline lagged + clean tab -> adoptSavedIntoTab(saved) immediately
  (latest content + this patch; stays clean; token = committed token)

An ABSENT baseline is unknown, not provably lagged (every production
link path stamps one) - legacy draft-patch behavior is kept there so
clean draft-only Spec extensions survive.

Also: Library star/rename handlers re-fire the tabs signal so an adopted
active tab's editor resyncs; new saved-history tests cover the
deletedExternally toast+refresh branches (the CI coverage failure).

Regression tests: unit clean-lagging adopt; cross-tab "B opens q1 clean,
A changes SQL, B renames without refreshing -> B immediately holds both
changes, stays clean, refresh no-ops, store holds both".

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UDsUDSPoDYa1M1rbpgdG3f

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
@BorisTyshkevich
BorisTyshkevich deleted the feat/time-range-control-335 branch August 6, 2026 15:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Dashboard time-range control: token-based From/To picker in the filter bar

1 participant