diff --git a/package-lock.json b/package-lock.json index 1c24b0c0..af1d2f80 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "tempo-monorepo", - "version": "3.0.3", + "version": "3.1.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "tempo-monorepo", - "version": "3.0.3", + "version": "3.1.1", "workspaces": [ "packages/*" ], @@ -9930,7 +9930,7 @@ }, "packages/library": { "name": "@magmacomputing/library", - "version": "3.1.0", + "version": "3.1.1", "license": "MIT", "dependencies": { "tslib": "^2.8.1" @@ -9941,14 +9941,14 @@ }, "packages/tempo": { "name": "@magmacomputing/tempo", - "version": "3.1.0", + "version": "3.1.1", "license": "MIT", "dependencies": { "tslib": "^2.8.1" }, "devDependencies": { "@js-temporal/polyfill": "^0.5.1", - "@magmacomputing/library": "3.1.0", + "@magmacomputing/library": "*", "@rollup/plugin-alias": "^6.0.0", "javascript-obfuscator": "^5.4.3", "magic-string": "^0.30.21", diff --git a/package.json b/package.json index ac375a52..d1594e93 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "tempo-monorepo", - "version": "3.1.0", + "version": "3.1.1", "private": true, "engines": { "node": ">=20.0.0" @@ -59,4 +59,4 @@ "edgedriver@6.3.0": true, "geckodriver@6.1.0": true } -} \ No newline at end of file +} diff --git a/packages/library/package.json b/packages/library/package.json index c90fcb9f..e9ea3102 100644 --- a/packages/library/package.json +++ b/packages/library/package.json @@ -1,6 +1,6 @@ { "name": "@magmacomputing/library", - "version": "3.1.0", + "version": "3.1.1", "description": "Shared utility library for Tempo", "author": "Magma Computing Solutions", "license": "MIT", diff --git a/packages/library/src/common/coercion.library.ts b/packages/library/src/common/coercion.library.ts index 7483ea46..4f75a8f5 100644 --- a/packages/library/src/common/coercion.library.ts +++ b/packages/library/src/common/coercion.library.ts @@ -49,14 +49,23 @@ export function asInteger(str?: T) { /** return as Number if possible, else original String */ export const ifNumeric = (str: string | number | bigint, stripZero = false) => { switch (true) { - case isInteger(str): // BigInt → Number - return Number(str); + case isInteger(str): { + const big = str as bigint; + if (big > BigInt(Number.MAX_SAFE_INTEGER) || big < BigInt(Number.MIN_SAFE_INTEGER)) return big; + return Number(big); + } - case isNumber(str): // Number → as-is + case isNumber(str): return str; - case isNumeric(str) && (!str?.toString().startsWith('0') || stripZero): - return asNumber(str); // numeric String → Number + case isNumeric(str) && (!str?.toString().startsWith('0') || stripZero): { + const numStr = String(str); + if (/^-?[0-9]+$/.test(numStr)) { + const big = BigInt(numStr); + if (big > BigInt(Number.MAX_SAFE_INTEGER) || big < BigInt(Number.MIN_SAFE_INTEGER)) return big; + } + return asNumber(str); + } default: return str as string; // non-numeric String → as-is diff --git a/packages/library/src/server/file.library.ts b/packages/library/src/server/file.library.ts index 62057890..5fb513d6 100644 --- a/packages/library/src/server/file.library.ts +++ b/packages/library/src/server/file.library.ts @@ -31,7 +31,7 @@ export class File { return targetPath; } - static read = (file: string): Promise => new Promise((resolve, reject) => { + static read = (file: string): Promise => new Promise((resolve, reject) => { try { const target = File._resolvePath(file); fs.readFile(target, File.encoding, (err, data) => { diff --git a/packages/tempo/CHANGELOG.md b/packages/tempo/CHANGELOG.md index c3443c08..087c95a8 100644 --- a/packages/tempo/CHANGELOG.md +++ b/packages/tempo/CHANGELOG.md @@ -6,6 +6,17 @@ 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.1.1] - 2026-06-14 + +### Added +- **Tokens & Modifiers**: Added `{h12}` for 12-hour clock output with automatic meridiem injection, `{cal}` for calendar system resolution, and the `:raw` modifier for stripping leading zeros and suppressing auto-meridiem. +- **Dynamic Format Options**: The `.format()` method on `Tempo` instances now natively accepts an optional configuration object (e.g., `{ locale: 'fr-FR' }`) as its second argument, aligning its signature with native `Intl` overrides. + +### Changed +- **Automatic Meridiem Injection**: Rewritten to derive modifiers directly from the matched `{h12}` or `{HH}` token, properly applying casing and placement. +- **Zero-Padded Documentation**: Refined format token documentation to explicitly refer to fixed-length representations as "Zero-padded" (e.g., "Zero-padded Month"), clarifying the exact behavior of the `:raw` modifier. +- **Strict ISO Vocabulary**: Updated documentation for `{dow}`, `{ww}`, and `{yw}` to explicitly designate them as ISO standards, removing ambiguity with native JavaScript `Date` offsets. + ## [3.1.0] - 2026-06-13 ### Added diff --git a/packages/tempo/README.md b/packages/tempo/README.md index efcaf6d6..a639f629 100644 --- a/packages/tempo/README.md +++ b/packages/tempo/README.md @@ -48,7 +48,7 @@ const diff = event.until('2026-12-25'); console.log(diff.iso); // P2M2D // 📝 Beautiful Formatting -console.log(event.format('{mon} {day}, {yyyy}')); // October 23, 2026 +console.log(event.format('{mon} {dd:ord}, {yyyy}')); // October 23rd, 2026 ``` --- diff --git a/packages/tempo/doc/installation.md b/packages/tempo/doc/installation.md index f68f88a8..e6d13cf9 100644 --- a/packages/tempo/doc/installation.md +++ b/packages/tempo/doc/installation.md @@ -121,7 +121,7 @@ The easiest way to use Tempo natively in the browser is via the pre-optimized ES import { Tempo } from '@magmacomputing/tempo'; const t = new Tempo('tomorrow'); - console.log(t.format('{mon} {day}')); + console.log(t.format('{mon} {dd:ord}')); ``` diff --git a/packages/tempo/doc/tempo.cookbook.md b/packages/tempo/doc/tempo.cookbook.md index c4067648..73ef8a19 100644 --- a/packages/tempo/doc/tempo.cookbook.md +++ b/packages/tempo/doc/tempo.cookbook.md @@ -398,16 +398,17 @@ You can extend the built-in registries (e.g. `formats`, `locales`) and toggle fo ```typescript Tempo.init({ + locale: 'fr-FR', registry: { formats: { - 'customDate': '{yyyy}-{mm}-{dd} {HH}:{mi}' + 'customDate': '{dd} {mon:upper} {yyyy} - {h12}:{mi}' } }, format: { - localize: true // Enable automatic localized number formatting + localize: true // Automatically applies the :locale modifier to {mon} } }); const t = new Tempo('2026-06-03 14:30'); -console.log(t.format('customDate')); // "2026-06-03 14:30" +console.log(t.format('customDate')); // "03 JUIN 2026 - 02:30pm" ``` diff --git a/packages/tempo/doc/tempo.format.md b/packages/tempo/doc/tempo.format.md index 5d6f7f0f..b4c592c1 100644 --- a/packages/tempo/doc/tempo.format.md +++ b/packages/tempo/doc/tempo.format.md @@ -10,9 +10,9 @@ If you have a native `Temporal.ZonedDateTime` and want to format it using Tempo' import { format } from '@magmacomputing/tempo/format'; const zdt = Temporal.Now.zonedDateTimeISO(); -const str = format(zdt, '{mon} {day}, {yyyy}'); +const str = format(zdt, '{mon} {dd:ord}, {yyyy}'); -console.log(str); // e.g., "October 24, 2026" +console.log(str); // e.g., "October 24th, 2026" ``` ::: warning @@ -45,7 +45,7 @@ Tempo comes with several pre-configured format aliases. You can also define your ```typescript Tempo.init({ formats: { - 'fancy': '{mon} the {dd}th day of {yyyy}' + 'fancy': '{mon} the {dd:ord} day of {yyyy}' } }); @@ -78,54 +78,100 @@ Tempo.extend(FormatModule); ## 🔠 Supported Tokens +> [!NOTE] +> **Tempo is heavily opinionated.** To provide maximum predictability and eliminate common timezone or regional bugs, Tempo strictly defaults to **ISO-8601 standards**. This means weeks always start on Monday (`1`), and mathematical bounds (like week-of-year and year-of-week calculations) adhere to the rigorous ISO specification. + | Token | Description | Example | | :--- | :--- | :--- | | `{yyyy}` | 4-digit Year | `2026` | | `{yy}` | 2-digit Year | `26` | -| `{yw}` | Year of Week (ISO) | `2026` | -| `{yyww}` | Year & Week (ISO) | `202617` | +| `{yw}` | ISO Year of Week | `2026` | +| `{yyww}` | ISO Year & Week | `202617` | | `{mon}` | Full Month Name | `October` | | `{mmm}` | Short Month Name | `Oct` | -| `{mm}` | 2-digit Month | `10` | -| `{MM}` | Ordinal Month | `10th` | -| `{dd}` | 2-digit Day | `24` | -| `{day}` | Unpadded Day | `24` (or `9`) | -| `{DAY}` | Ordinal Day | `24th` (or `9th`) | +| `{mm}` | Zero-padded Month | `10` | +| `{dd}` | Zero-padded Day | `24` | | `{wkd}` | Full Weekday Name | `Saturday` | | `{www}` | Short Weekday Name | `Sat` | -| `{dow}` | Day of Week (1-7) | `6` | -| `{ww}` | Week of Year | `43` | -| `{WW}` | Ordinal Week of Year | `43rd` | -| `{hh}` | 2-digit Hour (24h) | `15` | -| `{HH}` | 2-digit Hour (12h) | `03` | +| `{dow}` | ISO Day of Week (1=Mon, 7=Sun) | `6` | +| `{ww}` | Zero-padded ISO Week of Year | `43` | +| `{hh}` | Zero-padded Hour (24h) | `15` | +| `{h12}` | Zero-padded Hour (12h) plus meridiem | `03pm` | | `{mer}` | am/pm marker | `pm` | -| `{MER}` | AM/PM marker | `PM` | -| `{mi}` | Minutes | `30` | -| `{ss}` | Seconds | `45` | +| `{mi}` | Zero-padded Minutes | `30` | +| `{ss}` | Zero-padded Seconds | `45` | | `{dmy}` | Compact Date (ddmmyyyy) | `24102026` | | `{mdy}` | Compact Date (mmddyyyy) | `10242026` | | `{ymd}` | Compact Date (yyyymmdd) | `20261024` | | `{hms}` | Compact Time (24h) | `153045` | -| `{ms}` | 3-digit Milliseconds | `123` | -| `{us}` | 3-digit Microseconds | `456` | -| `{ns}` | 3-digit Nanoseconds | `789` | +| `{ms}` | Zero-padded Milliseconds (3-digit) | `123` | +| `{us}` | Zero-padded Microseconds (3-digit) | `456` | +| `{ns}` | Zero-padded Nanoseconds (3-digit) | `789` | | `{ff}` | Fractional Seconds | `123456789` | | `{ts}` | Unix Timestamp | `1792843200000` | | `{nano}` | Nanosecond Timestamp | `1792843200000000000` | | `{tz}` | Time Zone ID | `Australia/Sydney` | +| `{cal}` | Calendar System | `iso8601` | + +### 🎛️ Token Modifiers +You can append modifiers to any token using a colon (`:`) to transform its output. Multiple modifiers can be chained together (e.g., `{mon:locale:title}`). + +| Modifier | Target | Description | Example | +| :--- | :--- | :--- | :--- | +| `:raw` | Number | Unpadded number, no meridiem | `{hh:raw}` → `3` | +| `:ord` | Number | Appends an ordinal suffix (unpadded) | `{dd:ord}` → `24th` | +| `:upper` | String | Converts to uppercase | `{mer:upper}` → `PM` | +| `:lower` | String | Converts to lowercase | `{mon:lower}` → `october` | +| `:title` | String | Converts to titlecase | `{mon:locale:title}` → `Octobre` | +| `:locale` | String | Resolves term via localization dictionary | `{mon:locale}` → `octobre` | ### 🔄 Automatic Meridiem -If your format string contains `{HH}` (12-hour clock) but lacks a `{mer}` or `{MER}` token, Tempo will automatically append `{mer}` to the end of the last time component to ensure the time remains unambiguous. +If your format string contains `{h12}` (12-hour clock) but lacks a `{mer}` token, Tempo will automatically append a `{mer}` token with the same modifiers as the `{h12}` token after the last time component to ensure the time remains unambiguous. + +*(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. ```typescript -t.format('{HH}:{mi}'); // "03:30pm" (auto-appended pm) +t.format('{h12}:{mi}'); // "03:30pm" (auto-append meridiem) +t.format('{h12:upper}:{mi}'); // "03:30PM" (auto-append meridiem) +t.format('{h12:raw}:{mi}'); // "3:30" (no meridiem added) +t.format('{h12:raw}:{mi} {mer}'); // "3:30 am" (blank + meridiem added manually) ``` ### 🔢 Numeric Resolution -If your format string consists *only* of numeric tokens (e.g., `{yyyy}{mm}{dd}`), the `format()` function will return a **Number** instead of a string. This is useful for generating sortable keys or IDs. +If your format string consists *only* of numeric tokens (e.g., `{yyyy}{mm}{dd}`), the `format()` function will automatically coerce the output to a numeric primitive instead of a string. This is useful for generating sortable keys or IDs. + +For standard lengths, it returns a **Number**. However, if the resulting value exceeds JavaScript's `Number.MAX_SAFE_INTEGER` (such as the `{nano}` token), Tempo safely upgrades the return type to a **BigInt** to prevent precision loss. ```typescript +// Standard Numeric Format -> Number const key = t.format('{yyyy}{mm}{dd}'); console.log(typeof key); // "number" console.log(key); // 20261024 + +// Large Numeric Format -> BigInt +const epoch = t.format('{nano}'); +console.log(typeof epoch); // "bigint" +console.log(epoch); // 1792843200000000000n +``` + +### 📝 Common Formatting Examples +Here are a few real-world examples demonstrating how tokens and modifiers can be composed together to build readable sentences and structured strings. + +```typescript +const t = new Tempo('2026-10-05T15:30:00'); + +// Injecting tokens directly into sentences +t.format('Today is the {dd:ord} of {mon:title}'); +// "Today is the 5th of October" + +// Building standard UI formats +t.format('{wkd}, {mmm} {dd:raw}, {yyyy} @ {h12}:{mi}'); +// "Monday, Oct 5, 2026 @ 03:30pm" + +// Forcing fully lowercase strings +t.format('{wkd:lower} afternoon'); +// "monday afternoon" ``` diff --git a/packages/tempo/doc/tempo.registry.md b/packages/tempo/doc/tempo.registry.md index 136a13ef..47dea5d3 100644 --- a/packages/tempo/doc/tempo.registry.md +++ b/packages/tempo/doc/tempo.registry.md @@ -12,7 +12,7 @@ In v3.1.0+, you can access active registries using the static `Tempo.registry` g import { Tempo } from '@magmacomputing/tempo'; console.log(Tempo.registry.formats); -// { '{iso}': '{yyyy}-{mm}-{dd}T{HH}:{mi}:{ss}', ... } +// { '{iso}': '{yyyy}-{mm}-{dd}T{hh}:{mi}:{ss}', ... } ``` ::: warning diff --git a/packages/tempo/package.json b/packages/tempo/package.json index 962de83e..5c5c5dfb 100644 --- a/packages/tempo/package.json +++ b/packages/tempo/package.json @@ -1,6 +1,6 @@ { "name": "@magmacomputing/tempo", - "version": "3.1.0", + "version": "3.1.1", "engines": { "node": ">=20.0.0" }, @@ -211,7 +211,7 @@ }, "devDependencies": { "@js-temporal/polyfill": "^0.5.1", - "@magmacomputing/library": "3.1.0", + "@magmacomputing/library": "*", "@rollup/plugin-alias": "^6.0.0", "javascript-obfuscator": "^5.4.3", "magic-string": "^0.30.21", diff --git a/packages/tempo/public/bundle.index.html b/packages/tempo/public/bundle.index.html index 20bdb9f5..ed6bffd0 100644 --- a/packages/tempo/public/bundle.index.html +++ b/packages/tempo/public/bundle.index.html @@ -208,7 +208,7 @@

Tempo

import { Tempo } from '@magmacomputing/tempo'; const t = new Tempo('next friday'); -t.format('{mon} {day}'); +t.format('{mon} {dd:raw}');
@@ -226,7 +226,7 @@

Tempo

{ "imports": { "jsbi": "https://cdn.jsdelivr.net/npm/jsbi@4.3.0/dist/jsbi.mjs", - "@magmacomputing/tempo": "https://cdn.jsdelivr.net/npm/@magmacomputing/tempo@2/dist/tempo.bundle.esm.js", + "@magmacomputing/tempo": "https://cdn.jsdelivr.net/npm/@magmacomputing/tempo@3.1.1/dist/tempo.bundle.esm.js", "@js-temporal/polyfill": "https://cdn.jsdelivr.net/npm/@js-temporal/polyfill@0.5/dist/index.esm.js" } } @@ -237,10 +237,10 @@

Tempo

try { const t = new Tempo('next friday'); - console.log('Tempo execution successful:', t.format('{mon} {day}')); + console.log('Tempo execution successful:', t.format('{mon} {dd:raw}')); const resEl = document.getElementById('result'); - resEl.textContent = t.format('{mon} {day}'); + resEl.textContent = t.format('{mon} {dd:raw}'); resEl.classList.remove('pulse-loading'); } catch (e) { console.error(e); diff --git a/packages/tempo/src/module/module.format.ts b/packages/tempo/src/module/module.format.ts index a2130bae..2659669f 100644 --- a/packages/tempo/src/module/module.format.ts +++ b/packages/tempo/src/module/module.format.ts @@ -13,7 +13,7 @@ import type { Tempo } from '../tempo.class.js'; declare module '../tempo.class.js' { interface Tempo { - /** applies a format to the instance. */ format(fmt: any): any; + /** applies a format to the instance. */ format(fmt: any, options?: any): any; } } @@ -30,11 +30,22 @@ declare module '../tempo.class.js' { * const stamp = format().logStamp; // defaults to 'Now' */ export function format(obj?: Temporal.ZonedDateTime | any): any; -export function format(obj: Temporal.ZonedDateTime | any, fmt: NumericPattern): number; -export function format(obj: Temporal.ZonedDateTime | any, fmt: string | symbol): string; -export function format(obj?: Temporal.ZonedDateTime | any, fmt?: string | symbol): string | number | any { +export function format(obj: Temporal.ZonedDateTime | any, fmt: NumericPattern, options?: any): number; +export function format(obj: Temporal.ZonedDateTime | any, fmt: string | symbol, options?: any): string; +export function format(obj?: Temporal.ZonedDateTime | any, fmt?: string | symbol, options?: any): string | number | any { const state = getRuntime().state; - const config = isTempo(obj) ? obj.config : state?.config; + const baseConfig = isTempo(obj) ? obj.config : state?.config; + let config = baseConfig; + if (options) { + config = { ...baseConfig, ...options }; + if (options.intl) config.intl = { ...baseConfig?.intl, ...options.intl }; + if (options.format) config.format = { ...baseConfig?.format, ...options.format }; + if (options.registry) { + config.registry = { ...baseConfig?.registry, ...options.registry }; + if (options.registry.formats) config.registry.formats = { ...baseConfig?.registry?.formats, ...options.registry.formats }; + if (options.registry.locales) config.registry.locales = { ...baseConfig?.registry?.locales, ...options.registry.locales }; + } + } const formats = Object.assign({}, enums.FORMAT, config?.registry?.formats); const tz = config?.timeZone ?? 'UTC'; @@ -76,12 +87,40 @@ export function format(obj?: Temporal.ZonedDateTime | any, fmt?: string | symbol ? (formats as Record)[fmt as string] : String(fmt); - // auto-meridiem: if {HH} is present and {mer} is absent, append it after the last time component - if (template.includes('{HH}') && !template.toLowerCase().includes('{mer')) { - const index = Math.max(template.lastIndexOf('{HH}'), template.lastIndexOf('{mi}'), template.lastIndexOf('{ss}')); - if (index !== -1) { - const end = template.indexOf('}', index) + 1; - template = template.slice(0, end) + '{mer}' + template.slice(end); + // 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)[^}]*\}/); + let merMod = ''; + let skipMeridiem = false; + if (hMatch) { + const modifiers = hMatch[0].toLowerCase(); + if (modifiers.includes('raw')) skipMeridiem = true; + if (modifiers.includes('upper')) merMod = ':upper'; + else if (modifiers.includes('lower')) merMod = ':lower'; + + if (modifiers.includes('locale')) merMod += ':locale'; + } + + if (!skipMeridiem) { + const lastSearch = (rgx: RegExp) => { + 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 miIndex = lastSearch(/\{mi[^}]*\}/g); + const ssIndex = lastSearch(/\{ss[^}]*\}/g); + const subIndex = Math.max( + lastSearch(/\{ms[^}]*\}/g), + lastSearch(/\{us[^}]*\}/g), + lastSearch(/\{ns[^}]*\}/g), + lastSearch(/\{ff[^}]*\}/g) + ); + const index = Math.max(hIndex, miIndex, ssIndex, subIndex); + + if (index !== -1) { + const end = template.indexOf('}', index) + 1; + template = template.slice(0, end) + `{mer${merMod}}` + template.slice(end); + } } } @@ -111,6 +150,7 @@ export function format(obj?: Temporal.ZonedDateTime | any, fmt?: string | symbol 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 'mer': res = zdt.hour >= 12 ? 'pm' : 'am'; break; case 'MER': res = zdt.hour >= 12 ? 'PM' : 'AM'; break; @@ -129,6 +169,7 @@ export function format(obj?: Temporal.ZonedDateTime | any, fmt?: string | symbol : zdt.epochMilliseconds.toString(); break; case 'nano': res = zdt.epochNanoseconds.toString(); break; case 'tz': res = zdt.timeZoneId; break; + case 'cal': res = zdt.calendarId; break; default: { if (token.startsWith('#') && isTempo(obj)) { const termObj = (obj as unknown as Tempo).term[token.slice(1)]; @@ -160,6 +201,10 @@ export function format(obj?: Temporal.ZonedDateTime | any, fmt?: string | symbol case 'ord': res = suffix(parseInt(String(res), 10)); break; + case 'raw': + if (/^[0-9]+$/.test(String(res))) + res = BigInt(String(res)).toString(); + break; case 'locale': { try { if (token.startsWith('#') && isTempo(obj)) { @@ -230,7 +275,7 @@ export function format(obj?: Temporal.ZonedDateTime | any, fmt?: string | symbol * Format Module Plugin */ // @ts-ignore -export const FormatModule: Tempo.Module = defineInterpreterModule('FormatModule', function (this: Tempo, fmt: any) { +export const FormatModule: Tempo.Module = defineInterpreterModule('FormatModule', function (this: Tempo, fmt: any, options?: any) { if (!this.isValid) return '' as unknown as any; - return format(this, fmt); + return format(this, fmt, options); }); diff --git a/packages/tempo/src/tempo.class.ts b/packages/tempo/src/tempo.class.ts index 780b0417..aa4d3e65 100644 --- a/packages/tempo/src/tempo.class.ts +++ b/packages/tempo/src/tempo.class.ts @@ -1482,7 +1482,7 @@ export class Tempo { */ /** @internal */ get #Tempo() { return this.constructor as typeof Tempo; } - /** apply a custom format. */ format(fmt: K) { return this.#resolve(() => interpret(this, 'FormatModule', () => `{${String(fmt)}}`, false, fmt)); } + /** apply a custom format. */ format(fmt: K, options?: any) { return this.#resolve(() => interpret(this, 'FormatModule', () => `{${String(fmt)}}`, false, fmt, options)); } /** time duration until another date-time */ until(arg0?: any, arg1?: any): any { return this.#resolve(() => interpret(this, 'DurationModule', undefined, false, 'until', arg0, arg1) ?? this); } /** time elapsed since another date-time */ since(arg0?: any, arg1?: any): any { return this.#resolve(() => interpret(this, 'DurationModule', undefined, false, 'since', arg0, arg1) ?? this); } diff --git a/packages/tempo/src/tempo.version.ts b/packages/tempo/src/tempo.version.ts index 0b403b24..53f948ed 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.1.0'; +export const TEMPO_VERSION = '3.1.1'; diff --git a/packages/tempo/test/discrete/format.test.ts b/packages/tempo/test/discrete/format.test.ts index 546d2ad3..c0bba03d 100644 --- a/packages/tempo/test/discrete/format.test.ts +++ b/packages/tempo/test/discrete/format.test.ts @@ -32,33 +32,74 @@ describe('Tempo.format() refinements', () => { expect(typeof t3.format('{yw}{ww}')).toBe('number'); }) + it('accepts an options object as the second argument to override configuration', () => { + const t3 = new Tempo('2024-10-05T10:30:45', { locale: 'en-US' }); + // The default locale is en-US which outputs English. We override it to fr-FR here. + expect(t3.format('{mon:locale}', { locale: 'fr-FR' })).toBe('octobre'); + }) + describe('auto-meridiem', () => { const tAM = new Tempo('2024-05-20T10:30:45'); const tPM = new Tempo('2024-05-20T22:30:45'); - it('adds am/pm after {HH}', () => { - expect(tAM.format('{HH}')).toBe('10am'); - expect(tPM.format('{HH}')).toBe('10pm'); + it('adds am/pm after {h12}', () => { + expect(tAM.format('{h12}')).toBe('10am'); + expect(tPM.format('{h12}')).toBe('10pm'); }) - it('adds am/pm after {mi} if it follow {HH}', () => { - expect(tAM.format('{HH}:{mi}')).toBe('10:30am'); - expect(tPM.format('{HH}:{mi}')).toBe('10:30pm'); + it('adds am/pm after {mi} if it follow {h12}', () => { + expect(tAM.format('{h12}:{mi}')).toBe('10:30am'); + expect(tPM.format('{h12}:{mi}')).toBe('10:30pm'); + }) + + it('adds AM/PM after {mi} if {h12:upper} is used', () => { + expect(tAM.format('{h12:upper}:{mi}')).toBe('10:30AM'); + expect(tPM.format('{h12:upper}:{mi}')).toBe('10:30PM'); + }) + + it('adds am/pm after {mi} if {h12:lower} is used', () => { + expect(tAM.format('{h12:lower}:{mi}')).toBe('10:30am'); + expect(tPM.format('{h12:lower}:{mi}')).toBe('10:30pm'); + }) + + it('adds am/pm after {ss} if it follows {h12}', () => { + expect(tAM.format('{h12}:{mi}:{ss}')).toBe('10:30:45am'); + expect(tPM.format('{h12}:{mi}:{ss}')).toBe('10:30:45pm'); }) - it('adds am/pm after {ss} if it follows {HH}', () => { - expect(tAM.format('{HH}:{mi}:{ss}')).toBe('10:30:45am'); - expect(tPM.format('{HH}:{mi}:{ss}')).toBe('10:30:45pm'); + it('adds am/pm after sub-seconds ({ms}, {us}, {ns}, {ff}) if it follows {h12}', () => { + expect(tAM.format('{h12}:{mi}:{ss}.{ms}')).toBe('10:30:45.000am'); + expect(tPM.format('{h12}:{mi}:{ss}.{ff}')).toBe('10:30:45.000000000pm'); + }) + + it('does not add am/pm if :raw modifier is used on {h12}', () => { + expect(tAM.format('{h12:raw}:{mi}')).toBe('10:30'); + expect(tPM.format('{h12:raw}:{mi}')).toBe('10:30'); + }) + + it('strips leading zeros from any numeric token when :raw is used', () => { + const tPad = new Tempo('2024-05-09T03:05:07.042Z'); + expect(tPad.format('{mm:raw}')).toBe('5'); + expect(tPad.format('{dd:raw}')).toBe('9'); + expect(tPad.format('{hh:raw}')).toBe('3'); + expect(tPad.format('{mi:raw}')).toBe('5'); + expect(tPad.format('{ss:raw}')).toBe('7'); + expect(tPad.format('{ms:raw}')).toBe('42'); + expect(tPad.format('{h12:raw}')).toBe('3'); + }) + + it('supports the {cal} token for calendar tracking', () => { + expect(tAM.format('{cal}')).toBe('gregory'); }) it('does not add am/pm if {mer} is already present', () => { - expect(tAM.format('{HH} {mer}')).toBe('10 am'); - expect(tPM.format('{HH} {mer}')).toBe('10 pm'); + expect(tAM.format('{h12} {mer}')).toBe('10 am'); + expect(tPM.format('{h12} {mer}')).toBe('10 pm'); }) it('does not add am/pm if {mer:upper} is already present', () => { - expect(tAM.format('{HH} {mer:upper}')).toBe('10 AM'); - expect(tPM.format('{HH} {mer:upper}')).toBe('10 PM'); + expect(tAM.format('{h12} {mer:upper}')).toBe('10 AM'); + expect(tPM.format('{h12} {mer:upper}')).toBe('10 PM'); }) it('does not add am/pm for {hh} (24-hour)', () => { @@ -66,7 +107,12 @@ describe('Tempo.format() refinements', () => { }) it('handles non-time tokens in between', () => { - expect(tAM.format('{HH} on {mon}')).toBe('10am on May'); + 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'); }) }) diff --git a/packages/tempo/test/discrete/locale.test.ts b/packages/tempo/test/discrete/locale.test.ts new file mode 100644 index 00000000..90652fd8 --- /dev/null +++ b/packages/tempo/test/discrete/locale.test.ts @@ -0,0 +1,8 @@ +import { Tempo } from '#tempo'; + +describe('locale formatting', () => { + it('translates tokens based on locale', () => { + const t = new Tempo('2026-10-24', { locale: 'fr-FR' }); + expect(t.format('{mon:locale}')).toBe('octobre'); + }); +}); diff --git a/packages/tempo/test/discrete/raw.test.ts b/packages/tempo/test/discrete/raw.test.ts new file mode 100644 index 00000000..5b46c19f --- /dev/null +++ b/packages/tempo/test/discrete/raw.test.ts @@ -0,0 +1,14 @@ +import { Tempo } from '#tempo'; + +describe('raw token formatting', () => { + it('strips leading zeros', () => { + const t0 = new Tempo('2026-01-01T00:00:00'); + expect(t0.format('{mi:raw}')).toBe('0'); + + const t5 = new Tempo('2026-01-01T00:05:00'); + expect(t5.format('{mi:raw}')).toBe('5'); + + const t15 = new Tempo('2026-01-01T00:15:00'); + expect(t15.format('{mi:raw}')).toBe('15'); + }); +}); diff --git a/packages/tempo/test/discrete/test_upper.test.ts b/packages/tempo/test/discrete/test_upper.test.ts new file mode 100644 index 00000000..f757f044 --- /dev/null +++ b/packages/tempo/test/discrete/test_upper.test.ts @@ -0,0 +1,8 @@ +import { Tempo } from '#tempo'; + +describe('h12:upper formatting', () => { + it('correctly appends uppercase meridiem when h12:upper is used', () => { + const t = new Tempo('2026-10-24T15:30:00'); + expect(t.format('{h12:upper}:{mi}')).toBe('03:30PM'); + }); +}); diff --git a/packages/tempo/test/engine/meridiem.test.ts b/packages/tempo/test/engine/meridiem.test.ts index 29519b94..4196e8e7 100644 --- a/packages/tempo/test/engine/meridiem.test.ts +++ b/packages/tempo/test/engine/meridiem.test.ts @@ -15,10 +15,10 @@ describe('Meridiem (AM/PM) parsing and formatting', () => { test('12-hour clock with meridiem', () => { const t1 = new Tempo('2024-05-20 00:00'); // Midnight - expect(t1.format('{HH}{mer}')).toBe('12am'); + expect(t1.format('{h12}{mer}')).toBe('12am'); const t2 = new Tempo('2024-05-20 12:00'); // Midday - expect(t2.format('{HH}{mer}')).toBe('12pm'); + expect(t2.format('{h12}{mer}')).toBe('12pm'); }) }) diff --git a/packages/tempo/test/instance/instance.format.test.ts b/packages/tempo/test/instance/instance.format.test.ts index 349bc355..5abc1966 100644 --- a/packages/tempo/test/instance/instance.format.test.ts +++ b/packages/tempo/test/instance/instance.format.test.ts @@ -13,7 +13,7 @@ describe(`${label} format method`, () => { test('formats with 12-hour clock and meridiem', () => { const t = new Tempo('2024-05-20 15:30:00'); - expect(t.format('{HH}:{mi}{mer}')).toBe('03:30pm'); + expect(t.format('{h12}:{mi}{mer}')).toBe('03:30pm'); }); test('accesses term properties via {term.xxx}', () => { diff --git a/packages/tempo/test/issues/issue-fixes.test.ts b/packages/tempo/test/issues/issue-fixes.test.ts index 4798d718..a66d327c 100644 --- a/packages/tempo/test/issues/issue-fixes.test.ts +++ b/packages/tempo/test/issues/issue-fixes.test.ts @@ -34,7 +34,7 @@ describe('Tempo Issue Fixes', () => { const expectedDate = new Tempo().add({ days: -1 }).format('{yyyy}-{mm}-{dd}') expect(t.format('{yyyy}-{mm}-{dd}')).toBe(expectedDate) - expect(t.format('{HH}:{mi}:{ss}')).toBe('03:00:00pm') + expect(t.format('{h12}:{mi}:{ss}')).toBe('03:00:00pm') }) test('dynamic period alias with `this` binding (e.g. half-hour)', () => {