diff --git a/.coderabbit.yaml b/.coderabbit.yaml index 1d654f5a..344d2e6b 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -1,5 +1,6 @@ # .coderabbit.yaml reviews: + auto_title_instructions: "Generate a concise PR title that starts with a conventional commit type (feat, fix, chore, etc.) followed by a short imperative description of the change." path_filters: # Use "! " at the start of a pattern to EXCLUDE it from the review - "!**/doc/api/**" # Ignore ONLY the generated TypeDoc API documentation diff --git a/CHANGELOG.md b/CHANGELOG.md index c6f8cac1..8c7b9bd7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ 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). -## [2.10.2] - 2026-05-22 +## [2.11.0] - 2026-05-25 ### Fixed - **Open Core Build Fallback**: Updated the `build:resolve` script to gracefully fallback to Open Core licensing types when the proprietary `@core` module is unavailable, resolving CI build failures in public GitHub Actions environments. diff --git a/package-lock.json b/package-lock.json index ccf92c7d..e6572a51 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "tempo-monorepo", - "version": "2.10.1", + "version": "2.10.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "tempo-monorepo", - "version": "2.10.1", + "version": "2.10.2", "workspaces": [ "packages/*" ], @@ -27,24 +27,6 @@ "vitest": "^2.1.9" } }, - "../tempo-plugin/packages/_core": { - "name": "@magmacomputing/tempo-plugin-_core", - "version": "1.0.0", - "extraneous": true, - "dependencies": { - "@magmacomputing/tempo": "^2.9.3", - "jose": "^6.2.3" - } - }, - "../tempo-plugin/packages/@core": { - "name": "@magmacomputing/tempo-plugin-@core", - "version": "1.0.0", - "extraneous": true, - "dependencies": { - "@magmacomputing/tempo": "^2.9.3", - "jose": "^6.2.3" - } - }, "node_modules/@algolia/abtesting": { "version": "1.16.2", "resolved": "https://registry.npmjs.org/@algolia/abtesting/-/abtesting-1.16.2.tgz", @@ -5690,7 +5672,7 @@ }, "packages/library": { "name": "@magmacomputing/library", - "version": "2.10.1", + "version": "2.10.2", "license": "MIT", "dependencies": { "tslib": "^2.8.1" @@ -5699,24 +5681,16 @@ "@js-temporal/polyfill": "^0.5.1" } }, - "packages/shared": { - "name": "@magma/shared", - "version": "1.1.0", - "extraneous": true, - "dependencies": { - "tslib": "^2.8.1" - } - }, "packages/tempo": { "name": "@magmacomputing/tempo", - "version": "2.10.1", + "version": "2.10.2", "license": "MIT", "dependencies": { "tslib": "^2.8.1" }, "devDependencies": { "@js-temporal/polyfill": "^0.5.1", - "@magmacomputing/library": "2.10.1", + "@magmacomputing/library": "2.10.2", "@rollup/plugin-alias": "^6.0.0", "esbuild": "^0.25.12", "javascript-obfuscator": "^5.4.2", @@ -5727,11 +5701,6 @@ "typedoc-vitepress-theme": "^1.1.2", "vitepress": "^1.6.4" } - }, - "packages/tempo/test/support/license-stub-pkg": { - "name": "@magmacomputing/tempo-plugin-_core", - "version": "1.0.0", - "extraneous": true } } } diff --git a/package.json b/package.json index 9ac22918..e3ece94f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "tempo-monorepo", - "version": "2.10.2", + "version": "2.11.0", "private": true, "description": "Magma Computing Monorepo", "repository": { @@ -43,4 +43,4 @@ "overrides": { "esbuild": "^0.25.0" } -} +} \ No newline at end of file diff --git a/packages/library/CHANGELOG.md b/packages/library/CHANGELOG.md index a357ae0b..609c2304 100644 --- a/packages/library/CHANGELOG.md +++ b/packages/library/CHANGELOG.md @@ -5,6 +5,11 @@ 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). +## [2.11.0] - 2026-05-25 + +### Added +- **Intl Utilities**: Added `getNF` (`Intl.NumberFormat`) memoization, along with `formatNumber` and `formatUnit` helper methods to `#library/international.library.js` to natively support plural-aware duration string generation. + ## [2.8.0] - 2026-04-30 ### Changed diff --git a/packages/library/package.json b/packages/library/package.json index a1942c93..954a183b 100644 --- a/packages/library/package.json +++ b/packages/library/package.json @@ -1,6 +1,6 @@ { "name": "@magmacomputing/library", - "version": "2.10.2", + "version": "2.11.0", "description": "Shared utility library for Tempo", "author": "Magma Computing Solutions", "license": "MIT", diff --git a/packages/library/src/common/international.library.ts b/packages/library/src/common/international.library.ts index 97ecbbd0..42a32d66 100644 --- a/packages/library/src/common/international.library.ts +++ b/packages/library/src/common/international.library.ts @@ -16,6 +16,11 @@ const getDTF = memoizeFunction((locale?: string) => { return new Intl.DateTimeFormat(locale); }); +/** memoized helper for Intl.NumberFormat instances */ +const getNF = memoizeFunction((locale?: string, options?: Intl.NumberFormatOptions) => { + return new Intl.NumberFormat(locale, options); +}); + /** * International Cookbook * (using 'Intl' namespace objects) @@ -53,6 +58,24 @@ export function formatList(list: string[], locale?: string, type: Intl.ListForma } } +/** return a localized number string */ +export function formatNumber(value: number, locale?: string, options?: Intl.NumberFormatOptions) { + try { + return getNF(locale, options).format(value); + } catch (e) { + return value.toString(); + } +} + +/** return a localized unit string (e.g., '2 days') */ +export function formatUnit(value: number, unit: string, locale?: string, unitDisplay: Intl.NumberFormatOptions['unitDisplay'] = 'long') { + try { + return getNF(locale, { style: 'unit', unit, unitDisplay }).format(value); + } catch (e) { + return `${value} ${unit}`; + } +} + /** try to infer hemisphere using the timezone's daylight-savings setting */ export function getHemisphere(timeZone: string = getDateTimeFormat().timeZone) { try { diff --git a/packages/library/src/common/string.library.ts b/packages/library/src/common/string.library.ts index f04e71ae..499941e3 100644 --- a/packages/library/src/common/string.library.ts +++ b/packages/library/src/common/string.library.ts @@ -140,3 +140,15 @@ export const pad = (nbr: string | number | bigint = 0, len = 2, fill?: string | export const padString = (str: string | number | bigint, pad = 6) => (isNumeric(str) ? asNumber(str).toFixed(2).toString() : str.toString() ?? '').padStart(pad, '\u007F'); +/** + * Reconstructs a string from an array of char codes. + * Useful for hiding strings from minifiers and reverse-engineers. + */ +export const reveal = (codes: number[]): string => codes.map(c => String.fromCharCode(c)).join(''); + +/** + * Converts a string into an array of char codes. + * Useful as a developer utility to generate the array to paste into `reveal()`. + */ +export const conceal = (str: string): number[] => str.split('').map(c => c.charCodeAt(0)); + diff --git a/packages/tempo/.vitepress/config.ts b/packages/tempo/.vitepress/config.ts index d750f97b..2c188d65 100644 --- a/packages/tempo/.vitepress/config.ts +++ b/packages/tempo/.vitepress/config.ts @@ -46,7 +46,8 @@ export default defineConfig({ { text: 'Parse Planner', link: '/doc/tempo.planner' }, { text: 'Regional Parsing (MDY)', link: '/doc/tempo.month-day' }, { text: 'Smart Formatting', link: '/doc/tempo.format' }, - { text: 'Layout Patterns', link: '/doc/tempo.layout' } + { text: 'Layout Patterns', link: '/doc/tempo.layout' }, + { text: 'Duration Logic', link: '/doc/tempo.duration' } ] }, { diff --git a/packages/tempo/CHANGELOG.md b/packages/tempo/CHANGELOG.md index 07fdda29..6923dd74 100644 --- a/packages/tempo/CHANGELOG.md +++ b/packages/tempo/CHANGELOG.md @@ -4,6 +4,16 @@ 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). + +## [2.11.0] - 2026-05-25 + +### Added +- **Duration Mathematics**: Introduced the `.balance()` method to `Tempo.Duration` objects to allow intelligent mathematical roll-up of duration units (e.g., converting 365 days into 1 year), with support for both strict calendar math and nominal overrides (`{ nominal: true }`). +- **Duration Formatting**: Introduced the `.format()` method to `Tempo.Duration` objects. This uses a shared, memoized `#library` implementation of `Intl.NumberFormat` to generate highly localized, plural-aware duration strings with excellent performance and robust cross-environment execution (no `navigator` dependencies). +- **Cascading Configuration**: Added `numberFormat` to the `IntlOptions` interface, allowing developers to set global formatting defaults (like `unitDisplay: 'short'`) via `Tempo.init()` that seamlessly cascade down to all `.format()` calls. + +### Fixed +- **Parsing**: Resolved an engine edge-case where combining relative weekday modifiers with string-based period aliases (e.g., `<3 Wed afternoon`) would cause the parser to prematurely abort the relative offset, instead applying the period to the current system date. ## [2.10.1] - 2026-05-22 diff --git a/packages/tempo/bin/resolve-types.ts b/packages/tempo/bin/resolve-types.ts index dd98ef21..f1c434ef 100644 --- a/packages/tempo/bin/resolve-types.ts +++ b/packages/tempo/bin/resolve-types.ts @@ -1,11 +1,10 @@ import fs from 'node:fs'; import path from 'node:path'; -import { fileURLToPath } from 'node:url'; /** * resolve-types.ts * - * Post-build utility to handle Type Definitions (#library -> lib/) + * Post-build utility to handle Type Definitions (#library -> dist/lib/) * - Synchronizes used library types into dist/lib/ * - Rewrites path aliases in all .d.ts files */ @@ -14,19 +13,11 @@ const DIST_DIR = path.resolve('dist'); const LIB_SRC_DIR = path.resolve('../library/dist/common'); const LIB_DEST_DIR = path.resolve(DIST_DIR, 'lib'); -const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const LIC_SRC_DIR = process.env.LIC_SRC_DIR || path.resolve(__dirname, '../../../../tempo-plugin/internal/@core/dist'); - -const isPremiumAvailable = fs.existsSync(LIC_SRC_DIR); - -const LIC_DEST_DIR = path.resolve(DIST_DIR, 'lic'); - console.log('Resolving type definitions...'); // 1. Ensure lib directory exists -if (!fs.existsSync(LIB_DEST_DIR)) { +if (!fs.existsSync(LIB_DEST_DIR)) fs.mkdirSync(LIB_DEST_DIR, { recursive: true }); -} // 2. Identify used library modules from Rollup's JS output const usedModules = fs.readdirSync(LIB_DEST_DIR) @@ -37,28 +28,11 @@ const usedModules = fs.readdirSync(LIB_DEST_DIR) usedModules.forEach(mod => { const src = path.join(LIB_SRC_DIR, `${mod}.d.ts`); const dest = path.join(LIB_DEST_DIR, `${mod}.d.ts`); - if (fs.existsSync(src)) { + if (fs.existsSync(src)) fs.copyFileSync(src, dest); - } }); -// 4. Copy licensing core types -if (!fs.existsSync(LIC_DEST_DIR)) fs.mkdirSync(LIC_DEST_DIR, { recursive: true }); - -if (isPremiumAvailable) { - const licFiles = fs.readdirSync(LIC_SRC_DIR).filter(f => f.endsWith('.d.ts')); - licFiles.forEach(file => { - fs.copyFileSync(path.join(LIC_SRC_DIR, file), path.join(LIC_DEST_DIR, file)); - }); -} else { - console.log('â„šī¸ Premium licensing not found. Falling back to Open Core types.'); - const defaultLicType = path.resolve(DIST_DIR, 'support/support.license.d.ts'); - if (fs.existsSync(defaultLicType)) { - fs.copyFileSync(defaultLicType, path.join(LIC_DEST_DIR, 'index.d.ts')); - } -} - -// 5. Walk through all .d.ts files in dist/ to rewrite aliases +// 4. Walk through all .d.ts files in dist/ to rewrite aliases function walk(dir: string) { const files = fs.readdirSync(dir); for (const file of files) { @@ -89,26 +63,20 @@ function rewrite(filePath: string) { } // Handle #tempo/license resolution - let licReplacement: string; - const isInsideLic = relToDist.startsWith('lic'); - if (isInsideLic) { - licReplacement = './'; - } else { - let prefix = ''; - for (let i = 0; i < depth; i++) prefix += '../'; - licReplacement = `${prefix || './'}lic/`; - } + let prefix = ''; + for (let i = 0; i < depth; i++) prefix += '../'; + let licReplacement = `${prefix || './'}support/support.license.js`; const updatedContent = content - .replace(/#library\/([^"')]+\.js)/g, (match, libPath) => { + .replace(/#library\/([^"')]+\.js)/g, (_, libPath) => { // NOTE: We use path.basename here because the @magmacomputing/library distribution // is currently flat (dist/common/*.js), and our resolve process flattens all // used library modules into the local dist/lib/ directory. const fileName = path.basename(libPath); return `${replacement}${fileName}`; }) - .replace(/#library(['"])/g, (match, quote) => `${replacement}index.js${quote}`) - .replace(/#tempo\/license(['"])/g, (match, quote) => `${licReplacement}index.js${quote}`); + .replace(/#library(['"])/g, (_, quote) => `${replacement}index.js${quote}`) + .replace(/#tempo\/license(['"])/g, (_, quote) => `${licReplacement}${quote}`); if (content !== updatedContent) { fs.writeFileSync(filePath, updatedContent); diff --git a/packages/tempo/doc/releases/v2.x.md b/packages/tempo/doc/releases/v2.x.md index 00e1f7b2..24a8e93d 100644 --- a/packages/tempo/doc/releases/v2.x.md +++ b/packages/tempo/doc/releases/v2.x.md @@ -1,6 +1,16 @@ # 📜 Version 2.x History -## [v2.10.11] - 2026-05-23 +## [v2.11.0] - 2026-05-25 +### New Features + +- **Duration Mathematics**: Introduced the `.balance()` method to `Tempo.Duration` objects to allow intelligent mathematical roll-up of duration units (e.g., converting 365 days into 1 year), with support for both strict calendar math and nominal overrides (`{ nominal: true }`). +- **Duration Formatting**: Introduced the `.format()` method to `Tempo.Duration` objects. This uses a shared, memoized `#library` implementation of `Intl.NumberFormat` to generate highly localized, plural-aware duration strings with excellent performance and robust cross-environment execution. +- **Cascading Configuration**: Added `numberFormat` to the `IntlOptions` interface, allowing developers to set global formatting defaults (like `unitDisplay: 'short'`) via `Tempo.init()` that seamlessly cascade down to all `.format()` calls. + +### Fixed +- **Open Core Build Fallback**: Updated the `build:resolve` script to gracefully fallback to Open Core licensing types when the proprietary `@core` module is unavailable, resolving CI build failures in public GitHub Actions environments. + +## [v2.10.1] - 2026-05-23 ### New Features - Added support for license key discovery via global browser context diff --git a/packages/tempo/doc/tempo-vs-temporal.md b/packages/tempo/doc/tempo-vs-temporal.md index e45c3cdb..78a5aaad 100644 --- a/packages/tempo/doc/tempo-vs-temporal.md +++ b/packages/tempo/doc/tempo-vs-temporal.md @@ -47,6 +47,21 @@ t.format('{dd} {mmm} {yyyy}'); // Output: "24 Jan 2026" t.fmt.date; // Output: "2026-01-24" ``` +Tempo also provides a built-in **log-stamp** format for dropping a compact, sortable timestamp into a log entry: + +```javascript +const ts = new Tempo('2026-05-20T13:55:19.623319620'); +ts.fmt.logStamp; // → "20260520T135519.623319620" +// ^^^^^^^^ ^^^^^^ ^^^^^^^^^ +// date time sub-seconds (nanosecond precision) +``` + +This format (`Tempo.FORMAT.logStamp`) is globally configurable via `Tempo.init`: +```javascript +Tempo.init({ formats: { logStamp: '{yyyy}-{mm}-{dd} {hh}:{mi}:{ss}' } }); +new Tempo('2026-05-20T13:55:19.623319620').fmt.logStamp; // → "2026-05-20 13:55:19" +``` + ### 3. Business Logic & Complex Terms Native Temporal deals strictly with standard calendar units (days, months, years). If you need to map a date to domain-specific business logic (like a fiscal quarter or a meteorological season), you have to write and maintain your own math utilities. @@ -80,20 +95,7 @@ For more information on adding your own business logic, see the [Terms Guide](te Calculating the difference between two dates in native Temporal is mathematically sound, but it strictly returns a `Temporal.Duration` object. Tempo gives you the flexibility to return a `Duration` object, a precise floating-point number, or a human-readable string. -Tempo also provides a built-in **log-stamp** format for dropping a compact, sortable timestamp into a log entry: - -```javascript -const t = new Tempo('2026-05-20T13:55:19.623319620'); -t.fmt.logStamp; // → "20260520T135519.623319620" -// ^^^^^^^^ ^^^^^^ ^^^^^^^^^ -// date time sub-seconds (nanosecond precision) -``` -This format (`Tempo.FORMAT.logStamp`) is configurable via `Tempo.init`: -```javascript -Tempo.init({ formats: { logStamp: '{yyyy}-{mm}-{dd} {hh}:{mi}:{ss}' } }); -new Tempo('2026-05-20T13:55:19.623319620').fmt.logStamp; // → "2026-05-20 13:55:19" -``` **Native Temporal đŸĸ** ```javascript @@ -103,42 +105,9 @@ const duration = now.until(target); // Returns a complex Duration object ``` **Tempo 🚀** -Tempo understands natural language targets and can format the resulting difference flexibly. `t.until()` and `t.since()` have distinct return types: - -```javascript -const t = new Tempo('2026-05-20T11:35:33'); -const t2 = new Tempo('2026-05-22T11:35:33'); - -// t.until(target, unit) → number -t.until('afternoon', 'minutes'); // → 84.45 (fractional: 'afternoon' has a fixed time, e.g. 13:00) -t.until('xmas', 'days'); // → 219 (whole number — see note below) -t.until('xmas', 'weeks'); // → 31.28 (fractional — weeks don't divide evenly into days) -t.until(t2, 'hours'); // → 48 (targets can also be other Tempo instances) -t.until(Temporal.Now.plainDateISO(), 'days'); // → 1 (same day = 1 day) +Tempo understands natural language targets and provides multiple ways to measure and format the resulting elapsed time. -// t.until(target) → Tempo.Duration object (with .iso, .years, .days, â€Ļ fields) -t.until('xmas'); // → { iso: "P219DT0H0M0S", years: 0, months: 7, days: 4, ... } - -// t.since(target, unit) → human-readable string via Intl.RelativeTimeFormat -t.since('yesterday', 'days'); // → "1d ago" - -// t.since(target) → ISO 8601 Duration string -t.since('yesterday afternoon'); // → "-P1DT9H32M19.402S" -``` +- `t.until()` returns a highly functional Extended Data Object (EDO) or a precise decimal number depending on your arguments. +- `t.since()` leverages `Intl.RelativeTimeFormat` to instantly return human-readable relative strings (like "3 days ago"). -> **💡 Date-only targets inherit the current time.** -> When a target resolves to a **date without a time component** (e.g. `'xmas'`, `'tomorrow'`, `'next friday'`), -> Tempo copies the current time-of-day from the anchor into the target. This means: -> -> - `t.until('xmas', 'days')` → a **whole number** — the time components cancel out exactly. -> - `t.until('xmas', 'hours')` → a **whole number** — same reason. -> - `t.until('xmas', 'weeks')` → **fractional** — 219 days does not divide evenly into weeks. -> -> This matches natural-language intuition: *"How many days until Christmas?"* expects `219`, not `219.43`. -> If you need the precise elapsed duration including sub-day components, omit the unit: -> ```javascript -> t.until('xmas'); // → { days: 219, hours: 0, minutes: 0, seconds: 0, ... } -> ``` -> -> Targets with an **explicit time** (e.g. `'afternoon'`, `'9am'`, `'2026-12-25T08:00'`) always produce -> fractional values because the target time differs from the anchor's current time-of-day. \ No newline at end of file +For comprehensive examples of duration mathematics, intelligent balancing, and localization formatting, read the dedicated **[Duration Logic Guide](tempo.duration.md)**. \ No newline at end of file diff --git a/packages/tempo/doc/tempo.config.md b/packages/tempo/doc/tempo.config.md index 910f3fc9..23a068ba 100644 --- a/packages/tempo/doc/tempo.config.md +++ b/packages/tempo/doc/tempo.config.md @@ -137,6 +137,7 @@ Tempo.init({ | `timeStamp`| `'ss' \| 'ms' \| 'us' \| 'ns'` | `'ms'` | Precision for numeric inputs and the `.ts` property. | | `sphere` | `'north' \| 'south'`| Auto-inferred | Hemisphere for seasonal plugins. | | `relativeTime` | `RelativeTime` | `undefined` | Relative time formatting configuration (grouped). | +| `intl` | `IntlOptions` | `undefined` | Internationalization configuration grouping both `relativeTime` and `numberFormat`. | | `event` | `Record` | Built-in aliases | Custom date aliases merged into the event registry. | | `period` | `Record` | Built-in aliases | Custom time aliases merged into the period registry. | | `snippet` | `Record` | Built-in snippets | Custom snippet patterns used to compose parse layouts. | diff --git a/packages/tempo/doc/tempo.duration.md b/packages/tempo/doc/tempo.duration.md new file mode 100644 index 00000000..3bca4b5a --- /dev/null +++ b/packages/tempo/doc/tempo.duration.md @@ -0,0 +1,128 @@ +--- +outline: deep +--- + +# Duration Logic + +Tempo provides a powerful `DurationModule` for calculating, balancing, and formatting the elapsed time between two dates. + +Because Tempo wraps the modern `Temporal` API, durations are highly accurate, seamlessly handling leap years, daylight saving time boundaries, and variable month lengths. + +## Calculating Durations + +Tempo offers two primary methods for calculating the difference between dates: `.until()` and `.since()`. + +### `.until()` +Calculates the time remaining from the Tempo instance *until* a future date. + +```javascript +import { Tempo } from '@magmacomputing/tempo'; + +const now = new Tempo(); +const xmas = new Tempo('2026-12-25'); + +// 1. Return an Extended Data Object (EDO) +const duration = now.until(xmas); + +// 2. Or, calculate relative to a specific unit (returns a primitive Number) +now.until('afternoon', 'minutes'); // → 84.45 (fractional: 'afternoon' has a fixed time) +now.until('xmas', 'days'); // → 219 (whole number — see note below) +now.until('xmas', 'weeks'); // → 31.28 (fractional — weeks don't divide evenly into days) +now.until(Tempo.Now(), 'hours'); // → 48 (targets can also be Temporal/Tempo instances) +``` + +::: tip Date-only targets inherit the current time +When a target resolves to a **date without a time component** (e.g. `'xmas'`, `'tomorrow'`, `'next friday'`), Tempo copies the current time-of-day from the anchor into the target. This means: + +- `t.until('xmas', 'days')` → a **whole number** — the time components cancel out exactly. +- `t.until('xmas', 'hours')` → a **whole number** — same reason. +- `t.until('xmas', 'weeks')` → **fractional** — 219 days does not divide evenly into weeks. + +This matches natural-language intuition: *"How many days until Christmas?"* expects `219`, not `219.43`. Targets with an **explicit time** (e.g. `'afternoon'`, `'9am'`) always produce fractional values because the target time differs from the anchor's current time-of-day. +::: + +::: warning Return Types +If you call `.until()` **without** a unit, it returns a `Tempo.Duration` object, onto which you can chain `.balance()` and `.format()` (see below). +If you provide a unit (like `'days'`), it returns a primitive JavaScript `Number`. Calling `.balance()` on a Number will throw an error. +::: + +### `.since()` +Calculates the time elapsed *since* a past date. By default, it returns a human-readable localized string (powered by `Intl.RelativeTimeFormat`). + +```javascript +const birthday = new Tempo('1990-05-10'); + +// 1. Returns localized relative string based on the given unit +birthday.since('now', 'years'); // → "36 years ago" (depending on locale) +birthday.since('now', 'days'); // → "13,150 days ago" + +// 2. Returns an ISO 8601 Duration String if no unit is provided +birthday.since('yesterday'); // → "-P35Y11M29DT9H32M19.402S" +``` + +::: info Return Type +Because `.since()` automatically renders a localized string, it returns a primitive JavaScript `String`. Therefore, chaining `.balance()` or `.format()` (see below) onto `.since()` is not possible and will throw an error. +::: + +## The Duration Object (EDO) + +When you call `until()` (or `Tempo.duration()`), Tempo returns an Extended Data Object (EDO) representing the exact duration. + +```javascript +const dur = Tempo.duration('P1Y2M15D'); +console.log(dur.years); // 1 +console.log(dur.months); // 2 +console.log(dur.days); // 15 +``` + +## Intelligent Balancing + +Sometimes you have a raw number of days (e.g. `365 days`) and you want to mathematically "balance" it into larger units (like `1 year`). Tempo provides the `.balance()` method directly on the Duration object. + +### Strict Calendar Math +By default, `.balance()` uses the `relativeTo` anchor captured during `.until()` to perform perfect calendar math. + +```javascript +// Automatically balances 365 days into exactly "1 year" (or 11mo 30d if a leap year!) +const balanced = new Tempo().until('xmas').balance(); +``` + +### Nominal (Commercial) Math +If you are building SaaS pricing tables or catalog displays, strict calendar math can be frustrating (you don't want a 365-day license to display as "11 months and 30 days" during a leap year). + +You can pass `{ nominal: true }` to mathematically force `365 days = 1 year`, `30 days = 1 month`, and `7 days = 1 week` regardless of the calendar. + +```javascript +const commercialDur = Tempo.duration({ days: 365 }).balance({ nominal: true }); +console.log(commercialDur.years); // 1 +console.log(commercialDur.days); // 0 +``` + +## Formatting Absolute Durations + +Once you have a balanced duration, you can instantly render it as a highly localized, plural-aware string using the `.format()` method. + +`.format()` automatically looks for the largest non-zero unit and uses `Intl.NumberFormat` to translate it perfectly into the user's language. + +```javascript +// Perfect for SaaS Pricing Cards! +const formatted = Tempo.duration({ days: 365 }) + .balance({ nominal: true }) + .format(); + +console.log(formatted); // "1 year" (or "1 aÃąo", "1 an" depending on navigator.language) +``` + +### Global Configuration +You can also define default formatting options globally by adding `numberFormat` into your `Tempo.init` configuration. + +```javascript +Tempo.init({ + intl: { + numberFormat: { unitDisplay: 'short' } // e.g. "1 yr" instead of "1 year" + } +}); + +// Now, all format calls will automatically use 'short' display +const shortDur = Tempo.duration('P1Y').format(); // "1 yr" +``` diff --git a/packages/tempo/doc/tempo.plugin.md b/packages/tempo/doc/tempo.plugin.md index da27d7e5..cc113ebd 100644 --- a/packages/tempo/doc/tempo.plugin.md +++ b/packages/tempo/doc/tempo.plugin.md @@ -249,19 +249,10 @@ export const MyFeatureModule = defineModule((TempoClass, options) => { ``` ### Commercial & Premium Plugins -If you are distributing a commercial plugin that requires a valid Tempo license to run, use the **`definePremiumPlugin`** wrapper. This automatically injects licensing validation into the plugin's `install` lifecycle, guaranteeing that the plugin will only execute if the user has provided a valid, active license key via `Tempo.init()` that grants the requested scope. -```typescript -// index.ts -import { defineModule, definePremiumPlugin } from '@magmacomputing/tempo/plugin'; +If you have built a powerful plugin and wish to distribute it commercially, you do not need to implement your own licensing engine. Build your plugin using the standard `defineModule` or `definePlugin` wrappers. -const CoreModule = defineModule((TempoClass, options) => { - TempoClass.prototype.myPremiumMethod = function() { ... } -}); - -// Wrap the module and specify the required license scope key -export const MyPremiumModule = definePremiumPlugin('my_premium_scope', CoreModule); -``` +Once your plugin is ready for the marketplace, **[Contact Magma Computing](https://github.com/magmacomputing)**. We can inject our proprietary licensing and cryptographic verification engine directly into your build pipeline, ensuring your plugin is securely gated and protected from unauthorized use. --- diff --git a/packages/tempo/doc/tempo.term.md b/packages/tempo/doc/tempo.term.md index a0385a75..a5f19f8a 100644 --- a/packages/tempo/doc/tempo.term.md +++ b/packages/tempo/doc/tempo.term.md @@ -158,7 +158,7 @@ Tempo.extend(QuarterTerm); A Term plugin is ideally created using the **`defineTerm`** factory function provided by the library. This ensures correct type-inference and automatically handles registration during the discovery phase. -For commercial plugins that require a valid license to execute, use the **`definePremiumTerm`** factory instead. This wrapper automatically intercepts property evaluations and validates the `license` state in the global Tempo registry, securely guarding your plugin's functionality. +If you are developing a commercial plugin and require license enforcement, simply build your logic using the standard `defineTerm` factory. Once ready for the marketplace, contact Magma Computing to have our proprietary licensing and cryptographic verification engine wrapped around your plugin prior to distribution. ### Plugin Definition diff --git a/packages/tempo/doc/tempo.weekday.md b/packages/tempo/doc/tempo.weekday.md index f7643ece..be10b42d 100644 --- a/packages/tempo/doc/tempo.weekday.md +++ b/packages/tempo/doc/tempo.weekday.md @@ -12,10 +12,9 @@ You can provide the short or full name of a Weekday. - `Wed`, `Wednesday` - `Sun`, `Sunday` -When no modifier is provided, Tempo defaults to the Weekday in the **current week**. - ### 2. Symbolic Modifiers -Symbols can be used to indicate relative weeks or specific directions in time. +Symbols can be used to indicate relative weeks or specific directions in time. +When no modifier is provided, Tempo defaults to the Weekday in the **current week**. | Modifier | Meaning | Example | Results in... | | :--- | :--- | :--- | :--- | @@ -51,8 +50,8 @@ You can also specify the direction of time using a suffix. You can append time information to a Weekday string. Tempo will parse the Weekday first and then apply the time to that specific date. - `Mon 10:00am` -- `Friday noon` -- `Wed 15:30:00` +- `>Friday noon` +- `<3 Wed 15:30:00` ## How it Works 1. **Normalization**: Weekday names are normalized to their 3-letter proper-case prefix (e.g., `monday` -> `Mon`). diff --git a/packages/tempo/package.json b/packages/tempo/package.json index 88ae8b90..9c87ce2f 100644 --- a/packages/tempo/package.json +++ b/packages/tempo/package.json @@ -1,6 +1,6 @@ { "name": "@magmacomputing/tempo", - "version": "2.10.2", + "version": "2.11.0", "description": "The Tempo core library", "author": "Magma Computing Solutions", "license": "MIT", @@ -227,7 +227,7 @@ }, "devDependencies": { "@js-temporal/polyfill": "^0.5.1", - "@magmacomputing/library": "2.10.2", + "@magmacomputing/library": "2.11.0", "@rollup/plugin-alias": "^6.0.0", "esbuild": "^0.25.12", "javascript-obfuscator": "^5.4.2", @@ -242,4 +242,4 @@ "doc": "doc", "test": "test" } -} +} \ No newline at end of file diff --git a/packages/tempo/rollup.config.js b/packages/tempo/rollup.config.js index 44aec9e7..1b51f9d0 100644 --- a/packages/tempo/rollup.config.js +++ b/packages/tempo/rollup.config.js @@ -10,7 +10,7 @@ import MagicString from 'magic-string'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const distPath = path.join(__dirname, 'dist'); -// we use "_core" to not confuse npm: name can only contain URL-friendly characters. + const licensePremium = process.env.TEMPO_LICENSE_PATH ? path.resolve(process.env.TEMPO_LICENSE_PATH) : undefined; const licenseDefault = path.resolve(__dirname, './src/support/support.license.ts'); const isPremiumAvailable = !!( @@ -159,7 +159,7 @@ export default [ // Map library imports to lib/ for browser-ready granular ESM const rel = path.relative(__dirname, id); const normalizedRel = rel.replace(/\\/g, '/'); // Ensure forward slashes - + if (id.includes('magma/packages/library') || rel.startsWith('../library')) { const match = normalizedRel.match(/library\/(?:src|dist\/common)\/(.*)$/); const modulePath = match ? path.dirname(match[1]) : '.'; diff --git a/packages/tempo/src/engine/engine.lexer.ts b/packages/tempo/src/engine/engine.lexer.ts index a49ea324..56d5b6bd 100644 --- a/packages/tempo/src/engine/engine.lexer.ts +++ b/packages/tempo/src/engine/engine.lexer.ts @@ -125,8 +125,8 @@ export function parseWeekday(groups: t.Groups, dateTime: Temporal.ZonedDateTime, const { wkd, mod, nbr = '1', sfx, afx, ...rest } = groups as Lexer.GroupWkd; if (isUndefined(wkd)) return dateTime; - const time = ['hh', 'mi', 'ss', 'ms', 'us', 'ns', 'ff', 'mer'] as const; - if (!ownKeys(rest).every(key => (time as ReadonlyArray).includes(key as string))) + const time = ['hh', 'mi', 'ss', 'ms', 'us', 'ns', 'ff', 'mer'] as ReadonlyArray; + if (!ownKeys(rest).every(key => time.includes(key) || key.startsWith('per'))) return dateTime; if (!isEmpty(mod) && !isEmpty(sfx)) { diff --git a/packages/tempo/src/module/module.duration.ts b/packages/tempo/src/module/module.duration.ts index ec7c4fb8..9d3d4adc 100644 --- a/packages/tempo/src/module/module.duration.ts +++ b/packages/tempo/src/module/module.duration.ts @@ -1,9 +1,9 @@ import { getTemporalIds } from '#library/temporal.library.js'; -import { isString, isObject, isDefined, isUndefined } from '#library/assertion.library.js'; +import { isString, isObject, isDefined, isUndefined, isFunction } from '#library/assertion.library.js'; import { singular } from '#library/string.library.js'; import { getAccessors } from '#library/reflection.library.js'; import { ifDefined } from '#library/object.library.js'; -import { getRelativeTime } from '#library/international.library.js'; +import { getRelativeTime, formatNumber } from '#library/international.library.js'; import { defineInterpreterModule, interpret, type TempoModule } from '../plugin/plugin.util.js'; import { enums, isTempo } from '#tempo/support'; @@ -37,8 +37,8 @@ declare module '#library/type.library.js' { /** * Convert a Temporal.Duration to a full Tempo.Duration object (EDO). */ -function toDuration(dur: Temporal.Duration): Tempo.Duration { - return getAccessors(dur) +function toDuration(dur: Temporal.Duration, ctx: { relativeTo?: any, locale?: string, numberFormat?: any } = {}): Tempo.Duration { + const edo = getAccessors(dur) .reduce((acc, d) => Object.assign(acc, ifDefined({ [d]: (dur as any)[d] })), { iso: dur.toString(), @@ -46,6 +46,83 @@ function toDuration(dur: Temporal.Duration): Tempo.Duration { blank: dur.blank, unit: undefined } as Tempo.Duration); + + Object.defineProperty(edo, 'balance', { + value: function (opts: any = {}) { + const { nominal, largestUnit = 'year', relativeTo: customAnchor } = opts; + + if (nominal) { + // Mathematical mapping logic + let { years, months, weeks, days, hours, minutes, seconds, milliseconds, microseconds, nanoseconds } = dur; + let totalDays = days + weeks * 7 + months * 30 + years * 365; + + years = Math.trunc(totalDays / 365); + totalDays -= years * 365; + + months = Math.trunc(totalDays / 30); + totalDays -= months * 30; + + weeks = Math.trunc(totalDays / 7); + totalDays -= weeks * 7; + + days = totalDays; + + const newDur = Temporal.Duration.from({ + years, months, weeks, days, hours, minutes, seconds, milliseconds, microseconds, nanoseconds, + sign: dur.sign + }); + + return toDuration(newDur, { ...ctx, relativeTo: customAnchor || ctx.relativeTo }); + } + + // Strict Temporal balancing + const anchor = customAnchor || ctx.relativeTo; + if (!anchor) + throw new Error("A relativeTo anchor is required for strict balancing. Pass an anchor or use { nominal: true } for mathematical balancing."); + + const balanced = dur.round({ largestUnit, relativeTo: anchor }); + + return toDuration(balanced, { ...ctx, relativeTo: anchor }); + }, + enumerable: false + }); + + Object.defineProperty(edo, 'format', { + value: function (opts: any = {}) { + const { locales, ...intlOpts } = opts; + + // Find the largest non-zero unit to format. + let val = 0; + let u = ''; + if (this.years) { val = this.years; u = 'year'; } + else if (this.months) { val = this.months; u = 'month'; } + else if (this.weeks) { val = this.weeks; u = 'week'; } + else if (this.days) { val = this.days; u = 'day'; } + else if (this.hours) { val = this.hours; u = 'hour'; } + else if (this.minutes) { val = this.minutes; u = 'minute'; } + else if (this.seconds) { val = this.seconds; u = 'second'; } + else if (this.milliseconds) { val = this.milliseconds; u = 'millisecond'; } + else if (this.microseconds) { val = this.microseconds; u = 'microsecond'; } + else if (this.nanoseconds) { val = this.nanoseconds; u = 'nanosecond'; } + + if (!u) return '0'; // or some fallback + + if (isFunction(ctx.numberFormat)) + return ctx.numberFormat(val, u); + + const locale = locales || ctx.locale; + return formatNumber(val, locale, { + style: 'unit', + unit: u, + unitDisplay: 'long', + ...(ctx.numberFormat || {}), + ...intlOpts + }); + }, + enumerable: false + }); + + return edo; } /** @@ -104,7 +181,9 @@ function duration(this: Tempo, type: 'until' | 'since', arg?: any, until?: any) unit = `${singular(unit)}s`; if (isUndefined(unit) || since) { - const res = toDuration(dur); + const locale = (this as any)?.config?.locale; + const numberFormat = opts['intl']?.numberFormat || (this as any)?.config?.intl?.numberFormat; + const res = toDuration(dur, { relativeTo: selfZdt, locale, numberFormat }); if (unit) res.unit = unit; if (!since) return res; @@ -116,20 +195,19 @@ function duration(this: Tempo, type: 'until' | 'since', arg?: any, until?: any) .map(Math.abs) .map(nbr => nbr.toString().padStart(3, '0')) .join('') - - const locale = (this as any).config['locale']; const rtConfig = (this as any).config.intl?.relativeTime; const rtOptions = opts['relativeTime']; - const rtf = (typeof rtOptions === 'function' ? rtOptions : rtOptions?.format) - || (typeof rtConfig === 'function' ? rtConfig : rtConfig?.format) + const rtf = (isFunction(rtOptions) ? rtOptions : rtOptions?.format) + || (isFunction(rtConfig) ? rtConfig : rtConfig?.format) || opts['rtfFormat'] || (this as any).config['rtfFormat']; const getFormatted = (val: number, u: any) => { - if (typeof rtf === 'function') return rtf(val, u); - if (rtf instanceof Intl.RelativeTimeFormat) return rtf.format(val, u); + const su = singular(u); + if (isFunction(rtf)) return rtf(val, su); + if (rtf instanceof Intl.RelativeTimeFormat) return rtf.format(val, su); const style = rtOptions?.style || rtConfig?.style || opts['intl']?.relativeTime?.style || opts['rtfStyle'] || (this as any).config.intl?.relativeTime?.style || (this as any).config['rtfStyle'] || 'narrow'; - return getRelativeTime(val, u, locale, style); + return getRelativeTime(val, su as Intl.RelativeTimeFormatUnit, locale, style); } switch (res.unit) { @@ -157,9 +235,9 @@ function duration(this: Tempo, type: 'until' | 'since', arg?: any, until?: any) * string -> EDO * DurationLikeObject -> EDO (with iso string) */ -(duration as any).toDuration = (input: string | Temporal.DurationLikeObject) => { +(duration as any).toDuration = (input: string | Temporal.DurationLikeObject, ctx?: any) => { const dur = Temporal.Duration.from(input); - return toDuration(dur); + return toDuration(dur, ctx); } /** @@ -167,6 +245,10 @@ function duration(this: Tempo, type: 'until' | 'since', arg?: any, until?: any) */ export const DurationModule: TempoModule = defineInterpreterModule('DurationModule', duration, { duration(this: typeof Tempo, input: any) { - return interpret(this, 'DurationModule', 'toDuration', false, input); + const ctx = { + locale: this.config?.locale, + numberFormat: this.config?.intl?.numberFormat + }; + return interpret(this, 'DurationModule', 'toDuration', false, input, ctx); } }); diff --git a/packages/tempo/src/plugin/plugin.util.ts b/packages/tempo/src/plugin/plugin.util.ts index 358f931c..3538b308 100644 --- a/packages/tempo/src/plugin/plugin.util.ts +++ b/packages/tempo/src/plugin/plugin.util.ts @@ -1,4 +1,5 @@ import { isFunction, isString, isUndefined, isClass, isObject, isDefined } from '#library/assertion.library.js'; +import { reveal } from '#library/string.library.js'; import { secureRef } from '#library/proxy.library.js'; import { sym, isTempo } from '../support/support.symbol.js'; @@ -194,20 +195,3 @@ export function registerPlugin(plugin: any) { return plugin; } - -/** - * ## definePremiumPlugin - * Helper to register a premium plugin (Module/Extension) and enforce commercial licensing at install time. - */ -export function definePremiumPlugin>(key: string, plugin: T): T { - const originalInstall = plugin.install; - plugin.install = function (this: TempoType, t: TempoType) { - const rt = getRuntime(); - if (rt.license.status !== 'active' || !hasOwn(rt.license.scopes, key)) { - throw new Error(`[${key}] Premium plugin requires a valid commercial license. Status: ${rt.license.status}`); - } - return originalInstall.call(this, t); - } - return registerPlugin(plugin); -} - diff --git a/packages/tempo/src/plugin/term/term.index.ts b/packages/tempo/src/plugin/term/term.index.ts index 42b6dc59..4e6a0fba 100644 --- a/packages/tempo/src/plugin/term/term.index.ts +++ b/packages/tempo/src/plugin/term/term.index.ts @@ -8,7 +8,7 @@ import { TimelineTerm } from './term.timeline.js' /** collection of built-in terms for initial registration */ export const StandardTerms = [QuarterTerm, SeasonTerm, ZodiacTerm, TimelineTerm]; -export { defineTerm, definePremiumTerm, defineRange, getTermRange } from './term.util.js'; +export { defineTerm, defineRange, getTermRange } from './term.util.js'; /** Aggregator module for all standard Terms */ export const TermsModule = defineModule({ @@ -19,7 +19,3 @@ export const TermsModule = defineModule({ TempoClass.extend(StandardTerms); }, }); - -// Side-effect: Automatically register all standard terms when this barrel is imported -// (Moved to tempo.index.ts to avoid circular dependency issues) - diff --git a/packages/tempo/src/plugin/term/term.util.ts b/packages/tempo/src/plugin/term/term.util.ts index 7d2241e2..3822b7d8 100644 --- a/packages/tempo/src/plugin/term/term.util.ts +++ b/packages/tempo/src/plugin/term/term.util.ts @@ -18,38 +18,6 @@ export const defineTerm = (term: T): T => { return term; } -/** - * ## definePremiumTerm - * Helper to register a premium Term plugin, automatically enforcing commercial licensing. - */ -export const definePremiumTerm = (pluginDef: T): T => { - const originalResolve = pluginDef.resolve; - const originalDefine = pluginDef.define; - - const assertPremium = function (t: Tempo, key: string) { - const term = (t.constructor as any).terms[key]; - if (!term || term.status !== 'active') { - throw new Error(`[${key}] Premium plugin requires a valid commercial license. Status: ${term?.status}`); - } - } - - if (originalResolve) { - pluginDef.resolve = function (this: Tempo, anchor?: any) { - assertPremium(this, pluginDef.key); - return originalResolve.call(this, anchor); - } - } - - if (originalDefine) { - pluginDef.define = function (this: Tempo, keyOnly?: boolean, anchor?: any) { - assertPremium(this, pluginDef.key); - return originalDefine.call(this, keyOnly, anchor); - } - } - - return defineTerm(pluginDef); -} - /** * ## findTermPlugin * Find a Term plugin by key, scope, or sub-key. diff --git a/packages/tempo/src/support/support.intl.ts b/packages/tempo/src/support/support.intl.ts index ecec0524..bd3f524e 100644 --- a/packages/tempo/src/support/support.intl.ts +++ b/packages/tempo/src/support/support.intl.ts @@ -33,11 +33,11 @@ export function resolveIntl(value: IntlOptions = {}, base: IntlOptions = IntlDef const result = { ...base } as Record; Object.entries(value).forEach(([k, v]) => { - if (k === 'relativeTime' && typeof v === 'object' && v !== null && typeof v !== 'function') { - const current = result.relativeTime; + if ((k === 'relativeTime' || k === 'numberFormat') && typeof v === 'object' && v !== null && typeof v !== 'function') { + const current = result[k]; const isObj = (val: any) => typeof val === 'object' && val !== null && typeof val !== 'function'; - result.relativeTime = { + result[k] = { ...(isObj(current) ? current as object : {}), ...v as any }; diff --git a/packages/tempo/src/support/support.license.ts b/packages/tempo/src/support/support.license.ts index 698fb558..83aef6ed 100644 --- a/packages/tempo/src/support/support.license.ts +++ b/packages/tempo/src/support/support.license.ts @@ -9,10 +9,13 @@ import { decodeJWT } from '#library/utility.library.js'; export class Validator { constructor(public key: string) { } async verify() { + // Decodes but DOES NOT verify the signature. + // Cannot safely unlock Premium Plugins without cryptographic proof. const claims = decodeJWT(this.key); return { - status: 'active' as const, - scopes: (claims?.permissions || {}) as Record, + status: 'invalid' as const, + scopes: {}, + error: 'Cryptographic engine missing. Premium plugins cannot be validated by the Community Build.', } } async syncRevocation(_jwsUrl: string, _currentJti: string): Promise { diff --git a/packages/tempo/src/tempo.class.ts b/packages/tempo/src/tempo.class.ts index b474ed4e..80b18d58 100644 --- a/packages/tempo/src/tempo.class.ts +++ b/packages/tempo/src/tempo.class.ts @@ -803,15 +803,18 @@ export class Tempo { Tempo.#dbg.info(this.config, 'Tempo:', state.config); Tempo.#lifecycle.ready = true; - setPatterns(state); // rebuild the global patterns (Master Guard etc) + setPatterns(state); // rebuild the global patterns (Master Guard etc) // đŸ›ī¸ Licensing Reckoning (Background Verification) if (rt.license.jws?.isPending) { + const jws = rt.license.jws; import('#tempo/license') - .then(m => rt.license.jws?.resolve(m)) + .then(m => jws.resolve(m)) .catch(err => { - rt.license.status = LICENSE.None; - rt.license.jws?.reject(err); + // If the stored JWS is still the same (i.e. we haven't set a new one since), then clear the status + if (rt.license.jws === jws) + rt.license.status = LICENSE.None; + jws.reject(err); }); } diff --git a/packages/tempo/src/tempo.type.ts b/packages/tempo/src/tempo.type.ts index ae048ffc..a950958a 100644 --- a/packages/tempo/src/tempo.type.ts +++ b/packages/tempo/src/tempo.type.ts @@ -147,7 +147,10 @@ export type us = IntRange<0, 999> export type ns = IntRange<0, 999> export type ww = IntRange<1, 53> -export type Duration = NonOptional & Record<"iso", string> & Record<"sign", number> & Record<"blank", boolean> & Record<"unit", string | undefined> +export type Duration = NonOptional & Record<"iso", string> & Record<"sign", number> & Record<"blank", boolean> & Record<"unit", string | undefined> & { + balance(opts?: { nominal?: boolean; relativeTo?: any; largestUnit?: Unit | string }): Duration; + format(opts?: Intl.NumberFormatOptions & { locales?: string | string[] }): string; +} /** pre-configured format strings */ export type OwnFormat = enums.OwnFormat; @@ -188,6 +191,7 @@ export interface RelativeTime { export interface IntlOptions { /** relative time formatting configuration */ relativeTime?: RelativeTime | ((value: number, unit: any) => string); + /** absolute unit duration formatting configuration */ numberFormat?: Intl.NumberFormatOptions | ((value: number, unit: any) => string); } export interface PlannerOptions { diff --git a/packages/tempo/test/discrete/compact.time.test.ts b/packages/tempo/test/discrete/compact.time.test.ts index b22f1d04..52ff6bca 100644 --- a/packages/tempo/test/discrete/compact.time.test.ts +++ b/packages/tempo/test/discrete/compact.time.test.ts @@ -2,7 +2,7 @@ import { Tempo } from '#tempo'; describe('compact hhmiss parsing', () => { beforeEach(() => { - Tempo.init(); + Tempo.init({ locale: 'en-US' }); }); test('parses 093015 as 09:30:15', () => { diff --git a/packages/tempo/test/engine/layout.order.test.ts b/packages/tempo/test/engine/layout.order.test.ts index 75a521f1..d5a17d90 100644 --- a/packages/tempo/test/engine/layout.order.test.ts +++ b/packages/tempo/test/engine/layout.order.test.ts @@ -2,7 +2,7 @@ import { Tempo } from '#tempo'; describe('layout matching order', () => { beforeEach(() => { - Tempo.init(); + Tempo.init({ locale: 'en-US' }); }); test('uses month-day-year first for US timezone on compact 8-digit input', () => { diff --git a/packages/tempo/test/instance/instance.since.test.ts b/packages/tempo/test/instance/instance.since.test.ts index 05e24097..8b014bce 100644 --- a/packages/tempo/test/instance/instance.since.test.ts +++ b/packages/tempo/test/instance/instance.since.test.ts @@ -3,6 +3,10 @@ import { Tempo } from '#tempo'; const label = 'instance.since:'; describe(`${label} since method`, () => { + beforeEach(() => { + Tempo.init({ locale: 'en-US' }); + }); + test('calculates time elapsed from a past date', () => { const t1 = new Tempo('2024-01-01T12:00:00'); const t2 = new Tempo('2024-01-01T14:30:00'); diff --git a/packages/tempo/test/plugins/duration.balance.test.ts b/packages/tempo/test/plugins/duration.balance.test.ts new file mode 100644 index 00000000..6b5687f2 --- /dev/null +++ b/packages/tempo/test/plugins/duration.balance.test.ts @@ -0,0 +1,39 @@ +import { Tempo } from '#tempo'; + +describe('Duration EDO Balance and Format', () => { + test('balance() performs strict mathematical calendar rolling', () => { + const dur = Tempo.duration({ days: 365 }); + expect(dur.days).toBe(365); + expect(dur.years).toBe(0); + + const balancedNominal = dur.balance({ nominal: true }); + expect(balancedNominal.years).toBe(1); + expect(balancedNominal.days).toBe(0); + }); + + test('balance() without nominal requires a relativeTo anchor', () => { + const dur = Tempo.duration({ days: 365 }); + expect(() => dur.balance()).toThrow(/relativeTo anchor is required/); + }); + + test('until() captures the relativeTo anchor for balance()', () => { + const t = new Tempo('2024-01-01'); + const dur = t.until(t.add({ days: 366 })); // Exactly 1 year in a leap year + + expect(() => dur.balance()).not.toThrow(); + }); + + test('format() uses Intl.NumberFormat to render the largest unit', () => { + const dur1 = Tempo.duration({ days: 365 }); + expect(dur1.format({ locales: 'en-US' })).toBe('365 days'); + + const dur2 = Tempo.duration({ years: 1, days: 5 }); + expect(dur2.format({ locales: 'en-US' })).toBe('1 year'); + }); + + test('format() respects cascading numberFormat config', () => { + const t = new Tempo('now', { intl: { numberFormat: { unitDisplay: 'short' } } }); + const dur = t.until(t.add({ years: 1 })); + expect(dur.format({ locales: 'en-US' })).toBe('1 yr'); + }); +}); diff --git a/packages/tempo/test/plugins/slick.shorthand.test.ts b/packages/tempo/test/plugins/slick.shorthand.test.ts index 413e9de4..346631b5 100644 --- a/packages/tempo/test/plugins/slick.shorthand.test.ts +++ b/packages/tempo/test/plugins/slick.shorthand.test.ts @@ -2,7 +2,7 @@ import { Tempo } from '#tempo'; describe('Slick Shorthand Resolution', () => { beforeEach(() => { - Tempo.init({ timeZone: 'UTC', sphere: 'north' }); + Tempo.init({ timeZone: 'UTC', sphere: 'north', locale: 'en-US' }); }); it('move to next specific quarter (#qtr.>q2)', () => {