From fbd6978868c595ad5258ea62734fd92f343a252e Mon Sep 17 00:00:00 2001 From: robinvrd Date: Mon, 2 Nov 2026 16:07:27 +0100 Subject: [PATCH 1/9] feat: support relative datetime ranges and picker comparisons --- REFERENCE.md | 179 +++- bun.lock | 3 + docs/contract.md | 43 +- docs/ui.md | 7 + docs/variables.md | 54 ++ .../data-app-react/src/authoring/index.ts | 17 + packages/data-app-react/src/bindings.ts | 15 +- packages/data-app-react/src/exploration.ts | 21 +- packages/data-app-react/src/hooks.ts | 290 +++++- packages/data-app-react/src/index.ts | 17 + packages/data-app-react/src/styles.css | 1 + .../src/ui/DateTimeRangeCalendar.tsx | 179 ++++ .../src/ui/DateTimeRangePicker.css | 230 +++++ .../src/ui/DateTimeRangePicker.tsx | 825 ++++++++++++++++++ .../src/ui/date-time-range-input.ts | 286 ++++++ .../src/ui/date-time-range-label.ts | 30 + packages/data-app-react/src/ui/index.ts | 17 + .../src/ui/metric-comparison.ts | 20 +- packages/data-app-react/src/ui/variables.ts | 37 +- .../data-app-react/src/view-control-bar.tsx | 2 +- packages/data-app-react/src/view-controls.tsx | 83 +- packages/data-app-react/src/view.ts | 108 ++- packages/data-app/package.json | 3 +- packages/data-app/src/authoring/contract.ts | 64 +- packages/data-app/src/authoring/index.ts | 9 + packages/data-app/src/core/config.ts | 14 +- packages/data-app/src/core/contract.ts | 28 + .../src/core/date-time-range-contract.ts | 119 +++ .../src/core/date-time-range-parser.ts | 745 ++++++++++++++++ .../data-app/src/core/date-time-range-sql.ts | 87 ++ .../src/core/date-time-range-value.ts | 280 ++++++ packages/data-app/src/core/date-time-range.ts | 36 + .../data-app/src/core/date-time-variable.ts | 352 ++++++++ .../data-app/src/core/dimension-source.ts | 23 +- packages/data-app/src/core/format.ts | 19 + packages/data-app/src/core/queries.ts | 104 ++- packages/data-app/src/core/reading.ts | 7 +- packages/data-app/src/core/variables.ts | 6 + packages/data-app/src/index.ts | 9 + packages/data-app/src/internal.ts | 25 + packages/data-app/src/server/handler.ts | 2 +- .../apps-datetime-comparison.test.ts | 116 +++ tests/public-api/apps-datetime.test.ts | 415 +++++++++ tests/public-api/apps-filters.test.ts | 9 +- tests/public-api/date-time-comparison.test.ts | 89 ++ .../date-time-range-contract.test.ts | 126 +++ tests/public-api/date-time-range-flow.test.ts | 415 +++++++++ .../public-api/date-time-range-parser.test.ts | 652 ++++++++++++++ tests/public-api/datetime-time-view.types.ts | 100 +++ .../fixtures/datetime-comparison.html | 10 + .../fixtures/datetime-comparison.tsx | 121 +++ tests/public-api/fixtures/datetime.html | 10 + tests/public-api/fixtures/datetime.tsx | 180 ++++ tests/public-api/fixtures/server.ts | 4 + 54 files changed, 6470 insertions(+), 173 deletions(-) create mode 100644 packages/data-app-react/src/ui/DateTimeRangeCalendar.tsx create mode 100644 packages/data-app-react/src/ui/DateTimeRangePicker.css create mode 100644 packages/data-app-react/src/ui/DateTimeRangePicker.tsx create mode 100644 packages/data-app-react/src/ui/date-time-range-input.ts create mode 100644 packages/data-app-react/src/ui/date-time-range-label.ts create mode 100644 packages/data-app/src/core/date-time-range-contract.ts create mode 100644 packages/data-app/src/core/date-time-range-parser.ts create mode 100644 packages/data-app/src/core/date-time-range-sql.ts create mode 100644 packages/data-app/src/core/date-time-range-value.ts create mode 100644 packages/data-app/src/core/date-time-range.ts create mode 100644 packages/data-app/src/core/date-time-variable.ts create mode 100644 tests/public-api/apps-datetime-comparison.test.ts create mode 100644 tests/public-api/apps-datetime.test.ts create mode 100644 tests/public-api/date-time-comparison.test.ts create mode 100644 tests/public-api/date-time-range-contract.test.ts create mode 100644 tests/public-api/date-time-range-flow.test.ts create mode 100644 tests/public-api/date-time-range-parser.test.ts create mode 100644 tests/public-api/datetime-time-view.types.ts create mode 100644 tests/public-api/fixtures/datetime-comparison.html create mode 100644 tests/public-api/fixtures/datetime-comparison.tsx create mode 100644 tests/public-api/fixtures/datetime.html create mode 100644 tests/public-api/fixtures/datetime.tsx diff --git a/REFERENCE.md b/REFERENCE.md index 1425c905..0f1aab24 100644 --- a/REFERENCE.md +++ b/REFERENCE.md @@ -52,6 +52,10 @@ AboutSubject.references?: { kind: 'ids'; glossaryIds?: readonly string[] | undef AboutSubject.queries?: DisclosedQuery[] AboutSubject.records?: InspectionRecords AboutTab (type, @altertable/data-app-react/ui): = 'glossary' | 'queries' | 'records' +AbsoluteOrRelativeDateTime (type, @altertable/data-app, @altertable/data-app/config, @altertable/data-app/contract, @altertable/data-app/format): Frontend-compatible range values. Absolute dates encode wall-clock fields as UTC, not instants converted from a reporting timezone. The backend interprets that zone. +AbsoluteOrRelativeDateTime.anchor: RelativeAnchor [variant 2 of 2] +AbsoluteOrRelativeDateTime.offset: RelativeOffset[] [variant 2 of 2] +AbsoluteOrRelativeDateTime.…: plus props inherited from built-in AnnotationTarget (component, @altertable/data-app-react): (props: AnnotationTargetProps) — A stable feedback target for app-owned content outside the built-in widgets. AnnotationTarget.annotationId: string AnnotationTarget.label: string @@ -147,6 +151,8 @@ Button.variant?: 'elevated' | 'ghost' | 'inline' | 'outline' | 'primary' — Inl Button.size?: 'compact' | 'default' | 'icon' | 'icon-compact' Button.…: plus props inherited from React ButtonProps (type, @altertable/data-app-react): = props listed as Button.* +canParseDateTimeRangeInput (function, @altertable/data-app/format): (input: string, options: DateTimeRangeParserOptions): boolean +canParseDateTimeRangeInput.options.now?: Date — UTC-encoded reference fields for omitted dates/years. Defaults to the current UTC date/time. chartColor (function, @altertable/data-app-react): (id: string): string ChartLegend (component, @altertable/data-app-react/ui): (props: ChartLegendProps) ChartLegend.children: ReactNode @@ -507,9 +513,10 @@ DateFormatOptions.missing?: string DatePresetId (type, @altertable/data-app-react/ui): = 'day' | 'last-14' | 'last-3' | 'last-30' | 'last-7' | 'last-90' | 'previous-month' | 'previous-week' | 'this-month' | 'this-week' DateRange (type, @altertable/data-app-react/ui): = props listed as previousDateRange.options.* — ISO calendar dates; the app operation defines timezone and inclusive bounds. DateRangeContract (type, @altertable/data-app/contract) +DateRangeContract.datetime: { timeZone: string; bounds: (now?: Date | undefined) => { minDate: string | undefined; maxDate: string; maxRangeDays: number; timeZone: string; }; parse: (value: unknown, now?: Date | undefined) => DateTimeRange; resolve: (value: unknown, now?: Date | undefined) => { ...; }; comparison: (value: unk… DateRangeContract.request: (value: unknown, compare?: boolean | undefined) => DateRangeRequest DateRangeContract.parseRequest: (value: unknown) => DateRangeRequest -DateRangeContract.bounds: () => { minDate: string | undefined; maxDate: string; maxRangeDays: number; timeZone: string; } +DateRangeContract.bounds: (now?: Date | undefined) => { minDate: string | undefined; maxDate: string; maxRangeDays: number; timeZone: string; } DateRangeContract.parse: (value: unknown) => DateRange DateRangeContract.period: ({ start, end }: DateRange) => ReportingPeriod DateRangeContract.comparison: (input: DateRange) => DateRange | null @@ -549,7 +556,7 @@ dateRangeVariable (function, @altertable/data-app-react): (options: DateRangeVar dateRangeVariable.options.label?: string dateRangeVariable.options.key: string — URL key for a relative preset; explicit dates use start/end for period, otherwise key-prefixed fields. dateRangeVariable.options.defaultValue: DateRangeSelection & { comparison?: undefined; } -dateRangeVariable.options.contract: { request: (value: unknown, compare?: boolean | undefined) => DateRangeRequest; parseRequest: (value: unknown) => DateRangeRequest; bounds: () => { minDate: string | undefined; maxDate: string; maxRangeDays: number; timeZone: string; }; ... 4 more ...; view: { ...; }; } +dateRangeVariable.options.contract: { datetime: { timeZone: string; bounds: (now?: Date | undefined) => { minDate: string | undefined; maxDate: string; maxRangeDays: number; timeZone: string; }; parse: (value: unknown, now?: Date | undefined) => DateTimeRange; resolve: (value: unknown, now?: Date | undefined) => { ...; }; comparison:… dateRangeVariable.options.history?: HistoryMode dateRangeVariable.options.comparison?: boolean — Opt into a URL-backed comparison with the preceding equal-length range. dateRangeVariable.options.presentation?: VariablePresentation @@ -579,6 +586,102 @@ DateTimeFormatOptions.locale?: string DateTimeFormatOptions.missing?: string DateTimeFormatOptions.timeZone?: string DateTimeFormatOptions.precision?: 'minute' | 'second' +DateTimeRange (type, @altertable/data-app, @altertable/data-app/config, @altertable/data-app/contract, @altertable/data-app/format): = props listed as dateTimeRangeSql.range.* +DateTimeRangeComparisonRequest (type, @altertable/data-app/contract, @altertable/data-app-react, @altertable/data-app-react/ui): An execution snapshot with the relative selection retained for URL and displayed-scope matching. +DateTimeRangeComparisonRequest.range: { from: Date; to: Date; } +DateTimeRangeComparisonRequest.comparison: { from: Date; to: Date; } | null +DateTimeRangeComparisonRequest.selection: DateTimeRangeComparisonSelection +DateTimeRangeComparisonRequest.reference: string +DateTimeRangeComparisonSelection (type, @altertable/data-app/contract, @altertable/data-app-react, @altertable/data-app-react/ui) +DateTimeRangeComparisonSelection.range: DateTimeRange +DateTimeRangeComparisonSelection.comparison?: 'previous' +DateTimeRangeComparisonVariable (type, @altertable/data-app/contract, @altertable/data-app-react, @altertable/data-app-react/ui) +DateTimeRangeComparisonVariable.kind: 'dateTimeRangeComparison' +DateTimeRangeComparisonVariable.label?: string +DateTimeRangeComparisonVariable.presentation?: VariablePresentation +DateTimeRangeComparisonVariable.urlKeys: readonly string[] +DateTimeRangeComparisonVariable.defaultValue: DateTimeRangeComparisonSelection +DateTimeRangeComparisonVariable.clearValue?: DateTimeRangeComparisonSelection — Unrestricted value, when this variable represents a filter. +DateTimeRangeComparisonVariable.describe?: ((value: DateTimeRangeComparisonSelection) => string) +DateTimeRangeComparisonVariable.history: HistoryMode +DateTimeRangeComparisonVariable.read: (params: URLSearchParams) => DateTimeRangeComparisonSelection +DateTimeRangeComparisonVariable.write: (value: DateTimeRangeComparisonSelection) => Record +DateTimeRangeComparisonVariable.valid: (value: DateTimeRangeComparisonSelection) => boolean +DateTimeRangeComparisonVariable.same: (left: DateTimeRangeComparisonSelection, right: DateTimeRangeComparisonSelection) => boolean +DateTimeRangeComparisonVariable.contract: { datetime: { timeZone: string; bounds: (now?: Date | undefined) => { minDate: string | undefined; maxDate: string; maxRangeDays: number; timeZone: string; }; parse: (value: unknown, now?: Date | undefined) => DateTimeRange; resolve: (value: unknown, now?: Date | undefined) => { ...; }; comparison:… +DateTimeRangeComparisonVariable.maxRangeDays?: number +DateTimeRangeComparisonVariable.presets?: readonly DateTimeRangePreset[] +DateTimeRangeComparisonVariable.input: (selection: DateTimeRangeComparisonSelection, now?: Date | undefined) => DateTimeRangeComparisonRequest +DateTimeRangeComparisonVariable.parseInput: (value: unknown) => DateTimeRangeComparisonRequest +DateTimeRangeComparisonVariable.previous: (selection: DateTimeRangeComparisonSelection) => { from: Date; to: Date; } | null +DateTimeRangeComparisonVariableOptions (type, @altertable/data-app/contract, @altertable/data-app-react, @altertable/data-app-react/ui): = props listed as dateTimeRangeVariable.options.* +DateTimeRangeContract (type, @altertable/data-app/contract): Datetime policy exposed by defineDateRangeContract().datetime. Parsing preserves relative values. +DateTimeRangeContract.timeZone: string +DateTimeRangeContract.bounds: (now?: Date | undefined) => { minDate: string | undefined; maxDate: string; maxRangeDays: number; timeZone: string; } +DateTimeRangeContract.parse: (value: unknown, now?: Date | undefined) => DateTimeRange +DateTimeRangeContract.resolve: (value: unknown, now?: Date | undefined) => { from: Date; to: Date; } +DateTimeRangeContract.comparison: (value: unknown, now?: Date | undefined) => { from: Date; to: Date; } | null +DateTimeRangeContract.request: (value: unknown, compare?: boolean | undefined, now?: Date | undefined) => DateTimeRangeRequest — Resolve both periods against one reference instant. Use these bounds for comparison queries. +DateTimeRangeParseFailureReason (type, @altertable/data-app/format): = 'ambiguous' | 'empty' | 'invalid-range' | 'unrecognized' +DateTimeRangeParserOptions (type, @altertable/data-app/format) +DateTimeRangeParserOptions.now?: Date — UTC-encoded reference fields for omitted dates/years. Defaults to the current UTC date/time. +DateTimeRangePicker (component, @altertable/data-app-react, @altertable/data-app-react/ui): (props: DateTimeRangePickerProps) — A datetime picker with presets, natural-language input, and a calendar. Presets apply immediately; Enter or calendar Apply commits edits; dismissal discards them. +DateTimeRangePicker.value: DateTimeRange | null +DateTimeRangePicker.onChange: (value: DateTimeRange, live?: boolean | undefined) => void — Commits a selection; the optional second argument indicates host-owned Live mode. +DateTimeRangePicker.liveOption?: { dateTimeRange: DateTimeRange; isLive: boolean; } — Adds a Live preset using dateTimeRange; isLive reflects the applied mode. The host owns refresh scheduling. +DateTimeRangePicker.className?: string +DateTimeRangePicker.placeholder?: string +DateTimeRangePicker.label?: string +DateTimeRangePicker.labelPlacement?: ControlLabelPlacement +DateTimeRangePicker.maxRangeDays?: number — Client limit for determinate durations; validate timezone-dependent ranges on the backend. +DateTimeRangePicker.contract?: { datetime: { timeZone: string; bounds: (now?: Date | undefined) => { minDate: string | undefined; maxDate: string; maxRangeDays: number; timeZone: string; }; parse: (value: unknown, now?: Date | undefined) => DateTimeRange; resolve: (value: unknown, now?: Date | undefined) => { ...; }; comparison:… — Shared reporting policy; generated controls inherit it from their variable. +DateTimeRangePicker.presets?: readonly DateTimeRangePreset[] — Suggestions in display order. Omit for defaults; [] retains calendar and text entry. Invalid or over-limit suggestions are omitted; typed input is not restricted to the menu. +DateTimeRangePicker.disabled?: boolean +DateTimeRangePicker.comparison?: { enabled: boolean; range: DateTimeRange | null; onChange: (enabled: boolean) => void; } — Optional controlled comparison with the immediately preceding equal-length interval. +DateTimeRangePickerProps (type, @altertable/data-app-react, @altertable/data-app-react/ui): = props listed as DateTimeRangePicker.* +DateTimeRangePreset (type, @altertable/data-app/contract, @altertable/data-app-react, @altertable/data-app-react/ui): A picker suggestion. Text ranges use the same natural-language parser as typed input. +DateTimeRangePreset.label: string — Menu label, also shown when this preset matches the selected range. +DateTimeRangePreset.range: string | DateTimeRange +DateTimeRangePreset.shortcut?: string — Optional compact badge, for example "15m". +dateTimeRangePresets (const, @altertable/data-app/contract, @altertable/data-app-react, @altertable/data-app-react/ui): Monitoring offers minute/hour windows; analytics offers longer periods and to-date windows. Spread either menu to add custom presets. +dateTimeRangePresets.monitoring: readonly DateTimeRangePreset[] +dateTimeRangePresets.analytics: readonly DateTimeRangePreset[] +DateTimeRangeRequest (type, @altertable/data-app/contract): Resolved UTC-rebased bounds for one execution, optionally with the preceding equal-length interval. +DateTimeRangeRequest.range: { from: Date; to: Date; } +DateTimeRangeRequest.comparison: { from: Date; to: Date; } | null +dateTimeRangeSql (function, @altertable/data-app/contract): (range: DateTimeRange, parameter: string): { from: string; to: string; params: Record; } — DuckDB timestamp expressions using the query session's timezone for relative anchors. Absolute fields remain UTC-rebased TIMESTAMP values. Start is inclusive, end exclusive. +dateTimeRangeSql.range.from?: AbsoluteOrRelativeDateTime | null — Inclusive start; null or omitted means unbounded. +dateTimeRangeSql.range.to?: AbsoluteOrRelativeDateTime | null — Exclusive end; null or omitted means now. +dateTimeRangeVariable (function, @altertable/data-app/contract, @altertable/data-app-react, @altertable/data-app-react/ui): (options: DateTimeRangeComparisonVariableOptions): DateTimeRangeComparisonVariable; (options: DateTimeRangeVariableOptions): DateTimeRangeVariable — One declaration drives the picker, URL state, and operation input. With comparison enabled, use input(defaultValue) for operation checks and variableInput() to validate request snapshots. +dateTimeRangeVariable.options.key: string +dateTimeRangeVariable.options.label?: string +dateTimeRangeVariable.options.defaultValue: string | DateTimeRange — Relative defaults such as "7d" stay relative. +dateTimeRangeVariable.options.maxRangeDays?: number — Client limit for determinate durations; the backend must validate timezone-dependent ranges. +dateTimeRangeVariable.options.presets?: readonly DateTimeRangePreset[] — Suggestions in display order. Omit for defaults; [] retains calendar and text entry. Invalid or over-limit suggestions are omitted; typed input is not restricted to the menu. +dateTimeRangeVariable.options.history?: HistoryMode +dateTimeRangeVariable.options.presentation?: VariablePresentation +dateTimeRangeVariable.options.comparison: true +dateTimeRangeVariable.options.contract: { datetime: { timeZone: string; bounds: (now?: Date | undefined) => { minDate: string | undefined; maxDate: string; maxRangeDays: number; timeZone: string; }; parse: (value: unknown, now?: Date | undefined) => DateTimeRange; resolve: (value: unknown, now?: Date | undefined) => { ...; }; comparison:… +dateTimeRangeVariable.options.comparison?: false — Enable the previous-period reporting workflow. Requires a shared contract. +dateTimeRangeVariable.options.contract?: { datetime: { timeZone: string; bounds: (now?: Date | undefined) => { minDate: string | undefined; maxDate: string; maxRangeDays: number; timeZone: string; }; parse: (value: unknown, now?: Date | undefined) => DateTimeRange; resolve: (value: unknown, now?: Date | undefined) => { ...; }; comparison:… — Shared reporting coverage, timezone, duration limits, and comparison policy. +DateTimeRangeVariable (type, @altertable/data-app/contract, @altertable/data-app-react, @altertable/data-app-react/ui) +DateTimeRangeVariable.kind: 'dateTimeRange' +DateTimeRangeVariable.label?: string +DateTimeRangeVariable.presentation?: VariablePresentation +DateTimeRangeVariable.urlKeys: readonly string[] +DateTimeRangeVariable.defaultValue: DateTimeRange +DateTimeRangeVariable.clearValue?: DateTimeRange — Unrestricted value, when this variable represents a filter. +DateTimeRangeVariable.describe?: ((value: DateTimeRange) => string) +DateTimeRangeVariable.history: HistoryMode +DateTimeRangeVariable.read: (params: URLSearchParams) => DateTimeRange +DateTimeRangeVariable.write: (value: DateTimeRange) => Record +DateTimeRangeVariable.valid: (value: DateTimeRange) => boolean +DateTimeRangeVariable.same: (left: DateTimeRange, right: DateTimeRange) => boolean +DateTimeRangeVariable.parse: (value: unknown) => DateTimeRange +DateTimeRangeVariable.maxRangeDays?: number +DateTimeRangeVariable.contract?: { datetime: { timeZone: string; bounds: (now?: Date | undefined) => { minDate: string | undefined; maxDate: string; maxRangeDays: number; timeZone: string; }; parse: (value: unknown, now?: Date | undefined) => DateTimeRange; resolve: (value: unknown, now?: Date | undefined) => { ...; }; comparison:… — Shared reporting coverage, timezone, duration limits, and comparison policy. +DateTimeRangeVariable.presets?: readonly DateTimeRangePreset[] +DateTimeRangeVariableOptions (type, @altertable/data-app/contract, @altertable/data-app-react, @altertable/data-app-react/ui): = props listed as dateTimeRangeVariable.options.* DateTimeTooltip (component, @altertable/data-app-react): (props: DateTimeTooltipProps) — Exact-time disclosure uses the same reporting zone and locale as other date displays. DateTimeTooltip.date: Date DateTimeTooltip.timeZone?: string @@ -601,7 +704,7 @@ defineDataApp.config.queries: Queries & KnownRecord & ('' extends keyof defineDataIdentifiers (function, @altertable/data-app-react): , const Columns extends Record>>>(options: {…}): { definitions: Readonly, DataIdentifierDefinition>>; DataIdentifier: ({ id, display }: { ...; }) => Eleme… — Register exact source names once; returned JSX IDs are checked against this registry. defineDataIdentifiers.options.tables: Tables defineDataIdentifiers.options.columns: Columns -defineDateRangeContract (function, @altertable/data-app/contract): (options: DateRangeContractOptions): { request: (value: unknown, compare?: boolean | undefined) => DateRangeRequest; parseRequest: (value: unknown) => DateRangeRequest; bounds: () => { minDate: string | undefined; maxDate: string; maxRangeDays: number; timeZone: string; }; ... 4 more ...; view: { .… — One source policy for the server parser, URL variable, and displayed reporting period. +defineDateRangeContract (function, @altertable/data-app/contract): (options: DateRangeContractOptions): { datetime: { timeZone: string; bounds: (now?: Date | undefined) => { minDate: string | undefined; maxDate: string; maxRangeDays: number; timeZone: string; }; parse: (value: unknown, now?: Date | undefined) => DateTimeRange; resolve: (value: unknown, now?: Date… — One source policy for the server parser, URL variable, and displayed reporting period. defineDateRangeContract.options.minDate?: string defineDateRangeContract.options.maxDate?: string | (() => string) defineDateRangeContract.options.maxRangeDays: number @@ -743,6 +846,13 @@ formatDateTime.options.locale?: string formatDateTime.options.missing?: string formatDateTime.options.timeZone?: string formatDateTime.options.precision?: 'minute' | 'second' +formatDateTimeBound (function, @altertable/data-app/contract): (value: AbsoluteOrRelativeDateTime | null | undefined, fallback: string): string +formatDateTimeBound.value.anchor: RelativeAnchor [variant 2 of 2] +formatDateTimeBound.value.offset: RelativeOffset[] [variant 2 of 2] +formatDateTimeBound.value.…: plus props inherited from built-in +formatDateTimeRange (function, @altertable/data-app/contract, @altertable/data-app/format): (value: DateTimeRange): string +formatDateTimeRange.value.from?: AbsoluteOrRelativeDateTime | null — Inclusive start; null or omitted means unbounded. +formatDateTimeRange.value.to?: AbsoluteOrRelativeDateTime | null — Exclusive end; null or omitted means now. formatMetric (function, @altertable/data-app/format): (value: number, format: MetricFormat): string formatMetric.format.kind: 'count' [variant 1 of 3] formatMetric.format.compact?: boolean [variant 1 of 3] @@ -914,7 +1024,7 @@ MetricFormat (type, @altertable/data-app/format): = props listed as formatMetric MetricReading (type, @altertable/data-app-react/ui) MetricReading.loading: true [variant 1 of 2] MetricReading.loading: false [variant 2 of 2] -MetricReading.value: MetricValues & { period?: DateRangeRequest | undefined; } [variant 2 of 2] +MetricReading.value: MetricValues & { period?: DateRangeRequest | DateTimeRangeRequest | undefined; } [variant 2 of 2] MetricRecords (class, @altertable/data-app-react): — A metric-owned record destination. It can only render from that metric's displayed source. MetricRecords.render: (source: ViewSource, navigation?: DetailNavigation | undefined) => { scope: string; content: FunctionComponentElement<{ content: DetailContent; input: any; navigation?: DetailNavigation | undefined; }>; } | undefined MetricValues (type, @altertable/data-app-react): Zero is measured data. null or omitted means no previous measurement. Comparison activation belongs to the reading's period, not these values. @@ -1050,6 +1160,18 @@ parseDateRangeInput.options.minDate?: string parseDateRangeInput.options.maxDate?: string parseDateRangeInput.options.maxRangeDays: number parseDateTime (function, @altertable/data-app/format): (value: DateTimeValue): Date | null — Parse only unambiguous instants. String values must include `Z` or an explicit offset. +parseDateTimeBound (function, @altertable/data-app/contract): (text: string): AbsoluteOrRelativeDateTime | null — Parse one bound without resolving relative anchors or offsets. +parseDateTimeRange (function, @altertable/data-app/contract): (value: unknown, options: {…}): DateTimeRange — Validate and restore UTC-rebased dates from JSON, without resolving relative bounds. +parseDateTimeRange.options.maxRangeDays?: number +parseDateTimeRangeInput (function, @altertable/data-app/format): (input: string, options: DateTimeRangeParserOptions): ParseDateTimeRangeResult +parseDateTimeRangeInput.options.now?: Date — UTC-encoded reference fields for omitted dates/years. Defaults to the current UTC date/time. +ParseDateTimeRangeResult (type, @altertable/data-app/format) +ParseDateTimeRangeResult.ok: true [variant 1 of 2] +ParseDateTimeRangeResult.range: DateTimeRange [variant 1 of 2] +ParseDateTimeRangeResult.normalizedInput: string +ParseDateTimeRangeResult.parser: 'calendar-preset' | 'calendar-range' | 'chrono' | 'relative' | 'unix' [variant 1 of 2] +ParseDateTimeRangeResult.ok: false [variant 2 of 2] +ParseDateTimeRangeResult.reason: DateTimeRangeParseFailureReason [variant 2 of 2] parseDimensionSelection (function, @altertable/data-app/contract): (value: unknown, filter: DimensionSelectionVariable): DimensionSelection — Server-side parser; call from the operation input parser before building SQL. parseDimensionSelection.filter.kind: 'dimension' parseDimensionSelection.filter.label: string @@ -1148,20 +1270,22 @@ QueryDefinitions (type, @altertable/data-app, @altertable/data-app/config, @alte QueryField (type, @altertable/data-app, @altertable/data-app/config, @altertable/data-app/contract) QueryField.type: keyof QueryValues QueryField.nullable?: boolean -QueryParameter (type, @altertable/data-app, @altertable/data-app/config, @altertable/data-app/contract): Typed scalar parameters with optional fallback values. -QueryParameter.type: 'boolean' [variants 1, 5 of 8] -QueryParameter.nullable?: false [variants 1, 2, 3, 4 of 8] -QueryParameter.defaultValue?: boolean [variant 1 of 8] -QueryParameter.type: 'null' [variants 2, 6 of 8] -QueryParameter.defaultValue?: null [variants 2, 6 of 8] -QueryParameter.type: 'number' [variants 3, 7 of 8] -QueryParameter.defaultValue?: number [variant 3 of 8] -QueryParameter.type: 'string' [variants 4, 8 of 8] -QueryParameter.defaultValue?: string [variant 4 of 8] -QueryParameter.nullable: true [variants 5, 6, 7, 8 of 8] -QueryParameter.defaultValue?: boolean | null [variant 5 of 8] -QueryParameter.defaultValue?: number | null [variant 7 of 8] -QueryParameter.defaultValue?: string | null [variant 8 of 8] +QueryParameter (type, @altertable/data-app, @altertable/data-app/config, @altertable/data-app/contract): Typed scalar or datetime-range parameters with optional fallback values. +QueryParameter.type: 'dateTimeRange' [variant 1 of 9] +QueryParameter.nullable?: false [variants 1, 2, 3, 4, 5 of 9] +QueryParameter.defaultValue?: { from?: string | AbsoluteOrRelativeDateTime | null | undefined; to?: string | AbsoluteOrRelativeDateTime | null | undefined; } [variant 1 of 9] — Absolute defaults may use canonical UTC ISO strings (including milliseconds) for static manifests. Snapshots hydrate them into Dates. +QueryParameter.type: 'boolean' [variants 2, 6 of 9] +QueryParameter.defaultValue?: boolean [variant 2 of 9] +QueryParameter.type: 'null' [variants 3, 7 of 9] +QueryParameter.defaultValue?: null [variants 3, 7 of 9] +QueryParameter.type: 'number' [variants 4, 8 of 9] +QueryParameter.defaultValue?: number [variant 4 of 9] +QueryParameter.type: 'string' [variants 5, 9 of 9] +QueryParameter.defaultValue?: string [variant 5 of 9] +QueryParameter.nullable: true [variants 6, 7, 8, 9 of 9] +QueryParameter.defaultValue?: boolean | null [variant 6 of 9] +QueryParameter.defaultValue?: number | null [variant 8 of 9] +QueryParameter.defaultValue?: string | null [variant 9 of 9] QueryParameters (type, @altertable/data-app/contract): > = { readonly [x: string]: QueryParameterValue; } — Parameters are scalar SQL values, never identifiers or SQL fragments. QueryParameterValue (type, @altertable/data-app, @altertable/data-app/config, @altertable/data-app/contract): = string | number | boolean | null QueryProjections (type, @altertable/data-app, @altertable/data-app/config, @altertable/data-app/contract): > = { readonly [x: string]: QueryField; } @@ -1192,6 +1316,14 @@ RankingItem.value: number RankingItem.detail?: ReactNode RankingProps (type, @altertable/data-app-react): = props listed as Ranking.* RegisteredQuery (type, @altertable/data-app/contract): = (name: Name & (true extends IsUnion, NoInfer> ? [ParameterNames] extends [...] ? unknown : never : unknown), params?: ExactQueryParameters<...> | undefined,… +RelativeAnchor (type, @altertable/data-app, @altertable/data-app/config, @altertable/data-app/format): = 'RELATIVE_ANCHOR_NOW' | 'RELATIVE_ANCHOR_START_OF_MONTH' | 'RELATIVE_ANCHOR_START_OF_TODAY' | 'RELATIVE_ANCHOR_START_OF_TOMORROW' | 'RELATIVE_ANCHOR_START_OF_WEEK' | 'RELATIVE_ANCHOR_START_OF_YEAR' | 'RELATIVE_ANCHOR_START_OF_YESTERDAY' +RelativeDateTime (type, @altertable/data-app, @altertable/data-app/config, @altertable/data-app/contract, @altertable/data-app/format) +RelativeDateTime.anchor: RelativeAnchor +RelativeDateTime.offset: RelativeOffset[] +RelativeOffset (type, @altertable/data-app, @altertable/data-app/config, @altertable/data-app/format) +RelativeOffset.amount: number +RelativeOffset.unit: RelativeUnit +RelativeUnit (type, @altertable/data-app, @altertable/data-app/config, @altertable/data-app/format): = 'DAY' | 'HOUR' | 'MINUTE' | 'MONTH' | 'SECOND' | 'WEEK' | 'YEAR' ReportingPeriod (type, @altertable/data-app/contract, @altertable/data-app-react) ReportingPeriod.kind: 'rolling' [variant 1 of 2] ReportingPeriod.amount: number [variant 1 of 2] @@ -1200,6 +1332,10 @@ ReportingPeriod.end: string ReportingPeriod.kind: 'calendar' [variant 2 of 2] ReportingPeriod.start: string [variant 2 of 2] ReportingPeriod.timeZone: string [variant 2 of 2] +resolveDateTimeRange (function, @altertable/data-app/contract): (value: DateTimeRange, now: Date): { from: Date | null; to: Date; } — UTC preview only. Backend execution owns reporting-timezone interpretation. Null start is unbounded; null end is now. +resolveDateTimeRange.value.from?: AbsoluteOrRelativeDateTime | null — Inclusive start; null or omitted means unbounded. +resolveDateTimeRange.value.to?: AbsoluteOrRelativeDateTime | null — Exclusive end; null or omitted means now. +resolveDateTimeRange.now.…: plus props inherited from built-in RetentionChart (component, @altertable/data-app-react/ui): (props: RetentionChartProps) — Query-defined retention curves with count inspection and dotted incomplete periods. Rates are not recomputed from counts. Compose inside VisualizationWidget. RetentionChart.ariaLabel: string — Accessible measure and scope description. RetentionChart.unit: string — Population unit, such as users or sessions. @@ -1341,6 +1477,9 @@ Select.id?: string Select.className?: string Select.align?: 'end' | 'start' — Align the popup with the selection button. Defaults to start. SelectProps (type, @altertable/data-app-react/ui): = props listed as Select.* +serializeDateTimeRange (function, @altertable/data-app/contract): (value: DateTimeRange): string — Canonical URL/JSON representation. Dates retain their encoded UTC fields. +serializeDateTimeRange.value.from?: AbsoluteOrRelativeDateTime | null — Inclusive start; null or omitted means unbounded. +serializeDateTimeRange.value.to?: AbsoluteOrRelativeDateTime | null — Exclusive end; null or omitted means now. Sheet (component, @altertable/data-app-react/ui): (props: SheetProps) — Uses the native dialog top layer and focus trap. Closing keeps the modal mounted until its exit transition completes. Sheet.open: boolean Sheet.onOpenChange: (open: boolean) => void @@ -1517,7 +1656,7 @@ UpdatedAt.open?: boolean UpdatedAt.onOpenChange?: ((open: boolean) => void) UpdatedAt.timeZone?: string UpdatedAtProps (type, @altertable/data-app-react): = props listed as UpdatedAt.* -useAppVariables (hook, @altertable/data-app-react/ui): (definitions: Variables): { values: AppVariableValues; set: (name: Key, value: AppVariableValues[Key]) => void; ... 4 more ...; bind: (name: Key) => { ...; }… — URL is the source of truth for app variables; one update can change dependent values atomically. +useAppVariables (hook, @altertable/data-app-react/ui): (definitions: Variables): { values: AppVariableValues; calendarRevision: number; set: (name: Key, value: AppVariableValues[Key]) => void; ... 4 more ...; bind: (parse: (value: unknown) => Input, variables: Variables & { [Key in keyof Variables]: Variables[Key] extends { kind: 'dateRange'; } ? never : Variables[Key]; }): (value: unknown) => Input & AppVariableValues — Validate named variable values at the operation boundary. Date inputs belong to the base parser. +variableInput (function, @altertable/data-app/contract): (parse: (value: unknown) => Input, variables: Variables & { [Key in keyof Variables]: Variables[Key] extends { kind: 'dateRange'; } ? never : Variables[Key]; }): (value: unknown) => Input & ParsedVariableValues — Validate named variable values at the operation boundary. Date inputs belong to the base parser. VariablePresentation (type, @altertable/data-app-react) VariablePresentation.group?: 'display' | 'scope' — Scope changes the included data; display changes its presentation. Defaults to scope. VariablePresentation.control?: 'toolbar' | 'widget' — Widget controls render beside their visualization instead of in the toolbar. Scope filters remain available in the Filters sheet and active summaries. diff --git a/bun.lock b/bun.lock index c9b5fd45..473b0c83 100644 --- a/bun.lock +++ b/bun.lock @@ -42,6 +42,7 @@ "version": "0.74.0", "dependencies": { "@internationalized/date": "3.12.4", + "chrono-node": "2.9.1", }, }, "packages/data-app-bun": { @@ -733,6 +734,8 @@ "chownr": ["chownr@1.1.4", "", {}, "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg=="], + "chrono-node": ["chrono-node@2.9.1", "", {}, "sha512-nqP8Zp11efCYQIESXPxeDM8ikzN5BDb3Zzou+a66fZq+X2hzKFdsNLQE2/uBAh//BZEMbaMo1eTnagK7hOenAg=="], + "ci-info": ["ci-info@4.4.0", "", {}, "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg=="], "clean-stack": ["clean-stack@4.2.0", "", { "dependencies": { "escape-string-regexp": "5.0.0" } }, "sha512-LYv6XPxoyODi36Dp976riBtSY27VmFo+MKqEU9QCCWyTrdEPDog+RWA7xQWHi6Vbp61j5c4cdzzX1NidnwtUWg=="], diff --git a/docs/contract.md b/docs/contract.md index 40a93cdf..f7c2a4e6 100644 --- a/docs/contract.md +++ b/docs/contract.md @@ -82,11 +82,38 @@ return SQL text and declare `string` when exact numeric precision matters. Use `operations.sampleCountsByGroup.queryNames` for context and evidence. -## Shared date ranges +### Bind datetime ranges -For period-driven analysis, declare `defineDateRangeContract()` from -`@altertable/data-app/contract` once and share it between the operation's parser -and `defineTimeView()`: +Declare a `dateTimeRange` parameter and pass the [datetime variable](variables.md#absolute-and-relative-datetime-ranges) +value directly to `query()`. Reference its bounds in SQL: + +```ts +const dataApp = defineDataApp({ + // ... title, description, and scope + queries: { + events: { + statement: `SELECT occurred_at AS "occurredAt" FROM events + WHERE occurred_at >= $period.from AND occurred_at < $period.to`, + params: { period: { type: 'dateTimeRange' } }, + projections: { occurredAt: { type: 'string' } }, + }, + }, +}); +``` + +The start is inclusive and the end exclusive; null bounds mean unbounded start +and now respectively. Relative values resolve at query execution. Absolute +values encode wall-clock fields in UTC: selecting 4 PM stores 16:00Z. +SQL bounds are timezone-free timestamps. Configure the backend session to the +reporting timezone and interpret timestamp-with-timezone comparisons in that +same zone. Validate timezone-dependent range limits on the backend. + +## Share reporting policy + +Pass an optional `defineDateRangeContract()` to `dateTimeRangeVariable()` to +share coverage, duration limits, and reporting timezone between the picker and +server parser. By default, coverage ends yesterday; set `completeDays: false` +to include today. Declare the policy once: ```ts import { defineDateRangeContract } from '@altertable/data-app/contract'; @@ -98,5 +125,9 @@ const calendar = defineDateRangeContract({ }); ``` -Use `calendar.parseRequest()` to validate periods. The contract owns source -coverage, time zone, maximum range, and comparison rules. +For datetime values, `calendar.datetime.parse()` validates without resolving +relative bounds. `calendar.datetime.request(range, true)` resolves one execution +snapshot and its preceding equal-length comparison for querying. +The existing calendar flow retains `calendar.parseRequest()` and the comparison +control in `defineTimeView()`; its inclusive `start`/`end` dates remain supported. +Datetime variables enable the same workflow with `comparison: true`. diff --git a/docs/ui.md b/docs/ui.md index afbe0e84..cd7de85f 100644 --- a/docs/ui.md +++ b/docs/ui.md @@ -293,3 +293,10 @@ for inline forms. Use ` + { + const year = currentDay.year - index; + return { id: String(year), label: String(year) }; + } + )} + onChange={year => setFocused(focused.set({ year: Number(year) }))} + /> + + + + + {day => {day}} + + + {date => ( + + )} + + + + ); +} + +function CalendarCompleteness({ + onChange, +}: { + onChange: (complete: boolean) => void; +}) { + const state = useContext(RangeCalendarStateContext); + const complete = !!state?.value && !state.anchorDate; + useEffect(() => { + onChange(complete); + }, [complete, onChange]); + return null; +} diff --git a/packages/data-app-react/src/ui/DateTimeRangePicker.css b/packages/data-app-react/src/ui/DateTimeRangePicker.css new file mode 100644 index 00000000..8e3559d2 --- /dev/null +++ b/packages/data-app-react/src/ui/DateTimeRangePicker.css @@ -0,0 +1,230 @@ +.altertable-datetime-input { + display: flex; + align-items: center; + gap: 0.5rem; + width: 22rem; + max-width: 100%; + min-width: 0; + box-sizing: border-box; + min-height: var(--atbl-control-height); + padding: var(--atbl-control-padding-block) var(--atbl-control-padding-inline); + font: var(--atbl-type-label-weight) var(--atbl-type-label-size) / + var(--atbl-type-label-leading) var(--atbl-font); + border: 1px solid var(--atbl-border); + border-radius: var(--atbl-radius-control); + background: var(--atbl-surface); + color: var(--atbl-text); + cursor: text; +} +.altertable-datetime-input input { + width: 100%; + min-width: 0; + border: 0; + outline: none; + background: transparent; + color: inherit; + font: inherit; +} +.altertable-datetime-input[data-disabled] { + opacity: 0.5; +} +.altertable-datetime-shortcut { + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + min-width: 2rem; + padding: 0.1rem 0.25rem; + border-radius: var(--atbl-radius-control); + background: var(--atbl-subtle); + color: var(--atbl-muted); + text-align: center; + font-size: var(--atbl-type-meta-size); +} +.altertable-datetime-popover { + width: min(22rem, calc(100vw - 2rem)); + max-width: calc(100vw - 2rem); + overflow: visible; + box-sizing: border-box; +} +.altertable-datetime-presets { + padding: 0.25rem; + max-height: var(--available-height, 32rem); + overflow-y: auto; + overscroll-behavior: contain; +} +.altertable-datetime-presets ~ .altertable-datetime-presets { + padding-top: 0; +} +.altertable-datetime-presets:has(~ .altertable-datetime-presets) { + padding-bottom: 0; +} +.altertable-datetime-presets button { + display: flex; + align-items: center; + gap: 0.5rem; + width: 100%; + padding: 0.45rem 0.5rem; + border: 0; + border-radius: var(--atbl-radius-control); + color: inherit; + background: transparent; + text-align: left; + font: inherit; + cursor: pointer; +} +.altertable-datetime-presets button[data-divider] { + position: relative; + margin-top: 0.8rem; +} +.altertable-datetime-presets button[data-divider]::before { + content: ''; + position: absolute; + inset: -0.55rem 0 auto; + border-top: 1px solid var(--atbl-border); +} +.altertable-datetime-presets button small { + display: block; + margin-top: 0.125rem; + color: var(--atbl-muted); + font-size: var(--atbl-type-meta-size); +} +.altertable-datetime-presets button[data-highlighted] { + background: var(--atbl-subtle); +} +.altertable-datetime-check { + margin-left: auto; +} +.altertable-datetime-help { + position: absolute; + top: 0; + width: 16rem; + max-height: var(--available-height, 32rem); + overflow-y: auto; + box-sizing: border-box; + padding: 1rem; + border: 1px solid var(--atbl-border); + border-radius: var(--atbl-radius-control); + background: var(--atbl-surface); + color: var(--atbl-text); +} +.altertable-datetime-help[data-side='left'] { + right: calc(100% + 0.5rem); +} +.altertable-datetime-help[data-side='right'] { + left: calc(100% + 0.5rem); +} +.altertable-datetime-help h4 { + margin: 0.75rem 0 0.25rem; + color: var(--atbl-muted); + font-weight: 500; +} +.altertable-datetime-help section div { + display: flex; + flex-wrap: wrap; + gap: 0.25rem; +} +.altertable-datetime-help button { + font: inherit; + font-size: var(--atbl-type-meta-size); + padding: 0.25rem; + color: inherit; + background: var(--atbl-subtle); + border: 0; + border-radius: var(--atbl-radius-control); + cursor: pointer; +} +.altertable-datetime-error { + padding: 0.5rem; + margin: 0; +} +.altertable-datetime-calendar-panel { + padding: 0.75rem; + max-height: var(--available-height, 32rem); + overflow-y: auto; +} +.altertable-datetime-actions { + display: flex; + justify-content: space-between; + gap: 0.5rem; + margin-top: 0.75rem; +} +@media (max-width: 680px) { + .altertable-datetime-popover { + max-height: var(--available-height, calc(100dvh - 2rem)); + overflow-y: auto; + } + .altertable-datetime-help { + position: static; + width: 100%; + max-height: none; + border: 0; + border-top: 1px solid var(--atbl-border); + } +} +.altertable-datetime-calendar { + width: 100%; + min-width: 0; +} +.altertable-datetime-calendar header { + display: grid; + grid-template-columns: auto minmax(0, 1fr) minmax(0, 1fr) auto; + align-items: end; + gap: 0.5rem; + margin-bottom: 0.75rem; +} +.altertable-datetime-calendar table { + table-layout: fixed; + width: 100%; + border-collapse: separate; + border-spacing: 0 2px; +} +.altertable-datetime-calendar th { + color: var(--atbl-muted); + font-size: var(--atbl-type-meta-size); + font-weight: 500; +} +.altertable-datetime-calendar td { + text-align: center; + padding: 0; +} +.altertable-datetime-calendar .react-aria-CalendarCell { + display: grid; + place-items: center; + min-height: 40px; + cursor: pointer; + border-radius: var(--atbl-radius-control); + font-variant-numeric: tabular-nums; +} +.altertable-datetime-calendar .react-aria-CalendarCell[data-selected] { + background: var(--atbl-subtle); + font-weight: 650; +} +.altertable-datetime-calendar .react-aria-CalendarCell[data-selection-start], +.altertable-datetime-calendar .react-aria-CalendarCell[data-selection-end] { + background: var(--atbl-text); + color: var(--atbl-surface); +} +.altertable-datetime-calendar .react-aria-CalendarCell[data-disabled], +.altertable-datetime-calendar .react-aria-CalendarCell[data-unavailable] { + color: var(--atbl-muted); + cursor: default; + text-decoration: line-through; +} +.altertable-datetime-calendar .react-aria-CalendarCell[data-outside-month] { + visibility: hidden; +} + +.altertable-datetime-help[data-side='inline'] { + position: static; + width: 100%; + max-height: none; + border: 0; + border-top: 1px solid var(--atbl-border); +} +.altertable-datetime-popover:has( + .altertable-datetime-help[data-side='inline'] +) { + max-height: var(--available-height, calc(100dvh - 2rem)); + overflow-y: auto; +} diff --git a/packages/data-app-react/src/ui/DateTimeRangePicker.tsx b/packages/data-app-react/src/ui/DateTimeRangePicker.tsx new file mode 100644 index 00000000..b35463dd --- /dev/null +++ b/packages/data-app-react/src/ui/DateTimeRangePicker.tsx @@ -0,0 +1,825 @@ +/* eslint-disable jsx-a11y/prefer-tag-over-role -- The editable combobox owns a custom listbox and calendar popup, not a native select. */ +import { + parseDateTimeBound, + parseDateTimeRange, + formatDateTimeBound, + formatDateTimeRange, + resolveDateTimeRange, + serializeDateTimeRange, + type DateTimeRange, + type DateTimeRangePreset, + type DateRangeContract, +} from '@altertable/data-app/contract'; +import { parseDateTimeRangeInput } from '@altertable/data-app/format'; +import { useEffect, useId, useLayoutEffect, useRef, useState } from 'react'; + +import { Button } from './Button.tsx'; +import { classNames } from './classNames.ts'; +import { + editDateTimeRangeInputSegment, + getDateTimeRangeInputSegmentSelection, +} from './date-time-range-input.ts'; +import { dateTimeRangeLabel } from './date-time-range-label.ts'; +import { DateTimeRangeCalendar } from './DateTimeRangeCalendar.tsx'; +import { AppIcon } from './icons.ts'; +import { PickerField, type ControlLabelPlacement } from './PickerField.tsx'; +import { PopoverSurface } from './PopoverSurface.tsx'; + +export type DateTimeRangePickerProps = { + value: DateTimeRange | null; + /** Commits a selection; the optional second argument indicates host-owned Live mode. */ + onChange: (value: DateTimeRange, live?: boolean) => void; + /** Adds a Live preset using dateTimeRange; isLive reflects the applied mode. The host owns refresh scheduling. */ + liveOption?: { dateTimeRange: DateTimeRange; isLive: boolean }; + className?: string; + placeholder?: string; + label?: string; + labelPlacement?: ControlLabelPlacement; + /** Client limit for determinate durations; validate timezone-dependent ranges on the backend. */ + maxRangeDays?: number; + /** Shared reporting policy; generated controls inherit it from their variable. */ + contract?: DateRangeContract; + /** Suggestions in display order. Omit for defaults; [] retains calendar and text entry. + * Invalid or over-limit suggestions are omitted; typed input is not restricted to the menu. + */ + presets?: readonly DateTimeRangePreset[]; + disabled?: boolean; + /** Optional controlled comparison with the immediately preceding equal-length interval. */ + comparison?: { + enabled: boolean; + range: DateTimeRange | null; + onChange: (enabled: boolean) => void; + }; +}; + +const presetInputs = [ + ['15m', 'Past 15 minutes'], + ['4h', 'Past 4 hours'], + ['1d', 'Past 1 day'], + ['2d', 'Past 2 days'], + ['3d', 'Past 3 days'], + ['7d', 'Past 7 days'], + ['1mo', 'Past 1 month'], + ['today', 'Today'], + ['yesterday', 'Yesterday'], +] as const; +const examples = { + Recent: [ + '45m', + '12 hours', + '10d', + '2 weeks', + 'last month', + 'yesterday', + 'today', + 'month to date', + ], + Calendar: [ + '2022', + '2023 to 2024', + 'July 2024', + 'Jul 1', + 'Jul 1 - Jul 2', + '7/1', + '10:00 - 16:00', + ], + Since: ['since 7/1', 'Jul 2 12pm to now'], + Unix: ['1783003329 - 1783608129', '1783609703 to now'], +}; +function dateLabel(date: Date, year = false) { + return new Intl.DateTimeFormat('en-US', { + month: 'short', + day: 'numeric', + ...(year ? ({ year: 'numeric' } as const) : {}), + hour: 'numeric', + minute: '2-digit', + hour12: true, + timeZone: 'UTC', + }).format(date); +} +function resolvedLabel( + value: DateTimeRange | null, + contract?: DateRangeContract +) { + if (!value) return ''; + try { + const { from, to } = contract + ? contract.datetime.resolve(value) + : resolveDateTimeRange(value); + return from ? `${dateLabel(from, true)} - ${dateLabel(to, true)}` : ''; + } catch { + // A UTC-only preview may be inverted even when valid in the reporting zone. + return value.from + ? `${formatDateTimeBound(value.from)} to ${formatDateTimeBound(value.to)}` + : ''; + } +} +function summaryLabel(value: DateTimeRange | null) { + if (!value) return ''; + for (const [input, label] of presetInputs) { + const parsed = parseDateTimeRangeInput(input); + if ( + parsed.ok && + serializeDateTimeRange(parsed.range) === serializeDateTimeRange(value) + ) + return label; + } + if ( + value.from && + !(value.from instanceof Date) && + !value.to && + value.from.offset.length === 0 + ) { + switch (value.from.anchor) { + case 'RELATIVE_ANCHOR_START_OF_WEEK': + return 'Week to date'; + case 'RELATIVE_ANCHOR_START_OF_MONTH': + return 'Month to date'; + case 'RELATIVE_ANCHOR_START_OF_YEAR': + return 'Year to date'; + } + } + if ( + value.from && + !(value.from instanceof Date) && + !value.to && + value.from.anchor === 'RELATIVE_ANCHOR_NOW' && + value.from.offset.length === 1 + ) { + const offset = value.from.offset[0]!; + if (offset.amount < 0) + return `Past ${-offset.amount} ${offset.unit.toLowerCase()}${offset.amount === -1 ? '' : 's'}`; + } + if (value.from instanceof Date) { + return value.to instanceof Date + ? `${dateLabel(value.from)} - ${dateLabel(value.to)}` + : !value.to + ? `Since ${dateLabel(value.from)}` + : formatDateTimeRange(value); + } + return formatDateTimeRange(value); +} + +/** A datetime picker with presets, natural-language input, and a calendar. + * Presets apply immediately; Enter or calendar Apply commits edits; dismissal discards them. + * Unmodified arrows navigate presets or edit date segments. Escape returns from the + * calendar to presets or closes the picker. Calendar days end at exclusive next UTC midnight. + */ +export function DateTimeRangePicker({ + value, + onChange, + liveOption, + comparison, + className, + placeholder = 'Select date range…', + label = 'Date range', + labelPlacement = 'inline', + maxRangeDays, + contract, + presets: configuredPresets, + disabled, +}: DateTimeRangePickerProps) { + const id = useId(); + const trigger = useRef(null); + const input = useRef(null); + const pending = useRef<{ value: string; start: number; end: number } | null>( + null + ); + const helpTimer = useRef | undefined>( + undefined + ); + const [open, setOpen] = useState(false); + const [selectionRevision, setSelectionRevision] = useState(0); + useLayoutEffect(() => { + if (selectionRevision) { + input.current?.focus(); + input.current?.select(); + } + }, [selectionRevision]); + const [mode, setMode] = useState<'presets' | 'editing' | 'calendar'>( + 'presets' + ); + const [draft, setDraft] = useState(''); + const [edited, setEdited] = useState(false); + const [highlight, setHighlight] = useState(-1); + const [error, setError] = useState(''); + const [help, setHelp] = useState(false); + const [helpSide, setHelpSide] = useState<'left' | 'right' | 'inline'>('left'); + const [calendar, setCalendar] = useState(null); + const [complete, setComplete] = useState(false); + const popup = useRef(null); + useEffect(() => { + if (!open) return; + function dismissOutside(event: Event) { + const target = event.target; + if ( + !(target instanceof Element) || + trigger.current?.contains(target) || + popup.current?.contains(target) + ) + return; + // Calendar month/year selects render their own nested overlay in a portal. + if (mode === 'calendar' && target.closest('.altertable-popover-surface')) + return; + setOpen(false); + setHelp(false); + setError(''); + pending.current = null; + } + document.addEventListener('pointerdown', dismissOutside, true); + document.addEventListener('focusin', dismissOutside, true); + return () => { + document.removeEventListener('pointerdown', dismissOutside, true); + document.removeEventListener('focusin', dismissOutside, true); + }; + }, [open, mode]); + + useEffect(() => () => clearTimeout(helpTimer.current), []); + function rangeError(range: DateTimeRange) { + try { + parseDateTimeRange(range, { maxRangeDays }); + contract?.datetime.parse(range); + return ''; + } catch (error) { + return error instanceof Error ? error.message : 'Invalid date range.'; + } + } + + const presets = [ + ...(liveOption + ? [ + { + shortcut: 'Live', + label: 'Live', + range: liveOption.dateTimeRange, + live: true, + }, + ] + : []), + ...( + configuredPresets ?? + presetInputs.map(([shortcut, label]) => ({ + shortcut, + label, + range: shortcut, + })) + ).flatMap(preset => { + if (typeof preset.range !== 'string') + return [{ ...preset, range: preset.range, live: false }]; + const result = parseDateTimeRangeInput(preset.range); + return result.ok ? [{ ...preset, range: result.range, live: false }] : []; + }), + ].filter(preset => !rangeError(preset.range)); + const calendarIndex = presets.length; + const moreIndex = calendarIndex + 1; + const comparisonIndex = moreIndex + 1; + const lastIndex = comparison?.range ? comparisonIndex : moreIndex; + const live = !!( + value && + liveOption?.isLive && + serializeDateTimeRange(value) === + serializeDateTimeRange(liveOption.dateTimeRange) + ); + const selected = presets.find( + preset => + value && + preset.live === live && + serializeDateTimeRange(preset.range) === serializeDateTimeRange(value) + ); + function selectInput() { + setSelectionRevision(revision => revision + 1); + } + function openPresets() { + if (!open) { + setDraft(resolvedLabel(value, contract)); + setEdited(false); + setError(''); + setHelp(false); + } + pending.current = null; + setHighlight(-1); + setMode('presets'); + setOpen(true); + const bounds = trigger.current?.getBoundingClientRect(); + setHelpSide( + bounds && bounds.left >= 280 + ? 'left' + : bounds && window.innerWidth - bounds.right >= 280 + ? 'right' + : 'inline' + ); + selectInput(); + } + function close(focus = false) { + setError(''); + setOpen(false); + setHelp(false); + pending.current = null; + if (focus) input.current?.focus(); + } + function commit(range: DateTimeRange, isLive = false) { + const issue = rangeError(range); + if (issue) { + setError(issue); + setHelp(true); + return; + } + onChange(range, isLive); + close(true); + } + function submit(text: string) { + const result = parseDateTimeRangeInput(text); + if (result.ok) { + commit(result.range); + return; + } + // Explicit bound expressions retain anchors on either side of the range. + const bounds = text.split(/\s+to\s+/i); + if (bounds.length === 2) { + try { + const from = parseDateTimeBound(bounds[0]!); + const to = parseDateTimeBound(bounds[1]!); + commit({ from, to }); + return; + } catch { + /* Show the same recoverable parse error as natural language. */ + } + } + setError("Couldn't parse that date range."); + setHelp(true); + } + function choose(index: number) { + if (index === comparisonIndex && comparison?.range) { + comparison.onChange(!comparison.enabled); + close(true); + return; + } + const preset = presets[index]; + if (preset) { + commit(preset.range, preset.live); + return; + } + if (index === calendarIndex) { + try { + if (value) { + const resolved = contract + ? contract.datetime.resolve(value) + : resolveDateTimeRange(value); + const from = resolved.from ? new Date(resolved.from) : null; + const to = new Date(resolved.to.getTime() - 1); + from?.setUTCHours(0, 0, 0, 0); + to.setUTCHours(24, 0, 0, 0); + setCalendar(from ? { from, to } : null); + } else setCalendar(null); + } catch { + setCalendar(null); + } + setComplete(false); + setMode('calendar'); + setHelp(false); + setError(''); + } else { + setHighlight(moreIndex); + setHelp(true); + selectInput(); + } + } + function back() { + setMode('presets'); + setHighlight(calendarIndex); + selectInput(); + } + function highlightOption(index: number) { + setHighlight(index); + clearTimeout(helpTimer.current); + if (index === moreIndex) setHelp(true); + else helpTimer.current = setTimeout(() => setHelp(false), 300); + requestAnimationFrame(() => + document + .getElementById(`${id}-option-${index}`) + ?.scrollIntoView({ block: 'nearest' }) + ); + } + function navigateOption(index: number) { + highlightOption(index); + if (index === comparisonIndex) { + popup.current + ?.querySelector('button[role="checkbox"]') + ?.focus(); + } else { + input.current?.focus(); + input.current?.select(); + } + } + return ( + + {/* The input owns keyboard activation; the surrounding padding shares its click target. */} + {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */} +
{ + if (!disabled) openPresets(); + }} + > + + = 0 && + highlight !== comparisonIndex + ? `${id}-option-${highlight}` + : undefined + } + aria-invalid={!!error} + aria-describedby={error ? `${id}-error` : undefined} + disabled={disabled} + readOnly={!open} + placeholder={placeholder} + value={ + open + ? draft + : live + ? 'Live' + : `${selected?.label ?? summaryLabel(value)}${comparison?.enabled ? ' · vs previous' : ''}` + } + onFocus={() => setError('')} + onChange={event => { + pending.current = null; + setDraft(event.target.value); + setEdited(true); + setMode('editing'); + setHighlight(-1); + setError(''); + }} + onKeyDown={event => { + if ( + event.key.startsWith('Arrow') && + (event.shiftKey || event.ctrlKey || event.altKey || event.metaKey) + ) { + pending.current = null; + return; + } + if (!open) { + if (['Enter', ' ', 'ArrowDown', 'ArrowUp'].includes(event.key)) { + event.preventDefault(); + openPresets(); + } + return; + } + if (event.key === 'Escape') { + event.preventDefault(); + if (mode === 'calendar') back(); + else close(true); + return; + } + if (event.key === 'Tab') { + const checkbox = popup.current?.querySelector( + 'button[role="checkbox"]:not(:disabled)' + ); + if (!event.shiftKey && comparison && checkbox) { + event.preventDefault(); + checkbox.focus(); + return; + } + close(); + return; + } + if (event.key === 'Enter') { + event.preventDefault(); + if (mode === 'presets' && highlight >= 0 && !edited) + choose(highlight); + else if (mode === 'calendar') { + if (complete && calendar) commit(calendar); + } else { + submit(pending.current?.value ?? event.currentTarget.value); + pending.current = null; + } + return; + } + const current = pending.current ?? { + value: event.currentTarget.value, + start: event.currentTarget.selectionStart ?? 0, + end: event.currentTarget.selectionEnd ?? 0, + }; + const args = { + value: current.value, + selectionStart: current.start, + selectionEnd: current.end, + direction: (event.key === 'ArrowLeft' || event.key === 'ArrowDown' + ? -1 + : 1) as -1 | 1, + }; + if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') { + setMode('editing'); + setHighlight(-1); + const selection = getDateTimeRangeInputSegmentSelection(args); + if (selection) { + event.preventDefault(); + pending.current = { value: current.value, ...selection }; + input.current?.setSelectionRange( + selection.start, + selection.end + ); + } + } + if (event.key === 'ArrowUp' || event.key === 'ArrowDown') { + const edit = editDateTimeRangeInputSegment(args); + if (edit) { + event.preventDefault(); + pending.current = edit; + setDraft(edit.value); + setEdited(true); + setMode('editing'); + setError(''); + requestAnimationFrame(() => { + const next = pending.current; + if (next) + input.current?.setSelectionRange(next.start, next.end); + }); + } else if (mode === 'presets') { + event.preventDefault(); + navigateOption( + highlight < 0 + ? event.key === 'ArrowDown' + ? 0 + : lastIndex + : (highlight + + (event.key === 'ArrowDown' ? 1 : -1) + + lastIndex + + 1) % + (lastIndex + 1) + ); + } + } + if ( + mode === 'presets' && + (event.key === 'Home' || event.key === 'End') + ) { + event.preventDefault(); + navigateOption(event.key === 'Home' ? 0 : lastIndex); + } + }} + /> + +
+ { + if (!next) close(); + }} + isNonModal + placement="bottom start" + shouldCloseOnInteractOutside={element => + !trigger.current?.contains(element) + } + className="altertable-datetime-popover" + > +
{ + if (event.key === 'Escape') { + event.preventDefault(); + event.stopPropagation(); + if (mode === 'calendar') back(); + else close(true); + } + if ( + event.key === 'Tab' && + event.shiftKey && + event.target instanceof HTMLButtonElement && + event.target.getAttribute('role') === 'checkbox' + ) { + event.preventDefault(); + input.current?.focus(); + } + }} + > + {mode === 'calendar' ? ( + + ) : ( + <> +
+ {[ + ...presets.map(preset => preset.label), + 'Select from calendar…', + 'More', + ].map((text, index) => { + const preset = presets[index]; + return ( + + ); + })} +
+ {error && ( + + )} + {/* Pointer retention lets users cross the gap to the keyboard-accessible example buttons. */} + {(help || !!error) && ( + // eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions -- Hover retains the help panel while crossing the popup gap. + + )} + + )} + {comparison && mode !== 'calendar' && ( +
+ +
+ )} +
+
+
+ ); +} diff --git a/packages/data-app-react/src/ui/date-time-range-input.ts b/packages/data-app-react/src/ui/date-time-range-input.ts new file mode 100644 index 00000000..37e1c84c --- /dev/null +++ b/packages/data-app-react/src/ui/date-time-range-input.ts @@ -0,0 +1,286 @@ +type DateTimeRangeSegmentKind = + | 'month' + | 'day' + | 'year' + | 'hour' + | 'minute' + | 'period'; + +type DateTimeRangeSegment = { + kind: DateTimeRangeSegmentKind; + endpointIndex: number; + start: number; + end: number; +}; + +type DateTimeRangeEndpoint = { + start: number; + end: number; + month: number; + day: number; + year: number; + hour: number; + minute: number; + period: 'AM' | 'PM'; + segments: DateTimeRangeSegment[]; +}; + +type DateTimeRangeInputSelection = { + start: number; + end: number; +}; + +type DateTimeRangeInputEdit = DateTimeRangeInputSelection & { + value: string; +}; + +const MONTHS = [ + 'Jan', + 'Feb', + 'Mar', + 'Apr', + 'May', + 'Jun', + 'Jul', + 'Aug', + 'Sep', + 'Oct', + 'Nov', + 'Dec', +]; + +export function getDateTimeRangeInputSegmentSelection({ + value, + selectionStart, + selectionEnd, + direction, +}: { + value: string; + selectionStart: number; + selectionEnd: number; + direction: -1 | 1; +}): DateTimeRangeInputSelection | null { + const segments = getDateTimeRangeEndpoints(value).flatMap( + endpoint => endpoint.segments + ); + const selectedIndex = segments.findIndex( + segment => segment.start === selectionStart && segment.end === selectionEnd + ); + + if (selectedIndex >= 0) { + const adjacentIndex = selectedIndex + direction; + const adjacentSegment = + adjacentIndex >= 0 && adjacentIndex < segments.length + ? segments.at(adjacentIndex) + : undefined; + if (adjacentSegment) { + return toSelection(adjacentSegment); + } + + const cursorPosition = direction === -1 ? 0 : value.length; + return { start: cursorPosition, end: cursorPosition }; + } + + if (selectionStart !== selectionEnd) { + return null; + } + + const segment = + direction === 1 + ? segments.find(candidate => candidate.end > selectionStart) + : segments.findLast(candidate => candidate.start < selectionStart); + + return toSelection(segment); +} + +export function editDateTimeRangeInputSegment({ + value, + selectionStart, + selectionEnd, + direction, +}: { + value: string; + selectionStart: number; + selectionEnd: number; + direction: -1 | 1; +}): DateTimeRangeInputEdit | null { + const endpoints = getDateTimeRangeEndpoints(value); + const segment = endpoints + .flatMap(endpoint => endpoint.segments) + .find( + candidate => + candidate.start === selectionStart && candidate.end === selectionEnd + ); + if (!segment) { + return null; + } + + const endpoint = endpoints.at(segment.endpointIndex); + if (!endpoint) { + return null; + } + + const editedEndpoint = editEndpoint(endpoint, segment.kind, direction); + const nextEndpointValue = formatEndpoint(editedEndpoint); + const nextValue = `${value.slice(0, endpoint.start)}${nextEndpointValue}${value.slice(endpoint.end)}`; + const nextSegment = getDateTimeRangeEndpoints(nextValue) + .at(segment.endpointIndex) + ?.segments.find(candidate => candidate.kind === segment.kind); + + if (!nextSegment) { + return null; + } + + return { + value: nextValue, + start: nextSegment.start, + end: nextSegment.end, + }; +} + +function getDateTimeRangeEndpoints(value: string): DateTimeRangeEndpoint[] { + const pattern = new RegExp( + `\\b(${MONTHS.join('|')}) (\\d{1,2}), (\\d{4}), (\\d{1,2}):(\\d{2}) (AM|PM)\\b`, + 'g' + ); + + return [...value.matchAll(pattern)].flatMap((match, endpointIndex) => { + const [ + matchedValue, + monthLabel, + dayLabel, + yearLabel, + hourLabel, + minuteLabel, + period, + ] = match; + const start = match.index; + + if ( + !matchedValue || + !monthLabel || + !dayLabel || + !yearLabel || + !hourLabel || + !minuteLabel || + (period !== 'AM' && period !== 'PM') + ) { + return []; + } + + const month = MONTHS.findIndex(candidate => candidate === monthLabel); + if (month < 0) { + return []; + } + + const dayStart = start + monthLabel.length + 1; + const yearStart = dayStart + dayLabel.length + 2; + const hourStart = yearStart + yearLabel.length + 2; + const minuteStart = hourStart + hourLabel.length + 1; + const periodStart = minuteStart + minuteLabel.length + 1; + + return [ + { + start, + end: start + matchedValue.length, + month, + day: Number(dayLabel), + year: Number(yearLabel), + hour: Number(hourLabel), + minute: Number(minuteLabel), + period, + segments: [ + createSegment('month', endpointIndex, start, monthLabel.length), + createSegment('day', endpointIndex, dayStart, dayLabel.length), + createSegment('year', endpointIndex, yearStart, yearLabel.length), + createSegment('hour', endpointIndex, hourStart, hourLabel.length), + createSegment( + 'minute', + endpointIndex, + minuteStart, + minuteLabel.length + ), + createSegment('period', endpointIndex, periodStart, period.length), + ], + }, + ]; + }); +} + +function createSegment( + kind: DateTimeRangeSegmentKind, + endpointIndex: number, + start: number, + length: number +): DateTimeRangeSegment { + return { kind, endpointIndex, start, end: start + length }; +} + +function editEndpoint( + endpoint: DateTimeRangeEndpoint, + kind: DateTimeRangeSegmentKind, + direction: -1 | 1 +): DateTimeRangeEndpoint { + const edited = { ...endpoint }; + + switch (kind) { + case 'month': + edited.month = wrap(endpoint.month + direction, 0, 11); + edited.day = Math.min( + endpoint.day, + getEndpointDaysInMonth(edited.year, edited.month) + ); + break; + case 'day': + edited.day = wrap( + endpoint.day + direction, + 1, + getEndpointDaysInMonth(endpoint.year, endpoint.month) + ); + break; + case 'year': + edited.year = wrap(endpoint.year + direction, 1, 9_999); + edited.day = Math.min( + endpoint.day, + getEndpointDaysInMonth(edited.year, endpoint.month) + ); + break; + case 'hour': + edited.hour = wrap(endpoint.hour + direction, 1, 12); + break; + case 'minute': + edited.minute = wrap(endpoint.minute + direction, 0, 59); + break; + case 'period': + edited.period = endpoint.period === 'AM' ? 'PM' : 'AM'; + break; + } + + return edited; +} + +function formatEndpoint(endpoint: DateTimeRangeEndpoint): string { + const month = MONTHS.at(endpoint.month); + if (!month) { + return ''; + } + + return `${month} ${endpoint.day}, ${String(endpoint.year).padStart(4, '0')}, ${endpoint.hour}:${String(endpoint.minute).padStart(2, '0')} ${endpoint.period}`; +} + +function getEndpointDaysInMonth(year: number, month: number): number { + const date = new Date(0); + date.setUTCFullYear(year, month + 1, 0); + return date.getUTCDate(); +} + +function wrap(value: number, minimum: number, maximum: number): number { + const size = maximum - minimum + 1; + return ((value - minimum + size) % size) + minimum; +} + +function toSelection( + segment: DateTimeRangeSegment | undefined +): DateTimeRangeInputSelection | null { + return segment ? { start: segment.start, end: segment.end } : null; +} diff --git a/packages/data-app-react/src/ui/date-time-range-label.ts b/packages/data-app-react/src/ui/date-time-range-label.ts new file mode 100644 index 00000000..3b72ce9e --- /dev/null +++ b/packages/data-app-react/src/ui/date-time-range-label.ts @@ -0,0 +1,30 @@ +import { + formatDateTimeRange, + parseDateTimeRange, + type DateTimeRange, +} from '@altertable/data-app/contract'; +import { + formatDateRange, + formatDateTime, + type DateFormatOptions, +} from '@altertable/data-app/format'; + +/** Rebased timestamps are wall-clock fields; never convert them through the display timezone. */ +export function dateTimeRangeLabel( + value: DateTimeRange, + options: DateFormatOptions = {} +) { + const { from, to } = parseDateTimeRange(value); + if (!(from instanceof Date) || !(to instanceof Date)) + return formatDateTimeRange(value); + if (from.getTime() % 86_400_000 === 0 && to.getTime() % 86_400_000 === 0) + return formatDateRange( + { + start: from.toISOString().slice(0, 10), + end: new Date(to.getTime() - 1).toISOString().slice(0, 10), + }, + options + ); + const rebased = { ...options, timeZone: 'UTC' }; + return `${formatDateTime(from, rebased)} – ${formatDateTime(to, rebased)}`; +} diff --git a/packages/data-app-react/src/ui/index.ts b/packages/data-app-react/src/ui/index.ts index 22bbf59d..d31de043 100644 --- a/packages/data-app-react/src/ui/index.ts +++ b/packages/data-app-react/src/ui/index.ts @@ -211,3 +211,20 @@ export type { ExplorationAction, ItemActions, } from './ExplorationActionControl.tsx'; + +export { + dateTimeRangeVariable, + dateTimeRangePresets, + type DateTimeRangePreset, + type DateTimeRangeVariable, + type DateTimeRangeComparisonVariable, + type DateTimeRangeComparisonVariableOptions, + type DateTimeRangeComparisonSelection, + type DateTimeRangeComparisonRequest, + type DateTimeRangeVariableOptions, +} from './variables.ts'; + +export { + DateTimeRangePicker, + type DateTimeRangePickerProps, +} from './DateTimeRangePicker.tsx'; diff --git a/packages/data-app-react/src/ui/metric-comparison.ts b/packages/data-app-react/src/ui/metric-comparison.ts index f48f12d8..29f6431d 100644 --- a/packages/data-app-react/src/ui/metric-comparison.ts +++ b/packages/data-app-react/src/ui/metric-comparison.ts @@ -1,4 +1,7 @@ -import { type DateRangeRequest } from '@altertable/data-app/contract'; +import { + type DateTimeRangeRequest, + type DateRangeRequest, +} from '@altertable/data-app/contract'; import { formatDateRange, formatMetric, @@ -6,6 +9,8 @@ import { type DateFormatOptions, } from '@altertable/data-app/format'; +import { dateTimeRangeLabel } from './date-time-range-label.ts'; + export type MetricComparison = { current: { value: number; formattedValue: string; periodLabel?: string }; previous?: { @@ -19,7 +24,7 @@ export type MetricComparison = { /** Label a comparison from the input that produced the displayed result. Null means the * requested previous period has no comparable value; zero remains a measured value. */ export function calendarMetricComparison( - displayedInput: DateRangeRequest, + displayedInput: DateRangeRequest | DateTimeRangeRequest, values: { current: number; previous: number | null; @@ -30,11 +35,18 @@ export function calendarMetricComparison( ): MetricComparison | undefined { if (!displayedInput.comparison) return undefined; + function label( + range: DateRangeRequest['range'] | DateTimeRangeRequest['range'] + ) { + return 'start' in range + ? formatDateRange(range, options) + : dateTimeRangeLabel(range, options); + } return { current: { value: values.current, formattedValue: formatMetric(values.current, values.format), - periodLabel: formatDateRange(displayedInput.range, options), + periodLabel: label(displayedInput.range), }, previous: { value: values.previous, @@ -42,7 +54,7 @@ export function calendarMetricComparison( values.previous === null ? 'Not available' : formatMetric(values.previous, values.format), - periodLabel: formatDateRange(displayedInput.comparison, options), + periodLabel: label(displayedInput.comparison), }, favorableDirection: values.favorableDirection, }; diff --git a/packages/data-app-react/src/ui/variables.ts b/packages/data-app-react/src/ui/variables.ts index 0e0c41d4..fe6e6d1f 100644 --- a/packages/data-app-react/src/ui/variables.ts +++ b/packages/data-app-react/src/ui/variables.ts @@ -102,11 +102,17 @@ export function useAppVariables( currentSearch, serverSearch ); - const zones = Object.values(definitions) - .filter(item => item.kind === 'dateRange') - .map(item => (item as DateRangeVariable).bounds().timeZone); + const zones = Object.values(definitions).flatMap(item => + item.kind === 'dateRange' + ? [item.bounds().timeZone] + : (item.kind === 'dateTimeRange' || + item.kind === 'dateTimeRangeComparison') && + item.contract + ? [item.contract.bounds().timeZone] + : [] + ); const zoneKey = [...new Set(zones)].sort().join('|'); - const [, bumpDay] = useReducer(value => value + 1, 0); + const [calendarRevision, bumpDay] = useReducer(value => value + 1, 0); useEffect(() => { if (!zoneKey) return; @@ -218,5 +224,26 @@ export function useAppVariables( }; } - return { values, set, reset, resetAll, clearAll, update, bind }; + return { + values, + calendarRevision, + set, + reset, + resetAll, + clearAll, + update, + bind, + }; } + +export { + dateTimeRangeVariable, + dateTimeRangePresets, + type DateTimeRangePreset, + type DateTimeRangeVariable, + type DateTimeRangeComparisonVariable, + type DateTimeRangeComparisonVariableOptions, + type DateTimeRangeComparisonSelection, + type DateTimeRangeComparisonRequest, + type DateTimeRangeVariableOptions, +} from '@altertable/data-app/internal'; diff --git a/packages/data-app-react/src/view-control-bar.tsx b/packages/data-app-react/src/view-control-bar.tsx index 3a375273..b92a4e6a 100644 --- a/packages/data-app-react/src/view-control-bar.tsx +++ b/packages/data-app-react/src/view-control-bar.tsx @@ -131,7 +131,6 @@ export function ViewControlBar({ )} )} - {onClear && } )} {primaryFilters} @@ -149,6 +148,7 @@ export function ViewControlBar({ ))} )} + {onClear && } {displayOptions.length > 0 && ( diff --git a/packages/data-app-react/src/view-controls.tsx b/packages/data-app-react/src/view-controls.tsx index 40894497..38f053d8 100644 --- a/packages/data-app-react/src/view-controls.tsx +++ b/packages/data-app-react/src/view-controls.tsx @@ -1,3 +1,7 @@ +import type { + DateTimeRangeComparisonSelection, + DateTimeRange, +} from '@altertable/data-app/contract'; import type { NumberFilter, NumberSelection, @@ -15,7 +19,7 @@ import { import type { AppVariable } from '@altertable/data-app/internal'; import { type DimensionSelectionVariable } from '@altertable/data-app/internal'; import { useQueries } from '@tanstack/react-query'; -import { useContext, type ReactNode } from 'react'; +import { useContext, useMemo, useReducer, type ReactNode } from 'react'; import { SearchSuggestionsContext, @@ -23,6 +27,7 @@ import { } from './search-suggestions.ts'; import { ChoicePicker } from './ui/ChoicePicker.tsx'; import { DateRangePicker } from './ui/DateRangePicker.tsx'; +import { DateTimeRangePicker } from './ui/DateTimeRangePicker.tsx'; import { DimensionPicker } from './ui/DimensionPicker.tsx'; import { NumberFilterPicker } from './ui/NumberFilterPicker.tsx'; import type { ControlLabelPlacement } from './ui/PickerField.tsx'; @@ -53,14 +58,31 @@ export function useViewVariables( ) => Promise ) { const variables = useAppVariables(definitions); + const [referenceRevision, refreshReference] = useReducer( + value => value + 1, + 0 + ); + const selectionKey = JSON.stringify(variables.values); + const reference = useMemo( + // Capture one reporting instant per selection, refresh, or reporting-day change. + // eslint-disable-next-line react/purity + () => new Date(), + // eslint-disable-next-line react-hooks/exhaustive-deps + [selectionKey, referenceRevision, variables.calendarRevision] + ); + const hasComparison = Object.values(definitions).some( + variable => variable.kind === 'dateTimeRangeComparison' + ); const resolved = {} as ResolvedVariables; for (const name of Object.keys(definitions) as (keyof Variables & string)[]) { const definition = definitions[name]!; const value = variables.values[name]; resolved[name] = ( - definition.kind === 'dateRange' - ? definition.input(value as DateRangeSelection) - : value + definition.kind === 'dateTimeRangeComparison' + ? definition.input(value as DateTimeRangeComparisonSelection, reference) + : definition.kind === 'dateRange' + ? definition.input(value as DateRangeSelection) + : value ) as ResolvedVariables[typeof name]; } const facets = Object.entries(definitions).filter( @@ -236,6 +258,54 @@ export function useViewVariables( )} /> ); + } else if (definition.kind === 'dateTimeRangeComparison') { + const selection = value as DateTimeRangeComparisonSelection; + control = ( + + setControlValue({ + range: selection.range, + ...(enabled ? { comparison: 'previous' } : {}), + } as AppVariableValues[typeof name]), + }} + onChange={range => { + const next: DateTimeRangeComparisonSelection = { + range, + ...(selection.comparison && definition.previous({ range }) + ? { comparison: 'previous' } + : {}), + }; + setControlValue( + next as AppVariableValues[typeof name] + ); + }} + /> + ); + } else if (definition.kind === 'dateTimeRange') { + control = ( + + setControlValue(next as AppVariableValues[typeof name]) + } + /> + ); } else if (definition.kind === 'dimension') { const filter = definition as DimensionSelectionVariable; const facet = dimensionState(name, filter); @@ -365,6 +435,11 @@ export function useViewVariables( const hasControls = allFilters.length > 0 || orderedDisplayOptions.length > 0; return { + refreshTime() { + if (!hasComparison) return false; + refreshReference(); + return true; + }, ...variables, resolved, searchControls: searchControls.length ? searchControls : null, diff --git a/packages/data-app-react/src/view.ts b/packages/data-app-react/src/view.ts index afba3b42..b7cc5814 100644 --- a/packages/data-app-react/src/view.ts +++ b/packages/data-app-react/src/view.ts @@ -1,4 +1,11 @@ -import type { DateRangeRequest } from '@altertable/data-app/contract'; +import { + parseDateTimeRange, + serializeDateTimeRange, + formatDateTimeRange, + type DateTimeRangeComparisonRequest, + type DateTimeRangeComparisonVariable, + type DateRangeRequest, +} from '@altertable/data-app/contract'; import type { AppVariable, AppVariableValues, @@ -11,9 +18,12 @@ import { invariant } from '@altertable/data-app/internal'; import type { DataContext } from './ui/data-context.ts'; import type { EmptyContent } from './ui/presentation.ts'; -type ResolvedVariable = Variable extends DateRangeVariable - ? DateRangeRequest - : AppVariableValues<{ value: Variable }>['value']; +type ResolvedVariable = + Variable extends DateTimeRangeComparisonVariable + ? DateTimeRangeComparisonRequest + : Variable extends DateRangeVariable + ? DateRangeRequest + : AppVariableValues<{ value: Variable }>['value']; export type ResolvedVariables = { [Key in keyof Variables]: | ResolvedVariable @@ -21,16 +31,18 @@ export type ResolvedVariables = { }; type DateVariableKey = { - [Key in keyof Variables]: Variables[Key] extends DateRangeVariable + [Key in keyof Variables]: Variables[Key] extends + | DateRangeVariable + | DateTimeRangeComparisonVariable ? Key : never; }[keyof Variables] & string; +/** Extract the selected range and comparison unchanged, including from nested inputs. */ export type ViewDate = { variable: DateVariableKey; - /** Extract the selected range and comparison unchanged, including from nested inputs. */ - input: (input: Input) => DateRangeRequest; + input: (input: Input) => DateRangeRequest | DateTimeRangeComparisonRequest; }; /** Read variable values from a custom operation input; omitted keys use top-level fields. */ @@ -42,7 +54,9 @@ export type ViewBindings< }>; type RequiredFilterBindingKeys = { - [Key in keyof Variables]: Variables[Key]['kind'] extends 'dateRange' + [Key in keyof Variables]: Variables[Key]['kind'] extends + | 'dateRange' + | 'dateTimeRangeComparison' ? never : Variables[Key]['kind'] extends 'choice' ? [Input] extends [Record] @@ -144,13 +158,18 @@ export type ResolvedDataViewDefinition< export function describeViewInput(definition: { variables: VariableCollection; bindings?: Partial unknown>>; - date?: { variable: string; input: (input: Input) => DateRangeRequest }; + date?: { + variable: string; + input: (input: Input) => DateRangeRequest | DateTimeRangeComparisonRequest; + }; describeInput?: (input: Input) => string; }): (input: Input) => string { const date = definition.date; const variable = date && definition.variables[date.variable]; invariant( - !date || variable?.kind === 'dateRange', + !date || + variable?.kind === 'dateRange' || + variable?.kind === 'dateTimeRangeComparison', 'The view date must reference a date range variable.' ); if (definition.describeInput) return definition.describeInput; @@ -158,9 +177,13 @@ export function describeViewInput(definition: { const dateBinding = date; function describeInput(input: Input) { - const period = (variable as DateRangeVariable).describeInput( - dateBinding.input(input) - ); + const request = dateBinding.input(input); + const period = + variable?.kind === 'dateTimeRangeComparison' + ? formatDateTimeRange(parseDateTimeRange(request.range)) + : (variable as DateRangeVariable).describeInput( + request as DateRangeRequest + ); const filters = Object.entries(definition.variables) .filter(([, variable]) => ['dimension', 'numberFilter', 'booleanFilter'].includes(variable.kind) @@ -192,28 +215,55 @@ export function resolveViewInput( ): Input { const input = definition.input(values); if (definition.date) { - const selected = values[definition.date.variable] as DateRangeRequest; - const mapped = definition.date.input(input); - invariant( - mapped && mapped.range && 'comparison' in mapped, - 'The operation input must preserve the selected date range and comparison.' - ); + const variable = definition.variables[definition.date.variable]; + if (variable?.kind === 'dateTimeRangeComparison') { + const selected = values[ + definition.date.variable + ] as DateTimeRangeComparisonRequest; + const mapped = definition.date.input( + input + ) as DateTimeRangeComparisonRequest; + invariant( + mapped && + variable.same(selected.selection, mapped.selection) && + selected.reference === mapped.reference && + serializeDateTimeRange(selected.range) === + serializeDateTimeRange(mapped.range) && + (selected.comparison === null + ? mapped.comparison === null + : !!mapped.comparison && + serializeDateTimeRange(selected.comparison) === + serializeDateTimeRange(mapped.comparison)), + 'The operation input must preserve the selected datetime range and comparison.' + ); + } else { + const selected = values[definition.date.variable] as DateRangeRequest; + const mapped = definition.date.input(input) as DateRangeRequest; + invariant( + mapped && mapped.range && 'comparison' in mapped, + 'The operation input must preserve the selected date range and comparison.' + ); - function sameRange( - a: DateRangeRequest['comparison'], - b: DateRangeRequest['comparison'] - ) { - return a === b || (!!a && !!b && a.start === b.start && a.end === b.end); + function sameRange( + a: DateRangeRequest['comparison'], + b: DateRangeRequest['comparison'] + ) { + return ( + a === b || (!!a && !!b && a.start === b.start && a.end === b.end) + ); + } + invariant( + sameRange(selected.range, mapped.range) && + sameRange(selected.comparison, mapped.comparison), + 'The operation input must preserve the selected date range and comparison.' + ); } - invariant( - sameRange(selected.range, mapped.range) && - sameRange(selected.comparison, mapped.comparison), - 'The operation input must preserve the selected date range and comparison.' - ); } + for (const [key, variable] of Object.entries(definition.variables)) { if ( variable.kind === 'dateRange' || + variable.kind === 'dateTimeRangeComparison' || (variable.presentation?.group === 'display' && variable.kind !== 'choice') ) continue; diff --git a/packages/data-app/package.json b/packages/data-app/package.json index ba2409b8..448d1a80 100644 --- a/packages/data-app/package.json +++ b/packages/data-app/package.json @@ -77,6 +77,7 @@ "typecheck": "bun ../../node_modules/typescript/bin/tsc --noEmit -p tsconfig.build.json" }, "dependencies": { - "@internationalized/date": "3.12.4" + "@internationalized/date": "3.12.4", + "chrono-node": "2.9.1" } } diff --git a/packages/data-app/src/authoring/contract.ts b/packages/data-app/src/authoring/contract.ts index cd573308..518bc519 100644 --- a/packages/data-app/src/authoring/contract.ts +++ b/packages/data-app/src/authoring/contract.ts @@ -5,10 +5,15 @@ */ import { parseDate, today } from '@internationalized/date'; +import { createDateTimeRangeContract } from '../core/date-time-range-contract.ts'; import { attachDimensionSelectors, validateVariableInput, } from '../core/dimension-source.ts'; +export type { + DateTimeRangeContract, + DateTimeRangeRequest, +} from '../core/date-time-range-contract.ts'; import { formatDateRange } from '../core/format.ts'; import { invariant } from '../core/invariant.ts'; import { @@ -18,7 +23,6 @@ import { decodeQueryResult, type QueryDefinitions, type CheckedQueries, - type QueryParameters, type QueryInputParameters, type QueryRow, type IsUnion, @@ -103,6 +107,7 @@ export type DateRangeContractOptions = { /** One source policy for the server parser, URL variable, and displayed reporting period. */ export function defineDateRangeContract(options: DateRangeContractOptions) { + options = { ...options }; invariant( Number.isInteger(options.maxRangeDays) && options.maxRangeDays > 0, 'Date range needs a positive maxRangeDays.' @@ -111,26 +116,8 @@ export function defineDateRangeContract(options: DateRangeContractOptions) { if (typeof options.maxDate === 'string') parseDate(options.maxDate); today(options.timeZone); - function bounds() { - const latest = today(options.timeZone).subtract({ - days: options.completeDays === false ? 0 : 1, - }); - const sourceMax = - typeof options.maxDate === 'function' - ? options.maxDate() - : options.maxDate; - const maxDate = - sourceMax && parseDate(sourceMax).compare(latest) < 0 - ? parseDate(sourceMax).toString() - : latest.toString(); - - return { - minDate: options.minDate, - maxDate, - maxRangeDays: options.maxRangeDays, - timeZone: options.timeZone, - }; - } + const datetime = createDateTimeRangeContract(options); + const bounds = datetime.bounds; function period({ start, end }: DateRangeInput): ReportingPeriod { return { @@ -181,6 +168,7 @@ export function defineDateRangeContract(options: DateRangeContractOptions) { } return { + datetime, request, parseRequest, bounds, @@ -375,7 +363,12 @@ export function defineOperation< async run(context, input) { async function query( name: Name, - values: Partial = {}, + values: Record< + string, + | import('../core/queries.ts').QueryParameterValue + | import('../core/date-time-range.ts').DateTimeRange + | undefined + > = {}, options?: { limit?: number } ) { invariant(Object.hasOwn(queries, name), `Unknown query name: ${name}.`); @@ -461,3 +454,30 @@ export type { } from '../core/filters.ts'; export { variableInput } from '../core/dimension-source.ts'; + +export { + dateTimeRangeVariable, + dateTimeRangePresets, + type DateTimeRangePreset, + type DateTimeRangeVariable, + type DateTimeRangeComparisonVariable, + type DateTimeRangeComparisonVariableOptions, + type DateTimeRangeComparisonSelection, + type DateTimeRangeComparisonRequest, + type DateTimeRangeVariableOptions, +} from '../core/date-time-variable.ts'; +export { + parseDateTimeRange, + serializeDateTimeRange, + resolveDateTimeRange, + formatDateTimeRange, + formatDateTimeBound, + parseDateTimeBound, +} from '../core/date-time-range-value.ts'; +export type { + DateTimeRange, + AbsoluteOrRelativeDateTime, + RelativeDateTime, +} from '../core/date-time-range.ts'; + +export { dateTimeRangeSql } from '../core/date-time-range-sql.ts'; diff --git a/packages/data-app/src/authoring/index.ts b/packages/data-app/src/authoring/index.ts index b71fad29..0ad90d9d 100644 --- a/packages/data-app/src/authoring/index.ts +++ b/packages/data-app/src/authoring/index.ts @@ -11,3 +11,12 @@ export type { QueryProjections, QueryRow, } from '../core/config.ts'; + +export type { + DateTimeRange, + AbsoluteOrRelativeDateTime, + RelativeDateTime, + RelativeAnchor, + RelativeOffset, + RelativeUnit, +} from '../core/date-time-range.ts'; diff --git a/packages/data-app/src/core/config.ts b/packages/data-app/src/core/config.ts index 1566eb58..0673d706 100644 --- a/packages/data-app/src/core/config.ts +++ b/packages/data-app/src/core/config.ts @@ -75,9 +75,10 @@ export type DataAppDefinition< * references, and callbacks. Extraction reads literals without executing app code. * TypeScript and runtime query rules own configuration validation. * - * SQL passes unchanged to the backend, which interprets placeholders such as `$orgId`. + * Scalar placeholders pass to the backend. Datetime-range bound references ($period.from/to) + * expand to DuckDB timestamp expressions while the range is bound as JSON. * Declare each parameter's type and optional defaultValue. - * Values are finite numbers, strings, booleans, or null, never SQL fragments or identifiers. + * Values are scalars or declared datetime ranges, never SQL fragments or identifiers. * Host queryParams supply protected values; callers cannot override them. */ export function defineDataApp( @@ -110,3 +111,12 @@ export function dataAppTitle( ): string { return `${config.title} • ${config.scope.organization}/${config.scope.environment} • Altertable app`; } + +export type { + DateTimeRange, + AbsoluteOrRelativeDateTime, + RelativeDateTime, + RelativeAnchor, + RelativeOffset, + RelativeUnit, +} from './date-time-range.ts'; diff --git a/packages/data-app/src/core/contract.ts b/packages/data-app/src/core/contract.ts index 808d571c..9a0ea3d6 100644 --- a/packages/data-app/src/core/contract.ts +++ b/packages/data-app/src/core/contract.ts @@ -25,6 +25,8 @@ export type { ReportingPeriod, DateRangeContractOptions, DateRangeContract, + DateTimeRangeContract, + DateTimeRangeRequest, RegisteredQuery, OperationDefinition, ConnectionOperation, @@ -136,3 +138,29 @@ export { type OpenExternalLink, type ExternalLinkResult, } from './external-link.ts'; +export { + dateTimeRangeVariable, + dateTimeRangePresets, + type DateTimeRangePreset, + type DateTimeRangeVariable, + type DateTimeRangeComparisonVariable, + type DateTimeRangeComparisonVariableOptions, + type DateTimeRangeComparisonSelection, + type DateTimeRangeComparisonRequest, + type DateTimeRangeVariableOptions, +} from '../authoring/contract.ts'; +export { + parseDateTimeRange, + serializeDateTimeRange, + resolveDateTimeRange, + formatDateTimeRange, + formatDateTimeBound, + parseDateTimeBound, +} from '../authoring/contract.ts'; +export type { + DateTimeRange, + AbsoluteOrRelativeDateTime, + RelativeDateTime, +} from '../authoring/contract.ts'; + +export { dateTimeRangeSql } from '../authoring/contract.ts'; diff --git a/packages/data-app/src/core/date-time-range-contract.ts b/packages/data-app/src/core/date-time-range-contract.ts new file mode 100644 index 00000000..b792ad28 --- /dev/null +++ b/packages/data-app/src/core/date-time-range-contract.ts @@ -0,0 +1,119 @@ +import { + fromDate, + parseDate, + toCalendarDateTime, +} from '@internationalized/date'; + +import type { DateRangeContractOptions } from '../authoring/contract.ts'; +import { + parseDateTimeRange, + resolveDateTimeRange, +} from './date-time-range-value.ts'; +import type { DateTimeRange } from './date-time-range.ts'; +import { invariant } from './invariant.ts'; + +/** Resolved UTC-rebased bounds for one execution, optionally with the preceding equal-length interval. */ +export type DateTimeRangeRequest = { + range: { from: Date; to: Date }; + comparison: { from: Date; to: Date } | null; +}; + +/** Datetime policy exposed by defineDateRangeContract().datetime. Parsing preserves relative values. */ +export type DateTimeRangeContract = ReturnType< + typeof createDateTimeRangeContract +>; + +export function createDateTimeRangeContract(options: DateRangeContractOptions) { + const { + minDate, + maxDate: sourceMaxDate, + maxRangeDays, + timeZone, + completeDays, + } = options; + function reference(now: Date) { + invariant(Number.isFinite(now.getTime()), 'Invalid reference date.'); + // Resolve symbolic anchors using reporting-zone wall-clock fields, not browser-local fields. + return toCalendarDateTime(fromDate(now, timeZone)).toDate('UTC'); + } + function bounds(now = new Date()) { + const date = reference(now).toISOString().slice(0, 10); + const latest = parseDate(date) + .subtract({ days: completeDays === false ? 0 : 1 }) + .toString(); + const sourceMax = + typeof sourceMaxDate === 'function' ? sourceMaxDate() : sourceMaxDate; + if (sourceMax) parseDate(sourceMax); + return { + minDate, + maxDate: sourceMax && sourceMax < latest ? sourceMax : latest, + maxRangeDays, + timeZone, + }; + } + function resolve( + value: unknown, + now = new Date() + ): DateTimeRangeRequest['range'] { + const range = resolveDateTimeRange( + parseDateTimeRange(value), + reference(now) + ); + const limits = bounds(now); + invariant(range.from, 'Choose a bounded datetime range.'); + invariant( + range.to.getTime() - range.from.getTime() <= maxRangeDays * 86_400_000, + `Choose a range of at most ${maxRangeDays} days.` + ); + invariant( + !minDate || range.from >= parseDate(minDate).toDate('UTC'), + 'Range starts before available source coverage.' + ); + invariant( + range.to <= parseDate(limits.maxDate).add({ days: 1 }).toDate('UTC'), + 'Range ends after available source coverage.' + ); + return { from: range.from, to: range.to }; + } + function parse(value: unknown, now = new Date()): DateTimeRange { + const range = parseDateTimeRange(value); + resolve(range, now); + return range; + } + function comparison( + value: unknown, + now = new Date() + ): DateTimeRangeRequest['comparison'] { + const { from, to } = resolve(value, now); + const previous = { + from: new Date(2 * from.getTime() - to.getTime()), + to: from, + }; + try { + return resolve(previous, now); + } catch { + return null; + } + } + return { + timeZone, + bounds, + parse, + resolve, + comparison, + /** Resolve both periods against one reference instant. Use these bounds for comparison queries. */ + request( + value: unknown, + compare = false, + now = new Date() + ): DateTimeRangeRequest { + const range = resolve(value, now); + const previous = compare ? comparison(range, now) : null; + invariant( + !compare || previous, + 'Comparison is outside available source coverage.' + ); + return { range, comparison: previous }; + }, + }; +} diff --git a/packages/data-app/src/core/date-time-range-parser.ts b/packages/data-app/src/core/date-time-range-parser.ts new file mode 100644 index 00000000..b79c809b --- /dev/null +++ b/packages/data-app/src/core/date-time-range-parser.ts @@ -0,0 +1,745 @@ +import * as chrono from 'chrono-node'; + +import type { + DateTimeRange, + RelativeDateTime, + RelativeUnit, +} from './date-time-range.ts'; + +// Adapted from the frontend parser. Absolute fields are encoded as UTC, +// while relative anchors remain unevaluated for the backend. + +export type DateTimeRangeParseFailureReason = + | 'empty' + | 'unrecognized' + | 'invalid-range' + | 'ambiguous'; + +export type DateTimeRangeParserOptions = { + /** UTC-encoded reference fields for omitted dates/years. Defaults to the current UTC date/time. */ + now?: Date; +}; + +export type ParseDateTimeRangeResult = + | { + ok: true; + range: DateTimeRange; + normalizedInput: string; + parser: + | 'relative' + | 'calendar-preset' + | 'calendar-range' + | 'unix' + | 'chrono'; + } + | { + ok: false; + reason: DateTimeRangeParseFailureReason; + normalizedInput: string; + }; + +type ParseSuccess = Extract; + +const RANGE_SEPARATOR_PATTERN = /\s*(?:-|–|—| to )\s*/i; + +const RELATIVE_UNIT_PATTERNS: ReadonlyArray<{ + unit: RelativeUnit; + patterns: readonly string[]; +}> = [ + { + unit: 'SECOND', + patterns: ['seconds', 'second', 'secs', 'sec', 's'], + }, + { + unit: 'MINUTE', + patterns: ['minutes', 'minute', 'mins', 'min', 'mn', 'm'], + }, + { + unit: 'HOUR', + patterns: ['hours', 'hour', 'hrs', 'hr', 'h'], + }, + { + unit: 'DAY', + patterns: ['days', 'day', 'd'], + }, + { + unit: 'WEEK', + patterns: ['weeks', 'week', 'wks', 'wk', 'w'], + }, + { + unit: 'MONTH', + patterns: ['months', 'month', 'mos', 'mo', 'mon'], + }, + { + unit: 'YEAR', + patterns: ['years', 'year', 'yrs', 'yr', 'y'], + }, +]; + +const RELATIVE_UNIT_BY_PATTERN = new Map( + RELATIVE_UNIT_PATTERNS.flatMap(({ unit, patterns }) => + patterns.map(pattern => [pattern, unit] as const) + ) +); + +function escapeRegex(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +const RELATIVE_UNIT_PATTERN = [...RELATIVE_UNIT_BY_PATTERN.keys()] + .sort((left, right) => right.length - left.length) + .map(escapeRegex) + .join('|'); + +const SLIDING_RELATIVE_PATTERN = new RegExp( + `^(?:last|past)\\s+(?:(\\d+)\\s*)?(${RELATIVE_UNIT_PATTERN})$`, + 'i' +); + +const AGO_RELATIVE_PATTERN = new RegExp( + `^(\\d+)\\s*(${RELATIVE_UNIT_PATTERN})\\s+ago$`, + 'i' +); + +const BARE_RELATIVE_PATTERN = new RegExp( + `^(\\d+)\\s*(${RELATIVE_UNIT_PATTERN})$`, + 'i' +); + +const SINCE_PREFIX_PATTERN = /^(?:since|from)\s+(.+)$/i; + +const UNIX_TIMESTAMP_PATTERN = /^\d+$/; + +const NOW_KEYWORDS = new Set(['now', 'today']); + +function normalizeInput(input: string): string { + return input.trim().replace(/\s+/g, ' '); +} + +function getParserContext(options?: DateTimeRangeParserOptions) { + const now = options?.now ?? new Date(); + return { now, reference: { instant: now, timezone: 'UTC' } }; +} + +/** Construct wall-clock fields without interpreting the runtime timezone. */ +function utcDate( + year: number, + month: number, + day: number, + hour = 0, + minute = 0, + second = 0, + millisecond = 0 +): Date { + const date = new Date(0); + date.setUTCFullYear(year, month, day); + date.setUTCHours(hour, minute, second, millisecond); + return date; +} + +function parseRelativeUnit(rawUnit: string): RelativeUnit | null { + return RELATIVE_UNIT_BY_PATTERN.get(rawUnit.toLowerCase()) ?? null; +} + +function relativeFromNowOffset( + amount: number, + unit: RelativeUnit +): RelativeDateTime { + return { + anchor: 'RELATIVE_ANCHOR_NOW', + offset: [{ amount: -amount, unit }], + }; +} + +function success( + range: DateTimeRange, + normalizedInput: string, + parser: ParseSuccess['parser'] +): ParseSuccess { + return { ok: true, range, normalizedInput, parser }; +} + +function failure( + reason: DateTimeRangeParseFailureReason, + normalizedInput: string +): ParseDateTimeRangeResult { + return { ok: false, reason, normalizedInput }; +} + +function parseUnixTimestamp(raw: string): Date | null { + const trimmed = raw.trim(); + if (!UNIX_TIMESTAMP_PATTERN.test(trimmed)) { + return null; + } + + const value = Number(trimmed); + if (!Number.isFinite(value)) { + return null; + } + + const date = trimmed.length <= 10 ? new Date(value * 1_000) : new Date(value); + return Number.isFinite(date.getTime()) ? date : null; +} + +function dateFromChronoComponents( + components: chrono.ParsedComponents +): Date | null { + const year = components.get('year'); + const month = components.get('month'); + const day = components.get('day'); + const hour = components.get('hour'); + const minute = components.get('minute'); + const second = components.get('second'); + const millisecond = components.get('millisecond'); + + if (year === null || month === null || day === null) { + return null; + } + + return utcDate( + year, + month - 1, + day, + hour ?? 0, + minute ?? 0, + second ?? 0, + millisecond ?? 0 + ); +} + +// Chrono validates calendar fields using local Date construction, which rejects +// wall-clock times in the host's DST gap. Validate these parse-local components +// as UTC fields instead, without changing Chrono's global parser or prototypes. +const wallClockParser = chrono.casual.clone(); +wallClockParser.refiners = wallClockParser.refiners.map(refiner => ({ + refine(context, results) { + for (const result of results) { + for (const components of [result.start, result.end]) { + if (!components) continue; + components.isValidDate = () => { + const date = dateFromChronoComponents(components); + return ( + date !== null && + Number.isFinite(date.getTime()) && + date.getUTCFullYear() === components.get('year') && + date.getUTCMonth() + 1 === components.get('month') && + date.getUTCDate() === components.get('day') && + date.getUTCHours() === (components.get('hour') ?? 0) && + date.getUTCMinutes() === (components.get('minute') ?? 0) + ); + }; + } + } + return refiner.refine(context, results); + }, +})); + +function parseAbsoluteResult( + text: string, + reference: chrono.ParsingReference +): chrono.ParsedResult | null | 'ambiguous' { + const trimmed = text.trim(); + if (!trimmed) { + return null; + } + + const results = wallClockParser.parse(trimmed, reference, { + forwardDate: false, + }); + if (results.length === 0) { + return null; + } + + const parsed = results[0]; + if (!parsed) { + return null; + } + + if (parsed.index !== 0 || parsed.text.length !== trimmed.length) { + return null; + } + + if (results.length > 1) { + const firstStart = parsed.start.date().getTime(); + const hasDistinctInterpretation = results.some( + result => result.start.date().getTime() !== firstStart + ); + if (hasDistinctInterpretation) { + return 'ambiguous'; + } + } + + return parsed; +} + +function parseAbsoluteDateTime( + text: string, + reference: chrono.ParsingReference, + end = false +): Date | null | 'ambiguous' { + const parsed = parseAbsoluteResult(text, reference); + if (!parsed || parsed === 'ambiguous') { + return parsed; + } + + if (parsed.end) return null; + return absoluteBoundary(parsed.start, end); +} + +function absoluteBoundary( + components: chrono.ParsedComponents, + end = false +): Date | null { + const date = dateFromChronoComponents(components); + if (date && !components.isCertain('hour')) { + date.setUTCHours(0, 0, 0, 0); + if (end) date.setUTCDate(date.getUTCDate() + 1); + } + return date; +} + +function isNowKeyword(text: string): boolean { + return NOW_KEYWORDS.has(text.trim().toLowerCase()); +} + +const CALENDAR_MONTH_PATTERN = + /^(jan(?:uary)?|feb(?:ruary)?|mar(?:ch)?|apr(?:il)?|may|jun(?:e)?|jul(?:y)?|aug(?:ust)?|sep(?:t(?:ember)?)?|oct(?:ober)?|nov(?:ember)?|dec(?:ember)?)(?:\s+(\d{4}))?$/i; + +const CALENDAR_MONTHS = [ + 'jan', + 'feb', + 'mar', + 'apr', + 'may', + 'jun', + 'jul', + 'aug', + 'sep', + 'oct', + 'nov', + 'dec', +]; + +function parseCalendarPeriod(text: string) { + if (/^\d{4}$/.test(text)) { + return { year: Number(text), month: 0, months: 12 }; + } + + const match = text.match(CALENDAR_MONTH_PATTERN); + if (!match?.[1]) { + return null; + } + + return { + year: match[2] ? Number(match[2]) : null, + month: CALENDAR_MONTHS.indexOf(match[1].slice(0, 3).toLowerCase()), + months: 1, + }; +} + +function tryParseCalendarRange( + normalized: string, + now: Date +): ParseDateTimeRangeResult | null { + const parts = normalized.split(RANGE_SEPARATOR_PATTERN); + if (parts.length > 2) { + return null; + } + + const first = parseCalendarPeriod(parts[0] ?? ''); + const last = parts.length === 1 ? first : parseCalendarPeriod(parts[1] ?? ''); + if (!first || !last || first.months !== last.months) { + return null; + } + + const currentYear = now.getUTCFullYear(); + const fromYear = first.year ?? last.year ?? currentYear; + const toYear = last.year ?? first.year ?? currentYear; + const from = utcDate(fromYear, first.month, 1); + const to = utcDate(toYear, last.month + last.months, 1); + + if (from.getTime() >= to.getTime()) { + return failure('invalid-range', normalized); + } + + return success({ from, to }, normalized, 'calendar-range'); +} + +function tryParseUnixRange( + normalized: string +): ParseSuccess | ParseDateTimeRangeResult | null { + const parts = normalized.split(RANGE_SEPARATOR_PATTERN); + if (parts.length !== 2) { + return null; + } + + const fromPart = parts.at(0); + const toPart = parts.at(1); + if (!fromPart || !toPart) { + return null; + } + + const from = parseUnixTimestamp(fromPart); + if (from && isNowKeyword(toPart)) { + return success({ from, to: null }, normalized, 'unix'); + } + + const to = parseUnixTimestamp(toPart); + if (!from || !to) { + return null; + } + + if (from.getTime() > to.getTime()) { + return failure('invalid-range', normalized); + } + + return success({ from, to }, normalized, 'unix'); +} + +function tryParseSlidingRelative(normalized: string): ParseSuccess | null { + const match = normalized.match(SLIDING_RELATIVE_PATTERN); + if (!match) { + return null; + } + + const amount = match[1] ? Number(match[1]) : 1; + const unit = parseRelativeUnit(match[2] ?? ''); + if (!Number.isSafeInteger(amount) || amount <= 0 || !unit) { + return null; + } + + return success( + { + from: relativeFromNowOffset(amount, unit), + to: null, + }, + normalized, + 'relative' + ); +} + +function tryParseCalendarPreset(normalized: string): ParseSuccess | null { + const lower = normalized.toLowerCase(); + + if (lower === 'today') { + return success( + { + from: { + anchor: 'RELATIVE_ANCHOR_START_OF_TODAY', + offset: [], + }, + to: null, + }, + normalized, + 'calendar-preset' + ); + } + + if (lower === 'yesterday') { + return success( + { + from: { + anchor: 'RELATIVE_ANCHOR_START_OF_YESTERDAY', + offset: [], + }, + to: { + anchor: 'RELATIVE_ANCHOR_START_OF_TODAY', + offset: [], + }, + }, + normalized, + 'calendar-preset' + ); + } + + if (lower === 'week to date' || lower === 'wtd') { + return success( + { + from: { + anchor: 'RELATIVE_ANCHOR_START_OF_WEEK', + offset: [], + }, + to: null, + }, + normalized, + 'calendar-preset' + ); + } + + if (lower === 'month to date' || lower === 'mtd') { + return success( + { + from: { + anchor: 'RELATIVE_ANCHOR_START_OF_MONTH', + offset: [], + }, + to: null, + }, + normalized, + 'calendar-preset' + ); + } + + if (lower === 'year to date' || lower === 'ytd') { + return success( + { + from: { + anchor: 'RELATIVE_ANCHOR_START_OF_YEAR', + offset: [], + }, + to: null, + }, + normalized, + 'calendar-preset' + ); + } + + return null; +} + +function tryParseAgoRelative(normalized: string): ParseSuccess | null { + const match = normalized.match(AGO_RELATIVE_PATTERN); + if (!match) { + return null; + } + + const amount = Number(match[1]); + const unit = parseRelativeUnit(match[2] ?? ''); + if (!Number.isSafeInteger(amount) || amount <= 0 || !unit) { + return null; + } + + return success( + { + from: relativeFromNowOffset(amount, unit), + to: null, + }, + normalized, + 'relative' + ); +} + +function tryParseBareRelative(normalized: string): ParseSuccess | null { + const match = normalized.match(BARE_RELATIVE_PATTERN); + if (!match) { + return null; + } + + const amount = Number(match[1]); + const unit = parseRelativeUnit(match[2] ?? ''); + if (!Number.isSafeInteger(amount) || amount <= 0 || !unit) { + return null; + } + + return success( + { + from: relativeFromNowOffset(amount, unit), + to: null, + }, + normalized, + 'relative' + ); +} + +function tryParseSinceRelative( + normalized: string +): ParseSuccess | ParseDateTimeRangeResult | null { + const match = normalized.match(SINCE_PREFIX_PATTERN); + if (!match) { + return null; + } + + const remainder = match[1]?.trim(); + if (!remainder) { + return failure('unrecognized', normalized); + } + + const agoMatch = remainder.match(AGO_RELATIVE_PATTERN); + if (agoMatch) { + const amount = Number(agoMatch[1]); + const unit = parseRelativeUnit(agoMatch[2] ?? ''); + if (!Number.isSafeInteger(amount) || amount <= 0 || !unit) { + return failure('unrecognized', normalized); + } + + return success( + { + from: relativeFromNowOffset(amount, unit), + to: null, + }, + normalized, + 'relative' + ); + } + + return null; +} + +function tryParseSinceAbsolute( + normalized: string, + reference: chrono.ParsingReference +): ParseSuccess | ParseDateTimeRangeResult | null { + const match = normalized.match(SINCE_PREFIX_PATTERN); + if (!match) { + return null; + } + + const remainder = match[1]?.trim(); + if (!remainder) { + return failure('unrecognized', normalized); + } + + const range = tryParseAbsoluteRange(remainder, reference); + if (range) return { ...range, normalizedInput: normalized }; + const absolute = parseAbsoluteDateTime(remainder, reference); + if (absolute === 'ambiguous') { + return failure('ambiguous', normalized); + } + if (!absolute) { + return failure('unrecognized', normalized); + } + + return success({ from: absolute, to: null }, normalized, 'chrono'); +} + +function tryParseAbsoluteRange( + normalized: string, + reference: chrono.ParsingReference +): ParseSuccess | ParseDateTimeRangeResult | null { + const parts = normalized.split(RANGE_SEPARATOR_PATTERN); + if (parts.length !== 2) { + return null; + } + + const fromPart = parts.at(0)?.trim(); + const toPart = parts.at(1)?.trim(); + if (!fromPart || !toPart) { + return null; + } + + if (isNowKeyword(toPart)) { + const from = parseAbsoluteDateTime(fromPart, reference); + if (from === 'ambiguous') { + return failure('ambiguous', normalized); + } + if (!from) { + return null; + } + + return success({ from, to: null }, normalized, 'chrono'); + } + + // Parse both ends together so an explicit year is shared with the other bound. + const paired = parseAbsoluteResult(normalized, reference); + if (paired === 'ambiguous') return failure('ambiguous', normalized); + if (paired?.end) { + const from = absoluteBoundary(paired.start); + const to = absoluteBoundary(paired.end, true); + if (!from || !to) return null; + if (from.getTime() >= to.getTime()) + return failure('invalid-range', normalized); + return success({ from, to }, normalized, 'chrono'); + } + const from = parseAbsoluteDateTime(fromPart, reference); + if (from === 'ambiguous') { + return failure('ambiguous', normalized); + } + + const to = parseAbsoluteDateTime(toPart, reference, true); + if (to === 'ambiguous') { + return failure('ambiguous', normalized); + } + + if (!from || !to) { + return null; + } + + if (from.getTime() > to.getTime()) { + return failure('invalid-range', normalized); + } + + return success({ from, to }, normalized, 'chrono'); +} + +function tryParseAbsoluteDay( + normalized: string, + reference: chrono.ParsingReference +): ParseSuccess | ParseDateTimeRangeResult | null { + const parsed = parseAbsoluteResult(normalized, reference); + if (parsed === 'ambiguous') { + return failure('ambiguous', normalized); + } + if ( + !parsed || + parsed.end || + !parsed.start.isCertain('month') || + !parsed.start.isCertain('day') || + parsed.start.isCertain('hour') + ) { + return null; + } + + const year = parsed.start.get('year'); + const month = parsed.start.get('month'); + const day = parsed.start.get('day'); + if (year === null || month === null || day === null) { + return null; + } + + const from = utcDate(year, month - 1, day); + const to = utcDate(year, month - 1, day + 1); + + return success({ from, to }, normalized, 'chrono'); +} + +export function parseDateTimeRangeInput( + input: string, + options?: DateTimeRangeParserOptions +): ParseDateTimeRangeResult { + const normalizedInput = normalizeInput(input); + + if (!normalizedInput) { + return failure('empty', normalizedInput); + } + + const { reference, now } = getParserContext(options); + + if (!Number.isFinite(now.getTime())) + return failure('invalid-range', normalizedInput); + + const attempts: Array<() => ParseSuccess | ParseDateTimeRangeResult | null> = + [ + () => tryParseCalendarRange(normalizedInput, now), + () => tryParseUnixRange(normalizedInput), + () => tryParseSlidingRelative(normalizedInput), + () => tryParseCalendarPreset(normalizedInput), + () => tryParseAgoRelative(normalizedInput), + () => tryParseBareRelative(normalizedInput), + () => tryParseSinceRelative(normalizedInput), + () => tryParseSinceAbsolute(normalizedInput, reference), + () => tryParseAbsoluteRange(normalizedInput, reference), + () => tryParseAbsoluteDay(normalizedInput, reference), + ]; + + for (const attempt of attempts) { + const result = attempt(); + if (!result) { + continue; + } + + if (!result.ok) { + return result; + } + + return result; + } + + return failure('unrecognized', normalizedInput); +} + +export function canParseDateTimeRangeInput( + input: string, + options?: DateTimeRangeParserOptions +): boolean { + return parseDateTimeRangeInput(input, options).ok; +} diff --git a/packages/data-app/src/core/date-time-range-sql.ts b/packages/data-app/src/core/date-time-range-sql.ts new file mode 100644 index 00000000..8134aa44 --- /dev/null +++ b/packages/data-app/src/core/date-time-range-sql.ts @@ -0,0 +1,87 @@ +import { serializeDateTimeRange } from './date-time-range-value.ts'; +import type { DateTimeRange } from './date-time-range.ts'; +import { invariant } from './invariant.ts'; + +/** DuckDB timestamp expressions using the query session's timezone for relative anchors. + * Absolute fields remain UTC-rebased TIMESTAMP values. Start is inclusive, end exclusive. + */ +export function dateTimeRangeSql( + range: DateTimeRange, + parameter = 'period' +): { from: string; to: string; params: Record } { + invariant( + /^[A-Za-z_][A-Za-z0-9_]*$/.test(parameter), + 'Use a SQL parameter name.' + ); + return { + from: boundExpression(parameter, 'from'), + to: boundExpression(parameter, 'to'), + params: { [parameter]: serializeDateTimeRange(range) }, + }; +} +function boundExpression(parameter: string, bound: 'from' | 'to'): string { + const json = `$${parameter}`; + const path = `$.${bound}`; + const anchor = `json_extract_string(${json}, '${path}.anchor')`; + const base = `CASE WHEN json_type(${json}, '${path}') = 'VARCHAR' THEN CAST(json_extract_string(${json}, '${path}') AS TIMESTAMP) + WHEN ${anchor} = 'RELATIVE_ANCHOR_START_OF_TODAY' THEN date_trunc('day', localtimestamp) + WHEN ${anchor} = 'RELATIVE_ANCHOR_START_OF_YESTERDAY' THEN date_trunc('day', localtimestamp) - INTERVAL '1 day' + WHEN ${anchor} = 'RELATIVE_ANCHOR_START_OF_TOMORROW' THEN date_trunc('day', localtimestamp) + INTERVAL '1 day' + WHEN ${anchor} = 'RELATIVE_ANCHOR_START_OF_WEEK' THEN date_trunc('week', localtimestamp) + WHEN ${anchor} = 'RELATIVE_ANCHOR_START_OF_MONTH' THEN date_trunc('month', localtimestamp) + WHEN ${anchor} = 'RELATIVE_ANCHOR_START_OF_YEAR' THEN date_trunc('year', localtimestamp) + WHEN ${anchor} = 'RELATIVE_ANCHOR_NOW' THEN localtimestamp + ELSE ${bound === 'from' ? "TIMESTAMP '-infinity'" : 'localtimestamp'} END`; + const item = `json_extract(${json}, '${path}.offset[' || i::VARCHAR || ']')`; + const amount = `CAST(json_extract_string(${item}, '$.amount') AS INTEGER)`; + const interval = `CASE json_extract_string(${item}, '$.unit') ${['SECOND', 'MINUTE', 'HOUR', 'DAY', 'WEEK', 'MONTH', 'YEAR'].map(unit => `WHEN '${unit}' THEN ${amount} * INTERVAL '1 ${unit}'`).join(' ')} ELSE INTERVAL '0 seconds' END`; + return `(WITH RECURSIVE bounds(i, value) AS ( + SELECT 0, ${base} + UNION ALL SELECT i + 1, value + ${interval} FROM bounds + WHERE i < COALESCE(json_array_length(${json}, '${path}.offset'), 0) + ) SELECT value FROM bounds ORDER BY i DESC LIMIT 1)`; +} + +/** Expand bound references only in SQL code, leaving quoted text and comments untouched. */ +export function expandDateTimeRangeParameters( + statement: string, + names: ReadonlySet +): string { + // SQL strings/identifiers, dollar-quoted strings, and comments are opaque to expansion. + const token = + /[eE]'(?:\\.|''|[^'\\])*'|'(?:''|[^'])*'|"(?:""|[^"])*"|\$(?:[A-Za-z_][A-Za-z0-9_]*)?\$|--[^\n]*(?:\n|$)|\/\*|\$([A-Za-z_][A-Za-z0-9_]*)(?:\.(from|to)\b)?/g; + let output = ''; + let position = 0; + for ( + let match = token.exec(statement); + match; + match = token.exec(statement) + ) { + output += statement.slice(position, match.index); + const text = match[0]; + if (text === '/*') { + let depth = 1; + let end = token.lastIndex; + while (depth && end < statement.length) { + if (statement.slice(end, end + 2) === '/*') { + depth++; + end += 2; + } else if (statement.slice(end, end + 2) === '*/') { + depth--; + end += 2; + } else end++; + } + output += statement.slice(match.index, end); + token.lastIndex = end; + } else if (/^\$(?:[A-Za-z_][A-Za-z0-9_]*)?\$$/.test(text)) { + const closing = statement.indexOf(text, token.lastIndex); + const end = closing < 0 ? statement.length : closing + text.length; + output += statement.slice(match.index, end); + token.lastIndex = end; + } else if (match[1] && names.has(match[1]) && match[2]) { + output += boundExpression(match[1], match[2] as 'from' | 'to'); + } else output += text; + position = token.lastIndex; + } + return output + statement.slice(position); +} diff --git a/packages/data-app/src/core/date-time-range-value.ts b/packages/data-app/src/core/date-time-range-value.ts new file mode 100644 index 00000000..de6654ce --- /dev/null +++ b/packages/data-app/src/core/date-time-range-value.ts @@ -0,0 +1,280 @@ +import { CalendarDateTime } from '@internationalized/date'; + +import { parseDateTimeRangeInput } from './date-time-range-parser.ts'; +import type { + AbsoluteOrRelativeDateTime, + DateTimeRange, + RelativeAnchor, + RelativeDateTime, + RelativeUnit, +} from './date-time-range.ts'; +import { invariant } from './invariant.ts'; + +const anchors: Record = { + RELATIVE_ANCHOR_NOW: 'Now', + RELATIVE_ANCHOR_START_OF_TODAY: 'Start of today', + RELATIVE_ANCHOR_START_OF_YESTERDAY: 'Start of yesterday', + RELATIVE_ANCHOR_START_OF_TOMORROW: 'Start of tomorrow', + RELATIVE_ANCHOR_START_OF_WEEK: 'Start of week', + RELATIVE_ANCHOR_START_OF_MONTH: 'Start of month', + RELATIVE_ANCHOR_START_OF_YEAR: 'Start of year', +}; +const units: Record = { + SECOND: 's', + MINUTE: 'm', + HOUR: 'h', + DAY: 'd', + WEEK: 'w', + MONTH: 'mo', + YEAR: 'y', +}; + +/** Validate and restore UTC-rebased dates from JSON, without resolving relative bounds. */ +export function parseDateTimeRange( + value: unknown, + { maxRangeDays }: { maxRangeDays?: number } = {} +): DateTimeRange { + invariant( + !!value && typeof value === 'object' && !Array.isArray(value), + 'Choose a datetime range.' + ); + const record = value as Record; + invariant( + Object.keys(record).every(key => key === 'from' || key === 'to'), + 'Unknown datetime range field.' + ); + function bound(raw: unknown): AbsoluteOrRelativeDateTime | null { + if (raw == null) return null; + if (raw instanceof Date || typeof raw === 'string') { + const date = new Date(raw); + invariant( + Number.isFinite(date.getTime()) && + (raw instanceof Date || date.toISOString() === raw), + 'Absolute bounds must be UTC ISO dates.' + ); + return date; + } + invariant( + typeof raw === 'object' && !Array.isArray(raw), + 'Invalid datetime bound.' + ); + const r = raw as Record; + invariant( + Object.keys(r).every(key => key === 'anchor' || key === 'offset') && + typeof r.anchor === 'string' && + Object.hasOwn(anchors, r.anchor), + 'Invalid relative anchor.' + ); + invariant( + Array.isArray(r.offset) && r.offset.length <= 16, + 'Use at most 16 relative offsets.' + ); + const offset = r.offset.map((o: unknown) => { + invariant( + !!o && typeof o === 'object' && !Array.isArray(o), + 'Invalid relative offset.' + ); + const v = o as Record; + invariant( + Object.keys(v).every(key => key === 'amount' || key === 'unit') && + typeof v.amount === 'number' && + Number.isSafeInteger(v.amount) && + Math.abs(v.amount) <= 1_000_000 && + typeof v.unit === 'string' && + Object.hasOwn(units, v.unit), + 'Invalid relative offset.' + ); + return { amount: v.amount, unit: v.unit as RelativeUnit }; + }); + return { anchor: r.anchor as RelativeAnchor, offset }; + } + const range = { from: bound(record.from), to: bound(record.to) }; + if (range.from instanceof Date && range.to instanceof Date) + invariant(range.from < range.to, 'Start must be before end.'); + invariant( + maxRangeDays === undefined || + (Number.isFinite(maxRangeDays) && maxRangeDays > 0), + 'Maximum range days must be positive.' + ); + // Only compare bounds whose separation is independent of reporting time and zone. + // Calendar offsets and mixed absolute/relative bounds require backend resolution. + let duration: number | undefined; + if (range.from instanceof Date && range.to instanceof Date) { + duration = range.to.getTime() - range.from.getTime(); + } else if ( + range.from && + !(range.from instanceof Date) && + !(range.to instanceof Date) + ) { + const to = range.to ?? { anchor: 'RELATIVE_ANCHOR_NOW', offset: [] }; + if (range.from.anchor === to.anchor) { + const fixed: Partial> = { + SECOND: 1000, + MINUTE: 60_000, + HOUR: 3_600_000, + DAY: 86_400_000, + WEEK: 7 * 86_400_000, + }; + function offset(bound: RelativeDateTime) { + let total = 0; + for (const item of bound.offset) { + const unit = fixed[item.unit]; + if (unit === undefined) return undefined; + total += item.amount * unit; + } + return total; + } + const start = offset(range.from); + const end = offset(to); + if (start !== undefined && end !== undefined) duration = end - start; + } + } + invariant( + maxRangeDays === undefined || + (!!range.from && + (duration === undefined || duration <= maxRangeDays * 86_400_000)), + `Choose a range of at most ${maxRangeDays} days.` + ); + return range; +} + +/** Canonical URL/JSON representation. Dates retain their encoded UTC fields. */ +export function serializeDateTimeRange(value: DateTimeRange): string { + return JSON.stringify(parseDateTimeRange(value)); +} + +/** UTC preview only. Backend execution owns reporting-timezone interpretation. Null start is unbounded; null end is now. */ +export function resolveDateTimeRange( + value: DateTimeRange, + now = new Date() +): { from: Date | null; to: Date } { + const range = parseDateTimeRange(value); + invariant(Number.isFinite(now.getTime()), 'Invalid reference date.'); + function resolve(bound: AbsoluteOrRelativeDateTime): Date { + if (bound instanceof Date) return new Date(bound); + let date = new CalendarDateTime( + now.getUTCFullYear(), + now.getUTCMonth() + 1, + now.getUTCDate(), + now.getUTCHours(), + now.getUTCMinutes(), + now.getUTCSeconds(), + now.getUTCMilliseconds() + ); + if (bound.anchor !== 'RELATIVE_ANCHOR_NOW') + date = date.set({ hour: 0, minute: 0, second: 0, millisecond: 0 }); + switch (bound.anchor) { + case 'RELATIVE_ANCHOR_START_OF_YESTERDAY': + date = date.subtract({ days: 1 }); + break; + case 'RELATIVE_ANCHOR_START_OF_TOMORROW': + date = date.add({ days: 1 }); + break; + case 'RELATIVE_ANCHOR_START_OF_WEEK': + date = date.subtract({ days: (now.getUTCDay() + 6) % 7 }); + break; + case 'RELATIVE_ANCHOR_START_OF_MONTH': + date = date.set({ day: 1 }); + break; + case 'RELATIVE_ANCHOR_START_OF_YEAR': + date = date.set({ month: 1, day: 1 }); + break; + } + const durations = { + SECOND: 'seconds', + MINUTE: 'minutes', + HOUR: 'hours', + DAY: 'days', + WEEK: 'weeks', + MONTH: 'months', + YEAR: 'years', + } as const; + for (const offset of bound.offset) + date = date.add({ [durations[offset.unit]]: offset.amount }); + return date.toDate('UTC'); + } + const from = range.from ? resolve(range.from) : null; + const to = range.to ? resolve(range.to) : new Date(now); + invariant(!from || from < to, 'Start must be before end.'); + return { from, to }; +} + +export function formatDateTimeBound( + value: AbsoluteOrRelativeDateTime | null | undefined, + fallback = 'Now' +): string { + if (!value) return fallback; + if (value instanceof Date) + return value + .toISOString() + .replace('T', ' ') + .replace(/:00\.000Z$/, '') + .replace(/Z$/, ''); + const offsets = value.offset + .map( + o => `${o.amount >= 0 ? '+' : '−'} ${Math.abs(o.amount)}${units[o.unit]}` + ) + .join(' '); + return `${anchors[value.anchor]}${offsets ? ` ${offsets}` : ''}`; +} +export function formatDateTimeRange(value: DateTimeRange): string { + const range = parseDateTimeRange(value); + const from = range.from; + if ( + from && + !(from instanceof Date) && + from.anchor === 'RELATIVE_ANCHOR_NOW' && + from.offset.length === 1 && + from.offset[0]!.amount < 0 && + !range.to + ) { + const offset = from.offset[0]!; + return `Last ${Math.abs(offset.amount)}${units[offset.unit]}`; + } + return `${formatDateTimeBound(from, 'Any time')} → ${formatDateTimeBound(range.to)}`; +} + +/** Parse one bound without resolving relative anchors or offsets. */ +export function parseDateTimeBound( + text: string +): AbsoluteOrRelativeDateTime | null { + const input = text.trim(); + invariant(!!input, 'Enter a date, time, or relative expression.'); + for (const [anchor, label] of Object.entries(anchors)) { + if (!input.toLowerCase().startsWith(label.toLowerCase())) continue; + const suffix = input.slice(label.length).trim(); + if (!/^(?:[+−-]\s*\d+\s*(?:mo|[smhdwy])\s*)*$/.test(suffix)) continue; + const offset = Array.from( + suffix.matchAll(/([+−-])\s*(\d+)\s*(mo|[smhdwy])/g), + match => ({ + amount: Number(match[2]) * (match[1] === '+' ? 1 : -1), + unit: Object.entries(units).find( + ([, abbreviation]) => abbreviation === match[3] + )![0] as RelativeUnit, + }) + ); + return parseDateTimeRange({ from: { anchor, offset } }).from!; + } + // datetime-local controls encode fields without performing a local-time conversion. + if ( + /^\d{4}-\d{2}-\d{2}(?:T\d{2}:\d{2}(?::\d{2}(?:\.\d{3})?)?)?Z?$/.test(input) + ) { + const encoded = input.includes('T') + ? input.replace(/Z$/, '') + : `${input}T00:00`; + const date = new Date(`${encoded}Z`); + invariant( + Number.isFinite(date.getTime()) && date.toISOString().startsWith(encoded), + 'Choose a valid date and time.' + ); + return date; + } + const parsed = parseDateTimeRangeInput(input); + if (parsed.ok && parsed.range.from) return parsed.range.from; + const since = parseDateTimeRangeInput(`since ${input}`); + invariant( + since.ok && !!since.range.from, + 'Enter a date, time, or relative expression.' + ); + return since.range.from; +} diff --git a/packages/data-app/src/core/date-time-range.ts b/packages/data-app/src/core/date-time-range.ts new file mode 100644 index 00000000..d0a6eb3c --- /dev/null +++ b/packages/data-app/src/core/date-time-range.ts @@ -0,0 +1,36 @@ +/** Frontend-compatible range values. Absolute dates encode wall-clock fields as UTC, + * not instants converted from a reporting timezone. The backend interprets that zone. + */ +export type AbsoluteOrRelativeDateTime = Date | RelativeDateTime; + +export type DateTimeRange = { + /** Inclusive start; null or omitted means unbounded. */ + from?: AbsoluteOrRelativeDateTime | null; + /** Exclusive end; null or omitted means now. */ + to?: AbsoluteOrRelativeDateTime | null; +}; + +export type RelativeDateTime = { + anchor: RelativeAnchor; + offset: RelativeOffset[]; +}; + +export type RelativeOffset = { amount: number; unit: RelativeUnit }; + +export type RelativeAnchor = + | 'RELATIVE_ANCHOR_NOW' + | 'RELATIVE_ANCHOR_START_OF_TODAY' + | 'RELATIVE_ANCHOR_START_OF_YESTERDAY' + | 'RELATIVE_ANCHOR_START_OF_TOMORROW' + | 'RELATIVE_ANCHOR_START_OF_WEEK' + | 'RELATIVE_ANCHOR_START_OF_MONTH' + | 'RELATIVE_ANCHOR_START_OF_YEAR'; + +export type RelativeUnit = + | 'SECOND' + | 'MINUTE' + | 'HOUR' + | 'DAY' + | 'WEEK' + | 'MONTH' + | 'YEAR'; diff --git a/packages/data-app/src/core/date-time-variable.ts b/packages/data-app/src/core/date-time-variable.ts new file mode 100644 index 00000000..5c635510 --- /dev/null +++ b/packages/data-app/src/core/date-time-variable.ts @@ -0,0 +1,352 @@ +import type { DateRangeContract } from '../authoring/contract.ts'; +import type { DateTimeRangeRequest } from './date-time-range-contract.ts'; +import { parseDateTimeRangeInput } from './date-time-range-parser.ts'; +import { + parseDateTimeRange, + serializeDateTimeRange, + formatDateTimeRange, +} from './date-time-range-value.ts'; +import type { DateTimeRange } from './date-time-range.ts'; +import { invariant } from './invariant.ts'; +import type { + AppVariable, + VariablePresentation, + HistoryMode, +} from './variables.ts'; + +/** A picker suggestion. Text ranges use the same natural-language parser as typed input. */ +export type DateTimeRangePreset = { + /** Menu label, also shown when this preset matches the selected range. */ + label: string; + range: DateTimeRange | string; + /** Optional compact badge, for example "15m". */ + shortcut?: string; +}; + +/** Monitoring offers minute/hour windows; analytics offers longer periods and to-date windows. Spread either menu to add custom presets. */ +export const dateTimeRangePresets: { + readonly monitoring: readonly DateTimeRangePreset[]; + readonly analytics: readonly DateTimeRangePreset[]; +} = { + monitoring: [ + { label: 'Past 5 minutes', range: '5m', shortcut: '5m' }, + { label: 'Past 15 minutes', range: '15m', shortcut: '15m' }, + { label: 'Past hour', range: '1h', shortcut: '1h' }, + { label: 'Past 4 hours', range: '4h', shortcut: '4h' }, + { label: 'Past 24 hours', range: '1d', shortcut: '1d' }, + ], + analytics: [ + { label: 'Past 7 days', range: '7d', shortcut: '7d' }, + { label: 'Past 30 days', range: '30d', shortcut: '30d' }, + { label: 'Past 90 days', range: '90d', shortcut: '90d' }, + { label: 'Month to date', range: 'MTD', shortcut: 'MTD' }, + { label: 'Year to date', range: 'YTD', shortcut: 'YTD' }, + { label: 'Past year', range: '1y', shortcut: '1y' }, + ], +}; + +export type DateTimeRangeVariableOptions = { + key: string; + label?: string; + /** Enable the previous-period reporting workflow. Requires a shared contract. */ + comparison?: false; + /** Relative defaults such as "7d" stay relative. */ + defaultValue: DateTimeRange | string; + /** Client limit for determinate durations; the backend must validate timezone-dependent ranges. */ + maxRangeDays?: number; + /** Shared reporting coverage, timezone, duration limits, and comparison policy. */ + contract?: DateRangeContract; + /** Suggestions in display order. Omit for defaults; [] retains calendar and text entry. + * Invalid or over-limit suggestions are omitted; typed input is not restricted to the menu. + */ + presets?: readonly DateTimeRangePreset[]; + history?: HistoryMode; + presentation?: VariablePresentation; +}; +export type DateTimeRangeVariable = AppVariable< + DateTimeRange, + 'dateTimeRange' +> & { + parse: (value: unknown) => DateTimeRange; + maxRangeDays?: number; + /** Shared reporting coverage, timezone, duration limits, and comparison policy. */ + contract?: DateRangeContract; + presets?: readonly DateTimeRangePreset[]; +}; + +export type DateTimeRangeComparisonSelection = { + range: DateTimeRange; + comparison?: 'previous'; +}; + +/** An execution snapshot with the relative selection retained for URL and displayed-scope matching. */ +export type DateTimeRangeComparisonRequest = DateTimeRangeRequest & { + selection: DateTimeRangeComparisonSelection; + reference: string; +}; + +export type DateTimeRangeComparisonVariableOptions = Omit< + DateTimeRangeVariableOptions, + 'comparison' | 'contract' +> & { + comparison: true; + contract: DateRangeContract; +}; + +export type DateTimeRangeComparisonVariable = AppVariable< + DateTimeRangeComparisonSelection, + 'dateTimeRangeComparison' +> & { + contract: DateRangeContract; + maxRangeDays?: number; + presets?: readonly DateTimeRangePreset[]; + input: ( + selection: DateTimeRangeComparisonSelection, + now?: Date + ) => DateTimeRangeComparisonRequest; + parseInput: (value: unknown) => DateTimeRangeComparisonRequest; + previous: ( + selection: DateTimeRangeComparisonSelection + ) => DateTimeRangeRequest['comparison']; +}; + +/** One declaration drives the picker, URL state, and operation input. + * With comparison enabled, use input(defaultValue) for operation checks and variableInput() + * to validate request snapshots. Query the resulting range and optional comparison bounds. + */ +export function dateTimeRangeVariable( + options: DateTimeRangeComparisonVariableOptions +): DateTimeRangeComparisonVariable; +export function dateTimeRangeVariable( + options: DateTimeRangeVariableOptions +): DateTimeRangeVariable; +export function dateTimeRangeVariable( + options: DateTimeRangeVariableOptions | DateTimeRangeComparisonVariableOptions +): DateTimeRangeVariable | DateTimeRangeComparisonVariable { + const base = createDateTimeRangeVariable(options); + return options.comparison ? withComparison(base, options) : base; +} + +/** One declaration drives the generated picker, URL state, and structured query parameter. */ +function createDateTimeRangeVariable({ + key, + label = 'Date range', + defaultValue, + maxRangeDays, + contract, + presets, + history = 'push', + presentation, +}: + | DateTimeRangeVariableOptions + | DateTimeRangeComparisonVariableOptions): DateTimeRangeVariable { + invariant( + maxRangeDays === undefined || + (Number.isFinite(maxRangeDays) && maxRangeDays > 0), + 'Maximum range days must be positive.' + ); + function parse(value: unknown) { + const range = parseDateTimeRange(value, { maxRangeDays }); + return contract ? contract.datetime.parse(range) : range; + } + const parsed = + typeof defaultValue === 'string' + ? parseDateTimeRangeInput(defaultValue) + : null; + invariant(!parsed || parsed.ok, 'Invalid default datetime range.'); + const initial = parse(parsed?.ok ? parsed.range : defaultValue); + function valid(value: DateTimeRange) { + try { + parse(value); + return true; + } catch { + return false; + } + } + function same(left: DateTimeRange, right: DateTimeRange) { + return serializeDateTimeRange(left) === serializeDateTimeRange(right); + } + return { + kind: 'dateTimeRange', + label, + presentation, + urlKeys: [key], + defaultValue: initial, + clearValue: initial, + maxRangeDays: contract + ? Math.min(maxRangeDays ?? Infinity, contract.bounds().maxRangeDays) + : maxRangeDays, + contract, + presets, + history, + parse, + valid, + same, + describe: formatDateTimeRange, + read(params) { + const text = params.get(key); + if (!text || text.length > 8192) return parse(initial); + try { + return parse(JSON.parse(text)); + } catch { + return parse(initial); + } + }, + write(value) { + return { + [key]: same(value, initial) + ? null + : serializeDateTimeRange(parse(value)), + }; + }, + }; +} + +function withComparison( + base: DateTimeRangeVariable, + options: DateTimeRangeComparisonVariableOptions +): DateTimeRangeComparisonVariable { + const contract = options.contract; + const compareKey = + options.key === 'period' ? 'compare' : `${options.key}-compare`; + const initial = { range: base.defaultValue }; + function parseSelection( + value: unknown, + now = new Date() + ): DateTimeRangeComparisonSelection { + invariant( + !!value && typeof value === 'object' && !Array.isArray(value), + 'Choose a datetime range.' + ); + const selection = value as Record; + invariant( + Object.keys(selection).every( + key => key === 'range' || key === 'comparison' + ) && + (selection.comparison === undefined || + selection.comparison === 'previous'), + 'Invalid comparison selection.' + ); + const range = contract.datetime.parse( + parseDateTimeRange(selection.range, { maxRangeDays: base.maxRangeDays }), + now + ); + invariant( + !selection.comparison || contract.datetime.comparison(range, now), + 'Comparison is outside available source coverage.' + ); + return { + range, + ...(selection.comparison ? { comparison: 'previous' as const } : {}), + }; + } + function input( + value: DateTimeRangeComparisonSelection, + now = new Date() + ): DateTimeRangeComparisonRequest { + const selection = parseSelection(value, now); + return { + ...contract.datetime.request( + selection.range, + selection.comparison === 'previous', + now + ), + selection, + reference: now.toISOString(), + }; + } + function same( + left: DateTimeRangeComparisonSelection, + right: DateTimeRangeComparisonSelection + ) { + return ( + left.comparison === right.comparison && base.same(left.range, right.range) + ); + } + function valid(value: DateTimeRangeComparisonSelection) { + try { + parseSelection(value); + return true; + } catch { + return false; + } + } + return { + kind: 'dateTimeRangeComparison', + label: base.label, + presentation: base.presentation, + history: base.history, + urlKeys: [...base.urlKeys, compareKey], + defaultValue: initial, + clearValue: initial, + contract, + maxRangeDays: base.maxRangeDays, + presets: base.presets, + input, + parseInput(value) { + invariant( + !!value && typeof value === 'object' && !Array.isArray(value), + 'Invalid datetime request.' + ); + const request = value as Record; + invariant( + Object.keys(request).every(key => + ['range', 'comparison', 'selection', 'reference'].includes(key) + ) && typeof request.reference === 'string', + 'Invalid datetime request.' + ); + const now = new Date(request.reference); + invariant( + Number.isFinite(now.getTime()) && + now.toISOString() === request.reference, + 'Invalid reference date.' + ); + const expected = input( + request.selection as DateTimeRangeComparisonSelection, + now + ); + // Validate current source coverage too; a supplied historical reference cannot bypass policy. + base.parse(request.range); + const actual = parseDateTimeRange(request.range); + invariant( + base.same(actual, expected.range), + 'The request must preserve the selected datetime range.' + ); + invariant( + request.comparison === null + ? expected.comparison === null + : !!expected.comparison && + base.same( + parseDateTimeRange(request.comparison), + expected.comparison + ), + 'Comparison must be the preceding equal-length range.' + ); + return expected; + }, + previous(selection) { + return contract.datetime.comparison(selection.range); + }, + valid, + same, + describe: selection => + `${base.describe!(selection.range)}${selection.comparison ? ' vs previous period' : ''}`, + read(params) { + const range = base.read(params); + const compared: DateTimeRangeComparisonSelection = { + range, + comparison: 'previous', + }; + return params.get(compareKey) === 'previous' && valid(compared) + ? compared + : { range }; + }, + write(value) { + const selection = parseSelection(value); + return { + ...base.write(selection.range), + [compareKey]: selection.comparison ?? null, + }; + }, + }; +} diff --git a/packages/data-app/src/core/dimension-source.ts b/packages/data-app/src/core/dimension-source.ts index cdc0c428..9609434e 100644 --- a/packages/data-app/src/core/dimension-source.ts +++ b/packages/data-app/src/core/dimension-source.ts @@ -1,3 +1,7 @@ +import type { + DateTimeRangeComparisonRequest, + DateTimeRangeComparisonVariable, +} from './date-time-variable.ts'; import { createDimensionFilter, parseFacetOptions, @@ -48,6 +52,14 @@ export class DimensionMembershipError extends Error { } } +type ParsedVariableValues = { + [ + Key in keyof Variables + ]: Variables[Key] extends DateTimeRangeComparisonVariable + ? DateTimeRangeComparisonRequest + : AppVariableValues[Key]; +}; + /** Validate named variable values at the operation boundary. Date inputs belong to the base parser. */ export function variableInput< Input extends object, @@ -59,7 +71,7 @@ export function variableInput< ? never : Variables[Key]; } -): (value: unknown) => Input & AppVariableValues { +): (value: unknown) => Input & ParsedVariableValues { const previous = parserVariables.get(parse) ?? {}; const definitions = { ...variables }; for (const [name, variable] of Object.entries(definitions)) @@ -80,13 +92,18 @@ export function variableInput< const values: Record = {}; for (const [name, variable] of Object.entries(definitions)) { const selected = (value as Record)[name]; + if (variable.kind === 'dateTimeRangeComparison') { + values[name] = variable.parseInput(selected); + continue; + } invariant( variable.valid(selected as never), `Invalid ${variable.label ?? name}.` ); - values[name] = selected; + values[name] = + variable.kind === 'dateTimeRange' ? variable.parse(selected) : selected; } - return { ...input, ...values } as Input & AppVariableValues; + return { ...input, ...values } as Input & ParsedVariableValues; } parserVariables.set(parser, { ...previous, diff --git a/packages/data-app/src/core/format.ts b/packages/data-app/src/core/format.ts index 84af9679..2f7ffa69 100644 --- a/packages/data-app/src/core/format.ts +++ b/packages/data-app/src/core/format.ts @@ -1,3 +1,20 @@ +export { + parseDateTimeRangeInput, + canParseDateTimeRangeInput, +} from './date-time-range-parser.ts'; +export type { + DateTimeRangeParserOptions, + DateTimeRangeParseFailureReason, + ParseDateTimeRangeResult, +} from './date-time-range-parser.ts'; +export type { + DateTimeRange, + AbsoluteOrRelativeDateTime, + RelativeDateTime, + RelativeAnchor, + RelativeOffset, + RelativeUnit, +} from './date-time-range.ts'; import { parseDateTime, type DateFormatOptions } from './date-format.ts'; export { formatDate, @@ -143,3 +160,5 @@ export function formatPercent( return formatNumber(ratio, percentOptions); } + +export { formatDateTimeRange } from './date-time-range-value.ts'; diff --git a/packages/data-app/src/core/queries.ts b/packages/data-app/src/core/queries.ts index 1a613c26..13c34cf4 100644 --- a/packages/data-app/src/core/queries.ts +++ b/packages/data-app/src/core/queries.ts @@ -1,3 +1,9 @@ +import { expandDateTimeRangeParameters } from './date-time-range-sql.ts'; +import { + parseDateTimeRange, + serializeDateTimeRange, +} from './date-time-range-value.ts'; +import type { DateTimeRange } from './date-time-range.ts'; import { invariant } from './invariant.ts'; import type { QueryResult } from './operation-types.ts'; @@ -23,17 +29,36 @@ export type QueryRow = { }; export type QueryParameterValue = string | number | boolean | null; -/** Typed scalar parameters with optional fallback values. */ -export type QueryParameter = { - [Type in QueryValueType]: - | { type: Type; nullable?: false; defaultValue?: QueryValues[Type] } - | { type: Type; nullable: true; defaultValue?: QueryValues[Type] | null }; -}[QueryValueType]; +/** Typed scalar or datetime-range parameters with optional fallback values. */ +export type QueryParameter = + | { + type: 'dateTimeRange'; + nullable?: false; + /** Absolute defaults may use canonical UTC ISO strings (including milliseconds) for static manifests. Snapshots hydrate them into Dates. */ + defaultValue?: { + [Bound in keyof DateTimeRange]: DateTimeRange[Bound] | string; + }; + } + | { + [Type in QueryValueType]: + | { type: Type; nullable?: false; defaultValue?: QueryValues[Type] } + | { + type: Type; + nullable: true; + defaultValue?: QueryValues[Type] | null; + }; + }[QueryValueType]; /** Callers may omit values supplied by host context or declaration defaults. */ export type QueryInputParameters< Params extends Readonly>, -> = { [Name in keyof Params]?: QueryValue }; +> = { + [Name in keyof Params]?: Params[Name] extends { type: 'dateTimeRange' } + ? DateTimeRange + : Params[Name] extends QueryField + ? QueryValue + : never; +}; type QueryDefinition = { statement: string; params: Readonly>; @@ -54,7 +79,7 @@ export type KnownRecord = '__proto__' extends keyof RecordType ? never : RecordType : never; -type CheckedField = +type CheckedField = true extends IsUnion ? never : true extends IsUnion @@ -98,9 +123,16 @@ export type CheckedQueries = projections: CheckedProjections; }; }; -type FrozenField = Field extends unknown - ? Readonly - : never; +type FrozenField = + Field extends { type: 'dateTimeRange' } + ? Readonly<{ + [Key in keyof Field]: Key extends 'defaultValue' + ? DateTimeRange + : Field[Key]; + }> + : Field extends unknown + ? Readonly + : never; export type QuerySnapshot = { readonly [Name in keyof Queries]: { readonly statement: Queries[Name]['statement']; @@ -169,12 +201,34 @@ export function snapshotQueries( const params = Object.freeze( Object.fromEntries( Object.entries(query.params).map(([key, declaration]) => { + if (declaration.type === 'dateTimeRange') + invariant( + /^[A-Za-z_][A-Za-z0-9_]*$/.test(key) && + (declaration as { nullable?: boolean }).nullable !== true, + 'Datetime ranges require a SQL parameter name and cannot be nullable.' + ); invariant( !Object.hasOwn(declaration, 'defaultValue') || - isQueryParameterValue(declaration.defaultValue), + (declaration.type === 'dateTimeRange' + ? !!parseDateTimeRange(declaration.defaultValue) + : isQueryParameterValue(declaration.defaultValue)), `Invalid default for parameter ${key} in query ${name}.` ); - return [key, snapshotField(declaration)]; + return [ + key, + declaration.type === 'dateTimeRange' + ? Object.freeze({ + ...declaration, + ...(declaration.defaultValue === undefined + ? {} + : { + defaultValue: parseDateTimeRange( + declaration.defaultValue + ), + }), + }) + : snapshotField(declaration), + ]; }) ) ); @@ -187,7 +241,14 @@ export function snapshotQueries( return [ name, Object.freeze({ - statement: query.statement, + statement: expandDateTimeRangeParameters( + query.statement, + new Set( + Object.entries(query.params) + .filter(([, p]) => p.type === 'dateTimeRange') + .map(([key]) => key) + ) + ), params, projections: Object.freeze( Object.fromEntries( @@ -287,7 +348,9 @@ function castQueryCell( /** Host context supplies protected values; callers and defaults fill the remaining parameters. */ export function resolveQueryParameters( declarations: Readonly>, - values: Partial, + values: Readonly< + Record + >, context: QueryParameters ): QueryParameters { if (!values || typeof values !== 'object' || Array.isArray(values)) @@ -306,6 +369,15 @@ export function resolveQueryParameters( ? values[key] : declaration.defaultValue; if (value === undefined) throw new Error(`Missing parameter ${key}.`); + if (declaration.type === 'dateTimeRange') + return [ + key, + serializeDateTimeRange( + parseDateTimeRange( + typeof value === 'string' ? JSON.parse(value) : value + ) + ), + ]; const nullable = declaration.type === 'null' || declaration.nullable === true; if ( @@ -314,7 +386,7 @@ export function resolveQueryParameters( : typeof value === declaration.type && isQueryParameterValue(value)) ) throw new Error(`Parameter ${key} does not match ${declaration.type}.`); - return [key, value]; + return [key, value as QueryParameterValue]; }) ); } diff --git a/packages/data-app/src/core/reading.ts b/packages/data-app/src/core/reading.ts index ae639ebf..646e812e 100644 --- a/packages/data-app/src/core/reading.ts +++ b/packages/data-app/src/core/reading.ts @@ -1,4 +1,7 @@ -import { type DateRangeRequest } from './contract.ts'; +import { + type DateTimeRangeRequest, + type DateRangeRequest, +} from './contract.ts'; export type DataReading = | { loading: true; value?: never } @@ -8,5 +11,5 @@ export type DataReading = * Comparison activation belongs to the reading's period, not these values. */ export type MetricValues = { current: number; previous?: number | null }; export type MetricReading = DataReading< - MetricValues & { period?: DateRangeRequest } + MetricValues & { period?: DateRangeRequest | DateTimeRangeRequest } >; diff --git a/packages/data-app/src/core/variables.ts b/packages/data-app/src/core/variables.ts index 2540b36c..3ab4a2c5 100644 --- a/packages/data-app/src/core/variables.ts +++ b/packages/data-app/src/core/variables.ts @@ -4,6 +4,10 @@ import { type DatePresetId, type DateRange, } from './date-range.ts'; +import type { + DateTimeRangeVariable, + DateTimeRangeComparisonVariable, +} from './date-time-variable.ts'; import type { SourceDimensionVariable } from './dimension-source.ts'; import type { DimensionSelection, @@ -50,6 +54,8 @@ export type VariableCollection = Record< | ChoiceVariable | MultiChoiceVariable | DateRangeVariable + | DateTimeRangeVariable + | DateTimeRangeComparisonVariable | DimensionVariable | FacetVariable | SourceDimensionVariable diff --git a/packages/data-app/src/index.ts b/packages/data-app/src/index.ts index a08c9a74..522de710 100644 --- a/packages/data-app/src/index.ts +++ b/packages/data-app/src/index.ts @@ -11,3 +11,12 @@ export type { QueryRow, } from './authoring/index.ts'; export { dataAppTitle } from './core/config.ts'; + +export type { + DateTimeRange, + AbsoluteOrRelativeDateTime, + RelativeDateTime, + RelativeAnchor, + RelativeOffset, + RelativeUnit, +} from './authoring/index.ts'; diff --git a/packages/data-app/src/internal.ts b/packages/data-app/src/internal.ts index 3c0b89d2..96b14924 100644 --- a/packages/data-app/src/internal.ts +++ b/packages/data-app/src/internal.ts @@ -88,3 +88,28 @@ export { type SourceDimensionVariable, } from './core/dimension-source.ts'; export { parseAppDiagnosticReport } from './core/diagnostics.ts'; + +export { + dateTimeRangeVariable, + dateTimeRangePresets, + type DateTimeRangePreset, + type DateTimeRangeVariable, + type DateTimeRangeComparisonVariable, + type DateTimeRangeComparisonVariableOptions, + type DateTimeRangeComparisonSelection, + type DateTimeRangeComparisonRequest, + type DateTimeRangeVariableOptions, +} from './core/date-time-variable.ts'; +export { + parseDateTimeRange, + serializeDateTimeRange, + resolveDateTimeRange, + formatDateTimeRange, + formatDateTimeBound, + parseDateTimeBound, +} from './core/date-time-range-value.ts'; +export type { + DateTimeRange, + AbsoluteOrRelativeDateTime, + RelativeDateTime, +} from './core/date-time-range.ts'; diff --git a/packages/data-app/src/server/handler.ts b/packages/data-app/src/server/handler.ts index e46cf7ab..257b1e8a 100644 --- a/packages/data-app/src/server/handler.ts +++ b/packages/data-app/src/server/handler.ts @@ -14,7 +14,7 @@ import { readRequestBody, RequestBodyTooLargeError } from './request-body.ts'; /** Authorization result for one viewer request. A hosted adapter must scope its lakehouse client. */ export type RequestAccess = { lakehouse: Lakehouse; - /** Trusted values shared across queries; each query uses only its declared keys. */ + /** Trusted values shared across queries; each query uses only its declared keys. Datetime ranges use serializeDateTimeRange(); they are decoded and validated before SQL binding. */ queryParams?: QueryParameters; }; diff --git a/tests/public-api/apps-datetime-comparison.test.ts b/tests/public-api/apps-datetime-comparison.test.ts new file mode 100644 index 00000000..fc817ff3 --- /dev/null +++ b/tests/public-api/apps-datetime-comparison.test.ts @@ -0,0 +1,116 @@ +import { expect } from 'vitest'; + +import { test } from '@/tests/public-api/browser'; + +test('datetime comparisons preserve selection through queries, reload, refresh, and clear', async ({ + page, +}) => { + await page.clock.setFixedTime(new Date('2026-07-10T16:00:00Z')); + await page.goto('/datetime-comparison'); + const picker = page.getByRole('combobox', { + name: 'Date range', + exact: true, + }); + const output = page.getByRole('status', { name: 'Applied comparison' }); + await expect.poll(() => output.textContent()).toContain('"comparison":null'); + await picker.click(); + await picker.press('End'); + const compare = page.getByRole('checkbox', { + name: 'Compare with previous period', + }); + expect( + await compare.evaluate(element => element === document.activeElement) + ).toBe(true); + expect(await picker.getAttribute('aria-activedescendant')).toBe(null); + await compare.press('Enter'); + await picker.press('Escape'); + await expect + .poll(() => output.textContent()) + .toContain('2026-06-26T16:00:00.000Z'); + expect(new URL(page.url()).searchParams.get('compare')).toBe('previous'); + expect( + await page.getByText('100.0% vs', { exact: false }).count() + ).toBeGreaterThan(0); + await page.reload(); + await expect + .poll(() => output.textContent()) + .toContain('2026-06-26T16:00:00.000Z'); + await page.clock.setFixedTime(new Date('2026-07-10T17:00:00Z')); + await page.getByRole('button', { name: 'Refresh data', exact: true }).click(); + await expect + .poll(() => output.textContent()) + .toContain('2026-06-26T17:00:00.000Z'); + await expect + .poll(() => output.textContent()) + .toContain('2026-07-10T17:00:00.000Z'); + await page.getByRole('button', { name: 'Clear', exact: true }).click(); + await expect.poll(() => output.textContent()).toContain('"comparison":null'); + expect(new URL(page.url()).searchParams.has('compare')).toBe(false); +}); + +test('coverage disables comparison and changing range repairs an unavailable previous period', async ({ + page, +}) => { + await page.clock.setFixedTime(new Date('2026-07-10T16:00:00Z')); + await page.goto('/datetime-comparison'); + const picker = page.getByRole('combobox', { + name: 'Date range', + exact: true, + }); + await picker.click(); + await page + .getByRole('checkbox', { name: 'Compare with previous period' }) + .click(); + await picker.click(); + await picker.fill('June 1 to June 3 2026'); + await picker.press('Enter'); + await expect + .poll(() => + page.getByRole('status', { name: 'Applied comparison' }).textContent() + ) + .toContain('"comparison":null'); + await picker.click(); + expect( + await page + .getByRole('checkbox', { name: 'Compare with previous period' }) + .isEnabled() + ).toBe(false); + await picker.press('Escape'); + await page.goBack(); + await expect + .poll(() => + page.getByRole('status', { name: 'Applied comparison' }).textContent() + ) + .toContain('"comparison":"previous"'); +}); + +test('calendar editing hides comparison until returning to presets', async ({ + page, +}) => { + await page.goto('/datetime-comparison'); + await page.getByRole('combobox', { name: 'Date range', exact: true }).click(); + await page.getByRole('option', { name: 'Select from calendar…' }).click(); + expect( + await page + .getByRole('checkbox', { name: 'Compare with previous period' }) + .count() + ).toBe(0); + await page.getByRole('button', { name: '‹ Presets', exact: true }).click(); + expect( + await page + .getByRole('checkbox', { name: 'Compare with previous period' }) + .isVisible() + ).toBe(true); +}); + +test('plain datetime views inherit the reporting contract timezone', async ({ + page, +}) => { + await page.clock.setFixedTime(new Date('2026-07-10T16:00:00Z')); + await page.goto('/datetime?policy&reportingZone=America%2FNew_York'); + await expect + .poll(() => + page.getByRole('status', { name: 'Reporting timezone' }).textContent() + ) + .toBe('America/New_York'); +}); diff --git a/tests/public-api/apps-datetime.test.ts b/tests/public-api/apps-datetime.test.ts new file mode 100644 index 00000000..0e7f8dce --- /dev/null +++ b/tests/public-api/apps-datetime.test.ts @@ -0,0 +1,415 @@ +import { expect } from 'vitest'; + +import { test } from '@/tests/public-api/browser'; + +test('datetime input preserves relative selections through queries, URLs, history and dismissal', async ({ + page, +}) => { + await page.goto('/datetime'); + const input = page.getByRole('combobox', { name: 'Date range', exact: true }); + const output = page.getByRole('status', { name: 'Applied period' }); + await expect.poll(() => input.inputValue()).toBe('Past 7 days'); + await input.click(); + expect(await input.inputValue()).toMatch(/\d{4}/); + await input.fill('2w'); + await expect.poll(() => output.textContent()).toContain('"amount":-7'); + await input.press('Enter'); + await expect.poll(() => output.textContent()).toContain('"unit":"WEEK"'); + expect(new URL(page.url()).searchParams.get('period')).toContain( + 'RELATIVE_ANCHOR_NOW' + ); + await page.reload(); + await expect.poll(() => output.textContent()).toContain('"amount":-2'); + await input.click(); + await input.fill('not a date'); + await input.press('Escape'); + await expect.poll(() => output.textContent()).toContain('"unit":"WEEK"'); + await page.goBack(); + await expect.poll(() => output.textContent()).toContain('"amount":-7'); +}); + +test('preset keyboard navigation retains input focus, and syntax errors recover through examples', async ({ + page, +}) => { + await page.goto('/datetime'); + const input = page.getByRole('combobox', { name: 'Date range', exact: true }); + await input.focus(); + await input.press('ArrowDown'); + await input.press('ArrowDown'); + await input.press('ArrowDown'); + expect( + await input.evaluate(element => element === document.activeElement) + ).toBe(true); + await input.press('Enter'); + await expect.poll(() => input.inputValue()).toBe('Past 4 hours'); + await input.click(); + await input.fill('not a date'); + await input.press('Enter'); + expect(await input.inputValue()).toBe('not a date'); + expect(await input.getAttribute('aria-invalid')).toBe('true'); + await page + .getByRole('complementary', { name: 'Date range syntax help' }) + .getByRole('button', { name: '45m', exact: true }) + .click(); + await expect + .poll(() => + page.getByRole('status', { name: 'Applied period' }).textContent() + ) + .toContain('"amount":-45'); + await input.click(); + await input.press('End'); + expect( + await page + .getByRole('complementary', { name: 'Date range syntax help' }) + .isVisible() + ).toBe(true); + await input.press('Escape'); +}); + +test('typed mixed and relative bounds keep UTC wall-clock fields and symbolic anchors', async ({ + page, +}) => { + await page.goto('/datetime'); + const input = page.getByRole('combobox', { name: 'Date range', exact: true }); + const output = page.getByRole('status', { name: 'Applied period' }); + await input.click(); + await input.fill('since July 1 2026 4pm'); + await input.press('Enter'); + await expect + .poll(() => output.textContent()) + .toContain('2026-07-01T16:00:00.000Z'); + expect(await output.textContent()).toContain('"to":null'); + await input.click(); + await input.fill('Start of month − 1mo to Start of today'); + await input.press('Enter'); + await expect + .poll(() => output.textContent()) + .toContain('RELATIVE_ANCHOR_START_OF_TODAY'); + expect(await output.textContent()).toContain( + 'RELATIVE_ANCHOR_START_OF_MONTH' + ); + expect(await output.textContent()).toContain('"amount":-1,"unit":"MONTH"'); +}); + +test('calendar starts from applied dates, requires a complete selection, and Escape returns to presets', async ({ + page, +}) => { + await page.goto('/datetime'); + const input = page.getByRole('combobox', { name: 'Date range', exact: true }); + const output = page.getByRole('status', { name: 'Applied period' }); + await input.click(); + await input.fill('Jul 1 - Jul 2 2026'); + await input.press('Enter'); + await input.click(); + await input.fill('Jan 2025'); + await page.getByRole('option', { name: 'Select from calendar…' }).click(); + const calendar = page.getByRole('dialog', { name: 'Date range calendar' }); + await expect + .poll(() => + calendar + .getByRole('grid') + .evaluate(element => element.contains(document.activeElement)) + ) + .toBe(true); + await calendar.getByRole('button', { name: /July 5, 2026/ }).click(); + expect( + await calendar + .getByRole('button', { name: 'Apply', exact: true }) + .isDisabled() + ).toBe(true); + await calendar.getByRole('button', { name: /July 7, 2026/ }).click(); + await calendar.getByRole('button', { name: 'Apply', exact: true }).click(); + await expect + .poll(() => output.textContent()) + .toContain('2026-07-05T00:00:00.000Z'); + expect(await output.textContent()).toContain('2026-07-08T00:00:00.000Z'); + await input.click(); + await page.getByRole('option', { name: 'Select from calendar…' }).click(); + await page.keyboard.press('Escape'); + expect( + await page.getByRole('listbox', { name: 'Date range presets' }).isVisible() + ).toBe(true); + expect( + await input.evaluate(element => element === document.activeElement) + ).toBe(true); + await input.press('Escape'); + expect(await input.getAttribute('aria-expanded')).toBe('false'); +}); + +test('formatted date segments support arrow selection and rapid edits before Enter', async ({ + page, +}) => { + await page.goto('/datetime'); + const input = page.getByRole('combobox', { name: 'Date range', exact: true }); + await input.click(); + await input.fill('Jul 1 2026 4pm to Jul 2 2026 4pm'); + await input.press('Enter'); + await input.click(); + await input.press('ArrowLeft'); + await input.press('ArrowRight'); + await input.press('ArrowRight'); + await input.press('ArrowRight'); + await input.press('ArrowRight'); + await input.press('ArrowUp'); + await input.press('ArrowUp'); + await input.press('Enter'); + await expect + .poll(() => + page.getByRole('status', { name: 'Applied period' }).textContent() + ) + .toContain('2026-07-01T18:00:00.000Z'); +}); + +test('live mode, limits, empty values, and outside focus remain controlled', async ({ + page, +}) => { + await page.goto('/datetime?controls'); + const input = page.getByRole('combobox', { name: 'Custom period' }); + const output = page.getByRole('status', { name: 'Custom selection' }); + await input.click(); + expect( + await page.getByRole('option', { name: 'Past 7 days', exact: true }).count() + ).toBe(0); + await page.getByRole('option', { name: 'Live', exact: true }).click(); + await expect.poll(() => output.textContent()).toContain('"live":true'); + await input.click(); + await page.getByRole('option', { name: 'Past 1 day', exact: true }).click(); + await expect.poll(() => output.textContent()).toContain('"live":false'); + await input.click(); + await input.fill('2w'); + await input.press('Enter'); + expect(await page.getByRole('alert').textContent()).toContain( + 'at most 3 days' + ); + await input.press('Escape'); + await input.click(); + const clear = page.getByRole('button', { + name: 'Clear selection', + exact: true, + }); + await clear.click(); + expect( + await clear.evaluate(element => element === document.activeElement) + ).toBe(true); + await expect.poll(() => output.textContent()).toContain('"value":null'); + expect(await input.getAttribute('aria-expanded')).toBe('false'); +}); + +for (const theme of ['light', 'dark'] as const) { + test(`datetime menus fit narrow ${theme} screens and recover focus`, async ({ + page, + }) => { + await page.setViewportSize({ width: 320, height: 760 }); + await page.goto('/datetime'); + if (theme === 'dark') + await page + .getByRole('button', { name: 'Switch to dark theme', exact: true }) + .click(); + const input = page.getByRole('combobox', { + name: 'Date range', + exact: true, + }); + await input.click(); + await page.getByRole('option', { name: 'More', exact: true }).click(); + const help = page.getByRole('complementary', { + name: 'Date range syntax help', + }); + await help.scrollIntoViewIfNeeded(); + const helpBox = await help.boundingBox(); + expect(helpBox!.x).toBeGreaterThanOrEqual(0); + expect(helpBox!.x + helpBox!.width).toBeLessThanOrEqual(320); + await page.getByRole('option', { name: 'Select from calendar…' }).click(); + const calendar = page.getByRole('dialog', { name: 'Date range calendar' }); + const box = await calendar.boundingBox(); + expect(box!.x).toBeGreaterThanOrEqual(0); + expect(box!.x + box!.width).toBeLessThanOrEqual(320); + await page.keyboard.press('Escape'); + await expect + .poll(() => input.evaluate(element => element === document.activeElement)) + .toBe(true); + await input.press('Escape'); + }); +} + +test('applying the initial calendar selection commits absolute days and Tab dismisses text drafts', async ({ + page, +}) => { + await page.goto('/datetime?controls'); + const input = page.getByRole('combobox', { name: 'Custom period' }); + const output = page.getByRole('status', { name: 'Custom selection' }); + await input.click(); + await page.getByRole('option', { name: 'Past 1 day', exact: true }).click(); + await input.click(); + await page.getByRole('option', { name: 'Select from calendar…' }).click(); + await page.getByRole('button', { name: 'Apply', exact: true }).click(); + await expect + .poll(() => output.textContent()) + .not.toContain('RELATIVE_ANCHOR'); + expect(await output.textContent()).toContain('T00:00:00.000Z'); + const applied = await output.textContent(); + await input.click(); + await input.fill('2h'); + await input.press('Tab'); + expect(await input.getAttribute('aria-expanded')).toBe('false'); + expect(await output.textContent()).toBe(applied); + expect( + await page + .getByRole('button', { name: 'Clear selection', exact: true }) + .evaluate(element => element === document.activeElement) + ).toBe(true); +}); + +test('rebased mixed ranges can be applied, reopened, and restored before their UTC preview start', async ({ + page, +}) => { + await page.clock.setFixedTime(new Date('2026-07-02T14:30:00.000Z')); + await page.goto('/datetime'); + const input = page.getByRole('combobox', { name: 'Date range', exact: true }); + const output = page.getByRole('status', { name: 'Applied period' }); + await input.click(); + await input.fill('since July 2 2026 4pm'); + await input.press('Enter'); + await expect + .poll(() => output.textContent()) + .toContain('2026-07-02T16:00:00.000Z'); + await input.click(); + expect(await input.getAttribute('aria-expanded')).toBe('true'); + await page.getByRole('option', { name: 'Select from calendar…' }).click(); + expect( + await page.getByRole('dialog', { name: 'Date range calendar' }).isVisible() + ).toBe(true); + await page.keyboard.press('Escape'); + await input.press('Escape'); + await page.reload(); + await expect + .poll(() => output.textContent()) + .toContain('2026-07-02T16:00:00.000Z'); +}); + +test('configured presets flow through generated filters, labels, query values and reloads', async ({ + page, +}) => { + await page.goto('/datetime?presets=analytics'); + const input = page.getByRole('combobox', { name: 'Date range', exact: true }); + await input.click(); + expect( + await page.getByRole('option', { name: 'Past 15 minutes' }).count() + ).toBe(0); + await page.getByRole('option', { name: 'Year to date' }).click(); + await expect.poll(() => input.inputValue()).toBe('Year to date'); + await expect + .poll(() => + page.getByRole('status', { name: 'Applied period' }).textContent() + ) + .toContain('RELATIVE_ANCHOR_START_OF_YEAR'); + await input.click(); + await page.getByRole('option', { name: 'Launch window' }).click(); + await expect.poll(() => input.inputValue()).toBe('Launch window'); + await expect + .poll(() => + page.getByRole('status', { name: 'Applied period' }).textContent() + ) + .toContain('2026-01-03T00:00:00.000Z'); + await page.reload(); + await expect.poll(() => input.inputValue()).toBe('Launch window'); +}); + +test('standalone menus support monitoring and empty presets without losing text or calendar entry', async ({ + page, +}) => { + await page.goto('/datetime?controls&presets=monitoring'); + const input = page.getByRole('combobox', { + name: 'Custom period', + exact: true, + }); + await input.click(); + await page.getByRole('option', { name: 'Past 5 minutes' }).click(); + await expect.poll(() => input.inputValue()).toBe('Past 5 minutes'); + await expect + .poll(() => + page.getByRole('status', { name: 'Custom selection' }).textContent() + ) + .toContain('"amount":-5'); + await page.goto('/datetime?presets=empty'); + const generated = page.getByRole('combobox', { + name: 'Date range', + exact: true, + }); + await generated.click(); + expect(await page.getByRole('option').count()).toBe(2); + expect( + await page + .getByRole('option', { name: 'Select from calendar…' }) + .isVisible() + ).toBe(true); + await generated.fill('2w'); + await generated.press('Enter'); + await expect.poll(() => generated.inputValue()).toBe('Past 2 weeks'); +}); + +test('modified arrows preserve native text selection without editing date segments', async ({ + page, +}) => { + await page.goto('/datetime'); + const input = page.getByRole('combobox', { name: 'Date range', exact: true }); + await input.click(); + await input.fill('Jul 1 2026 4pm to Jul 2 2026 4pm'); + await input.press('Enter'); + await input.click(); + const draft = await input.inputValue(); + await input.evaluate((element: HTMLInputElement) => + element.setSelectionRange(0, 0) + ); + await input.press('Shift+ArrowRight'); + expect( + await input.evaluate((element: HTMLInputElement) => [ + element.selectionStart, + element.selectionEnd, + ]) + ).toEqual([0, 1]); + for (const modifier of ['Shift', 'Control', 'Alt', 'Meta']) { + await input.evaluate((element: HTMLInputElement) => + element.setSelectionRange(0, 3) + ); + await input.press(`${modifier}+ArrowUp`); + expect(await input.inputValue()).toBe(draft); + } +}); + +test('shared reporting policy governs generated datetime presets, calendar, text and URLs', async ({ + page, +}) => { + await page.clock.setFixedTime(new Date('2026-07-02T12:00:00Z')); + await page.goto('/datetime?policy'); + const input = page.getByRole('combobox', { name: 'Date range', exact: true }); + await input.click(); + expect( + await page.getByRole('option', { name: 'Today', exact: true }).count() + ).toBe(0); + expect( + await page.getByRole('option', { name: 'Yesterday', exact: true }).count() + ).toBe(1); + await input.fill('today'); + await input.press('Enter'); + expect(await input.getAttribute('aria-invalid')).toBe('true'); + await input.press('Escape'); + await input.click(); + await page.getByRole('option', { name: 'Yesterday', exact: true }).click(); + await expect + .poll(() => + page.getByRole('status', { name: 'Applied period' }).textContent() + ) + .toContain('RELATIVE_ANCHOR_START_OF_YESTERDAY'); + await input.click(); + await page.getByRole('option', { name: 'Select from calendar…' }).click(); + expect( + await page.getByRole('dialog', { name: 'Date range calendar' }).isVisible() + ).toBe(true); + expect( + await page.getByRole('button', { name: /July 2, 2026/ }).isEnabled() + ).toBe(false); + await page.keyboard.press('Escape'); + await page.keyboard.press('Escape'); + await page.reload(); + await expect.poll(() => input.inputValue()).toBe('Yesterday'); +}); diff --git a/tests/public-api/apps-filters.test.ts b/tests/public-api/apps-filters.test.ts index 84ffd0d5..953c9fb7 100644 --- a/tests/public-api/apps-filters.test.ts +++ b/tests/public-api/apps-filters.test.ts @@ -973,7 +973,14 @@ test('filter actions stay balanced with long selections and Done preserves filte const clearBox = await controls .getByRole('button', { name: 'Clear', exact: true }) .boundingBox(); - expect(Math.abs(inputBox!.y - clearBox!.y)).toBeLessThan(1); + const lastFilterBox = await controls + .getByRole('button', { name: 'Segment: Enterprise', exact: true }) + .boundingBox(); + expect(clearBox!.y).toBeGreaterThanOrEqual(lastFilterBox!.y); + if (Math.abs(clearBox!.y - lastFilterBox!.y) < 1) + expect(clearBox!.x).toBeGreaterThanOrEqual( + lastFilterBox!.x + lastFilterBox!.width + ); expect( await page.evaluate( () => document.documentElement.scrollWidth <= window.innerWidth diff --git a/tests/public-api/date-time-comparison.test.ts b/tests/public-api/date-time-comparison.test.ts new file mode 100644 index 00000000..58a877f4 --- /dev/null +++ b/tests/public-api/date-time-comparison.test.ts @@ -0,0 +1,89 @@ +import { + dateTimeRangeVariable, + defineDateRangeContract, + variableInput, +} from '@altertable/data-app/contract'; +import { expect, test, vi } from 'vitest'; + +test('comparison selection stays relative in URLs and resolves one protected request snapshot', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-07-10T16:00:00Z')); + try { + const contract = defineDateRangeContract({ + minDate: '2026-06-01', + maxRangeDays: 31, + timeZone: 'America/New_York', + completeDays: false, + }); + const period = dateTimeRangeVariable({ + key: 'period', + contract, + comparison: true, + defaultValue: '7d', + }); + const selected = { + ...period.defaultValue, + comparison: 'previous' as const, + }; + const params = new URLSearchParams(); + for (const [key, value] of Object.entries(period.write(selected))) + if (value !== null) params.set(key, value); + expect(params.get('compare')).toBe('previous'); + expect(period.read(params)).toEqual(selected); + const request = period.input(selected); + expect(request.range).toEqual({ + from: new Date('2026-07-03T12:00:00Z'), + to: new Date('2026-07-10T12:00:00Z'), + }); + expect(request.comparison).toEqual({ + from: new Date('2026-06-26T12:00:00Z'), + to: request.range.from, + }); + const parse = variableInput(() => ({}), { period }); + vi.setSystemTime(new Date('2026-07-10T16:10:00Z')); + expect( + parse(JSON.parse(JSON.stringify({ period: request }))).period + ).toEqual(request); + expect(() => + parse({ period: { ...request, comparison: request.range } }) + ).toThrow('preceding'); + expect(() => + parse({ + period: { + ...request, + range: { ...request.range, from: new Date('2026-07-02T12:00:00Z') }, + }, + }) + ).toThrow('preserve'); + expect(() => + parse({ period: { ...request, reference: 'invalid' } }) + ).toThrow('reference'); + expect(period.write(period.clearValue!).compare).toBeNull(); + } finally { + vi.useRealTimers(); + } +}); + +test('comparison is unavailable outside coverage and an invalid comparison URL is repaired', () => { + const contract = defineDateRangeContract({ + minDate: '2026-06-01', + maxRangeDays: 31, + timeZone: 'UTC', + }); + const period = dateTimeRangeVariable({ + key: 'window', + contract, + comparison: true, + defaultValue: { + from: new Date('2026-06-01T00:00:00Z'), + to: new Date('2026-06-03T00:00:00Z'), + }, + }); + expect(period.previous(period.defaultValue)).toBeNull(); + expect(period.valid({ ...period.defaultValue, comparison: 'previous' })).toBe( + false + ); + expect(period.read(new URLSearchParams('window-compare=previous'))).toEqual( + period.defaultValue + ); +}); diff --git a/tests/public-api/date-time-range-contract.test.ts b/tests/public-api/date-time-range-contract.test.ts new file mode 100644 index 00000000..5af3fc1a --- /dev/null +++ b/tests/public-api/date-time-range-contract.test.ts @@ -0,0 +1,126 @@ +import { + defineDateRangeContract, + dateTimeRangeVariable, + variableInput, +} from '@altertable/data-app/contract'; +import { parseDateTimeRangeInput } from '@altertable/data-app/format'; +import { expect, test, vi } from 'vitest'; + +function range(input: string) { + const parsed = parseDateTimeRangeInput(input); + if (!parsed.ok) throw new Error(input); + return parsed.range; +} + +test('one contract shares reporting-zone coverage between calendar and datetime ranges', () => { + const contract = defineDateRangeContract({ + minDate: '2026-06-01', + maxRangeDays: 7, + timeZone: 'America/New_York', + }); + const now = new Date('2026-07-02T02:30:00Z'); // Still July 1 in New York. + expect(contract.bounds(now).maxDate).toBe('2026-06-30'); + expect(contract.datetime.parse(range('yesterday'), now)).toEqual( + range('yesterday') + ); + expect(contract.datetime.resolve(range('yesterday'), now)).toEqual({ + from: new Date('2026-06-30T00:00:00Z'), + to: new Date('2026-07-01T00:00:00Z'), + }); + expect(() => contract.datetime.parse(range('today'), now)).toThrow( + 'coverage' + ); + expect(() => + contract.datetime.parse( + { + from: new Date('2026-05-31T23:59:00Z'), + to: new Date('2026-06-01T01:00:00Z'), + }, + now + ) + ).toThrow('coverage'); +}); + +test('datetime policy validates mixed bounds in the reporting zone and preserves relative values', () => { + const contract = defineDateRangeContract({ + maxRangeDays: 1, + timeZone: 'America/New_York', + completeDays: false, + }); + const now = new Date('2026-07-02T22:00:00Z'); + const input = { from: new Date('2026-07-02T16:00:00Z'), to: null }; + expect(contract.datetime.parse(input, now)).toEqual(input); + expect(contract.datetime.resolve(input, now).to).toEqual( + new Date('2026-07-02T18:00:00Z') + ); + expect(() => + contract.datetime.parse( + { from: new Date('2026-07-02T19:00:00Z'), to: null }, + now + ) + ).toThrow('before end'); + expect(() => contract.datetime.parse(range('2d'), now)).toThrow('at most 1'); +}); + +test('comparison requests resolve both periods at one reference and enforce coverage', () => { + const contract = defineDateRangeContract({ + minDate: '2026-03-01', + maxRangeDays: 7, + timeZone: 'UTC', + }); + const now = new Date('2026-03-07T12:00:00Z'); + const selected = { + from: new Date('2026-03-04T00:00:00Z'), + to: new Date('2026-03-07T00:00:00Z'), + }; + expect(contract.datetime.request(selected, true, now)).toEqual({ + range: selected, + comparison: { from: new Date('2026-03-01T00:00:00Z'), to: selected.from }, + }); + expect(() => + contract.datetime.request( + { + from: new Date('2026-03-02T00:00:00Z'), + to: new Date('2026-03-04T00:00:00Z'), + }, + true, + now + ) + ).toThrow('Comparison'); +}); + +test('a variable applies its contract to defaults, URL values, and server parsing', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-07-02T12:00:00Z')); + try { + const contract = defineDateRangeContract({ + minDate: '2026-06-01', + maxRangeDays: 7, + timeZone: 'UTC', + }); + const period = dateTimeRangeVariable({ + key: 'period', + contract, + defaultValue: 'yesterday', + }); + expect(period.maxRangeDays).toBe(7); + expect(period.valid(range('today'))).toBe(false); + expect(() => period.parse(range('today'))).toThrow('coverage'); + const parseInput = variableInput(() => ({}), { period }); + expect(() => parseInput({ period: range('today') })).toThrow(); + expect( + parseInput({ period: JSON.parse(JSON.stringify(range('yesterday'))) }) + .period + ).toEqual(range('yesterday')); + expect( + period.read( + new URLSearchParams({ period: JSON.stringify(range('today')) }) + ) + ).toEqual(range('yesterday')); + expect(() => + dateTimeRangeVariable({ key: 'bad', contract, defaultValue: 'today' }) + ).toThrow('coverage'); + } finally { + vi.useRealTimers(); + } +}); diff --git a/tests/public-api/date-time-range-flow.test.ts b/tests/public-api/date-time-range-flow.test.ts new file mode 100644 index 00000000..9f1d03fa --- /dev/null +++ b/tests/public-api/date-time-range-flow.test.ts @@ -0,0 +1,415 @@ +import { defineDataApp } from '@altertable/data-app'; +import { extractDataAppManifest } from '@altertable/data-app-manifest'; +import { createDataClient } from '@altertable/data-app/client'; +import { + dateTimeRangeVariable, + variableInput, + parseDateTimeRange, + parseDateTimeBound, + formatDateTimeBound, + serializeDateTimeRange, + dateTimeRangeSql, + resolveDateTimeRange, + type DateTimeRange, +} from '@altertable/data-app/contract'; +import { createDataHandler } from '@altertable/data-app/server'; +import { expect, expectTypeOf, test, vi } from 'vitest'; + +const relative: DateTimeRange = { + from: { + anchor: 'RELATIVE_ANCHOR_NOW', + offset: [{ amount: -7, unit: 'DAY' }], + }, + to: null, +}; + +test('datetime variables preserve relative intent and restore absolute values across URLs and JSON', () => { + const period = dateTimeRangeVariable({ + key: 'period', + defaultValue: '7d', + maxRangeDays: 90, + }); + expect(period.defaultValue).toEqual(relative); + expect(period.read(new URLSearchParams())).toEqual(relative); + const mixed: DateTimeRange = { + from: new Date('2026-01-01T16:00:00Z'), + to: { anchor: 'RELATIVE_ANCHOR_START_OF_MONTH', offset: [] }, + }; + const unbounded = dateTimeRangeVariable({ + key: 'period', + defaultValue: '7d', + }); + const written = unbounded.write(mixed); + const restored = unbounded.read( + new URLSearchParams({ period: written.period! }) + ); + expect(restored.from).toBeInstanceOf(Date); + expect(restored).toEqual(mixed); + expect(unbounded.same(restored, mixed)).toBe(true); + expect(period.read(new URLSearchParams({ period: '{broken' }))).toEqual( + relative + ); + expect( + period.valid({ + from: new Date('2020-01-01T00:00:00Z'), + to: new Date('2026-01-01T00:00:00Z'), + }) + ).toBe(false); + expect(serializeDateTimeRange(relative)).toBe(JSON.stringify(relative)); +}); + +test('variableInput rehydrates dates and a registered query binds a range without app conversion', async () => { + const period = dateTimeRangeVariable({ key: 'period', defaultValue: '7d' }); + const app = defineDataApp({ + title: 'Datetime', + description: 'Datetime query', + scope: { organization: 'test', environment: 'test' }, + queries: { + events: { + statement: 'SELECT $period.from AS start, $period.to AS end', + params: { period: { type: 'dateTimeRange' } }, + projections: { start: { type: 'string' }, end: { type: 'string' } }, + }, + }, + }); + const input = variableInput(() => ({}), { period }); + const operation = app.defineOperation({ + input, + checks: [{ period: relative }], + policy: { maxQueryRows: 10, maxDurationMs: 1000 }, + async run({ query }, values) { + return (await query('events', values)).rows; + }, + }); + let sent: { statement: string; params: unknown } | undefined; + const client = createDataClient({ + operations: { events: operation }, + lakehouse: { + async queryAll(statement, options) { + sent = { statement, params: options.params }; + return { + columns: [{ name: 'start' }, { name: 'end' }], + rows: [['start', 'end']], + }; + }, + }, + }); + const result = await client.query('events', { period: relative }); + expect(result.data).toEqual([{ start: 'start', end: 'end' }]); + expect(sent?.statement).not.toContain('$period.from'); + expect(sent?.statement).toContain('localtimestamp'); + expect(sent?.params).toEqual({ period: JSON.stringify(relative) }); + const absolute = { + period: { + from: '2026-01-01T16:00:00.000Z', + to: '2026-01-02T16:00:00.000Z', + }, + }; + expect(input(absolute).period.from).toBeInstanceOf(Date); + expect(() => + input({ period: { from: { anchor: 'DROP TABLE events', offset: [] } } }) + ).toThrow(); +}); + +test('query expansion leaves literals, comments, quoted identifiers, and other parameters intact', () => { + const statement = `SELECT '$period.from' AS "a$period.to", $$ $period.from $$ AS literal, $period.from AS bound, $count AS count -- $period.to\n/* outer /* $period.from */ comment */`; + const app = defineDataApp({ + title: 'Quoted', + description: 'Quoted SQL', + scope: { organization: 'test', environment: 'test' }, + queries: { + events: { + statement, + params: { + period: { type: 'dateTimeRange' }, + count: { type: 'number' }, + }, + projections: { bound: { type: 'string' } }, + }, + }, + }); + expect(app.queries.events.statement).toContain("'$period.from'"); + expect(app.queries.events.statement).toContain('$$ $period.from $$'); + expect(app.queries.events.statement).toContain( + '/* outer /* $period.from */ comment */' + ); + expect(app.queries.events.statement).toContain('$count AS count'); + expect(app.queries.events.statement).toContain('WITH RECURSIVE'); +}); + +test('SQL helpers produce parameterized bounds and reject unsafe structured values', () => { + const compiled = dateTimeRangeSql(relative); + expect(JSON.parse(compiled.params.period!)).toEqual(relative); + expect(compiled.from).toContain("INTERVAL '1 DAY'"); + expect(compiled.to).toContain('localtimestamp'); + expect(() => + dateTimeRangeSql(relative, 'period); DROP TABLE events') + ).toThrow(); + for (const invalid of [ + { from: new Date(NaN) }, + { from: '2026-01-01' }, + { + from: { + anchor: 'RELATIVE_ANCHOR_NOW', + offset: [{ amount: 1, unit: 'DAY); SELECT 1' }], + }, + }, + { + from: { + anchor: 'RELATIVE_ANCHOR_NOW', + offset: [{ amount: NaN, unit: 'DAY' }], + }, + }, + { + from: { + anchor: 'RELATIVE_ANCHOR_NOW', + offset: Array(17).fill({ amount: -1, unit: 'DAY' }), + }, + }, + ]) + expect(() => parseDateTimeRange(invalid)).toThrow(); +}); + +test('preview uses UTC calendar arithmetic and preserves ordered month offsets', () => { + const result = resolveDateTimeRange( + { + from: { + anchor: 'RELATIVE_ANCHOR_NOW', + offset: [ + { amount: 1, unit: 'MONTH' }, + { amount: 1, unit: 'MONTH' }, + ], + }, + to: new Date('2024-04-01T00:00:00Z'), + }, + new Date('2024-01-31T16:00:00Z') + ); + expect(result.from?.toISOString()).toBe('2024-03-29T16:00:00.000Z'); +}); + +test('editable relative bounds round-trip anchors and ordered offsets', () => { + const bound = { + anchor: 'RELATIVE_ANCHOR_START_OF_MONTH', + offset: [ + { amount: -1, unit: 'MONTH' }, + { amount: 2, unit: 'DAY' }, + ], + } as const; + const parsed = parseDateTimeBound('Start of month − 1mo + 2d'); + expect(parsed).toEqual(bound); + expect(parseDateTimeBound(formatDateTimeBound(parsed))).toEqual(bound); + expect(parseDateTimeBound('now')).toEqual({ + anchor: 'RELATIVE_ANCHOR_NOW', + offset: [], + }); + expect(() => parseDateTimeBound('')).toThrow(); +}); + +test('absolute query defaults remain dates, are snapshotted, and bind without caller conversion', async () => { + const from = new Date('2026-01-01T16:00:00Z'); + const app = defineDataApp({ + title: 'Defaults', + description: 'Absolute datetime defaults', + scope: { organization: 'test', environment: 'test' }, + queries: { + events: { + statement: String.raw`SELECT E'escaped\' $period.from' AS text, $period.from AS start`, + params: { + period: { + type: 'dateTimeRange', + defaultValue: { from, to: new Date('2026-01-02T16:00:00Z') }, + }, + }, + projections: { start: { type: 'string' } }, + }, + }, + }); + from.setUTCFullYear(2000); + expect(app.queries.events.params.period.defaultValue.from).toBeInstanceOf( + Date + ); + expect(app.queries.events.statement).toContain( + String.raw`E'escaped\' $period.from'` + ); + const operation = app.defineOperation({ + input: () => ({}), + checks: [{}], + policy: { maxQueryRows: 1, maxDurationMs: 1000 }, + async run({ query }) { + return (await query('events')).rows; + }, + }); + const client = createDataClient({ + operations: { events: operation }, + lakehouse: { + async queryAll(_statement, { params }) { + expect(JSON.parse(String(params?.period)).from).toBe( + '2026-01-01T16:00:00.000Z' + ); + return { columns: [{ name: 'start' }], rows: [['2026-01-01 16:00']] }; + }, + }, + }); + expect((await client.query('events', {})).data).toEqual([ + { start: '2026-01-01 16:00' }, + ]); +}); + +test('literal ISO query defaults survive manifest extraction and hydrate before query execution', async () => { + const declaration = { + title: 'Defaults', + description: 'Literal datetime defaults', + scope: { organization: 'test', environment: 'test' }, + queries: { + events: { + statement: 'SELECT $period.from AS start, $period.to AS end', + params: { + period: { + type: 'dateTimeRange' as const, + defaultValue: { + from: '2026-01-01T16:00:00.000Z', + to: { + anchor: 'RELATIVE_ANCHOR_START_OF_YEAR' as const, + offset: [], + }, + }, + }, + }, + projections: { start: { type: 'string' as const } }, + }, + }, + }; + const manifest = extractDataAppManifest( + `import { defineDataApp } from '@altertable/data-app'; +export const dataApp = defineDataApp(${JSON.stringify(declaration)});` + ); + expect(manifest.diagnostics).toEqual([]); + expect(manifest.manifest).not.toBeNull(); + const dataApp = defineDataApp(declaration); + const defaults = dataApp.queries.events.params.period.defaultValue; + expectTypeOf(defaults).toEqualTypeOf(); + expect(defaults.from).toEqual(new Date('2026-01-01T16:00:00.000Z')); + expect(defaults.to).toEqual( + declaration.queries.events.params.period.defaultValue.to + ); + declaration.queries.events.params.period.defaultValue.from = + '2000-01-01T00:00:00.000Z'; + const operation = dataApp.defineOperation({ + input: () => ({}), + checks: [{}], + policy: { maxQueryRows: 1, maxDurationMs: 1000 }, + async run({ query }) { + return (await query('events')).rows; + }, + }); + const client = createDataClient({ + operations: { events: operation }, + lakehouse: { + async queryAll(_statement, { params }) { + expect(JSON.parse(String(params?.period))).toEqual({ + from: '2026-01-01T16:00:00.000Z', + to: { anchor: 'RELATIVE_ANCHOR_START_OF_YEAR', offset: [] }, + }); + return { columns: [{ name: 'start' }], rows: [['2026-01-01 16:00']] }; + }, + }, + }); + expect((await client.query('events', {})).data).toEqual([ + { start: '2026-01-01 16:00' }, + ]); +}); + +test('rebased mixed ranges survive validation and URL restoration without a reporting timezone', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-07-02T14:30:00.000Z')); + try { + const period = dateTimeRangeVariable({ + key: 'period', + defaultValue: '1d', + maxRangeDays: 3, + }); + const range = { from: new Date('2026-07-02T16:00:00.000Z'), to: null }; + expect(period.valid(range)).toBe(true); + expect( + period.read( + new URLSearchParams({ period: serializeDateTimeRange(range) }) + ) + ).toEqual(range); + expect(period.parse(range)).toEqual(range); + expect( + period.valid({ + from: new Date('2026-07-02T16:00:00Z'), + to: new Date('2026-07-01T16:00:00Z'), + }) + ).toBe(false); + expect(period.valid(relative)).toBe(false); + expect( + period.valid({ + from: new Date('2026-07-01T16:00:00Z'), + to: new Date('2026-07-05T16:00:00Z'), + }) + ).toBe(false); + } finally { + vi.useRealTimers(); + } +}); + +test('HTTP host datetime parameters decode validated JSON and remain protected from caller overrides', async () => { + const dataApp = defineDataApp({ + title: 'Host range', + description: 'Protected range', + scope: { organization: 'test', environment: 'test' }, + queries: { + events: { + statement: 'SELECT $period.from AS start', + params: { period: { type: 'dateTimeRange' } }, + projections: { start: { type: 'string' } }, + }, + }, + }); + const operation = dataApp.defineOperation({ + input: (value: unknown) => ({ override: value === true }), + checks: [{ override: false }], + policy: { maxQueryRows: 1, maxDurationMs: 1000 }, + async run({ query }, input) { + return input.override + ? (await query('events', { period: relative })).rows + : (await query('events')).rows; + }, + }); + let sqlCalls = 0; + let hostValue = serializeDateTimeRange({ + from: new Date('2026-07-02T16:00:00.000Z'), + to: relative.from, + }); + const handle = createDataHandler({ events: operation }, async () => ({ + queryParams: { period: hostValue }, + lakehouse: { + async queryAll(_statement, { params }) { + sqlCalls++; + expect(params?.period).toBe(hostValue); + return { columns: [{ name: 'start' }], rows: [['2026-07-02 16:00']] }; + }, + }, + })); + function request(override = false) { + return new Request('http://localhost/api/data/events', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(override), + }); + } + expect((await handle(request())).status).toBe(200); + expect(sqlCalls).toBe(1); + expect((await handle(request(true))).status).toBe(502); + expect(sqlCalls).toBe(1); + for (const invalid of [ + 'not json', + '{"from":"not a date"}', + '{"from":{"anchor":"INVALID","offset":[]}}', + ]) { + hostValue = invalid; + expect((await handle(request())).status).toBe(502); + } + expect(sqlCalls).toBe(1); +}); diff --git a/tests/public-api/date-time-range-parser.test.ts b/tests/public-api/date-time-range-parser.test.ts new file mode 100644 index 00000000..cd10a820 --- /dev/null +++ b/tests/public-api/date-time-range-parser.test.ts @@ -0,0 +1,652 @@ +import { + canParseDateTimeRangeInput, + parseDateTimeRangeInput, +} from '@altertable/data-app/format'; +import { describe, expect, it } from 'vitest'; +const NOW = new Date('2026-07-02T15:30:00Z'); +const parserOptions = { now: NOW }; + +describe('parseDateTimeRangeInput', () => { + it.each([ + ['2022', '2022-01-01T00:00:00.000Z', '2023-01-01T00:00:00.000Z'], + ['2021 to 2024', '2021-01-01T00:00:00.000Z', '2025-01-01T00:00:00.000Z'], + ['2022–2022', '2022-01-01T00:00:00.000Z', '2023-01-01T00:00:00.000Z'], + ['Jan to Jul', '2026-01-01T00:00:00.000Z', '2026-08-01T00:00:00.000Z'], + ['January to July', '2026-01-01T00:00:00.000Z', '2026-08-01T00:00:00.000Z'], + [ + 'Jan 2026 to April 2027', + '2026-01-01T00:00:00.000Z', + '2027-05-01T00:00:00.000Z', + ], + ['Jan to Jul 2024', '2024-01-01T00:00:00.000Z', '2024-08-01T00:00:00.000Z'], + ['Jan 2024 to Jul', '2024-01-01T00:00:00.000Z', '2024-08-01T00:00:00.000Z'], + ['Jul', '2026-07-01T00:00:00.000Z', '2026-08-01T00:00:00.000Z'], + ['February 2024', '2024-02-01T00:00:00.000Z', '2024-03-01T00:00:00.000Z'], + [ + ' DECEMBER 2025 ', + '2025-12-01T00:00:00.000Z', + '2026-01-01T00:00:00.000Z', + ], + ])('parses %s as complete calendar periods', (input, from, to) => { + const result = parseDateTimeRangeInput(input, parserOptions); + expect(result.ok).toBe(true); + if (!result.ok) return; + + expect((result.range.from as Date).getTime()).toBe( + new Date(from).getTime() + ); + expect((result.range.to as Date).getTime()).toBe(new Date(to).getTime()); + }); + + it.each([ + [ + 'from July 1 to July 2 2026', + '2026-07-01T00:00:00.000Z', + '2026-07-03T00:00:00.000Z', + ], + [ + 'from July 1 to July 2 2024', + '2024-07-01T00:00:00.000Z', + '2024-07-03T00:00:00.000Z', + ], + ['Jul 1 - Jul 2', '2026-07-01T00:00:00.000Z', '2026-07-03T00:00:00.000Z'], + ['Jul 1 to Jul 1', '2026-07-01T00:00:00.000Z', '2026-07-02T00:00:00.000Z'], + ['since July 1 2026', '2026-07-01T00:00:00.000Z', null], + ['July 1 2026 to now', '2026-07-01T00:00:00.000Z', null], + [ + 'from July 1 2026 4pm to July 2 2026 5pm', + '2026-07-01T16:00:00.000Z', + '2026-07-02T17:00:00.000Z', + ], + [ + 'July 1 2026 4pm to July 2 2026', + '2026-07-01T16:00:00.000Z', + '2026-07-03T00:00:00.000Z', + ], + ])( + 'preserves complete day and explicit time boundaries in %s', + (input, from, to) => { + expect(parseDateTimeRangeInput(input, parserOptions)).toMatchObject({ + ok: true, + range: { from: new Date(from), to: to ? new Date(to) : null }, + }); + } + ); + + it.each(['2024 to 2021', 'Jul to Jan', 'April 2027 to Jan 2026'])( + 'rejects reversed calendar ranges: %s', + input => { + expect(parseDateTimeRangeInput(input, parserOptions)).toMatchObject({ + ok: false, + reason: 'invalid-range', + }); + } + ); + + it('uses UTC reference fields to infer the current year', () => { + const result = parseDateTimeRangeInput('Jan', { + now: new Date('2026-01-01T02:00:00Z'), + }); + expect(result.ok).toBe(true); + if (!result.ok) return; + + expect((result.range.from as Date).getTime()).toBe( + new Date('2026-01-01T00:00:00.000Z').getTime() + ); + }); + + it.each(['July 1', 'Jul 1', '7/1'])( + 'parses %s as a full calendar day', + input => { + const result = parseDateTimeRangeInput(input, parserOptions); + expect(result.ok).toBe(true); + if (!result.ok) return; + + expect(result.parser).toBe('chrono'); + expect((result.range.from as Date).getTime()).toBe( + new Date('2026-07-01T00:00:00.000Z').getTime() + ); + expect((result.range.to as Date).getTime()).toBe( + new Date('2026-07-02T00:00:00.000Z').getTime() + ); + } + ); + + it('parses today as a live calendar preset', () => { + const result = parseDateTimeRangeInput('today', parserOptions); + + expect(result).toEqual({ + ok: true, + normalizedInput: 'today', + parser: 'calendar-preset', + range: { + from: { + anchor: 'RELATIVE_ANCHOR_START_OF_TODAY', + offset: [], + }, + to: null, + }, + }); + }); + + it('parses yesterday as the previous full calendar day', () => { + const result = parseDateTimeRangeInput('yesterday', parserOptions); + + expect(result).toEqual({ + ok: true, + normalizedInput: 'yesterday', + parser: 'calendar-preset', + range: { + from: { + anchor: 'RELATIVE_ANCHOR_START_OF_YESTERDAY', + offset: [], + }, + to: { + anchor: 'RELATIVE_ANCHOR_START_OF_TODAY', + offset: [], + }, + }, + }); + }); + + it('parses to-date calendar presets', () => { + expect(parseDateTimeRangeInput('week to date', parserOptions)).toEqual({ + ok: true, + normalizedInput: 'week to date', + parser: 'calendar-preset', + range: { + from: { + anchor: 'RELATIVE_ANCHOR_START_OF_WEEK', + offset: [], + }, + to: null, + }, + }); + + expect(parseDateTimeRangeInput('month to date', parserOptions)).toEqual({ + ok: true, + normalizedInput: 'month to date', + parser: 'calendar-preset', + range: { + from: { + anchor: 'RELATIVE_ANCHOR_START_OF_MONTH', + offset: [], + }, + to: null, + }, + }); + + expect(parseDateTimeRangeInput('year to date', parserOptions)).toEqual({ + ok: true, + normalizedInput: 'year to date', + parser: 'calendar-preset', + range: { + from: { + anchor: 'RELATIVE_ANCHOR_START_OF_YEAR', + offset: [], + }, + to: null, + }, + }); + + expect(parseDateTimeRangeInput('WTD', parserOptions)).toEqual({ + ok: true, + normalizedInput: 'WTD', + parser: 'calendar-preset', + range: { + from: { + anchor: 'RELATIVE_ANCHOR_START_OF_WEEK', + offset: [], + }, + to: null, + }, + }); + + expect(parseDateTimeRangeInput('MTD', parserOptions)).toEqual({ + ok: true, + normalizedInput: 'MTD', + parser: 'calendar-preset', + range: { + from: { + anchor: 'RELATIVE_ANCHOR_START_OF_MONTH', + offset: [], + }, + to: null, + }, + }); + + expect(parseDateTimeRangeInput('YTD', parserOptions)).toEqual({ + ok: true, + normalizedInput: 'YTD', + parser: 'calendar-preset', + range: { + from: { + anchor: 'RELATIVE_ANCHOR_START_OF_YEAR', + offset: [], + }, + to: null, + }, + }); + }); + + it('parses sliding relative ranges', () => { + expect(parseDateTimeRangeInput('last month', parserOptions)).toEqual({ + ok: true, + normalizedInput: 'last month', + parser: 'relative', + range: { + from: { + anchor: 'RELATIVE_ANCHOR_NOW', + offset: [{ amount: -1, unit: 'MONTH' }], + }, + to: null, + }, + }); + + expect(parseDateTimeRangeInput('past week', parserOptions)).toEqual({ + ok: true, + normalizedInput: 'past week', + parser: 'relative', + range: { + from: { + anchor: 'RELATIVE_ANCHOR_NOW', + offset: [{ amount: -1, unit: 'WEEK' }], + }, + to: null, + }, + }); + + expect(parseDateTimeRangeInput('last 2 weeks', parserOptions)).toEqual({ + ok: true, + normalizedInput: 'last 2 weeks', + parser: 'relative', + range: { + from: { + anchor: 'RELATIVE_ANCHOR_NOW', + offset: [{ amount: -2, unit: 'WEEK' }], + }, + to: null, + }, + }); + + expect(parseDateTimeRangeInput('past 3 hours', parserOptions)).toEqual({ + ok: true, + normalizedInput: 'past 3 hours', + parser: 'relative', + range: { + from: { + anchor: 'RELATIVE_ANCHOR_NOW', + offset: [{ amount: -3, unit: 'HOUR' }], + }, + to: null, + }, + }); + + expect(parseDateTimeRangeInput('past 45 s', parserOptions)).toEqual({ + ok: true, + normalizedInput: 'past 45 s', + parser: 'relative', + range: { + from: { + anchor: 'RELATIVE_ANCHOR_NOW', + offset: [{ amount: -45, unit: 'SECOND' }], + }, + to: null, + }, + }); + + expect(parseDateTimeRangeInput('last 2 w', parserOptions)).toEqual({ + ok: true, + normalizedInput: 'last 2 w', + parser: 'relative', + range: { + from: { + anchor: 'RELATIVE_ANCHOR_NOW', + offset: [{ amount: -2, unit: 'WEEK' }], + }, + to: null, + }, + }); + + expect(parseDateTimeRangeInput('past 2h', parserOptions)).toEqual({ + ok: true, + normalizedInput: 'past 2h', + parser: 'relative', + range: { + from: { + anchor: 'RELATIVE_ANCHOR_NOW', + offset: [{ amount: -2, unit: 'HOUR' }], + }, + to: null, + }, + }); + + expect(parseDateTimeRangeInput('last 2w', parserOptions)).toEqual({ + ok: true, + normalizedInput: 'last 2w', + parser: 'relative', + range: { + from: { + anchor: 'RELATIVE_ANCHOR_NOW', + offset: [{ amount: -2, unit: 'WEEK' }], + }, + to: null, + }, + }); + + expect(parseDateTimeRangeInput('2w', parserOptions)).toEqual({ + ok: true, + normalizedInput: '2w', + parser: 'relative', + range: { + from: { + anchor: 'RELATIVE_ANCHOR_NOW', + offset: [{ amount: -2, unit: 'WEEK' }], + }, + to: null, + }, + }); + + expect(parseDateTimeRangeInput('2wks', parserOptions)).toEqual({ + ok: true, + normalizedInput: '2wks', + parser: 'relative', + range: { + from: { + anchor: 'RELATIVE_ANCHOR_NOW', + offset: [{ amount: -2, unit: 'WEEK' }], + }, + to: null, + }, + }); + }); + + it('parses ago-style relative ranges', () => { + expect(parseDateTimeRangeInput('3 days ago', parserOptions)).toEqual({ + ok: true, + normalizedInput: '3 days ago', + parser: 'relative', + range: { + from: { + anchor: 'RELATIVE_ANCHOR_NOW', + offset: [{ amount: -3, unit: 'DAY' }], + }, + to: null, + }, + }); + + expect(parseDateTimeRangeInput('2 weeks ago', parserOptions)).toEqual({ + ok: true, + normalizedInput: '2 weeks ago', + parser: 'relative', + range: { + from: { + anchor: 'RELATIVE_ANCHOR_NOW', + offset: [{ amount: -2, unit: 'WEEK' }], + }, + to: null, + }, + }); + + expect(parseDateTimeRangeInput('3 hrs ago', parserOptions)).toEqual({ + ok: true, + normalizedInput: '3 hrs ago', + parser: 'relative', + range: { + from: { + anchor: 'RELATIVE_ANCHOR_NOW', + offset: [{ amount: -3, unit: 'HOUR' }], + }, + to: null, + }, + }); + + expect(parseDateTimeRangeInput('15 mn ago', parserOptions)).toEqual({ + ok: true, + normalizedInput: '15 mn ago', + parser: 'relative', + range: { + from: { + anchor: 'RELATIVE_ANCHOR_NOW', + offset: [{ amount: -15, unit: 'MINUTE' }], + }, + to: null, + }, + }); + + expect(parseDateTimeRangeInput('2h ago', parserOptions)).toEqual({ + ok: true, + normalizedInput: '2h ago', + parser: 'relative', + range: { + from: { + anchor: 'RELATIVE_ANCHOR_NOW', + offset: [{ amount: -2, unit: 'HOUR' }], + }, + to: null, + }, + }); + + expect(parseDateTimeRangeInput('45m ago', parserOptions)).toEqual({ + ok: true, + normalizedInput: '45m ago', + parser: 'relative', + range: { + from: { + anchor: 'RELATIVE_ANCHOR_NOW', + offset: [{ amount: -45, unit: 'MINUTE' }], + }, + to: null, + }, + }); + }); + + it('parses since relative and absolute ranges', () => { + expect(parseDateTimeRangeInput('since 3 days ago', parserOptions)).toEqual({ + ok: true, + normalizedInput: 'since 3 days ago', + parser: 'relative', + range: { + from: { + anchor: 'RELATIVE_ANCHOR_NOW', + offset: [{ amount: -3, unit: 'DAY' }], + }, + to: null, + }, + }); + + const sinceAbsolute = parseDateTimeRangeInput( + 'since Jul 2 12pm', + parserOptions + ); + expect(sinceAbsolute.ok).toBe(true); + if (!sinceAbsolute.ok) { + return; + } + + expect(sinceAbsolute.parser).toBe('chrono'); + expect(sinceAbsolute.range.to).toBeNull(); + expect(sinceAbsolute.range.from).toBeInstanceOf(Date); + + const from = sinceAbsolute.range.from as Date; + const tzFrom = from; + expect(tzFrom.getUTCFullYear()).toBe(2026); + expect(tzFrom.getUTCMonth()).toBe(6); + expect(tzFrom.getUTCDate()).toBe(2); + expect(tzFrom.getUTCHours()).toBe(12); + }); + + it('parses absolute ranges with now and same-day times', () => { + const toNow = parseDateTimeRangeInput('Jul 2 12pm to now', parserOptions); + expect(toNow.ok).toBe(true); + if (!toNow.ok) { + return; + } + + expect(toNow.parser).toBe('chrono'); + expect(toNow.range.to).toBeNull(); + + const sameDay = parseDateTimeRangeInput('9:00 am - 3:00 pm', parserOptions); + expect(sameDay.ok).toBe(true); + if (!sameDay.ok) { + return; + } + + const from = sameDay.range.from as Date; + const to = sameDay.range.to as Date; + const tzFrom = from; + const tzTo = to; + + expect(tzFrom.getUTCFullYear()).toBe(2026); + expect(tzFrom.getUTCMonth()).toBe(6); + expect(tzFrom.getUTCDate()).toBe(2); + expect(tzFrom.getUTCHours()).toBe(9); + expect(tzTo.getUTCHours()).toBe(15); + }); + + it('parses unix timestamp ranges in seconds and milliseconds', () => { + const seconds = parseDateTimeRangeInput( + '1782913084 - 1783517884', + parserOptions + ); + expect(seconds.ok).toBe(true); + if (!seconds.ok) { + return; + } + + expect(seconds.parser).toBe('unix'); + expect((seconds.range.from as Date).getTime()).toBe(1_782_913_084_000); + expect((seconds.range.to as Date).getTime()).toBe(1_783_517_884_000); + + const milliseconds = parseDateTimeRangeInput( + '1782913084000 - 1783517884000', + parserOptions + ); + expect(milliseconds.ok).toBe(true); + if (!milliseconds.ok) { + return; + } + + expect(milliseconds.parser).toBe('unix'); + expect((milliseconds.range.from as Date).getTime()).toBe(1_782_913_084_000); + expect((milliseconds.range.to as Date).getTime()).toBe(1_783_517_884_000); + + const toNow = parseDateTimeRangeInput('1783609703 to now', parserOptions); + expect(toNow.ok).toBe(true); + if (!toNow.ok) { + return; + } + + expect(toNow.parser).toBe('unix'); + expect(toNow.range.to).toBeNull(); + expect((toNow.range.from as Date).getTime()).toBe(1_783_609_703_000); + }); + + it('reports validation failures for unsupported input', () => { + expect(parseDateTimeRangeInput('', parserOptions)).toEqual({ + ok: false, + reason: 'empty', + normalizedInput: '', + }); + + expect(parseDateTimeRangeInput(' ', parserOptions)).toEqual({ + ok: false, + reason: 'empty', + normalizedInput: '', + }); + + expect(parseDateTimeRangeInput('not a date', parserOptions)).toEqual({ + ok: false, + reason: 'unrecognized', + normalizedInput: 'not a date', + }); + + const partiallyParsedRange = 'aaa 5, 2026, 6:49 PM - Aug 5, 2027, 3:49 PM'; + expect( + parseDateTimeRangeInput(partiallyParsedRange, parserOptions) + ).toEqual({ + ok: false, + reason: 'unrecognized', + normalizedInput: partiallyParsedRange, + }); + + expect( + parseDateTimeRangeInput('1783517884 - 1782913084', parserOptions) + ).toEqual({ + ok: false, + reason: 'invalid-range', + normalizedInput: '1783517884 - 1782913084', + }); + + expect(parseDateTimeRangeInput('since', parserOptions)).toEqual({ + ok: false, + reason: 'unrecognized', + normalizedInput: 'since', + }); + }); +}); + +describe('canParseDateTimeRangeInput', () => { + it('returns true only for parseable input', () => { + expect(canParseDateTimeRangeInput('today', parserOptions)).toBe(true); + expect(canParseDateTimeRangeInput('last 2 weeks', parserOptions)).toBe( + true + ); + expect(canParseDateTimeRangeInput('', parserOptions)).toBe(false); + expect(canParseDateTimeRangeInput('not a date', parserOptions)).toBe(false); + expect( + canParseDateTimeRangeInput('1783517884 - 1782913084', parserOptions) + ).toBe(false); + }); +}); + +describe('UTC-rebased absolute dates', () => { + it.each([ + ['since July 1 2026 4pm', '2026-07-01T16:00:00.000Z'], + ['since July 1 2026 4pm GMT+0200', '2026-07-01T16:00:00.000Z'], + ['since March 8 2026 2:30am', '2026-03-08T02:30:00.000Z'], + ['since November 1 2026 1:30am', '2026-11-01T01:30:00.000Z'], + ])('preserves written fields for %s', (input, expected) => { + const result = parseDateTimeRangeInput(input, parserOptions); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.range.from).toBeInstanceOf(Date); + expect((result.range.from as Date).toISOString()).toBe(expected); + expect(result.range.to).toBeNull(); + }); + + it('preserves relative expressions across reference dates', () => { + for (const input of [ + '2w', + 'since 3 days ago', + 'today', + 'yesterday', + 'WTD', + 'MTD', + 'YTD', + ]) { + expect( + parseDateTimeRangeInput(input, { + now: new Date('2024-01-01T00:00:00Z'), + }) + ).toEqual( + parseDateTimeRangeInput(input, { + now: new Date('2027-12-31T23:59:59Z'), + }) + ); + } + }); + + it('rejects an invalid reference date', () => { + expect( + parseDateTimeRangeInput('July 1', { now: new Date(NaN) }) + ).toMatchObject({ ok: false, reason: 'invalid-range' }); + }); +}); + +it.each(['since February 30 2026 4pm', 'since April 31 2026 4pm'])( + 'rejects impossible wall-clock dates: %s', + input => { + expect(canParseDateTimeRangeInput(input, parserOptions)).toBe(false); + } +); diff --git a/tests/public-api/datetime-time-view.types.ts b/tests/public-api/datetime-time-view.types.ts new file mode 100644 index 00000000..26671821 --- /dev/null +++ b/tests/public-api/datetime-time-view.types.ts @@ -0,0 +1,100 @@ +import { createDataContext, createDataHooks } from '@altertable/data-app-react'; +import { createDataClient } from '@altertable/data-app/client'; +import { + dateTimeRangeVariable, + defineDateRangeContract, + type DataOperation, + type DateTimeRange, +} from '@altertable/data-app/contract'; + +const period = dateTimeRangeVariable({ + key: 'period', + defaultValue: 'yesterday', + contract: defineDateRangeContract({ maxRangeDays: 90, timeZone: 'UTC' }), +}); +const hooks = + createDataHooks( + createDataClient<{ + events: DataOperation<{ period: DateTimeRange }, number>; + nested: DataOperation<{ filters: { period: DateTimeRange } }, number>; + }>() + ); +const feedback = { + dataContext: createDataContext({ events: 'events' })({ + description: 'Events', + glossary: { count: { term: 'Count', definition: 'Event count' } }, + }), + isEmpty: () => false, + emptyFallback: { title: 'No events' }, +}; +hooks.defineTimeView({ ...feedback, operation: 'events', time: period }); +hooks.defineTimeView({ + ...feedback, + operation: 'nested', + time: period, + input: values => ({ filters: { period: values.period } }), + bindings: { period: input => input.filters.period }, +}); +// @ts-expect-error Nested inputs need an explicit mapping and period reader. +hooks.defineTimeView({ ...feedback, operation: 'nested', time: period }); +const shadowed = { + ...feedback, + operation: 'events' as const, + time: period, + variables: { period }, +}; +// @ts-expect-error The selected period cannot be shadowed by an extra variable. +hooks.defineTimeView(shadowed); + +const compared = dateTimeRangeVariable({ + key: 'period', + defaultValue: 'yesterday', + comparison: true, + contract: defineDateRangeContract({ maxRangeDays: 90, timeZone: 'UTC' }), +}); +const comparisonHooks = + createDataHooks( + createDataClient<{ + events: DataOperation< + { period: ReturnType }, + number + >; + nested: DataOperation< + { filters: { period: ReturnType } }, + number + >; + }>() + ); +const comparedView = comparisonHooks.defineTimeView({ + ...feedback, + operation: 'events', + time: compared, +}); +comparedView.metric( + { + id: 'count', + glossaryId: 'count', + queryNames: ['events'], + format: { kind: 'count' }, + }, + count => ({ current: count, previous: 1 }) +); +comparisonHooks.defineTimeView({ + ...feedback, + operation: 'nested', + time: compared, + input: values => ({ filters: { period: values.period } }), + bindings: { period: input => input.filters.period }, +}); +const missingReader = { + ...feedback, + operation: 'nested' as const, + time: compared, + input: (values: { period: ReturnType }) => ({ + filters: values, + }), +}; +// @ts-expect-error Nested datetime comparison inputs require a period binding. +comparisonHooks.defineTimeView(missingReader); +// @ts-expect-error Comparison policy is required when enabling the reporting workflow. +dateTimeRangeVariable({ key: 'period', defaultValue: '7d', comparison: true }); diff --git a/tests/public-api/fixtures/datetime-comparison.html b/tests/public-api/fixtures/datetime-comparison.html new file mode 100644 index 00000000..0ff81dc6 --- /dev/null +++ b/tests/public-api/fixtures/datetime-comparison.html @@ -0,0 +1,10 @@ + + + + + + +
+ + + diff --git a/tests/public-api/fixtures/datetime-comparison.tsx b/tests/public-api/fixtures/datetime-comparison.tsx new file mode 100644 index 00000000..987960a2 --- /dev/null +++ b/tests/public-api/fixtures/datetime-comparison.tsx @@ -0,0 +1,121 @@ +import { defineDataApp } from '@altertable/data-app'; +import { + createDataContext, + createDataHooks, + DataApp, + DataSection, + MetricWidget, + injectDataAppStyles, + mountDataApp, +} from '@altertable/data-app-react'; +import { createDataClient } from '@altertable/data-app/client'; +import { + dateTimeRangeVariable, + defineDateRangeContract, + variableInput, +} from '@altertable/data-app/contract'; + +const contract = defineDateRangeContract({ + minDate: '2026-06-01', + maxRangeDays: 31, + timeZone: 'UTC', + completeDays: false, +}); +const period = dateTimeRangeVariable({ + key: 'period', + defaultValue: '7d', + contract, + comparison: true, +}); +const dataApp = defineDataApp({ + title: 'Datetime comparisons', + description: 'Compare absolute or relative reporting intervals.', + scope: { organization: 'test', environment: 'test' }, + queries: { + current: { + statement: 'SELECT 12 AS count WHERE $window.from < $window.to', + params: { window: { type: 'dateTimeRange' } }, + projections: { count: { type: 'number' } }, + }, + previous: { + statement: 'SELECT 6 AS count WHERE $window.from < $window.to', + params: { window: { type: 'dateTimeRange' } }, + projections: { count: { type: 'number' } }, + }, + }, +}); +const events = dataApp.defineOperation({ + input: variableInput(() => ({}), { period }), + checks: [{ period: period.input(period.defaultValue) }], + policy: { maxQueryRows: 1, maxDurationMs: 1000 }, + async run({ query }, input) { + const current = await query('current', { window: input.period.range }); + const previous = input.period.comparison + ? await query('previous', { window: input.period.comparison }) + : null; + return { + current: current.rows[0]!.count, + previous: previous?.rows[0]?.count, + }; + }, +}); +const client = createDataClient({ + operations: { events }, + lakehouse: { + async queryAll(statement, { params }) { + if (!statement.includes('WITH RECURSIVE') || !params?.window) + throw new Error('Datetime parameter not compiled'); + return { + columns: [{ name: 'count' }], + rows: [[statement.includes('SELECT 12') ? 12 : 6]], + }; + }, + }, +}); +const context = createDataContext(events.queryNames)({ + description: 'Events in the selected period', + glossary: { + events: { + term: 'Events', + definition: 'Event count', + queryNames: ['current', 'previous'], + }, + }, +}); +const view = createDataHooks(client).defineTimeView({ + operation: 'events', + time: period, + dataContext: context, + isEmpty: () => false, + emptyFallback: { title: 'No events' }, +}); +const metric = view.metric( + { id: 'events', glossaryId: 'events', format: { kind: 'count' } }, + data => data +); +const dataset = view.dataset({ + name: 'Events', + select: data => [data], + rowKey: () => 'events', + columns: { current: { value: row => row.current } }, + evidence: { id: 'events', queryNames: ['current', 'previous'] }, +}); +const content = view.content(source => ( + <> + + {!source.loading && ( + + {JSON.stringify(source.input.period)} + + )} + +)); +function App() { + return ( + []}> + + + ); +} +injectDataAppStyles(); +mountDataApp({ app: dataApp, component: App }); diff --git a/tests/public-api/fixtures/datetime.html b/tests/public-api/fixtures/datetime.html new file mode 100644 index 00000000..fde69f5b --- /dev/null +++ b/tests/public-api/fixtures/datetime.html @@ -0,0 +1,10 @@ + + + + + + +
+ + + diff --git a/tests/public-api/fixtures/datetime.tsx b/tests/public-api/fixtures/datetime.tsx new file mode 100644 index 00000000..d0cf77a9 --- /dev/null +++ b/tests/public-api/fixtures/datetime.tsx @@ -0,0 +1,180 @@ +import { defineDataApp } from '@altertable/data-app'; +import { + dateTimeRangeVariable, + dateTimeRangePresets, + DateTimeRangePicker, + Button, + createDataHooks, + createDataContext, + DataApp, + DataSection, + TextWidget, + mountDataApp, + injectDataAppStyles, + useDateFormatting, +} from '@altertable/data-app-react'; +import { createDataClient } from '@altertable/data-app/client'; +import { + type DateTimeRange, + defineDateRangeContract, + variableInput, +} from '@altertable/data-app/contract'; +import { useState } from 'react'; + +const app = defineDataApp({ + title: 'Datetime exploration', + description: 'Explore a datetime range.', + scope: { organization: 'test', environment: 'test' }, + queries: { + events: { + statement: 'SELECT $period.from AS start, $period.to AS end', + params: { period: { type: 'dateTimeRange' } }, + projections: { selection: { type: 'string' } }, + }, + }, +}); +const presetMenu = new URLSearchParams(location.search).get('presets'); +const contract = new URLSearchParams(location.search).has('policy') + ? defineDateRangeContract({ + minDate: '2026-06-01', + maxDate: '2026-07-01', + maxRangeDays: 7, + timeZone: + new URLSearchParams(location.search).get('reportingZone') ?? 'UTC', + }) + : undefined; +const presets = + presetMenu === 'analytics' + ? [ + ...dateTimeRangePresets.analytics, + { + label: 'Launch window', + range: { + from: new Date('2026-01-01T00:00:00.000Z'), + to: new Date('2026-01-03T00:00:00.000Z'), + }, + }, + ] + : presetMenu === 'monitoring' + ? dateTimeRangePresets.monitoring + : presetMenu === 'empty' + ? [] + : undefined; +const period = dateTimeRangeVariable({ + key: 'period', + defaultValue: contract + ? { + from: new Date('2026-06-28T00:00:00Z'), + to: new Date('2026-07-02T00:00:00Z'), + } + : '7d', + contract, + presets, +}); +const events = app.defineOperation({ + input: variableInput(() => ({}), { period }), + checks: [{ period: period.defaultValue }], + policy: { maxQueryRows: 1, maxDurationMs: 1000 }, + async run({ query }, input) { + return (await query('events', input)).rows; + }, +}); +const client = createDataClient({ + operations: { events }, + lakehouse: { + async queryAll(statement, { params }) { + if ( + statement.includes('$period.from') || + !statement.includes('WITH RECURSIVE') + ) + throw new Error('Range not compiled'); + return { columns: [{ name: 'selection' }], rows: [[params?.period]] }; + }, + }, +}); +const view = createDataHooks(client).defineTimeView({ + dataContext: createDataContext(events.queryNames)({ + description: 'Datetime results', + glossary: {}, + }), + operation: 'events', + time: period, + isEmpty: () => false, + emptyFallback: { title: 'No events' }, +}); +const dataset = view.dataset({ + name: 'Selection', + select: rows => rows, + rowKey: () => 'selection', + columns: { selection: { value: row => row.selection } }, + evidence: { id: 'selection', queryNames: ['events'] }, +}); +const content = view.content(source => ( + + {rows => {rows[0]?.selection}} + +)); +function App() { + if (new URLSearchParams(location.search).has('controls')) + return ; + return ( + []}> + + + + ); +} +injectDataAppStyles(); +mountDataApp({ app, component: App }); + +function PickerControls() { + const [value, setValue] = useState(null); + const [live, setLive] = useState(false); + return ( +
+ { + setValue(next); + setLive(isLive ?? false); + }} + /> + + + {JSON.stringify({ value, live })} + +
+ ); +} + +function ReportingZone() { + const { timeZone } = useDateFormatting(); + return {timeZone}; +} diff --git a/tests/public-api/fixtures/server.ts b/tests/public-api/fixtures/server.ts index 98d8f26e..1aa54dd5 100644 --- a/tests/public-api/fixtures/server.ts +++ b/tests/public-api/fixtures/server.ts @@ -14,6 +14,8 @@ import clients from '@/tests/public-api/fixtures/clients.html'; import controls from '@/tests/public-api/fixtures/controls.html'; import dateAxes from '@/tests/public-api/fixtures/date-axes.html'; import datePresentation from '@/tests/public-api/fixtures/date-presentation.html'; +import datetimeComparison from '@/tests/public-api/fixtures/datetime-comparison.html'; +import datetime from '@/tests/public-api/fixtures/datetime.html'; import declaredApp from '@/tests/public-api/fixtures/declared-app.html'; import diagnosticsHost from '@/tests/public-api/fixtures/diagnostics-host.html'; import exploration from '@/tests/public-api/fixtures/exploration.html'; @@ -91,6 +93,8 @@ const server = Bun.serve({ '/controls': controls, '/tooltips': tooltips, '/filters': filters, + '/datetime': datetime, + '/datetime-comparison': datetimeComparison, '/filter-presentation': filterPresentation, '/search-catalog': searchCatalog, '/static': staticApp, From be3330e931476b0da209c1f8898a359b75f6ab7d Mon Sep 17 00:00:00 2001 From: robinvrd Date: Mon, 2 Nov 2026 16:13:21 +0100 Subject: [PATCH 2/9] docs: separate datetime authoring guidance from API details --- REFERENCE.md | 8 +++--- docs/contract.md | 22 +++++++-------- docs/variables.md | 27 ++++++++++++------- packages/data-app/src/authoring/contract.ts | 1 + .../src/core/date-time-range-contract.ts | 3 +++ 5 files changed, 35 insertions(+), 26 deletions(-) diff --git a/REFERENCE.md b/REFERENCE.md index 0f1aab24..24547d75 100644 --- a/REFERENCE.md +++ b/REFERENCE.md @@ -618,9 +618,9 @@ DateTimeRangeComparisonVariableOptions (type, @altertable/data-app/contract, @al DateTimeRangeContract (type, @altertable/data-app/contract): Datetime policy exposed by defineDateRangeContract().datetime. Parsing preserves relative values. DateTimeRangeContract.timeZone: string DateTimeRangeContract.bounds: (now?: Date | undefined) => { minDate: string | undefined; maxDate: string; maxRangeDays: number; timeZone: string; } -DateTimeRangeContract.parse: (value: unknown, now?: Date | undefined) => DateTimeRange -DateTimeRangeContract.resolve: (value: unknown, now?: Date | undefined) => { from: Date; to: Date; } -DateTimeRangeContract.comparison: (value: unknown, now?: Date | undefined) => { from: Date; to: Date; } | null +DateTimeRangeContract.parse: (value: unknown, now?: Date | undefined) => DateTimeRange — Validate coverage and duration while preserving symbolic relative bounds. +DateTimeRangeContract.resolve: (value: unknown, now?: Date | undefined) => { from: Date; to: Date; } — Resolve to UTC-rebased bounds using reporting-zone wall-clock fields, then validate policy. +DateTimeRangeContract.comparison: (value: unknown, now?: Date | undefined) => { from: Date; to: Date; } | null — Return the preceding equal-length interval, or null when source coverage excludes it. DateTimeRangeContract.request: (value: unknown, compare?: boolean | undefined, now?: Date | undefined) => DateTimeRangeRequest — Resolve both periods against one reference instant. Use these bounds for comparison queries. DateTimeRangeParseFailureReason (type, @altertable/data-app/format): = 'ambiguous' | 'empty' | 'invalid-range' | 'unrecognized' DateTimeRangeParserOptions (type, @altertable/data-app/format) @@ -709,7 +709,7 @@ defineDateRangeContract.options.minDate?: string defineDateRangeContract.options.maxDate?: string | (() => string) defineDateRangeContract.options.maxRangeDays: number defineDateRangeContract.options.timeZone: string -defineDateRangeContract.options.completeDays?: boolean +defineDateRangeContract.options.completeDays?: boolean — Defaults to complete days through yesterday in the reporting timezone. Set false to include today. defineOperation (function, @altertable/data-app/contract): (operation: OperationDefinition & { queries: CheckedQueries; }): Omit, 'queryNames'> & { queryNames: { readonly [Name in keyof Queries & string]: Name; }; } defineOperation.operation.input: (value: unknown) => Input defineOperation.operation.checks: readonly [Input, ...Input[]] diff --git a/docs/contract.md b/docs/contract.md index f7c2a4e6..3e8b2c55 100644 --- a/docs/contract.md +++ b/docs/contract.md @@ -84,8 +84,9 @@ Use `operations.sampleCountsByGroup.queryNames` for context and evidence. ### Bind datetime ranges -Declare a `dateTimeRange` parameter and pass the [datetime variable](variables.md#absolute-and-relative-datetime-ranges) -value directly to `query()`. Reference its bounds in SQL: +Declare a `dateTimeRange` parameter and pass a `DateTimeRange` to `query()`. +For a [comparison variable](variables.md#absolute-and-relative-datetime-ranges), +pass its `range` or `comparison` bounds. Reference those bounds in SQL: ```ts const dataApp = defineDataApp({ @@ -101,12 +102,12 @@ const dataApp = defineDataApp({ }); ``` -The start is inclusive and the end exclusive; null bounds mean unbounded start -and now respectively. Relative values resolve at query execution. Absolute -values encode wall-clock fields in UTC: selecting 4 PM stores 16:00Z. +The start is inclusive and the end exclusive. Relative values resolve at query +execution. Absolute values encode wall-clock fields in UTC: selecting 4 PM stores +16:00Z. SQL bounds are timezone-free timestamps. Configure the backend session to the reporting timezone and interpret timestamp-with-timezone comparisons in that -same zone. Validate timezone-dependent range limits on the backend. +same zone. ## Share reporting policy @@ -125,9 +126,6 @@ const calendar = defineDateRangeContract({ }); ``` -For datetime values, `calendar.datetime.parse()` validates without resolving -relative bounds. `calendar.datetime.request(range, true)` resolves one execution -snapshot and its preceding equal-length comparison for querying. -The existing calendar flow retains `calendar.parseRequest()` and the comparison -control in `defineTimeView()`; its inclusive `start`/`end` dates remain supported. -Datetime variables enable the same workflow with `comparison: true`. +Use the shared contract in the [datetime variable workflow](variables.md#absolute-and-relative-datetime-ranges), +including optional previous-period comparison. Calendar-only views share it with +`calendar.parseRequest()` and `defineTimeView()`. diff --git a/docs/variables.md b/docs/variables.md index 29657195..4d8d728c 100644 --- a/docs/variables.md +++ b/docs/variables.md @@ -220,6 +220,7 @@ import { defineDateRangeContract, variableInput, } from '@altertable/data-app/contract'; +import { createDataHooks } from '@altertable/data-app-react'; const calendar = defineDateRangeContract({ maxRangeDays: 366, @@ -248,15 +249,21 @@ const events = dataApp.defineOperation({ return { current: current.rows, previous: previous?.rows ?? null }; }, }); -// Use operation: 'events', time: period in defineTimeView(). +// ... client created from { events }, and eventsContext +const { defineTimeView } = createDataHooks(client); +const eventsView = defineTimeView({ + operation: 'events', + dataContext: eventsContext, + time: period, + isEmpty: result => result.current.length === 0, + emptyFallback: { title: 'No events' }, +}); ``` -Use `dateTimeRangePresets.monitoring` for shorter windows, or omit `presets` -for the default menu. The optional contract shares coverage and range limits with -the picker and server parser; omit it for unrestricted datetime filters. Relative -selections stay relative across reloads. -Bind the value to a [datetime query parameter](contract.md#bind-datetime-ranges). -`comparison: true` adds the previous-period toggle and resolves both query ranges -against one reference time. `defineTimeView({ time: period, ... })` supplies metric -comparison labels from the displayed request. Omit `comparison` for a plain -`DateTimeRange` query input. Existing calendar variables remain supported. +Use `dateTimeRangePresets.monitoring` for shorter windows. Bind the query bounds +to a [datetime parameter](contract.md#bind-datetime-ranges). +`comparison: true` adds the toggle and supplies current/previous ranges resolved +against one reference time; the view derives metric comparison labels from that +request. Omit `comparison` for a plain `DateTimeRange` input, and omit `contract` +when no shared reporting policy is needed. Relative selections stay relative +across reloads. diff --git a/packages/data-app/src/authoring/contract.ts b/packages/data-app/src/authoring/contract.ts index 518bc519..ab7fe060 100644 --- a/packages/data-app/src/authoring/contract.ts +++ b/packages/data-app/src/authoring/contract.ts @@ -102,6 +102,7 @@ export type DateRangeContractOptions = { maxDate?: string | (() => string); maxRangeDays: number; timeZone: string; + /** Defaults to complete days through yesterday in the reporting timezone. Set false to include today. */ completeDays?: boolean; }; diff --git a/packages/data-app/src/core/date-time-range-contract.ts b/packages/data-app/src/core/date-time-range-contract.ts index b792ad28..1e581c6e 100644 --- a/packages/data-app/src/core/date-time-range-contract.ts +++ b/packages/data-app/src/core/date-time-range-contract.ts @@ -98,8 +98,11 @@ export function createDateTimeRangeContract(options: DateRangeContractOptions) { return { timeZone, bounds, + /** Validate coverage and duration while preserving symbolic relative bounds. */ parse, + /** Resolve to UTC-rebased bounds using reporting-zone wall-clock fields, then validate policy. */ resolve, + /** Return the preceding equal-length interval, or null when source coverage excludes it. */ comparison, /** Resolve both periods against one reference instant. Use these bounds for comparison queries. */ request( From 022caa8ded3c39079af475a703bbe75065461eb0 Mon Sep 17 00:00:00 2001 From: robinvrd Date: Mon, 2 Nov 2026 16:22:43 +0100 Subject: [PATCH 3/9] fix: avoid ambiguous whitespace matching in datetime parser --- .../src/core/date-time-range-parser.ts | 5 +++-- .../public-api/date-time-range-parser.test.ts | 19 +++++++++++++++++++ 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/packages/data-app/src/core/date-time-range-parser.ts b/packages/data-app/src/core/date-time-range-parser.ts index b79c809b..2f36d37a 100644 --- a/packages/data-app/src/core/date-time-range-parser.ts +++ b/packages/data-app/src/core/date-time-range-parser.ts @@ -40,7 +40,8 @@ export type ParseDateTimeRangeResult = type ParseSuccess = Extract; -const RANGE_SEPARATOR_PATTERN = /\s*(?:-|–|—| to )\s*/i; +// Input whitespace is normalized to single spaces before matching. +const RANGE_SEPARATOR_PATTERN = / ?(?:-|–|—| to ) ?/i; const RELATIVE_UNIT_PATTERNS: ReadonlyArray<{ unit: RelativeUnit; @@ -106,7 +107,7 @@ const BARE_RELATIVE_PATTERN = new RegExp( 'i' ); -const SINCE_PREFIX_PATTERN = /^(?:since|from)\s+(.+)$/i; +const SINCE_PREFIX_PATTERN = /^(?:since|from) (.+)$/i; const UNIX_TIMESTAMP_PATTERN = /^\d+$/; diff --git a/tests/public-api/date-time-range-parser.test.ts b/tests/public-api/date-time-range-parser.test.ts index cd10a820..521c0ed7 100644 --- a/tests/public-api/date-time-range-parser.test.ts +++ b/tests/public-api/date-time-range-parser.test.ts @@ -650,3 +650,22 @@ it.each(['since February 30 2026 4pm', 'since April 31 2026 4pm'])( expect(canParseDateTimeRangeInput(input, parserOptions)).toBe(false); } ); + +it.each([ + ['Jan to Jul 2026', 'Jan\t'.concat('\t'.repeat(10000), 'to\tJul 2026')], + [ + '1700000000 to 1700003600', + '1700000000\t'.concat('\t'.repeat(10000), 'to\t1700003600'), + ], + [ + 'July 1 2026 to July 2 2026', + 'July 1 2026\t'.concat('\t'.repeat(10000), 'to\tJuly 2 2026'), + ], + ['from 2 days ago', 'from'.concat('\t'.repeat(10000), '2 days ago')], + ['since July 1 2026', 'since'.concat('\t'.repeat(10000), 'July 1 2026')], + ['from', 'from'.concat('\t'.repeat(10000))], +])('normalizes repeated whitespace before parsing %s', (normalized, input) => { + expect(parseDateTimeRangeInput(input, parserOptions)).toEqual( + parseDateTimeRangeInput(normalized, parserOptions) + ); +}); From e28f94f76b3dae5949c853e571a8bf5976d80ba1 Mon Sep 17 00:00:00 2001 From: robinvrd Date: Mon, 2 Nov 2026 17:04:16 +0100 Subject: [PATCH 4/9] fix: reject nonpositive fixed relative datetime ranges --- .../src/core/date-time-range-value.ts | 7 +- tests/public-api/date-time-range-flow.test.ts | 68 +++++++++++++++++++ 2 files changed, 74 insertions(+), 1 deletion(-) diff --git a/packages/data-app/src/core/date-time-range-value.ts b/packages/data-app/src/core/date-time-range-value.ts index de6654ce..cca22409 100644 --- a/packages/data-app/src/core/date-time-range-value.ts +++ b/packages/data-app/src/core/date-time-range-value.ts @@ -129,6 +129,10 @@ export function parseDateTimeRange( if (start !== undefined && end !== undefined) duration = end - start; } } + invariant( + duration === undefined || duration > 0, + 'Start must be before end.' + ); invariant( maxRangeDays === undefined || (!!range.from && @@ -253,7 +257,8 @@ export function parseDateTimeBound( )![0] as RelativeUnit, }) ); - return parseDateTimeRange({ from: { anchor, offset } }).from!; + // Validate the bound without comparing it to an implicit "now" end. + return parseDateTimeRange({ to: { anchor, offset } }).to!; } // datetime-local controls encode fields without performing a local-time conversion. if ( diff --git a/tests/public-api/date-time-range-flow.test.ts b/tests/public-api/date-time-range-flow.test.ts index 9f1d03fa..0a9d9eb5 100644 --- a/tests/public-api/date-time-range-flow.test.ts +++ b/tests/public-api/date-time-range-flow.test.ts @@ -413,3 +413,71 @@ test('HTTP host datetime parameters decode validated JSON and remain protected f } expect(sqlCalls).toBe(1); }); + +test('nonpositive fixed relative ranges are rejected by variables and HTTP input before querying', async () => { + const period = dateTimeRangeVariable({ + key: 'period', + defaultValue: '1h', + maxRangeDays: 1, + }); + const unrestricted = dateTimeRangeVariable({ + key: 'period', + defaultValue: '1h', + }); + const dataApp = defineDataApp({ + title: 'Validated range', + description: 'Relative order', + scope: { organization: 'test', environment: 'test' }, + queries: { + events: { + statement: 'SELECT $period.from AS start', + params: { period: { type: 'dateTimeRange' } }, + projections: { start: { type: 'string' } }, + }, + }, + }); + const operation = dataApp.defineOperation({ + input: variableInput(() => ({}), { period }), + checks: [{ period: period.defaultValue }], + policy: { maxQueryRows: 1, maxDurationMs: 1000 }, + async run({ query }, input) { + return (await query('events', input)).rows; + }, + }); + const queryAll = vi.fn(async () => ({ + columns: [{ name: 'start' }], + rows: [['start']], + })); + const handle = createDataHandler({ events: operation }, async () => ({ + lakehouse: { queryAll }, + })); + for (const amount of [0, 1]) { + for (const to of [ + null, + { anchor: 'RELATIVE_ANCHOR_NOW' as const, offset: [] }, + ]) { + const range: DateTimeRange = { + from: { + anchor: 'RELATIVE_ANCHOR_NOW', + offset: [{ amount, unit: 'HOUR' }], + }, + to, + }; + expect(period.valid(range)).toBe(false); + expect(unrestricted.valid(range)).toBe(false); + expect(() => parseDateTimeRange(range)).toThrow( + 'Start must be before end.' + ); + const response = await handle( + new Request('http://localhost/api/data/events', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ period: range }), + }) + ); + expect(response.status).toBe(400); + } + } + expect(queryAll).not.toHaveBeenCalled(); + expect(period.valid(period.defaultValue)).toBe(true); +}); From 5980acd4523ffb97af86312214593be5118e37e1 Mon Sep 17 00:00:00 2001 From: robinvrd Date: Mon, 2 Nov 2026 17:17:01 +0100 Subject: [PATCH 5/9] test: wait for iframe link readiness after cancellation --- tests/public-api/apps-external-links.test.ts | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/tests/public-api/apps-external-links.test.ts b/tests/public-api/apps-external-links.test.ts index d1a968c0..66c7f786 100644 --- a/tests/public-api/apps-external-links.test.ts +++ b/tests/public-api/apps-external-links.test.ts @@ -25,10 +25,17 @@ test('sandboxed widget opens an approved external link only after host confirmat ); await dialog.getByRole('button', { name: 'Cancel' }).press('Escape'); await expect.poll(() => dialog.count()).toBe(0); - await app.getByRole('link', { name: 'View full report' }).press('Enter'); - const opened = page.context().waitForEvent('page'); - await dialog.getByRole('link', { name: 'Open example.com' }).click(); - const external = await opened; + const reportLink = app.getByRole('link', { name: 'View full report' }); + // Host dismissal precedes the asynchronous cancellation response to the iframe. + await expect + .poll(() => reportLink.getAttribute('aria-disabled')) + .toBe('false'); + await reportLink.press('Enter'); + await expect.poll(() => dialog.isVisible()).toBe(true); + const [external] = await Promise.all([ + page.context().waitForEvent('page'), + dialog.getByRole('link', { name: 'Open example.com' }).click(), + ]); await external.waitForLoadState(); expect(external.url()).toBe('https://example.com/report?source=widget'); expect(await external.evaluate(() => window.opener)).toBeNull(); From 94f5a300a68b4d2a9a5aae0899043f23dcddde02 Mon Sep 17 00:00:00 2001 From: robinvrd Date: Tue, 3 Nov 2026 10:57:44 +0100 Subject: [PATCH 6/9] fix: align datetime picker formatting and preset badges --- .../src/ui/DateTimeRangeCalendar.tsx | 4 +- .../src/ui/DateTimeRangePicker.css | 15 +- .../src/ui/DateTimeRangePicker.tsx | 66 +++--- .../src/ui/date-time-range-input.ts | 201 +++++++++--------- .../apps-datetime-comparison.test.ts | 55 +++++ tests/public-api/apps-datetime.test.ts | 8 +- .../fixtures/datetime-comparison.tsx | 3 + 7 files changed, 219 insertions(+), 133 deletions(-) diff --git a/packages/data-app-react/src/ui/DateTimeRangeCalendar.tsx b/packages/data-app-react/src/ui/DateTimeRangeCalendar.tsx index c626a1c1..326fc2c2 100644 --- a/packages/data-app-react/src/ui/DateTimeRangeCalendar.tsx +++ b/packages/data-app-react/src/ui/DateTimeRangeCalendar.tsx @@ -16,6 +16,7 @@ import { type RangeValue, } from 'react-aria-components'; +import { useDateFormatting } from '../date-formatting.ts'; import { Button } from './Button.tsx'; import { Select } from './Select.tsx'; @@ -33,6 +34,7 @@ export function DateTimeRangeCalendar({ onCompleteChange: (complete: boolean) => void; onChange: (value: DateTimeRange | null) => void; }) { + const { locale } = useDateFormatting(); const limits = contract?.bounds(); const currentDay = limits ? parseDate(limits.maxDate) : today('UTC'); const [selection, setSelection] = useState | null>( @@ -112,7 +114,7 @@ export function DateTimeRangeCalendar({ value={String(focused.month)} options={Array.from({ length: 12 }, (_, index) => ({ id: String(index + 1), - label: new Intl.DateTimeFormat('en', { + label: new Intl.DateTimeFormat(locale, { month: 'long', timeZone: 'UTC', }).format(new Date(Date.UTC(2000, index, 1))), diff --git a/packages/data-app-react/src/ui/DateTimeRangePicker.css b/packages/data-app-react/src/ui/DateTimeRangePicker.css index 8e3559d2..5f7388c8 100644 --- a/packages/data-app-react/src/ui/DateTimeRangePicker.css +++ b/packages/data-app-react/src/ui/DateTimeRangePicker.css @@ -33,14 +33,25 @@ align-items: center; justify-content: center; flex-shrink: 0; - min-width: 2rem; + width: 4.25rem; + height: 1.5rem; + box-sizing: border-box; padding: 0.1rem 0.25rem; border-radius: var(--atbl-radius-control); background: var(--atbl-subtle); - color: var(--atbl-muted); + color: var(--atbl-text); text-align: center; font-size: var(--atbl-type-meta-size); } +.altertable-datetime-shortcut span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.altertable-datetime-shortcut[data-word] { + text-transform: uppercase; + font-size: 0.625rem; +} .altertable-datetime-popover { width: min(22rem, calc(100vw - 2rem)); max-width: calc(100vw - 2rem); diff --git a/packages/data-app-react/src/ui/DateTimeRangePicker.tsx b/packages/data-app-react/src/ui/DateTimeRangePicker.tsx index b35463dd..400abeae 100644 --- a/packages/data-app-react/src/ui/DateTimeRangePicker.tsx +++ b/packages/data-app-react/src/ui/DateTimeRangePicker.tsx @@ -10,13 +10,19 @@ import { type DateTimeRangePreset, type DateRangeContract, } from '@altertable/data-app/contract'; -import { parseDateTimeRangeInput } from '@altertable/data-app/format'; +import { + formatDateTime, + type DateFormatOptions, + parseDateTimeRangeInput, +} from '@altertable/data-app/format'; import { useEffect, useId, useLayoutEffect, useRef, useState } from 'react'; +import { useDateFormatting } from '../date-formatting.ts'; import { Button } from './Button.tsx'; import { classNames } from './classNames.ts'; import { editDateTimeRangeInputSegment, + normalizeDateTimeRangeInput, getDateTimeRangeInputSegmentSelection, } from './date-time-range-input.ts'; import { dateTimeRangeLabel } from './date-time-range-label.ts'; @@ -55,7 +61,7 @@ export type DateTimeRangePickerProps = { const presetInputs = [ ['15m', 'Past 15 minutes'], ['4h', 'Past 4 hours'], - ['1d', 'Past 1 day'], + ['1d', 'Past 24 hours'], ['2d', 'Past 2 days'], ['3d', 'Past 3 days'], ['7d', 'Past 7 days'], @@ -86,27 +92,19 @@ const examples = { Since: ['since 7/1', 'Jul 2 12pm to now'], Unix: ['1783003329 - 1783608129', '1783609703 to now'], }; -function dateLabel(date: Date, year = false) { - return new Intl.DateTimeFormat('en-US', { - month: 'short', - day: 'numeric', - ...(year ? ({ year: 'numeric' } as const) : {}), - hour: 'numeric', - minute: '2-digit', - hour12: true, - timeZone: 'UTC', - }).format(date); -} function resolvedLabel( value: DateTimeRange | null, - contract?: DateRangeContract + contract?: DateRangeContract, + options: DateFormatOptions = {} ) { if (!value) return ''; try { const { from, to } = contract ? contract.datetime.resolve(value) : resolveDateTimeRange(value); - return from ? `${dateLabel(from, true)} - ${dateLabel(to, true)}` : ''; + return from + ? `${formatDateTime(from, { ...options, timeZone: 'UTC' })} – ${formatDateTime(to, { ...options, timeZone: 'UTC' })}` + : ''; } catch { // A UTC-only preview may be inverted even when valid in the reporting zone. return value.from @@ -114,7 +112,7 @@ function resolvedLabel( : ''; } } -function summaryLabel(value: DateTimeRange | null) { +function summaryLabel(value: DateTimeRange | null, options: DateFormatOptions) { if (!value) return ''; for (const [input, label] of presetInputs) { const parsed = parseDateTimeRangeInput(input); @@ -152,9 +150,9 @@ function summaryLabel(value: DateTimeRange | null) { } if (value.from instanceof Date) { return value.to instanceof Date - ? `${dateLabel(value.from)} - ${dateLabel(value.to)}` + ? dateTimeRangeLabel(value, options) : !value.to - ? `Since ${dateLabel(value.from)}` + ? `Since ${formatDateTime(value.from, { ...options, timeZone: 'UTC' })}` : formatDateTimeRange(value); } return formatDateTimeRange(value); @@ -180,6 +178,7 @@ export function DateTimeRangePicker({ disabled, }: DateTimeRangePickerProps) { const id = useId(); + const dateFormatting = useDateFormatting(); const trigger = useRef(null); const input = useRef(null); const pending = useRef<{ value: string; start: number; end: number } | null>( @@ -291,7 +290,7 @@ export function DateTimeRangePicker({ } function openPresets() { if (!open) { - setDraft(resolvedLabel(value, contract)); + setDraft(resolvedLabel(value, contract, dateFormatting)); setEdited(false); setError(''); setHelp(false); @@ -328,13 +327,14 @@ export function DateTimeRangePicker({ close(true); } function submit(text: string) { - const result = parseDateTimeRangeInput(text); + const normalized = normalizeDateTimeRangeInput(text, dateFormatting.locale); + const result = parseDateTimeRangeInput(normalized); if (result.ok) { commit(result.range); return; } // Explicit bound expressions retain anchors on either side of the range. - const bounds = text.split(/\s+to\s+/i); + const bounds = normalized.split(/\s+(?:to|–)\s+/i); if (bounds.length === 2) { try { const from = parseDateTimeBound(bounds[0]!); @@ -425,8 +425,14 @@ export function DateTimeRangePicker({ if (!disabled) openPresets(); }} > -