Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "tempo-monorepo",
"version": "2.11.0",
"version": "2.11.1",
"private": true,
"description": "Magma Computing Monorepo",
"repository": {
Expand Down
2 changes: 1 addition & 1 deletion packages/library/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@magmacomputing/library",
"version": "2.11.0",
"version": "2.11.1",
"description": "Shared utility library for Tempo",
"author": "Magma Computing Solutions",
"license": "MIT",
Expand Down
9 changes: 7 additions & 2 deletions packages/library/src/common/serialize.library.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,13 @@ export const Registry = (globalThis as any)[sym.$SerializerRegistry] ??= new Map
export const registerSerializable = (name: string, cls: Function) => {
const key = name.startsWith('$') ? name : `$${name}`;

if (Registry.has(key))
throw new Error(`[registerSerializable] Collision: '${key}' is already registered with ${Registry.get(key)?.name || 'anonymous constructor'}`);
if (Registry.has(key)) {
const existingCls = Registry.get(key);
if (existingCls === cls || existingCls?.toString() === cls.toString()) {
return; // Silently allow idempotent dual-registration
}
throw new Error(`[registerSerializable] Collision: '${key}' is already registered with ${existingCls?.name || 'anonymous constructor'}`);
}

Registry.set(key, cls);
}
Expand Down
10 changes: 10 additions & 0 deletions packages/tempo/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,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.1] - 2026-05-26

### Added
- **ISO 8601 Convenience Getter**: Added the `.iso` getter to `Tempo` instances to provide a fast, familiar, and UTC-safe mechanism for retrieving the standard ISO 8601 string representation (analogous to `Date.toISOString()`).
- **Type Utilities Export**: Exported `KeyOf`, `ValueOf`, `OwnOf`, and `EntryOf` type utilities in `library.index.ts` to provide cleaner TypeScript typing for downstream consumers utilizing `enumify`.

### Fixed
- **Build Pipeline Path Resolution**: Fixed a critical path-resolution bug in the `resolve-types.ts` script where `library.index.d.ts` was incorrectly identified as being inside the `lib/` directory. This ensures proper `.d.ts` generation and restores JSDoc/typings for downstream consumers.
- **Documentation Domains**: Updated all premium extension documentation (`tempo.license.md`) and internal plugins to use the centralized `registry.magmacomputing.com.au` domain for license verification and the UI portal.

## [2.11.0] - 2026-05-25

