- {#if showDefaultItem && defaultTimeRange}
-
- {/if}
-
{
- handleRangeSelect(r, true);
- }}
+ onClick={(r) => void onSelectRange(r, true)}
/>
{#if allTimeAllowed}
@@ -363,9 +285,7 @@
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]}
@@ -432,23 +352,19 @@
>
{
- onSelectTimeZone(z);
- closeMenu();
- timeZonePickerOpen = false;
- }}
+ {onSelectTimeZone}
/>
{/if}
- {#if timeDimensions.length > 1 && onTimeDimensionSelect}
+ {#if showTimeDimensionSelector && timeDimensions.length > 1}
@@ -489,14 +405,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}
@@ -524,15 +436,15 @@
{
- if (searchValue) handleRangeSelect(searchValue);
+ if (searchValue) onSelectRange(searchValue);
}}
updateRange={(string) => {
searchValue = string;
@@ -553,16 +465,16 @@
isPeriodToDate={parsedTime?.interval instanceof RillPeriodToGrainInterval}
{watermark}
latest={maxDate}
- {smallestTimeGrain}
+ smallestTimeGrain={largestMinTimeGrain}
{snapToEnd}
{ref}
- {zone}
- onSelectEnding={onSelectGrain}
+ zone={timeZone}
+ onSelectEnding={(g) => timeFilterManager.onSelectGrainEnding(g)}
onToggleAlignment={(inclusive) => {
- onSelectAsOfOption(ref, inclusive);
+ timeFilterManager.onSelectAsOfOption(ref, inclusive);
}}
onSelectAsOfOption={(o) => {
- onSelectAsOfOption(o, snapToEnd);
+ timeFilterManager.onSelectAsOfOption(o, snapToEnd);
}}
/>
{/if}
diff --git a/web-common/src/features/dashboards/time-controls/super-pill/components/RangePicker.svelte b/web-common/src/features/dashboards/time-controls/super-pill/components/RangePicker.svelte
deleted file mode 100644
index 7ba52e612c0a..000000000000
--- a/web-common/src/features/dashboards/time-controls/super-pill/components/RangePicker.svelte
+++ /dev/null
@@ -1,98 +0,0 @@
-
-
- {
- showSelector = selected === "CUSTOM";
- }}
->
-
- {#snippet child({ props })}
-
- {/snippet}
-
-
-
-
- {
- onSelectRange(selected);
-
- open = false;
- }}
- onSelectCustomOption={() => (showSelector = !showSelector)}
- />
-
- {#if showSelector}
-
- {
- applyCustomRange(i);
- }}
- closeMenu={() => (open = false)}
- />
-
- {/if}
-
-
-
diff --git a/web-common/src/features/dashboards/time-controls/super-pill/components/index.ts b/web-common/src/features/dashboards/time-controls/super-pill/components/index.ts
index f5be8c7d77f7..5e01863a5648 100644
--- a/web-common/src/features/dashboards/time-controls/super-pill/components/index.ts
+++ b/web-common/src/features/dashboards/time-controls/super-pill/components/index.ts
@@ -2,6 +2,5 @@ import Comparison from "./Comparison.svelte";
import Nudge from "./Nudge.svelte";
import Zone from "./Zone.svelte";
import Zoom from "./Zoom.svelte";
-import RangePicker from "./RangePicker.svelte";
-export { Comparison, Nudge, Zone, Zoom, RangePicker };
+export { Comparison, Nudge, Zone, Zoom };
diff --git a/web-common/src/features/dashboards/time-controls/super-pill/new-time-dropdown/TruncationSelector.svelte b/web-common/src/features/dashboards/time-controls/super-pill/new-time-dropdown/TruncationSelector.svelte
index b6bd51c6fb38..577b0c525af8 100644
--- a/web-common/src/features/dashboards/time-controls/super-pill/new-time-dropdown/TruncationSelector.svelte
+++ b/web-common/src/features/dashboards/time-controls/super-pill/new-time-dropdown/TruncationSelector.svelte
@@ -309,6 +309,7 @@
onclick={() => {
onToggleAlignment(!snapToEnd);
}}
+ label={m.dashboard_anchor_period_end()}
/>
diff --git a/web-common/src/features/dashboards/time-controls/test/canvas-time-filters-variant.ts b/web-common/src/features/dashboards/time-controls/test/canvas-time-filters-variant.ts
new file mode 100644
index 000000000000..e369fad9a248
--- /dev/null
+++ b/web-common/src/features/dashboards/time-controls/test/canvas-time-filters-variant.ts
@@ -0,0 +1,184 @@
+import {
+ getCanvasStore,
+ removeCanvasStore,
+} from "@rilldata/web-common/features/canvas/state-managers/state-managers";
+import { lastVisitedState } from "@rilldata/web-common/features/canvas/stores/canvas-entity";
+import CanvasExpressionFiltersTest from "@rilldata/web-common/features/dashboards/filters/test/CanvasExpressionFiltersTest.svelte";
+import {
+ mockPointerEventsForComponentTesting,
+ mockResizeObserverForComponentTesting,
+ useDashboardFetchMocksForComponentTests,
+ waitForBodyScrollCleanup,
+} from "@rilldata/web-common/features/dashboards/filters/test/filter-test-utils";
+import {
+ type HoistedPageForComponentTests,
+ PageMockForComponentTests,
+} from "@rilldata/web-common/features/dashboards/state-managers/loaders/test/PageMockForComponentTests.ts";
+import {
+ AD_BIDS_METRICS_INIT_WITH_TIME,
+ AD_BIDS_METRICS_NAME,
+} from "@rilldata/web-common/features/dashboards/stores/test-data/data";
+import {
+ DEFAULT_TIME_RANGE,
+ RESOLVED_RILL_TIMES,
+ TIME_RANGE_SUMMARY,
+ YAML_TIME_RANGES,
+ YAML_TIME_ZONES,
+} from "@rilldata/web-common/features/dashboards/time-controls/test/rill-time-mocks";
+import { waitForTimeRangeLabel } from "@rilldata/web-common/features/dashboards/time-controls/test/time-filter-test-utils";
+import type { TimeFiltersVariant } from "@rilldata/web-common/features/dashboards/time-controls/test/time-filters-suite";
+import type { TimeFilterManager } from "@rilldata/web-common/features/dashboards/time-controls/TimeFilterManager.svelte.ts";
+import { queryClient } from "@rilldata/web-common/lib/svelte-query/globalQueryClient";
+import { mockAnimationsForComponentTesting } from "@rilldata/web-common/lib/test/mock-animations";
+import type {
+ V1CanvasSpec,
+ V1ResolvedTimeRange,
+} from "@rilldata/web-common/runtime-client";
+import {
+ RUNTIME_CONTEXT_KEY,
+ RuntimeClient,
+} from "@rilldata/web-common/runtime-client/v2";
+import { render, screen, waitFor } from "@testing-library/svelte";
+import { afterAll, beforeEach, expect } from "vitest";
+
+const INSTANCE_ID = "test";
+
+const AD_BIDS_CANVAS_NAME = "AdBids_canvas";
+// A canvas with no rows, so the filter bar is all that renders. Its metrics view has a time
+// dimension, so the bar renders the time controls.
+const AD_BIDS_CANVAS_INIT: V1CanvasSpec = {
+ displayName: "AdBids canvas",
+ filtersEnabled: true,
+ timeRanges: YAML_TIME_RANGES,
+ timeZones: YAML_TIME_ZONES,
+ defaultPreset: {
+ timeRange: DEFAULT_TIME_RANGE,
+ },
+};
+
+// Url params for the dashboard before any time filter is applied.
+// A canvas with no url state redirects to its default preset, and the time filter then adds the
+// grain of the range it resolved to.
+// A rilltime expression carries characters the url escapes, and the url search history holds the
+// searches verbatim, so these are built through URLSearchParams rather than written out.
+const PageURLForDefaultPreset = new URLSearchParams([
+ ["tr", DEFAULT_TIME_RANGE],
+]).toString();
+const PageURLForInitialState = new URLSearchParams([
+ ["tr", DEFAULT_TIME_RANGE],
+ ["grain", "day"],
+]).toString();
+
+/** The label of the range the dashboard loads with, which is what the picker shows on render. */
+const DefaultTimeRangeLabel = /Last 7 days/;
+
+/**
+ * Renders the time filter bar the way a canvas dashboard does, so that the time range reaches the
+ * canvas entity's time filter manager and the url through `CanvasDashboardWrapper`.
+ *
+ * Registers the hooks the component tests need, so call it from a `describe`.
+ */
+export function useCanvasTimeFiltersVariant(
+ hoistedPage: HoistedPageForComponentTests,
+): TimeFiltersVariant {
+ mockAnimationsForComponentTesting();
+ mockPointerEventsForComponentTesting();
+ mockResizeObserverForComponentTesting();
+ const mocks = useDashboardFetchMocksForComponentTests();
+ let pageMock!: PageMockForComponentTests;
+
+ beforeEach(() => {
+ pageMock = new PageMockForComponentTests(hoistedPage);
+
+ mocks.mockMetricsView(AD_BIDS_METRICS_NAME, AD_BIDS_METRICS_INIT_WITH_TIME);
+ mocks.mockCanvas(AD_BIDS_CANVAS_NAME, AD_BIDS_CANVAS_INIT, {
+ [AD_BIDS_METRICS_NAME]: AD_BIDS_METRICS_INIT_WITH_TIME,
+ });
+ mocks.mockTimeRangeSummary(AD_BIDS_METRICS_NAME, TIME_RANGE_SUMMARY);
+ mocks.mockResolvedRillTimes(AD_BIDS_METRICS_NAME, RESOLVED_RILL_TIMES);
+
+ localStorage.clear();
+ sessionStorage.clear();
+ queryClient.clear();
+ // The canvas store registry and the last visited state both outlive a test, and a canvas that
+ // has a last visited state redirects to it as it loads.
+ removeCanvasStore(AD_BIDS_CANVAS_NAME, INSTANCE_ID);
+ lastVisitedState.clear();
+ });
+
+ afterAll(waitForBodyScrollCleanup);
+
+ // The canvas entity holds the time filter manager, and the entity is rebuilt for every test.
+ const canvasTimeFilterManager = () =>
+ getCanvasStore(AD_BIDS_CANVAS_NAME, INSTANCE_ID).canvasEntity
+ .timeFilterManager;
+
+ return {
+ initialUrlSearch: PageURLForInitialState,
+ // The default preset lands in the url as soon as the dashboard loads.
+ initialUrlSearchHistory: [PageURLForDefaultPreset, PageURLForInitialState],
+
+ urlSearchWithTimeParams: (timeParams: Record
) => {
+ const urlSearch = new URLSearchParams(PageURLForInitialState);
+ Object.entries(timeParams).forEach(([key, value]) =>
+ urlSearch.set(key, value),
+ );
+ return urlSearch.toString();
+ },
+
+ pageMock: () => pageMock,
+
+ timeFilterManager: {
+ getTimeRange: () => testTimeRange(canvasTimeFilterManager()),
+ getTimeGrain: () => canvasTimeFilterManager().timeGrain,
+ getComparisonTimeRange: () =>
+ testComparisonTimeRange(canvasTimeFilterManager()),
+ getComparisonEnabled: () => canvasTimeFilterManager().showComparison,
+ },
+
+ render: async () => {
+ // The canvas loads on an empty url and redirects to its default preset, so nothing is
+ // populated here.
+ render(CanvasExpressionFiltersTest, {
+ props: {
+ canvasName: AD_BIDS_CANVAS_NAME,
+ },
+ // TODO: we need to make sure every single query uses an explicit queryClient instead of the
+ // global one. Only then we can use a fresh client here.
+ context: new Map([
+ ["$$_queryClient", queryClient],
+ [
+ RUNTIME_CONTEXT_KEY,
+ new RuntimeClient({
+ host: "http://localhost",
+ instanceId: INSTANCE_ID,
+ }),
+ ],
+ ]),
+ });
+ await waitFor(() => expect(screen.getByText("Dashboard loaded!")));
+ // Resolving the interval of the initial range is a network call, so the picker is only ready
+ // for interaction once it shows the range the dashboard loaded with.
+ await waitForTimeRangeLabel(DefaultTimeRangeLabel);
+ },
+ };
+}
+
+function testTimeRange(timeFilterManager: TimeFilterManager) {
+ if (!timeFilterManager.timeRange) return undefined;
+ return {
+ expression: timeFilterManager.timeRange,
+ start: timeFilterManager.timeStart,
+ end: timeFilterManager.timeEnd,
+ grain: timeFilterManager.timeGrain,
+ };
+}
+
+function testComparisonTimeRange(timeFilterManager: TimeFilterManager) {
+ if (!timeFilterManager.comparisonTimeRange) return undefined;
+ return {
+ expression: timeFilterManager.comparisonTimeRange,
+ start: timeFilterManager.comparisonTimeStart,
+ end: timeFilterManager.comparisonTimeEnd,
+ };
+}
diff --git a/web-common/src/features/dashboards/time-controls/test/explore-time-filters-variant.ts b/web-common/src/features/dashboards/time-controls/test/explore-time-filters-variant.ts
new file mode 100644
index 000000000000..c81f29bb8d00
--- /dev/null
+++ b/web-common/src/features/dashboards/time-controls/test/explore-time-filters-variant.ts
@@ -0,0 +1,169 @@
+import ExploreExpressionFiltersTest from "@rilldata/web-common/features/dashboards/filters/test/ExploreExpressionFiltersTest.svelte";
+import {
+ mockPointerEventsForComponentTesting,
+ useDashboardFetchMocksForComponentTests,
+ waitForBodyScrollCleanup,
+} from "@rilldata/web-common/features/dashboards/filters/test/filter-test-utils";
+import {
+ type HoistedPageForComponentTests,
+ PageMockForComponentTests,
+} from "@rilldata/web-common/features/dashboards/state-managers/loaders/test/PageMockForComponentTests.ts";
+import { metricsExplorerStore } from "@rilldata/web-common/features/dashboards/stores/dashboard-stores";
+import {
+ AD_BIDS_EXPLORE_INIT,
+ AD_BIDS_EXPLORE_NAME,
+ AD_BIDS_METRICS_INIT_WITH_TIME,
+ AD_BIDS_METRICS_NAME,
+ AD_BIDS_PRESET_WITHOUT_TIMESTAMP,
+} from "@rilldata/web-common/features/dashboards/stores/test-data/data";
+import {
+ DEFAULT_TIME_RANGE,
+ RESOLVED_RILL_TIMES,
+ TIME_RANGE_SUMMARY,
+ YAML_TIME_RANGES,
+ YAML_TIME_ZONES,
+} from "@rilldata/web-common/features/dashboards/time-controls/test/rill-time-mocks";
+import { waitForTimeRangeLabel } from "@rilldata/web-common/features/dashboards/time-controls/test/time-filter-test-utils";
+import type { TimeFiltersVariant } from "@rilldata/web-common/features/dashboards/time-controls/test/time-filters-suite";
+import { queryClient } from "@rilldata/web-common/lib/svelte-query/globalQueryClient";
+import { mockAnimationsForComponentTesting } from "@rilldata/web-common/lib/test/mock-animations";
+import type { DashboardTimeControls } from "@rilldata/web-common/lib/time/types";
+import {
+ RUNTIME_CONTEXT_KEY,
+ RuntimeClient,
+} from "@rilldata/web-common/runtime-client/v2";
+import { render, screen, waitFor } from "@testing-library/svelte";
+import { get } from "svelte/store";
+import { afterAll, beforeEach, expect } from "vitest";
+import type { V1ResolvedTimeRange } from "@rilldata/web-common/runtime-client";
+
+// Url params for the dashboard before any time filter is applied, coming from the yaml preset.
+// A rilltime expression carries characters the url escapes, and the url search history holds the
+// searches verbatim, so these are built through URLSearchParams rather than written out.
+const pageURLForInitialState = (grain: string) =>
+ new URLSearchParams([
+ ["tr", DEFAULT_TIME_RANGE],
+ ["grain", grain],
+ ["measures", "impressions"],
+ ["dims", "publisher"],
+ ["sort_type", "percent"],
+ ["sort_dir", "ASC"],
+ ]).toString();
+const PageURLForInitialState = pageURLForInitialState("day");
+// Explore derives the grain of a range from its resolved start and end, which a component test does
+// not resolve, so the dashboard loads on the smallest grain and the time filter then corrects it to
+// the grain of the range.
+const PageURLBeforeGrainIsResolved = pageURLForInitialState("minute");
+
+/** The label of the range the dashboard loads with, which is what the picker shows on render. */
+const DefaultTimeRangeLabel = /Last 7 days/;
+
+/**
+ * Renders the time filter bar the way the explore dashboard does, so that the time range reaches
+ * the explore state and the url through `DashboardStateSync`.
+ *
+ * Registers the hooks the component tests need, so call it from a `describe`.
+ */
+export function useExploreTimeFiltersVariant(
+ hoistedPage: HoistedPageForComponentTests,
+): TimeFiltersVariant {
+ mockAnimationsForComponentTesting();
+ mockPointerEventsForComponentTesting();
+ const mocks = useDashboardFetchMocksForComponentTests();
+ let pageMock!: PageMockForComponentTests;
+
+ beforeEach(() => {
+ pageMock = new PageMockForComponentTests(hoistedPage);
+
+ mocks.mockMetricsView(AD_BIDS_METRICS_NAME, AD_BIDS_METRICS_INIT_WITH_TIME);
+ mocks.mockMetricsExplore(
+ AD_BIDS_EXPLORE_NAME,
+ AD_BIDS_METRICS_INIT_WITH_TIME,
+ {
+ ...AD_BIDS_EXPLORE_INIT,
+ timeRanges: YAML_TIME_RANGES,
+ timeZones: YAML_TIME_ZONES,
+ defaultPreset: {
+ ...AD_BIDS_PRESET_WITHOUT_TIMESTAMP,
+ timeRange: DEFAULT_TIME_RANGE,
+ },
+ },
+ );
+ mocks.mockTimeRangeSummary(AD_BIDS_METRICS_NAME, TIME_RANGE_SUMMARY);
+ mocks.mockResolvedRillTimes(AD_BIDS_METRICS_NAME, RESOLVED_RILL_TIMES);
+
+ localStorage.clear();
+ sessionStorage.clear();
+ queryClient.clear();
+ metricsExplorerStore.remove(AD_BIDS_EXPLORE_NAME);
+ });
+
+ afterAll(waitForBodyScrollCleanup);
+
+ return {
+ initialUrlSearch: PageURLForInitialState,
+ // The preset lands in the url as soon as the dashboard loads.
+ initialUrlSearchHistory: [
+ PageURLBeforeGrainIsResolved,
+ PageURLForInitialState,
+ ],
+
+ urlSearchWithTimeParams: (timeParams: Record) => {
+ // Overriding the params of the initial url keeps them in the order the dashboard writes them,
+ // which is the order the url search assertions compare against.
+ const urlSearch = new URLSearchParams(PageURLForInitialState);
+ Object.entries(timeParams).forEach(([key, value]) =>
+ urlSearch.set(key, value),
+ );
+ return urlSearch.toString();
+ },
+
+ pageMock: () => pageMock,
+
+ timeFilterManager: {
+ getTimeRange: () => testTimeRange(exploreState()?.selectedTimeRange),
+ getTimeGrain: () => exploreState()?.selectedTimeRange?.interval,
+ getComparisonTimeRange: () =>
+ testTimeRange(exploreState()?.selectedComparisonTimeRange),
+ getComparisonEnabled: () => exploreState()?.showTimeComparison ?? false,
+ },
+
+ render: async (initUrlSearch?: string) => {
+ // Make sure to populate the url with the initial states.
+ pageMock.gotoSearch(PageURLForInitialState);
+ if (initUrlSearch) pageMock.gotoSearch(initUrlSearch);
+ render(ExploreExpressionFiltersTest, {
+ props: {
+ exploreName: AD_BIDS_EXPLORE_NAME,
+ },
+ // TODO: we need to make sure every single query uses an explicit queryClient instead of the
+ // global one. Only then we can use a fresh client here.
+ context: new Map([
+ ["$$_queryClient", queryClient],
+ [
+ RUNTIME_CONTEXT_KEY,
+ new RuntimeClient({ host: "http://localhost", instanceId: "test" }),
+ ],
+ ]),
+ });
+ await waitFor(() => expect(screen.getByText("Dashboard loaded!")));
+ // Resolving the interval of the initial range is a network call, so the picker is only ready
+ // for interaction once it shows the range the dashboard loaded with.
+ await waitForTimeRangeLabel(DefaultTimeRangeLabel);
+ },
+ };
+}
+
+function exploreState() {
+ return get(metricsExplorerStore).entities[AD_BIDS_EXPLORE_NAME];
+}
+
+function testTimeRange(timeRange: DashboardTimeControls | undefined) {
+ if (!timeRange?.name) return undefined;
+ return {
+ expression: timeRange.name,
+ start: timeRange.start.toISOString(),
+ end: timeRange.end.toISOString(),
+ ...("interval" in timeRange ? { grain: timeRange.interval } : {}),
+ };
+}
diff --git a/web-common/src/features/dashboards/time-controls/test/rill-time-mocks.ts b/web-common/src/features/dashboards/time-controls/test/rill-time-mocks.ts
new file mode 100644
index 000000000000..3031b3b47c2a
--- /dev/null
+++ b/web-common/src/features/dashboards/time-controls/test/rill-time-mocks.ts
@@ -0,0 +1,86 @@
+import {
+ type V1ResolvedTimeRange,
+ V1TimeGrain,
+} from "@rilldata/web-common/runtime-client";
+
+/**
+ * The time range summary every time filter test runs against.
+ * `latest` in a rilltime expression is `max`, which deliberately sits mid hour so that a snap to a
+ * grain moves the anchor.
+ */
+export const TIME_RANGE_SUMMARY = {
+ min: "2024-01-01T00:00:00Z",
+ max: "2024-03-31T14:30:00Z",
+ watermark: "2024-03-30T14:30:00Z",
+};
+
+/** The range the yaml preset starts the dashboard on. */
+export const DEFAULT_TIME_RANGE = "7D as of latest/D+1D";
+
+/** The ranges the yaml offers, which is what the time range dropdown lists. */
+export const YAML_TIME_RANGES = [
+ { range: "12h" },
+ { range: "24h" },
+ { range: "4W" },
+];
+
+export const YAML_TIME_ZONES = ["UTC", "America/New_York", "Asia/Kathmandu"];
+
+/**
+ * The intervals the runtime resolves rilltime expressions to, keyed by expression.
+ *
+ * The runtime does the resolving, so a test cannot derive these; every expression its interactions
+ * can produce needs an entry here. The intervals below are `latest` snapped to the grain of the
+ * `as of` clause, offset by its padding, and then walked back by the range.
+ */
+export const RESOLVED_RILL_TIMES: Record = {
+ // latest/D+1D is 2024-04-01T00:00:00Z, minus 7 days.
+ [DEFAULT_TIME_RANGE]: {
+ start: "2024-03-25T00:00:00.000Z",
+ end: "2024-04-01T00:00:00.000Z",
+ grain: V1TimeGrain.TIME_GRAIN_DAY,
+ },
+ "7D as of latest/D": {
+ start: "2024-03-24T00:00:00.000Z",
+ end: "2024-03-31T00:00:00.000Z",
+ grain: V1TimeGrain.TIME_GRAIN_DAY,
+ },
+ // Hardcoded `now` to 2024-05-03T12:00:00Z
+ "7D as of now/D+1D": {
+ start: "2024-04-27T00:00:00.000Z",
+ end: "2024-05-04T00:00:00.000Z",
+ grain: V1TimeGrain.TIME_GRAIN_DAY,
+ },
+ "7D as of now/h+1h": {
+ start: "2024-04-26T13:00:00.000Z",
+ end: "2024-05-03T13:00:00.000Z",
+ grain: V1TimeGrain.TIME_GRAIN_DAY,
+ },
+ // The same anchor, minus 4 weeks.
+ "4W as of latest/D+1D": {
+ start: "2024-03-04T00:00:00.000Z",
+ end: "2024-04-01T00:00:00.000Z",
+ grain: V1TimeGrain.TIME_GRAIN_DAY,
+ },
+ // latest/h+1h is 2024-03-31T15:00:00Z, minus 12 hours.
+ "12h as of latest/h+1h": {
+ start: "2024-03-31T03:00:00.000Z",
+ end: "2024-03-31T15:00:00.000Z",
+ grain: V1TimeGrain.TIME_GRAIN_HOUR,
+ },
+ // latest/h+1h is 2024-03-31T15:00:00Z, minus 24 hours.
+ "24h as of latest/h+1h": {
+ start: "2024-03-30T15:00:00.000Z",
+ end: "2024-03-31T15:00:00.000Z",
+ grain: V1TimeGrain.TIME_GRAIN_HOUR,
+ },
+};
+
+/** The time range a test expects once `timeRange` is applied, resolved interval included. */
+export function resolvedTimeRange(timeRange: string) {
+ const resolved = RESOLVED_RILL_TIMES[timeRange];
+ if (!resolved) {
+ throw new Error(`No resolved interval mocked for "${timeRange}"`);
+ }
+ return { expression: timeRange, ...resolved };
+}
diff --git a/web-common/src/features/dashboards/time-controls/test/time-filter-test-utils.ts b/web-common/src/features/dashboards/time-controls/test/time-filter-test-utils.ts
new file mode 100644
index 000000000000..bab68fb3140e
--- /dev/null
+++ b/web-common/src/features/dashboards/time-controls/test/time-filter-test-utils.ts
@@ -0,0 +1,108 @@
+import { act, screen, waitFor } from "@testing-library/svelte";
+import { expect } from "vitest";
+
+/** The picker trigger, whose text content is the time range as the user sees it. */
+export function getTimeRangePicker() {
+ return screen.getByLabelText("Select time range");
+}
+
+/** Opens the time range dropdown. */
+export async function openTimeRangePicker() {
+ await act(() => getTimeRangePicker().click());
+ await waitFor(() =>
+ expect(screen.getByRole("menuitem", { name: /All time/ })).toBeVisible(),
+ );
+}
+
+/**
+ * Picks the range labelled `label` in the time range dropdown and waits for it to be applied.
+ * The menu items carry their rilltime syntax alongside the label,
+ * so `label` is a pattern rather than the accessible name in full.
+ */
+export async function selectTimeRange(label: RegExp) {
+ await openTimeRangePicker();
+ await act(() => screen.getByRole("menuitem", { name: label }).click());
+ // Applying a range needs a network call to resolve its interval,
+ // so the picker only shows the new label once the range has reached the dashboard.
+ // TODO: fix this and make sure the dashboard has the new name immediately and resolved async
+ await waitForTimeRangeLabel(label);
+}
+
+/** Waits for the picker to show `label`, which it does once the range is applied. */
+export async function waitForTimeRangeLabel(label: RegExp) {
+ await waitFor(() => expect(getTimeRangePicker()).toHaveTextContent(label));
+}
+
+export function getSnapPicker() {
+ return screen.getByLabelText("Select reference time and grain");
+}
+
+export async function openSnapPicker() {
+ await act(() => getSnapPicker().click());
+ await waitFor(() =>
+ expect(
+ screen.getByRole("menuitemcheckbox", { name: /latest/ }),
+ ).toBeVisible(),
+ );
+}
+
+export async function selectSnapRefOrGrain(
+ label: string,
+ selectionLabel: string,
+) {
+ await openSnapPicker();
+ await act(() =>
+ screen.getByRole("menuitemcheckbox", { name: label }).click(),
+ );
+ // Applying a range needs a network call to resolve its interval,
+ // so the picker only shows the new label once the range has reached the dashboard.
+ // TODO: fix this and make sure the dashboard has the new name immediately and resolved async
+ await waitFor(() =>
+ expect(getSnapPicker()).toHaveTextContent(selectionLabel),
+ );
+}
+
+function getSnapOffsetToggle() {
+ return screen.getByLabelText("Anchor to period end");
+}
+
+export async function snapOffsetToggleIsDisabled() {
+ await openSnapPicker();
+ await waitFor(() => expect(getSnapOffsetToggle()).toBeVisible());
+ await act(() => getSnapPicker().click());
+}
+
+export async function toggleSnapOffset(label: string) {
+ await openSnapPicker();
+ await act(() => getSnapOffsetToggle().click());
+ await waitFor(() => expect(getSnapPicker()).toHaveTextContent(label));
+ // Snap toggle doesnt auto toggle
+ await act(() => getSnapPicker().click());
+}
+
+export function getComparisonTimeRangePicker() {
+ return screen.getByLabelText("Select time comparison option");
+}
+
+export async function openComparisonTimeRangePicker() {
+ await act(() => getComparisonTimeRangePicker().click());
+ await waitFor(() =>
+ expect(screen.getByRole("menuitem", { name: "Custom" })).toBeVisible(),
+ );
+}
+
+export async function selectComparisonTimeRange(label: string) {
+ await openComparisonTimeRangePicker();
+ await act(() => screen.getByRole("menuitem", { name: label }).click());
+ await waitFor(() =>
+ expect(getComparisonTimeRangePicker()).toHaveTextContent(label),
+ );
+}
+
+export function getToggleComparisonPicker() {
+ return screen.getByLabelText("Toggle time comparison");
+}
+
+export async function toggleComparison() {
+ await act(() => getToggleComparisonPicker().click());
+}
diff --git a/web-common/src/features/dashboards/time-controls/test/time-filters-suite.ts b/web-common/src/features/dashboards/time-controls/test/time-filters-suite.ts
new file mode 100644
index 000000000000..2d03adfc5c06
--- /dev/null
+++ b/web-common/src/features/dashboards/time-controls/test/time-filters-suite.ts
@@ -0,0 +1,294 @@
+import {
+ type V1ResolvedTimeRange,
+ V1TimeGrain,
+} from "@rilldata/web-common/runtime-client";
+import type { PageMockForComponentTests } from "@rilldata/web-common/features/dashboards/state-managers/loaders/test/PageMockForComponentTests.ts";
+import {
+ DEFAULT_TIME_RANGE,
+ resolvedTimeRange,
+} from "@rilldata/web-common/features/dashboards/time-controls/test/rill-time-mocks";
+import {
+ selectComparisonTimeRange,
+ selectSnapRefOrGrain,
+ selectTimeRange,
+ snapOffsetToggleIsDisabled,
+ toggleComparison,
+ toggleSnapOffset,
+} from "@rilldata/web-common/features/dashboards/time-controls/test/time-filter-test-utils";
+import { describe, expect, it } from "vitest";
+
+/**
+ * How a test reads the filter state back.
+ * Explore takes it from the dashboard store the filter bar writes to,
+ * while a standalone filter bar has the manager itself as the only holder of the state.
+ * TODO: this should not be needed and all variants should read/write from the manager.
+ */
+export interface TimeFilterManagerAccessor {
+ getTimeRange(): V1ResolvedTimeRange | undefined;
+ getTimeGrain(): V1TimeGrain | undefined;
+ getComparisonTimeRange(): V1ResolvedTimeRange | undefined;
+ getComparisonEnabled(): boolean;
+}
+
+/**
+ * One way of rendering the filter bar.
+ * The tests in this file drive the UI identically for every variant,
+ * so a variant only covers where the filter state ends up and how it reaches the url.
+ */
+export interface TimeFiltersVariant {
+ /** Renders the filter bar and resolves once it is ready for interaction. */
+ render(initUrlSearch?: string): Promise;
+ timeFilterManager: TimeFilterManagerAccessor;
+ /** Url search of the dashboard before any filter is applied. */
+ initialUrlSearch: string;
+ /** Url searches the dashboard goes through before the first filter is applied. */
+ initialUrlSearchHistory: string[];
+ /** Url search once `timeParams`, the time params of the filter bar, are applied. */
+ urlSearchWithTimeParams(timeParams: Record): string;
+ /** A method rather than a field, since the page mock is rebuilt for every test. */
+ pageMock(): PageMockForComponentTests;
+ /** If true, the url search assertions will be no-op. */
+ noUrlSync?: boolean;
+}
+
+/**
+ * The assertions that differ between variants, bound to one of them.
+ * Every test asserts through these, so the test bodies themselves stay variant agnostic.
+ */
+function variantAssertions(variant: TimeFiltersVariant) {
+ return {
+ initialUrlSearch: variant.initialUrlSearch,
+ urlSearchWithTimeParams: (timeParams: Record) =>
+ variant.urlSearchWithTimeParams(timeParams),
+
+ assertTimeRange: (expected: V1ResolvedTimeRange) =>
+ expect(variant.timeFilterManager.getTimeRange()).toEqual(expected),
+
+ assertTimeGrain: (expected: V1TimeGrain) =>
+ expect(variant.timeFilterManager.getTimeGrain()).toEqual(expected),
+
+ assertComparisonTimeRange: (expected: V1ResolvedTimeRange) =>
+ expect(variant.timeFilterManager.getComparisonTimeRange()).toEqual(
+ expected,
+ ),
+
+ assertComparisonEnabled: (expected: boolean) =>
+ expect(variant.timeFilterManager.getComparisonEnabled()).toEqual(
+ expected,
+ ),
+
+ assertUrlSearch: (expectedSearch: string) => {
+ if (variant.noUrlSync) return;
+ variant.pageMock().assertSearchParams(expectedSearch);
+ },
+
+ /**
+ * Asserts that the dashboard went through exactly `searches` after it loaded,
+ * which is how the tests catch extra history entries.
+ */
+ assertUrlSearchHistory: (...searches: string[]) => {
+ if (variant.noUrlSync) return;
+ variant
+ .pageMock()
+ .assertSearchHistory([...variant.initialUrlSearchHistory, ...searches]);
+ },
+ };
+}
+
+export function testTimeRangeFilters(variant: TimeFiltersVariant) {
+ const {
+ urlSearchWithTimeParams,
+ assertTimeRange,
+ assertTimeGrain,
+ assertUrlSearch,
+ assertUrlSearchHistory,
+ } = variantAssertions(variant);
+
+ describe("Time range filters", () => {
+ it("Should change time range and keep snap", async () => {
+ await variant.render();
+
+ // The dashboard loads on the yaml default, snapped to the day and padded to include today.
+ assertTimeRange(resolvedTimeRange(DEFAULT_TIME_RANGE));
+ assertTimeGrain(V1TimeGrain.TIME_GRAIN_DAY);
+
+ // A range coarser than the snap keeps the whole `as of` clause, so the new range is anchored
+ // at the same point in time as the one it replaces.
+ await selectTimeRange(/Last 4 weeks/);
+
+ const weeksTimeRange = "4W as of latest/D+1D";
+ assertTimeRange(resolvedTimeRange(weeksTimeRange));
+ // Day is still a grain the wider interval allows, so the grain carries over as well.
+ assertTimeGrain(V1TimeGrain.TIME_GRAIN_DAY);
+ const weeksUrlSearch = urlSearchWithTimeParams({ tr: weeksTimeRange });
+ assertUrlSearch(weeksUrlSearch);
+ // Applying the range should add a single entry to history.
+ assertUrlSearchHistory(weeksUrlSearch);
+
+ // A range finer than the snap narrows it to the grain of the range, keeping the offset.
+ await selectTimeRange(/Last 12 hours/);
+
+ const hoursTimeRange = "12h as of latest/h+1h";
+ assertTimeRange(resolvedTimeRange(hoursTimeRange));
+ assertTimeGrain(V1TimeGrain.TIME_GRAIN_HOUR);
+ const hoursUrlSearch = urlSearchWithTimeParams({
+ tr: hoursTimeRange,
+ grain: "hour",
+ });
+ assertUrlSearch(hoursUrlSearch);
+ assertUrlSearchHistory(weeksUrlSearch, hoursUrlSearch);
+ });
+
+ it("Should change reference and snap grains", async () => {
+ await variant.render();
+
+ // Select 'watermark' as reference
+ await selectSnapRefOrGrain("current time", "as of current day end");
+
+ const nowTimeRange = "7D as of now/D+1D";
+ assertTimeRange(resolvedTimeRange(nowTimeRange));
+ // Day is still the grain.
+ assertTimeGrain(V1TimeGrain.TIME_GRAIN_DAY);
+ const nowUrlSearch = urlSearchWithTimeParams({
+ tr: nowTimeRange,
+ });
+ assertUrlSearch(nowUrlSearch);
+ // Applying the changes should add a single entry to history.
+ assertUrlSearchHistory(nowUrlSearch);
+
+ // Watermark snap doesn't allow snap to end
+ await snapOffsetToggleIsDisabled();
+
+ // Select 'hour' as snap grain
+ await selectSnapRefOrGrain("hour", "as of current hour end");
+ const nowHourTimeRange = "7D as of now/h+1h";
+ assertTimeRange(resolvedTimeRange(nowHourTimeRange));
+ assertTimeGrain(V1TimeGrain.TIME_GRAIN_DAY); // Snap doesnt change the selected grain
+ const nowHourUrlSearch = urlSearchWithTimeParams({
+ tr: nowHourTimeRange,
+ grain: "day",
+ });
+ assertUrlSearch(nowHourUrlSearch);
+ assertUrlSearchHistory(nowUrlSearch, nowHourUrlSearch);
+ });
+
+ it("Should change snap offset", async () => {
+ await variant.render();
+
+ // Toggle to start of time range
+ await toggleSnapOffset("start");
+
+ const defaultStartTimeRange = "7D as of latest/D";
+ assertTimeRange(resolvedTimeRange(defaultStartTimeRange));
+ // Day is still the grain.
+ assertTimeGrain(V1TimeGrain.TIME_GRAIN_DAY);
+ const defaultStartUrlSearch = urlSearchWithTimeParams({
+ tr: defaultStartTimeRange,
+ });
+ assertUrlSearch(defaultStartUrlSearch);
+ // Applying the changes should add a single entry to history.
+ assertUrlSearchHistory(defaultStartUrlSearch);
+ });
+ });
+}
+
+export function testComparisonTimeRangeFilters(variant: TimeFiltersVariant) {
+ const {
+ urlSearchWithTimeParams,
+ assertComparisonTimeRange,
+ assertComparisonEnabled,
+ assertUrlSearch,
+ assertUrlSearchHistory,
+ } = variantAssertions(variant);
+
+ describe("Comparison Time range filters", () => {
+ it("Should toggle comparison time range", async () => {
+ await variant.render();
+
+ assertComparisonEnabled(false);
+ // Comparison time range is always calculated
+ assertComparisonTimeRange({
+ expression: "rill-PP",
+ start: "2024-03-18T00:00:00.000Z",
+ end: "2024-03-25T00:00:00.000Z",
+ });
+ await toggleComparison();
+ assertComparisonEnabled(true);
+ // Comparison time range didnt change after toggling.
+ assertComparisonTimeRange({
+ expression: "rill-PP",
+ start: "2024-03-18T00:00:00.000Z",
+ end: "2024-03-25T00:00:00.000Z",
+ });
+
+ // Url is updated with comparison after enabling
+ const defaultComparisonUrl = urlSearchWithTimeParams({
+ compare_tr: "rill-PP",
+ });
+ assertUrlSearch(defaultComparisonUrl);
+
+ await selectTimeRange(/Last 4 weeks/);
+ assertComparisonEnabled(true);
+ // Comparison time range did change after changing time range.
+ assertComparisonTimeRange({
+ expression: "rill-PP",
+ start: "2024-02-05T00:00:00.000Z",
+ end: "2024-03-04T00:00:00.000Z",
+ });
+
+ // Url is updated with comparison after enabling
+ const last4WeeksComparisonUrl = urlSearchWithTimeParams({
+ tr: "4W as of latest/D+1D",
+ compare_tr: "rill-PP",
+ });
+ assertUrlSearch(last4WeeksComparisonUrl);
+
+ // Only 2 entries are present
+ assertUrlSearchHistory(defaultComparisonUrl, last4WeeksComparisonUrl);
+ });
+
+ it("Should change comparison range on selection", async () => {
+ await variant.render();
+
+ await toggleComparison();
+ assertComparisonEnabled(true);
+ const defaultComparisonUrl = urlSearchWithTimeParams({
+ compare_tr: "rill-PP",
+ });
+
+ await selectComparisonTimeRange("Previous week");
+ assertComparisonTimeRange({
+ expression: "rill-PW",
+ start: "2024-03-18T00:00:00.000Z",
+ end: "2024-03-25T00:00:00.000Z",
+ });
+ assertComparisonEnabled(true);
+
+ const previousWeekComparisonUrl = urlSearchWithTimeParams({
+ compare_tr: "rill-PW",
+ });
+ assertUrlSearch(previousWeekComparisonUrl);
+
+ await selectTimeRange(/Last 24 hours/);
+ // Previous week selection was retained and calculated for 24 hrs instead
+ assertComparisonTimeRange({
+ expression: "rill-PW",
+ start: "2024-03-23T15:00:00.000Z",
+ end: "2024-03-24T15:00:00.000Z",
+ });
+
+ const last24PreviousWeekComparisonUrl = urlSearchWithTimeParams({
+ tr: "24h as of latest/h+1h",
+ compare_tr: "rill-PW",
+ });
+ assertUrlSearch(last24PreviousWeekComparisonUrl);
+
+ // Only 3 entries are present
+ assertUrlSearchHistory(
+ defaultComparisonUrl,
+ previousWeekComparisonUrl,
+ last24PreviousWeekComparisonUrl,
+ );
+ });
+ });
+}
diff --git a/web-common/src/features/dashboards/time-controls/time-filters-config.ts b/web-common/src/features/dashboards/time-controls/time-filters-config.ts
new file mode 100644
index 000000000000..c1aa7867b556
--- /dev/null
+++ b/web-common/src/features/dashboards/time-controls/time-filters-config.ts
@@ -0,0 +1,12 @@
+export type TimeFiltersConfig = {
+ hidePan?: boolean;
+ showGrainSelector?: boolean; // TODO: not used in new picker?
+ showTimeDimensionSelector?: boolean;
+ showComparisonSelector?: boolean;
+ allowCustomTimeRange?: boolean;
+ lockTimeZone?: boolean;
+ showFullRange?: boolean;
+ showWatermark?: boolean;
+
+ side?: "top" | "right" | "bottom" | "left";
+};
diff --git a/web-common/src/features/dashboards/time-controls/time-range-mappers.ts b/web-common/src/features/dashboards/time-controls/time-range-mappers.ts
index 4b2466118d46..8e8cd9e3a396 100644
--- a/web-common/src/features/dashboards/time-controls/time-range-mappers.ts
+++ b/web-common/src/features/dashboards/time-controls/time-range-mappers.ts
@@ -15,6 +15,7 @@ import {
type V1TimeRange,
type V1TimeRangeSummary,
} from "@rilldata/web-common/runtime-client";
+import type { TimeFilterManager } from "@rilldata/web-common/features/dashboards/time-controls/TimeFilterManager.svelte.ts";
// Temporary fix to split previous complete ranges to duration and round to grain to get it working on backend
// TODO: Eventually we should support this in the backend.
@@ -236,3 +237,146 @@ export function mapV1TimeRangeToSelectedComparisonTimeRange(
return selectedTimeRange;
}
+
+export function mapTimeManagerRangeToV1TimeRange(
+ timeFilterManager: TimeFilterManager | undefined,
+): V1TimeRange | undefined {
+ if (!timeFilterManager?.timeRange) return undefined;
+ if (timeFilterManager.parsedTime) {
+ return {
+ expression: timeFilterManager.timeRange,
+ timeZone: timeFilterManager.timeZone,
+ };
+ }
+
+ // Legacy rill-time handling
+ const timeRange: V1TimeRange = {};
+ switch (timeFilterManager.timeRange) {
+ case TimeRangePreset.CUSTOM:
+ timeRange.start = timeFilterManager.timeStart;
+ timeRange.end = timeFilterManager.timeEnd;
+ break;
+
+ default:
+ if (timeFilterManager.timeRange in PreviousCompleteRangeMap) {
+ const prevCompleteTimeRange: V1TimeRange | undefined =
+ PreviousCompleteRangeMap[timeFilterManager.timeRange];
+ // Backend doesn't support previous complete ranges since it has offset built in.
+ // We add the offset manually as a workaround for now
+ timeRange.isoDuration = prevCompleteTimeRange?.isoDuration;
+ timeRange.isoOffset = prevCompleteTimeRange?.isoOffset;
+ timeRange.roundToGrain = prevCompleteTimeRange?.roundToGrain;
+ } else {
+ timeRange.isoDuration = timeFilterManager.timeRange;
+ }
+ break;
+ }
+
+ timeRange.timeZone = timeFilterManager.timeZone;
+
+ return timeRange;
+}
+
+export function mapTimeManagerComparisonRangeToV1TimeRange(
+ timeFilterManager: TimeFilterManager | undefined,
+ timeRange: V1TimeRange | undefined,
+): V1TimeRange | undefined {
+ if (
+ !timeRange ||
+ !timeFilterManager ||
+ !timeFilterManager.showComparison ||
+ !timeFilterManager.comparisonTimeRange
+ ) {
+ return undefined;
+ }
+
+ let isoDuration = timeRange.isoDuration;
+ const name = timeFilterManager.comparisonTimeRange;
+
+ if (timeRange.expression && TIME_COMPARISON[name]?.rillTimeOffset) {
+ const rt = parseRillTime(timeRange.expression);
+ if (!rt.isOldFormat) {
+ return {
+ expression:
+ rt.toString() + " offset " + TIME_COMPARISON[name]?.rillTimeOffset,
+ };
+ } else {
+ // Handle old syntax differently until we have the backend parser updated.
+ isoDuration = timeRange.expression;
+ }
+ }
+
+ const comparisonTimeRange: V1TimeRange = {};
+ switch (name) {
+ default:
+ comparisonTimeRange.isoOffset = name;
+ comparisonTimeRange.isoDuration = isoDuration;
+ break;
+ case TimeComparisonOption.CONTIGUOUS:
+ comparisonTimeRange.isoOffset = comparisonTimeRange.isoDuration =
+ isoDuration;
+ break;
+
+ case TimeComparisonOption.CUSTOM:
+ comparisonTimeRange.start = timeFilterManager.comparisonTimeStart;
+ comparisonTimeRange.end = timeFilterManager.comparisonTimeEnd;
+ break;
+ }
+ return comparisonTimeRange;
+}
+
+export function mapV1TimeRangeToRillTime(timeRange: V1TimeRange) {
+ let duration = timeRange.isoDuration;
+
+ const fullRangeKey = `${timeRange.isoDuration ?? ""}_${timeRange.isoOffset ?? ""}_${timeRange.roundToGrain ?? ""}`;
+ if (fullRangeKey in PreviousCompleteRangeReverseMap) {
+ duration = PreviousCompleteRangeReverseMap[fullRangeKey];
+ }
+
+ if (timeRange.start && timeRange.end) {
+ return `${timeRange.start} to ${timeRange.end}`;
+ }
+ return duration ?? timeRange.expression;
+}
+
+export function mapV1TimeRangeToComparisonTimeOption(
+ primaryTimeRange: V1TimeRange,
+ timeRange: V1TimeRange,
+) {
+ let duration = timeRange.isoOffset;
+
+ const fullRangeKey = `${timeRange.isoDuration ?? ""}_${timeRange.isoOffset ?? ""}_${timeRange.roundToGrain ?? ""}`;
+ if (fullRangeKey in PreviousCompleteRangeReverseMap) {
+ duration = PreviousCompleteRangeReverseMap[fullRangeKey];
+ }
+
+ if (timeRange.start && timeRange.end) {
+ return `${timeRange.start} to ${timeRange.end}`;
+ } else if (
+ timeRange.isoOffset &&
+ timeRange.isoOffset === timeRange.isoDuration
+ ) {
+ return TimeComparisonOption.CONTIGUOUS;
+ } else if (timeRange.expression) {
+ if (
+ primaryTimeRange.expression &&
+ timeRange.expression.startsWith(primaryTimeRange.expression)
+ ) {
+ const offset = timeRange.expression.replace(
+ primaryTimeRange.expression + " offset ",
+ "",
+ );
+ for (const comparisonOption in TIME_COMPARISON) {
+ if (TIME_COMPARISON[comparisonOption].rillTimeOffset === offset) {
+ return comparisonOption;
+ }
+ }
+
+ return timeRange.expression;
+ }
+
+ return timeRange.expression;
+ }
+
+ return duration;
+}
diff --git a/web-common/src/features/dashboards/time-controls/time-range-utils.ts b/web-common/src/features/dashboards/time-controls/time-range-utils.ts
index 1bb325c70e2a..339e5197b05a 100644
--- a/web-common/src/features/dashboards/time-controls/time-range-utils.ts
+++ b/web-common/src/features/dashboards/time-controls/time-range-utils.ts
@@ -3,62 +3,19 @@
* this file should be deprecated in favor of the other time utils.
*
* */
-import type { TimeRange } from "@rilldata/web-common/lib/time/types";
-import { V1TimeGrain } from "@rilldata/web-common/runtime-client";
-import { TimeRangeName_DEPRECATE } from "./time-control-types";
-
+import {
+ type MetricsViewSpecDimension,
+ MetricsViewSpecDimensionType,
+ V1TimeGrain,
+} from "@rilldata/web-common/runtime-client";
import { TIME_GRAIN } from "@rilldata/web-common/lib/time/config";
import { durationToMillis } from "@rilldata/web-common/lib/time/grains";
-
-// May not need this anymore as using TimeGrain objects
-export const supportedTimeGrainEnums = () => {
- const supportedEnums: string[] = [];
- const unsupportedTypes = [
- V1TimeGrain.TIME_GRAIN_UNSPECIFIED,
- V1TimeGrain.TIME_GRAIN_MILLISECOND,
- V1TimeGrain.TIME_GRAIN_SECOND,
- ];
-
- for (const timeGrain in V1TimeGrain) {
- if (unsupportedTypes.includes(V1TimeGrain[timeGrain])) {
- continue;
- }
- supportedEnums.push(timeGrain);
- }
-
- return supportedEnums;
-};
-
-// Moved to time range and renamed to isTimeRangeValidForMinTimeGrain
-export function isTimeRangeValidForTimeGrain(
- minTimeGrain: V1TimeGrain,
- timeRange: TimeRangeName_DEPRECATE,
-): boolean {
- const timeGrainEnums = Object.values(TIME_GRAIN).map(
- (timeGrain) => timeGrain.grain,
- );
- if (!timeGrainEnums.includes(minTimeGrain)) {
- return true;
- }
- if (!timeRange || timeRange === TimeRangeName_DEPRECATE.ALL_TIME) {
- return true;
- }
-
- const timeRangeDurationMs = getLastXTimeRangeDurationMs(timeRange);
-
- const allowedTimeGrains = getAllowedTimeGrains(timeRangeDurationMs);
- const maxAllowedTimeGrain = allowedTimeGrains[allowedTimeGrains.length - 1];
- return !isGrainBigger(minTimeGrain, maxAllowedTimeGrain);
-}
-
-// Moved to time-grain and renamed
-export function isGrainBigger(
- grain1: V1TimeGrain,
- grain2: V1TimeGrain,
-): boolean {
- if (grain1 === V1TimeGrain.TIME_GRAIN_UNSPECIFIED) return false;
- return getTimeGrainDurationMs(grain1) > getTimeGrainDurationMs(grain2);
-}
+import { TimeComparisonOption } from "@rilldata/web-common/lib/time/types.ts";
+import { parseRillTime } from "@rilldata/web-common/features/dashboards/url-state/time-ranges/parser.ts";
+import {
+ RillLegacyDaxInterval,
+ RillPeriodToGrainInterval,
+} from "@rilldata/web-common/features/dashboards/url-state/time-ranges/RillTime.ts";
// Moved
export function getAllowedTimeGrains(timeRangeDurationMs) {
@@ -142,97 +99,69 @@ export function getDefaultTimeGrain(start: Date, end: Date): V1TimeGrain {
}
}
-// Not needed
-export const timeGrainStringToEnum = (timeGrain: string): V1TimeGrain => {
- switch (timeGrain) {
- case "minute":
- return V1TimeGrain.TIME_GRAIN_MINUTE;
- case "hour":
- return V1TimeGrain.TIME_GRAIN_HOUR;
- case "day":
- return V1TimeGrain.TIME_GRAIN_DAY;
- case "week":
- return V1TimeGrain.TIME_GRAIN_WEEK;
- case "month":
- return V1TimeGrain.TIME_GRAIN_MONTH;
- case "year":
- return V1TimeGrain.TIME_GRAIN_YEAR;
- default:
- return V1TimeGrain.TIME_GRAIN_UNSPECIFIED;
- }
-};
+export function getTimeDimensionOptions(
+ dimensions: MetricsViewSpecDimension[],
+ restrictedDimensions: string[] | undefined,
+) {
+ const timeDimensions = dimensions.filter(
+ (d) =>
+ d.type === MetricsViewSpecDimensionType.DIMENSION_TYPE_TIME &&
+ (!restrictedDimensions || restrictedDimensions.includes(d.name!)),
+ );
-// Not needed
-export const timeGrainEnumToYamlString = (timeGrain: V1TimeGrain): string => {
- if (!timeGrain) return "";
- switch (timeGrain) {
- case V1TimeGrain.TIME_GRAIN_MINUTE:
- return "minute";
- case V1TimeGrain.TIME_GRAIN_HOUR:
- return "hour";
- case V1TimeGrain.TIME_GRAIN_DAY:
- return "day";
- case V1TimeGrain.TIME_GRAIN_WEEK:
- return "week";
- case V1TimeGrain.TIME_GRAIN_MONTH:
- return "month";
- case V1TimeGrain.TIME_GRAIN_YEAR:
- return "year";
- default:
- return timeGrain;
+ if (restrictedDimensions) {
+ timeDimensions.sort(
+ (a, b) =>
+ restrictedDimensions.indexOf(a.name!) -
+ restrictedDimensions.indexOf(b.name!),
+ );
}
-};
-// This is the wrong way to deal with this. We should be (1) calculating the time range first
-// then (2) getting the exact duration.
-const getLastXTimeRangeDurationMs = (name: TimeRangeName_DEPRECATE): number => {
- switch (name) {
- case TimeRangeName_DEPRECATE.LAST_SIX_HOURS:
- return durationToMillis(TIME_GRAIN.TIME_GRAIN_HOUR.duration) * 6;
- case TimeRangeName_DEPRECATE.LAST_24_HOURS:
- return durationToMillis(TIME_GRAIN.TIME_GRAIN_DAY.duration);
- case TimeRangeName_DEPRECATE.LAST_7_DAYS:
- return durationToMillis(TIME_GRAIN.TIME_GRAIN_DAY.duration) * 7;
- case TimeRangeName_DEPRECATE.LAST_4_WEEKS:
- return durationToMillis(TIME_GRAIN.TIME_GRAIN_DAY.duration) * 28;
+ return timeDimensions.map((timeDim) => {
+ return {
+ value: timeDim.name!,
+ label: timeDim.displayName || timeDim.name!,
+ description: timeDim.description,
+ };
+ });
+}
- default:
- throw new Error(`Unknown last X time range name: ${name}`);
+export function getComparisonTypeFromRangeString(
+ range: string | undefined,
+): TimeComparisonOption {
+ if (!range) {
+ return TimeComparisonOption.CONTIGUOUS;
}
-};
-
-// map from time grain to duration in ms.
-const getTimeGrainDurationMs = (timeGrain: V1TimeGrain): number => {
- switch (timeGrain) {
- case V1TimeGrain.TIME_GRAIN_MINUTE:
- return durationToMillis(TIME_GRAIN.TIME_GRAIN_MINUTE.duration);
- case V1TimeGrain.TIME_GRAIN_HOUR:
- return durationToMillis(TIME_GRAIN.TIME_GRAIN_HOUR.duration);
- case V1TimeGrain.TIME_GRAIN_DAY:
- return durationToMillis(TIME_GRAIN.TIME_GRAIN_DAY.duration);
- case V1TimeGrain.TIME_GRAIN_WEEK:
- return durationToMillis(TIME_GRAIN.TIME_GRAIN_DAY.duration) * 7;
- case V1TimeGrain.TIME_GRAIN_MONTH:
- return durationToMillis(TIME_GRAIN.TIME_GRAIN_DAY.duration) * 30;
- case V1TimeGrain.TIME_GRAIN_YEAR:
- return durationToMillis(TIME_GRAIN.TIME_GRAIN_YEAR.duration);
- default:
- throw new Error(`Unknown time grain: ${timeGrain}`);
+ try {
+ const { interval, rangeGrain } = parseRillTime(range);
+
+ if (
+ interval instanceof RillLegacyDaxInterval ||
+ interval instanceof RillPeriodToGrainInterval
+ ) {
+ return rangeGrain && rangeGrain in timeGrainToComparisonOptionMap
+ ? timeGrainToComparisonOptionMap[rangeGrain]
+ : TimeComparisonOption.CONTIGUOUS;
+ } else {
+ return TimeComparisonOption.CONTIGUOUS;
+ }
+ } catch {
+ return TimeComparisonOption.CONTIGUOUS;
}
-};
-
-// might not need it
-export function makeRelativeTimeRange(
- timeRangeName: TimeRangeName_DEPRECATE,
- allTimeRange: TimeRange,
-): TimeRange {
- if (timeRangeName === TimeRangeName_DEPRECATE.ALL_TIME) return allTimeRange;
- const startTime = new Date(
- allTimeRange.end.getTime() - getLastXTimeRangeDurationMs(timeRangeName),
- );
- return {
- name: timeRangeName,
- start: startTime,
- end: allTimeRange.end,
- };
}
+
+const timeGrainToComparisonOptionMap: Record<
+ V1TimeGrain,
+ TimeComparisonOption
+> = {
+ [V1TimeGrain.TIME_GRAIN_MILLISECOND]: TimeComparisonOption.CONTIGUOUS,
+ [V1TimeGrain.TIME_GRAIN_SECOND]: TimeComparisonOption.CONTIGUOUS,
+ [V1TimeGrain.TIME_GRAIN_MINUTE]: TimeComparisonOption.CONTIGUOUS,
+ [V1TimeGrain.TIME_GRAIN_HOUR]: TimeComparisonOption.CONTIGUOUS,
+ [V1TimeGrain.TIME_GRAIN_DAY]: TimeComparisonOption.DAY,
+ [V1TimeGrain.TIME_GRAIN_WEEK]: TimeComparisonOption.WEEK,
+ [V1TimeGrain.TIME_GRAIN_MONTH]: TimeComparisonOption.MONTH,
+ [V1TimeGrain.TIME_GRAIN_QUARTER]: TimeComparisonOption.QUARTER,
+ [V1TimeGrain.TIME_GRAIN_YEAR]: TimeComparisonOption.YEAR,
+ [V1TimeGrain.TIME_GRAIN_UNSPECIFIED]: TimeComparisonOption.CONTIGUOUS,
+};
diff --git a/web-common/src/features/dashboards/time-dimension-details/TDDHeader.svelte b/web-common/src/features/dashboards/time-dimension-details/TDDHeader.svelte
index 71531e532ef2..0e72a240e989 100644
--- a/web-common/src/features/dashboards/time-dimension-details/TDDHeader.svelte
+++ b/web-common/src/features/dashboards/time-dimension-details/TDDHeader.svelte
@@ -11,17 +11,11 @@
dimensionSearchText,
metricsExplorerStore,
} from "@rilldata/web-common/features/dashboards/stores/dashboard-stores";
- import ComparisonSelector from "@rilldata/web-common/features/dashboards/time-controls/ComparisonSelector.svelte";
+ import ComparisonSelector from "@rilldata/web-common/features/dashboards/time-controls/comparison/ComparisonSelector.svelte";
import DelayedSpinner from "@rilldata/web-common/features/entity-management/DelayedSpinner.svelte";
- import type {
- DashboardTimeControls,
- TimeRange,
- } from "@rilldata/web-common/lib/time/types";
- import { V1TimeGrain } from "@rilldata/web-common/runtime-client";
import ExportMenu from "../../exports/ExportMenu.svelte";
import { featureFlags } from "../../feature-flags";
import { PivotChipType } from "../pivot/types";
- import { useTimeControlStore } from "../time-controls/time-control-store";
import TimeGrainSelector from "../time-controls/TimeGrainSelector.svelte";
import ExcludeButton from "../toolbars/ExcludeButton.svelte";
import SearchButton from "../toolbars/SearchButton.svelte";
@@ -64,10 +58,16 @@
dimensions: { getDimensionDisplayName },
},
dashboardStore,
- validSpecStore,
+ dashboardConfigProvider,
expressionFilterManager,
+ timeFilterManager,
} = stateManagers;
+ let { metricsViewsProvider } = $derived(dashboardConfigProvider);
+ let { largestMinTimeGrain } = $derived(metricsViewsProvider);
+
+ let { timeGrain, timeStart, timeEnd } = $derived(timeFilterManager);
+
const ephemeralDefsByName = $derived(
new Map(
($dashboardStore?.ephemeralMeasures ?? []).map((def) => [def.name, def]),
@@ -140,10 +140,9 @@
function createPivot() {
showReplacePivotModal = false;
- const dashboardGrain = $dashboardStore?.selectedTimeRange?.interval;
- if (!dashboardGrain || !expandedMeasureName) return;
+ if (!timeGrain || !expandedMeasureName) return;
- const timeGrain = V1TimeGrainToDateTimeUnit[dashboardGrain];
+ const dateUnit = V1TimeGrainToDateTimeUnit[timeGrain];
const rowDimensions = dimensionName
? [
{
@@ -155,8 +154,8 @@
: [];
metricsExplorerStore.createPivot(exploreName, rowDimensions, [
{
- id: dashboardGrain,
- title: timeGrain,
+ id: timeGrain,
+ title: dateUnit,
type: PivotChipType.Time,
},
{
@@ -166,52 +165,6 @@
},
]);
}
-
- const timeControlsStore = useTimeControlStore(stateManagers);
-
- const minTimeGrain = $derived($timeControlsStore.minTimeGrain);
- const timeStart = $derived($timeControlsStore.timeStart);
- const timeEnd = $derived($timeControlsStore.timeEnd);
- const selectedTimeRange = $derived($timeControlsStore.selectedTimeRange);
-
- const activeTimeGrain = $derived(selectedTimeRange?.interval);
-
- const baseTimeRange = $derived(
- selectedTimeRange?.start &&
- selectedTimeRange?.end && {
- name: selectedTimeRange?.name,
- start: selectedTimeRange.start,
- end: selectedTimeRange.end,
- },
- );
-
- function onTimeGrainSelect(timeGrain: V1TimeGrain) {
- if (baseTimeRange) {
- makeTimeSeriesTimeRangeAndUpdateAppState(
- baseTimeRange,
- timeGrain,
- $dashboardStore?.selectedComparisonTimeRange,
- );
- }
- }
-
- function makeTimeSeriesTimeRangeAndUpdateAppState(
- timeRange: TimeRange,
- timeGrain: V1TimeGrain,
- /** we should only reset the comparison range when the user has explicitly chosen a new
- * time range. Otherwise, the current comparison state should continue to be the
- * source of truth.
- */
- comparisonTimeRange: DashboardTimeControls | undefined,
- ) {
- metricsExplorerStore.selectTimeRange(
- exploreName,
- timeRange,
- timeGrain,
- comparisonTimeRange,
- $validSpecStore.data?.metricsView ?? {},
- );
- }