diff --git a/web-common/src/features/dashboards/time-controls/super-pill/components/CalendarPlusDateInput.spec.ts b/web-common/src/features/dashboards/time-controls/super-pill/components/CalendarPlusDateInput.spec.ts new file mode 100644 index 00000000000..54608b3014d --- /dev/null +++ b/web-common/src/features/dashboards/time-controls/super-pill/components/CalendarPlusDateInput.spec.ts @@ -0,0 +1,167 @@ +import CalendarPlusDateInputTest from "@rilldata/web-common/features/dashboards/time-controls/super-pill/components/CalendarPlusDateInputTest.svelte"; +import { mockAnimationsForComponentTesting } from "@rilldata/web-common/lib/test/mock-animations"; +import { act, fireEvent, render, screen } from "@testing-library/svelte"; +import { DateTime, Interval, type DateTimeUnit } from "luxon"; +import { describe, expect, it, vi } from "vitest"; + +const UTC = "UTC"; +const NEW_YORK = "America/New_York"; + +function dateTime(iso: string, zone: string) { + return DateTime.fromISO(iso, { zone }) as DateTime; +} + +function intervalOf(startIso: string, endIso: string, zone: string) { + return Interval.fromDateTimes( + dateTime(startIso, zone), + dateTime(endIso, zone), + ) as Interval; +} + +function renderCalendar({ + interval, + maxDate, + minTimeGrain, + zone, + minDate, +}: { + interval: Interval; + maxDate: DateTime; + minTimeGrain: DateTimeUnit; + zone: string; + minDate?: DateTime; +}) { + const updateRange = vi.fn(); + const onApply = vi.fn(); + const closeMenu = vi.fn(); + + render(CalendarPlusDateInputTest, { + props: { + interval, + minDate: minDate ?? dateTime("2024-01-01T00:00:00", zone), + maxDate, + minTimeGrain, + zone, + updateRange, + onApply, + closeMenu, + }, + }); + + return { updateRange, onApply, closeMenu }; +} + +/** + * The out-of-range indicator is a tooltip trigger rendered next to the input. + * It uses a yellow icon for out-of-range dates and a red one for invalid dates. + */ +function getOutOfRangeIndicator(boundary: "start" | "end") { + const indicator = screen + .getByLabelText(`${boundary} date`) + .parentElement!.querySelector("button"); + const isOutOfRange = indicator + ?.querySelector("svg") + ?.classList.contains("text-yellow-500"); + return isOutOfRange ? indicator : null; +} + +async function enterDate(boundary: "start" | "end", value: string) { + const input = screen.getByLabelText(`${boundary} date`); + await act(() => fireEvent.focus(input)); + await act(() => fireEvent.input(input, { target: { value } })); + await act(() => fireEvent.blur(input)); +} + +describe("CalendarPlusDateInput", () => { + mockAnimationsForComponentTesting(); + + it("allows the day containing the max date when the min grain is smaller than a day", () => { + renderCalendar({ + // The selected range ends at the end of the day that contains the max date. + interval: intervalOf("2024-03-15T00:00:00", "2024-03-16T00:00:00", UTC), + maxDate: dateTime("2024-03-15T10:30:00", UTC), + minTimeGrain: "hour", + zone: UTC, + }); + + expect(getOutOfRangeIndicator("start")).toBeNull(); + expect(getOutOfRangeIndicator("end")).toBeNull(); + }); + + it("flags dates past the day containing the max date when the min grain is smaller than a day", async () => { + renderCalendar({ + interval: intervalOf("2024-03-15T00:00:00", "2024-03-16T00:00:00", UTC), + maxDate: dateTime("2024-03-15T10:30:00", UTC), + minTimeGrain: "hour", + zone: UTC, + }); + + await enterDate("end", "Mar 20, 2024"); + + expect(getOutOfRangeIndicator("end")).not.toBeNull(); + }); + + it("resets an out-of-range end date to the day after the max date", async () => { + const { updateRange } = renderCalendar({ + interval: intervalOf("2024-03-15T00:00:00", "2024-03-16T00:00:00", UTC), + maxDate: dateTime("2024-03-15T10:30:00", UTC), + minTimeGrain: "hour", + zone: UTC, + }); + + await enterDate("end", "Mar 20, 2024"); + await act(() => fireEvent.click(getOutOfRangeIndicator("end")!)); + + expect(updateRange).toHaveBeenLastCalledWith("2024-03-15 to 2024-03-16"); + expect(getOutOfRangeIndicator("end")).toBeNull(); + }); + + it("snaps the max date to the min grain when the grain is larger than a day", async () => { + renderCalendar({ + // Later in the same month as the max date, which is allowed for a month grain. + interval: intervalOf("2024-03-20T00:00:00", "2024-03-21T00:00:00", UTC), + maxDate: dateTime("2024-03-15T10:30:00", UTC), + minTimeGrain: "month", + zone: UTC, + }); + + expect(getOutOfRangeIndicator("end")).toBeNull(); + + // The next month is past the snapped max date. + await enterDate("end", "Apr 5, 2024"); + + expect(getOutOfRangeIndicator("end")).not.toBeNull(); + }); + + it("snaps the max date in the dashboard time zone", async () => { + renderCalendar({ + // In New York the max date is Mar 14 at 22:00, so Mar 14 is the last selectable day. + interval: intervalOf( + "2024-03-14T00:00:00", + "2024-03-15T00:00:00", + NEW_YORK, + ), + maxDate: dateTime("2024-03-15T02:00:00", UTC), + minTimeGrain: "hour", + zone: NEW_YORK, + }); + + expect(getOutOfRangeIndicator("end")).toBeNull(); + + await enterDate("end", "Mar 15, 2024"); + + expect(getOutOfRangeIndicator("end")).not.toBeNull(); + }); + + it("keeps the min date snapped to the start of the day for larger grains", () => { + renderCalendar({ + interval: intervalOf("2024-02-01T00:00:00", "2024-02-02T00:00:00", UTC), + minDate: dateTime("2024-02-01T14:00:00", UTC), + maxDate: dateTime("2024-03-15T10:30:00", UTC), + minTimeGrain: "month", + zone: UTC, + }); + + expect(getOutOfRangeIndicator("start")).toBeNull(); + }); +}); diff --git a/web-common/src/features/dashboards/time-controls/super-pill/components/CalendarPlusDateInput.svelte b/web-common/src/features/dashboards/time-controls/super-pill/components/CalendarPlusDateInput.svelte index 796e65cdeba..70a94a6ab07 100644 --- a/web-common/src/features/dashboards/time-controls/super-pill/components/CalendarPlusDateInput.svelte +++ b/web-common/src/features/dashboards/time-controls/super-pill/components/CalendarPlusDateInput.svelte @@ -4,6 +4,7 @@ import Calendar from "@rilldata/web-common/components/date-picker/Calendar.svelte"; import DateInput from "@rilldata/web-common/components/date-picker/DateInput.svelte"; import { DateTime, Duration, Interval, type DateTimeUnit } from "luxon"; + import { snapToDayOrLargerGrain } from "@rilldata/web-common/lib/time/new-grains.ts"; export let interval: Interval | undefined; export let minDate: DateTime | undefined = undefined; @@ -32,10 +33,13 @@ ? inputInterval?.end : inputInterval?.end.minus({ millisecond: 1 }); + // Calender picker is for selecting days. So always snap to day. $: adjustedMinDate = minDate?.startOf("day"); + // The exception is end date and the min grain is larger than day. + // For grains like week, month, year, etc. we need to snap to that instead. $: adjustedMaxDate = maxDate - ?.plus({ [minTimeGrain]: 1 }) - .startOf(minTimeGrain); + ? snapToDayOrLargerGrain(maxDate, minTimeGrain, zone) + : undefined; $: capMs = maxQueryTimeRange?.as("milliseconds") ?? 0; $: exceedsCap = @@ -115,10 +119,10 @@ { firstVisibleMonth = inputInterval.start; diff --git a/web-common/src/features/dashboards/time-controls/super-pill/components/CalendarPlusDateInputTest.svelte b/web-common/src/features/dashboards/time-controls/super-pill/components/CalendarPlusDateInputTest.svelte new file mode 100644 index 00000000000..5845f1d706e --- /dev/null +++ b/web-common/src/features/dashboards/time-controls/super-pill/components/CalendarPlusDateInputTest.svelte @@ -0,0 +1,34 @@ + + + + + diff --git a/web-common/src/lib/time/new-grains.ts b/web-common/src/lib/time/new-grains.ts index 9bd1f5a049b..5591c1166ce 100644 --- a/web-common/src/lib/time/new-grains.ts +++ b/web-common/src/lib/time/new-grains.ts @@ -1,7 +1,7 @@ import { reverseMap } from "@rilldata/web-common/lib/map-utils.ts"; import { m } from "@rilldata/web-common/lib/i18n/gen/messages"; import { V1TimeGrain } from "@rilldata/web-common/runtime-client/gen/index.schemas"; -import type { DateTimeUnit, Interval } from "luxon"; +import type { DateTime, DateTimeUnit, Interval } from "luxon"; const MAX_BUCKETS = 1500; @@ -287,6 +287,19 @@ export function getAllowedGrains( return getAllowedGrainsFromOrder(order); } +export function snapToDayOrLargerGrain( + date: DateTime, + grain: DateTimeUnit, + zone: string, +) { + const snapToGrain = GrainToOrder[grain] > GrainToOrder.day ? grain : "day"; + return date + .setZone(zone) + .plus({ [snapToGrain]: 1 }) + .startOf(snapToGrain) + .toUTC(); +} + export function getLowerOrderGrain(grain: V1TimeGrain): V1TimeGrain { switch (grain) { case V1TimeGrain.TIME_GRAIN_MILLISECOND: