diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9b8c6fdd7a0c..3d9db9fa76d6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3866,6 +3866,9 @@ importers: projects/packages/premium-analytics: dependencies: + '@date-fns/tz': + specifier: 1.4.1 + version: 1.4.1 '@wordpress/boot': specifier: 0.13.0 version: 0.13.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -3881,6 +3884,9 @@ importers: '@wordpress/route': specifier: 0.12.0 version: 0.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + date-fns: + specifier: 4.1.0 + version: 4.1.0 react: specifier: 18.3.1 version: 18.3.1 diff --git a/projects/packages/premium-analytics/changelog/wooa7s-1312-integrate-datetime-package-into-analytics b/projects/packages/premium-analytics/changelog/wooa7s-1312-integrate-datetime-package-into-analytics new file mode 100644 index 000000000000..56b6c3ea2da2 --- /dev/null +++ b/projects/packages/premium-analytics/changelog/wooa7s-1312-integrate-datetime-package-into-analytics @@ -0,0 +1,4 @@ +Significance: patch +Type: added + +Port datetime package (timezone-aware date helpers and date-range presets) as an internal package from next-woocommerce-analytics. diff --git a/projects/packages/premium-analytics/eslint.config.mjs b/projects/packages/premium-analytics/eslint.config.mjs new file mode 100644 index 000000000000..4d905c2efea4 --- /dev/null +++ b/projects/packages/premium-analytics/eslint.config.mjs @@ -0,0 +1,16 @@ +import { makeBaseConfig, defineConfig } from 'jetpack-js-tools/eslintrc/base.mjs'; + +/** + * Soften JSDoc rules for `packages/datetime/**` so the initial port can + * land. Temporary — backfill proper descriptions on the helpers and + * remove this override (at which point this whole file can go away). + */ +export default defineConfig( makeBaseConfig( import.meta.url ), { + files: [ 'packages/datetime/**' ], + rules: { + 'jsdoc/require-description': 'off', + 'jsdoc/require-param-description': 'off', + 'jsdoc/require-returns': 'off', + 'jsdoc/check-indentation': 'off', + }, +} ); diff --git a/projects/packages/premium-analytics/package.json b/projects/packages/premium-analytics/package.json index 31d5de969868..3f0ab771f18b 100644 --- a/projects/packages/premium-analytics/package.json +++ b/projects/packages/premium-analytics/package.json @@ -29,11 +29,13 @@ } }, "dependencies": { + "@date-fns/tz": "1.4.1", "@wordpress/boot": "0.13.0", "@wordpress/data": "10.46.0", "@wordpress/i18n": "^6.9.0", "@wordpress/icons": "^13.0.0", "@wordpress/route": "0.12.0", + "date-fns": "4.1.0", "react": "18.3.1", "react-dom": "18.3.1" }, diff --git a/projects/packages/premium-analytics/packages/datetime/README.md b/projects/packages/premium-analytics/packages/datetime/README.md new file mode 100644 index 000000000000..ce284079bfee --- /dev/null +++ b/projects/packages/premium-analytics/packages/datetime/README.md @@ -0,0 +1,129 @@ +# @automattic/jetpack-premium-analytics-datetime + +Date and timezone utilities for Jetpack Premium Analytics. + +## Overview + +Provides timezone-aware date handling and comparison range calculations +for analytics widgets and date-range pickers. + +## Functions + +### Timezone Utilities + +#### `createTZDateFromParts( dateParts: number[], timezone? )` + +Creates a timezone-aware date in the specified timezone using the provided date parts. +**Important:** Months are zero-based (0 = January, 11 = December). + +```ts +// October 09, 2025 00:00 AM in America/New_York time +const date = createTZDateFromParts( [ 2025, 9, 9 ], 'America/New_York' ); +``` + +**Parameters:** + +- `dateParts` : `number[]` - Date value to convert +- `timezone` (optional): `string` - Target timezone, default is GMT + +**Returns:** `TZDate` - Timezone-aware date object + +#### `toLocalTZ( value?, timezone? )` + +Creates a timezone-aware date in the specified timezone. + +```typescript +const date = toLocalTZ( '2024-01-15', 'America/New_York' ); +const now = toLocalTZ( undefined, '+05:30' ); // Current time in +05:30 +``` + +**Parameters:** + +- `value` (optional): `number | string | Date` - Date value to convert +- `timezone` (optional): `string` - Target timezone + +**Returns:** `TZDate` - Timezone-aware date object + +#### `formatToTimezoneNaiveString( date, timezone )` + +Formats a date to an ISO string without timezone offset. + +```typescript +const naive = formatToTimezoneNaiveString( new Date(), 'Europe/London' ); +// Returns: "2024-01-15T14:30:00.000" +``` + +**Parameters:** + +- `date`: `Date` - Date to format +- `timezone`: `string` - Timezone for interpretation + +**Returns:** `string` - ISO string without timezone offset + +#### `dateToISOStringWithTZ( date, timezone )` + +Converts a date to ISO string with timezone offset applied. + +```typescript +const withTZ = dateToISOStringWithTZ( new Date(), 'America/New_York' ); +// Returns: "2024-01-15T14:30:00.000-05:00" +``` + +**Parameters:** + +- `date`: `Date` - Date to convert +- `timezone`: `string` - Target timezone + +**Returns:** `string` - ISO string with timezone offset + +### Comparison Range Calculations + +#### `getComparisonRangeFromPreset( reference, presetId )` + +Calculates comparison date ranges based on predefined presets. + +```typescript +const reference = { + from: new Date( '2024-01-15' ), + to: new Date( '2024-01-21' ), +}; +const comparison = getComparisonRangeFromPreset( reference, 'previous-week' ); +// Returns dates for Jan 8-14, 2024 +``` + +**Parameters:** + +- `reference`: `DateRange` - Reference date range with `from` and `to` +- `presetId`: `ComparisonPresetId` - One of the supported preset identifiers + +**Returns:** `DateRange | undefined` - Comparison date range or undefined +if inputs are invalid + +**Supported presets:** + +- `previous-period` - Same duration, immediately before reference +- `previous-week` - One week before reference dates +- `previous-month` - One month before reference dates +- `previous-year` - One year before reference dates + +## Types + +### `DateRange` + +```typescript +type DateRange = { + from?: Date; + to?: Date; +}; +``` + +### `ComparisonPresetId` + +```typescript +type ComparisonPresetId = 'previous-period' | 'previous-week' | 'previous-month' | 'previous-year'; +``` + +## Dependencies + +- `date-fns` - Date manipulation functions +- `@date-fns/tz` - Timezone support for date-fns diff --git a/projects/packages/premium-analytics/packages/datetime/package.json b/projects/packages/premium-analytics/packages/datetime/package.json new file mode 100644 index 000000000000..b4a9dfcea148 --- /dev/null +++ b/projects/packages/premium-analytics/packages/datetime/package.json @@ -0,0 +1,14 @@ +{ + "name": "@automattic/jetpack-premium-analytics-datetime", + "version": "0.1.0", + "private": true, + "type": "module", + "main": "src/index.ts", + "types": "src/index.ts", + "sideEffects": false, + "dependencies": { + "@date-fns/tz": "1.4.1", + "@wordpress/i18n": "^6.9.0", + "date-fns": "4.1.0" + } +} diff --git a/projects/packages/premium-analytics/packages/datetime/src/get-comparison-range.ts b/projects/packages/premium-analytics/packages/datetime/src/get-comparison-range.ts new file mode 100644 index 000000000000..5c04d56f6323 --- /dev/null +++ b/projects/packages/premium-analytics/packages/datetime/src/get-comparison-range.ts @@ -0,0 +1,105 @@ +/** + * External dependencies + */ +import { + differenceInDays, + subDays, + subWeeks, + subMonths, + subYears, + startOfDay, + endOfDay, +} from 'date-fns'; + +/** + * Supported comparison preset identifiers. + */ +export type DateRange = { from?: Date; to?: Date }; + +/** + * Named constants for comparison preset identifiers. + */ +export const COMPARISON_PREVIOUS_PERIOD = 'previous-period' as const; +export const COMPARISON_PREVIOUS_WEEK = 'previous-week' as const; +export const COMPARISON_PREVIOUS_MONTH = 'previous-month' as const; +export const COMPARISON_PREVIOUS_YEAR = 'previous-year' as const; + +/** + * All comparison preset identifiers, in display order. + */ +export const COMPARISON_PRESETS = [ + COMPARISON_PREVIOUS_PERIOD, + COMPARISON_PREVIOUS_WEEK, + COMPARISON_PREVIOUS_MONTH, + COMPARISON_PREVIOUS_YEAR, +] as const; + +export type ComparisonPresetId = ( typeof COMPARISON_PRESETS )[ number ]; + +/** + * Type guard to check if a string is a valid ComparisonPresetId. + * + * @param value - The value to check. + * @return True if the value is a valid ComparisonPresetId, false otherwise. + */ +export function isComparisonPresetId( value: unknown ): value is ComparisonPresetId { + return typeof value === 'string' && ( COMPARISON_PRESETS as readonly string[] ).includes( value ); +} + +/** + * Returns a comparison DateRange (as Date objects) derived from a reference range + * and a given preset. + * + * - This function is pure and has no side effects. + * - It does not apply any timezone adjustments. The caller is responsible for + * normalizing dates to the desired local day boundaries before passing them in. + * + * @param reference - The reference range to compare against (must include both `from` and `to`). + * @param presetId - One of the supported preset identifiers. + * @return A new DateRange for the comparison period, or `undefined` if inputs are invalid. + */ +export function getComparisonRangeFromPreset( + reference: DateRange, + presetId: ComparisonPresetId +): DateRange | undefined { + if ( ! reference?.from || ! reference?.to ) { + return undefined; + } + + const refFrom = reference.from; + const refTo = reference.to; + + const clampDayBound = ( date: Date, bound: 0 | 1 ) => + bound === 1 ? endOfDay( startOfDay( date ) ) : startOfDay( date ); + + if ( presetId === COMPARISON_PREVIOUS_PERIOD ) { + const daysInclusive = differenceInDays( refTo, refFrom ) + 1; + return { + from: clampDayBound( subDays( refFrom, daysInclusive ), 0 ), + to: clampDayBound( subDays( refTo, daysInclusive ), 1 ), + }; + } + + if ( presetId === COMPARISON_PREVIOUS_WEEK ) { + return { + from: clampDayBound( subWeeks( refFrom, 1 ), 0 ), + to: clampDayBound( subWeeks( refTo, 1 ), 1 ), + }; + } + + if ( presetId === COMPARISON_PREVIOUS_MONTH ) { + return { + from: clampDayBound( subMonths( refFrom, 1 ), 0 ), + to: clampDayBound( subMonths( refTo, 1 ), 1 ), + }; + } + + if ( presetId === COMPARISON_PREVIOUS_YEAR ) { + return { + from: clampDayBound( subYears( refFrom, 1 ), 0 ), + to: clampDayBound( subYears( refTo, 1 ), 1 ), + }; + } + + return undefined; +} diff --git a/projects/packages/premium-analytics/packages/datetime/src/index.ts b/projects/packages/premium-analytics/packages/datetime/src/index.ts new file mode 100644 index 000000000000..fd8f8ac3e8ff --- /dev/null +++ b/projects/packages/premium-analytics/packages/datetime/src/index.ts @@ -0,0 +1,49 @@ +export { + getComparisonRangeFromPreset, + isComparisonPresetId, + type DateRange, + type ComparisonPresetId, +} from './get-comparison-range'; + +export { + createTZDateFromParts, + toLocalTZ, + formatToTimezoneNaiveString, + dateToISOStringWithTZ, + startOfDayTZ, + endOfDayTZ, +} from './tz'; + +export { + // Constants + SELECTABLE_PRESETS, + PRESET_TODAY, + PRESET_YESTERDAY, + PRESET_LAST_7_DAYS, + PRESET_LAST_30_DAYS, + PRESET_LAST_90_DAYS, + PRESET_LAST_365_DAYS, + PRESET_LAST_MONTH, + PRESET_LAST_12_MONTHS, + PRESET_LAST_YEAR, + PRESET_CUSTOM, + + // Guards + isSelectablePreset, + isPrimaryPreset, + + // Types + type SelectablePresetId, + type PrimaryPresetId, + + // Primary presets + PRESET_DEFINITIONS, + getPresetLabel, + getDefaultDateRangePresets, + computePrimaryRange, + type DateRangePreset, + + // Comparison presets + getComparisonPresetLabel, + getComparisonPresetConfigs, +} from './presets'; diff --git a/projects/packages/premium-analytics/packages/datetime/src/presets/comparison.ts b/projects/packages/premium-analytics/packages/datetime/src/presets/comparison.ts new file mode 100644 index 000000000000..b993189947fc --- /dev/null +++ b/projects/packages/premium-analytics/packages/datetime/src/presets/comparison.ts @@ -0,0 +1,65 @@ +/** + * External dependencies + */ +import { __ } from '@wordpress/i18n'; +/** + * Internal dependencies + */ +import { + COMPARISON_PREVIOUS_PERIOD, + COMPARISON_PREVIOUS_WEEK, + COMPARISON_PREVIOUS_MONTH, + COMPARISON_PREVIOUS_YEAR, + type ComparisonPresetId, +} from '../get-comparison-range'; + +/** + * Comparison preset label configuration. + */ +const COMPARISON_PRESET_LABELS: { + id: ComparisonPresetId; + getLabel: () => string; +}[] = [ + { + id: COMPARISON_PREVIOUS_PERIOD, + getLabel: () => __( 'Previous period', 'jetpack-premium-analytics' ), + }, + { + id: COMPARISON_PREVIOUS_WEEK, + getLabel: () => __( 'Previous week', 'jetpack-premium-analytics' ), + }, + { + id: COMPARISON_PREVIOUS_MONTH, + getLabel: () => __( 'Previous month', 'jetpack-premium-analytics' ), + }, + { + id: COMPARISON_PREVIOUS_YEAR, + getLabel: () => __( 'Previous year', 'jetpack-premium-analytics' ), + }, +]; + +/** + * Get the label for a comparison preset. + * + * @param id - The comparison preset identifier. + * @return The label string, or null if not found. + */ +export function getComparisonPresetLabel( id: ComparisonPresetId ): string | null { + const config = COMPARISON_PRESET_LABELS.find( item => item.id === id ); + return config?.getLabel() ?? null; +} + +/** + * Get all comparison preset configurations (id + label). + * + * @return Array of comparison preset configs. + */ +export function getComparisonPresetConfigs(): { + id: ComparisonPresetId; + label: string; +}[] { + return COMPARISON_PRESET_LABELS.map( ( { id, getLabel } ) => ( { + id, + label: getLabel(), + } ) ); +} diff --git a/projects/packages/premium-analytics/packages/datetime/src/presets/index.ts b/projects/packages/premium-analytics/packages/datetime/src/presets/index.ts new file mode 100644 index 000000000000..d3e4f17ca93b --- /dev/null +++ b/projects/packages/premium-analytics/packages/datetime/src/presets/index.ts @@ -0,0 +1,27 @@ +export { + SELECTABLE_PRESETS, + PRESET_TODAY, + PRESET_YESTERDAY, + PRESET_LAST_7_DAYS, + PRESET_LAST_30_DAYS, + PRESET_LAST_90_DAYS, + PRESET_LAST_365_DAYS, + PRESET_LAST_MONTH, + PRESET_LAST_12_MONTHS, + PRESET_LAST_YEAR, + PRESET_CUSTOM, + isSelectablePreset, + isPrimaryPreset, + type SelectablePresetId, + type PrimaryPresetId, +} from './types'; + +export { + PRESET_DEFINITIONS, + getPresetLabel, + getDefaultDateRangePresets, + computePrimaryRange, + type DateRangePreset, +} from './primary'; + +export { getComparisonPresetLabel, getComparisonPresetConfigs } from './comparison'; diff --git a/projects/packages/premium-analytics/packages/datetime/src/presets/primary.ts b/projects/packages/premium-analytics/packages/datetime/src/presets/primary.ts new file mode 100644 index 000000000000..8807338f6712 --- /dev/null +++ b/projects/packages/premium-analytics/packages/datetime/src/presets/primary.ts @@ -0,0 +1,220 @@ +/** + * External dependencies + */ +import { __ } from '@wordpress/i18n'; +import { + startOfDay, + endOfDay, + subDays, + subMonths, + subYears, + startOfMonth, + endOfMonth, + startOfYear, + endOfYear, +} from 'date-fns'; +/** + * Internal dependencies + */ +import { toLocalTZ } from '../tz'; +import { + PRESET_TODAY, + PRESET_YESTERDAY, + PRESET_LAST_7_DAYS, + PRESET_LAST_30_DAYS, + PRESET_LAST_90_DAYS, + PRESET_LAST_365_DAYS, + PRESET_LAST_MONTH, + PRESET_LAST_12_MONTHS, + PRESET_LAST_YEAR, + PRESET_CUSTOM, + type SelectablePresetId, + type PrimaryPresetId, +} from './types'; +import type { DateRange } from '../get-comparison-range'; + +/** + * Shared date calculations used by multiple presets. + */ +type DateContext = { + initOfToday: Date; + endOfToday: Date; + endOfYesterday: Date; + lastMonth: Date; + endOfLastMonth: Date; + lastYear: Date; +}; + +/** + * Preset definition with label getter and range calculator. + */ +type PresetDefinition = { + id: SelectablePresetId; + getLabel: () => string; + getRange: ( ctx: DateContext ) => Required< DateRange >; +}; + +/** + * Canonical preset definitions with labels and range calculators. + * Labels are defined once here and reused by all consumers. + */ +export const PRESET_DEFINITIONS: ReadonlyArray< PresetDefinition > = [ + { + id: PRESET_TODAY, + getLabel: () => __( 'Today', 'jetpack-premium-analytics' ), + getRange: ( { initOfToday, endOfToday } ) => ( { + from: initOfToday, + to: endOfToday, + } ), + }, + { + id: PRESET_YESTERDAY, + getLabel: () => __( 'Yesterday', 'jetpack-premium-analytics' ), + getRange: ( { initOfToday, endOfYesterday } ) => ( { + from: subDays( initOfToday, 1 ), + to: endOfYesterday, + } ), + }, + { + id: PRESET_LAST_7_DAYS, + getLabel: () => __( 'Last 7 days', 'jetpack-premium-analytics' ), + getRange: ( { initOfToday, endOfYesterday } ) => ( { + from: subDays( initOfToday, 7 ), + to: endOfYesterday, + } ), + }, + { + id: PRESET_LAST_30_DAYS, + getLabel: () => __( 'Last 30 days', 'jetpack-premium-analytics' ), + getRange: ( { initOfToday, endOfYesterday } ) => ( { + from: subDays( initOfToday, 30 ), + to: endOfYesterday, + } ), + }, + { + id: PRESET_LAST_90_DAYS, + getLabel: () => __( 'Last 90 days', 'jetpack-premium-analytics' ), + getRange: ( { initOfToday, endOfYesterday } ) => ( { + from: subDays( initOfToday, 90 ), + to: endOfYesterday, + } ), + }, + { + id: PRESET_LAST_365_DAYS, + getLabel: () => __( 'Last 365 days', 'jetpack-premium-analytics' ), + getRange: ( { initOfToday, endOfYesterday } ) => ( { + from: subDays( initOfToday, 365 ), + to: endOfYesterday, + } ), + }, + { + id: PRESET_LAST_MONTH, + getLabel: () => __( 'Last month', 'jetpack-premium-analytics' ), + getRange: ( { lastMonth, endOfLastMonth } ) => ( { + from: startOfMonth( lastMonth ), + to: endOfLastMonth, + } ), + }, + { + id: PRESET_LAST_12_MONTHS, + getLabel: () => __( 'Last 12 months', 'jetpack-premium-analytics' ), + getRange: ( { initOfToday, endOfLastMonth } ) => ( { + from: startOfMonth( subMonths( initOfToday, 12 ) ), + to: endOfLastMonth, + } ), + }, + { + id: PRESET_LAST_YEAR, + getLabel: () => __( 'Last year', 'jetpack-premium-analytics' ), + getRange: ( { lastYear } ) => ( { + from: startOfYear( lastYear ), + to: endOfYear( lastYear ), + } ), + }, +]; + +/** + * Get the label for a preset without calculating date ranges. + * + * @param id - The preset identifier + * @return The preset label, or null if not found or custom + */ +export function getPresetLabel( id: PrimaryPresetId | null | undefined ): string | null { + if ( ! id || id === PRESET_CUSTOM ) { + return null; + } + + const preset = PRESET_DEFINITIONS.find( p => p.id === id ); + return preset?.getLabel() ?? null; +} + +/** + * Build a DateContext for a given timezone. + * @param timeZone + */ +function buildDateContext( timeZone: string ): DateContext { + const nowWithTZ = toLocalTZ( undefined, timeZone ); + const initOfToday = startOfDay( nowWithTZ ); + const endOfToday = endOfDay( nowWithTZ ); + const endOfYesterday = endOfDay( subDays( initOfToday, 1 ) ); + const lastMonth = subMonths( initOfToday, 1 ); + const endOfLastMonth = endOfMonth( lastMonth ); + const lastYear = subYears( initOfToday, 1 ); + + return { + initOfToday, + endOfToday, + endOfYesterday, + lastMonth, + endOfLastMonth, + lastYear, + }; +} + +/** + * Represents a date range preset option. + * Preset ranges always have both `from` and `to` defined. + */ +export type DateRangePreset = { + id: PrimaryPresetId; + label: string; + range: Required< DateRange >; +}; + +/** + * Get the default date range presets with computed ranges. + * + * @param timeZone - IANA timezone string (e.g., 'America/New_York') + * @return The default date range presets. + */ +export function getDefaultDateRangePresets( timeZone: string ): DateRangePreset[] { + const ctx = buildDateContext( timeZone ); + + return PRESET_DEFINITIONS.map( ( { id, getLabel, getRange } ) => ( { + id, + label: getLabel(), + range: getRange( ctx ), + } ) ); +} + +/** + * Compute the absolute date range (as Date objects) for a given + * selectable preset ID in the specified timezone. + * + * @param presetId - A valid selectable preset identifier. + * @param timeZone - IANA timezone string. + * @return The computed { from, to } Date range, or undefined + * if the preset is not recognized. + */ +export function computePrimaryRange( + presetId: SelectablePresetId, + timeZone: string +): DateRange | undefined { + const def = PRESET_DEFINITIONS.find( p => p.id === presetId ); + if ( ! def ) { + return undefined; + } + + const ctx = buildDateContext( timeZone ); + return def.getRange( ctx ); +} diff --git a/projects/packages/premium-analytics/packages/datetime/src/presets/types.ts b/projects/packages/premium-analytics/packages/datetime/src/presets/types.ts new file mode 100644 index 000000000000..03023e6f677c --- /dev/null +++ b/projects/packages/premium-analytics/packages/datetime/src/presets/types.ts @@ -0,0 +1,63 @@ +/** + * Named constants for selectable date-range presets. + */ +export const PRESET_TODAY = 'today' as const; +export const PRESET_YESTERDAY = 'yesterday' as const; +export const PRESET_LAST_7_DAYS = 'last-7-days' as const; +export const PRESET_LAST_30_DAYS = 'last-30-days' as const; +export const PRESET_LAST_90_DAYS = 'last-90-days' as const; +export const PRESET_LAST_365_DAYS = 'last-365-days' as const; +export const PRESET_LAST_MONTH = 'last-month' as const; +export const PRESET_LAST_12_MONTHS = 'last-12-months' as const; +export const PRESET_LAST_YEAR = 'last-year' as const; + +/** + * All selectable (non-custom) preset IDs, in display order. + */ +export const SELECTABLE_PRESETS = [ + PRESET_TODAY, + PRESET_YESTERDAY, + PRESET_LAST_7_DAYS, + PRESET_LAST_30_DAYS, + PRESET_LAST_90_DAYS, + PRESET_LAST_365_DAYS, + PRESET_LAST_MONTH, + PRESET_LAST_12_MONTHS, + PRESET_LAST_YEAR, +] as const; + +/** + * Union of the 9 selectable preset identifiers. + */ +export type SelectablePresetId = ( typeof SELECTABLE_PRESETS )[ number ]; + +/** + * The custom marker — not user-selectable, used as a disabled state. + */ +export const PRESET_CUSTOM = 'custom' as const; + +/** + * Primary preset: one of the 9 selectable presets, or 'custom'. + */ +export type PrimaryPresetId = SelectablePresetId | typeof PRESET_CUSTOM; + +/** + * Type guard to check if a value is a selectable preset ID. + * + * @param value - The value to check. + * @return True if the value is a valid SelectablePresetId. + */ +export function isSelectablePreset( value: unknown ): value is SelectablePresetId { + return typeof value === 'string' && ( SELECTABLE_PRESETS as readonly string[] ).includes( value ); +} + +/** + * Type guard to check if a value is any primary preset ID + * (selectable or custom). + * + * @param value - The value to check. + * @return True if the value is a valid PrimaryPresetId. + */ +export function isPrimaryPreset( value: unknown ): value is PrimaryPresetId { + return isSelectablePreset( value ) || value === PRESET_CUSTOM; +} diff --git a/projects/packages/premium-analytics/packages/datetime/src/tz.ts b/projects/packages/premium-analytics/packages/datetime/src/tz.ts new file mode 100644 index 000000000000..fae1a696e4e4 --- /dev/null +++ b/projects/packages/premium-analytics/packages/datetime/src/tz.ts @@ -0,0 +1,129 @@ +/** + * External dependencies + */ +import { tz, TZDate, TZDateMini } from '@date-fns/tz'; +import { format, isValid, startOfDay, endOfDay } from 'date-fns'; + +type GrowTuple< T extends unknown[], Max extends number > = T[ 'length' ] extends Max + ? T + : T | GrowTuple< [ ...T, number ], Max >; +/** + * Date parts tuple in the same order as the native `Date` constructor: + * [ year, month, day, hours, minutes, seconds, milliseconds ] + * + * Positions: + * - year: full year, e.g. 2025 + * - month: month index 0–11 (0=January, 11=December) + * - day: day of month 1–31 (default 1 if omitted) + * - hours: 0–23 (default 0) + * - minutes: 0–59 (default 0) + * - seconds: 0–59 (default 0) + * - milliseconds: 0–999 (default 0) + * + * Rules: + * - Valid lengths: 2 to 7 elements (must always start with [year, month]). + * - Do not skip intermediate positions: contiguous prefixes only (trimmed at the first `undefined`). + * - Time zone is applied when creating the date (see `createTZDateFromParts`). + * + * Examples: + * - [ 2025, 0 ] → 2025-01-01T00:00:00.000 (January is 0) + * - [ 2025, 6, 15, 14, 30 ] → 2025-07-15T14:30:00.000 + */ +type DateParts = GrowTuple< [ number, number ], 7 >; + +/** + * + * @param root0 + * @param root0."0" + * @param root0."1" + * @param root0."2" + * @param root0."3" + * @param root0."4" + * @param root0."5" + * @param root0."6" + * @param timeZone + */ +export function createTZDateFromParts( + [ year, month, day, hours, minutes, seconds, milliseconds ]: DateParts, + timeZone?: string +): TZDate { + const tzid = timeZone ?? '+00:00'; + + const dateParts = [ year, month, day, hours, minutes, seconds, milliseconds ]; + + // Trim until first undefined, to match one of the DateParts types. + const idx = dateParts.indexOf( undefined ); + const datePartsTrimmed = idx === -1 ? dateParts : dateParts.slice( 0, idx ); + + // @ts-expect-error: We know datePartsTrimmed is a tuple of numbers, spreading is safe. + return new TZDateMini( ...datePartsTrimmed, tzid ); +} + +/** + * Create a TZDate in the provided timezone. + * Defaults to UTC when no timezone is given. + * @param value + * @param timeZone + */ +export function toLocalTZ( value?: number | string | Date, timeZone?: string ): TZDate { + const tzid = timeZone ?? '+00:00'; + if ( value !== undefined ) { + return new TZDateMini( value as number, tzid ); + } + + return TZDateMini.tz( tzid ); +} + +/** + * Format a date to a timezone-naive ISO string (no offset), + * using the given timezone for interpretation. + * Example: TZDateMini("...+01:00") -> "YYYY-MM-DDTHH:mm:ss.SSS" + * @param date + * @param timezone + */ +export function formatToTimezoneNaiveString( date: Date, timezone: string ): string { + if ( ! isValid( date ) ) { + throw new Error( 'Invalid date provided' ); + } + return format( date, "yyyy-MM-dd'T'HH:mm:ss.SSS", { in: tz( timezone ) } ); +} + +/** + * Convert a date to ISO string with the timezone offset applied. + * Example output: "YYYY-MM-DDTHH:mm:ss.SSS±hh:mm" + * @param date + * @param timezone + */ +export function dateToISOStringWithTZ( date: Date, timezone: string ): string { + return format( date, "yyyy-MM-dd'T'HH:mm:ss.SSSxxx", { + in: tz( timezone ), + } ); +} + +/** + * Returns the start of day (00:00:00) for the given date in the specified timezone. + * + * @param date - The date to get the start of day for + * @param timeZone - Timezone string (e.g., 'America/New_York', 'UTC', '+08:00') + * @return A Date object representing midnight in the specified timezone + */ +export function startOfDayTZ( date: Date | number, timeZone: string ): Date { + // Create TZDate in the target timezone - this interprets the input date in that timezone + const tzDate = new TZDateMini( new Date( date ).getTime(), timeZone ); + // startOfDay from date-fns respects the timezone context in TZDate + return startOfDay( tzDate ); +} + +/** + * Returns the end of day (23:59:59.999) for the given date in the specified timezone. + * + * @param date - The date to get the end of day for + * @param timeZone - Timezone string (e.g., 'America/New_York', 'UTC', '+08:00') + * @return A Date object representing the last millisecond of the day in the specified timezone + */ +export function endOfDayTZ( date: Date | number, timeZone: string ): Date { + // Create TZDate in the target timezone - this interprets the input date in that timezone + const tzDate = new TZDateMini( new Date( date ).getTime(), timeZone ); + // endOfDay from date-fns respects the timezone context in TZDate + return endOfDay( tzDate ); +}