diff --git a/package.json b/package.json index 4189ec87..79359276 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "tempo-monorepo", - "version": "3.2.1", + "version": "3.2.2", "private": true, "engines": { "node": ">=20.0.0" @@ -60,4 +60,4 @@ "esbuild@0.28.1": true, "esbuild@0.21.5": true } -} +} \ No newline at end of file diff --git a/packages/library/package.json b/packages/library/package.json index 92ed315c..0c0bb04a 100644 --- a/packages/library/package.json +++ b/packages/library/package.json @@ -1,6 +1,6 @@ { "name": "@magmacomputing/library", - "version": "3.2.1", + "version": "3.2.2", "description": "Shared utility library for Tempo", "author": "Magma Computing Solutions", "license": "MIT", @@ -99,4 +99,4 @@ "optionalDependencies": { "@js-temporal/polyfill": "^0.5.1" } -} +} \ No newline at end of file diff --git a/packages/library/src/common/international.library.ts b/packages/library/src/common/international.library.ts index 49238359..a7121914 100644 --- a/packages/library/src/common/international.library.ts +++ b/packages/library/src/common/international.library.ts @@ -1,6 +1,6 @@ import { getOffsets } from '#library/temporal.library.js'; import { memoizeFunction } from '#library/function.library.js'; -import { isFunction } from '#library/assertion.library.js'; +import { isFunction, isDefined } from '#library/assertion.library.js'; /** memoized helper for Intl.RelativeTimeFormat instances */ const getRTF = memoizeFunction((locale?: string, style: Intl.RelativeTimeFormatStyle = 'narrow') => { @@ -131,3 +131,57 @@ export function getHemisphere(timeZone: string = getDateTimeFormat().timeZone) { return undefined; } } + +type input = { + toPlainDate?: () => any, + year: number, month: number, day: number, dayOfWeek: number, + weekOfYear?: number | undefined, yearOfWeek?: number | undefined +} +type result = { weekOfYear: number, yearOfWeek: number }; +/** + * Polyfill fallback for ISO 8601 Week of Year and Year of Week. + * + * Introduced because highly experimental native browser implementations of the Temporal API + * (e.g., Chrome/Firefox behind flags) currently return `undefined` for `weekOfYear` and `yearOfWeek` + * on ZonedDateTime objects. The TC39 spec moved toward calendar-dependent definitions, + * causing divergence between the @js-temporal/polyfill (which returns numbers) and native browsers (which return undefined). + */ +export function getISOWeekOfYear(zdt: input): result { + if (isDefined(zdt.weekOfYear) && isDefined(zdt.yearOfWeek)) + return { weekOfYear: zdt.weekOfYear, yearOfWeek: zdt.yearOfWeek }; + + // Since Temporal.ZonedDateTime is passed in, we can safely extract the PlainDate + // to avoid crossing daylight saving boundaries when adding/subtracting days. + // Normalize to ISO 8601 calendar because properties like dayOfYear/dayOfWeek are calendar-dependent. + const pd = (isFunction(zdt.toPlainDate) ? zdt.toPlainDate() : Temporal.PlainDate.from(zdt)).withCalendar('iso8601'); + + // ISO week date algorithm: weeks start on Monday, and the first week of the year contains the first Thursday. + // Find the nearest Thursday to the current date. + const shift = 4 - pd.dayOfWeek; + const nearestThursday = shift >= 0 ? pd.add({ days: shift }) : pd.subtract({ days: -shift }); + + // The calendar year of that nearest Thursday is the ISO week-numbering year + const yearOfWeek = nearestThursday.year; + + // The week number is exactly the nearest Thursday's dayOfYear divided by 7 + const weekOfYear = Math.ceil(nearestThursday.dayOfYear / 7); + + return { weekOfYear, yearOfWeek }; +} + +/** + * Probe the runtime to see if the locale defaults to Month-Day-Year order. + * @example + * probeMDY('en-US') // true + * probeMDY('en-GB') // false + */ +export function probeMDY(locale: string): boolean { + try { + // Use Dec 24th to check if '12' comes first + const date = new Date(2024, 11, 24); + const parts = new Intl.DateTimeFormat(locale).formatToParts(date); + return parts[0].type === 'month' && parts[0].value === '12'; + } catch { + return false; + } +} diff --git a/packages/library/src/common/object.library.ts b/packages/library/src/common/object.library.ts index eb7e0894..2806c634 100644 --- a/packages/library/src/common/object.library.ts +++ b/packages/library/src/common/object.library.ts @@ -107,6 +107,25 @@ export const pluck = (objs: T[], key: K): T[K][] => export const extend = (obj: T, ...objs: U[]) => Object.assign(obj, ...objs) as T; +/** recursively deep-merge objects */ +export const deepMerge = >(...objects: Partial[]): T => { + return objects.reduce((prev, obj) => { + if (!isObject(obj)) return prev; + + Object.entries(obj).forEach(([key, value]) => { + if (key === '__proto__' || key === 'constructor' || key === 'prototype') return; + const pVal = prev[key]; + if (isObject(pVal) && isObject(value)) { + prev[key as keyof T] = deepMerge(pVal, value) as any; + } else { + prev[key as keyof T] = value as any; + } + }); + + return prev; + }, {} as any) as T; +} + export const countProperties = (obj = {}) => ownKeys(obj).length diff --git a/packages/library/src/common/utility.library.ts b/packages/library/src/common/utility.library.ts index 6a46e0f7..45fca1f1 100644 --- a/packages/library/src/common/utility.library.ts +++ b/packages/library/src/common/utility.library.ts @@ -1,4 +1,4 @@ -import { ownValues } from '#library/primitive.library.js'; +import { ownEntries } from '#library/primitive.library.js'; import { isDefined, isPrimitive } from '#library/assertion.library.js'; import { sym } from '#library/symbol.library.js'; import type { Secure, ValueOf } from '#library/type.library.js'; @@ -100,7 +100,10 @@ export function deepFreeze(obj: T, options?: { skip?: We seen.add(obj); - ownValues(obj as any).forEach(val => deepFreeze(val, { skip }, seen)); + ownEntries(obj as any).forEach(([key, val]) => { + if (key !== '__proto__' && key !== 'constructor' && key !== 'prototype') + deepFreeze(val, { skip }, seen); + }); return Object.freeze(obj) as Secure; } diff --git a/packages/tempo/CHANGELOG.md b/packages/tempo/CHANGELOG.md index 8670d2c5..51177aad 100644 --- a/packages/tempo/CHANGELOG.md +++ b/packages/tempo/CHANGELOG.md @@ -6,6 +6,21 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [3.2.2] - 2026-06-18 + +### Added +- **New Format Tokens**: Added support for 6-digit compact date formatting tokens (`{dmy6}`, `{mdy6}`, `{ymd6}`) and short-year bounds (`{yywy}`, `{yyww}`). + +### Changed +- **Intl Configuration**: Completely overhauled the `options.intl` resolution pipeline. Replaced brittle shallow-spread assignment with a robust, recursive `deepMerge` utility to prevent nested configuration clobbering (e.g. `dateTimeFormat` vs `relativeTimeFormat`). +- **ISO Week Renaming**: Renamed the `{ww}` token to `{wy}` to provide better semantic alignment with week-of-year calculations and resolve ambiguity with structural tokens. + +### Fixed +- **Configuration Security**: Hardened recursive object manipulation utilities (`deepMerge` and `deepFreeze`) against Prototype Mutation and Prototype Freezing Denial of Service (DoS) vulnerabilities by strictly guarding against `__proto__`, `constructor`, and `prototype` keys during object traversals. + +### Removed +- **Redundant Format Tokens**: Removed uppercase format tokens (`{MER}`, `{HH}`, `{DAY}`, `{WW}`, `{MM}`) from the engine to strictly enforce the token modifier pattern (e.g., `{mer:upper}`, `{h24}`, `{dd:ord}`). This eliminates ambiguity and ensures all output routes securely through the `Intl` localization engine. + ## [3.2.1] - 2026-06-17 ### Added diff --git a/packages/tempo/doc/tempo.format.md b/packages/tempo/doc/tempo.format.md index e4ed8eee..9110cf6c 100644 --- a/packages/tempo/doc/tempo.format.md +++ b/packages/tempo/doc/tempo.format.md @@ -87,8 +87,9 @@ Tempo.extend(FormatModule); | :--- | :--- | :--- | | `{yyyy}` | 4-digit Year | `2026` | | `{yy}` | 2-digit Year | `26` | +| `{yywy}` | ISO Year & Week | `202617` | | `{yw}` | ISO Year of Week | `2026` | -| `{yyww}` | ISO Year & Week | `202617` | +| `{wy}` | Zero-padded ISO Week of Year | `43` | | `{mon}` | Full Month Name | `October` | | `{mmm}` | Short Month Name | `Oct` | | `{mm}` | Zero-padded Month | `10` | @@ -96,8 +97,8 @@ Tempo.extend(FormatModule); | `{wkd}` | Full Weekday Name | `Saturday` | | `{www}` | Short Weekday Name | `Sat` | | `{dow}` | ISO Day of Week (1=Mon, 7=Sun) | `6` | -| `{ww}` | Zero-padded ISO Week of Year | `43` | | `{hh}` | Zero-padded Hour (24h) | `15` | +| `{h24}` | Zero-padded Hour synonym (24h) | `15` | | `{h12}` | Zero-padded Hour (12h) plus meridiem | `03pm` | | `{mer}` | am/pm meridiem marker | `pm` | | `{mi}` | Zero-padded Minutes | `30` | @@ -110,6 +111,9 @@ Tempo.extend(FormatModule); | `{dmy}` | Compact Date (ddmmyyyy) | `24102026` | | `{mdy}` | Compact Date (mmddyyyy) | `10242026` | | `{ymd}` | Compact Date (yyyymmdd) | `20261024` | +| `{dmy6}` | Compact 6-digit Date (ddmmyy) | `241026` | +| `{mdy6}` | Compact 6-digit Date (mmddyy) | `102426` | +| `{ymd6}` | Compact 6-digit Date (yymmdd) | `261024` | | `{hms}` | Compact Time (24h) | `153045` | | `{nano}` | Nanosecond Timestamp | `1792843200000000000` | | `{tz}` | Time Zone ID | `Australia/Sydney` | @@ -133,7 +137,7 @@ If your format string contains `{h12}` (12-hour clock) but lacks a `{mer}` token *(If you explicitly want a 12-hour digit without an auto-appended meridiem, use the `:raw` modifier: `{h12:raw}`)* > [!NOTE] -> **Why `{h12}`?** In most date libraries, `{hh}` means 12-hour and `{HH}` means 24-hour time. However, Tempo standardizes `{hh}` on the default 24-hour expectation (with `{h12}` serving as the specific 12-hour override). This keeps all token definitions, and their corresponding time getters, fully lowercase and semantic. +> **Why `{h12}` and `{h24}`?** In other date libraries, `{hh}` could mean 12-hour and `{HH}` means 24-hour time. This is confusing and error-prone. Tempo standardizes `{hh}` on the default 24-hour expectation, but provides explicit `{h12}` and `{h24}` tokens to completely eliminate ambiguity. This keeps all token definitions fully lowercase and semantic, without relying on uppercase variations like `{HH}`. ```typescript t.format('{h12}:{mi}'); // "03:30pm" (auto-append standard meridiem) diff --git a/packages/tempo/package.json b/packages/tempo/package.json index 690b9f83..b6806072 100644 --- a/packages/tempo/package.json +++ b/packages/tempo/package.json @@ -1,6 +1,6 @@ { "name": "@magmacomputing/tempo", - "version": "3.2.1", + "version": "3.2.2", "engines": { "node": ">=20.0.0" }, diff --git a/packages/tempo/src/module/module.format.ts b/packages/tempo/src/module/module.format.ts index 86dd26c8..d1dacce9 100644 --- a/packages/tempo/src/module/module.format.ts +++ b/packages/tempo/src/module/module.format.ts @@ -1,9 +1,10 @@ import '#library/temporal.polyfill.js'; import { pad, toTitleCase } from '#library/string.library.js'; +import { deepMerge } from '#library/object.library.js'; import { suffix } from '#library/number.library.js'; import { ifNumeric } from '#library/coercion.library.js'; import { isString, isObject, isZonedDateTime, isInstant, isPlainDate, isPlainDateTime, isUndefined, isDefined, isFunction } from '#library/assertion.library.js'; -import { formatDayPeriod, getDTF, getPR } from '#library/international.library.js'; +import { formatDayPeriod, getDTF, getPR, getISOWeekOfYear } from '#library/international.library.js'; import { delegator } from '#library/proxy.library.js'; import { isTempo, enums, Match, getRuntime, NumericPattern, BigIntPattern } from '#tempo/support'; @@ -48,7 +49,7 @@ export function format(obj?: any, fmt?: any, options?: any): any { if (options) { config = { ...baseConfig, ...options }; - if (options.intl) config.intl = { ...baseConfig?.intl, ...options.intl }; + if (options.intl) config.intl = deepMerge(baseConfig?.intl || {}, options.intl); if (options.registry) { config.registry = { ...baseConfig?.registry, ...options.registry }; @@ -122,9 +123,9 @@ export function format(obj?: any, fmt?: any, options?: any): any { ? (formats as Record)[fmt as string] : String(fmt); - // auto-meridiem: if {h12} or {HH} is present and {mer} is absent, append it after the last time component - if (/(?:\{h12|\{HH)/.test(template) && !template.toLowerCase().includes('{mer')) { - const hMatch = template.match(/\{(h12|HH)[^}]*\}/); + // auto-meridiem: if {h12} is present and {mer} is absent, append it after the last time component + if (template.includes('{h12') && !template.includes('{mer')) { + const hMatch = template.match(/\{h12[^}]*\}/); let merMod = ''; let skipMeridiem = false; if (hMatch) { @@ -141,7 +142,7 @@ export function format(obj?: any, fmt?: any, options?: any): any { const matches = [...template.matchAll(rgx)]; return matches.length ? matches[matches.length - 1].index! : -1; } - const hIndex = Math.max(lastSearch(/\{h12[^}]*\}/g), lastSearch(/\{HH[^}]*\}/g)); + const hIndex = lastSearch(/\{h12[^}]*\}/g); const miIndex = lastSearch(/\{mi[^}]*\}/g); const ssIndex = lastSearch(/\{ss[^}]*\}/g); const subIndex = Math.max( @@ -166,8 +167,13 @@ export function format(obj?: any, fmt?: any, options?: any): any { switch (token) { case 'yyyy': res = pad(zdt.year, 4); break; case 'yy': res = pad(zdt.year % 100); break; - case 'yw': res = pad(zdt.yearOfWeek, 4); break; - case 'yyww': res = pad(zdt.yearOfWeek, 4) + pad(zdt.weekOfYear); break; + case 'yw': res = pad(getISOWeekOfYear(zdt).yearOfWeek, 4); break; + case 'ww': case 'wy': res = pad(getISOWeekOfYear(zdt).weekOfYear); break; + case 'yyww': case 'yywy': { + const { weekOfYear, yearOfWeek } = getISOWeekOfYear(zdt); + res = pad(yearOfWeek, 4) + pad(weekOfYear); + break; + } case 'mm': res = pad(zdt.month); break; case 'mon': res = enums.MONTHS.keyOf(zdt.month as any); break; case 'mmm': res = enums.MONTH.keyOf(zdt.month as any); break; @@ -176,15 +182,9 @@ export function format(obj?: any, fmt?: any, options?: any): any { case 'dow': res = zdt.dayOfWeek.toString(); break; case 'wkd': res = enums.WEEKDAYS.keyOf(zdt.dayOfWeek as any); break; case 'www': res = enums.WEEKDAY.keyOf(zdt.dayOfWeek as any); break; - case 'ww': res = pad(zdt.weekOfYear); break; - case 'DAY': res = suffix(zdt.day); break; - case 'WW': res = suffix(zdt.weekOfYear); break; - case 'MM': res = suffix(zdt.month); break; - case 'hh': res = pad(zdt.hour); break; - case 'h12': - case 'HH': res = pad(zdt.hour > 12 ? zdt.hour % 12 : zdt.hour || 12); break; + case 'h24': case 'hh': res = pad(zdt.hour); break; + case 'h12': res = pad(zdt.hour > 12 ? zdt.hour % 12 : zdt.hour || 12); break; case 'mer': res = zdt.hour >= 12 ? 'pm' : 'am'; break; - case 'MER': res = zdt.hour >= 12 ? 'PM' : 'AM'; break; case 'mi': res = pad(zdt.minute); break; case 'ss': res = pad(zdt.second); break; case 'ms': res = pad(zdt.millisecond, 3); break; @@ -194,6 +194,9 @@ export function format(obj?: any, fmt?: any, options?: any): any { case 'dmy': res = `${pad(zdt.day)}${pad(zdt.month)}${pad(zdt.year, 4)}`; break; case 'mdy': res = `${pad(zdt.month)}${pad(zdt.day)}${pad(zdt.year, 4)}`; break; case 'ymd': res = `${pad(zdt.year, 4)}${pad(zdt.month)}${pad(zdt.day)}`; break; + case 'dmy6': res = `${pad(zdt.day)}${pad(zdt.month)}${pad(zdt.year % 100)}`; break; + case 'mdy6': res = `${pad(zdt.month)}${pad(zdt.day)}${pad(zdt.year % 100)}`; break; + case 'ymd6': res = `${pad(zdt.year % 100)}${pad(zdt.month)}${pad(zdt.day)}`; break; case 'hms': res = `${pad(zdt.hour)}${pad(zdt.minute)}${pad(zdt.second)}`; break; case 'ts': res = ((config?.timeStamp ?? 'ms') === 'ss') ? Math.trunc(zdt.epochMilliseconds / 1000).toString() diff --git a/packages/tempo/src/support/support.default.ts b/packages/tempo/src/support/support.default.ts index dc648da4..cc9fa17b 100644 --- a/packages/tempo/src/support/support.default.ts +++ b/packages/tempo/src/support/support.default.ts @@ -5,8 +5,7 @@ import { LOG } from '#library/logger.class.js'; import { NUMBER, MODE, MONTH_DAY } from './support.enum.js'; import { Token } from './support.symbol.js'; -import { IntlDefault } from './support.intl.js'; -import type { Options, AliasContext } from '../tempo.type.js'; +import type { Options, AliasContext, IntlOptions } from '../tempo.type.js'; /** characters allowed inside timezone/calendar brackets */ const bracket_content = /[^\]]+/; @@ -191,6 +190,16 @@ export const Guard = [ 'mondays', 'tuesdays', 'wednesdays', 'thursdays', 'fridays', 'saturdays', 'sundays' ] as const; +/** @internal baseline Intl settings */ +export const IntlDefault: IntlOptions = { + relativeTimeFormat: { + style: 'narrow', + }, + durationFormat: { + style: 'long', + } +} + /** @internal Tempo Default options */ export const Default = secure({ /** log to console */ debug: LOG.Info, diff --git a/packages/tempo/src/support/support.enum.ts b/packages/tempo/src/support/support.enum.ts index 0f8918fc..9617d10e 100644 --- a/packages/tempo/src/support/support.enum.ts +++ b/packages/tempo/src/support/support.enum.ts @@ -84,7 +84,7 @@ export const DEFAULTS = { /** display with Time */ dayTime: '{dd}-{mmm}-{yyyy} {hh}:{mi}:{ss}', /** useful for stamping logs */ logStamp: '{ymd}T{hms}.{ff}', /** useful for sorting display-strings */ sortTime: '{yyyy}-{mm}-{dd} {hh}:{mi}:{ss}', - /** useful for sorting week order */ yearWeek: '{yw}{ww}', + /** useful for sorting week order */ yearWeek: '{yw}{wy}', /** useful for sorting month order */ yearMonth: '{yyyy}{mm}', /** useful for sorting date order */ yearMonthDay: '{ymd}', /** just Date portion */ date: '{yyyy}-{mm}-{dd}', @@ -182,7 +182,7 @@ export type FORMAT = typeof FORMAT; export type Format = LooseUnion & string> /** patterns that return a number */ -export const NumericPattern = ['{yyyy}{ww}', '{yyyy}{mm}', '{yyyy}{mm}{dd}', '{yyww}', '{yw}{ww}', '{yw}', '{ymd}', '{ymd6}'] as const; +export const NumericPattern = ['{yyyy}{wy}', '{yyyy}{mm}', '{yyyy}{mm}{dd}', '{yywy}', '{yw}{wy}', '{yw}', '{ymd}', '{ymd6}', '{hms}', '{ff}', '{dmy}', '{dmy6}', '{mdy}', '{mdy6}'] as const; export type NumericPattern = typeof NumericPattern[number] /** patterns that return a bigint */ @@ -214,11 +214,11 @@ export const MONTH_DAY = proxify(STATE.MONTH_DAY, true, false); export const LOCALE = proxify(STATE.LOCALE, true, true); /** date-time element tokens */ -const elementKeys = ['yy', 'mm', 'ww', 'dd', 'hh', 'mi', 'ss', 'ms', 'us', 'ns'] as const; +const elementKeys = ['yy', 'mm', 'wy', 'dd', 'hh', 'mi', 'ss', 'ms', 'us', 'ns'] as const; export const ELEMENT = enumify({ yy: 'year', mm: 'month', - ww: 'week', + wy: 'week', dd: 'day', hh: 'hour', mi: 'minute', diff --git a/packages/tempo/src/support/support.init.ts b/packages/tempo/src/support/support.init.ts index 99702fc1..6d61331f 100644 --- a/packages/tempo/src/support/support.init.ts +++ b/packages/tempo/src/support/support.init.ts @@ -4,6 +4,7 @@ import { asArray } from '#library/coercion.library.js'; import { getDateTimeFormat, getHemisphere, canonicalLocale } from '#library/international.library.js'; import { normalizeUtcOffset } from '#library/temporal.library.js'; import { markConfig } from '#library/symbol.library.js'; +import { deepMerge } from '#library/object.library.js'; import { asType } from '#library/type.library.js'; import { isString, isObject, isUndefined, isDefined, isRegExp, isEmpty } from '#library/assertion.library.js'; import { ScopedSet } from '#library/scopedset.class.js'; @@ -289,7 +290,7 @@ export function extendState(state: t.Internal.State, options: t.Options): boolea case 'intl': if (!isObject(state.config.intl)) setProperty(state.config, 'intl', {}); - state.config.intl = { ...state.config.intl, ...arg.value }; + state.config.intl = deepMerge(state.config.intl, arg.value); break; case 'planner': diff --git a/packages/tempo/src/support/support.intl.ts b/packages/tempo/src/support/support.intl.ts deleted file mode 100644 index 83bb34a9..00000000 --- a/packages/tempo/src/support/support.intl.ts +++ /dev/null @@ -1,56 +0,0 @@ -import type { IntlOptions } from '../tempo.type.js'; -import { isObject } from '#library/assertion.library.js'; - -/** @internal baseline Intl settings */ -export const IntlDefault: IntlOptions = { - relativeTimeFormat: { - style: 'narrow', - }, - durationFormat: { - style: 'long', - } -} - -/** - * @internal - * Probe the runtime to see if the locale defaults to Month-Day-Year order. - * This is a heuristic check used during Tempo.init(). - */ -export function probeMDY(locale: string): boolean { - try { - // Use Dec 24th to check if '12' comes first - const date = new Date(2024, 11, 24); - const parts = new Intl.DateTimeFormat(locale).formatToParts(date); - return parts[0].type === 'month' && parts[0].value === '12'; - } catch { - return false; - } -} - -/** - * @internal - * Normalize and merge Intl configuration options. - * @param value The user-supplied options to merge. - * @param base The base configuration to merge against. - */ -export function resolveIntl(value: IntlOptions = {}, base: IntlOptions = IntlDefault): IntlOptions { - const result = { ...base } as Record; - const intls = ['relativeTimeFormat', 'numberFormat', 'durationFormat']; - - Object - .entries(value) - .forEach(([k, v]) => { - if (intls.includes(k) && isObject(v)) { - const current = result[k]; - - result[k] = { - ...(isObject(current) ? current as object : {}), - ...v as any - }; - } else { - result[k] = v; - } - }); - - return result; -} diff --git a/packages/tempo/src/tempo.class.ts b/packages/tempo/src/tempo.class.ts index 575fe25c..9e4b18a6 100644 --- a/packages/tempo/src/tempo.class.ts +++ b/packages/tempo/src/tempo.class.ts @@ -13,7 +13,7 @@ import { getType } from '#library/type.library.js'; import { clone } from '#library/serialize.library.js'; import { isEmpty, isDefined, isUndefined, isString, isObject, isSymbol, isFunction, isClass, isZonedDateTime, isDurationLike, isError, isNumber } from '#library/assertion.library.js'; import { instant, getTemporalIds } from '#library/temporal.library.js'; -import { getDateTimeFormat, getHemisphere, canonicalLocale } from '#library/international.library.js'; +import { getDateTimeFormat, getHemisphere, canonicalLocale, getISOWeekOfYear } from '#library/international.library.js'; import { LOG } from '#library/logger.class.js'; import type { Property, Secure } from '#library/type.library.js'; @@ -1373,9 +1373,10 @@ export class Tempo { } /** 4-digit year (e.g., 2024) */ get yy() { return this.toDateTime().year } - /** 4-digit iso week-numbering year */ get yw() { return this.toDateTime().yearOfWeek } + /** 4-digit iso week-numbering year */ get yw() { return getISOWeekOfYear(this.toDateTime()).yearOfWeek; } /** Month number: Jan=1, Dec=12 */ get mm() { return this.toDateTime().month as t.mm } - /** iso week number of the year */ get ww() { return this.toDateTime().weekOfYear as t.ww } + /** iso week number of the year */ get wy() { return getISOWeekOfYear(this.toDateTime()).weekOfYear as t.wy; } + /** @deprecated use `wy` */ get ww() { return getISOWeekOfYear(this.toDateTime()).weekOfYear as t.wy; } /** Day of the month (1-31) */ get dd() { return this.toDateTime().day } /** Day of the month (alias for `dd`) */ get day() { return this.toDateTime().day } /** Hour of the day (0-23) */ get hh() { return this.toDateTime().hour as t.hh } @@ -1667,7 +1668,9 @@ export namespace Tempo { export type ms = t.ms; export type us = t.us; export type ns = t.ns; - export type ww = t.ww; + export type wy = t.wy; + /** @deprecated use `wy` */ + export type ww = t.wy; export type Duration = t.Duration; diff --git a/packages/tempo/src/tempo.type.ts b/packages/tempo/src/tempo.type.ts index 5c52c241..8f78cef2 100644 --- a/packages/tempo/src/tempo.type.ts +++ b/packages/tempo/src/tempo.type.ts @@ -145,6 +145,8 @@ export type ss = IntRange<0, 60> export type ms = IntRange<0, 999> export type us = IntRange<0, 999> export type ns = IntRange<0, 999> +export type wy = IntRange<1, 53> +/** @deprecated use `wy` */ export type ww = IntRange<1, 53> export type Duration = NonOptional & Record<"iso", string> & Record<"sign", number> & Record<"blank", boolean> & Record<"unit", string | undefined> & { diff --git a/packages/tempo/src/tempo.version.ts b/packages/tempo/src/tempo.version.ts index b3301369..5f794148 100644 --- a/packages/tempo/src/tempo.version.ts +++ b/packages/tempo/src/tempo.version.ts @@ -5,4 +5,4 @@ * ⚠️ This file is auto-updated by `npm run build:version` (see `bin/update-version.mjs`). * Do NOT edit manually — your changes will be overwritten on the next build. */ -export const TEMPO_VERSION = '3.2.1'; +export const TEMPO_VERSION = '3.2.2'; diff --git a/packages/tempo/test/core/static.test.ts b/packages/tempo/test/core/static.test.ts index 9a76bcdc..ddd859c5 100644 --- a/packages/tempo/test/core/static.test.ts +++ b/packages/tempo/test/core/static.test.ts @@ -9,7 +9,7 @@ describe(`${label}`, () => { test(`${label} get the properties`, () => { expect(Tempo.properties.toSorted()) - .toEqual(['yy', 'yw', 'mm', 'dd', 'hh', 'mi', 'ss', 'ms', 'us', 'ns', 'ff', 'fmt', 'ww', 'tz', 'cal', 'ts', 'dow', 'mmm', 'mon', 'www', 'wkd', 'day', 'nano', 'term', 'terms', 'config', 'epoch', 'parse', 'ranges', 'isValid', 'iso'].toSorted()) + .toEqual(['yy', 'yw', 'mm', 'dd', 'hh', 'mi', 'ss', 'ms', 'us', 'ns', 'ff', 'fmt', 'ww', 'wy', 'tz', 'cal', 'ts', 'dow', 'mmm', 'mon', 'www', 'wkd', 'day', 'nano', 'term', 'terms', 'config', 'epoch', 'parse', 'ranges', 'isValid', 'iso'].toSorted()) }) test(`${label} get the elements`, () => { diff --git a/packages/tempo/test/discrete/format.test.ts b/packages/tempo/test/discrete/format.test.ts index b350ae8c..a82cbe6c 100644 --- a/packages/tempo/test/discrete/format.test.ts +++ b/packages/tempo/test/discrete/format.test.ts @@ -109,11 +109,6 @@ describe('Tempo.format() refinements', () => { it('handles non-time tokens in between', () => { expect(tAM.format('{h12} on {mon}')).toBe('10am on May'); }) - - it('supports {HH} for backward compatibility', () => { - expect(tAM.format('{HH}:{mi}')).toBe('10:30am'); - expect(tPM.format('{HH}:{mi}')).toBe('10:30pm'); - }) }) describe('manual-localize', () => { diff --git a/packages/tempo/test/engine/timezone_offset.test.ts b/packages/tempo/test/engine/timezone_offset.test.ts index f7b68715..85a9334b 100644 --- a/packages/tempo/test/engine/timezone_offset.test.ts +++ b/packages/tempo/test/engine/timezone_offset.test.ts @@ -5,14 +5,14 @@ describe('Tempo TimeZone Offset', () => { Tempo.init() }) - it('should accept +HH:MM syntax for timeZone', () => { + it('should accept +hh:mi syntax for timeZone', () => { const t = new Tempo('2024-01-01T12:00:00', { timeZone: '+05:00' }); expect(t.config.timeZone).toBe('+05:00'); // verify config // 12:00 at +05:00 is 07:00 UTC expect(t.format('{yyyy}-{mm}-{dd} {hh}:{mi}:{ss} {tz}')).toContain('+05:00'); // verify format }); - it('should accept -HH:MM syntax for timeZone', () => { + it('should accept -hh:mi syntax for timeZone', () => { const t = new Tempo('2024-01-01T12:00:00', { timeZone: '-05:00' }); expect(t.config.timeZone).toBe('-05:00'); expect(t.format('{yyyy}-{mm}-{dd} {hh}:{mi}:{ss} {tz}')).toContain('-05:00'); diff --git a/packages/tempo/test/instance/instance.format.test.ts b/packages/tempo/test/instance/instance.format.test.ts index 7df82596..4c88b424 100644 --- a/packages/tempo/test/instance/instance.format.test.ts +++ b/packages/tempo/test/instance/instance.format.test.ts @@ -7,7 +7,7 @@ describe(`${label} format method`, () => { test('formats with standard tokens', () => { const t = new Tempo('2024-05-20 15:30:00'); expect(t.format('{yyyy}-{mm}-{dd}')).toBe('2024-05-20'); - // hh is 24-hour hour. HH is 12-hour hour. + // hh is 24-hour hour. h12 is 12-hour hour. expect(t.format('{hh}:{mi}')).toBe('15:30'); }); @@ -47,7 +47,7 @@ describe(`${label} format method`, () => { test('delegates format(options) directly to native Intl and handles strict Temporal bounds', () => { const t = new Tempo('2024-12-25T14:30:00Z'); - + const arabicConfig = { locale: 'ar-EG', timeZone: 'Africa/Cairo', @@ -66,7 +66,7 @@ describe(`${label} format method`, () => { test('delegates format(options) directly to native Intl for Japanese Reiwa era formatting', () => { const t = new Tempo('2024-12-25T14:30:00Z'); - + const japaneseConfig = { locale: 'ja-JP-u-ca-japanese', timeZone: 'Asia/Tokyo',