From f982b4ba89b7e6308cc10c68bb0c16e1a068090f Mon Sep 17 00:00:00 2001 From: Sean Lynch Date: Tue, 11 Aug 2026 12:29:49 -0400 Subject: [PATCH 1/3] =?UTF-8?q?fix(date):=20Correct=20`utcToLocalDate()`?= =?UTF-8?q?=20shifting=20the=20year=20by=20=C2=B11=20near=20a=20year=20bou?= =?UTF-8?q?ndary,=20and=20preserve=20milliseconds=20in=20both=20`utcToLoca?= =?UTF-8?q?lDate()`=20and=20`localToUtcDate()`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changeset/hungry-eagles-repeat.md | 5 + packages/utils/src/lib/date.test.ts | 18 ++++ packages/utils/src/lib/date.timezones.test.ts | 92 +++++++++++++++++++ packages/utils/src/lib/date.ts | 16 +++- 4 files changed, 128 insertions(+), 3 deletions(-) create mode 100644 .changeset/hungry-eagles-repeat.md create mode 100644 packages/utils/src/lib/date.timezones.test.ts diff --git a/.changeset/hungry-eagles-repeat.md b/.changeset/hungry-eagles-repeat.md new file mode 100644 index 0000000..5479f82 --- /dev/null +++ b/.changeset/hungry-eagles-repeat.md @@ -0,0 +1,5 @@ +--- +'@layerstack/utils': patch +--- + +fix(date): Correct `utcToLocalDate()` shifting the year by ±1 near a year boundary, and preserve milliseconds in both `utcToLocalDate()` and `localToUtcDate()` diff --git a/packages/utils/src/lib/date.test.ts b/packages/utils/src/lib/date.test.ts index 0ee0275..ec0000f 100644 --- a/packages/utils/src/lib/date.test.ts +++ b/packages/utils/src/lib/date.test.ts @@ -1145,6 +1145,20 @@ describe('utcToLocalDate()', () => { const localDate = utcToLocalDate(utcDate); expect(localDate.toISOString()).equal('2023-11-21T08:00:00.000Z'); }); + + it('keeps the year when the local date falls in the previous UTC year', () => { + // Regression: `setUTCFullYear()` was applied to a date built from *local* fields. Late on + // Dec 31 in this zone the constructed date has already rolled into the next UTC year, so + // forcing the UTC year back to 2026 pulled the local date back to Dec 31 2025. + const localDate = utcToLocalDate(new Date('2026-12-31T23:59:59.999Z')); + expect(localDate.getFullYear()).equal(2026); + expect(localDate.getMonth()).equal(11); + expect(localDate.getDate()).equal(31); + }); + + it('preserves milliseconds', () => { + expect(utcToLocalDate(new Date('2023-11-21T00:00:00.123Z')).getMilliseconds()).equal(123); + }); }); describe('localToUtcDate()', () => { @@ -1165,6 +1179,10 @@ describe('localToUtcDate()', () => { const utcDate = localToUtcDate(localDate); expect(utcDate.toISOString()).equal('2023-11-21T04:00:00.000Z'); }); + + it('preserves milliseconds', () => { + expect(localToUtcDate(new Date('2023-11-21T00:00:00.123Z')).getUTCMilliseconds()).equal(123); + }); }); describe('getMonthDaysByWeek()', () => { diff --git a/packages/utils/src/lib/date.timezones.test.ts b/packages/utils/src/lib/date.timezones.test.ts new file mode 100644 index 0000000..eba6a13 --- /dev/null +++ b/packages/utils/src/lib/date.timezones.test.ts @@ -0,0 +1,92 @@ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { localToUtcDate, utcToLocalDate } from './date.js'; + +/** + * Timezone-independent invariants for `utcToLocalDate()` / `localToUtcDate()`. + * + * The assertions in `date.test.ts` are written against the single offset the suite runs under + * (`TZ=UTC+4`, which POSIX inverts to UTC-4), so they can only ever exercise one side of the + * world — which is how a year-boundary bug in `utcToLocalDate()` survived. Everything here + * must hold in *any* zone, so each block re-runs the same assertions under a different one. + * + * Node (>= 16) applies `process.env.TZ` at runtime, so no separate test command or CI step is + * needed — `pnpm test:unit` covers the whole matrix. + */ +const TIMEZONES = [ + 'UTC', + 'America/New_York', // UTC-5/-4, northern DST + 'Asia/Tokyo', // UTC+9, no DST + 'Australia/Sydney', // UTC+10/+11, southern DST + 'Pacific/Kiritimati', // UTC+14, furthest ahead + 'Pacific/Midway', // UTC-11, furthest behind +]; + +/** + * Instants on the boundaries where naive conversions break. + * + * Both functions rebuild a date from calendar fields, which is inherently lossy across a DST + * spring-forward gap (the reconstructed wall clock doesn't exist and JS silently shifts it). + * Keep these clear of 00:00-04:00, the window transitions land in, or the round-trips below + * will fail for reasons that aren't a bug in the conversion. + */ +const INSTANTS = [ + '2026-08-10T12:00:00.000Z', // midday + '2026-08-10T23:59:59.999Z', // last ms of the UTC day — next day east of UTC + '2024-01-01T12:00:00.000Z', // year boundary + '2026-12-31T23:59:59.999Z', // year boundary + '2026-03-08T12:30:00.000Z', // northern DST transition day + '2026-11-01T12:30:00.000Z', // northern DST transition day + '2026-04-05T12:30:00.000Z', // southern DST transition day + '2026-06-15T12:34:56.789Z', // sub-second precision +]; + +describe.each(TIMEZONES)('TZ=%s', (timeZone) => { + const original = process.env.TZ; + beforeAll(() => { + process.env.TZ = timeZone; + }); + afterAll(() => { + process.env.TZ = original; + }); + + describe('utcToLocalDate()', () => { + it.each(INSTANTS)('reads UTC calendar fields back as local fields (%s)', (iso) => { + const utc = new Date(iso); + const local = utcToLocalDate(utc); + + expect(local.getFullYear()).equal(utc.getUTCFullYear()); + expect(local.getMonth()).equal(utc.getUTCMonth()); + expect(local.getDate()).equal(utc.getUTCDate()); + expect(local.getHours()).equal(utc.getUTCHours()); + expect(local.getMinutes()).equal(utc.getUTCMinutes()); + expect(local.getSeconds()).equal(utc.getUTCSeconds()); + expect(local.getMilliseconds()).equal(utc.getUTCMilliseconds()); + }); + }); + + describe('localToUtcDate()', () => { + it.each(INSTANTS)('reads local calendar fields back as UTC fields (%s)', (iso) => { + const local = new Date(iso); + const utc = localToUtcDate(local); + + expect(utc.getUTCFullYear()).equal(local.getFullYear()); + expect(utc.getUTCMonth()).equal(local.getMonth()); + expect(utc.getUTCDate()).equal(local.getDate()); + expect(utc.getUTCHours()).equal(local.getHours()); + expect(utc.getUTCMinutes()).equal(local.getMinutes()); + expect(utc.getUTCSeconds()).equal(local.getSeconds()); + expect(utc.getUTCMilliseconds()).equal(local.getMilliseconds()); + }); + }); + + describe('round-trip', () => { + it.each(INSTANTS)('localToUtcDate(utcToLocalDate(d)) === d (%s)', (iso) => { + expect(localToUtcDate(utcToLocalDate(new Date(iso))).toISOString()).equal(iso); + }); + + it.each(INSTANTS)('utcToLocalDate(localToUtcDate(d)) === d (%s)', (iso) => { + const date = new Date(iso); + expect(utcToLocalDate(localToUtcDate(date)).getTime()).equal(date.getTime()); + }); + }); +}); diff --git a/packages/utils/src/lib/date.ts b/packages/utils/src/lib/date.ts index eef78c1..280d01c 100644 --- a/packages/utils/src/lib/date.ts +++ b/packages/utils/src/lib/date.ts @@ -865,9 +865,15 @@ export function utcToLocalDate(date: Date | string | null | undefined) { date.getUTCDate(), date.getUTCHours(), date.getUTCMinutes(), - date.getUTCSeconds() + date.getUTCSeconds(), + date.getUTCMilliseconds() ); - d.setUTCFullYear(date.getUTCFullYear()); + // `new Date(year, ...)` maps years 0-99 onto 1900-1999; restore the intended year. + // Must be `setFullYear`, not `setUTCFullYear`: `d` was built from *local* fields, so near a + // year boundary its UTC year differs from its local year and setting the UTC one shifts the + // date by a full year (e.g. `2024-01-01T00:00Z` -> Jan 1 2025 in Asia/Tokyo, and + // `2026-12-31T23:59:59.999Z` -> Dec 31 2025 in America/New_York). + d.setFullYear(date.getUTCFullYear()); return d; } @@ -886,9 +892,13 @@ export function localToUtcDate(date: Date | string | null | undefined) { date.getDate(), date.getHours(), date.getMinutes(), - date.getSeconds() + date.getSeconds(), + date.getMilliseconds() ) ); + // `Date.UTC` applies the same 0-99 year mapping as `new Date(year, ...)`. Here `d` is built + // from UTC fields, so `setUTCFullYear` is the correct counterpart to `utcToLocalDate`. + d.setUTCFullYear(date.getFullYear()); return d; } From 1be2f375f5a7f20087d27847a1198486cd4b1b2b Mon Sep 17 00:00:00 2001 From: Sean Lynch Date: Tue, 11 Aug 2026 12:49:52 -0400 Subject: [PATCH 2/3] feat(date): Add UTC time intervals (`'utcDay'`, `'utcMonth'`, ...) to `TimeIntervalType`, usable anywhere an interval name is accepted (`startOfInterval('utcDay', date)`, `intervalOffset('utcMonth', date, -1)`, ...), along with a `utcQuarter` interval --- .changeset/olive-moons-invite.md | 5 + packages/utils/src/lib/date.timezones.test.ts | 95 ++++++++++++++++++- packages/utils/src/lib/date.ts | 56 ++++++++++- packages/utils/src/lib/date_types.ts | 12 ++- 4 files changed, 164 insertions(+), 4 deletions(-) create mode 100644 .changeset/olive-moons-invite.md diff --git a/.changeset/olive-moons-invite.md b/.changeset/olive-moons-invite.md new file mode 100644 index 0000000..177aa29 --- /dev/null +++ b/.changeset/olive-moons-invite.md @@ -0,0 +1,5 @@ +--- +'@layerstack/utils': patch +--- + +feat(date): Add UTC time intervals (`'utcDay'`, `'utcMonth'`, ...) to `TimeIntervalType`, usable anywhere an interval name is accepted (`startOfInterval('utcDay', date)`, `intervalOffset('utcMonth', date, -1)`, ...), along with a `utcQuarter` interval diff --git a/packages/utils/src/lib/date.timezones.test.ts b/packages/utils/src/lib/date.timezones.test.ts index eba6a13..bf7c713 100644 --- a/packages/utils/src/lib/date.timezones.test.ts +++ b/packages/utils/src/lib/date.timezones.test.ts @@ -1,8 +1,17 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import { localToUtcDate, utcToLocalDate } from './date.js'; +import { + endOfInterval, + intervalOffset, + localToUtcDate, + startOfInterval, + timeInterval, + utcToLocalDate, +} from './date.js'; +import type { TimeIntervalType } from './date_types.js'; /** - * Timezone-independent invariants for `utcToLocalDate()` / `localToUtcDate()`. + * Timezone-independent invariants: the `utcToLocalDate()` / `localToUtcDate()` pair, and the + * `utc*` time intervals. * * The assertions in `date.test.ts` are written against the single offset the suite runs under * (`TZ=UTC+4`, which POSIX inverts to UTC-4), so they can only ever exercise one side of the @@ -89,4 +98,86 @@ describe.each(TIMEZONES)('TZ=%s', (timeZone) => { expect(utcToLocalDate(localToUtcDate(date)).getTime()).equal(date.getTime()); }); }); + + describe('utc intervals', () => { + // The whole point of the `utc*` names: identical results in every zone. + const date = new Date('2026-08-10T17:43:12.500Z'); + + it('startOfInterval() floors to the UTC boundary', () => { + expect(startOfInterval('utcDay', date).toISOString()).equal('2026-08-10T00:00:00.000Z'); + expect(startOfInterval('utcMonth', date).toISOString()).equal('2026-08-01T00:00:00.000Z'); + expect(startOfInterval('utcQuarter', date).toISOString()).equal('2026-07-01T00:00:00.000Z'); + expect(startOfInterval('utcYear', date).toISOString()).equal('2026-01-01T00:00:00.000Z'); + }); + + it('endOfInterval() returns the last ms of the UTC interval', () => { + expect(endOfInterval('utcDay', date).toISOString()).equal('2026-08-10T23:59:59.999Z'); + expect(endOfInterval('utcMonth', date).toISOString()).equal('2026-08-31T23:59:59.999Z'); + expect(endOfInterval('utcQuarter', date).toISOString()).equal('2026-09-30T23:59:59.999Z'); + expect(endOfInterval('utcYear', date).toISOString()).equal('2026-12-31T23:59:59.999Z'); + }); + + it('intervalOffset() shifts by whole UTC intervals', () => { + const utcMidnight = new Date('2026-08-10T00:00:00.000Z'); + expect(intervalOffset('utcDay', utcMidnight, -6).toISOString()).equal( + '2026-08-04T00:00:00.000Z' + ); + // `offset` shifts, it does not floor — the day of month is preserved. + expect(intervalOffset('utcMonth', utcMidnight, 1).toISOString()).equal( + '2026-09-10T00:00:00.000Z' + ); + }); + + it('offsets across a DST transition without shifting the time of day', () => { + // `'day'` crosses *local* day boundaries, so a local DST transition inside the range + // moves the UTC time of day. `'utcDay'` must not. + const beforeSpringForward = new Date('2026-03-05T00:00:00.000Z'); + expect(intervalOffset('utcDay', beforeSpringForward, 7).toISOString()).equal( + '2026-03-12T00:00:00.000Z' + ); + }); + + it.each([ + ['millisecond', 'utcMillisecond'], + ['second', 'utcSecond'], + ['minute', 'utcMinute'], + ['hour', 'utcHour'], + ['day', 'utcDay'], + ['week', 'utcWeek'], + ['month', 'utcMonth'], + ['quarter', 'utcQuarter'], + ['year', 'utcYear'], + ] as [TimeIntervalType, TimeIntervalType][])( + 'timeInterval() resolves both %s and %s', + (local, utc) => { + expect(timeInterval(local)).toBeDefined(); + expect(timeInterval(utc)).toBeDefined(); + } + ); + + it.each([ + 'utcMinute', + 'utcHour', + 'utcDay', + 'utcWeek', + 'utcMonth', + 'utcQuarter', + 'utcYear', + ] as TimeIntervalType[])('timeInterval(%s) is distinct from its local counterpart', (utc) => { + // `utcMillisecond`/`utcSecond` are excluded: d3 aliases them to the local intervals, + // since those boundaries don't depend on the timezone. + const local = (utc.charAt(3).toLowerCase() + utc.slice(4)) as TimeIntervalType; + expect(timeInterval(utc)).not.toBe(timeInterval(local)); + }); + + it.each([ + ['utcDay', '2026-08-10T00:00:00.000Z'], + ['utcWeek', '2026-08-09T00:00:00.000Z'], // d3 weeks start Sunday + ['utcMonth', '2026-08-01T00:00:00.000Z'], + ['utcQuarter', '2026-07-01T00:00:00.000Z'], + ['utcYear', '2026-01-01T00:00:00.000Z'], + ] as [TimeIntervalType, string][])('%s floors to %s in every zone', (interval, expected) => { + expect(startOfInterval(interval, date).toISOString()).equal(expected); + }); + }); }); diff --git a/packages/utils/src/lib/date.ts b/packages/utils/src/lib/date.ts index 280d01c..1aac0f8 100644 --- a/packages/utils/src/lib/date.ts +++ b/packages/utils/src/lib/date.ts @@ -16,6 +16,14 @@ import { timeThursday, timeFriday, timeSaturday, + utcDay, + utcHour, + utcMillisecond, + utcMinute, + utcMonth, + utcSecond, + utcWeek, + utcYear, } from 'd3-time'; import { timeFormat, timeParse } from 'd3-time-format'; import { min, max } from 'd3-array'; @@ -1000,7 +1008,34 @@ export const timeQuarter = d3TimeInterval( (date) => date.getMonth() // TODO: what should this be? ); -/** Get a time interval function by name */ +/** + * Custom time interval for quarters, in UTC. + * + * The UTC counterpart of {@link timeQuarter} — d3-time has no quarter interval of either + * kind, so this mirrors the local implementation using the UTC accessors. + */ +export const utcQuarter = d3TimeInterval( + // floor + (date) => { + date.setUTCMonth(date.getUTCMonth() - (date.getUTCMonth() % 3), 1); + date.setUTCHours(0, 0, 0, 0); + }, + // offset + (date, step) => date.setUTCMonth(date.getUTCMonth() + step * 3, 1), + // count + (start, end) => (end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24 * 30 * 3), + // field + (date) => date.getUTCMonth() // TODO: what should this be? +); + +/** + * Get a time interval function by name. + * + * Every interval has a `utc`-prefixed counterpart (`'utcDay'`, `'utcMonth'`, ...) that floors + * and offsets on UTC boundaries instead of local ones, so it is unaffected by the ambient + * timezone or by DST. Because these are plain names, they work anywhere an interval name is + * accepted — `startOfInterval('utcDay', date)`, `intervalOffset('utcMonth', date, -1)`, etc. + */ export function timeInterval(name: TimeIntervalType) { switch (name) { case 'millisecond': @@ -1021,6 +1056,25 @@ export function timeInterval(name: TimeIntervalType) { return timeQuarter; case 'year': return timeYear; + + case 'utcMillisecond': + return utcMillisecond; + case 'utcSecond': + return utcSecond; + case 'utcMinute': + return utcMinute; + case 'utcHour': + return utcHour; + case 'utcDay': + return utcDay; + case 'utcWeek': + return utcWeek; + case 'utcMonth': + return utcMonth; + case 'utcQuarter': + return utcQuarter; + case 'utcYear': + return utcYear; } } diff --git a/packages/utils/src/lib/date_types.ts b/packages/utils/src/lib/date_types.ts index 037125f..c83fcc5 100644 --- a/packages/utils/src/lib/date_types.ts +++ b/packages/utils/src/lib/date_types.ts @@ -102,7 +102,8 @@ export const periodTypeMappings = { export type PeriodTypeCode = ValueOf; -export type TimeIntervalType = +/** Time intervals that floor and offset on *local* calendar boundaries. */ +export type LocalTimeIntervalType = | 'millisecond' | 'second' | 'minute' @@ -113,6 +114,15 @@ export type TimeIntervalType = | 'quarter' | 'year'; +/** + * UTC counterparts of {@link LocalTimeIntervalType}, floored and offset on UTC boundaries and + * so unaffected by the ambient timezone or by DST. Use these for values keyed on a UTC + * calendar date. Named to match d3-time's exports (`utcDay`, `utcMonth`, ...). + */ +export type UtcTimeIntervalType = `utc${Capitalize}`; + +export type TimeIntervalType = LocalTimeIntervalType | UtcTimeIntervalType; + export enum DayOfWeek { Sunday = 0, Monday = 1, From 8ed0de17863140682460b61fe04c674743a954af Mon Sep 17 00:00:00 2001 From: Sean Lynch Date: Tue, 11 Aug 2026 13:54:06 -0400 Subject: [PATCH 3/3] feat(date): Add a `utc` option to date utils (ex. `formatDate()`, `formatIntl()`, etc) so period math and display can both run on UTC boundaries --- .changeset/tidy-donkeys-smell.md | 5 + packages/utils/src/lib/date.timezones.test.ts | 115 ++++- packages/utils/src/lib/date.ts | 400 ++++++++++-------- packages/utils/src/lib/dateRange.ts | 18 +- packages/utils/src/lib/date_types.ts | 5 + 5 files changed, 362 insertions(+), 181 deletions(-) create mode 100644 .changeset/tidy-donkeys-smell.md diff --git a/.changeset/tidy-donkeys-smell.md b/.changeset/tidy-donkeys-smell.md new file mode 100644 index 0000000..7842917 --- /dev/null +++ b/.changeset/tidy-donkeys-smell.md @@ -0,0 +1,5 @@ +--- +'@layerstack/utils': patch +--- + +feat(date): Add a `utc` option to date utils (ex. `formatDate()`, `formatIntl()`, etc) so period math and display can both run on UTC boundaries diff --git a/packages/utils/src/lib/date.timezones.test.ts b/packages/utils/src/lib/date.timezones.test.ts index bf7c713..ab6ee29 100644 --- a/packages/utils/src/lib/date.timezones.test.ts +++ b/packages/utils/src/lib/date.timezones.test.ts @@ -1,13 +1,18 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { endOfInterval, + formatDate, + getDateFuncsByPeriodType, + getMonthDaysByWeek, intervalOffset, localToUtcDate, startOfInterval, timeInterval, utcToLocalDate, } from './date.js'; -import type { TimeIntervalType } from './date_types.js'; +import { getDateRangePresets } from './dateRange.js'; +import { PeriodType, type TimeIntervalType } from './date_types.js'; +import { defaultLocale } from './locale.js'; /** * Timezone-independent invariants: the `utcToLocalDate()` / `localToUtcDate()` pair, and the @@ -180,4 +185,112 @@ describe.each(TIMEZONES)('TZ=%s', (timeZone) => { expect(startOfInterval(interval, date).toISOString()).equal(expected); }); }); + + describe('{ utc: true }', () => { + // `2026-08-10T00:00:00Z` is the previous day west of UTC and midday-ish east of it, so any + // helper that leaks local time reports a different calendar day in at least one zone. + const date = new Date('2026-08-10T00:00:00.000Z'); + + describe('formatDate()', () => { + it('renders the UTC calendar day for a unicode/strftime format', () => { + expect(formatDate(date, 'yyyy-MM-dd', { utc: true })).equal('2026-08-10'); + }); + + it('renders the UTC calendar day for a period type', () => { + expect(formatDate(date, PeriodType.Day, { utc: true, variant: 'short' })).equal('8/10'); + }); + + it('differs from local formatting exactly when the UTC day differs', () => { + // Proves `utc` actually takes effect: it must change the output wherever the local and + // UTC calendar days disagree, and leave it alone where they agree. + const sameCalendarDay = date.getDate() === date.getUTCDate(); + const asUtc = formatDate(date, PeriodType.Day, { utc: true, variant: 'short' }); + const asLocal = formatDate(date, PeriodType.Day, { variant: 'short' }); + expect(asUtc === asLocal).equal(sameCalendarDay); + }); + + it('renders the UTC month/year', () => { + expect(formatDate(date, PeriodType.MonthYear, { utc: true })).contains('2026'); + expect( + formatDate(new Date('2026-12-31T23:59:59.999Z'), PeriodType.CalendarYear, { + utc: true, + }) + ).equal('2026'); + }); + + it('honours an explicit timeZone in custom Intl options over utc', () => { + const formatted = formatDate(date, PeriodType.Custom, { + utc: true, + custom: { year: 'numeric', month: '2-digit', day: '2-digit', timeZone: 'UTC' }, + }); + expect(formatted).equal('08/10/2026'); + }); + }); + + describe('getDateFuncsByPeriodType()', () => { + it('floors a day on the UTC boundary', () => { + const { start, end } = getDateFuncsByPeriodType(defaultLocale, PeriodType.Day, { + utc: true, + }); + expect(start(date).toISOString()).equal('2026-08-10T00:00:00.000Z'); + expect(end(date).toISOString()).equal('2026-08-10T23:59:59.999Z'); + }); + + it('floors a week on the UTC boundary', () => { + const { start, end } = getDateFuncsByPeriodType(defaultLocale, PeriodType.WeekMon, { + utc: true, + }); + // 2026-08-10 is a Monday + expect(start(date).toISOString()).equal('2026-08-10T00:00:00.000Z'); + expect(end(date).toISOString()).equal('2026-08-16T23:59:59.999Z'); + }); + + it('floors a month/quarter/year on the UTC boundary', () => { + for (const [periodType, from, to] of [ + [PeriodType.Month, '2026-08-01T00:00:00.000Z', '2026-08-31T23:59:59.999Z'], + [PeriodType.Quarter, '2026-07-01T00:00:00.000Z', '2026-09-30T23:59:59.999Z'], + [PeriodType.CalendarYear, '2026-01-01T00:00:00.000Z', '2026-12-31T23:59:59.999Z'], + ] as const) { + const { start, end } = getDateFuncsByPeriodType(defaultLocale, periodType, { utc: true }); + expect(start(date).toISOString()).equal(from); + expect(end(date).toISOString()).equal(to); + } + }); + + it('floors a fiscal year on the UTC boundary', () => { + const { start, end } = getDateFuncsByPeriodType( + defaultLocale, + PeriodType.FiscalYearOctober, + { utc: true } + ); + expect(start(date).toISOString()).equal('2025-10-01T00:00:00.000Z'); + expect(end(date).toISOString()).equal('2026-09-30T23:59:59.999Z'); + }); + + it('floors a bi-week on the UTC boundary', () => { + const { start, end } = getDateFuncsByPeriodType(defaultLocale, PeriodType.BiWeek1Sun, { + utc: true, + }); + // Whatever the bi-week grid resolves to, both ends must land on UTC midnight/end-of-day + expect(start(date).toISOString()).match(/T00:00:00\.000Z$/); + expect(end(date).toISOString()).match(/T00:00:00\.000Z$/); + }); + }); + + it('getMonthDaysByWeek() returns UTC-midnight days', () => { + const weeks = getMonthDaysByWeek(date, 0, { utc: true }); + for (const day of weeks.flat()) { + expect(day.toISOString()).match(/T00:00:00\.000Z$/); + } + }); + + it('getDateRangePresets() derives presets from UTC boundaries', () => { + const presets = getDateRangePresets(defaultLocale, PeriodType.Day, { utc: true }); + expect(presets.length).toBeGreaterThan(0); + for (const { value } of presets) { + expect(value.from!.toISOString()).match(/T00:00:00\.000Z$/); + expect(value.to!.toISOString()).match(/T23:59:59\.999Z$/); + } + }); + }); }); diff --git a/packages/utils/src/lib/date.ts b/packages/utils/src/lib/date.ts index 1aac0f8..9adf962 100644 --- a/packages/utils/src/lib/date.ts +++ b/packages/utils/src/lib/date.ts @@ -17,15 +17,21 @@ import { timeFriday, timeSaturday, utcDay, + utcFriday, utcHour, utcMillisecond, utcMinute, + utcMonday, utcMonth, + utcSaturday, utcSecond, + utcThursday, + utcTuesday, + utcWednesday, utcWeek, utcYear, } from 'd3-time'; -import { timeFormat, timeParse } from 'd3-time-format'; +import { timeFormat, timeParse, utcFormat } from 'd3-time-format'; import { min, max } from 'd3-array'; import { hasKeyOf } from './typeGuards.js'; @@ -211,17 +217,29 @@ export function getMonths(year = new Date().getFullYear()) { export function getMonthDaysByWeek( dateInTheMonth: Date, - weekStartsOn: DayOfWeek = DayOfWeek.Sunday + weekStartsOn: DayOfWeek = DayOfWeek.Sunday, + options?: { utc?: boolean } ): Date[][] { - const startOfFirstWeek = startOfWeek(startOfInterval('month', dateInTheMonth), weekStartsOn); - const endOfLastWeek = endOfWeek(endOfInterval('month', dateInTheMonth), weekStartsOn); + const monthInterval = options?.utc ? 'utcMonth' : 'month'; + const dayInterval = options?.utc ? 'utcDay' : 'day'; + + const startOfFirstWeek = startOfWeek( + startOfInterval(monthInterval, dateInTheMonth), + weekStartsOn, + options + ); + const endOfLastWeek = endOfWeek( + endOfInterval(monthInterval, dateInTheMonth), + weekStartsOn, + options + ); const list = []; let valueToAdd = startOfFirstWeek; while (valueToAdd <= endOfLastWeek) { list.push(valueToAdd); - valueToAdd = intervalOffset('day', valueToAdd, 1); + valueToAdd = intervalOffset(dayInterval, valueToAdd, 1); } return chunk(list, 7) as Date[][]; @@ -255,26 +273,38 @@ export function getMaxSelectedDate(date: SelectedDate | null | undefined) { * Fiscal Year */ -export function getFiscalYear(date: Date | null = new Date(), options?: { startMonth?: number }) { +export function getFiscalYear( + date: Date | null = new Date(), + options?: { startMonth?: number; utc?: boolean } +) { if (date === null) { // null explicitly passed in (default value overridden) return NaN; } const startMonth = (options && options.startMonth) || 10; - return date.getMonth() >= startMonth - 1 ? date.getFullYear() + 1 : date.getFullYear(); + const month = options?.utc ? date.getUTCMonth() : date.getMonth(); + const year = options?.utc ? date.getUTCFullYear() : date.getFullYear(); + return month >= startMonth - 1 ? year + 1 : year; } export function getFiscalYearRange( date = new Date(), - options?: { startMonth?: number; numberOfMonths?: number } + options?: { startMonth?: number; numberOfMonths?: number; utc?: boolean } ) { const fiscalYear = getFiscalYear(date, options); const startMonth = (options && options.startMonth) || 10; const numberOfMonths = (options && options.numberOfMonths) || 12; - - const startDate = new Date((fiscalYear || 0) - 1, startMonth - 1, 1); - const endDate = endOfInterval('month', intervalOffset('month', startDate, numberOfMonths - 1)); + const utc = options?.utc ?? false; + + const startDate = utc + ? new Date(Date.UTC((fiscalYear || 0) - 1, startMonth - 1, 1)) + : new Date((fiscalYear || 0) - 1, startMonth - 1, 1); + const monthInterval = utc ? 'utcMonth' : 'month'; + const endDate = endOfInterval( + monthInterval, + intervalOffset(monthInterval, startDate, numberOfMonths - 1) + ); return { startDate, endDate }; } @@ -287,8 +317,12 @@ export function endOfFiscalYear(date: Date, options?: Parameters[1] +) { + return getFiscalYear(dateLeft, options) === getFiscalYear(dateRight, options); } /* @@ -296,163 +330,148 @@ export function isSameFiscalYear(dateLeft: Date, dateRight: Date) { */ const biweekBaseDates = [new Date('1799-12-22T00:00'), new Date('1799-12-15T00:00')]; +/** UTC counterparts of `biweekBaseDates` (the strings above parse as *local* midnight) */ +const utcBiweekBaseDates = [new Date(Date.UTC(1799, 11, 22)), new Date(Date.UTC(1799, 11, 15))]; -export function startOfBiWeek(date: Date, week: number, startOfWeek: DayOfWeek) { - var weekBaseDate = biweekBaseDates[week - 1]; - var baseDate = intervalOffset('day', weekBaseDate, startOfWeek); - var periodsSince = Math.floor(intervalDifference('day', baseDate, date) / 14); - return intervalOffset('day', baseDate, periodsSince * 14); +export function startOfBiWeek( + date: Date, + week: number, + startOfWeek: DayOfWeek, + options?: { utc?: boolean } +) { + const dayInterval = options?.utc ? 'utcDay' : 'day'; + const weekBaseDate = (options?.utc ? utcBiweekBaseDates : biweekBaseDates)[week - 1]; + const baseDate = intervalOffset(dayInterval, weekBaseDate, startOfWeek); + const periodsSince = Math.floor(intervalDifference(dayInterval, baseDate, date) / 14); + return intervalOffset(dayInterval, baseDate, periodsSince * 14); } -export function endOfBiWeek(date: Date, week: number, startOfWeek: DayOfWeek) { - return intervalOffset('day', startOfBiWeek(date, week, startOfWeek), 13); +export function endOfBiWeek( + date: Date, + week: number, + startOfWeek: DayOfWeek, + options?: { utc?: boolean } +) { + return intervalOffset( + options?.utc ? 'utcDay' : 'day', + startOfBiWeek(date, week, startOfWeek, options), + 13 + ); } -function startOfWeek(date: Date, weekStartsOn: DayOfWeek) { +/** The d3 interval for a week beginning on `weekStartsOn`, local or UTC */ +function weekInterval(weekStartsOn: DayOfWeek, utc = false) { switch (weekStartsOn) { case DayOfWeek.Sunday: - return startOfInterval(timeWeek, date); + return utc ? utcWeek : timeWeek; case DayOfWeek.Monday: - return startOfInterval(timeMonday, date); + return utc ? utcMonday : timeMonday; case DayOfWeek.Tuesday: - return startOfInterval(timeTuesday, date); + return utc ? utcTuesday : timeTuesday; case DayOfWeek.Wednesday: - return startOfInterval(timeWednesday, date); + return utc ? utcWednesday : timeWednesday; case DayOfWeek.Thursday: - return startOfInterval(timeThursday, date); + return utc ? utcThursday : timeThursday; case DayOfWeek.Friday: - return startOfInterval(timeFriday, date); + return utc ? utcFriday : timeFriday; case DayOfWeek.Saturday: - return startOfInterval(timeSaturday, date); + return utc ? utcSaturday : timeSaturday; } } -function endOfWeek(date: Date, weekStartsOn: DayOfWeek) { - switch (weekStartsOn) { - case DayOfWeek.Sunday: - return endOfInterval(timeWeek, date); - case DayOfWeek.Monday: - return endOfInterval(timeMonday, date); - case DayOfWeek.Tuesday: - return endOfInterval(timeTuesday, date); - case DayOfWeek.Wednesday: - return endOfInterval(timeWednesday, date); - case DayOfWeek.Thursday: - return endOfInterval(timeThursday, date); - case DayOfWeek.Friday: - return endOfInterval(timeFriday, date); - case DayOfWeek.Saturday: - return endOfInterval(timeSaturday, date); - } +function startOfWeek(date: Date, weekStartsOn: DayOfWeek, options?: { utc?: boolean }) { + return startOfInterval(weekInterval(weekStartsOn, options?.utc), date); +} + +function endOfWeek(date: Date, weekStartsOn: DayOfWeek, options?: { utc?: boolean }) { + return endOfInterval(weekInterval(weekStartsOn, options?.utc), date); } +/** + * Get the start/end/add/difference/isSame functions for a period type. + * + * @param options.utc Operate on UTC boundaries instead of local ones, so results are + * unaffected by the ambient timezone or by DST. Note this changes only the *math* — pair it + * with `utc` on `formatDate()` to also render the UTC calendar fields. + */ export function getDateFuncsByPeriodType( settings: LocaleSettings, - periodType: PeriodType | null | undefined + periodType: PeriodType | null | undefined, + options?: { utc?: boolean } ) { if (settings) { periodType = updatePeriodTypeWithWeekStartsOn(settings.formats.dates.weekStartsOn, periodType); } + const utc = options?.utc ?? false; + const dayInterval = utc ? 'utcDay' : 'day'; + const weekIntervalName = utc ? 'utcWeek' : 'week'; + const monthInterval = utc ? 'utcMonth' : 'month'; + const quarterInterval = utc ? 'utcQuarter' : 'quarter'; + const yearInterval = utc ? 'utcYear' : 'year'; + switch (periodType) { case PeriodType.Day: return { - start: startOfInterval('day'), - end: endOfInterval('day'), - add: (date: Date, amount: number) => intervalOffset('day', date, amount), - difference: intervalDifference('day'), - isSame: isSameInterval('day'), + start: startOfInterval(dayInterval), + end: endOfInterval(dayInterval), + add: (date: Date, amount: number) => intervalOffset(dayInterval, date, amount), + difference: intervalDifference(dayInterval), + isSame: isSameInterval(dayInterval), }; case PeriodType.Week: case PeriodType.WeekSun: - return { - start: startOfInterval(timeWeek), - end: endOfInterval(timeWeek), - add: (date: Date, amount: number) => intervalOffset('week', date, amount), - difference: intervalDifference(timeWeek), - isSame: isSameInterval(timeWeek), - }; case PeriodType.WeekMon: - return { - start: startOfInterval(timeMonday), - end: endOfInterval(timeMonday), - add: (date: Date, amount: number) => intervalOffset('week', date, amount), - difference: intervalDifference(timeMonday), - isSame: isSameInterval(timeMonday), - }; case PeriodType.WeekTue: - return { - start: startOfInterval(timeTuesday), - end: endOfInterval(timeTuesday), - add: (date: Date, amount: number) => intervalOffset('week', date, amount), - difference: intervalDifference(timeTuesday), - isSame: isSameInterval(timeTuesday), - }; case PeriodType.WeekWed: - return { - start: startOfInterval(timeWednesday), - end: endOfInterval(timeWednesday), - add: (date: Date, amount: number) => intervalOffset('week', date, amount), - difference: intervalDifference(timeWednesday), - isSame: isSameInterval(timeWednesday), - }; case PeriodType.WeekThu: - return { - start: startOfInterval(timeThursday), - end: endOfInterval(timeThursday), - add: (date: Date, amount: number) => intervalOffset('week', date, amount), - difference: intervalDifference(timeThursday), - isSame: isSameInterval(timeThursday), - }; case PeriodType.WeekFri: + case PeriodType.WeekSat: { + // `PeriodType.Week` has no day of week of its own — it only reaches here when `settings` + // is absent (so `updatePeriodTypeWithWeekStartsOn` did not resolve it), and the previous + // behaviour was to fall through to Sunday. + const interval = weekInterval(getDayOfWeek(periodType) ?? DayOfWeek.Sunday, utc); return { - start: startOfInterval(timeFriday), - end: endOfInterval(timeFriday), - add: (date: Date, amount: number) => intervalOffset('week', date, amount), - difference: intervalDifference(timeFriday), - isSame: isSameInterval(timeFriday), - }; - case PeriodType.WeekSat: - return { - start: startOfInterval(timeSaturday), - end: endOfInterval(timeSaturday), - add: (date: Date, amount: number) => intervalOffset('week', date, amount), - difference: intervalDifference(timeSaturday), - isSame: isSameInterval(timeSaturday), + start: startOfInterval(interval), + end: endOfInterval(interval), + add: (date: Date, amount: number) => intervalOffset(weekIntervalName, date, amount), + difference: intervalDifference(interval), + isSame: isSameInterval(interval), }; + } case PeriodType.Month: return { - start: startOfInterval('month'), - end: endOfInterval('month'), - add: (date: Date, amount: number) => intervalOffset('month', date, amount), - difference: intervalDifference('month'), - isSame: isSameInterval('month'), + start: startOfInterval(monthInterval), + end: endOfInterval(monthInterval), + add: (date: Date, amount: number) => intervalOffset(monthInterval, date, amount), + difference: intervalDifference(monthInterval), + isSame: isSameInterval(monthInterval), }; case PeriodType.Quarter: return { - start: startOfInterval('quarter'), - end: endOfInterval('quarter'), - add: (date: Date, amount: number) => intervalOffset('quarter', date, amount), - difference: intervalDifference('quarter'), - isSame: isSameInterval('quarter'), + start: startOfInterval(quarterInterval), + end: endOfInterval(quarterInterval), + add: (date: Date, amount: number) => intervalOffset(quarterInterval, date, amount), + difference: intervalDifference(quarterInterval), + isSame: isSameInterval(quarterInterval), }; case PeriodType.CalendarYear: return { - start: startOfInterval('year'), - end: endOfInterval('year'), - add: (date: Date, amount: number) => intervalOffset('year', date, amount), - difference: intervalDifference('year'), - isSame: isSameInterval('year'), + start: startOfInterval(yearInterval), + end: endOfInterval(yearInterval), + add: (date: Date, amount: number) => intervalOffset(yearInterval, date, amount), + difference: intervalDifference(yearInterval), + isSame: isSameInterval(yearInterval), }; case PeriodType.FiscalYearOctober: return { - start: startOfFiscalYear, - end: endOfFiscalYear, - add: (date: Date, amount: number) => intervalOffset('year', date, amount), - difference: intervalDifference('year'), - isSame: isSameFiscalYear, + start: (date: Date) => startOfFiscalYear(date, { utc }), + end: (date: Date) => endOfFiscalYear(date, { utc }), + add: (date: Date, amount: number) => intervalOffset(yearInterval, date, amount), + difference: intervalDifference(yearInterval), + isSame: (dateLeft: Date, dateRight: Date) => isSameFiscalYear(dateLeft, dateRight, { utc }), }; // BiWeek 1 @@ -476,18 +495,18 @@ export function getDateFuncsByPeriodType( const week = getPeriodTypeCode(periodType).startsWith('BIWEEK1') ? 1 : 2; const dayOfWeek = getDayOfWeek(periodType)!; return { - start: (date: Date) => startOfBiWeek(date, week, dayOfWeek), - end: (date: Date) => endOfBiWeek(date, week, dayOfWeek), - add: (date: Date, amount: number) => intervalOffset('week', date, amount * 2), + start: (date: Date) => startOfBiWeek(date, week, dayOfWeek, { utc }), + end: (date: Date) => endOfBiWeek(date, week, dayOfWeek, { utc }), + add: (date: Date, amount: number) => intervalOffset(weekIntervalName, date, amount * 2), difference: (dateLeft: Date, dateRight: Date) => { // TODO: Use interval based on start of bi-week (sunday, monday, etc) - return intervalDifference('week', dateLeft, dateRight) / 2; + return intervalDifference(weekIntervalName, dateLeft, dateRight) / 2; }, isSame: (dateLeft: Date, dateRight: Date) => { return isSameInterval( - 'day', - startOfBiWeek(dateLeft, week, dayOfWeek), - startOfBiWeek(dateRight, week, dayOfWeek) + dayInterval, + startOfBiWeek(dateLeft, week, dayOfWeek, { utc }), + startOfBiWeek(dateRight, week, dayOfWeek, { utc }) ); }, }; @@ -507,11 +526,11 @@ export function getDateFuncsByPeriodType( case undefined: // Default to end of day if periodType == null, etc return { - start: startOfInterval('day'), - end: endOfInterval('day'), - add: (date: Date, amount: number) => intervalOffset('day', date, amount), - difference: intervalDifference('day'), - isSame: isSameInterval('day'), + start: startOfInterval(dayInterval), + end: endOfInterval(dayInterval), + add: (date: Date, amount: number) => intervalOffset(dayInterval, date, amount), + difference: intervalDifference(dayInterval), + isSame: isSameInterval(dayInterval), }; default: @@ -519,10 +538,17 @@ export function getDateFuncsByPeriodType( } } +/** + * Format a date with `Intl.DateTimeFormat`. + * + * @param options.utc Render the date's UTC calendar fields rather than the local ones. An + * explicit `timeZone` in `tokens_or_intlOptions` takes precedence. + */ export function formatIntl( settings: LocaleSettings, dt: Date, - tokens_or_intlOptions: CustomIntlDateTimeFormatOptions + tokens_or_intlOptions: CustomIntlDateTimeFormatOptions, + options?: { utc?: boolean } ) { const { locale, @@ -531,6 +557,8 @@ export function formatIntl( }, } = settings; + const timeZone = options?.utc ? 'UTC' : undefined; + function formatIntlOrdinal(formatter: Intl.DateTimeFormat, with_ordinal = false) { if (with_ordinal) { const rules = new Intl.PluralRules(locale, { type: 'ordinal' }); @@ -553,7 +581,10 @@ export function formatIntl( if (typeof tokens_or_intlOptions !== 'string' && !Array.isArray(tokens_or_intlOptions)) { return formatIntlOrdinal( - new Intl.DateTimeFormat(locale, tokens_or_intlOptions), + new Intl.DateTimeFormat(locale, { + timeZone, + ...tokens_or_intlOptions, + }), tokens_or_intlOptions.withOrdinal ); } @@ -564,6 +595,8 @@ export function formatIntl( // Order of includes check is important! (longest first) const formatter = new Intl.DateTimeFormat(locale, { + timeZone, + year: tokens.includes(DateToken.Year_numeric) ? 'numeric' : tokens.includes(DateToken.Year_2Digit) @@ -628,16 +661,23 @@ function range( date: Date, weekStartsOn: DayOfWeek, formatToUse: CustomIntlDateTimeFormatOptions, - biWeek: undefined | 1 | 2 = undefined // undefined means that it's not a bi-week + biWeek: undefined | 1 | 2 = undefined, // undefined means that it's not a bi-week + options?: { utc?: boolean } ) { const start = biWeek === undefined - ? startOfWeek(date, weekStartsOn) - : startOfBiWeek(date, biWeek, weekStartsOn); + ? startOfWeek(date, weekStartsOn, options) + : startOfBiWeek(date, biWeek, weekStartsOn, options); const end = - biWeek === undefined ? endOfWeek(date, weekStartsOn) : endOfBiWeek(date, biWeek, weekStartsOn); + biWeek === undefined + ? endOfWeek(date, weekStartsOn, options) + : endOfBiWeek(date, biWeek, weekStartsOn, options); - return formatIntl(settings, start, formatToUse) + ' - ' + formatIntl(settings, end, formatToUse); + return ( + formatIntl(settings, start, formatToUse, options) + + ' - ' + + formatIntl(settings, end, formatToUse, options) + ); } export function formatDate( @@ -660,7 +700,7 @@ export function formatDate( // console.log({ periodOrFormat, strftimeFormat }); } - return timeFormat(strftimeFormat)(date); + return options.utc ? utcFormat(strftimeFormat)(date) : timeFormat(strftimeFormat)(date); } return formatDateWithLocale( @@ -760,97 +800,107 @@ export function formatDateWithLocale( return preset[options.variant ?? 'default']; } + // Bound to a local so the narrowing above survives into the closures below (`date` is a + // reassigned parameter). `fmt`/`rng` exist to carry `utc` into every branch of the switch. + const dt = date; + const utcOptions = { utc: options.utc }; + const fmt = (value: Date, format: CustomIntlDateTimeFormatOptions) => + formatIntl(settings, value, format, utcOptions); + const rng = (weekStartsOn: DayOfWeek, format: CustomIntlDateTimeFormatOptions, biWeek?: 1 | 2) => + range(settings, dt, weekStartsOn, format, biWeek, utcOptions); + switch (periodType) { case PeriodType.Custom: - return formatIntl(settings, date, options.custom!); + return fmt(date, options.custom!); case PeriodType.Day: - return formatIntl(settings, date, rv(day!)!); + return fmt(date, rv(day!)!); case PeriodType.DayTime: - return formatIntl(settings, date, rv(dayTime!)!); + return fmt(date, rv(dayTime!)!); case PeriodType.TimeOnly: - return formatIntl(settings, date, rv(timeOnly!)!); + return fmt(date, rv(timeOnly!)!); case PeriodType.Hour: - return formatIntl(settings, date, rv(hour!)!); + return fmt(date, rv(hour!)!); case PeriodType.Minute: - return formatIntl(settings, date, rv(minute!)!); + return fmt(date, rv(minute!)!); case PeriodType.Second: - return formatIntl(settings, date, rv(second!)!); + return fmt(date, rv(second!)!); case PeriodType.Millisecond: - return formatIntl(settings, date, rv(millisecond!)!); + return fmt(date, rv(millisecond!)!); case PeriodType.Week: //Should never happen, but to make types happy case PeriodType.WeekSun: - return range(settings, date, 0, rv(week!)!); + return rng(0, rv(week!)!); case PeriodType.WeekMon: - return range(settings, date, 1, rv(week!)!); + return rng(1, rv(week!)!); case PeriodType.WeekTue: - return range(settings, date, 2, rv(week!)!); + return rng(2, rv(week!)!); case PeriodType.WeekWed: - return range(settings, date, 3, rv(week!)!); + return rng(3, rv(week!)!); case PeriodType.WeekThu: - return range(settings, date, 4, rv(week!)!); + return rng(4, rv(week!)!); case PeriodType.WeekFri: - return range(settings, date, 5, rv(week!)!); + return rng(5, rv(week!)!); case PeriodType.WeekSat: - return range(settings, date, 6, rv(week!)!); + return rng(6, rv(week!)!); case PeriodType.Month: - return formatIntl(settings, date, rv(month!)!); + return fmt(date, rv(month!)!); case PeriodType.MonthYear: - return formatIntl(settings, date, rv(monthsYear!)!); + return fmt(date, rv(monthsYear!)!); case PeriodType.Quarter: return [ - formatIntl(settings, startOfInterval('quarter', date), rv(month!)!), - formatIntl(settings, endOfInterval('quarter', date), rv(monthsYear!)!), + fmt(startOfInterval(options.utc ? 'utcQuarter' : 'quarter', date), rv(month!)!), + fmt(endOfInterval(options.utc ? 'utcQuarter' : 'quarter', date), rv(monthsYear!)!), ].join(' - '); case PeriodType.CalendarYear: - return formatIntl(settings, date, rv(year!)!); + return fmt(date, rv(year!)!); case PeriodType.FiscalYearOctober: - const fDate = new Date(getFiscalYear(date), 0, 1); - return formatIntl(settings, fDate, rv(year!)!); + const fiscalYear = getFiscalYear(date, utcOptions); + const fDate = options.utc ? new Date(Date.UTC(fiscalYear, 0, 1)) : new Date(fiscalYear, 0, 1); + return fmt(fDate, rv(year!)!); case PeriodType.BiWeek1: //Should never happen, but to make types happy case PeriodType.BiWeek1Sun: - return range(settings, date, 0, rv(week!)!, 1); + return rng(0, rv(week!)!, 1); case PeriodType.BiWeek1Mon: - return range(settings, date, 1, rv(week!)!, 1); + return rng(1, rv(week!)!, 1); case PeriodType.BiWeek1Tue: - return range(settings, date, 2, rv(week!)!, 1); + return rng(2, rv(week!)!, 1); case PeriodType.BiWeek1Wed: - return range(settings, date, 3, rv(week!)!, 1); + return rng(3, rv(week!)!, 1); case PeriodType.BiWeek1Thu: - return range(settings, date, 4, rv(week!)!, 1); + return rng(4, rv(week!)!, 1); case PeriodType.BiWeek1Fri: - return range(settings, date, 5, rv(week!)!, 1); + return rng(5, rv(week!)!, 1); case PeriodType.BiWeek1Sat: - return range(settings, date, 6, rv(week!)!, 1); + return rng(6, rv(week!)!, 1); case PeriodType.BiWeek2: //Should never happen, but to make types happy case PeriodType.BiWeek2Sun: - return range(settings, date, 0, rv(week!)!, 2); + return rng(0, rv(week!)!, 2); case PeriodType.BiWeek2Mon: - return range(settings, date, 1, rv(week!)!, 2); + return rng(1, rv(week!)!, 2); case PeriodType.BiWeek2Tue: - return range(settings, date, 2, rv(week!)!, 2); + return rng(2, rv(week!)!, 2); case PeriodType.BiWeek2Wed: - return range(settings, date, 3, rv(week!)!, 2); + return rng(3, rv(week!)!, 2); case PeriodType.BiWeek2Thu: - return range(settings, date, 4, rv(week!)!, 2); + return rng(4, rv(week!)!, 2); case PeriodType.BiWeek2Fri: - return range(settings, date, 5, rv(week!)!, 2); + return rng(5, rv(week!)!, 2); case PeriodType.BiWeek2Sat: - return range(settings, date, 6, rv(week!)!, 2); + return rng(6, rv(week!)!, 2); default: return date.toISOString(); diff --git a/packages/utils/src/lib/dateRange.ts b/packages/utils/src/lib/dateRange.ts index ba89262..e34af2f 100644 --- a/packages/utils/src/lib/dateRange.ts +++ b/packages/utils/src/lib/dateRange.ts @@ -35,12 +35,19 @@ function formatMsg( : settings.dictionary.Date[type].LastX.replace('{0}', lastX.toString()); } +/** + * Build the "last X periods" presets for a period type. + * + * @param options.utc Derive the presets from UTC boundaries instead of local ones — "today" + * becomes the current UTC day, and every period is floored/offset in UTC. + */ export function getDateRangePresets( settings: LocaleSettings, - periodType: PeriodType + periodType: PeriodType, + options?: { utc?: boolean } ): { label: string; value: DateRange }[] { let now = new Date(); - const today = startOfInterval('day', now); + const today = startOfInterval(options?.utc ? 'utcDay' : 'day', now); if (settings) { periodType = @@ -48,7 +55,7 @@ export function getDateRangePresets( periodType; } - const { start, end, add } = getDateFuncsByPeriodType(settings, periodType); + const { start, end, add } = getDateFuncsByPeriodType(settings, periodType, options); switch (periodType) { case PeriodType.Day: { @@ -266,7 +273,8 @@ export type PeriodComparison = 'prevPeriod' | 'prevYear' | 'fiftyTwoWeeksAgo'; export function getPeriodComparisonOffset( settings: LocaleSettings, view: PeriodComparison, - period: DateRange | undefined + period: DateRange | undefined, + options?: { utc?: boolean } ) { if (period == null || period.from == null || period.to == null || period.periodType == null) { throw new Error('Period must be defined to calculate offset'); @@ -274,7 +282,7 @@ export function getPeriodComparisonOffset( switch (view) { case 'prevPeriod': - const dateFuncs = getDateFuncsByPeriodType(settings, period.periodType); + const dateFuncs = getDateFuncsByPeriodType(settings, period.periodType, options); // return dateFuncs.difference(period.from, period.to) - 1; // Difference counts full days, need additional offset return dateFuncs.difference(period.to, period.from); // Difference counts full days, need additional offset diff --git a/packages/utils/src/lib/date_types.ts b/packages/utils/src/lib/date_types.ts index c83fcc5..83f6efd 100644 --- a/packages/utils/src/lib/date_types.ts +++ b/packages/utils/src/lib/date_types.ts @@ -208,6 +208,11 @@ export type FormatDateOptions = { weekStartsOn?: DayOfWeek; variant?: DateFormatVariant | 'custom'; custom?: CustomIntlDateTimeFormatOptions; + /** + * Render the date's UTC calendar fields rather than the local ones — pair with `utc` on + * `getDateFuncsByPeriodType()` so period math and display agree. + */ + utc?: boolean; }; export interface FormatDateLocaleOptions {