### Added
Expand Down
2 changes: 1 addition & 1 deletion packages/tempo/bin/resolve-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ function rewrite(filePath: string) {
const content = fs.readFileSync(filePath, 'utf8');
const relToDist = path.relative(DIST_DIR, filePath);
const depth = relToDist.split(path.sep).length - 1;
const isInsideLib = relToDist.startsWith('lib');
const isInsideLib = relToDist.startsWith(`lib${path.sep}`);

let replacement: string;
if (isInsideLib) {
Expand Down
70 changes: 51 additions & 19 deletions packages/tempo/doc/tempo.duration.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,10 @@ const xmas = new Tempo('2026-12-25');
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)
now.until('afternoon', 'minutes'); // → ~84.45 (example output: fractional)
now.until('xmas', 'days'); // → ~219 (example output: whole number — see note below)
now.until('xmas', 'weeks'); // → ~31.28 (example output: fractional)
now.until(now.add({ days: 2 }), 'hours'); // → 48 (targets can also be Temporal/Tempo instances)
```

::: tip Date-only targets inherit the current time
Expand All @@ -41,23 +41,25 @@ When a target resolves to a **date without a time component** (e.g. `'xmas'`, `'
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 anchor = new Tempo('2026-05-10', { locale: 'en-US' });
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"
// Note: Tempo uses a compact ('narrow') Intl style by default
anchor.since(birthday, 'years'); // → "36y ago" (deterministic)
anchor.since(birthday, 'days'); // → "13,149d ago" (deterministic)

// 2. Pass a custom formatter for natural language output (e.g. "yesterday")
const yesterday = anchor.add({ days: -1 });
const autoFormat = new Intl.RelativeTimeFormat('en-US', { numeric: 'auto' });
anchor.since(yesterday, { unit: 'days', intl: { relativeTime: { format: autoFormat } } }); // → "yesterday"

// 2. Returns an ISO 8601 Duration String if no unit is provided
birthday.since('yesterday'); // → "-P35Y11M29DT9H32M19.402S"
// 3. Returns an ISO 8601 Duration String if no unit is provided
anchor.since(birthday); // → "-P36Y..."
```

::: info Return Type
Expand All @@ -70,21 +72,36 @@ When you call `until()` (or `Tempo.duration()`), Tempo returns an Extended Data

```javascript
const dur = Tempo.duration('P1Y2M15D');
console.log(dur.years); // 1
console.log(dur.months); // 2
console.log(dur.days); // 15
console.log(dur.years); // 1
console.log(dur.months); // 2
console.log(dur.days); // 15
```

## Intelligent Balancing

::: 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.
:::

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.

::: tip When to use .balance()
Because Tempo injects `{ largestUnit: 'years' }` by default, `.until()` is usually perfectly balanced out of the box. However, you will still need to chain `.balance()` in three specific scenarios:
1. **Raw Durations:** When you manually construct an unbalanced duration (e.g., `Tempo.duration({ days: 365 })`).
2. **Cross-Timezone Math:** If you run `.until()` between two *different* timezones, Tempo restricts the result to `hours` to prevent calendar ambiguity. Chaining `.balance()` forces the rollup into Years/Months.
3. **Nominal Math:** When you want to force commercial math (`{ nominal: true }`) instead of strict calendar math.
:::

### Strict Calendar Math
By default, `.balance()` uses the `relativeTo` anchor captured during `.until()` to perform perfect calendar math.
By default, `.balance()` uses a `relativeTo` anchor 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();
// A manually created duration is unbalanced by default: { days: 365 }
const rawDuration = Tempo.duration({ days: 365 });

// Balance it using an anchor: safely converts it to { years: 1 } (or 11mo 30d if a leap year!)
const balanced = rawDuration.balance({ relativeTo: '2026-01-01' });
```

### Nominal (Commercial) Math
Expand All @@ -98,6 +115,21 @@ console.log(commercialDur.years); // 1
console.log(commercialDur.days); // 0
```

### Balancing Downwards (Un-balancing)
You can also force a duration to roll *downwards* into smaller units by specifying a `largestUnit`. This is incredibly useful for UI countdown timers (e.g., displaying "48 Hours" instead of "2 Days") or per-diem billing calculations where you need to know exactly how many days are in a specific year.

```javascript
const yearlyLicense = Tempo.duration({ years: 1 });

// Un-balance the year down into exact days (365 or 366 depending on the anchor)
const exactDays = yearlyLicense.balance({
largestUnit: 'days',
relativeTo: '2024-01-01'
});

console.log(exactDays.days); // 366 (2024 is a leap year!)
```

## 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.
Expand Down
4 changes: 2 additions & 2 deletions packages/tempo/doc/tempo.license.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ console.log(t.term.astronomy);

**How to get it:**
1. Run `npm install @magmacomputing/tempo-plugin-astro` in your project.
2. Visit [magmacomputing.com.au/tempo/license.html](https://magmacomputing.com.au/tempo/license.html) to request your **one-year expiry key**, which will be instantly issued to your inbox.
2. Visit [registry.magmacomputing.com.au/tempo/](https://registry.magmacomputing.com.au/tempo/) to request your **one-year expiry key**, which will be instantly issued to your inbox.

## ⚙️ Applying Your License Key

Expand Down Expand Up @@ -143,7 +143,7 @@ globalThis.TEMPO_LICENSE_KEY = 'ey...';
To verify license validity and prevent abuse, Tempo's licensing engine performs background synchronization with our revocation registry:

* **Outbound Request:** When a license key is active, Tempo asynchronously fetches a cryptographically signed revocation list (JWS).
* **Endpoint:** `https://api.magmacomputing.com.au/tempo/v1/revoked.jws` (useful for configuring Content Security Policies (CSP) or egress firewall rules).
* **Endpoint:** `https://registry.magmacomputing.com.au/tempo/v1/revoked.jws` (useful for configuring Content Security Policies (CSP) or egress firewall rules).
* **Frequency:** The revocation check occurs once every **7 days**. The last-checked state is cached to avoid redundant network traffic on subsequent startups.
* **Offline Resilience (Fail-Open):** If your application is offline, behind a strict firewall, or the registry server is temporarily unreachable, the validation **fails open**. Tempo logs a warning in the console but continues to grant access to premium features (relying on the local cryptographic expiration of the JWT).

Expand Down
4 changes: 2 additions & 2 deletions packages/tempo/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@magmacomputing/tempo",
"version": "2.11.0",
"version": "2.11.1",
"description": "The Tempo core library",
"author": "Magma Computing Solutions",
"license": "MIT",
Expand Down Expand Up @@ -227,7 +227,7 @@
},
"devDependencies": {
"@js-temporal/polyfill": "^0.5.1",
"@magmacomputing/library": "2.11.0",
"@magmacomputing/library": "2.11.1",
"@rollup/plugin-alias": "^6.0.0",
"esbuild": "^0.25.12",
"javascript-obfuscator": "^5.4.2",
Expand Down
8 changes: 7 additions & 1 deletion packages/tempo/rollup.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,13 @@ export default [
format: 'es',
sourcemap: false
},
external: ['@js-temporal/polyfill'],
external: [
'@js-temporal/polyfill',
/^@magmacomputing\/tempo/,
/^@magmacomputing\/library/,
/^#library/,
/^#tempo/
],
plugins: [
resolve({ extensions: ['.js', '.ts'], moduleDirectories: ['node_modules'] }),
esbuild({ target: 'esnext', minify: false }),
Expand Down
2 changes: 2 additions & 0 deletions packages/tempo/src/library.index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,5 @@ export { Cipher } from '#library/cipher.class.js';
export { enumify, type Enum } from '#library/enumerate.library.js';
export { proxify } from '#library/proxy.library.js';
export { stringify, objectify, cloneify } from '#library/serialize.library.js';

export type { OwnOf, KeyOf, ValueOf, EntryOf } from '#library/type.library.js';
2 changes: 1 addition & 1 deletion packages/tempo/src/module/module.duration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -196,7 +196,7 @@ function duration(this: Tempo, type: 'until' | 'since', arg?: any, until?: any)
.map(nbr => nbr.toString().padStart(3, '0'))
.join('')
const rtConfig = (this as any).config.intl?.relativeTime;
const rtOptions = opts['relativeTime'];
const rtOptions = opts['intl']?.relativeTime || opts['relativeTime'];

const rtf = (isFunction(rtOptions) ? rtOptions : rtOptions?.format)
|| (isFunction(rtConfig) ? rtConfig : rtConfig?.format)
Expand Down
14 changes: 12 additions & 2 deletions packages/tempo/src/support/support.runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,19 +79,29 @@ export class TempoRuntime {
/**
* Record a Term in the discovery database.
* Validates the shape before storing so malformed entries cannot corrupt state.
* Replaces existing terms with the same key to support HMR and test module cache resets.
*/
addTerm(term: TermPlugin): void {
if (!term || typeof term.key !== 'string') return;
if (!this.pluginsDb.terms.some(t => t.key === term.key))
this.pluginsDb.terms.push(term);
const idx = this.pluginsDb.terms.findIndex(t => t.key === term.key);
if (idx >= 0) this.pluginsDb.terms[idx] = term;
else this.pluginsDb.terms.push(term);
}

/**
* Record a Plugin in the discovery database.
* Guards against duplicate entries.
* Replaces existing plugins with the same name to support HMR and test module cache resets.
*/
addPlugin(plugin: any): void {
if (!plugin) return;
if (plugin.name) {
const idx = this.pluginsDb.plugins.findIndex(p => p.name === plugin.name);
if (idx >= 0) {
this.pluginsDb.plugins[idx] = plugin;
return;
}
}
if (!this.pluginsDb.plugins.includes(plugin))
this.pluginsDb.plugins.push(plugin);
}
Expand Down
9 changes: 6 additions & 3 deletions packages/tempo/src/tempo.class.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1222,6 +1222,8 @@ export class Tempo {

/** Resolve the instance to a Temporal.ZonedDateTime (with optional callback) */
#resolve<T>(cb?: (zdt: Temporal.ZonedDateTime) => T): T | Temporal.ZonedDateTime {
const now = this.#now.toZonedDateTimeISO('UTC');

if (!this.#zdt) {
try {
const skip = [this.#local.parse.format, this.#local.parse.term, this.#local.parse.result]
Expand All @@ -1231,7 +1233,7 @@ export class Tempo {
this.#errored = true;
const msg = `Tempo parse returned undefined for: ${String(this.#tempo)}`;
Tempo.#dbg.error(this.#local.config, msg);
this.#zdt = this.#now.toZonedDateTimeISO('UTC');
this.#zdt = now;
}
secure(this.#local.config);
secure(this.#local.parse, new WeakSet(skip));
Expand All @@ -1240,15 +1242,15 @@ export class Tempo {
const msg = `Cannot create Tempo: ${(err as Error).message}\n${(err as Error).stack}`;
if (this.#local.config.catch === true) {
Tempo.#dbg.error(this.#local.config, msg); // log as error if in catch-mode
this.#zdt = this.#now.toZonedDateTimeISO('UTC');
this.#zdt = now;
} else {
Tempo.#dbg.error(this.#local.config, err, msg); // log as error then re-throw
throw err;
}
}
}

const zdt = isZonedDateTime(this.#zdt) ? this.#zdt : this.#now.toZonedDateTimeISO('UTC');
const zdt = isZonedDateTime(this.#zdt) ? this.#zdt : now;
return cb?.(zdt) ?? zdt;
}

Expand Down Expand Up @@ -1399,6 +1401,7 @@ export class Tempo {
/** Full weekday name (e.g., 'Monday') */ get wkd() { return Tempo.WEEKDAYS.keyOf(this.toDateTime().dayOfWeek as t.Weekday) }
/** iso weekday number: Mon=1, Sun=7 */ get dow() { return this.toDateTime().dayOfWeek as t.Weekday }
/** Nanoseconds since Unix epoch (BigInt) */ get nano() { return this.toDateTime().epochNanoseconds }
/** Standard ISO 8601 string in UTC */ get iso() { return this.toDate().toISOString() }
/** `true` if the underlying date-time is valid. */ get isValid() { return this.#resolve(zdt => !this.#errored && isZonedDateTime(zdt)); }

/** list of registered terms and their available range keys */
Expand Down
2 changes: 1 addition & 1 deletion packages/tempo/test/core/static.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'].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())
})

test(`${label} get the elements`, () => {
Expand Down
4 changes: 4 additions & 0 deletions packages/tempo/test/plugins/term-dispatch.core.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ import '#tempo/format';
import '#tempo/term';

describe('Term Dispatch Refactor', () => {
beforeEach(() => {
Tempo.init();
});

it('should set term by index (#quarter: 2)', () => {
const t = new Tempo('2024-01-01T00:00:00', { timeZone: 'America/New_York' });
const t2 = t.set({ '#quarter': 2 });
Expand Down
4 changes: 4 additions & 0 deletions packages/tempo/test/plugins/term-shorthand.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { Tempo } from '#tempo'

describe('Tempo Term Literacy (Namespace Shorthand)', () => {
beforeEach(() => {
Tempo.init();
});

describe('.set() shorthand', () => {
test('set("#period.morning") sets to the start of morning', () => {
const t = new Tempo('2026-01-01T12:00:00', { sphere: 'north' })
Expand Down
3 changes: 3 additions & 0 deletions packages/tempo/test/plugins/term.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ const label = 'term:';
* Test the Tempo term plugins
*/
describe(`${label}`, () => {
beforeEach(() => {
Tempo.init();
});

test(`${label} check for the {quarter} plugin`, () => {
const qtr = Tempo.terms.find(({ key }: any) => key === 'qtr');
Expand Down
Loading
Loading