diff --git a/configure/src/metaconfigs/layer-tile-config.json b/configure/src/metaconfigs/layer-tile-config.json index ceb2bc3e1..e3cf8bc2f 100644 --- a/configure/src/metaconfigs/layer-tile-config.json +++ b/configure/src/metaconfigs/layer-tile-config.json @@ -478,16 +478,23 @@ { "field": "time.dataStartTime", "name": "Data Start Time", - "description": "The earliest time for which data is available in this layer. This is for display purposes only and does not constrain queries. Format: ISO 8601 datetime string (e.g., 2020-01-01T00:00:00Z).", + "description": "The earliest time for which data is available in this layer. This is for display purposes only and does not constrain queries. Use an ISO 8601 datetime (e.g., 2020-01-01T00:00:00Z), or now to follow the current date, optionally offset by an ISO 8601 duration (e.g., now - P1D). An unreadable value is ignored.", "type": "text", - "width": 6 + "width": 4 }, { "field": "time.dataEndTime", "name": "Data End Time", - "description": "The latest time for which data is available in this layer. This is for display purposes only and does not constrain queries. Format: ISO 8601 datetime string (e.g., 2025-12-31T23:59:59Z).", + "description": "The latest time for which data is available in this layer. This is for display purposes only and does not constrain queries. Use an ISO 8601 datetime (e.g., 2025-12-31T23:59:59Z), or now for a collection that is still growing, so the layer stays current without edits, optionally offset by an ISO 8601 duration (e.g., now + P5D). An unreadable value is ignored.", "type": "text", - "width": 6 + "width": 4 + }, + { + "field": "time.interval", + "name": "Data Time Interval", + "description": "How often new data is added, as an ISO 8601 duration (e.g., P1D for daily, P7D for weekly, P1M for monthly). When set, the data end time snaps back to the most recent date that actually has data. Leave empty for continuous or irregular data. An unreadable value is ignored.", + "type": "text", + "width": 4 }, { "field": "time.dataDates", diff --git a/configure/src/metaconfigs/layer-vector-config.json b/configure/src/metaconfigs/layer-vector-config.json index fc779440a..345796f0e 100644 --- a/configure/src/metaconfigs/layer-vector-config.json +++ b/configure/src/metaconfigs/layer-vector-config.json @@ -508,16 +508,23 @@ { "field": "time.dataStartTime", "name": "Data Start Time", - "description": "The earliest time for which data is available in this layer. This is for display purposes only and does not constrain queries. Format: ISO 8601 datetime string (e.g., 2020-01-01T00:00:00Z).", + "description": "The earliest time for which data is available in this layer. This is for display purposes only and does not constrain queries. Use an ISO 8601 datetime (e.g., 2020-01-01T00:00:00Z), or now to follow the current date, optionally offset by an ISO 8601 duration (e.g., now - P1D). An unreadable value is ignored.", "type": "text", - "width": 6 + "width": 4 }, { "field": "time.dataEndTime", "name": "Data End Time", - "description": "The latest time for which data is available in this layer. This is for display purposes only and does not constrain queries. Format: ISO 8601 datetime string (e.g., 2025-12-31T23:59:59Z).", + "description": "The latest time for which data is available in this layer. This is for display purposes only and does not constrain queries. Use an ISO 8601 datetime (e.g., 2025-12-31T23:59:59Z), or now for a collection that is still growing, so the layer stays current without edits, optionally offset by an ISO 8601 duration (e.g., now + P5D). An unreadable value is ignored.", "type": "text", - "width": 6 + "width": 4 + }, + { + "field": "time.interval", + "name": "Data Time Interval", + "description": "How often new data is added, as an ISO 8601 duration (e.g., P1D for daily, P7D for weekly, P1M for monthly). When set, the data end time snaps back to the most recent date that actually has data. Leave empty for continuous or irregular data. An unreadable value is ignored.", + "type": "text", + "width": 4 }, { "field": "time.dataDates", diff --git a/src/essence/Basics/Layers_/Layers_.js b/src/essence/Basics/Layers_/Layers_.js index ec429a636..54f060b41 100644 --- a/src/essence/Basics/Layers_/Layers_.js +++ b/src/essence/Basics/Layers_/Layers_.js @@ -9,6 +9,7 @@ import CursorInfo from '../../Ancillary/CursorInfo' import ToolController_ from '../../Basics/ToolController_/ToolController_' import LayerGeologic from './LayerGeologic/LayerGeologic' import ServiceUrls from '../ServiceUrls/ServiceUrls' +import { resolveTemporalExtent } from '../TimeControl_/layerTimePolicy' import { isRasterTileLayerType, MAP_ENGINE, @@ -32,6 +33,10 @@ import $ from 'jquery' // Provider cleanup functions for re-initialization let _providerCleanups = [] +// Resolved at call time so an open-ended "now" is fresh on every ask. +const temporalExtentFor = (uuid) => + resolveTemporalExtent(L_.layers.data[uuid]?.time) + /** * Canonical layer types whose deck.gl builders read the legend as a style * specification. Others carry a legend purely for display. Membership is @@ -458,6 +463,21 @@ const L_ = { }) return capabilities }), + // When each layer has data, as ISO datetimes or null. The + // config's dataStartTime/dataEndTime may be a policy ("now", + // "now - P1D"); this is where it is resolved, so a plugin + // never sees the policy string. Same call shapes as above. + window.mmgisAPI.provide('layers:getTemporalExtent', (layerUUID) => { + if (layerUUID != null) { + const uuid = L_.asLayerUUID(layerUUID) + return uuid == null ? null : temporalExtentFor(uuid) + } + const extents = {} + Object.keys(L_.layers.data).forEach((uuid) => { + extents[uuid] = temporalExtentFor(uuid) + }) + return extents + }), // Where each layer sits, for moving the map to it. Called with // a layer identifier it answers for that one layer, resolving a // name the way every other layer-keyed provider does; called diff --git a/src/essence/Basics/TimeControl_/layerTimePolicy.ts b/src/essence/Basics/TimeControl_/layerTimePolicy.ts new file mode 100644 index 000000000..bda472795 --- /dev/null +++ b/src/essence/Basics/TimeControl_/layerTimePolicy.ts @@ -0,0 +1,170 @@ +/** + * Layer time policies: a layer's `dataStartTime`/`dataEndTime` may be a + * concrete ISO datetime — or a policy string that stays true as time + * passes, so configs for growing/forecast collections never go stale: + * + * "now" the current moment + * "now - P1D" an ISO-8601 duration before now + * "now + P5D" a duration after now (forecast windows) + * + * "now" resolves to the raw current moment, never rounded — matching + * veda-ui, which normalizes an ongoing (null-ended) STAC domain to the + * current datetime as-is. + * + * A periodic layer additionally declares `time.interval`, an ISO-8601 + * duration ("P7D"): data exists only at start-anchored steps, so the + * extent's end floors to the last step at or before the resolved end — + * a 7-day cadence that began ten days ago ended three days ago, not now. + * + * Core owns this vocabulary. Plugins never resolve it themselves: they ask + * `layers:getTemporalExtent` and receive plain ISO datetimes. + */ + +const DURATION_RE = + /^P(?:(\d+)Y)?(?:(\d+)M)?(?:(\d+)W)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$/ + +const POLICY_RE = /^now(?:\s*([+-])\s*(\S+))?$/ + +interface Duration { + years: number + months: number + weeks: number + days: number + hours: number + minutes: number + seconds: number +} + +export function parseISODuration(value: string): Duration | null { + const m = DURATION_RE.exec(value) + if (!m || value === 'P' || value.endsWith('T')) return null + const [, years, months, weeks, days, hours, minutes, seconds] = m + if (![years, months, weeks, days, hours, minutes, seconds].some((v) => v)) + return null + return { + years: Number(years || 0), + months: Number(months || 0), + weeks: Number(weeks || 0), + days: Number(days || 0), + hours: Number(hours || 0), + minutes: Number(minutes || 0), + seconds: Number(seconds || 0), + } +} + +// Months and years are not fixed millisecond amounts — apply them with UTC +// date-component math, never ms arithmetic. Applying `factor × d` in one +// pass keeps a month cadence anchored to the start's day-of-month instead +// of drifting through short months. +function addDuration(date: Date, d: Duration, factor: number): Date { + const out = new Date(date) + out.setUTCFullYear(out.getUTCFullYear() + factor * d.years) + out.setUTCMonth(out.getUTCMonth() + factor * d.months) + out.setUTCDate(out.getUTCDate() + factor * (d.days + 7 * d.weeks)) + out.setUTCHours(out.getUTCHours() + factor * d.hours) + out.setUTCMinutes(out.getUTCMinutes() + factor * d.minutes) + out.setUTCSeconds(out.getUTCSeconds() + factor * d.seconds) + return out +} + +function toIso(date: Date): string { + return date.toISOString().split('.')[0] + 'Z' +} + +/** + * Resolves a data time value — concrete or policy — to an ISO datetime + * string, or null when the value is absent or unparseable (callers keep + * their own fallback; a bad value must never break a consumer). + * + * @param value - `time.dataStartTime` / `time.dataEndTime`. + * @param options.now - Injectable current moment (tests). + */ +export function resolveTimePolicy( + value: string | null | undefined, + options: { now?: Date } = {} +): string | null { + if (value == null || value === '') return null + + const policy = POLICY_RE.exec(value.trim()) + if (policy == null) { + const concrete = new Date(value) + return isNaN(concrete.getTime()) ? null : toIso(concrete) + } + + let resolved = options.now != null ? new Date(options.now) : new Date() + const [, sign, offset] = policy + if (offset != null) { + const duration = parseISODuration(offset) + if (duration == null) return null + resolved = addDuration(resolved, duration, sign === '-' ? -1 : 1) + } + return toIso(resolved) +} + +export interface TemporalExtent { + start: string | null + end: string | null +} + +const MS_PER_DAY = 86400000 + +// Rough length of one cadence step, only to seed the step count — the +// exact landing is settled by calendar math below. +function approximateMs(d: Duration): number { + return ( + (d.years * 365.2425 + d.months * 30.436875 + d.weeks * 7 + d.days) * + MS_PER_DAY + + d.hours * 3600000 + + d.minutes * 60000 + + d.seconds * 1000 + ) +} + +// Last start-anchored step at or before `end`: start + N × cadence for the +// largest N ≥ 0 that fits. An end before start clamps to the start itself. +function floorToStep(start: Date, end: Date, cadence: Duration): Date { + const stepAt = (n: number) => addDuration(start, cadence, n) + let n = Math.max( + 0, + Math.floor( + (end.getTime() - start.getTime()) / approximateMs(cadence) + ) + ) + while (stepAt(n + 1).getTime() <= end.getTime()) n++ + while (n > 0 && stepAt(n).getTime() > end.getTime()) n-- + return stepAt(n) +} + +/** + * Resolves a layer's `time` block to its temporal extent: both data time + * policies resolved, and — when the layer declares a periodic `interval` + * (ISO-8601 duration) — the end floored to the last start-anchored step, + * since no data exists between steps. An unparseable interval, or one + * without a resolvable start to anchor to, leaves the extent unsnapped. + * + * @param time - The layer config's `time` block. + * @param options.now - Injectable current moment (tests). + */ +export function resolveTemporalExtent( + time: + | { + dataStartTime?: string | null + dataEndTime?: string | null + interval?: string | null + } + | null + | undefined, + options: { now?: Date } = {} +): TemporalExtent { + const start = resolveTimePolicy(time?.dataStartTime, options) + let end = resolveTimePolicy(time?.dataEndTime, options) + + if (start != null && end != null && time?.interval != null) { + const cadence = parseISODuration(String(time.interval).trim()) + // A zero cadence ("P0D") parses but cannot step anywhere. + if (cadence != null && approximateMs(cadence) > 0) { + end = toIso(floorToStep(new Date(start), new Date(end), cadence)) + } + } + return { start, end } +} diff --git a/src/essence/Tools/Layers/LayersTool.js b/src/essence/Tools/Layers/LayersTool.js index 0ec575329..1c85db506 100644 --- a/src/essence/Tools/Layers/LayersTool.js +++ b/src/essence/Tools/Layers/LayersTool.js @@ -3,6 +3,7 @@ import Sortable from 'sortablejs' import F_ from '../../Basics/Formulae_/Formulae_' import L_ from '../../Basics/Layers_/Layers_' import Map_ from '../../Basics/Map_/Map_' +import { resolveTemporalExtent } from '../../Basics/TimeControl_/layerTimePolicy' import ServiceUrls from '../../Basics/ServiceUrls/ServiceUrls' import DataShaders from '../../Ancillary/DataShaders' @@ -1849,15 +1850,10 @@ function interfaceWithMMGIS(fromInit) { const layerUUID = L_.asLayerUUID(layerName) const layerData = L_.layers.data[layerUUID] - if ( - layerData && - layerData.time && - layerData.time.dataStartTime && - layerData.time.dataEndTime - ) { - // Convert ISO strings to timestamps - const startTime = new Date(layerData.time.dataStartTime).getTime() - const endTime = new Date(layerData.time.dataEndTime).getTime() + const extent = resolveTemporalExtent(layerData?.time) + if (extent.start != null && extent.end != null) { + const startTime = new Date(extent.start).getTime() + const endTime = new Date(extent.end).getTime() // Update TimeUI with the layer's data extent TimeUI.updateTimes(startTime, endTime, endTime) diff --git a/src/essence/Tools/Timeline/TimelineAdapter.tsx b/src/essence/Tools/Timeline/TimelineAdapter.tsx index 33d550a35..7c1f2f3a1 100644 --- a/src/essence/Tools/Timeline/TimelineAdapter.tsx +++ b/src/essence/Tools/Timeline/TimelineAdapter.tsx @@ -11,6 +11,7 @@ import { mmgisGetTimeEnd, mmgisGetTimeCurrent, type LayerConfig, + mmgisGetTemporalExtents, } from '../_shared/adapters/mmgisAPI' import { useMMGISHandlerReady } from '../_shared/adapters/useMMGISHandlerReady' import { @@ -216,9 +217,10 @@ export const TimelineAdapter: React.FC = () => { let cancelled = false const fetchLayers = async () => { - const [configs, visibleLayers] = await Promise.all([ + const [configs, visibleLayers, extents] = await Promise.all([ mmgisGetLayerConfigs(), mmgisGetVisibleLayers(), + mmgisGetTemporalExtents(), ]) if (cancelled || !configs) return @@ -234,19 +236,30 @@ export const TimelineAdapter: React.FC = () => { ? 'var(--theme-color-secondary, #c91b6e)' : 'var(--theme-color-base, #71767a)' + // Core resolves the authored data times (open-ended "now", + // duration offsets, cadence flooring); the resolvers below + // read plain timestamps. A bound core could not read stays + // as written, so it falls back the way it always has. + const extent = extents?.[layerName] + const time = layer.time && { + ...layer.time, + dataStartTime: extent?.start ?? layer.time.dataStartTime, + dataEndTime: extent?.end ?? layer.time.dataEndTime, + } + newLayers.push({ name: layerName, displayName: layer.display_name || layer.name || layerName, color: color, timeRanges: resolveLayerTimeRanges( - layer.time, + time, startTime, endTime ), // Same fallback bounds as the ranges above, so a row // navigates the span it draws. navigation: resolveLayerNavigation( - layer.time, + time, startTime, endTime, layerName diff --git a/src/essence/Tools/Timeline/__tests__/TimelineAdapter.spec.tsx b/src/essence/Tools/Timeline/__tests__/TimelineAdapter.spec.tsx index 5b26b4a65..838ec3391 100644 --- a/src/essence/Tools/Timeline/__tests__/TimelineAdapter.spec.tsx +++ b/src/essence/Tools/Timeline/__tests__/TimelineAdapter.spec.tsx @@ -259,3 +259,96 @@ describe('TimelineAdapter layer navigation', () => { ).toMatch(/layer/i) }) }) + +/** + * A layer's authored data times can be open-ended ("now", a duration offset, + * a cadence to floor to). Core resolves those; the timeline draws and + * navigates the resolved dates rather than re-reading the config itself. + */ + +// Where core says a daily layer authored as ending "now" actually ends: its +// last complete day, well inside the window and nowhere near the clock. +const FLOORED_END = '2024-09-30T00:00:00.000Z' +const AUTHORED_START = '2024-03-01T00:00:00.000Z' + +const OPEN_ENDED_CONFIGS = { + daily: { + name: 'daily', + display_name: 'Daily Product', + time: { + enabled: true, + dataStartTime: AUTHORED_START, + dataEndTime: 'now', + interval: 'P1D', + }, + }, +} + +describe('TimelineAdapter open-ended layer time', () => { + let container: HTMLElement + let root: Root + let emits: Emit[] + let originalResizeObserver: unknown + + beforeEach(async () => { + emits = [] + originalResizeObserver = (globalThis as { ResizeObserver?: unknown }) + .ResizeObserver + ;(globalThis as { ResizeObserver?: unknown }).ResizeObserver = + NoopResizeObserver + ;(window as unknown as { mmgisAPI: unknown }).mmgisAPI = { + request: async (name: string) => { + if (name === 'time:isEnabled') return true + if (name === 'time:getStart') return START + if (name === 'time:getEnd') return END + if (name === 'time:getCurrent') return CURRENT + if (name === 'tool:getVars') return {} + if (name === 'layers:getAllConfigs') return OPEN_ENDED_CONFIGS + if (name === 'layers:getVisible') return { daily: true } + if (name === 'layers:getTemporalExtent') + return { daily: { start: AUTHORED_START, end: FLOORED_END } } + return null + }, + hasHandler: () => true, + on: () => () => {}, + emit: (event: string, payload?: unknown) => { + emits.push({ event, payload }) + }, + } + + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + await act(async () => { + root.render() + }) + await act(async () => {}) + }) + + afterEach(() => { + act(() => root.unmount()) + container.remove() + delete (window as { mmgisAPI?: unknown }).mmgisAPI + ;(globalThis as { ResizeObserver?: unknown }).ResizeObserver = + originalResizeObserver + }) + + const requests = () => emits.filter((e) => e.event === 'time:changeRequested') + + test('the last date of a layer ending "now" is where core floored it, not the clock', () => { + act(() => { + container + .querySelector( + '[aria-label="Daily Product: last date"]' + )! + .click() + }) + + expect(requests()).toHaveLength(1) + expect(requests()[0].payload).toEqual({ + startTime: new Date(START).toISOString(), + endTime: new Date(END).toISOString(), + currentTime: FLOORED_END, + }) + }) +}) diff --git a/src/essence/Tools/_shared/adapters/mmgisAPI.ts b/src/essence/Tools/_shared/adapters/mmgisAPI.ts index fb79f879f..b3b9ab945 100644 --- a/src/essence/Tools/_shared/adapters/mmgisAPI.ts +++ b/src/essence/Tools/_shared/adapters/mmgisAPI.ts @@ -22,6 +22,10 @@ export type LayerConfig = { display_name?: string time?: { enabled?: boolean + /** As authored: a concrete ISO datetime or a policy string ("now", + * "now - P1D"). A periodic layer may also carry an `interval` + * cadence, already folded into the resolved extent. Ask + * mmgisGetTemporalExtents for the dates. */ dataStartTime?: string dataEndTime?: string // The days a sparse layer holds data on, when it holds data on a @@ -184,6 +188,35 @@ export const mmgisGetLayerCogCapabilities = ( ) } +/** When a layer has data, as ISO datetimes; null where unset or unreadable. */ +export type TemporalExtent = { + start: string | null + end: string | null +} + +/** + * Temporal extent for every layer, keyed by layer UUID, resolved by core at + * the moment of asking. Null against a core without the handler. + */ +export const mmgisGetTemporalExtents = (): Promise | null> => { + return mmgisRequestIfProvided>( + 'layers:getTemporalExtent', + ) +} + +/** Temporal extent for one layer, by UUID or display name. */ +export const mmgisGetLayerTemporalExtent = ( + layerUUID: string, +): Promise => { + return mmgisRequestIfProvided( + 'layers:getTemporalExtent', + layerUUID, + ) +} + /** A geographic extent as `[[south, west], [north, east]]`. */ export type LayerBounds = [[number, number], [number, number]] diff --git a/tests/unit/layerTimePolicy.spec.js b/tests/unit/layerTimePolicy.spec.js new file mode 100644 index 000000000..cc0145f73 --- /dev/null +++ b/tests/unit/layerTimePolicy.spec.js @@ -0,0 +1,252 @@ +import { describe, test, expect } from 'vitest' +import { + resolveTimePolicy, + resolveTemporalExtent, + parseISODuration, +} from '../../src/essence/Basics/TimeControl_/layerTimePolicy' + +// Injected "now" so results are exact: mid-afternoon UTC. +const NOW = new Date('2026-08-25T15:42:31.500Z') + +describe('layer time policy', () => { + describe('parseISODuration', () => { + test.each([ + ['P1D', { days: 1 }], + ['PT1H', { hours: 1 }], + ['P1M', { months: 1 }], + ['P2W', { weeks: 2 }], + ['P1DT12H', { days: 1, hours: 12 }], + ])('parses %s', (value, expected) => { + expect(parseISODuration(value)).toMatchObject(expected) + }) + + test.each([['garbage'], ['P'], ['1D'], ['']])('rejects %s', (value) => { + expect(parseISODuration(value)).toBeNull() + }) + }) + + describe('resolveTimePolicy', () => { + test('concrete ISO datetimes pass through, normalized', () => { + expect( + resolveTimePolicy('2025-01-12T23:59:59+00:00', { now: NOW }), + ).toBe('2025-01-12T23:59:59Z') + }) + + test('absent or unparseable values resolve to null', () => { + expect(resolveTimePolicy(null, { now: NOW })).toBeNull() + expect(resolveTimePolicy(undefined, { now: NOW })).toBeNull() + expect(resolveTimePolicy('', { now: NOW })).toBeNull() + expect(resolveTimePolicy('not-a-date', { now: NOW })).toBeNull() + expect(resolveTimePolicy('now - garbage', { now: NOW })).toBeNull() + }) + + test('"now" is the raw current moment — no rounding (veda-ui rule)', () => { + expect(resolveTimePolicy('now', { now: NOW })).toBe( + '2026-08-25T15:42:31Z', + ) + }) + + test('offsets: "now - P1D" and forecast-style "now + P5D"', () => { + expect(resolveTimePolicy('now - P1D', { now: NOW })).toBe( + '2026-08-24T15:42:31Z', + ) + expect(resolveTimePolicy('now + P5D', { now: NOW })).toBe( + '2026-08-30T15:42:31Z', + ) + }) + + test('sub-day and calendar-unit offsets use date math, not ms math', () => { + expect(resolveTimePolicy('now - PT6H', { now: NOW })).toBe( + '2026-08-25T09:42:31Z', + ) + // One month back from late August is late July — a fixed-ms + // implementation would drift. + expect(resolveTimePolicy('now - P1M', { now: NOW })).toBe( + '2026-07-25T15:42:31Z', + ) + }) + + test('spacing around the sign is flexible', () => { + expect(resolveTimePolicy('now-P1D', { now: NOW })).toBe( + '2026-08-24T15:42:31Z', + ) + expect(resolveTimePolicy('now + P1D', { now: NOW })).toBe( + '2026-08-26T15:42:31Z', + ) + }) + }) + + describe('resolveTemporalExtent', () => { + test('without an interval both policies resolve, nothing snaps', () => { + expect( + resolveTemporalExtent( + { + dataStartTime: '2026-08-15T00:00:00Z', + dataEndTime: 'now', + }, + { now: NOW }, + ), + ).toEqual({ + start: '2026-08-15T00:00:00Z', + end: '2026-08-25T15:42:31Z', + }) + }) + + test('a 7-day cadence begun ten days ago ends three days ago, not now', () => { + // NOW is Aug 25; steps land Aug 15, Aug 22, (Aug 29 is future). + expect( + resolveTemporalExtent( + { + dataStartTime: '2026-08-15T00:00:00Z', + dataEndTime: 'now', + interval: 'P7D', + }, + { now: NOW }, + ), + ).toEqual({ + start: '2026-08-15T00:00:00Z', + end: '2026-08-22T00:00:00Z', + }) + }) + + test('an end exactly on a step stays put', () => { + expect( + resolveTemporalExtent( + { + dataStartTime: '2026-08-01T00:00:00Z', + dataEndTime: '2026-08-15T00:00:00Z', + interval: 'P7D', + }, + { now: NOW }, + ), + ).toEqual({ + start: '2026-08-01T00:00:00Z', + end: '2026-08-15T00:00:00Z', + }) + }) + + test('a concrete off-step end also floors — no data exists there', () => { + expect( + resolveTemporalExtent( + { + dataStartTime: '2026-08-01T00:00:00Z', + dataEndTime: '2026-08-20T00:00:00Z', + interval: 'P7D', + }, + { now: NOW }, + ), + ).toEqual({ + start: '2026-08-01T00:00:00Z', + end: '2026-08-15T00:00:00Z', + }) + }) + + test('monthly cadence steps by calendar months, not fixed ms', () => { + expect( + resolveTemporalExtent( + { + dataStartTime: '2026-01-15T00:00:00Z', + dataEndTime: 'now', + interval: 'P1M', + }, + { now: NOW }, + ), + ).toEqual({ + start: '2026-01-15T00:00:00Z', + end: '2026-08-15T00:00:00Z', + }) + }) + + test('monthly cadence stays anchored to the start day-of-month', () => { + // Jan 31 + N months in one calendar operation: Jul 31 fits, + // Aug 31 is past NOW. Iterated stepping would have drifted + // to Mar 3 at the first short month. + expect( + resolveTemporalExtent( + { + dataStartTime: '2026-01-31T00:00:00Z', + dataEndTime: 'now', + interval: 'P1M', + }, + { now: NOW }, + ), + ).toEqual({ + start: '2026-01-31T00:00:00Z', + end: '2026-07-31T00:00:00Z', + }) + }) + + test('policy start anchors the grid too', () => { + // start = now - P1M = Jul 25 15:42:31; weekly steps reach + // Aug 22 15:42:31 before overshooting NOW. + expect( + resolveTemporalExtent( + { + dataStartTime: 'now - P1M', + dataEndTime: 'now', + interval: 'P7D', + }, + { now: NOW }, + ), + ).toEqual({ + start: '2026-07-25T15:42:31Z', + end: '2026-08-22T15:42:31Z', + }) + }) + + test('an end before the start clamps to the start', () => { + expect( + resolveTemporalExtent( + { + dataStartTime: '2026-08-20T00:00:00Z', + dataEndTime: '2026-08-10T00:00:00Z', + interval: 'P1D', + }, + { now: NOW }, + ), + ).toEqual({ + start: '2026-08-20T00:00:00Z', + end: '2026-08-20T00:00:00Z', + }) + }) + + test.each([['every tuesday'], ['P0D'], ['PT0S']])( + 'interval %s is ignored, never breaking the extent', + (interval) => { + expect( + resolveTemporalExtent( + { + dataStartTime: '2026-08-15T00:00:00Z', + dataEndTime: 'now', + interval, + }, + { now: NOW }, + ), + ).toEqual({ + start: '2026-08-15T00:00:00Z', + end: '2026-08-25T15:42:31Z', + }) + }, + ) + + test('an interval without a start has no anchor — end unsnapped', () => { + expect( + resolveTemporalExtent( + { dataEndTime: 'now', interval: 'P7D' }, + { now: NOW }, + ), + ).toEqual({ start: null, end: '2026-08-25T15:42:31Z' }) + }) + + test('an absent time block resolves to a null extent', () => { + expect(resolveTemporalExtent(undefined, { now: NOW })).toEqual({ + start: null, + end: null, + }) + expect(resolveTemporalExtent(null, { now: NOW })).toEqual({ + start: null, + end: null, + }) + }) + }) +})