timeFilterManager.onSelectGrain(undefined)}
>
{defaultOption.label}
@@ -69,12 +67,12 @@
{/if}
- {#each options as option (option)}
+ {#each aggregationOptions as option (option)}
onSelect(option)}
+ onclick={() => timeFilterManager.onSelectGrain(option)}
>
{translateV1TimeGrain(option)}
diff --git a/web-common/src/features/dashboards/time-controls/CanvasTimeFilters.spec.ts b/web-common/src/features/dashboards/time-controls/CanvasTimeFilters.spec.ts
new file mode 100644
index 000000000000..1637ffa65764
--- /dev/null
+++ b/web-common/src/features/dashboards/time-controls/CanvasTimeFilters.spec.ts
@@ -0,0 +1,72 @@
+import type { HoistedPageForComponentTests } from "@rilldata/web-common/features/dashboards/state-managers/loaders/test/PageMockForComponentTests.ts";
+import { useCanvasTimeFiltersVariant } from "@rilldata/web-common/features/dashboards/time-controls/test/canvas-time-filters-variant";
+import {
+ testComparisonTimeRangeFilters,
+ testTimeRangeFilters,
+} from "@rilldata/web-common/features/dashboards/time-controls/test/time-filters-suite";
+import type { ActionResult } from "@sveltejs/kit";
+import { describe, vi } from "vitest";
+
+const hoistedPage: HoistedPageForComponentTests = vi.hoisted(() => ({}) as any);
+
+vi.stubEnv("TZ", "UTC");
+
+vi.mock("$app/navigation", () => {
+ return {
+ goto: (url, opts) => hoistedPage.goto(url, opts),
+ afterNavigate: (cb) => hoistedPage.afterNavigate(cb),
+ onNavigate: () => {},
+ // superforms, which the measure filter form uses, registers a navigation guard for tainted
+ // forms as soon as it is created.
+ beforeNavigate: () => {},
+ };
+});
+vi.mock("$app/forms", async (importOriginal) => {
+ const actual = await importOriginal
();
+ return {
+ ...actual,
+ // The real `applyAction` needs the SvelteKit client runtime, which a component test does not
+ // boot. The measure filter form reads its validation result back from the page, so hand the
+ // result to the page mock instead.
+ applyAction: (result: ActionResult) => {
+ hoistedPage.applyAction(result);
+ return Promise.resolve();
+ },
+ };
+});
+// The rune based url stores read `page` from here, and SvelteKit only populates it through its
+// client router, which a component test does not boot. `PageMockForComponentTests` keeps this in
+// sync with the `$app/stores` page below.
+vi.mock("$app/state", async () => {
+ return {
+ page: (
+ await import(
+ "@rilldata/web-common/features/dashboards/state-managers/loaders/test/page-state.mock.svelte"
+ )
+ ).pageStateMock,
+ };
+});
+vi.mock("$app/stores", () => {
+ return {
+ page: hoistedPage,
+ // superforms cancels a submit that navigates to another route, so it subscribes to this while
+ // the measure filter form is submitting. Nothing here navigates away from the dashboard.
+ navigating: {
+ subscribe: (run: (value: null) => void) => {
+ run(null);
+ return () => {};
+ },
+ },
+ };
+});
+
+// Tests time filter interactions on a canvas dashboard, where `CanvasProvider` resolves the canvas
+// and the time filter bar writes to the canvas entity's time filter manager and the url.
+//
+// The test bodies live in `test/time-filters-suite.ts`, which the explore dashboard runs as well.
+describe("CanvasTimeFilters", () => {
+ const variant = useCanvasTimeFiltersVariant(hoistedPage);
+
+ testTimeRangeFilters(variant);
+ testComparisonTimeRangeFilters(variant);
+});
diff --git a/web-common/src/features/dashboards/time-controls/ExploreTimeFilters.spec.ts b/web-common/src/features/dashboards/time-controls/ExploreTimeFilters.spec.ts
new file mode 100644
index 000000000000..4e3f424b5e9a
--- /dev/null
+++ b/web-common/src/features/dashboards/time-controls/ExploreTimeFilters.spec.ts
@@ -0,0 +1,74 @@
+import type { HoistedPageForComponentTests } from "@rilldata/web-common/features/dashboards/state-managers/loaders/test/PageMockForComponentTests.ts";
+import { useExploreTimeFiltersVariant } from "@rilldata/web-common/features/dashboards/time-controls/test/explore-time-filters-variant";
+import {
+ testComparisonTimeRangeFilters,
+ testTimeRangeFilters,
+} from "@rilldata/web-common/features/dashboards/time-controls/test/time-filters-suite";
+import type { ActionResult } from "@sveltejs/kit";
+import { describe, vi } from "vitest";
+
+const hoistedPage: HoistedPageForComponentTests = vi.hoisted(() => ({}) as any);
+
+vi.stubEnv("TZ", "UTC");
+
+vi.mock("$app/navigation", () => {
+ return {
+ goto: (url, opts) => hoistedPage.goto(url, opts),
+ afterNavigate: (cb) => hoistedPage.afterNavigate(cb),
+ onNavigate: () => {},
+ // superforms, which the measure filter form uses, registers a navigation guard for tainted
+ // forms as soon as it is created.
+ beforeNavigate: () => {},
+ };
+});
+vi.mock("$app/forms", async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ // The real `applyAction` needs the SvelteKit client runtime, which a component test does not
+ // boot. The measure filter form reads its validation result back from the page, so hand the
+ // result to the page mock instead.
+ applyAction: (result: ActionResult) => {
+ hoistedPage.applyAction(result);
+ return Promise.resolve();
+ },
+ };
+});
+// The rune based url stores read `page` from here, and SvelteKit only populates it through its
+// client router, which a component test does not boot. `PageMockForComponentTests` keeps this in
+// sync with the `$app/stores` page below.
+vi.mock("$app/state", async () => {
+ return {
+ page: (
+ await import(
+ "@rilldata/web-common/features/dashboards/state-managers/loaders/test/page-state.mock.svelte"
+ )
+ ).pageStateMock,
+ };
+});
+vi.mock("$app/stores", () => {
+ return {
+ page: hoistedPage,
+ // superforms cancels a submit that navigates to another route, so it subscribes to this while
+ // the measure filter form is submitting. Nothing here navigates away from the dashboard.
+ navigating: {
+ subscribe: (run: (value: null) => void) => {
+ run(null);
+ return () => {};
+ },
+ },
+ };
+});
+
+// Tests time filter interactions on the explore dashboard, where the time filter bar writes to the
+// explore state and the url. Loading state from other sources is covered by
+// DashboardStateManager.spec.ts.
+//
+// The test bodies live in `test/time-filters-suite.ts`, so that the other dashboards embedding the
+// same time filter bar can run them as well.
+describe("ExploreTimeFilters", () => {
+ const variant = useExploreTimeFiltersVariant(hoistedPage);
+
+ testTimeRangeFilters(variant);
+ testComparisonTimeRangeFilters(variant);
+});
diff --git a/web-common/src/features/dashboards/time-controls/ReadonlyTimeFilters.svelte b/web-common/src/features/dashboards/time-controls/ReadonlyTimeFilters.svelte
new file mode 100644
index 000000000000..f0aa133acf5b
--- /dev/null
+++ b/web-common/src/features/dashboards/time-controls/ReadonlyTimeFilters.svelte
@@ -0,0 +1,75 @@
+
+
+{#if timeRange}
+
+
+
+
+ {#if showRange}
+ {m.time_custom()}
+ {:else}
+ {selectedLabel}
+ {/if}
+
+ {#if showRange && interval}
+
+ {/if}
+
+
+
+
+ {#if showComparison && comparisonTimeRange && parsedComparisonTime}
+
+
+
+ {m.time_vs()}
+ {#if showComparisonRange && comparisonInterval}
+
+ {:else}
+
+ {getComparisonLabelFromRange(
+ comparisonTimeRange,
+ parsedComparisonTime,
+ )}
+
+ {/if}
+
+
+
+ {/if}
+{/if}
diff --git a/web-common/src/features/dashboards/time-controls/TimeFilterManager.spec.ts b/web-common/src/features/dashboards/time-controls/TimeFilterManager.spec.ts
new file mode 100644
index 000000000000..9c319630ad16
--- /dev/null
+++ b/web-common/src/features/dashboards/time-controls/TimeFilterManager.spec.ts
@@ -0,0 +1,1106 @@
+import { YAMLConfigProvider } from "@rilldata/web-common/features/dashboards/providers/YAMLConfigProvider.svelte.ts";
+import {
+ AD_BIDS_METRICS_INIT,
+ AD_BIDS_METRICS_INIT_WITH_TIME,
+ AD_BIDS_METRICS_NAME,
+ AD_BIDS_TIMESTAMP_DIMENSION,
+} from "@rilldata/web-common/features/dashboards/stores/test-data/data";
+import {
+ deriveInterval,
+ INHERIT_TIME_RANGE_ALIAS,
+} from "@rilldata/web-common/features/dashboards/time-controls/new-time-controls.ts";
+import {
+ DEFAULT_TIME_RANGE,
+ RESOLVED_RILL_TIMES,
+ TIME_RANGE_SUMMARY,
+} from "@rilldata/web-common/features/dashboards/time-controls/test/rill-time-mocks";
+import type { TimeFiltersConfig } from "@rilldata/web-common/features/dashboards/time-controls/time-filters-config.ts";
+import { TimeFilterManager } from "@rilldata/web-common/features/dashboards/time-controls/TimeFilterManager.svelte.ts";
+import { ExploreStateURLParams } from "@rilldata/web-common/features/dashboards/url-state/url-params.ts";
+import { MetricsViewsProvider } from "@rilldata/web-common/features/metrics-views/providers/MetricsViewsProvider.svelte.ts";
+import {
+ createInEffectRoot,
+ renderInRuntimeContext,
+ useMetricsViewMocks,
+} from "@rilldata/web-common/features/metrics-views/providers/test/metrics-views-test-utils.svelte.ts";
+import { queryClient } from "@rilldata/web-common/lib/svelte-query/globalQueryClient.ts";
+import {
+ TimeComparisonOption,
+ TimeRangePreset,
+} from "@rilldata/web-common/lib/time/types.ts";
+import { asyncWait, waitUntil } from "@rilldata/web-common/lib/waitUtils.ts";
+import { V1TimeGrain } from "@rilldata/web-common/runtime-client";
+import { DateTime } from "luxon";
+import { get } from "svelte/store";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+vi.stubEnv("TZ", "UTC");
+
+// Wrapped so that a test can tell whether a range was resolved again.
+vi.mock(
+ "@rilldata/web-common/features/dashboards/time-controls/new-time-controls.ts",
+ async (importOriginal) => {
+ const actual =
+ await importOriginal<
+ typeof import("@rilldata/web-common/features/dashboards/time-controls/new-time-controls.ts")
+ >();
+ return { ...actual, deriveInterval: vi.fn(actual.deriveInterval) };
+ },
+);
+
+// ---------------------------------------------------------------------------
+// Test metrics views
+//
+// AdBids has a time dimension and resolves rilltime expressions to the intervals in
+// `RESOLVED_RILL_TIMES`. Its mirror resolves the default range to a window ending a day later,
+// which makes the choice of interval across metrics views observable.
+// The third metrics view has no time dimension at all.
+// ---------------------------------------------------------------------------
+
+const AD_BIDS_MIRROR_METRICS_NAME = "AdBids_mirror_metrics";
+const AD_BIDS_NO_TIME_METRICS_NAME = "AdBids_no_time_metrics";
+
+const mocks = useMetricsViewMocks({
+ [AD_BIDS_METRICS_NAME]: AD_BIDS_METRICS_INIT_WITH_TIME,
+ [AD_BIDS_MIRROR_METRICS_NAME]: AD_BIDS_METRICS_INIT_WITH_TIME,
+ [AD_BIDS_NO_TIME_METRICS_NAME]: AD_BIDS_METRICS_INIT,
+});
+mocks.mockTimeRangeSummary(AD_BIDS_METRICS_NAME, TIME_RANGE_SUMMARY);
+mocks.mockResolvedRillTimes(AD_BIDS_METRICS_NAME, {
+ ...RESOLVED_RILL_TIMES,
+ // The default picked from the time range summary when the yaml has none:
+ // the summary spans about 90 days, which lands on quarter to date.
+ [TimeRangePreset.QUARTER_TO_DATE]: {
+ start: "2024-01-01T00:00:00.000Z",
+ end: "2024-04-01T00:00:00.000Z",
+ grain: V1TimeGrain.TIME_GRAIN_DAY,
+ },
+});
+mocks.mockTimeRangeSummary(AD_BIDS_MIRROR_METRICS_NAME, TIME_RANGE_SUMMARY);
+mocks.mockResolvedRillTimes(AD_BIDS_MIRROR_METRICS_NAME, {
+ [DEFAULT_TIME_RANGE]: {
+ start: "2024-03-26T00:00:00.000Z",
+ end: "2024-04-02T00:00:00.000Z",
+ grain: V1TimeGrain.TIME_GRAIN_DAY,
+ },
+});
+
+// Each test gets its own provider and manager.
+// The queries behind the provider are cached globally, so only the first provider waits on fetches.
+let metricsViewsProvider: MetricsViewsProvider;
+const cleanups: (() => void)[] = [];
+
+beforeEach(async () => {
+ metricsViewsProvider = await createReadyMetricsViewsProvider([
+ AD_BIDS_METRICS_NAME,
+ ]);
+});
+
+afterEach(() => {
+ cleanups.splice(0).forEach((destroy) => destroy());
+});
+
+// ---------------------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------------------
+
+type CreateOptions = {
+ provider?: MetricsViewsProvider;
+ yamlConfigProvider?: YAMLConfigProvider;
+ config?: TimeFiltersConfig;
+ parent?: TimeFilterManager;
+};
+
+/**
+ * A provider over `metricsViewNames`, torn down after the test.
+ * Resolves once the specs and the time range summaries have landed.
+ */
+async function createReadyMetricsViewsProvider(metricsViewNames: string[]) {
+ // MetricsViewsProvider calls createQuery, so it has to be created inside a component.
+ const rendered = renderInRuntimeContext(
+ ({ runtimeClient }) =>
+ new MetricsViewsProvider(runtimeClient, metricsViewNames),
+ );
+ cleanups.push(() => {
+ rendered.value.cleanup();
+ rendered.destroy();
+ });
+ expect(await waitUntil(() => rendered.value.ready, 2000, 5)).toBe(true);
+ return rendered.value;
+}
+
+/** A yaml config with the default time range the dashboards in `rill-time-mocks.ts` start on. */
+function yamlWithDefaultTimeRange() {
+ const yamlConfigProvider = new YAMLConfigProvider();
+ yamlConfigProvider.defaultTimeRange = DEFAULT_TIME_RANGE;
+ return yamlConfigProvider;
+}
+
+/** A real time filter manager over a ready provider, torn down after the test. */
+function createTimeFilterManager({
+ provider = metricsViewsProvider,
+ yamlConfigProvider = yamlWithDefaultTimeRange(),
+ config = {},
+ parent,
+}: CreateOptions = {}) {
+ const { value, destroy } = createInEffectRoot(
+ () =>
+ new TimeFilterManager(
+ provider.runtimeClient,
+ provider,
+ yamlConfigProvider,
+ config,
+ parent,
+ ),
+ );
+ cleanups.push(destroy);
+ return value;
+}
+
+async function waitForReady(manager: TimeFilterManager) {
+ expect(await waitUntil(() => manager.ready, 2000, 5)).toBe(true);
+}
+
+/** Resolves once `timeRange` has been resolved by the runtime and applied. */
+async function waitForTimeRange(
+ manager: TimeFilterManager,
+ timeRange: string | undefined,
+) {
+ const applied = await waitUntil(
+ () => manager.timeRange === timeRange && !!manager.interval,
+ 2000,
+ 5,
+ );
+ expect(applied, `time range to be "${timeRange}"`).toBe(true);
+}
+
+/** Records every param set the manager reports to the url. */
+function recordChanges(manager: TimeFilterManager) {
+ const changes: Record[] = [];
+ cleanups.push(
+ manager.storeSync.on("change", (params) => {
+ changes.push(Object.fromEntries(params));
+ }),
+ );
+ return changes;
+}
+
+/** `stateChanged` reports on the next microtask. */
+function flushStateChanges() {
+ return asyncWait(0);
+}
+
+function paramsOf(manager: TimeFilterManager) {
+ const params = new URLSearchParams();
+ manager.applyFilterToParams(params);
+ return Object.fromEntries(params);
+}
+
+function isoOf(dateTime: DateTime | undefined) {
+ return dateTime?.toJSDate().toISOString();
+}
+
+function intervalOf(manager: TimeFilterManager) {
+ return {
+ start: isoOf(manager.interval?.start),
+ end: isoOf(manager.interval?.end),
+ };
+}
+
+function comparisonIntervalOf(manager: TimeFilterManager) {
+ return {
+ start: isoOf(manager.comparisonInterval?.start),
+ end: isoOf(manager.comparisonInterval?.end),
+ };
+}
+
+function resolvedIntervalOf(timeRange: string) {
+ const { start, end } = RESOLVED_RILL_TIMES[timeRange];
+ return { start, end };
+}
+
+// ---------------------------------------------------------------------------
+// Tests
+// ---------------------------------------------------------------------------
+
+describe("setUrlParams", () => {
+ it("applies the yaml default time range when the url has none", async () => {
+ const manager = createTimeFilterManager();
+
+ // The tracker skips params equal to the ones it holds, and it starts out empty,
+ // so an empty url goes to the manager directly.
+ manager.setUrlParams(new URLSearchParams());
+ await waitForTimeRange(manager, DEFAULT_TIME_RANGE);
+
+ expect(manager.urlTimeRange).toBe(DEFAULT_TIME_RANGE);
+ expect(intervalOf(manager)).toEqual(resolvedIntervalOf(DEFAULT_TIME_RANGE));
+ // The grain comes from the snap of the `as of` clause.
+ expect(manager.timeGrain).toBe(V1TimeGrain.TIME_GRAIN_DAY);
+ expect(manager.apiTimeRange).toEqual({
+ ...resolvedIntervalOf(DEFAULT_TIME_RANGE),
+ timeZone: "UTC",
+ timeDimension: undefined,
+ });
+ });
+
+ it("resolves the time range in the url", async () => {
+ const manager = createTimeFilterManager();
+
+ manager.storeSync.setUrlParams(
+ new URLSearchParams({ tr: "4W as of latest/D+1D" }),
+ );
+ await waitForTimeRange(manager, "4W as of latest/D+1D");
+
+ expect(intervalOf(manager)).toEqual(
+ resolvedIntervalOf("4W as of latest/D+1D"),
+ );
+ expect(manager.timeGrain).toBe(V1TimeGrain.TIME_GRAIN_DAY);
+ });
+
+ it("reads the grain, time zone and time dimension from the url", async () => {
+ const manager = createTimeFilterManager();
+
+ manager.storeSync.setUrlParams(
+ new URLSearchParams({
+ tr: "4W as of latest/D+1D",
+ grain: "week",
+ tz: "America/New_York",
+ td: AD_BIDS_TIMESTAMP_DIMENSION,
+ }),
+ );
+ await waitForTimeRange(manager, "4W as of latest/D+1D");
+
+ // Week is a grain the 4 week interval allows, so the url grain wins over the snap.
+ expect(manager.timeGrain).toBe(V1TimeGrain.TIME_GRAIN_WEEK);
+ expect(manager.timeZone).toBe("America/New_York");
+ expect(manager.interval?.start.zoneName).toBe("America/New_York");
+ expect(manager.apiTimeRange).toEqual({
+ ...resolvedIntervalOf("4W as of latest/D+1D"),
+ timeZone: "America/New_York",
+ timeDimension: AD_BIDS_TIMESTAMP_DIMENSION,
+ });
+ });
+
+ it("falls back to the snap grain when the url grain is not allowed for the interval", async () => {
+ const manager = createTimeFilterManager();
+
+ manager.storeSync.setUrlParams(
+ new URLSearchParams({ tr: "12h as of latest/h+1h", grain: "month" }),
+ );
+ await waitForTimeRange(manager, "12h as of latest/h+1h");
+
+ expect(manager.timeGrain).toBe(V1TimeGrain.TIME_GRAIN_HOUR);
+ });
+
+ it("falls back to the yaml default time zone", async () => {
+ const yamlConfigProvider = yamlWithDefaultTimeRange();
+ yamlConfigProvider.defaultTimeZone = "Asia/Kathmandu";
+ const manager = createTimeFilterManager({ yamlConfigProvider });
+
+ manager.setUrlParams(new URLSearchParams());
+ await waitForTimeRange(manager, DEFAULT_TIME_RANGE);
+
+ expect(manager.timeZone).toBe("Asia/Kathmandu");
+ expect(manager.apiTimeRange.timeZone).toBe("Asia/Kathmandu");
+ });
+
+ it("computes the contiguous comparison even when comparison is off", async () => {
+ const manager = createTimeFilterManager();
+
+ manager.setUrlParams(new URLSearchParams());
+ await waitForTimeRange(manager, DEFAULT_TIME_RANGE);
+
+ expect(manager.showComparison).toBe(false);
+ expect(manager.comparisonTimeRange).toBe(TimeComparisonOption.CONTIGUOUS);
+ // The 7 days right before the selected ones.
+ expect(comparisonIntervalOf(manager)).toEqual({
+ start: "2024-03-18T00:00:00.000Z",
+ end: "2024-03-25T00:00:00.000Z",
+ });
+ expect(manager.apiComparisonTimeRange).toEqual({
+ start: "2024-03-18T00:00:00.000Z",
+ end: "2024-03-25T00:00:00.000Z",
+ timeZone: "UTC",
+ });
+ });
+
+ it("turns comparison on from the url", async () => {
+ const manager = createTimeFilterManager();
+
+ manager.storeSync.setUrlParams(
+ new URLSearchParams({
+ tr: DEFAULT_TIME_RANGE,
+ compare_tr: TimeComparisonOption.WEEK,
+ }),
+ );
+ await waitForTimeRange(manager, DEFAULT_TIME_RANGE);
+
+ expect(manager.showComparison).toBe(true);
+ expect(manager.comparisonTimeRange).toBe(TimeComparisonOption.WEEK);
+ expect(comparisonIntervalOf(manager)).toEqual({
+ start: "2024-03-18T00:00:00.000Z",
+ end: "2024-03-25T00:00:00.000Z",
+ });
+ });
+
+ it("leaves the time range empty with skipDefaultTimeRange", async () => {
+ const manager = createTimeFilterManager({
+ config: { skipDefaultTimeRange: true },
+ });
+
+ manager.setUrlParams(new URLSearchParams());
+ await asyncWait(20);
+
+ expect(manager.urlTimeRange).toBeUndefined();
+ expect(manager.timeRange).toBeUndefined();
+ expect(manager.interval).toBeUndefined();
+ expect(manager.apiTimeRange.start).toBeUndefined();
+ expect(manager.apiTimeRange.end).toBeUndefined();
+ });
+
+ it("clears a previous time range once the url drops it with skipDefaultTimeRange", async () => {
+ const manager = createTimeFilterManager({
+ config: { skipDefaultTimeRange: true },
+ });
+
+ manager.storeSync.setUrlParams(
+ new URLSearchParams({ tr: DEFAULT_TIME_RANGE }),
+ );
+ await waitForTimeRange(manager, DEFAULT_TIME_RANGE);
+
+ manager.storeSync.setUrlParams(new URLSearchParams());
+
+ expect(manager.timeRange).toBeUndefined();
+ expect(manager.interval).toBeUndefined();
+ });
+
+ it("reads the highlighted range from the url", async () => {
+ const manager = createTimeFilterManager();
+
+ manager.storeSync.setUrlParams(
+ new URLSearchParams({
+ tr: DEFAULT_TIME_RANGE,
+ highlighted_tr: "2024-03-26T00:00:00.000Z,2024-03-27T00:00:00.000Z",
+ }),
+ );
+ await waitForTimeRange(manager, DEFAULT_TIME_RANGE);
+
+ // Applying the time range clears the scrub, which must not drop the one in the url.
+ expect(manager.timeStart).toBe("2024-03-26T00:00:00.000Z");
+ expect(manager.timeEnd).toBe("2024-03-27T00:00:00.000Z");
+ expect(manager.scrubInterval?.isScrubbing).toBe(false);
+ // The selected range itself is untouched.
+ expect(intervalOf(manager)).toEqual(resolvedIntervalOf(DEFAULT_TIME_RANGE));
+ expect(paramsOf(manager).highlighted_tr).toBe(
+ "2024-03-26T00:00:00.000Z,2024-03-27T00:00:00.000Z",
+ );
+ });
+
+ it("clears the highlighted range once the url drops it", async () => {
+ const manager = createTimeFilterManager({
+ config: { skipDefaultTimeRange: true },
+ });
+ manager.storeSync.setUrlParams(
+ new URLSearchParams({
+ tr: DEFAULT_TIME_RANGE,
+ highlighted_tr: "2024-03-26T00:00:00.000Z,2024-03-27T00:00:00.000Z",
+ }),
+ );
+ await waitForTimeRange(manager, DEFAULT_TIME_RANGE);
+
+ // No time range is applied here, so nothing else clears the scrub.
+ manager.storeSync.setUrlParams(new URLSearchParams({ grain: "day" }));
+
+ expect(manager.scrubInterval).toBeUndefined();
+ expect(manager.lastDefinedScrubInterval).toBeUndefined();
+ });
+
+ it("ignores the url grain with skipTimeGrain", async () => {
+ const manager = createTimeFilterManager({
+ config: { skipTimeGrain: true },
+ });
+
+ manager.storeSync.setUrlParams(
+ new URLSearchParams({ tr: "4W as of latest/D+1D", grain: "week" }),
+ );
+ await waitForTimeRange(manager, "4W as of latest/D+1D");
+
+ // The resolved grain is still tracked, it is just not read from or written to the url.
+ expect(manager.timeGrain).toBe(V1TimeGrain.TIME_GRAIN_DAY);
+ expect(paramsOf(manager)).not.toHaveProperty(
+ ExploreStateURLParams.TimeGrain,
+ );
+ });
+});
+
+describe("delayed load", () => {
+ /**
+ * A manager created before its provider has any data, as on a dashboard's first load.
+ * `beforeSpecsLoad` runs right after the manager is created.
+ */
+ function createBeforeLoad(
+ yamlConfigProvider: YAMLConfigProvider,
+ beforeSpecsLoad: (manager: TimeFilterManager) => void,
+ ) {
+ // Drop the responses cached by the `beforeEach` provider so that this one has to fetch.
+ queryClient.clear();
+ const rendered = renderInRuntimeContext(({ runtimeClient }) => {
+ const provider = new MetricsViewsProvider(runtimeClient, [
+ AD_BIDS_METRICS_NAME,
+ ]);
+ const manager = new TimeFilterManager(
+ runtimeClient,
+ provider,
+ yamlConfigProvider,
+ );
+ beforeSpecsLoad(manager);
+ return { provider, manager };
+ });
+ cleanups.push(() => {
+ rendered.value.provider.cleanup();
+ rendered.destroy();
+ });
+ return rendered.value.manager;
+ }
+
+ it("holds the url params until the time range summary lands", async () => {
+ const manager = createBeforeLoad(yamlWithDefaultTimeRange(), (manager) =>
+ manager.storeSync.setUrlParams(
+ new URLSearchParams({ tr: "4W as of latest/D+1D", grain: "week" }),
+ ),
+ );
+ expect(manager.ready).toBe(false);
+ expect(manager.urlTimeRange).toBeUndefined();
+
+ await waitForReady(manager);
+ await waitForTimeRange(manager, "4W as of latest/D+1D");
+ expect(manager.timeGrain).toBe(V1TimeGrain.TIME_GRAIN_WEEK);
+ });
+
+ it("picks the default time range from the time range summary once it lands", async () => {
+ // Without a yaml default, the default depends on the span of the data,
+ // so it cannot be picked before the summary is loaded.
+ const manager = createBeforeLoad(new YAMLConfigProvider(), (manager) =>
+ manager.storeSync.setUrlParams(
+ new URLSearchParams({ tz: "America/New_York" }),
+ ),
+ );
+ expect(manager.ready).toBe(false);
+
+ await waitForReady(manager);
+ await waitForTimeRange(manager, TimeRangePreset.QUARTER_TO_DATE);
+ expect(manager.timeZone).toBe("America/New_York");
+ expect(intervalOf(manager)).toEqual({
+ start: "2024-01-01T00:00:00.000Z",
+ end: "2024-04-01T00:00:00.000Z",
+ });
+ });
+});
+
+describe("applyFilterToParams", () => {
+ it("writes the time params back", async () => {
+ const manager = createTimeFilterManager();
+ const params = {
+ tr: "4W as of latest/D+1D",
+ grain: "week",
+ tz: "America/New_York",
+ td: AD_BIDS_TIMESTAMP_DIMENSION,
+ compare_tr: TimeComparisonOption.WEEK,
+ };
+
+ manager.storeSync.setUrlParams(new URLSearchParams(params));
+ await waitForTimeRange(manager, "4W as of latest/D+1D");
+
+ expect(paramsOf(manager)).toEqual(params);
+ });
+
+ it("omits the default time zone and a comparison that is off", async () => {
+ const manager = createTimeFilterManager();
+
+ manager.storeSync.setUrlParams(
+ new URLSearchParams({ tr: DEFAULT_TIME_RANGE, tz: "UTC" }),
+ );
+ await waitForTimeRange(manager, DEFAULT_TIME_RANGE);
+
+ // The comparison range is still set internally, it is just not shown.
+ expect(manager.comparisonTimeRange).toBe(TimeComparisonOption.CONTIGUOUS);
+ expect(paramsOf(manager)).toEqual({
+ tr: DEFAULT_TIME_RANGE,
+ grain: "day",
+ });
+ });
+
+ it("removes params the manager no longer holds", () => {
+ const manager = createTimeFilterManager({
+ config: { skipDefaultTimeRange: true },
+ });
+ manager.setUrlParams(new URLSearchParams());
+
+ const searchParams = new URLSearchParams({
+ tr: DEFAULT_TIME_RANGE,
+ tz: "America/New_York",
+ td: AD_BIDS_TIMESTAMP_DIMENSION,
+ compare_tr: TimeComparisonOption.WEEK,
+ highlighted_tr: "2024-03-26T00:00:00.000Z,2024-03-27T00:00:00.000Z",
+ // Not a time param, so it is left alone.
+ view: "tdd",
+ });
+ manager.applyFilterToParams(searchParams);
+
+ expect(Object.fromEntries(searchParams)).toEqual({ view: "tdd" });
+ });
+});
+
+describe("onSelectRange", () => {
+ it("keeps the snap of the current range for a coarser range", async () => {
+ const manager = createTimeFilterManager();
+ manager.setUrlParams(new URLSearchParams());
+ await waitForTimeRange(manager, DEFAULT_TIME_RANGE);
+ const changes = recordChanges(manager);
+
+ await manager.onSelectRange("4W");
+ await flushStateChanges();
+
+ expect(manager.timeRange).toBe("4W as of latest/D+1D");
+ expect(intervalOf(manager)).toEqual(
+ resolvedIntervalOf("4W as of latest/D+1D"),
+ );
+ expect(manager.timeGrain).toBe(V1TimeGrain.TIME_GRAIN_DAY);
+ // One url update per selection.
+ expect(changes).toEqual([{ tr: "4W as of latest/D+1D", grain: "day" }]);
+ });
+
+ it("narrows the snap to the grain of a finer range", async () => {
+ const manager = createTimeFilterManager();
+ manager.setUrlParams(new URLSearchParams());
+ await waitForTimeRange(manager, DEFAULT_TIME_RANGE);
+
+ await manager.onSelectRange("12h");
+
+ expect(manager.timeRange).toBe("12h as of latest/h+1h");
+ expect(manager.timeGrain).toBe(V1TimeGrain.TIME_GRAIN_HOUR);
+ });
+
+ it("recomputes the comparison for the new range", async () => {
+ const manager = createTimeFilterManager();
+ manager.storeSync.setUrlParams(
+ new URLSearchParams({
+ tr: DEFAULT_TIME_RANGE,
+ compare_tr: TimeComparisonOption.WEEK,
+ }),
+ );
+ await waitForTimeRange(manager, DEFAULT_TIME_RANGE);
+
+ await manager.onSelectRange("24h");
+
+ expect(manager.timeRange).toBe("24h as of latest/h+1h");
+ expect(manager.comparisonTimeRange).toBe(TimeComparisonOption.WEEK);
+ expect(comparisonIntervalOf(manager)).toEqual({
+ start: "2024-03-23T15:00:00.000Z",
+ end: "2024-03-24T15:00:00.000Z",
+ });
+ });
+
+ it("clears the scrubbed range", async () => {
+ const manager = createTimeFilterManager();
+ manager.setUrlParams(new URLSearchParams());
+ await waitForTimeRange(manager, DEFAULT_TIME_RANGE);
+ manager.onScrubRange({
+ start: DateTime.fromISO("2024-03-26T00:00:00.000Z"),
+ end: DateTime.fromISO("2024-03-27T00:00:00.000Z"),
+ isScrubbing: false,
+ });
+
+ await manager.onSelectRange("4W");
+
+ expect(manager.scrubInterval).toBeUndefined();
+ expect(manager.lastDefinedScrubInterval).toBeUndefined();
+ });
+});
+
+describe("onSelectAsOfOption", () => {
+ it("moves the snap offset to the start of the grain", async () => {
+ const manager = createTimeFilterManager();
+ manager.setUrlParams(new URLSearchParams());
+ await waitForTimeRange(manager, DEFAULT_TIME_RANGE);
+
+ await manager.onSelectAsOfOption(manager.ref, false);
+
+ expect(manager.timeRange).toBe("7D as of latest/D");
+ expect(intervalOf(manager)).toEqual(
+ resolvedIntervalOf("7D as of latest/D"),
+ );
+ expect(manager.snapToEnd).toBe(false);
+ });
+});
+
+describe("parsed time range", () => {
+ it("exposes the reference, snap and truncation grain of the range", async () => {
+ const manager = createTimeFilterManager();
+ manager.setUrlParams(new URLSearchParams());
+ await waitForTimeRange(manager, DEFAULT_TIME_RANGE);
+
+ expect(manager.ref).toBe("latest");
+ expect(manager.snapToEnd).toBe(true);
+ expect(manager.truncationGrain).toBe(V1TimeGrain.TIME_GRAIN_DAY);
+ });
+});
+
+describe("onSelectGrain", () => {
+ it("sets the grain and reports it to the url", async () => {
+ const manager = createTimeFilterManager();
+ manager.storeSync.setUrlParams(
+ new URLSearchParams({ tr: "4W as of latest/D+1D" }),
+ );
+ await waitForTimeRange(manager, "4W as of latest/D+1D");
+ const changes = recordChanges(manager);
+
+ manager.onSelectGrain(V1TimeGrain.TIME_GRAIN_WEEK);
+ await flushStateChanges();
+
+ expect(manager.timeGrain).toBe(V1TimeGrain.TIME_GRAIN_WEEK);
+ expect(changes).toEqual([{ tr: "4W as of latest/D+1D", grain: "week" }]);
+ });
+});
+
+describe("onSelectTimeDimension", () => {
+ it("sets the time dimension and reports it to the url", async () => {
+ const manager = createTimeFilterManager();
+ manager.storeSync.setUrlParams(
+ new URLSearchParams({ tr: DEFAULT_TIME_RANGE }),
+ );
+ await waitForTimeRange(manager, DEFAULT_TIME_RANGE);
+ const changes = recordChanges(manager);
+
+ manager.onSelectTimeDimension(AD_BIDS_TIMESTAMP_DIMENSION);
+ await flushStateChanges();
+
+ expect(manager.apiTimeRange.timeDimension).toBe(
+ AD_BIDS_TIMESTAMP_DIMENSION,
+ );
+ expect(changes).toEqual([
+ { tr: DEFAULT_TIME_RANGE, grain: "day", td: AD_BIDS_TIMESTAMP_DIMENSION },
+ ]);
+ });
+
+ it("resolves the range again against the new time dimension", async () => {
+ const manager = createTimeFilterManager();
+ manager.storeSync.setUrlParams(
+ new URLSearchParams({ tr: DEFAULT_TIME_RANGE }),
+ );
+ await waitForTimeRange(manager, DEFAULT_TIME_RANGE);
+ vi.mocked(deriveInterval).mockClear();
+
+ manager.onSelectTimeDimension(AD_BIDS_TIMESTAMP_DIMENSION);
+ await asyncWait(20);
+
+ expect(deriveInterval).toHaveBeenCalledWith(
+ DEFAULT_TIME_RANGE,
+ expect.anything(),
+ AD_BIDS_METRICS_NAME,
+ "UTC",
+ AD_BIDS_TIMESTAMP_DIMENSION,
+ undefined,
+ );
+ });
+});
+
+describe("onSelectZone", () => {
+ it("sets the time zone and reports it to the url", async () => {
+ const manager = createTimeFilterManager();
+ manager.storeSync.setUrlParams(
+ new URLSearchParams({ tr: DEFAULT_TIME_RANGE }),
+ );
+ await waitForTimeRange(manager, DEFAULT_TIME_RANGE);
+ const changes = recordChanges(manager);
+
+ manager.onSelectZone("America/New_York");
+ await flushStateChanges();
+
+ expect(manager.timeZone).toBe("America/New_York");
+ expect(manager.apiTimeRange.timeZone).toBe("America/New_York");
+ expect(changes).toEqual([
+ { tr: DEFAULT_TIME_RANGE, grain: "day", tz: "America/New_York" },
+ ]);
+ });
+
+ it("resolves the range again in the new time zone", async () => {
+ const manager = createTimeFilterManager();
+ manager.storeSync.setUrlParams(
+ new URLSearchParams({ tr: DEFAULT_TIME_RANGE }),
+ );
+ await waitForTimeRange(manager, DEFAULT_TIME_RANGE);
+
+ manager.onSelectZone("America/New_York");
+ await asyncWait(20);
+
+ expect(manager.interval?.start.zoneName).toBe("America/New_York");
+ });
+});
+
+describe("pan", () => {
+ it("allows panning both ways for a range inside the data", async () => {
+ const manager = createTimeFilterManager();
+ manager.storeSync.setUrlParams(
+ new URLSearchParams({ tr: "7D as of latest/D" }),
+ );
+ await waitForTimeRange(manager, "7D as of latest/D");
+
+ // 2024-03-24 to 2024-03-31, inside 2024-01-01 to 2024-03-31T14:30.
+ expect(manager.canPanLeft).toBe(true);
+ expect(manager.canPanRight).toBe(true);
+ });
+
+ it("does not allow panning right past the end of the data", async () => {
+ const manager = createTimeFilterManager();
+ manager.storeSync.setUrlParams(
+ new URLSearchParams({ tr: DEFAULT_TIME_RANGE }),
+ );
+ await waitForTimeRange(manager, DEFAULT_TIME_RANGE);
+
+ // Padded to the end of the day, so it ends after the data does.
+ expect(manager.canPanLeft).toBe(true);
+ expect(manager.canPanRight).toBe(false);
+ });
+});
+
+describe("comparison", () => {
+ it("onSelectComparisonRange turns comparison on with the selected range", async () => {
+ const manager = createTimeFilterManager();
+ manager.storeSync.setUrlParams(
+ new URLSearchParams({ tr: DEFAULT_TIME_RANGE }),
+ );
+ await waitForTimeRange(manager, DEFAULT_TIME_RANGE);
+ const changes = recordChanges(manager);
+
+ manager.onSelectComparisonRange(TimeComparisonOption.WEEK);
+ await flushStateChanges();
+
+ expect(manager.showComparison).toBe(true);
+ expect(manager.comparisonTimeRange).toBe(TimeComparisonOption.WEEK);
+ expect(comparisonIntervalOf(manager)).toEqual({
+ start: "2024-03-18T00:00:00.000Z",
+ end: "2024-03-25T00:00:00.000Z",
+ });
+ expect(changes).toEqual([
+ {
+ tr: DEFAULT_TIME_RANGE,
+ grain: "day",
+ compare_tr: TimeComparisonOption.WEEK,
+ },
+ ]);
+ });
+
+ it("onToggleShowComparison and setShowComparison flip the comparison", async () => {
+ const manager = createTimeFilterManager();
+ manager.storeSync.setUrlParams(
+ new URLSearchParams({ tr: DEFAULT_TIME_RANGE }),
+ );
+ await waitForTimeRange(manager, DEFAULT_TIME_RANGE);
+ const changes = recordChanges(manager);
+
+ manager.onToggleShowComparison();
+ await flushStateChanges();
+ expect(manager.showComparison).toBe(true);
+
+ manager.setShowComparison(false);
+ await flushStateChanges();
+ expect(manager.showComparison).toBe(false);
+
+ expect(changes).toEqual([
+ {
+ tr: DEFAULT_TIME_RANGE,
+ grain: "day",
+ compare_tr: TimeComparisonOption.CONTIGUOUS,
+ },
+ { tr: DEFAULT_TIME_RANGE, grain: "day" },
+ ]);
+ });
+
+ it("switches to the contiguous comparison for an absolute time range", async () => {
+ const manager = createTimeFilterManager();
+ manager.setUrlParams(new URLSearchParams());
+ await waitForTimeRange(manager, DEFAULT_TIME_RANGE);
+ // Absolute ranges are not resolved by the runtime mock, so set the range directly.
+ manager.timeRange = "2024-03-01T00:00:00Z to 2024-03-08T00:00:00Z";
+
+ manager.onSelectComparisonRange(TimeComparisonOption.WEEK);
+
+ expect(manager.comparisonTimeRange).toBe(TimeComparisonOption.CONTIGUOUS);
+ });
+
+ it("offers the comparisons that fit in the data", async () => {
+ const manager = createTimeFilterManager();
+ manager.setUrlParams(new URLSearchParams());
+ await waitForTimeRange(manager, DEFAULT_TIME_RANGE);
+
+ // For 7 days, the previous period is the previous week, so only the latter is listed.
+ // The previous day is shorter than the range,
+ // and the previous quarter and year start before the data does on 2024-01-01.
+ expect(manager.comparisonTimeRangeOptions.map(({ name }) => name)).toEqual([
+ TimeComparisonOption.WEEK,
+ TimeComparisonOption.MONTH,
+ ]);
+ });
+
+ it("offers only the yaml comparisons for a yaml time range", async () => {
+ const yamlConfigProvider = yamlWithDefaultTimeRange();
+ yamlConfigProvider.timeRanges = [
+ {
+ range: DEFAULT_TIME_RANGE,
+ comparisonTimeRanges: [
+ { offset: TimeComparisonOption.WEEK },
+ { offset: TimeComparisonOption.DAY },
+ ],
+ },
+ ];
+ const manager = createTimeFilterManager({ yamlConfigProvider });
+ manager.setUrlParams(new URLSearchParams());
+ await waitForTimeRange(manager, DEFAULT_TIME_RANGE);
+
+ expect(
+ manager.comparisonTimeRangeOptions.map(({ name }) => name),
+ // The previous day is still dropped, since it is shorter than the range.
+ ).toEqual([TimeComparisonOption.WEEK]);
+ });
+
+ it("offers the custom comparison with allowCustomTimeRange", async () => {
+ const manager = createTimeFilterManager({
+ config: { allowCustomTimeRange: true },
+ });
+ manager.setUrlParams(new URLSearchParams());
+ await waitForTimeRange(manager, DEFAULT_TIME_RANGE);
+
+ expect(
+ manager.comparisonTimeRangeOptions.map(({ name }) => name),
+ ).toContain(TimeComparisonOption.CUSTOM);
+ });
+});
+
+describe("scrub", () => {
+ it("records the range only once scrubbing ends", async () => {
+ const manager = createTimeFilterManager();
+ manager.setUrlParams(new URLSearchParams());
+ await waitForTimeRange(manager, DEFAULT_TIME_RANGE);
+ const changes = recordChanges(manager);
+ const start = DateTime.fromISO("2024-03-26T00:00:00.000Z");
+ const end = DateTime.fromISO("2024-03-27T00:00:00.000Z");
+
+ manager.onScrubRange({ start, end, isScrubbing: true });
+ await flushStateChanges();
+
+ expect(manager.scrubInterval).toEqual({ start, end, isScrubbing: true });
+ expect(manager.lastDefinedScrubInterval).toBeUndefined();
+ expect(changes).toEqual([]);
+
+ manager.onScrubRange({ start, end, isScrubbing: false });
+ await flushStateChanges();
+
+ expect(isoOf(manager.lastDefinedScrubInterval?.start)).toBe(
+ "2024-03-26T00:00:00.000Z",
+ );
+ expect(isoOf(manager.lastDefinedScrubInterval?.end)).toBe(
+ "2024-03-27T00:00:00.000Z",
+ );
+ // The scrubbed range narrows the range queries use.
+ expect(manager.timeStart).toBe("2024-03-26T00:00:00.000Z");
+ expect(manager.timeEnd).toBe("2024-03-27T00:00:00.000Z");
+ expect(changes).toEqual([
+ {
+ tr: DEFAULT_TIME_RANGE,
+ grain: "day",
+ highlighted_tr: "2024-03-26T00:00:00.000Z,2024-03-27T00:00:00.000Z",
+ },
+ ]);
+ });
+
+ it("orders a range scrubbed right to left", async () => {
+ const manager = createTimeFilterManager();
+ manager.setUrlParams(new URLSearchParams());
+ await waitForTimeRange(manager, DEFAULT_TIME_RANGE);
+
+ manager.onScrubRange({
+ start: DateTime.fromISO("2024-03-27T00:00:00.000Z"),
+ end: DateTime.fromISO("2024-03-26T00:00:00.000Z"),
+ isScrubbing: false,
+ });
+
+ expect(manager.timeStart).toBe("2024-03-26T00:00:00.000Z");
+ expect(manager.timeEnd).toBe("2024-03-27T00:00:00.000Z");
+ });
+
+ it("resetScrubRange goes back to the selected range", async () => {
+ const manager = createTimeFilterManager();
+ manager.setUrlParams(new URLSearchParams());
+ await waitForTimeRange(manager, DEFAULT_TIME_RANGE);
+ manager.onScrubRange({
+ start: DateTime.fromISO("2024-03-26T00:00:00.000Z"),
+ end: DateTime.fromISO("2024-03-27T00:00:00.000Z"),
+ isScrubbing: false,
+ });
+
+ manager.resetScrubRange();
+
+ expect(manager.scrubInterval).toBeUndefined();
+ expect(manager.lastDefinedScrubInterval).toBeUndefined();
+ expect({ start: manager.timeStart, end: manager.timeEnd }).toEqual(
+ resolvedIntervalOf(DEFAULT_TIME_RANGE),
+ );
+ });
+});
+
+describe("inherit", () => {
+ function createParentAndChild() {
+ const parent = createTimeFilterManager();
+ const child = createTimeFilterManager({ parent });
+ return { parent, child };
+ }
+
+ it("resolves to the time range of the parent", async () => {
+ const { parent, child } = createParentAndChild();
+ parent.storeSync.setUrlParams(
+ new URLSearchParams({ tr: DEFAULT_TIME_RANGE }),
+ );
+ await waitForTimeRange(parent, DEFAULT_TIME_RANGE);
+
+ child.storeSync.setUrlParams(
+ new URLSearchParams({ tr: INHERIT_TIME_RANGE_ALIAS }),
+ );
+ await waitForTimeRange(child, DEFAULT_TIME_RANGE);
+
+ expect(child.urlTimeRange).toBe(INHERIT_TIME_RANGE_ALIAS);
+ expect(intervalOf(child)).toEqual(resolvedIntervalOf(DEFAULT_TIME_RANGE));
+ // The url keeps the alias, so the child keeps following the parent.
+ expect(paramsOf(child).tr).toBe(INHERIT_TIME_RANGE_ALIAS);
+ });
+
+ it("follows the parent when its time range changes", async () => {
+ const { parent, child } = createParentAndChild();
+ parent.storeSync.setUrlParams(
+ new URLSearchParams({ tr: DEFAULT_TIME_RANGE }),
+ );
+ await waitForTimeRange(parent, DEFAULT_TIME_RANGE);
+ child.storeSync.setUrlParams(
+ new URLSearchParams({ tr: INHERIT_TIME_RANGE_ALIAS }),
+ );
+ await waitForTimeRange(child, DEFAULT_TIME_RANGE);
+
+ await parent.onSelectRange("12h");
+ await flushStateChanges();
+
+ await waitForTimeRange(child, "12h as of latest/h+1h");
+ expect(child.urlTimeRange).toBe(INHERIT_TIME_RANGE_ALIAS);
+ });
+
+ it("follows the parent to a range with the same grain", async () => {
+ const { parent, child } = createParentAndChild();
+ parent.storeSync.setUrlParams(
+ new URLSearchParams({ tr: DEFAULT_TIME_RANGE }),
+ );
+ await waitForTimeRange(parent, DEFAULT_TIME_RANGE);
+ child.storeSync.setUrlParams(
+ new URLSearchParams({ tr: INHERIT_TIME_RANGE_ALIAS }),
+ );
+ await waitForTimeRange(child, DEFAULT_TIME_RANGE);
+
+ await parent.onSelectRange("4W");
+ await flushStateChanges();
+ await asyncWait(20);
+
+ expect(child.timeRange).toBe("4W as of latest/D+1D");
+ });
+
+ it("stops following the parent once it has its own time range", async () => {
+ const { parent, child } = createParentAndChild();
+ parent.storeSync.setUrlParams(
+ new URLSearchParams({ tr: DEFAULT_TIME_RANGE }),
+ );
+ await waitForTimeRange(parent, DEFAULT_TIME_RANGE);
+ child.storeSync.setUrlParams(
+ new URLSearchParams({ tr: "12h as of latest/h+1h" }),
+ );
+ await waitForTimeRange(child, "12h as of latest/h+1h");
+
+ await parent.onSelectRange("4W");
+ await flushStateChanges();
+ await asyncWait(20);
+
+ expect(child.timeRange).toBe("12h as of latest/h+1h");
+ });
+});
+
+describe("multiple metrics views", () => {
+ it("uses the interval that ends last", async () => {
+ const manager = createTimeFilterManager({
+ provider: await createReadyMetricsViewsProvider([
+ AD_BIDS_METRICS_NAME,
+ AD_BIDS_MIRROR_METRICS_NAME,
+ ]),
+ });
+
+ manager.setUrlParams(new URLSearchParams());
+ await waitForTimeRange(manager, DEFAULT_TIME_RANGE);
+
+ expect(intervalOf(manager)).toEqual({
+ start: "2024-03-26T00:00:00.000Z",
+ end: "2024-04-02T00:00:00.000Z",
+ });
+ });
+});
+
+describe("hasTimeSeries", () => {
+ it("is true when a metrics view has a time dimension", async () => {
+ const manager = createTimeFilterManager({
+ provider: await createReadyMetricsViewsProvider([
+ AD_BIDS_NO_TIME_METRICS_NAME,
+ AD_BIDS_METRICS_NAME,
+ ]),
+ });
+
+ expect(manager.hasTimeSeries).toBe(true);
+ });
+
+ it("is false when no metrics view has a time dimension", async () => {
+ // Metrics views without a time dimension have no summary to wait on,
+ // so the provider is ready as soon as the specs land.
+ const manager = createTimeFilterManager({
+ provider: await createReadyMetricsViewsProvider([
+ AD_BIDS_NO_TIME_METRICS_NAME,
+ ]),
+ });
+
+ expect(manager.hasTimeSeries).toBe(false);
+ });
+});
+
+describe("getTimeControlStore", () => {
+ it("exposes the comparison range only while comparison is shown", async () => {
+ const manager = createTimeFilterManager();
+ manager.storeSync.setUrlParams(
+ new URLSearchParams({ tr: DEFAULT_TIME_RANGE }),
+ );
+ await waitForTimeRange(manager, DEFAULT_TIME_RANGE);
+ const store = manager.getTimeControlStore();
+
+ expect(get(store)).toMatchObject({
+ timeRange: DEFAULT_TIME_RANGE,
+ timeGrain: V1TimeGrain.TIME_GRAIN_DAY,
+ timeZone: "UTC",
+ showComparison: false,
+ apiComparisonTimeRange: undefined,
+ hasTimeSeries: true,
+ ready: true,
+ });
+
+ manager.setShowComparison(true);
+
+ expect(get(store)).toMatchObject({
+ showComparison: true,
+ apiComparisonTimeRange: {
+ start: "2024-03-18T00:00:00.000Z",
+ end: "2024-03-25T00:00:00.000Z",
+ timeZone: "UTC",
+ },
+ });
+ });
+});
diff --git a/web-common/src/features/dashboards/time-controls/TimeFilterManager.svelte.ts b/web-common/src/features/dashboards/time-controls/TimeFilterManager.svelte.ts
new file mode 100644
index 000000000000..9c2b2bf1e67a
--- /dev/null
+++ b/web-common/src/features/dashboards/time-controls/TimeFilterManager.svelte.ts
@@ -0,0 +1,868 @@
+import { RuntimeClient } from "@rilldata/web-common/runtime-client/v2";
+import { MetricsViewsProvider } from "@rilldata/web-common/features/metrics-views/providers/MetricsViewsProvider.svelte.ts";
+import type { YAMLConfigProvider } from "@rilldata/web-common/features/dashboards/providers/YAMLConfigProvider.svelte.ts";
+import type { UrlParamsStore } from "@rilldata/web-common/lib/store-utils/url-params-store-sync.svelte.ts";
+import {
+ V1TimeGrain,
+ type V1TimeRange,
+} from "@rilldata/web-common/runtime-client";
+import { DEFAULT_TIMEZONE } from "@rilldata/web-common/lib/time/config.ts";
+import {
+ constructAsOfString,
+ constructNewString,
+ deriveInterval,
+ INHERIT_TIME_RANGE_ALIAS,
+} from "@rilldata/web-common/features/dashboards/time-controls/new-time-controls.ts";
+import {
+ allowedGrainsForInterval,
+ DateTimeUnitToV1TimeGrain,
+ getGrainOrder,
+ MinSupportedDateTimeUnit,
+ MinSupportedGrain,
+ V1TimeGrainToDateTimeUnit,
+ V1TimeGrainToOrder,
+} from "@rilldata/web-common/lib/time/new-grains.ts";
+import { getAdjustedInterval } from "@rilldata/web-common/lib/time/ranges";
+import {
+ overrideRillTimeRef,
+ parseRillTime,
+} from "@rilldata/web-common/features/dashboards/url-state/time-ranges/parser.ts";
+import {
+ RillIsoInterval,
+ RillPeriodToGrainInterval,
+ RillTime,
+ RillTimeLabel,
+} from "@rilldata/web-common/features/dashboards/url-state/time-ranges/RillTime.ts";
+import { DateTime, Interval } from "luxon";
+import { getTruncationGrain } from "@rilldata/web-common/lib/time/rill-time-grains.ts";
+import {
+ TimeComparisonOption,
+ TimeRangePreset,
+} from "@rilldata/web-common/lib/time/types.ts";
+import {
+ getAvailableComparisonsForTimeRange,
+ getComparisonInterval,
+} from "@rilldata/web-common/lib/time/comparisons";
+import { ExploreStateURLParams } from "@rilldata/web-common/features/dashboards/url-state/url-params.ts";
+import { getDefaultTimeRange } from "@rilldata/web-common/features/dashboards/stores/get-rill-default-explore-state.ts";
+import { measureSelection } from "@rilldata/web-common/features/dashboards/time-series/measure-selection/measure-selection.ts";
+import { getDurationObjectFromMS } from "@rilldata/web-common/lib/time/transforms";
+import { getOrderedStartEndDateTime } from "@rilldata/web-common/features/dashboards/time-series/utils.ts";
+import { toStore } from "svelte/store";
+import { UrlParamsChangeTracker } from "@rilldata/web-common/lib/store-utils/url-search-params-store.svelte.ts";
+import { EventEmitter } from "@rilldata/web-common/lib/event-emitter.ts";
+import type { TimeFiltersConfig } from "@rilldata/web-common/features/dashboards/time-controls/time-filters-config.ts";
+import { getValidatedTimeGrain } from "@rilldata/web-common/lib/time/grains";
+
+type ComparisonTimeRangeOption = {
+ name: TimeComparisonOption;
+ key: number;
+};
+
+const TimeFilterParams = new Set([
+ ExploreStateURLParams.TimeRange,
+ ExploreStateURLParams.TimeGrain,
+ ExploreStateURLParams.TimeZone,
+ ExploreStateURLParams.TimeDimension,
+ ExploreStateURLParams.ComparisonTimeRange,
+ ExploreStateURLParams.HighlightedTimeRange,
+]);
+
+export const DEFAULT_INHERIT_URL_PARAMS = [
+ [ExploreStateURLParams.TimeRange, INHERIT_TIME_RANGE_ALIAS],
+ [ExploreStateURLParams.ComparisonTimeRange, INHERIT_TIME_RANGE_ALIAS],
+];
+
+type ScrubRange = {
+ start: DateTime;
+ end: DateTime;
+ isScrubbing: boolean;
+};
+
+export type TimeControlState = {
+ timeRange: string | undefined;
+ timeGrain: V1TimeGrain | undefined;
+ timeZone: string;
+ interval: Interval | undefined;
+ apiTimeRange: V1TimeRange;
+
+ comparisonTimeRange: string | undefined;
+ showComparison: boolean;
+ apiComparisonTimeRange: V1TimeRange | undefined;
+
+ // Undefined until the metrics view specs resolve. Consumers that gate queries on a time range
+ // need to tell "this dashboard has no time dimension" apart from "we do not know yet".
+ hasTimeSeries: boolean | undefined;
+ ready: boolean;
+};
+
+type TimeFilterManagerEvents = {
+ ready: void;
+};
+
+export class TimeFilterManager implements UrlParamsStore {
+ // State set directly from URL/controls.
+ /**
+ * Exact value of time range as set from setUrlParams. It can have values like `inherit`.
+ */
+ public urlTimeRange = $state(undefined);
+ /**
+ * Resolved time range, taking into account the parent time range when `inherit` is set.
+ */
+ public timeRange = $state(undefined);
+ /**
+ * Exact value of time grain as set from setUrlParams.
+ */
+ public urlTimeGrain = $state(undefined);
+ /**
+ * Resolved time grain. Always has a value, if url param is undefined, it is based on time range's interval.
+ */
+ public timeGrain = $state(MinSupportedGrain);
+ public timeZone = $state(DEFAULT_TIMEZONE);
+ public timeDimension = $state(undefined);
+ /**
+ * Exact value of comparison time range as set from setUrlParams. It can have values like `inherit`.
+ */
+ public urlComparisonTimeRange = $state(undefined);
+ /**
+ * Resolved comparison time range, taking into account the parent time range when `inherit` is set.
+ */
+ public comparisonTimeRange = $state(undefined);
+ public showComparison = $state(false);
+ public lastDefinedScrubInterval = $state | undefined>(
+ undefined,
+ );
+ public scrubInterval = $state(undefined);
+
+ // When set, relative time ranges are anchored at this time instead of now/latest.
+ // Used by scheduled report exports to render data as of the report's execution time.
+ private executionTime = $state(undefined);
+
+ // Computed values
+ public minDate: DateTime | undefined;
+ public maxDate: DateTime | undefined;
+ public interval = $state | undefined>(undefined);
+ public adjustedInterval = $state | undefined>(undefined);
+ public comparisonInterval = $state | undefined>(undefined);
+ public adjustedComparisonInterval = $state | undefined>(
+ undefined,
+ );
+ public canPanLeft: boolean;
+ public canPanRight: boolean;
+ // For convenience
+ public timeStart: string | undefined;
+ public timeEnd: string | undefined;
+ public apiTimeRange: V1TimeRange;
+ public comparisonTimeStart: string | undefined;
+ public comparisonTimeEnd: string | undefined;
+ public apiComparisonTimeRange: V1TimeRange | undefined;
+
+ // RillTime related values
+ public parsedTime: RillTime | undefined;
+ public truncationGrain: V1TimeGrain | undefined;
+ public ref: RillTimeLabel | string | undefined;
+ public snapToEnd: boolean;
+ public parsedComparisonTime: RillTime | undefined;
+
+ // Options for controls
+ public aggregationOptions: V1TimeGrain[];
+ public comparisonTimeRangeOptions: ComparisonTimeRangeOption[];
+
+ public hasTimeSeries: boolean | undefined;
+ public ready = $state(false);
+
+ private events = new EventEmitter();
+ public readonly on = this.events.on.bind(
+ this.events,
+ ) as typeof this.events.on;
+
+ public paramKeys = TimeFilterParams;
+ public readonly storeSync: UrlParamsChangeTracker;
+
+ public constructor(
+ private readonly runtimeClient: RuntimeClient,
+ private readonly metricsViewsProvider: MetricsViewsProvider,
+ private readonly yamlConfigProvider: YAMLConfigProvider,
+ public readonly config: TimeFiltersConfig = {},
+ public readonly parent?: TimeFilterManager,
+ ) {
+ this.storeSync = new UrlParamsChangeTracker(this, config.log);
+
+ this.ready = metricsViewsProvider.ready;
+ metricsViewsProvider.on("time-specs-loaded", () => {
+ this.ready = true;
+ this.events.emit("ready");
+ });
+ // Replay `inherit` time range when parent changed.
+ parent?.storeSync.on("change", () => {
+ const timeRangeIsInherited =
+ this.urlTimeRange === INHERIT_TIME_RANGE_ALIAS;
+ const comparisonTimeRangeIsInherited =
+ this.urlComparisonTimeRange === INHERIT_TIME_RANGE_ALIAS;
+ if (timeRangeIsInherited) {
+ // If comparison is inherited it will be reapplied from applyTimeRange
+ void this.applyTimeRange(this.urlTimeRange!, this.timeZone, false);
+ } else if (comparisonTimeRangeIsInherited) {
+ this.applyComparisonRange(this.urlComparisonTimeRange!);
+ }
+ });
+
+ this.minDate = $derived.by(() => {
+ const minDate = this.metricsViewsProvider.timeRangeSummary?.min
+ ? DateTime.fromISO(this.metricsViewsProvider.timeRangeSummary.min)
+ : undefined;
+ if (!minDate?.isValid) return undefined;
+ return minDate;
+ });
+ this.maxDate = $derived.by(() => {
+ const maxDate = this.metricsViewsProvider.timeRangeSummary?.max
+ ? DateTime.fromISO(this.metricsViewsProvider.timeRangeSummary.max)
+ : undefined;
+ if (!maxDate?.isValid) return undefined;
+ return maxDate;
+ });
+
+ this.canPanLeft = $derived.by(() => {
+ if (!this.minDate || !this.interval) return false;
+ // Selected start - min > 0
+ const diff = this.interval.start.diff(this.minDate);
+ return diff.milliseconds > 0;
+ });
+ this.canPanRight = $derived.by(() => {
+ if (!this.maxDate || !this.interval) return false;
+ // max - selected end > 0
+ const diff = this.maxDate.diff(this.interval.end);
+ return diff.milliseconds > 0;
+ });
+
+ this.timeStart = $derived.by(() => {
+ const start =
+ this.lastDefinedScrubInterval?.start ?? this.interval?.start;
+ return start?.toJSDate()?.toISOString();
+ });
+ this.timeEnd = $derived.by(() => {
+ const end = this.lastDefinedScrubInterval?.end ?? this.interval?.end;
+ return end?.toJSDate()?.toISOString();
+ });
+ this.apiTimeRange = $derived({
+ start: this.timeStart,
+ end: this.timeEnd,
+ timeZone: this.timeZone,
+ timeDimension: this.timeDimension,
+ });
+
+ // TODO: calculate vs scrub
+ this.comparisonTimeStart = $derived(
+ this.comparisonInterval?.start.toJSDate().toISOString(),
+ );
+ this.comparisonTimeEnd = $derived(
+ this.comparisonInterval?.end.toJSDate().toISOString(),
+ );
+ this.apiComparisonTimeRange = $derived.by(() => {
+ if (!this.comparisonTimeStart || !this.comparisonTimeEnd)
+ return undefined;
+
+ return {
+ start: this.comparisonTimeStart,
+ end: this.comparisonTimeEnd,
+ timeZone: this.timeZone,
+ };
+ });
+
+ this.parsedTime = $derived.by(() => {
+ if (!this.timeRange) return undefined;
+ try {
+ return parseRillTime(this.timeRange);
+ } catch {
+ return undefined;
+ }
+ });
+ this.truncationGrain = $derived(getTruncationGrain(this.parsedTime));
+ this.ref = $derived(
+ this.parsedTime?.isOldFormat
+ ? RillTimeLabel.Latest
+ : this.parsedTime?.asOfLabel?.label,
+ );
+ this.snapToEnd = $derived(
+ this.parsedTime?.isOldFormat
+ ? true
+ : !!this.parsedTime?.asOfLabel?.offset,
+ );
+
+ this.parsedComparisonTime = $derived.by(() => {
+ if (!this.comparisonTimeRange) return undefined;
+ try {
+ return parseRillTime(this.comparisonTimeRange);
+ } catch {
+ return undefined;
+ }
+ });
+
+ this.aggregationOptions = $derived(
+ allowedGrainsForInterval(
+ this.interval,
+ this.metricsViewsProvider.largestMinTimeGrain,
+ ),
+ );
+ this.comparisonTimeRangeOptions = $derived(
+ this.getComparisonTimeRangeOptions(),
+ );
+
+ this.hasTimeSeries = $derived.by(() => {
+ if (!metricsViewsProvider.ready) return undefined;
+
+ let hasTimeSeries = false;
+ for (const metricsView of metricsViewsProvider.metricsViewNames) {
+ const spec = metricsViewsProvider.specs[metricsView];
+ // Every spec comes from the same ListResources response,
+ // so a missing one means they have not resolved and the answer is not knowable yet.
+ if (!spec) return undefined;
+ if (spec.timeDimension) hasTimeSeries = true;
+ }
+ return hasTimeSeries;
+ });
+ }
+
+ public normalizeParams(urlParams: URLSearchParams): URLSearchParams {
+ return urlParams;
+ }
+
+ public setUrlParams(urlParams: URLSearchParams) {
+ if (this.config.log)
+ console.log("TimeFilterManager::setUrlParams", urlParams.toString());
+
+ if (!this.config.skipTimeGrain) {
+ const urlGrain = urlParams.get(ExploreStateURLParams.TimeGrain);
+ this.urlTimeGrain = urlGrain
+ ? DateTimeUnitToV1TimeGrain[urlGrain]
+ : undefined;
+ }
+
+ this.timeZone =
+ urlParams.get(ExploreStateURLParams.TimeZone) ??
+ this.yamlConfigProvider.defaultTimeZone;
+
+ this.timeDimension =
+ urlParams.get(ExploreStateURLParams.TimeDimension) ?? undefined;
+
+ this.showComparison = urlParams.has(
+ ExploreStateURLParams.ComparisonTimeRange,
+ );
+ this.applyComparisonRange(
+ urlParams.get(ExploreStateURLParams.ComparisonTimeRange) ?? "rill-PP",
+ );
+
+ // Apply time range last so that params are taken from url
+ if (urlParams.has(ExploreStateURLParams.TimeRange)) {
+ void this.applyTimeRange(
+ urlParams.get(ExploreStateURLParams.TimeRange)!,
+ this.timeZone,
+ false,
+ );
+ } else {
+ let defaultTimeRange = this.yamlConfigProvider.defaultTimeRange;
+ if (!defaultTimeRange) {
+ defaultTimeRange = getDefaultTimeRange(
+ this.metricsViewsProvider.largestMinTimeGrain,
+ this.metricsViewsProvider.timeRangeSummary,
+ );
+ }
+ if (defaultTimeRange && !this.config.skipDefaultTimeRange) {
+ void this.applyTimeRange(defaultTimeRange, this.timeZone, false);
+ } else {
+ this.urlTimeRange = undefined;
+ this.timeRange = undefined;
+ this.interval = undefined;
+ }
+ }
+
+ // Apply the highlighted range after the time range, since applying a time range clears it.
+ this.scrubInterval = undefined;
+ this.lastDefinedScrubInterval = undefined;
+ if (urlParams.has(ExploreStateURLParams.HighlightedTimeRange)) {
+ try {
+ const parsedHighlightRange = parseRillTime(
+ urlParams.get(ExploreStateURLParams.HighlightedTimeRange)!,
+ );
+ if (parsedHighlightRange.interval instanceof RillIsoInterval) {
+ const { start, end, interval } =
+ parsedHighlightRange.interval.toLuxonTimes();
+ this.scrubInterval =
+ start && end
+ ? {
+ start,
+ end,
+ isScrubbing: false,
+ }
+ : undefined;
+ this.lastDefinedScrubInterval = interval;
+ }
+ } catch {
+ // no-op
+ }
+ }
+ }
+
+ public applyFilterToParams(urlParams: URLSearchParams) {
+ if (this.urlTimeRange) {
+ urlParams.set(ExploreStateURLParams.TimeRange, this.urlTimeRange);
+ } else {
+ urlParams.delete(ExploreStateURLParams.TimeRange);
+ }
+
+ if (!this.config.skipTimeGrain) {
+ const mappedGrain = this.urlTimeGrain
+ ? V1TimeGrainToDateTimeUnit[this.urlTimeGrain]
+ : undefined;
+ if (mappedGrain) {
+ urlParams.set(ExploreStateURLParams.TimeGrain, mappedGrain);
+ } else {
+ urlParams.delete(ExploreStateURLParams.TimeGrain);
+ }
+ }
+
+ if (this.timeZone !== DEFAULT_TIMEZONE) {
+ urlParams.set(ExploreStateURLParams.TimeZone, this.timeZone);
+ } else {
+ urlParams.delete(ExploreStateURLParams.TimeZone);
+ }
+
+ if (this.timeDimension) {
+ urlParams.set(ExploreStateURLParams.TimeDimension, this.timeDimension);
+ } else {
+ urlParams.delete(ExploreStateURLParams.TimeDimension);
+ }
+
+ if (this.showComparison && this.urlComparisonTimeRange) {
+ urlParams.set(
+ ExploreStateURLParams.ComparisonTimeRange,
+ this.urlComparisonTimeRange,
+ );
+ } else {
+ urlParams.delete(ExploreStateURLParams.ComparisonTimeRange);
+ }
+
+ if (this.lastDefinedScrubInterval) {
+ const scrubStart = this.lastDefinedScrubInterval.start
+ .toJSDate()
+ .toISOString();
+ const scrubEnd = this.lastDefinedScrubInterval.end
+ .toJSDate()
+ .toISOString();
+ urlParams.set(
+ ExploreStateURLParams.HighlightedTimeRange,
+ `${scrubStart},${scrubEnd}`,
+ );
+ } else {
+ urlParams.delete(ExploreStateURLParams.HighlightedTimeRange);
+ }
+ }
+
+ // Mutation methods used by different UI controls
+
+ public onSelectRange(range: string, ignoreSnap?: boolean) {
+ if (range === INHERIT_TIME_RANGE_ALIAS) {
+ return this.applyTimeRange(range, this.timeZone);
+ }
+
+ try {
+ const parsed = parseRillTime(range);
+
+ const isPeriodToDate =
+ parsed.interval instanceof RillPeriodToGrainInterval;
+
+ const rangeGrainOrder =
+ getGrainOrder(parsed.rangeGrain) - (isPeriodToDate ? 1 : 0);
+
+ const asOfGrainOrder = getGrainOrder(this.truncationGrain);
+
+ const shouldAppendAsOfString =
+ !parsed.asOfLabel && !(parsed.interval instanceof RillIsoInterval);
+
+ if (asOfGrainOrder > rangeGrainOrder && parsed.rangeGrain) {
+ this.truncationGrain = parsed.rangeGrain;
+ }
+
+ if (shouldAppendAsOfString) {
+ const hasAsOfClause = !!this.parsedTime?.asOfLabel;
+
+ const isTruncationGrainAllowed =
+ getGrainOrder(this.truncationGrain) >=
+ this.metricsViewsProvider.largestMinGrainOrder;
+ const newAsOfString = constructAsOfString(
+ this.ref ?? RillTimeLabel.Latest,
+ ignoreSnap
+ ? undefined
+ : this.truncationGrain
+ ? isTruncationGrainAllowed
+ ? this.truncationGrain
+ : parsed.rangeGrain
+ : (this.metricsViewsProvider.largestMinTimeGrain ??
+ V1TimeGrain.TIME_GRAIN_MINUTE),
+ hasAsOfClause || this.snapToEnd ? this.snapToEnd : true,
+ );
+
+ overrideRillTimeRef(parsed, newAsOfString);
+ }
+
+ return this.applyTimeRange(parsed.toString(), this.timeZone);
+ } catch {
+ // This function is called in a controlled manner and should not throw
+ }
+ }
+
+ public onPan(direction: "left" | "right") {
+ if (!this.interval) return;
+
+ const currentRangeWidth = this.interval.length("milliseconds");
+ const panAmount = getDurationObjectFromMS(currentRangeWidth);
+
+ const newStart = this.interval.start
+ .setZone(this.timeZone)
+ [direction === "left" ? "plus" : "minus"](panAmount)
+ .toISO();
+ const newEnd = this.interval.end
+ .setZone(this.timeZone)
+ [direction === "left" ? "plus" : "minus"](panAmount)
+ .toISO();
+
+ void this.applyTimeRange(`${newStart} to ${newEnd}`, this.timeZone);
+ }
+
+ public onSelectGrainEnding(grain: V1TimeGrain | undefined) {
+ if (!this.timeRange) return;
+
+ const newString = constructNewString({
+ currentString: this.timeRange,
+ truncationGrain: grain === this.truncationGrain ? undefined : grain,
+ snapToEnd: grain === this.truncationGrain ? false : this.snapToEnd,
+ ref: this.ref,
+ });
+
+ return this.applyTimeRange(newString, this.timeZone);
+ }
+
+ public onSelectGrain(grain: V1TimeGrain | undefined) {
+ this.urlTimeGrain = grain;
+ this.timeGrain =
+ getValidatedTimeGrain(
+ this.interval,
+ this.metricsViewsProvider.largestMinTimeGrain ?? MinSupportedGrain,
+ grain,
+ this.parsedTime,
+ ) ?? MinSupportedGrain;
+ this.storeSync.stateChanged();
+ }
+
+ public onSelectZone(tz: string) {
+ this.timeZone = tz;
+ if (!this.timeRange || !this.parsedTime) return;
+
+ if (this.parsedTime.interval instanceof RillIsoInterval) {
+ // TODO
+ } else {
+ void this.applyTimeRange(this.timeRange, tz);
+ }
+ }
+
+ public onSelectAsOfOption(
+ ref: RillTimeLabel | string | undefined,
+ inclusive: boolean,
+ ) {
+ if (!this.timeRange) return;
+ const newString = constructNewString({
+ currentString: this.timeRange,
+ truncationGrain: this.truncationGrain,
+ snapToEnd: ref === "watermark" ? false : inclusive,
+ ref,
+ });
+
+ return this.applyTimeRange(newString, this.timeZone);
+ }
+
+ public onSelectTimeDimension(timeDimension: string) {
+ this.timeDimension = timeDimension;
+ if (this.timeRange) void this.applyTimeRange(this.timeRange, this.timeZone);
+ else this.storeSync.stateChanged();
+ }
+
+ public onSelectComparisonRange(range: string) {
+ this.applyComparisonRange(range);
+ this.showComparison = true;
+ this.storeSync.stateChanged();
+ }
+
+ public setShowComparison(showComparison: boolean) {
+ this.showComparison = showComparison;
+ this.storeSync.stateChanged();
+ }
+ public onToggleShowComparison() {
+ this.showComparison = !this.showComparison;
+ this.storeSync.stateChanged();
+ }
+
+ public onScrubRange({ start, end, isScrubbing }: ScrubRange) {
+ this.scrubInterval = {
+ start,
+ end,
+ isScrubbing,
+ };
+ if (isScrubbing) return;
+
+ ({ start, end } = getOrderedStartEndDateTime(start, end));
+ const newInterval = Interval.fromDateTimes(start, end);
+ if (!newInterval.isValid) return;
+
+ this.lastDefinedScrubInterval = newInterval;
+ this.storeSync.stateChanged();
+ }
+
+ public resetScrubRange(notify = true) {
+ this.lastDefinedScrubInterval = undefined;
+ this.scrubInterval = undefined;
+ if (notify) this.storeSync.stateChanged();
+ }
+
+ public getTimeControlStore() {
+ return toStore(() => ({
+ timeRange: this.timeRange,
+ timeGrain: this.timeGrain,
+ timeZone: this.timeZone,
+ interval: this.interval,
+ apiTimeRange: this.apiTimeRange,
+
+ comparisonTimeRange: this.comparisonTimeRange,
+ showComparison: this.showComparison,
+ apiComparisonTimeRange: this.showComparison
+ ? this.apiComparisonTimeRange
+ : undefined,
+
+ hasTimeSeries: this.hasTimeSeries,
+ ready: this.ready,
+ }));
+ }
+
+ public setExecutionTime(executionTime: string | undefined) {
+ this.executionTime = executionTime;
+ }
+
+ private async applyTimeRange(
+ newTimeRange: string,
+ tz: string,
+ notifyChange = true,
+ ) {
+ this.urlTimeRange = newTimeRange;
+ if (newTimeRange === INHERIT_TIME_RANGE_ALIAS) {
+ if (!this.parent?.timeRange) return;
+ newTimeRange = this.parent.timeRange;
+ }
+
+ measureSelection.clear();
+ this.resetScrubRange(false);
+
+ // The runtime resolves the range against the metrics views, so their names are all this needs.
+ // The time range summary can still be loading at this point;
+ // waiting for it here would drop the range the dashboard loaded with.
+ if (!this.metricsViewsProvider.metricsViewNames.length) return;
+
+ // This should be returned by the API, but it is not yet implemented
+ const includesTimeZoneOffset = newTimeRange.includes("tz");
+
+ if (includesTimeZoneOffset) {
+ const timeZone = newTimeRange.match(/tz (.*)/)?.[1];
+
+ if (timeZone) this.timeZone = timeZone;
+ }
+
+ const promises = this.metricsViewsProvider.metricsViewNames.map(
+ (mvName) => {
+ return deriveInterval(
+ newTimeRange,
+ this.runtimeClient,
+ mvName,
+ tz ?? "UTC",
+ this.timeDimension,
+ this.executionTime,
+ );
+ },
+ );
+ const intervals = await Promise.all(promises);
+ let latestInterval: Interval | undefined = undefined;
+ let smallestGrain: V1TimeGrain | undefined = undefined;
+ intervals.forEach(({ interval, grain }) => {
+ if (
+ interval?.isValid &&
+ interval.end &&
+ (!latestInterval || latestInterval.end < interval.end)
+ ) {
+ latestInterval = interval;
+ }
+
+ if (
+ grain &&
+ (!smallestGrain ||
+ V1TimeGrainToOrder[grain] < V1TimeGrainToOrder[smallestGrain])
+ ) {
+ smallestGrain = grain;
+ }
+ });
+ if (!latestInterval) {
+ return;
+ }
+
+ const allowedGrains = allowedGrainsForInterval(
+ latestInterval,
+ this.metricsViewsProvider.largestMinTimeGrain ?? MinSupportedGrain,
+ );
+
+ let finalGrain: V1TimeGrain = allowedGrains[0];
+ if (this.urlTimeGrain) {
+ // If url time grain is explicitly set, use it if it is still valid in new interval.
+ finalGrain = allowedGrains.includes(this.urlTimeGrain)
+ ? this.urlTimeGrain
+ : smallestGrain && allowedGrains.includes(smallestGrain)
+ ? smallestGrain
+ : finalGrain;
+ } else {
+ // Else get the valid time grain for the new interval.
+ finalGrain =
+ getValidatedTimeGrain(
+ latestInterval,
+ this.metricsViewsProvider.largestMinTimeGrain ?? MinSupportedGrain,
+ undefined,
+ parseRillTime(newTimeRange),
+ ) ?? MinSupportedGrain;
+ }
+
+ this.interval = latestInterval;
+ this.adjustedInterval = getAdjustedInterval(
+ latestInterval,
+ finalGrain,
+ this.timeZone,
+ );
+ this.timeRange = newTimeRange;
+ this.timeGrain = finalGrain;
+
+ // Recalc comparison time range internal.
+ if (this.urlComparisonTimeRange)
+ this.applyComparisonRange(this.urlComparisonTimeRange);
+
+ if (this.config.log)
+ console.log(
+ "TimeFilterManager::applyTimeRange::dataUpdated",
+ newTimeRange,
+ );
+ if (notifyChange) this.storeSync.stateChanged();
+ }
+
+ private applyComparisonRange(newComparisonTimeRange: string) {
+ this.urlComparisonTimeRange = newComparisonTimeRange;
+ if (newComparisonTimeRange === INHERIT_TIME_RANGE_ALIAS) {
+ if (!this.parent?.timeRange) return;
+ newComparisonTimeRange = this.parent.timeRange;
+ }
+ this.comparisonTimeRange = newComparisonTimeRange;
+ if (!this.timeRange) return;
+
+ try {
+ const parsed = parseRillTime(this.timeRange);
+ // An absolute primary time range has no relative comparison other than the contiguous one,
+ // so mark the comparison as contiguous. An absolute comparison window is left as-is:
+ // it already names the exact period to compare against.
+ if (
+ parsed.interval instanceof RillIsoInterval &&
+ !(this.parsedComparisonTime?.interval instanceof RillIsoInterval)
+ ) {
+ this.comparisonTimeRange = TimeComparisonOption.CONTIGUOUS;
+ }
+ if (this.timeRange === TimeRangePreset.ALL_TIME) {
+ this.showComparison = false;
+ }
+
+ this.comparisonInterval = getComparisonInterval(
+ this.interval,
+ newComparisonTimeRange,
+ this.timeZone,
+ );
+ this.adjustedComparisonInterval = this.comparisonInterval
+ ? getAdjustedInterval(
+ this.comparisonInterval,
+ this.timeGrain,
+ this.timeZone,
+ )
+ : undefined;
+ } catch {
+ // no-op
+ }
+ }
+
+ private getComparisonTimeRangeOptions() {
+ // Type-safety
+ if (
+ !this.minDate ||
+ !this.maxDate ||
+ !this.timeRange ||
+ !this.interval?.isValid ||
+ !this.interval.start ||
+ !this.interval.end
+ )
+ return [];
+
+ let allOptions: TimeComparisonOption[];
+
+ const timeRange = this.yamlConfigProvider.timeRanges?.find(
+ (tr) => tr.range === this.timeRange,
+ );
+ if (timeRange?.comparisonTimeRanges?.length) {
+ allOptions =
+ timeRange.comparisonTimeRanges?.map(
+ (co) => co.offset as TimeComparisonOption,
+ ) ?? [];
+ if (this.config.allowCustomTimeRange)
+ allOptions.push(TimeComparisonOption.CUSTOM);
+ } else {
+ allOptions = [...Object.values(TimeComparisonOption)];
+ if (!this.config.allowCustomTimeRange) {
+ allOptions = allOptions.filter(
+ (o) => o !== TimeComparisonOption.CUSTOM,
+ );
+ }
+ }
+
+ const timeComparisonOptions = getAvailableComparisonsForTimeRange(
+ this.minDate.toJSDate(),
+ this.maxDate.toJSDate(),
+ this.interval.start.toJSDate(),
+ this.interval.end.toJSDate(),
+ allOptions,
+ this.timeZone,
+ );
+
+ const filteredOptions = timeComparisonOptions
+ .map((co, i) => {
+ const comparisonTimeRange = getComparisonInterval(
+ this.interval,
+ co,
+ this.timeZone,
+ );
+
+ if (
+ !comparisonTimeRange ||
+ !comparisonTimeRange.length(MinSupportedDateTimeUnit)
+ )
+ return undefined;
+ return {
+ name: co,
+ key: i,
+ };
+ })
+ .filter(Boolean) as ComparisonTimeRangeOption[];
+ if (this.config.allowCustomTimeRange) {
+ filteredOptions.push({
+ name: TimeComparisonOption.CUSTOM,
+ key: filteredOptions.length,
+ });
+ }
+ return filteredOptions;
+ }
+}
diff --git a/web-common/src/features/dashboards/time-controls/TimeFilters.svelte b/web-common/src/features/dashboards/time-controls/TimeFilters.svelte
new file mode 100644
index 000000000000..52c60349dffb
--- /dev/null
+++ b/web-common/src/features/dashboards/time-controls/TimeFilters.svelte
@@ -0,0 +1,135 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {#if !hidePan}
+ timeFilterManager.onPan(dir)}
+ direction="left"
+ />
+ timeFilterManager.onPan(dir)}
+ direction="right"
+ />
+ {/if}
+
+
+
+
+ {#if showComparisonSelector}
+
+ {/if}
+
+ {#if showGrainPicker}
+
+ {/if}
+
+
+
diff --git a/web-common/src/features/dashboards/time-controls/comparison-pill/ComparisonPill.svelte b/web-common/src/features/dashboards/time-controls/comparison-pill/ComparisonPill.svelte
deleted file mode 100644
index 261ee0a3b271..000000000000
--- a/web-common/src/features/dashboards/time-controls/comparison-pill/ComparisonPill.svelte
+++ /dev/null
@@ -1,137 +0,0 @@
-
-
-
-
- {#if activeTimeGrain && interval.isValid}
-
- {/if}
-
-
-
diff --git a/web-common/src/features/dashboards/time-controls/ComparisonSelector.svelte b/web-common/src/features/dashboards/time-controls/comparison/ComparisonSelector.svelte
similarity index 97%
rename from web-common/src/features/dashboards/time-controls/ComparisonSelector.svelte
rename to web-common/src/features/dashboards/time-controls/comparison/ComparisonSelector.svelte
index fb776eb10154..df1a280b2a45 100644
--- a/web-common/src/features/dashboards/time-controls/ComparisonSelector.svelte
+++ b/web-common/src/features/dashboards/time-controls/comparison/ComparisonSelector.svelte
@@ -3,9 +3,9 @@
import { Search } from "@rilldata/web-common/components/search";
import Tooltip from "@rilldata/web-common/components/tooltip/Tooltip.svelte";
import TooltipContent from "@rilldata/web-common/components/tooltip/TooltipContent.svelte";
- import { getStateManagers } from "@rilldata/web-common/features/dashboards/state-managers/state-managers";
- import { metricsExplorerStore } from "@rilldata/web-common/features/dashboards/stores/dashboard-stores";
- import { getNoComparisonLabel } from "@rilldata/web-common/lib/time/config";
+ import { getStateManagers } from "@rilldata/web-common/features/dashboards/state-managers/state-managers.ts";
+ import { metricsExplorerStore } from "@rilldata/web-common/features/dashboards/stores/dashboard-stores.ts";
+ import { getNoComparisonLabel } from "@rilldata/web-common/lib/time/config.ts";
import type { MetricsViewSpecDimension } from "@rilldata/web-common/runtime-client";
import { matchSorter } from "match-sorter";
import * as DropdownMenu from "@rilldata/web-common/components/dropdown-menu";
diff --git a/web-common/src/features/dashboards/time-controls/comparison/ComparisonTimeRangePicker.svelte b/web-common/src/features/dashboards/time-controls/comparison/ComparisonTimeRangePicker.svelte
new file mode 100644
index 000000000000..dd3482189bc7
--- /dev/null
+++ b/web-common/src/features/dashboards/time-controls/comparison/ComparisonTimeRangePicker.svelte
@@ -0,0 +1,242 @@
+
+
+
+
+ {#if timeGrain && comparisonInterval}
+
{
+ showSelector = !!(
+ comparisonTimeRange === TimeComparisonOption.CUSTOM && showComparison
+ );
+ }}
+ >
+
+ {#snippet child({ props })}
+
+ {/snippet}
+
+
+
+
+
+ {#if showInheritRange}
+
+ {/if}
+
+ {#each comparisonTimeRangeOptions as option (option.name)}
+ {@const preset = TIME_COMPARISON[option.name]}
+ {@const selected = selectedLabel === option.name}
+ onSelectComparisonRange(option.name)}
+ >
+
+ {preset?.label || option.name}
+
+
+ {#if option.name === TimeComparisonOption.CONTIGUOUS && comparisonTimeRangeOptions.length > 2}
+
+ {/if}
+ {/each}
+ {#if allowCustomTimeRange}
+ {#if comparisonTimeRangeOptions.length}
+
+ {/if}
+
+ {
+ showSelector = !showSelector;
+ }}
+ >
+
+ {m.time_custom()}
+
+
+ {/if}
+
+ {#if showSelector}
+
+ {#if !comparisonInterval || comparisonInterval?.isValid}
+ (open = false)}
+ />
+ {/if}
+
+ {/if}
+
+
+
+
+
+ {/if}
+
+
+
diff --git a/web-common/src/features/dashboards/time-controls/new-time-controls.ts b/web-common/src/features/dashboards/time-controls/new-time-controls.ts
index 91015a3cfae9..fb75b04b9e06 100644
--- a/web-common/src/features/dashboards/time-controls/new-time-controls.ts
+++ b/web-common/src/features/dashboards/time-controls/new-time-controls.ts
@@ -143,6 +143,8 @@ export type AllTime = typeof ALL_TIME_RANGE_ALIAS;
export type CustomRange = typeof CUSTOM_TIME_RANGE_ALIAS;
export type ISODurationString = string;
+export const INHERIT_TIME_RANGE_ALIAS = "inherit";
+
// An extra entry at the top of a range or comparison menu that hands the choice back to a parent,
// such as a canvas widget following the canvas time range.
export type InheritRangeOption = {
@@ -419,9 +421,12 @@ export async function deriveInterval(
cacheBust,
});
- const timeRange = response.resolvedTimeRanges?.[0];
+ const timeRange = response.resolvedTimeRanges?.find(
+ (rtr) => rtr.expression === name,
+ );
if (!timeRange?.start || !timeRange?.end) {
+ console.error("Invalid time range: no start/end");
return { interval: Interval.invalid("Invalid time range") };
}
@@ -802,6 +807,9 @@ export function constructNewString({
snapToEnd: boolean;
ref: RillTimeLabel | string | undefined;
}): string {
+ // Safeguard to ensure we don't try to parse an `inherit` time range.
+ if (currentString === INHERIT_TIME_RANGE_ALIAS) return currentString;
+
const legacy = isUsingLegacyTime(currentString);
const rillTime = parseRillTime(
diff --git a/web-common/src/features/dashboards/time-controls/super-pill/SuperPill.svelte b/web-common/src/features/dashboards/time-controls/super-pill/SuperPill.svelte
deleted file mode 100644
index 97c0fb7f71a3..000000000000
--- a/web-common/src/features/dashboards/time-controls/super-pill/SuperPill.svelte
+++ /dev/null
@@ -1,210 +0,0 @@
-
-
-
- {#if !hidePan}
-
-
- {/if}
-
- {#if $newPicker}
-
- {:else if interval && activeTimeGrain}
- {
- applyRange({
- name: TimeRangePreset.CUSTOM,
- start: interval.start.toJSDate(),
- end: interval.end.toJSDate(),
- });
- }}
- {onSelectRange}
- />
- {/if}
-
- {#if availableTimeZones.length && !$newPicker}
-
- {/if}
-
- {#if !$newPicker && !showPivot && minTimeGrain}
-
- {/if}
-
-
-
diff --git a/web-common/src/features/dashboards/time-controls/super-pill/new-time-dropdown/RangePickerV2.svelte b/web-common/src/features/dashboards/time-controls/super-pill/TimeRangePicker.svelte
similarity index 54%
rename from web-common/src/features/dashboards/time-controls/super-pill/new-time-dropdown/RangePickerV2.svelte
rename to web-common/src/features/dashboards/time-controls/super-pill/TimeRangePicker.svelte
index 209af5496d83..d23444caa970 100644
--- a/web-common/src/features/dashboards/time-controls/super-pill/new-time-dropdown/RangePickerV2.svelte
+++ b/web-common/src/features/dashboards/time-controls/super-pill/TimeRangePicker.svelte
@@ -1,210 +1,139 @@
@@ -254,14 +196,13 @@
aria-label={m.dashboard_select_time_range()}
type="button"
>
- {#if inherited && inheritOption}
- {inheritOption.label}
+ {#if inheritedTimeRange && showInheritRange}
+
+ {m.canvas_inherit_from_canvas()}
+
{/if}
{#if timeString}
-
+
{#if selectedLabel?.startsWith("-") || !isNaN(Number(selectedLabel?.[0]))}
{m.dashboard_custom()}
{:else}
@@ -311,15 +252,10 @@
{
- open = false;
-
- handleRangeSelect(range);
- }}
+ {onSelectRange}
/>
- {#if inheritOption}
-
-
-
-
- {/if}
-
- {#if showDefaultItem && defaultTimeRange}
-
{/if}
{
- handleRangeSelect(r, true);
- }}
+ onClick={(r) => void onSelectRange(r, true)}
/>
{#if allTimeAllowed}
@@ -393,14 +304,9 @@
type="button"
role="menuitem"
class="group truncate h-7 p-2 text-popover-foreground justify-between overflow-hidden hover:bg-popover-accent rounded-sm w-full select-none flex items-center"
- onclick={() => {
- handleRangeSelect("inf");
- }}
+ onclick={() => void onSelectRange("inf")}
>
-
+
{RILL_TO_LABEL[ALL_TIME_RANGE_ALIAS]}
@@ -465,23 +371,19 @@
>
{
- onSelectTimeZone(z);
- closeMenu();
- timeZonePickerOpen = false;
- }}
+ {onSelectTimeZone}
/>
{/if}
- {#if timeDimensions.length > 1 && onTimeDimensionSelect}
+ {#if showTimeDimensionSelector && timeDimensions.length > 1}
@@ -522,14 +424,10 @@
aria-label={m.dashboard_select_time_dimension({
label,
})}
- onclick={() => {
- onTimeDimensionSelect(value);
- closeMenu();
- timeAxisPickerOpen = false;
- }}
+ onclick={() => onSelectTimeDimension(value)}
>
{label}
- {#if value === (selectedTimeDimension || primaryTimeDimension)}
+ {#if value === (timeDimension || primaryTimeDimension)}
{/if}
@@ -557,15 +455,15 @@
{
- if (searchValue) handleRangeSelect(searchValue);
+ if (searchValue) onSelectRange(searchValue);
}}
updateRange={(string) => {
searchValue = string;
@@ -578,7 +476,7 @@
-{#if dateTimeAnchor && !hideTruncationSelector && !inherited}
+{#if dateTimeAnchor && !hideTruncationSelector}
timeFilterManager.onSelectGrainEnding(g)}
onToggleAlignment={(inclusive) => {
- onSelectAsOfOption(ref, inclusive);
+ timeFilterManager.onSelectAsOfOption(ref, inclusive);
}}
onSelectAsOfOption={(o) => {
- onSelectAsOfOption(o, snapToEnd);
+ timeFilterManager.onSelectAsOfOption(o, snapToEnd);
}}
/>
{/if}