From 8d1303813afbb03b6aa4f0eca4c8b721220cec2e Mon Sep 17 00:00:00 2001 From: Michael McRae Date: Fri, 10 Jul 2026 16:12:14 +1000 Subject: [PATCH 01/18] prototype tempo-fns --- package-lock.json | 375 ++++++++++++++++++++- package.json | 2 +- packages/tempo/.vitepress/config.ts | 2 +- packages/tempo/doc/architecture.md | 143 ++++---- packages/tempo/doc/tempo-vs-temporal.md | 113 ------- packages/tempo/test/core/bootstrap.test.ts | 4 +- 6 files changed, 444 insertions(+), 195 deletions(-) delete mode 100644 packages/tempo/doc/tempo-vs-temporal.md diff --git a/package-lock.json b/package-lock.json index d3b6d457..46cbc317 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,7 +27,7 @@ "rollup": "^4.62.2", "tslib": "^2.8.1", "tsx": "^4.22.3", - "typescript": "^6.0.3", + "typescript": "^7.0.2", "unplugin-swc": "^1.5.9", "vitest": "^4.1.7", "webdriverio": "^9.29.0" @@ -2741,6 +2741,346 @@ "@types/node": "*" } }, + "node_modules/@typescript/typescript-aix-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", + "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", + "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", + "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", + "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", + "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", + "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", + "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-loong64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", + "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-mips64el": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", + "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", + "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-riscv64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", + "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-s390x": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", + "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", + "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", + "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", + "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", + "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", + "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-sunos-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", + "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", + "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", + "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, "node_modules/@ungap/structured-clone": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.1.tgz", @@ -8392,17 +8732,38 @@ } }, "node_modules/typescript": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", - "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", + "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", "dev": true, "license": "Apache-2.0", "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" + "tsc": "bin/tsc" }, "engines": { - "node": ">=14.17" + "node": ">=16.20.0" + }, + "optionalDependencies": { + "@typescript/typescript-aix-ppc64": "7.0.2", + "@typescript/typescript-darwin-arm64": "7.0.2", + "@typescript/typescript-darwin-x64": "7.0.2", + "@typescript/typescript-freebsd-arm64": "7.0.2", + "@typescript/typescript-freebsd-x64": "7.0.2", + "@typescript/typescript-linux-arm": "7.0.2", + "@typescript/typescript-linux-arm64": "7.0.2", + "@typescript/typescript-linux-loong64": "7.0.2", + "@typescript/typescript-linux-mips64el": "7.0.2", + "@typescript/typescript-linux-ppc64": "7.0.2", + "@typescript/typescript-linux-riscv64": "7.0.2", + "@typescript/typescript-linux-s390x": "7.0.2", + "@typescript/typescript-linux-x64": "7.0.2", + "@typescript/typescript-netbsd-arm64": "7.0.2", + "@typescript/typescript-netbsd-x64": "7.0.2", + "@typescript/typescript-openbsd-arm64": "7.0.2", + "@typescript/typescript-openbsd-x64": "7.0.2", + "@typescript/typescript-sunos-x64": "7.0.2", + "@typescript/typescript-win32-arm64": "7.0.2", + "@typescript/typescript-win32-x64": "7.0.2" } }, "node_modules/uc.micro": { diff --git a/package.json b/package.json index a8dee3b2..ad3639d5 100644 --- a/package.json +++ b/package.json @@ -46,7 +46,7 @@ "rollup": "^4.62.2", "tslib": "^2.8.1", "tsx": "^4.22.3", - "typescript": "^6.0.3", + "typescript": "^7.0.2", "unplugin-swc": "^1.5.9", "vitest": "^4.1.7", "webdriverio": "^9.29.0" diff --git a/packages/tempo/.vitepress/config.ts b/packages/tempo/.vitepress/config.ts index 61e837b6..3db5bc35 100644 --- a/packages/tempo/.vitepress/config.ts +++ b/packages/tempo/.vitepress/config.ts @@ -98,7 +98,7 @@ export default defineConfig({ items: [ { text: 'Contribution Guide', link: '/CONTRIBUTING' }, { text: 'Comparison', link: '/doc/comparison' }, - { text: 'Tempo vs Temporal', link: '/doc/tempo-vs-temporal' }, + { text: 'Extending Temporal', link: '/doc/extending-temporal' }, { text: 'Project Vision', link: '/doc/vision' } ] }, diff --git a/packages/tempo/doc/architecture.md b/packages/tempo/doc/architecture.md index 039fcb52..00d5b145 100644 --- a/packages/tempo/doc/architecture.md +++ b/packages/tempo/doc/architecture.md @@ -1,45 +1,45 @@ # πŸ—οΈ Core Architecture -Tempo v2.0.1 introduces several industry-leading architectural patterns designed for maximum resilience in complex Monorepo and Proxy-wrapped environments. +Tempo v3.7.x introduces several industry-leading architectural patterns designed for maximum resilience in complex Monorepo and Proxy-wrapped environments. ## 🌐 Shared Global Registry -### TempoRuntime β€” single hardened bridge (v2.2+) +### TempoRuntime β€” single hardened bridge (v3.0+) -Prior to v2.2, Tempo spread its inter-module state across many `globalThis[Symbol.for(…)]` slots (`$terms`, `$extends`, `$modules`, `$installed`, `$reset`, `$Plugins`, `$Register`). Each slot was a potential tamper target and the scattered writes made the global namespace harder to audit. +Prior to the v3.x series, Tempo spread its inter-module state across many `globalThis[Symbol.for(…)]` slots (`$terms`, `$extends`, `$modules`, `$installed`, `$reset`, `$Plugins`, `$Register`). Each slot was a potential tamper target, making the global namespace difficult to audit securely. -As of v2.2, all of that bookkeeping is consolidated inside a single **`TempoRuntime`** object (`#tempo/support`). The runtime is stored on `globalThis` under one hardened property: +As of modern builds, all bookkeeping is consolidated inside a single **`TempoRuntime`** object (`#tempo/support`). The runtime is stored on `globalThis` under one hardened, highly protected property: ```typescript Symbol.for('magmacomputing/tempo/runtime') ``` -The property descriptor is `enumerable: false, configurable: false, writable: false`. External code can neither replace nor delete the runtime. +The property descriptor is `enumerable: false, configurable: false, writable: false`. External code can neither replace nor delete the runtime. **Benefits:** -- **Reduced global footprint** β€” one slot instead of seven. -- **Centralised hardening** β€” input validation (`Tempo.extend`) and hook management (`setRegisterHook`, `fireRegisterHook`) live in one place. -- **Scoped runtimes (Experimental)** β€” `TempoRuntime.createScoped()` returns a fresh, isolated runtime for clean test isolation without `globalThis` manipulation. This remains an experimental internal feature and is not yet fully threaded through all core utilities. Unlike the primary runtime, a scoped runtime is not pinned to `globalThis`, does not receive the hardened `defineProperty` protections, and relies on the returned lexical reference instead of the shared `getRuntime()` / `globalThis[BRIDGE]` path. Implementation examples of this test-scoping pattern can be found in [plugin_registration.test.ts](../test/plugin_registration.test.ts) and [duration.core.test.ts](../test/duration.core.test.ts). -- **Multi-bundle / HMR safety** β€” `getRuntime()` checks `globalThis[BRIDGE]` before constructing, so two bundle copies of Tempo always share the same runtime object, preserving the original split-brain guarantee. +- **Reduced Global Footprint** β€” Consolidation from seven scatter-slots down to a single root. +- **Centralized Hardening** β€” Strict input validation (`Tempo.extend`) and hook management (`setRegisterHook`, `fireRegisterHook`) operate securely from one nexus. +- **Scoped Runtimes (Experimental)** β€” `TempoRuntime.createScoped()` returns a fresh, isolated runtime for clean test isolation without `globalThis` manipulation. *Note: This remains an experimental internal feature and is not yet fully threaded through all core utilities. Unlike the primary runtime, a scoped runtime is not pinned to `globalThis`, does not receive the hardened `defineProperty` protections, and relies strictly on lexical scoping.* +- **Multi-Bundle / HMR Safety** β€” `getRuntime()` checks `globalThis[BRIDGE]` before constructing, guaranteeing that two bundle copies of Tempo always share the identical runtime object, thereby resolving complex split-brain states in monorepos. -**User-facing "Global Discovery" slots remain on `globalThis`.** The `sym.$Tempo` slot (and custom discovery symbols passed to `Tempo.init`) are intentionally user-readable, so they stay as ordinary writable properties. Only internal bookkeeping moved into the runtime. +**User-facing "Global Discovery" slots remain on `globalThis`.** The `sym.$Tempo` slot (and custom discovery symbols passed to `Tempo.init`) are intentionally exposed for user-readability, remaining ordinary writable properties. Only sensitive internal bookkeeping was moved into the hardened runtime. -To solve the "Split-Brain" issue inherent in monorepo development (where multiple instances of the same library might be loaded), Tempo utilizes a **Shared Global Registry**. By leveraging `Symbol.for('magmacomputing/library/registry')` on `globalThis`, all versions of the Tempo and Library packages share a unified type-identification engine. This ensures that classes are correctly identified as constructors even when loaded across different module boundaries. +To solve the "Split-Brain" issue inherent in monorepo development, Tempo utilizes a **Shared Global Registry**. By leveraging `Symbol.for('magmacomputing/library/registry')` on `globalThis`, all versions of the Tempo and Library packages share a unified type-identification engine. This ensures classes are accurately identified as constructors even when loaded across disparate module boundaries. ## πŸ•΅οΈ Decoupled Logging -Tempo uses a centralized, functional diagnostic engine (via `logError` / `logWarn` utilities) that relies on private context to avoid polluting the public console or object state. This ensures that parsing telemetry does not clash with application logic. -- **Context-Aware**: Logs track their discovery path (e.g., "Applied via Global Discovery"). +Tempo utilizes a centralized, functional diagnostic engine (via `logError` / `logWarn` utilities) relying on private context to avoid polluting the public console or altering object state. This ensures parsing telemetry never clashes with standard application logic. +- **Context-Aware**: Logs rigorously track their discovery path (e.g., "Applied via Global Discovery"). - **Zero-Footprint**: When `debug: 0`, the logging overhead is mathematically eliminated. -- **Symbol-Gated**: Diagnostic metadata is attached via the symbol variable directly (e.g., `config[sym.$LogConfig]`), making it invisible to standard iteration (`Object.keys`) and serialization (`JSON.stringify`). Note: $LogConfig is a Symbol created using `Symbol.for('$LibraryLogConfig')`. +- **Symbol-Gated**: Diagnostic metadata is attached via private symbol variable directly (e.g., `config[sym.$LogConfig]`), making it invisible to standard iteration (`Object.keys`) and serialization (`JSON.stringify`). ## πŸ›‘οΈ Hardened Functional Resolution The engine implements a "Fail-Safe" execution pattern for functional inputs, automatically recovering from misidentified typesβ€”such as ES6 classes wrapped in defensive Proxies or circular dependency deadlocks. -- **Defensive Execution**: All plugin invocations are wrapped in recursive `try/catch` blocks. -- **Silent Failover**: When combined with `catch: true`, resolution failures return a **Void Instance**, preventing application crashes while providing clear diagnostic symbols for debugging. +- **Defensive Execution**: All plugin invocations are wrapped in recursive `try/catch` execution blocks. +- **Silent Failover**: When combined with `catch: true`, resolution failures safely return a **Void Instance**, preventing application crashes while surfacing clear diagnostic symbols for debugging. ## πŸ—οΈ Tempo Architecture: Internal Protection & Performance -Tempo employs two distinct methodologies for protecting its internal state. These strategies are complementary, each tailored to a specific scope (Instance vs. Global) and performance requirement. +Tempo employs two distinct methodologies for protecting its internal state. These strategies are highly complementary, each meticulously tailored to a specific scope (Instance vs. Global) and performance requirement. --- @@ -49,57 +49,58 @@ Tempo employs two distinct methodologies for protecting its internal state. Thes | :--- | :--- | :--- | | **Primary Target** | `Tempo.#term`, `Tempo.#fmt` (Instance State) | `NUMBER`, `FORMAT` (Global Registries) | | **Scope** | **Instance-Specific**: Unique to every separate `new Tempo()` call. | **Global-Shared**: One single source of truth used by all instances. | -| **Primary Goal** | **Performance**: Avoid computing expensive Terms (e.g., `qtr` or `szn`) until they are needed, | **Extensibility**: Allow plugins to safely append new data to registries at runtime. | +| **Primary Goal** | **Performance**: Avoid computing expensive Terms (e.g., `qtr` or `szn`) until explicitly requested. | **Extensibility**: Allow plugins to safely append new data to registries at runtime. | | **Mechanism** | `Object.create(proto)` + Prototype Shadowing. | `new Proxy(target)` + Symbol-bypass. | -| **Why this one?** | **Memory Efficiency**: Thousands of instances share the same base prototype. | **Reference Stability**: Shared registries must stay at the same object reference. | +| **Why this one?** | **Memory Efficiency**: Thousands of instances share the same base prototype. | **Reference Stability**: Shared registries must persist at the same object reference. | --- ## ⚑ The "Zero-Cost Constructor" Objective -Tempo is built with a **"Performance First"** mindset, specifically targeting the overhead of the class constructor. In high-frequency applications (like Tickers or real-time Dashboards), creating thousands of objects must be nearly as cheap as a primitive assignment. +Tempo is built with a militant **"Performance First"** mindset, specifically targeting the computational overhead of the class constructor. In high-frequency applications (like Tickers or real-time Dashboards), creating thousands of objects must be nearly as computationally cheap as primitive assignments. + +**Status: Achieved.** We successfully meet the Zero-Cost objective. Benchmarks demonstrate an `O(1)` instantiation overhead of ~523Β΅s, and a fast-fail rejection speed of ~359Β΅s. This objective is achieved through two primary architectural pillars: -1. **Lazy Evaluation ([Section 1](#1-lazy-evaluation-shadowing))**: Deferring the expensive work of string parsing and Term computation until the first property access. -2. **Master Guard ([Section 3](#3-master-guard-fast-fail-sync-point))**: Implementing a high-speed "fast-fail" gatekeeper to instantly reject invalid inputs when parsing *is* eventually triggered. +1. **Lazy Evaluation ([Section 1](#1-lazy-evaluation-shadowing))**: Deferring the computationally expensive work of string parsing and Term calculation until the first strict property access. +2. **Master Guard ([Section 3](#3-master-guard-fast-fail-sync-point))**: Implementing a high-speed "fast-fail" gatekeeper to instantly reject invalid inputs before parsing logic is engaged. -Together, these ensure that `new Tempo()` maintains an `O(1)` constructor execution time by deferring `O(N)` full-parse work until the first property access, regardless of how many plugins or custom Terms are registered in the global system. +Together, these pillars guarantee that `new Tempo()` maintains an `O(1)` constructor execution time by completely deferring `O(N)` parsing workloads, regardless of the density of registered plugins or custom terms. --- ## πŸ” Iteration & Enumerability (Delegator Proxies) -A delegator Proxy is a Proxy wrapper whose traps forward operations to an internal target/handler pair; unlike a standard Proxy (which typically mediates access directly against one wrapped target object), delegation is explicit and routed through that intermediate forwarding layer. In Tempo, "delegator Proxy" and "Generic Lazy Delegator Proxy" refer to the same public delegation mechanism, while lazy shadowing for `Tempo.#term`/`Tempo.#fmt` is a separate private-field initialization mechanism. These mechanisms coexist: the `instance.term` / `instance.fmt` public API uses the delegator Proxy path, and `Tempo.#term` / `Tempo.#fmt` private fields are initialized once via lazy shadowing. +A delegator Proxy is a wrapper whose traps forward operations to an internal target/handler pair. Unlike a standard Proxy, delegation is explicit and tightly routed through an intermediate forwarding layer. In Tempo, the "Generic Lazy Delegator Proxy" handles the public delegation mechanism, while lazy shadowing initializes the private state. These mechanisms coexist: the `instance.term` / `instance.fmt` public API employs the delegator Proxy path, and `Tempo.#term` / `Tempo.#fmt` private fields are initialized exactly once via lazy shadowing. ### βœ… `Object.keys()` Behavior -`Object.keys(instance.fmt)` and `Object.keys(instance.term)` return the enumerable own keys currently registered on each delegator target. +`Object.keys(instance.fmt)` and `Object.keys(instance.term)` return the enumerable own keys strictly registered on each delegator target. -- **Proxy discovery (definition)**: Proxy discovery is the proxy-handler phase that enumerates available target keys and installs enumerable lazy getter properties on the proxy target without reading their values. -- **Triggered by enumeration APIs**: Discovery runs when enumeration APIs execute, including `Object.keys(instance.fmt)`, `for...in`, and `Reflect.ownKeys(...)` on the delegator proxy. -- **Timing**: Discovery happens at enumeration time (before any property `get`), so key visibility is established before value resolution. -- **Before direct access**: Keys can already be visible and probeable. -- **Relation to [Section 1](#1-lazy-evaluation-shadowing)**: Discovery only registers getters; actual value computation and memoization happen later, when a getter is first invoked (for example, `instance.fmt.someKey`). -- **After access**: Getter access memoizes values on the same target object; keys remain stable and do not "move" across prototype links. +- **Proxy Discovery (Definition)**: This is the proxy-handler phase that enumerates available target keys and installs enumerable lazy getter properties on the proxy target *without* computing their values. +- **Triggered by Enumeration APIs**: Discovery executes when enumeration APIs execute, including `Object.keys(instance.fmt)`, `for...in`, and `Reflect.ownKeys(...)`. +- **Timing**: Discovery happens at enumeration time (before any property `get`), establishing key visibility prior to value resolution. +- **Relation to [Section 1](#1-lazy-evaluation-shadowing)**: Discovery only registers getters; actual value computation and memoization trigger purely on-demand. +- **After Access**: Getter access memoizes values directly onto the target object; keys remain highly stable and do not "move" across prototype links. ### πŸ›‘οΈ Iteration Notes -- **`Object.keys` / `for...in` / object spread**: Operate on enumerable keys exposed by the delegator target after discovery. -- **`[Symbol.iterator]`**: Still provides explicit iterator semantics where implemented. -- **`Tempo.registry.formats` & `Tempo.registry.terms`**: These static getters continue to provide a registry-wide view of available keys across the system, independent of per-instance memoization state. +- **`Object.keys` / `for...in` / Object Spread**: Operate reliably on enumerable keys exposed by the delegator target after discovery. +- **`[Symbol.iterator]`**: Maintains explicit iterator semantics where fully implemented. +- **`Tempo.registry.formats` & `Tempo.registry.terms`**: These static getters provide a registry-wide macroscopic view of available keys, entirely independent of per-instance memoization state. --- ## 1. Lazy Evaluation (Shadowing) Used for: `Tempo.#term`, `Tempo.#fmt` -The **Instance Shadowing** pattern is designed for massive scale. When a library is used heavily, creating thousands of `new Proxy()` objects adds significant memory overhead. Instead, Tempo leverages the native JavaScript prototype chain. +The **Instance Shadowing** pattern is engineered for massive scale. When a library handles dense operations, creating thousands of `new Proxy()` objects injects unacceptable memory overhead. Instead, Tempo exploits the native JavaScript prototype chain. ### How it works: -- **Stage 0**: All instances initially point to the same base `#term` object containing un-evaluated getters. -- **Stage 1**: When a Term (e.g., `.qtr`) is accessed, the value is computed once. -- **Stage 2**: Tempo uses a **Generic Lazy Delegator** Proxy (via `getLazyDelegator`) which catches property access and evaluates it on-demand. -- **Result**: The JS engine executes lookups via an optimized Proxy handler, making lookups nearly as fast as raw property access while keeping the state strictly immutable. +- **Stage 0**: All instances initially point to the identical base `#term` object containing un-evaluated getters. +- **Stage 1**: When a Term (e.g., `.qtr`) is accessed, the value is computationally evaluated exactly once. +- **Stage 2**: Tempo utilizes a **Generic Lazy Delegator** Proxy (via `getLazyDelegator`) which catches property access and evaluates it strictly on-demand. +- **Result**: The V8 engine executes lookups via an aggressively optimized Proxy handler, making lookups mathematically comparable to raw property access while keeping the state strictly immutable. ::: tip -For more implementation details, see [Lazy Evaluation Pattern](./lazy-evaluation-pattern.md). +For deeper implementation schematics, see the **[Lazy Evaluation Pattern](./lazy-evaluation-pattern.md)**. ::: --- @@ -107,15 +108,15 @@ For more implementation details, see [Lazy Evaluation Pattern](./lazy-evaluation ## 2. Soft Freeze Strategy (Proxy) Used for: `Tempo.NUMBER`, `Tempo.FORMAT`, `Tempo.TIMEZONE`, `Tempo.config` -Global registries must be **live** but **secure**. As of **v2.0.1**, these are protected by a "Soft Freeze" layer to prevent accidental state corruption by third-party code. +Global registries must remain **live** yet highly **secure**. As of the modern v3.x series, these are protected by a "Soft Freeze" layer to prevent state corruption or prototype poisoning by third-party code. ### How it works: -- **The User**: Sees a read-only Proxy that behaves like a frozen object. Direct assignments are blocked to prevent "poisoning" the global state. -- **The Library**: Uses a private symbol bypass to perform "Transactional Updates" via `registryUpdate()`. -- **Result**: The object reference remains constant while allowing controlled extensibility. This ensures that internal caches (like the Master Guard) can be re-synchronized whenever a registry changes. +- **The User**: Interacts with a read-only Proxy that strictly behaves like a frozen object. Direct assignments are hard-blocked to prevent poisoning the global state. +- **The Library**: Utilizes a private symbol bypass to execute "Transactional Updates" natively via `registryUpdate()`. +- **Result**: The object reference remains constant while facilitating deeply controlled extensibility. This ensures that internal high-speed caches (like the Master Guard) can be synchronously rebuilt whenever a registry mutates. ::: tip -For more implementation details, see [Soft Freeze Strategy](./soft_freeze_strategy.md). +For deeper implementation schematics, see the **[Soft Freeze Strategy](./soft_freeze_strategy.md)**. ::: --- @@ -124,55 +125,55 @@ For more implementation details, see [Soft Freeze Strategy](./soft_freeze_strate ## ⚑ 3. Master Guard (Guarded-Lazy Strategy) Used for: `new Tempo(string | number)` -The **Guarded-Lazy** strategy ensures that even with hundreds of custom plugins, the entry point remains nearly instantaneous. As of **v2.9.2**, this logic is decoupled into a dedicated `engine.guard.ts` module. +The **Guarded-Lazy** strategy ensures that even under the weight of hundreds of custom plugins, the constructor entry point remains near-instantaneous. This logic is decoupled into a high-performance `engine.guard.ts` module. ### How it works: -1. **Longest-Token Matching**: To prevent partial matching (e.g., matching `qtr` inside `quarter`), the guard uses a greedy "Scan-and-Consume" loop that prioritizes the longest available token. -2. **Unified Wordlist**: The guard automatically ingests all registered Terms, Timezones, Month names, and Custom Events into a single high-speed lookup Set. -3. **High-Speed Gatekeeper**: By avoiding complex backtracking regexes, the gatekeeper provides predictable `O(1)` performance regardless of how many plugins are registered. -4. **Versioned Registry**: To avoid redundant wordlist rebuilding, the Guard monitors a version counter on the alias registry. The wordlist is only rebuilt when a mutation actually occurs. -5. **Auto-Lazy**: Valid inputs that pass the guard automatically switch the instance to `mode: 'defer'`, deferring the full `O(N)` parse work until a property is actually read. +1. **Longest-Token Matching**: To prevent fractional matching (e.g., matching `qtr` inside `quarter`), the guard executes a greedy "Scan-and-Consume" loop that strongly prioritizes the longest available token match. +2. **Unified Wordlist**: The guard systematically ingests all registered Terms, Timezones, Month names, and Custom Events into a single `O(1)` Set lookup. +3. **High-Speed Gatekeeper**: By eliminating complex backtracking regular expressions, the gatekeeper provides deterministic `O(1)` performance regardless of the depth of registered plugins. +4. **Versioned Registry**: To avoid catastrophic redundant wordlist rebuilding, the Guard strictly monitors a version counter on the alias registry. The wordlist is dynamically rebuilt *only* when a valid mutation occurs. +5. **Auto-Lazy**: Valid inputs that successfully clear the guard automatically shift the instance to `mode: 'defer'`, completely deferring the heavy `O(N)` parsing work until a property is strictly read. --- -## 🧩 Centralized Alias Management (v2.9.0) -As of **v2.9.0**, Tempo has consolidated all Event and Period alias logic into a dedicated **`AliasEngine`**. +## 🧩 Centralized Alias Management +Tempo consolidates all Event and Period alias logic into a deeply optimized **`AliasEngine`**. ### How it works: -- **Hierarchical Registry**: Aliases are managed in a prototype-aware chain. A local `Tempo` instance can have its own private aliases that shadow global ones, all while sharing the same underlying resolution logic. -- **Rich Metadata**: Every resolution returns a structured `AliasResult`, providing the Parser with immediate knowledge of the alias's origin (global vs local), type, and clock-snapping requirements. -- **Clock Snapping**: Time-based aliases (e.g. `8:00`) are automatically "snapped" to absolute precision, clearing sub-second drift (ms, us, ns) during the resolution phase. -- **Decoupled Registration**: By moving away from legacy raw objects, the registry is now protected against accidental mutation and supports efficient, version-aware monitoring. +- **Hierarchical Registry**: Aliases operate in a strict prototype-aware chain. A local `Tempo` instance can deploy its own private aliases that safely shadow global implementations, all while sharing the underlying resolution engines. +- **Rich Metadata**: Every resolution returns a strict `AliasResult`, providing the Parser with immediate categorical knowledge of the alias's origin (global vs local), structural type, and clock-snapping requirements. +- **Clock Snapping**: Time-based aliases (e.g. `8:00`) are mathematically "snapped" to absolute precision, clearing sub-second drift (ms, us, ns) directly during the resolution phase. +- **Decoupled Registration**: By deprecating legacy raw objects, the modern registry is highly protected against accidental mutation and supports high-efficiency, version-aware monitoring. ### πŸ“ˆ Validation & Performance -The efficiency of the Master Guard and the success of the Zero-Cost objective have been validated via local benchmarking: +The algorithmic efficiency of the Master Guard and the success of the Zero-Cost objective remain strictly validated via core benchmarking: -- **Instantiation Overhead**: ~523Β΅s on average (passing the Master Guard). *(Node.js v24.14.1, 12th Gen Intel i7-1255U, Linux x86_64; steady-state measured after 1k warm-up runs, n=10k. Validates the Zero-Cost objective on this hardware.)* +- **Instantiation Overhead**: ~523Β΅s on average (passing the Master Guard). *(Node.js v24.14.1, 12th Gen Intel i7-1255U, Linux x86_64; steady-state measured after 1k warm-up runs, n=10k. Validates the Zero-Cost O(1) objective on this hardware.)* - **Fast-Fail Rejection**: ~359Β΅s on average (failing the Master Guard). *(Node.js v24.14.1, 12th Gen Intel i7-1255U, Linux x86_64; steady-state measured after 1k warm-up runs, n=10k. Demonstrates the Master Guard's low-latency rejection performance.)* ::: tip -For detailed timing results and methodology, see [Performance Benchmarks](./tempo.benchmarks.md). +For detailed execution timings and methodology, see the **[Performance Benchmarks](./tempo.benchmarks.md)**. ::: --- ## πŸ”„ Internal Lifecycle & Reactive Sync -Tempo maintains system-wide synchronization through a private, Symbol-based hook system. +Tempo commands system-wide synchronization through a deeply integrated, Symbol-based hook ecosystem. ### Reactive Registration -When a plugin is imported via a side-effect (`import '@magmacomputing/tempo/duration'`), it triggers a **`sym.$Register`** hook. -- **Auto-Sync**: The `Tempo` class listens for these hooks and automatically updates its internal registries. -- **Guard Rebuild**: Every time a new Term or layout is registered, the **Master Guard** is automatically rebuilt to include the new tokens, ensuring the "Zero-Cost Constructor" always stays up to date. +When a plugin is imported via a side-effect (`import '@magmacomputing/tempo/duration'`), it triggers a strict **`sym.$Register`** hook. +- **Auto-Sync**: The `Tempo` core listens for these hooks and synchronously updates its internal registries. +- **Guard Rebuild**: Upon any new Term or layout registration, the **Master Guard** is automatically rebuilt to dynamically ingest the new tokens, ensuring the "Zero-Cost Constructor" remains entirely synchronized with the ecosystem. ### Disposable Engine (`Symbol.dispose`) -The `Tempo` class implements the explicit resource management pattern. -- **Clean Slate**: Calling `Tempo[Symbol.dispose]()` (or using the `using` keyword in a test suite) resets all global registries and configuration to their factory defaults. -- **Isolation**: This is critical for testing environments to prevent state-leaks between test cases. +The `Tempo` class rigorously implements the explicit resource management pattern. +- **Clean Slate**: Calling `Tempo[Symbol.dispose]()` (or utilizing the `using` keyword in a test suite) comprehensively resets all global registries and configuration parameters back to factory defaults. +- **Isolation**: This is mathematically critical for automated testing environments to prevent memory or state-leaks between discrete test cases. --- ## βš–οΈ Summary -The Tempo architecture follows the principle of **"Right Tool for the Job"**: -- **Shadowing** provides the extreme performance and memory efficiency required for **per-instance computed state**. -- **Proxies** provide the reference stability and controlled extensibility required for **global system registries**. -- **Master Guard** ensures that even with massive extensibility, the entry point remains a "Zero-Cost Constructor". +The Tempo architecture strictly follows the structural principle of **"Right Tool for the Job"**: +- **Shadowing** provides the extreme performance and mathematical memory efficiency required for **per-instance computed state**. +- **Proxies** enforce the strict reference stability and highly controlled extensibility required for **global system registries**. +- **Master Guard** algorithmically ensures that even under massive extensibility, the entry point maintains a true **"Zero-Cost Constructor"**. diff --git a/packages/tempo/doc/tempo-vs-temporal.md b/packages/tempo/doc/tempo-vs-temporal.md deleted file mode 100644 index 5845fd67..00000000 --- a/packages/tempo/doc/tempo-vs-temporal.md +++ /dev/null @@ -1,113 +0,0 @@ -# πŸ†š Tempo vs. Native Temporal - -While `Temporal` provides an excellent, mathematically sound foundation for dates in JavaScript, it is designed to be highly explicit and strict. **Tempo** acts as a developer-friendly wrapper that eliminates boilerplate and makes common tasks effortless, while still giving you the rock-solid reliability of Temporal under the hood. - -To enhance (not replace) Temporal's strictness, Tempo adds: -* flexibility (through its parsing engine and output formatting), -* convenience (through its many getters and methods), -* configurability (through its dynamic aliases (events, periods)), -* business logic (through its lazy-loaded plugin system (Terms)) - -Here is a side-by-side comparison of how you achieve the same outcomes, as well as things Tempo can do that native Temporal cannot easily. - -### 1. Parsing: Strict vs. Flexible - -Temporal only accepts strict ISO 8601 strings. If you have user input, database dumps, or human-readable dates, you have to write your own parser first. Tempo handles it out-of-the-box. - -**Native Temporal ❌** -```javascript - -Temporal.PlainDate.from('2026/01/24'); // Throws RangeError: invalid ISO 8601 string -Temporal.PlainDate.from('next Friday'); // Throws RangeError -``` - -**Tempo βœ…** -```javascript - -new Tempo('2026/01/24'); // Parses perfectly -new Tempo('next Friday'); // Parses relative natural language perfectly -``` - -### 2. Formatting: Verbose vs. Simple Tokens - -Temporal relies on the `Intl.DateTimeFormat` API for formatting. While powerful for localization, it is incredibly verbose for simple, specific string outputs. - -**Native Temporal 🐒** -```javascript -const date = Temporal.Now.plainDateISO(); -date.toLocaleString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' }); // Output: "24 Jan 2026" -``` - -**Tempo πŸš€** -```javascript -const t = new Tempo('2026-01-24T12:00:00'); - -// Use the format method to create custom formats, or use the pre-built getters (on the 'fmt' property) -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({ registry: { 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. - -**Native Temporal 🐒** -```javascript -const date = Temporal.Now.plainDateISO(); - -// To find the fiscal/calendar quarter... -const month = date.month; -const fiscalQuarter = `Q${Math.ceil(month / 3)}`; // Manual math - -// What if your fiscal year starts in July? Or you need meteorological seasons? -// Write more complex utility functions and import them everywhere. -``` - -**Tempo πŸš€** -Tempo solves this elegantly using the **Terms** plugin system. Terms are lazy-loaded plugins that evaluate the current date against semantic boundaries without adding memory bloat. - -```javascript -const t = new Tempo('2026-01-24T12:00:00', { sphere: 'north' }); - -// Built-in complex Terms via the standard plugin -t.term.qtr; // β†’ 'Q1' (Calculates fiscal quarter) -t.term.szn; // β†’ 'Winter' (Calculates meteorological season, respecting hemisphere) -``` - -For more information on adding your own business logic, see the [Terms Guide](tempo.term.md). - -### 4. Relative Time & Duration Strings - -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. - - - -**Native Temporal 🐒** -```javascript -const now = Temporal.Now.plainDateTimeISO(); -const target = Temporal.PlainDateTime.from('2026-12-25T00:00:00'); -const duration = now.until(target); // Returns a complex Duration object -``` - -**Tempo πŸš€** -Tempo understands natural language targets and provides multiple ways to measure and format the resulting elapsed time. - -- `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"). - -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/test/core/bootstrap.test.ts b/packages/tempo/test/core/bootstrap.test.ts index 807615be..24f54f74 100644 --- a/packages/tempo/test/core/bootstrap.test.ts +++ b/packages/tempo/test/core/bootstrap.test.ts @@ -8,7 +8,7 @@ describe('Tempo.bootstrap()', () => { it('should dynamically discover and load a configuration file', async () => { // Resolve the fixture directory from the workspace root - const fixtureDir = path.resolve('./test/core/__fixtures__/config'); + const fixtureDir = path.resolve(import.meta.dirname, '__fixtures__/config'); await Tempo.bootstrap({ cwd: fixtureDir }); @@ -20,7 +20,7 @@ describe('Tempo.bootstrap()', () => { }); it('should allow explicitly passing a configFile', async () => { - const fixtureDir = path.resolve('./test/core/__fixtures__/config'); + const fixtureDir = path.resolve(import.meta.dirname, '__fixtures__/config'); // Pass the explicit file path await Tempo.bootstrap({ configFile: path.join(fixtureDir, 'tempo.config.js') }); From 0597841601f77cfed9f32a9ab2879e8f0a1cbdd9 Mon Sep 17 00:00:00 2001 From: Michael McRae Date: Fri, 10 Jul 2026 16:57:35 +1000 Subject: [PATCH 02/18] new tempo-fns --- packages/tempo-fns/doc/CONTRIBUTING.md | 57 ++++++++ packages/tempo-fns/doc/README.md | 47 +++++++ packages/tempo-fns/doc/preview.html | 19 +++ packages/tempo-fns/img/tempo-fns-logo.svg | 14 ++ packages/tempo-fns/package.json | 50 +++++++ packages/tempo-fns/rollup.config.js | 15 ++ packages/tempo-fns/src/index.ts | 2 + packages/tempo-fns/src/isFirstDayOfMonth.ts | 12 ++ packages/tempo-fns/src/isSameFiscalQuarter.ts | 16 +++ .../tempo-fns/test/isFirstDayOfMonth.test.ts | 19 +++ .../test/isSameFiscalQuarter.test.ts | 22 +++ packages/tempo-fns/test/tsconfig.json | 20 +++ packages/tempo-fns/tsconfig.json | 20 +++ packages/tempo-fns/vitest.config.ts | 27 ++++ packages/tempo/doc/extending-temporal.md | 129 ++++++++++++++++++ 15 files changed, 469 insertions(+) create mode 100644 packages/tempo-fns/doc/CONTRIBUTING.md create mode 100644 packages/tempo-fns/doc/README.md create mode 100644 packages/tempo-fns/doc/preview.html create mode 100644 packages/tempo-fns/img/tempo-fns-logo.svg create mode 100644 packages/tempo-fns/package.json create mode 100644 packages/tempo-fns/rollup.config.js create mode 100644 packages/tempo-fns/src/index.ts create mode 100644 packages/tempo-fns/src/isFirstDayOfMonth.ts create mode 100644 packages/tempo-fns/src/isSameFiscalQuarter.ts create mode 100644 packages/tempo-fns/test/isFirstDayOfMonth.test.ts create mode 100644 packages/tempo-fns/test/isSameFiscalQuarter.test.ts create mode 100644 packages/tempo-fns/test/tsconfig.json create mode 100644 packages/tempo-fns/tsconfig.json create mode 100644 packages/tempo-fns/vitest.config.ts create mode 100644 packages/tempo/doc/extending-temporal.md diff --git a/packages/tempo-fns/doc/CONTRIBUTING.md b/packages/tempo-fns/doc/CONTRIBUTING.md new file mode 100644 index 00000000..5a23422a --- /dev/null +++ b/packages/tempo-fns/doc/CONTRIBUTING.md @@ -0,0 +1,57 @@ +# Contributing to `tempo-fns` + +Welcome to `@magmacomputing/tempo-fns`! We are thrilled that you want to contribute to the ecosystem. + +This library acts as a granular, tree-shakeable collection of utility functions designed to make working with dates easier for the modern web. Our goal is to provide a seamless bridge for users migrating from legacy tools (like Moment.js or `date-fns`), introducing them to the raw power of the Temporal API and the `@magmacomputing/tempo` ecosystem. + +To maintain our high standards, please adhere to the following guidance when submitting a Pull Request. + +--- + +## 1. The Dual Architecture Strategy + +We categorize utility functions into two distinct buckets. You must determine which bucket your function falls into before writing it. + +### A. Temporal-Only Functions +These functions rely strictly on standard date calculations and duck-typing native properties (like `.day`, `.month`, `.year`). +- **Requirement**: They MUST accept raw `Temporal` primitives (or a polymorphic duck-typed interface) alongside `Tempo` instances. +- **Why?**: This allows standard `Temporal` API users to download and use `tempo-fns` *without* being forced to adopt `Tempo`. +- **Example**: `isFirstDayOfMonth(input: TemporalLike | Tempo)` simply checks `input.day === 1`, which natively works for both objects! + +### B. Tempo-Enhanced Functions +These functions perform complex business intelligence calculations (like fiscal quarters, astrological seasons) and **require** the internal `Tempo` Terms engine. +- **Requirement**: They MUST type their arguments strictly as `Tempo` objects. +- **Why?**: This showcases the true power of `Tempo`. Users who need advanced business logic will quickly realize they need `Tempo` to drive it. +- **Example**: `isSameFiscalQuarter(date1: Tempo, date2: Tempo)` which relies on the `quarter` Term and comparing exact nanosecond `epoch.ns` bounds. + +--- + +## 2. No Polyfills Provided + +**CRITICAL RULE**: `tempo-fns` is a pure utility library. **We do not bundle or provide a Temporal polyfill.** + +- It is the **consumer's responsibility** to ensure their environment supports Temporal natively (Node 26+, modern browsers) or to provide their own polyfill (like `@js-temporal/polyfill`). +- Do not add any polyfills to `dependencies` in `package.json`. +- (We only use the polyfill in `devDependencies` strictly for running our internal `vitest` suite). + +--- + +## 3. Pull Request Process & Structure + +When submitting a PR for a new function, you must include three things: the Source, the Test, and the Doc update. + +1. **Source** (`src/[functionName].ts`): + - Export your single function. + - Do not use default exports. + - Keep it 100% pure and tree-shakeable. + - Update `src/index.ts` to export your new function. + +2. **Test** (`test/[functionName].test.ts`): + - Every function MUST have 100% test coverage. + - Test against raw `Temporal` objects (if applicable) AND `Tempo` objects. + - Handle edge cases (leap years, DST shifts, hemisphere differences if using Terms). + +3. **Documentation** (`doc/README.md`): + - Provide a quick code snippet in the package README showcasing how to import and use your new function. + +Once your files are structured correctly, submit a PR to the `magma` monorepo! Please ensure you run the tests and linter locally before submitting, as our automated CI pipeline is not yet available for this initial release. diff --git a/packages/tempo-fns/doc/README.md b/packages/tempo-fns/doc/README.md new file mode 100644 index 00000000..3c7c11d1 --- /dev/null +++ b/packages/tempo-fns/doc/README.md @@ -0,0 +1,47 @@ + + + + + + + +
+ Tempo-fns Logo + +

@magmacomputing/tempo-fns

+

The "date-fns" of the Temporal Era

+
+ +A massive library of highly granular, fully tree-shakeable utility functions built directly on top of the JavaScript Temporal API. + +This package provides a bridge for developers transitioning from legacy date wrappers (like Moment or `date-fns`) into the modern Temporal API. + +### Why `tempo-fns`? +1. **Tree-shakeable**: Import exactly what you need. `import { isFirstDayOfMonth } from '@magmacomputing/tempo-fns'` pulls in zero extra bloat. +2. **Native Temporal**: Functions accept native `Temporal.ZonedDateTime` and `Temporal.PlainDate` objects. You don't *have* to use the `Tempo` class. +3. **Synergy**: If you *do* use the `Tempo` class wrapper, `tempo-fns` provides advanced business-intelligence utilities that inherently understand Tempo's Terms engine (e.g., `isSameFiscalQuarter`). + +## Usage (NPM / Modern Bundlers) + +```typescript +import { Tempo } from '@magmacomputing/tempo'; +import { isFirstDayOfMonth, isSameFiscalQuarter } from '@magmacomputing/tempo-fns'; + +const today = new Tempo(); + +if (isFirstDayOfMonth(today)) { + console.log('Rent is due!'); +} +``` + +## Usage (Static CDN / Browser Global) + +If you aren't using a bundler (like Vite, Webpack, or Rollup), we provide a pre-bundled script that exposes a `TempoFns` global object. + +```html + + +``` diff --git a/packages/tempo-fns/doc/preview.html b/packages/tempo-fns/doc/preview.html new file mode 100644 index 00000000..29fbefc1 --- /dev/null +++ b/packages/tempo-fns/doc/preview.html @@ -0,0 +1,19 @@ + + + + + README Preview + + + + + +
+ + + \ No newline at end of file diff --git a/packages/tempo-fns/img/tempo-fns-logo.svg b/packages/tempo-fns/img/tempo-fns-logo.svg new file mode 100644 index 00000000..cea47eea --- /dev/null +++ b/packages/tempo-fns/img/tempo-fns-logo.svg @@ -0,0 +1,14 @@ + + + + + + Ξ» + + + + + + + + diff --git a/packages/tempo-fns/package.json b/packages/tempo-fns/package.json new file mode 100644 index 00000000..3c017d4b --- /dev/null +++ b/packages/tempo-fns/package.json @@ -0,0 +1,50 @@ +{ + "name": "@magmacomputing/tempo-fns", + "version": "3.7.1", + "description": "Tree-shakeable functional utilities for the Temporal API", + "author": "Magma Computing Solutions", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/magmacomputing/magma.git", + "directory": "packages/tempo-fns" + }, + "type": "module", + "sideEffects": false, + "main": "dist/index.js", + "types": "dist/index.d.ts", + "browser": "dist/tempo-fns.global.js", + "unpkg": "dist/tempo-fns.global.js", + "jsdelivr": "dist/tempo-fns.global.js", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "./*": { + "types": "./dist/*.d.ts", + "import": "./dist/*.js" + } + }, + "scripts": { + "test": "vitest run", + "build": "npm run clean && npm run build:esm && npm run build:global", + "build:esm": "tsc -b", + "build:global": "rollup -c", + "clean": "rm -rf dist && (tsc -b --clean || true)", + "prepublishOnly": "if [ $(git rev-parse --abbrev-ref HEAD) != main ]; then echo 'ERROR: Must be on main branch to publish.'; exit 1; fi && npm run build" + }, + "peerDependencies": { + "@magmacomputing/tempo": "^3.7.0" + }, + "peerDependenciesMeta": { + "@magmacomputing/tempo": { + "optional": true + } + }, + "devDependencies": { + "@rollup/plugin-node-resolve": "^16.0.3", + "rollup": "^4.62.2" + }, + "dependencies": {} +} diff --git a/packages/tempo-fns/rollup.config.js b/packages/tempo-fns/rollup.config.js new file mode 100644 index 00000000..f2d83fff --- /dev/null +++ b/packages/tempo-fns/rollup.config.js @@ -0,0 +1,15 @@ +import resolve from '@rollup/plugin-node-resolve'; + +export default { + input: 'dist/index.js', + output: { + file: 'dist/tempo-fns.global.js', + format: 'iife', + name: 'TempoFns', + globals: { + '@magmacomputing/tempo': 'Tempo' + } + }, + external: ['@magmacomputing/tempo'], + plugins: [resolve()] +} diff --git a/packages/tempo-fns/src/index.ts b/packages/tempo-fns/src/index.ts new file mode 100644 index 00000000..c0f2f99d --- /dev/null +++ b/packages/tempo-fns/src/index.ts @@ -0,0 +1,2 @@ +export { isFirstDayOfMonth } from './isFirstDayOfMonth.js'; +export { isSameFiscalQuarter } from './isSameFiscalQuarter.js'; diff --git a/packages/tempo-fns/src/isFirstDayOfMonth.ts b/packages/tempo-fns/src/isFirstDayOfMonth.ts new file mode 100644 index 00000000..2beff298 --- /dev/null +++ b/packages/tempo-fns/src/isFirstDayOfMonth.ts @@ -0,0 +1,12 @@ +import type { Tempo } from '@magmacomputing/tempo'; + +// Minimal duck-typing interface for Temporal objects +interface TemporalLike { + day: number; +} + +export const isFirstDayOfMonth = (input: TemporalLike | Tempo): boolean => { + // Tempo exposes `.day` directly, making it natively compatible + // with Temporal's duck-typing! + return input.day === 1; +}; diff --git a/packages/tempo-fns/src/isSameFiscalQuarter.ts b/packages/tempo-fns/src/isSameFiscalQuarter.ts new file mode 100644 index 00000000..5c239bcb --- /dev/null +++ b/packages/tempo-fns/src/isSameFiscalQuarter.ts @@ -0,0 +1,16 @@ +import type { Tempo } from '@magmacomputing/tempo'; + +/** + * Checks if two dates fall in the same Fiscal Quarter. + * This function inherently requires the Tempo Terms engine. + */ +export const isSameFiscalQuarter = (date1: Tempo, date2: Tempo): boolean => { + const q1 = date1.term.quarter; + const q2 = date2.term.quarter; + + if (!q1 || !q2) + throw new Error("QuarterTerm plugin must be loaded to calculate fiscal quarters."); + + // Compare the exact start timestamps to ensure they are the exact same quarter in the same year + return q1.start.epoch.ns === q2.start.epoch.ns; +} diff --git a/packages/tempo-fns/test/isFirstDayOfMonth.test.ts b/packages/tempo-fns/test/isFirstDayOfMonth.test.ts new file mode 100644 index 00000000..b4d95243 --- /dev/null +++ b/packages/tempo-fns/test/isFirstDayOfMonth.test.ts @@ -0,0 +1,19 @@ +import { isFirstDayOfMonth } from '../src/isFirstDayOfMonth.js'; +import { Tempo } from '@magmacomputing/tempo'; + +describe('isFirstDayOfMonth', () => { + it('should return true for the first day of the month (Tempo)', () => { + const t = new Tempo('2026-07-01'); + expect(isFirstDayOfMonth(t)).toBe(true); + }); + + it('should return false for other days (Tempo)', () => { + const t = new Tempo('2026-07-02'); + expect(isFirstDayOfMonth(t)).toBe(false); + }); + + it('should handle native Temporal objects gracefully', () => { + const t = { day: 1 }; // Duck typing to simulate native Temporal object + expect(isFirstDayOfMonth(t as any)).toBe(true); + }); +}); diff --git a/packages/tempo-fns/test/isSameFiscalQuarter.test.ts b/packages/tempo-fns/test/isSameFiscalQuarter.test.ts new file mode 100644 index 00000000..b0b85057 --- /dev/null +++ b/packages/tempo-fns/test/isSameFiscalQuarter.test.ts @@ -0,0 +1,22 @@ +import { isSameFiscalQuarter } from '../src/isSameFiscalQuarter.js'; +import { Tempo } from '@magmacomputing/tempo'; + +describe('isSameFiscalQuarter', () => { + beforeAll(() => { + Tempo.init(); + }); + + it('should return true for dates in the same fiscal quarter', () => { + const t1 = new Tempo('2026-02-01'); + const t2 = new Tempo('2026-03-01'); + + expect(isSameFiscalQuarter(t1, t2)).toBe(true); + }); + + it('should return false for dates in different fiscal quarters', () => { + const t1 = new Tempo('2026-03-01'); // Q1 + const t2 = new Tempo('2026-04-01'); // Q2 + + expect(isSameFiscalQuarter(t1, t2)).toBe(false); + }); +}); diff --git a/packages/tempo-fns/test/tsconfig.json b/packages/tempo-fns/test/tsconfig.json new file mode 100644 index 00000000..1c8d085e --- /dev/null +++ b/packages/tempo-fns/test/tsconfig.json @@ -0,0 +1,20 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "..", + "noEmit": true, + "composite": true, + "types": [ + "node", + "vitest/globals" + ] + }, + "include": [ + "**/*.ts" + ], + "references": [ + { + "path": ".." + } + ] +} \ No newline at end of file diff --git a/packages/tempo-fns/tsconfig.json b/packages/tempo-fns/tsconfig.json new file mode 100644 index 00000000..d5ebbb72 --- /dev/null +++ b/packages/tempo-fns/tsconfig.json @@ -0,0 +1,20 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "./src", + "outDir": "./dist", + "composite": true, + "declarationMap": true + }, + "include": [ + "src/**/*" + ], + "exclude": [ + "node_modules", + "dist", + "test" + ], + "references": [ + { "path": "../tempo/src" } + ] +} diff --git a/packages/tempo-fns/vitest.config.ts b/packages/tempo-fns/vitest.config.ts new file mode 100644 index 00000000..5d48ee3d --- /dev/null +++ b/packages/tempo-fns/vitest.config.ts @@ -0,0 +1,27 @@ +import { defineConfig } from 'vitest/config'; +import { resolve, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import swc from 'unplugin-swc'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const polyfill = resolve(__dirname, '../tempo/bin/temporal-polyfill.ts'); + +export default defineConfig({ + esbuild: false, + oxc: false, + plugins: [ + swc.vite({ + jsc: { + target: 'es2022', + parser: { syntax: 'typescript', decorators: true }, + transform: { decoratorVersion: '2023-11' }, + }, + }), + ], + test: { + globals: true, + environment: 'node', + include: ['test/**/*.test.ts'], + setupFiles: [polyfill] + } +}); diff --git a/packages/tempo/doc/extending-temporal.md b/packages/tempo/doc/extending-temporal.md new file mode 100644 index 00000000..e844fb60 --- /dev/null +++ b/packages/tempo/doc/extending-temporal.md @@ -0,0 +1,129 @@ +# 🀝 Extending Native Temporal + +> [!IMPORTANT] +> **Tempo is NOT a replacement for Native Temporal.** +> Temporal provides an excellent, mathematically sound foundation for dates in JavaScript, and Tempo is built directly on top of it. Tempo acts as a developer-friendly wrapper that eliminates boilerplate and makes common tasks effortless, while retaining the rock-solid reliability of Temporal under the hood. + +To complement Temporal's explicit strictness, Tempo adds: +* **Flexibility**: Out-of-the-box natural language parsing and intelligent formatting. +* **Convenience**: Extensive getters, method chaining, and zero-configuration defaults. +* **Configurability**: Dynamic semantic aliases for resolving events and periods. +* **Extensibility**: A lazy-loaded plugin architecture for domain-specific calculations. + +Here is a side-by-side comparison demonstrating how Tempo drastically reduces boilerplate for standard operations, while unlocking capabilities that are difficult to achieve with native Temporal alone. + +### 1. Parsing: Strict vs. Intelligent + +Temporal only accepts strict ISO 8601 strings. If you have user input, database dumps, or human-readable dates, you have to write your own parser first. Tempo handles it out-of-the-box. + +**Native Temporal ❌** +```javascript + +Temporal.PlainDate.from('2026/01/24'); // Throws RangeError: invalid ISO 8601 string +Temporal.PlainDate.from('next Friday'); // Throws RangeError: invalid ISO 8601 string +``` + +**Tempo βœ…** +```javascript + +new Tempo('2026/01/24'); // Parses perfectly +new Tempo('next Friday'); // Parses relative natural language perfectly +``` + +For more information on handling natural language and complex strings, read the dedicated **[Parsing Guide](tempo.parse.md)**. + +### 2. Formatting: Verbose vs. Expressive Tokens + +Temporal relies on the `Intl.DateTimeFormat` API for formatting. While powerful for localization, it is incredibly verbose for simple, specific string outputs. + +**Native Temporal 🐒** +```javascript +const date = Temporal.Now.plainDateISO(); +date.toLocaleString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' }); // Output: "24 Jan 2026" +``` + +**Tempo πŸš€** +```javascript +const t = new Tempo('2026-01-24T12:00:00'); + +// Use the format method to create custom formats, or use the pre-built getters (on the 'fmt' property) +t.format('{dd} {mmm} {yyyy}'); // Output: "24 Jan 2026" +t.fmt.date; // Output: "2026-01-24" +``` + +For comprehensive examples of localized output and custom layouts, read the dedicated **[Formatting Guide](tempo.format.md)**. + +### 3. Extensibility: Domain-Specific Logic + +Native Temporal deals strictly with standard calendar units (days, months, years). If you need to map a date to domain-specific logic (like a fiscal quarter or a meteorological season), you have to write and maintain your own math utilities. + +**Native Temporal 🐒** +```javascript +const date = Temporal.Now.plainDateISO(); + +// 1. Calculate the fiscal quarter +const month = date.month; +const fiscalQuarter = `Q${Math.ceil(month / 3)}`; // Manual math + +// 2. Calculate the meteorological season (Northern Hemisphere) +let season; +if (month === 12 || month <= 2) season = 'Winter'; +else if (month >= 3 && month <= 5) season = 'Spring'; +else if (month >= 6 && month <= 8) season = 'Summer'; +else season = 'Autumn'; + +// And what if you need to support the Southern Hemisphere? +// You have to write and maintain even more utility functions. +``` + +**Tempo πŸš€** +Tempo solves this elegantly using the **Terms** plugin system. Terms are lazy-loaded plugins that evaluate the current date against semantic boundaries without adding memory bloat. + +```javascript +const t = new Tempo('2026-01-24T12:00:00', { sphere: 'north' }); + +// Built-in complex Terms via the standard plugin +t.term.qtr; // β†’ 'Q1' (Calculates fiscal quarter) +t.term.szn; // β†’ 'Winter' (Calculates meteorological season, respecting hemisphere) +``` + +For more information on adding your own domain-specific logic, read the dedicated **[Terms Guide](tempo.term.md)**. + +### 4. Duration Logic: Strict Math vs. Human Readable + +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. + + + +**Native Temporal 🐒** +```javascript +// 1. Calculate the raw duration +const now = Temporal.Now.plainDateISO(); +const target = Temporal.PlainDate.from('2026-12-25'); +now.until(target); // β†’ Returns a complex Duration object + +// 2. Format a past date into a human-readable string +const pastDate = Temporal.PlainDate.from('2026-07-06'); +const diff = pastDate.until(now, { largestUnit: 'days' }); +const rtf = new Intl.RelativeTimeFormat('en', { numeric: 'auto' }); +rtf.format(-Math.abs(diff.days), 'day'); // β†’ "3 days ago" +``` + +**Tempo πŸš€** +Tempo understands natural language targets and provides multiple ways to measure and format the resulting elapsed time. + +```javascript +const t = new Tempo(); + +// 1. .until() returns an EDO or a precise number +t.until('2026-12-25').duration; // β†’ The underlying Temporal.Duration object +t.until('2026-12-25', 'days'); // β†’ 219 + +// 2. .since() instantly returns human-readable relative strings +t.since('last Friday', 'days'); // β†’ "3 days ago" +``` + +- `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"). + +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 From c60d9358342866e159037c7233fb00082b7bdeac Mon Sep 17 00:00:00 2001 From: Michael McRae Date: Sat, 11 Jul 2026 12:40:00 +1000 Subject: [PATCH 03/18] new namespace --- package-lock.json | 50 +++++- package.json | 10 +- packages/library/package.json | 6 +- packages/tempo-fns/src/index.ts | 4 + packages/tempo/.vitepress/config.ts | 71 ++++---- packages/tempo/CHANGELOG.md | 10 ++ packages/tempo/CONTRIBUTING.md | 6 +- .../{ => 1-getting-started}/installation.md | 0 .../{ => 1-getting-started}/tempo.cookbook.md | 12 +- .../doc/{ => 2-core-concepts}/tempo.config.md | 2 +- .../{ => 2-core-concepts}/tempo.duration.md | 0 .../doc/{ => 2-core-concepts}/tempo.format.md | 0 .../doc/{ => 2-core-concepts}/tempo.layout.md | 0 .../doc/{ => 2-core-concepts}/tempo.parse.md | 0 .../{ => 2-core-concepts}/tempo.registry.md | 0 .../doc/{ => 3-extending-tempo}/ecosystem.md | 0 .../tempo.extension.md | 4 + .../tempo.modularity.md | 0 .../doc/3-extending-tempo/tempo.namespace.md | 153 ++++++++++++++++++ .../{ => 3-extending-tempo}/tempo.plugin.md | 70 ++------ .../doc/{ => 3-extending-tempo}/tempo.term.md | 0 .../sandbox-factory.md | 0 .../tempo.debugging.md | 4 +- .../tempo.locale.md | 6 +- .../tempo.planner.md | 2 +- .../tempo.shorthand.md | 0 .../tempo.ticker.md | 0 .../tempo.weekday.md | 0 .../architecture.md | 0 .../lazy-evaluation-pattern.md | 0 .../soft_freeze_strategy.md | 0 .../tempo.benchmarks.md | 0 .../tempo.decorators.md | 0 .../tempo.enumerators.md | 0 .../{ => 6-utility-library}/tempo.library.md | 0 .../{ => 6-utility-library}/tempo.pledge.md | 0 .../tempo.serializers.md | 0 .../tempo/doc/{ => 7-ecosystem}/comparison.md | 2 +- .../{ => 7-ecosystem}/extending-temporal.md | 8 +- .../tempo/doc/{ => 7-ecosystem}/vision.md | 0 .../{ => 8-project-and-support}/commercial.md | 0 .../migration-guide.md | 0 .../releases/index.md | 0 .../releases/v0.x.md | 0 .../releases/v1.x.md | 0 .../releases/v2.x.md | 0 .../releases/v3.x.md | 11 ++ .../releases/v4.x.md | 0 packages/tempo/package.json | 6 +- packages/tempo/src/plugin/plugin.util.ts | 63 +++++++- packages/tempo/src/plugin/term/term.util.ts | 7 +- packages/tempo/src/tempo.class.ts | 29 ++-- packages/tempo/src/tempo.version.ts | 2 +- 53 files changed, 388 insertions(+), 150 deletions(-) rename packages/tempo/doc/{ => 1-getting-started}/installation.md (100%) rename packages/tempo/doc/{ => 1-getting-started}/tempo.cookbook.md (95%) rename packages/tempo/doc/{ => 2-core-concepts}/tempo.config.md (98%) rename packages/tempo/doc/{ => 2-core-concepts}/tempo.duration.md (100%) rename packages/tempo/doc/{ => 2-core-concepts}/tempo.format.md (100%) rename packages/tempo/doc/{ => 2-core-concepts}/tempo.layout.md (100%) rename packages/tempo/doc/{ => 2-core-concepts}/tempo.parse.md (100%) rename packages/tempo/doc/{ => 2-core-concepts}/tempo.registry.md (100%) rename packages/tempo/doc/{ => 3-extending-tempo}/ecosystem.md (100%) rename packages/tempo/doc/{ => 3-extending-tempo}/tempo.extension.md (93%) rename packages/tempo/doc/{ => 3-extending-tempo}/tempo.modularity.md (100%) create mode 100644 packages/tempo/doc/3-extending-tempo/tempo.namespace.md rename packages/tempo/doc/{ => 3-extending-tempo}/tempo.plugin.md (82%) rename packages/tempo/doc/{ => 3-extending-tempo}/tempo.term.md (100%) rename packages/tempo/doc/{ => 4-advanced-reference}/sandbox-factory.md (100%) rename packages/tempo/doc/{ => 4-advanced-reference}/tempo.debugging.md (97%) rename packages/tempo/doc/{ => 4-advanced-reference}/tempo.locale.md (96%) rename packages/tempo/doc/{ => 4-advanced-reference}/tempo.planner.md (93%) rename packages/tempo/doc/{ => 4-advanced-reference}/tempo.shorthand.md (100%) rename packages/tempo/doc/{ => 4-advanced-reference}/tempo.ticker.md (100%) rename packages/tempo/doc/{ => 4-advanced-reference}/tempo.weekday.md (100%) rename packages/tempo/doc/{ => 5-architecture-and-internals}/architecture.md (100%) rename packages/tempo/doc/{ => 5-architecture-and-internals}/lazy-evaluation-pattern.md (100%) rename packages/tempo/doc/{ => 5-architecture-and-internals}/soft_freeze_strategy.md (100%) rename packages/tempo/doc/{ => 5-architecture-and-internals}/tempo.benchmarks.md (100%) rename packages/tempo/doc/{ => 6-utility-library}/tempo.decorators.md (100%) rename packages/tempo/doc/{ => 6-utility-library}/tempo.enumerators.md (100%) rename packages/tempo/doc/{ => 6-utility-library}/tempo.library.md (100%) rename packages/tempo/doc/{ => 6-utility-library}/tempo.pledge.md (100%) rename packages/tempo/doc/{ => 6-utility-library}/tempo.serializers.md (100%) rename packages/tempo/doc/{ => 7-ecosystem}/comparison.md (98%) rename packages/tempo/doc/{ => 7-ecosystem}/extending-temporal.md (94%) rename packages/tempo/doc/{ => 7-ecosystem}/vision.md (100%) rename packages/tempo/doc/{ => 8-project-and-support}/commercial.md (100%) rename packages/tempo/doc/{ => 8-project-and-support}/migration-guide.md (100%) rename packages/tempo/doc/{ => 8-project-and-support}/releases/index.md (100%) rename packages/tempo/doc/{ => 8-project-and-support}/releases/v0.x.md (100%) rename packages/tempo/doc/{ => 8-project-and-support}/releases/v1.x.md (100%) rename packages/tempo/doc/{ => 8-project-and-support}/releases/v2.x.md (100%) rename packages/tempo/doc/{ => 8-project-and-support}/releases/v3.x.md (95%) rename packages/tempo/doc/{ => 8-project-and-support}/releases/v4.x.md (100%) diff --git a/package-lock.json b/package-lock.json index 46cbc317..ed8dccc0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,15 +1,18 @@ { "name": "tempo-monorepo", - "version": "3.7.1", + "version": "3.8.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "tempo-monorepo", - "version": "3.7.1", + "version": "3.8.0", "workspaces": [ "packages/*" ], + "dependencies": { + "typescript-7": "npm:typescript@^7.0.2" + }, "devDependencies": { "@js-temporal/polyfill": "^0.5.1", "@rollup/plugin-node-resolve": "^16.0.3", @@ -27,7 +30,8 @@ "rollup": "^4.62.2", "tslib": "^2.8.1", "tsx": "^4.22.3", - "typescript": "^7.0.2", + "typescript": "^6.0.3", + "typescript-7": "npm:typescript@^7.0.2", "unplugin-swc": "^1.5.9", "vitest": "^4.1.7", "webdriverio": "^9.29.0" @@ -1096,6 +1100,10 @@ "resolved": "packages/tempo", "link": true }, + "node_modules/@magmacomputing/tempo-fns": { + "resolved": "packages/tempo-fns", + "link": true + }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz", @@ -8732,6 +8740,21 @@ } }, "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-7": { + "name": "typescript", "version": "7.0.2", "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", @@ -10510,7 +10533,7 @@ }, "packages/library": { "name": "@magmacomputing/library", - "version": "3.7.1", + "version": "3.8.0", "license": "MIT", "dependencies": { "tslib": "^2.8.1" @@ -10521,7 +10544,7 @@ }, "packages/tempo": { "name": "@magmacomputing/tempo", - "version": "3.7.1", + "version": "3.8.0", "license": "MIT", "dependencies": { "tslib": "^2.8.1" @@ -10544,6 +10567,23 @@ "engines": { "node": ">=20.0.0" } + }, + "packages/tempo-fns": { + "name": "@magmacomputing/tempo-fns", + "version": "3.7.1", + "license": "MIT", + "devDependencies": { + "@rollup/plugin-node-resolve": "^16.0.3", + "rollup": "^4.62.2" + }, + "peerDependencies": { + "@magmacomputing/tempo": "^3.7.0" + }, + "peerDependenciesMeta": { + "@magmacomputing/tempo": { + "optional": true + } + } } } } diff --git a/package.json b/package.json index ad3639d5..b796ad02 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "tempo-monorepo", - "version": "3.7.1", + "version": "3.8.0", "private": true, "engines": { "node": ">=20.0.0" @@ -17,7 +17,7 @@ "test": "cross-env TEMPO_LICENSE_KEY=\"\" vitest run", "build:tempo": "npm run build --workspace=@magmacomputing/tempo", "build:library": "npm run build --workspace=@magmacomputing/library", - "clean": "tsc -b --clean", + "clean": "node node_modules/typescript-7/bin/tsc -b --clean", "version:bump": "node bin/version-bump.mjs", "version:sync": "node bin/version-sync.mjs", "repl": "npm run repl --workspace=@magmacomputing/tempo", @@ -46,7 +46,8 @@ "rollup": "^4.62.2", "tslib": "^2.8.1", "tsx": "^4.22.3", - "typescript": "^7.0.2", + "typescript": "^6.0.3", + "typescript-7": "npm:typescript@^7.0.2", "unplugin-swc": "^1.5.9", "vitest": "^4.1.7", "webdriverio": "^9.29.0" @@ -60,5 +61,8 @@ "@swc/core@1.15.41": true, "esbuild@0.28.1": true, "esbuild@0.21.5": true + }, + "dependencies": { + "typescript-7": "npm:typescript@^7.0.2" } } diff --git a/packages/library/package.json b/packages/library/package.json index 87ad3390..bfcb3fa2 100644 --- a/packages/library/package.json +++ b/packages/library/package.json @@ -1,6 +1,6 @@ { "name": "@magmacomputing/library", - "version": "3.7.1", + "version": "3.8.0", "description": "Shared utility library for Tempo", "author": "Magma Computing Solutions", "license": "MIT", @@ -87,8 +87,8 @@ }, "scripts": { "test": "vitest run", - "build": "tsc -b", - "clean": "tsc -b --clean", + "build": "node ../../node_modules/typescript-7/bin/tsc -b", + "clean": "node ../../node_modules/typescript-7/bin/tsc -b --clean", "prepublishOnly": "if [ $(git rev-parse --abbrev-ref HEAD) != main ]; then echo 'ERROR: Must be on main branch to publish.'; exit 1; fi && npm run build" }, "dependencies": { diff --git a/packages/tempo-fns/src/index.ts b/packages/tempo-fns/src/index.ts index c0f2f99d..464846e1 100644 --- a/packages/tempo-fns/src/index.ts +++ b/packages/tempo-fns/src/index.ts @@ -1,2 +1,6 @@ export { isFirstDayOfMonth } from './isFirstDayOfMonth.js'; export { isSameFiscalQuarter } from './isSameFiscalQuarter.js'; +export { workingHoursUntil, type SLAOptions, preloadHolidays } from './workingHoursUntil.js'; +export { nextCron } from './nextCron.js'; +export { prevCron } from './prevCron.js'; +export { Interval } from './interval.js'; diff --git a/packages/tempo/.vitepress/config.ts b/packages/tempo/.vitepress/config.ts index 3db5bc35..4c3067e9 100644 --- a/packages/tempo/.vitepress/config.ts +++ b/packages/tempo/.vitepress/config.ts @@ -30,36 +30,37 @@ export default defineConfig({ nav: [ { text: 'Guide', link: '/README' }, { text: 'API Reference', link: typedocSidebar[0].items[0].link }, - { text: 'Releases', link: '/doc/releases/' } + { text: 'Releases', link: '/doc/8-project-and-support/releases/' } ], sidebar: [ { text: 'Getting Started', items: [ { text: 'Introduction', link: '/README' }, - { text: 'Installation', link: '/doc/installation' }, - { text: 'Cookbook', link: '/doc/tempo.cookbook' } + { text: 'Installation', link: '/doc/1-getting-started/installation' }, + { text: 'Cookbook', link: '/doc/1-getting-started/tempo.cookbook' } ] }, { text: 'Core Concepts', items: [ - { text: 'Configuration', link: '/doc/tempo.config' }, - { text: 'Registries', link: '/doc/tempo.registry' }, - { text: 'Smart Parsing', link: '/doc/tempo.parse' }, - { text: 'Smart Formatting', link: '/doc/tempo.format' }, - { text: 'Layout Patterns', link: '/doc/tempo.layout' }, - { text: 'Duration Logic', link: '/doc/tempo.duration' } + { text: 'Configuration', link: '/doc/2-core-concepts/tempo.config' }, + { text: 'Registries', link: '/doc/2-core-concepts/tempo.registry' }, + { text: 'Smart Parsing', link: '/doc/2-core-concepts/tempo.parse' }, + { text: 'Smart Formatting', link: '/doc/2-core-concepts/tempo.format' }, + { text: 'Layout Patterns', link: '/doc/2-core-concepts/tempo.layout' }, + { text: 'Duration Logic', link: '/doc/2-core-concepts/tempo.duration' } ] }, { - text: 'Plugins & Terms', + text: 'Extending Tempo', items: [ - { text: 'Modularity', link: '/doc/tempo.modularity' }, - { text: 'Plugin Architecture', link: '/doc/tempo.plugin' }, - { text: 'Term Plugins', link: '/doc/tempo.term' }, - { text: 'Creating a Custom Plugin', link: '/doc/tempo.extension' }, - { text: 'Plugin Ecosystem', link: '/doc/ecosystem' } + { text: 'Modules', link: '/doc/3-extending-tempo/tempo.modularity' }, + { text: 'Plugins', link: '/doc/3-extending-tempo/tempo.plugin' }, + { text: 'Terms', link: '/doc/3-extending-tempo/tempo.term' }, + { text: 'Namespaces', link: '/doc/3-extending-tempo/tempo.namespace' }, + { text: 'Creating Custom Plugins', link: '/doc/3-extending-tempo/tempo.extension' }, + { text: 'Plugin Ecosystem', link: '/doc/3-extending-tempo/ecosystem' } ] }, { @@ -67,47 +68,47 @@ export default defineConfig({ items: [ { text: 'API Overview', link: '/doc/api/' }, { text: 'Technical Reference', link: typedocSidebar[0].items[0].link }, - { text: 'Parse Planner', link: '/doc/tempo.planner' }, - { text: 'The Role of Locale', link: '/doc/tempo.locale' }, - { text: 'Shorthand Engine', link: '/doc/tempo.shorthand' }, - { text: 'Weekday Engine', link: '/doc/tempo.weekday' }, - { text: 'Debugging', link: '/doc/tempo.debugging' } + { text: 'Parse Planner', link: '/doc/4-advanced-reference/tempo.planner' }, + { text: 'The Role of Locale', link: '/doc/4-advanced-reference/tempo.locale' }, + { text: 'Shorthand Engine', link: '/doc/4-advanced-reference/tempo.shorthand' }, + { text: 'Weekday Engine', link: '/doc/4-advanced-reference/tempo.weekday' }, + { text: 'Debugging', link: '/doc/4-advanced-reference/tempo.debugging' } ] }, { text: 'Architecture & Internals', items: [ - { text: 'Core Architecture', link: '/doc/architecture' }, - { text: 'Soft Freeze Strategy', link: '/doc/soft_freeze_strategy' }, - { text: 'Lazy Evaluation', link: '/doc/lazy-evaluation-pattern' }, - { text: 'Performance Benchmarks', link: '/doc/tempo.benchmarks' } + { text: 'Core Architecture', link: '/doc/5-architecture-and-internals/architecture' }, + { text: 'Soft Freeze Strategy', link: '/doc/5-architecture-and-internals/soft_freeze_strategy' }, + { text: 'Lazy Evaluation', link: '/doc/5-architecture-and-internals/lazy-evaluation-pattern' }, + { text: 'Performance Benchmarks', link: '/doc/5-architecture-and-internals/tempo.benchmarks' } ] }, { text: 'Utility Library', items: [ - { text: 'Library Overview', link: '/doc/tempo.library' }, - { text: 'Enumerators', link: '/doc/tempo.enumerators' }, - { text: 'Serializers', link: '/doc/tempo.serializers' }, - { text: 'Decorators', link: '/doc/tempo.decorators' }, - { text: 'Advanced Promises (Pledge)', link: '/doc/tempo.pledge' }, + { text: 'Library Overview', link: '/doc/6-utility-library/tempo.library' }, + { text: 'Enumerators', link: '/doc/6-utility-library/tempo.enumerators' }, + { text: 'Serializers', link: '/doc/6-utility-library/tempo.serializers' }, + { text: 'Decorators', link: '/doc/6-utility-library/tempo.decorators' }, + { text: 'Advanced Promises (Pledge)', link: '/doc/6-utility-library/tempo.pledge' }, ] }, { text: 'Ecosystem', items: [ { text: 'Contribution Guide', link: '/CONTRIBUTING' }, - { text: 'Comparison', link: '/doc/comparison' }, - { text: 'Extending Temporal', link: '/doc/extending-temporal' }, - { text: 'Project Vision', link: '/doc/vision' } + { text: 'Comparison', link: '/doc/7-ecosystem/comparison' }, + { text: 'Extending Temporal', link: '/doc/7-ecosystem/extending-temporal' }, + { text: 'Project Vision', link: '/doc/7-ecosystem/vision' } ] }, { text: 'Project & Support', items: [ - { text: 'Migration Guide', link: '/doc/migration-guide' }, - { text: 'Release Notes', link: '/doc/releases/' }, - { text: 'Professional Services', link: '/doc/commercial' } + { text: 'Migration Guide', link: '/doc/8-project-and-support/migration-guide' }, + { text: 'Release Notes', link: '/doc/8-project-and-support/releases/' }, + { text: 'Professional Services', link: '/doc/8-project-and-support/commercial' } ] } ], diff --git a/packages/tempo/CHANGELOG.md b/packages/tempo/CHANGELOG.md index 680b8207..f12a5e9c 100644 --- a/packages/tempo/CHANGELOG.md +++ b/packages/tempo/CHANGELOG.md @@ -6,6 +6,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). +## [3.8.0] - 2026-07-11 + +### Added +- **Namespace Architecture (`defineNamespace`)**: Officially launched the new Namespace Plugin architecture. This provides a clean mechanism to attach grouped API surfaces (like `t.finance.*`) onto the core Tempo instance without polluting the global scope or the natural-language parsing engine. +- **Strict Plugin Discrimination**: Core registration utilities (`definePlugin`, `defineTerm`, `defineModule`, `defineNamespace`) now strictly inject a discriminator `type` key (`'plugin' | 'term' | 'module' | 'namespace'`). This ensures internal registries and debugging tools can accurately categorize plugins without relying on loose structural sniffing. +- **Finance Sandbox (`@magmacomputing/tempo-plugin-finance`)**: Introduced the community `finance` package as the official reference implementation for Namespace plugins, complete with best-practice dual-build (ESM/DTS) architectures using `tsup`. + +### Changed +- **Documentation Architecture**: Completely overhauled the documentation repository to utilize a strictly-numbered directory structure (`1-getting-started`, `2-core-concepts`, etc.) that mirrors the VitePress UI 1:1, drastically reducing maintenance overhead and eliminating orphaned files. + ## [3.7.1] - 2026-07-08 ### Fixed diff --git a/packages/tempo/CONTRIBUTING.md b/packages/tempo/CONTRIBUTING.md index a3348b68..438ef794 100644 --- a/packages/tempo/CONTRIBUTING.md +++ b/packages/tempo/CONTRIBUTING.md @@ -5,9 +5,9 @@ Thank you for your interest in contributing to Tempo! This project is a professi ## πŸ—οΈ Project Architecture Tempo uses several advanced JavaScript patterns that contributors should be familiar with: -- **[Proxy-Delegators](./doc/lazy-evaluation-pattern.md)**: For $O(1)$ lazy evaluation of instance properties. -- **[Soft Freeze](./doc/soft_freeze_strategy.md)**: For secure but extensible global registries. -- **[Diagnostic Engine](./doc/architecture.md)**: For decoupled, symbol-based diagnostic logging. +- **[Proxy-Delegators](./doc/5-architecture-and-internals/lazy-evaluation-pattern.md)**: For $O(1)$ lazy evaluation of instance properties. +- **[Soft Freeze](./doc/5-architecture-and-internals/soft_freeze_strategy.md)**: For secure but extensible global registries. +- **[Diagnostic Engine](./doc/5-architecture-and-internals/architecture.md)**: For decoupled, symbol-based diagnostic logging. ## πŸ› οΈ Local Development diff --git a/packages/tempo/doc/installation.md b/packages/tempo/doc/1-getting-started/installation.md similarity index 100% rename from packages/tempo/doc/installation.md rename to packages/tempo/doc/1-getting-started/installation.md diff --git a/packages/tempo/doc/tempo.cookbook.md b/packages/tempo/doc/1-getting-started/tempo.cookbook.md similarity index 95% rename from packages/tempo/doc/tempo.cookbook.md rename to packages/tempo/doc/1-getting-started/tempo.cookbook.md index 559a5573..4e2117cb 100644 --- a/packages/tempo/doc/tempo.cookbook.md +++ b/packages/tempo/doc/1-getting-started/tempo.cookbook.md @@ -52,7 +52,7 @@ Tempo.init({ silent: true }); ``` -πŸ‘‰ **Learn More:** [Configuration Guide](./tempo.config.md) +πŸ‘‰ **Learn More:** [Configuration Guide](../2-core-concepts/tempo.config.md) --- @@ -64,7 +64,7 @@ Tempo intelligently resolves ambiguous dates like `04012026` based on your timez const us = new Tempo('04012026', { timeZone: 'America/New_York' }); console.log(us.format('{mon} {dd}')); // "April 01" ``` -πŸ‘‰ **Learn More:** [Ambiguity Resolution Guide](./tempo.parse.md) +πŸ‘‰ **Learn More:** [Ambiguity Resolution Guide](../2-core-concepts/tempo.parse.md) ### Handling Relative Strings Tempo natively understands human-readable offsets. @@ -248,7 +248,7 @@ t.format('{mon:locale}'); // "mai" (Native French Intl output) t.format('{mon:locale:upper} {dd}'); // "MAI 15" (Native French Intl output) ``` -πŸ‘‰ **Learn More:** See the [Smart Formatting Guide](./tempo.format.md) for the complete list of available modifiers. +πŸ‘‰ **Learn More:** See the [Smart Formatting Guide](../2-core-concepts/tempo.format.md) for the complete list of available modifiers. ::: tip **Tired of typing `:locale`?** @@ -295,9 +295,9 @@ t.format('{wkd-fr}'); // "lundi" ``` πŸ‘‰ **Learn More:** -- [Smart Formatting Guide](./tempo.format.md) -- [The Role of Locale](./tempo.locale.md) -- [Smart Parsing Guide](./tempo.parse.md) +- [Smart Formatting Guide](../2-core-concepts/tempo.format.md) +- [The Role of Locale](../4-advanced-reference/tempo.locale.md) +- [Smart Parsing Guide](../2-core-concepts/tempo.parse.md) --- diff --git a/packages/tempo/doc/tempo.config.md b/packages/tempo/doc/2-core-concepts/tempo.config.md similarity index 98% rename from packages/tempo/doc/tempo.config.md rename to packages/tempo/doc/2-core-concepts/tempo.config.md index 551f3beb..243beb08 100644 --- a/packages/tempo/doc/tempo.config.md +++ b/packages/tempo/doc/2-core-concepts/tempo.config.md @@ -74,7 +74,7 @@ Using `tempo.config.ts` is the modern standard, but it introduces specific archi ::: tip **Looking to configure Internationalization?** -Tempo offers deep integration with native `Intl` APIs for both parsing and formatting foreign languages out-of-the-box. See [The Role of Locale](./tempo.locale.md) for a general guide, and the [Internationalized Parsing](./tempo.parse.md#internationalized-parsing-locales) and [Format Modifiers & Localization](./tempo.cookbook.md#format-modifiers--localization) guides for configuration details. +Tempo offers deep integration with native `Intl` APIs for both parsing and formatting foreign languages out-of-the-box. See [The Role of Locale](../4-advanced-reference/tempo.locale.md) for a general guide, and the [Internationalized Parsing](./tempo.parse.md#internationalized-parsing-locales) and [Format Modifiers & Localization](../1-getting-started/tempo.cookbook.md#format-modifiers--localization) guides for configuration details. ::: --- diff --git a/packages/tempo/doc/tempo.duration.md b/packages/tempo/doc/2-core-concepts/tempo.duration.md similarity index 100% rename from packages/tempo/doc/tempo.duration.md rename to packages/tempo/doc/2-core-concepts/tempo.duration.md diff --git a/packages/tempo/doc/tempo.format.md b/packages/tempo/doc/2-core-concepts/tempo.format.md similarity index 100% rename from packages/tempo/doc/tempo.format.md rename to packages/tempo/doc/2-core-concepts/tempo.format.md diff --git a/packages/tempo/doc/tempo.layout.md b/packages/tempo/doc/2-core-concepts/tempo.layout.md similarity index 100% rename from packages/tempo/doc/tempo.layout.md rename to packages/tempo/doc/2-core-concepts/tempo.layout.md diff --git a/packages/tempo/doc/tempo.parse.md b/packages/tempo/doc/2-core-concepts/tempo.parse.md similarity index 100% rename from packages/tempo/doc/tempo.parse.md rename to packages/tempo/doc/2-core-concepts/tempo.parse.md diff --git a/packages/tempo/doc/tempo.registry.md b/packages/tempo/doc/2-core-concepts/tempo.registry.md similarity index 100% rename from packages/tempo/doc/tempo.registry.md rename to packages/tempo/doc/2-core-concepts/tempo.registry.md diff --git a/packages/tempo/doc/ecosystem.md b/packages/tempo/doc/3-extending-tempo/ecosystem.md similarity index 100% rename from packages/tempo/doc/ecosystem.md rename to packages/tempo/doc/3-extending-tempo/ecosystem.md diff --git a/packages/tempo/doc/tempo.extension.md b/packages/tempo/doc/3-extending-tempo/tempo.extension.md similarity index 93% rename from packages/tempo/doc/tempo.extension.md rename to packages/tempo/doc/3-extending-tempo/tempo.extension.md index 108af94f..7d6ad407 100644 --- a/packages/tempo/doc/tempo.extension.md +++ b/packages/tempo/doc/3-extending-tempo/tempo.extension.md @@ -4,6 +4,10 @@ While [Term Plugins](./tempo.term.md) are excellent for providing static, memoiz This guide will teach you the "Tempo-way" of authoring a custom plugin by building a classic, real-world example: **The Business Days Plugin**. +> [!TIP] Adding a group of properties? +> If you are building a plugin that adds multiple related properties or methods (like `.finance.taxYear` and `.finance.fiscalQuarter`), you should use the `defineNamespace` factory instead! It automatically lazy-loads your methods and prevents prototype pollution. +> πŸ‘‰ **[Read the Namespace Guide](./tempo.namespace.md)** + ## The Goal We want to add an `.addBusinessDays()` method to the Tempo instance that adds or subtracts a specific number of working days (defaulting to 1), skipping weekends automatically. diff --git a/packages/tempo/doc/tempo.modularity.md b/packages/tempo/doc/3-extending-tempo/tempo.modularity.md similarity index 100% rename from packages/tempo/doc/tempo.modularity.md rename to packages/tempo/doc/3-extending-tempo/tempo.modularity.md diff --git a/packages/tempo/doc/3-extending-tempo/tempo.namespace.md b/packages/tempo/doc/3-extending-tempo/tempo.namespace.md new file mode 100644 index 00000000..5b1c0df6 --- /dev/null +++ b/packages/tempo/doc/3-extending-tempo/tempo.namespace.md @@ -0,0 +1,153 @@ +# Creating a Namespace Plugin + +When building plugins that provide multiple related features, adding them directly to the `Tempo` prototype can clutter the root interface and increase the risk of naming collisions. + +To solve this, Tempo provides the `defineNamespace` factory. This allows you to mount an entire suite of related tools under a single property (e.g., `Tempo().finance.taxYear`). + +## The Lazy-Evaluation Advantage + +Namespaces in Tempo are **Lazy-Evaluated Proxies**. +When a user extends Tempo with your namespace plugin, it adds exactly **0 CPU cycles** of overhead to instantiation. Your methods and resolvers are only initialized and executed if the user actually accesses the namespace property on that specific date instance! + +## 1. The `defineNamespace` Factory + +Let's build a `finance` plugin. We want to add a `.finance` namespace that provides tools like `fiscalQuarter` and `isFiscalYearStart()`. + +```typescript +// finance.ts +import { defineNamespace } from '@magmacomputing/tempo/plugin-api'; +import type { Tempo } from '@magmacomputing/tempo/core'; + +export const FinancePlugin = defineNamespace({ + // 1. Define the property name where this namespace will live on the Tempo instance + name: 'finance', + + // 2. Define the lazy-resolvers + resolvers: { + // Static properties return values directly + fiscalQuarter: (tempo: Tempo): number => { + const month = tempo.mm; + return Math.floor((month - 1) / 3) + 1; + }, + + taxYear: (tempo: Tempo): number => { + return tempo.yy; + }, + + // Methods return callable functions + isFiscalYearStart: (tempo: Tempo): ((startMonth?: number) => boolean) => { + const currentMonth = tempo.mm; + const currentDay = tempo.dd; + return (startMonth: number = 1) => currentMonth === startMonth && currentDay === 1; + } + } +}); +``` + +Notice how every resolver receives the current `Tempo` instance as its first argument? This gives you full access to the date's underlying data (`tempo.mm`, `tempo.add()`, etc.) without ever needing to worry about `this` binding! + +### The Memoized Closure Pattern (Methods with Parameters) + +One of the most powerful features of the `defineNamespace` architecture is that your resolvers are **memoized closures**. Because a resolver is only evaluated the *very first time* the property is accessed on that specific `Tempo` instance, you can use the outer block to execute expensive setup logic or stash variables. + +Let's zoom in on the `isFiscalYearStart` method we defined above: + +```typescript +isFiscalYearStart: (tempo: Tempo) => { + // 1. SETUP BLOCK (Runs exactly once per Tempo instance) + // You can stash variables, run complex mathematical lookups, or build state here! + const currentMonth = tempo.mm; + const currentDay = tempo.dd; + + // 2. INNER CLOSURE (Runs every time the consumer calls the method) + // The consumer can pass their company's fiscal start month (e.g., 7 for July). Defaults to 1. + return (startMonth: number = 1): boolean => { + return currentMonth === startMonth && currentDay === 1; + }; +} +``` + +When a consumer types `t.finance.isFiscalYearStart(7)`, they are executing that returned inner function to check if the date is July 1st. This pattern allows you to write incredibly performant plugins with zero repeated overhead! + +## 2. TypeScript Module Augmentation + +Just like standard plugins, you must inform TypeScript about your new namespace so developers get full IDE autocomplete: + +```typescript +// finance.ts +import { defineNamespace } from '@magmacomputing/tempo/plugin-api'; + +// ... (FinancePlugin implementation) ... + +declare module '@magmacomputing/tempo/core' { + interface Tempo { + finance: { + fiscalQuarter: number; + taxYear: number; + isFiscalYearStart: (startMonth?: number) => boolean; + } + } +} +``` + +## 3. Consuming the Namespace + +Developers can now import your plugin, register it once, and enjoy your clean, isolated API! + +```typescript +import { Tempo } from '@magmacomputing/tempo/core'; +import { FinancePlugin } from './finance.js'; + +// Register the namespace +Tempo.extend(FinancePlugin); + +const t = new Tempo('2024-07-01'); + +// Access the static property (Evaluated instantly and memoized!) +console.log(t.finance.fiscalQuarter); // 3 + +// Access the callable method (Checking if it's the start of the Australian fiscal year) +console.log(t.finance.isFiscalYearStart(7)); // true +``` + +--- + +## Advanced: Symbol Namespaces + +If you are building a highly-specialized internal plugin and want to mathematically guarantee zero naming collisions, you can use a `Symbol` as your namespace key! + +```typescript +// secret.ts +import { defineNamespace } from '@magmacomputing/tempo/plugin-api'; +import type { Tempo } from '@magmacomputing/tempo/core'; + +export const InternalTools = Symbol('internal'); + +export const SecretPlugin = defineNamespace({ + name: InternalTools, + resolvers: { + audit: (tempo: Tempo) => () => console.log(`Audited: ${tempo.format('{yyyy}-{mm}-{dd}')}`) + } +}); +``` + +```typescript +// Consumer's code +import { Tempo } from '@magmacomputing/tempo/core'; +// The consumer must import BOTH the Plugin (to extend Tempo) and the Symbol (to access the namespace) +import { SecretPlugin, InternalTools } from './secret.js'; + +Tempo.extend(SecretPlugin); + +const t = new Tempo(); +t[InternalTools].audit(); +``` + +> [!WARNING] Symbol Tagging Requirement +> Tempo enforces a strict tagging policy for security and discovery. If you pass an undescribed Symbol (e.g., `Symbol()`) as a namespace, the factory will throw a `TempoError`. You must provide a description (e.g., `Symbol('myPlugin')`). + +### Symbol Discovery in the Registry + +When a developer calls `Tempo.versions` to view the loaded plugins, Tempo intelligently translates your Symbol namespaces into standard representations: +- Local Symbols (e.g., `Symbol('internal')`) are reported as `@internalNamespace`. +- Global Symbols (e.g., `Symbol.for('system')`) are reported as `@@systemNamespace`. diff --git a/packages/tempo/doc/tempo.plugin.md b/packages/tempo/doc/3-extending-tempo/tempo.plugin.md similarity index 82% rename from packages/tempo/doc/tempo.plugin.md rename to packages/tempo/doc/3-extending-tempo/tempo.plugin.md index bc378f46..24da8343 100644 --- a/packages/tempo/doc/tempo.plugin.md +++ b/packages/tempo/doc/3-extending-tempo/tempo.plugin.md @@ -6,6 +6,7 @@ In the Tempo ecosystem, a **Plugin** is the universal overarching term for any f 1. **`definePlugin`**: The standard factory for general-purpose features (e.g., adding prototype instance methods, static tools, or altering configuration). 2. **`defineTerm`**: A specialized factory exclusively for defining temporal vocabulary constraints (a "Term" is technically just a highly-opinionated "Plugin" focused on date ranges and schedules). +3. **`defineNamespace`**: A factory for creating lazily-evaluated property landing pads (e.g., `Tempo().finance.taxYear`). To manually register a plugin, use the static `extend` method. This is typically used for "opt-in" features or when you need to provide specific configuration to a plugin factory. @@ -106,7 +107,10 @@ The core methods of Tempo (like `add`, `set`, `format`) are **protected**. The ` When adding instance methods that "modify" the date, always follow the Tempo pattern of returning a **new instance**. Do not mutate `this`. Rely on the core methods (like `this.add()`) inside your plugin, as they automatically guarantee a fresh, cloned instance. ### 3. Namespace Respect -If your plugin provides many related methods, consider grouping them under a single namespace property on the instance (e.g., `tempo.holiday.isPublic()` rather than `tempo.isPublicHoliday()`). This keeps the root `Tempo` interface clean and minimizes the risk of naming collisions with other plugins or future core updates. +If your plugin provides many related methods, consider grouping them under a single namespace property on the instance (e.g., `tempo.holiday.isPublic()` rather than `tempo.isPublicHoliday()`). This keeps the root `Tempo` interface clean and minimizes the risk of naming collisions. + +Instead of manually building these namespaces on the prototype, Tempo provides the `defineNamespace` factory to automate lazy-evaluation. +πŸ‘‰ **[Read the Namespace Guide](./tempo.namespace.md)** to learn more. ### 4. Error Handling & The Diagnostic Engine When building plugins that perform complex parsing or logic, follow Tempo's **"Fail-fast by Default"** principle. @@ -129,69 +133,21 @@ if (errorCondition) { This pattern ensures that Tempo remains robust in production environments while providing strict validation during development. -## Advanced Pattern: Stateful Classes & Callable Proxies +## Alternative: Standalone Functions (`tempo-fns`) -For complex plugins (like the **Ticker**) that need to maintain internal state across multiple calls or provide both a class interface and a "shortcut" function, use the **Stateful Class + Proxy** pattern. +The JavaScript ecosystem is divided between two architectural preferences: **Chained Fluent APIs** (like Tempo Plugins) and **Pure Standalone Functions** (for aggressive tree-shaking). -### 1. Define a Dedicated Types Namespace -Avoid polluting the global `Tempo` namespace. Instead, create a dedicated `Types` namespace for your plugin's internal and public signatures. This prevents "Used before declaration" errors and name-shading. +To support teams that mandate strict 0kb bundle-impacts and functional programming paradigms, Magma Computing provides the **`@magmacomputing/tempo-fns`** library. ```typescript -export namespace MyPluginTypes { - export type Options = { ... } - export interface Descriptor extends AsyncGenerator { - doSomething(): void; - } - // The final public interface (callable as a function) - export interface Instance extends Descriptor { - (): void - } -} -``` - -### 2. Implement the Logic in a Class -Use a standard class to manage your state. This keeps your logic decoupled from the Proxy and the core engine. - -```typescript -class MyPluginInstance implements MyPluginTypes.Descriptor { - #self!: MyPluginTypes.Instance; - - // The developer writes this bootstrap method to save a reference to the Proxy. - // This is required so the class can return the Proxy (for method chaining) - // instead of returning 'this' (which would be the un-proxied, non-callable instance). - bootstrap(proxy: MyPluginTypes.Instance) { - this.#self = proxy; - return this.#self; - } - // ... implement Descriptor methods ... -} -``` - -### 3. Wrap with a Proxy in the Factory -Use a `Proxy` in your `definePlugin` factory to handle the callability trap. This allows your plugin to act as a function (the shortcut) and an object (the stateful class) simultaneously. +// The Pure, Tree-shakeable approach: +import { isLeapYear } from '@magmacomputing/tempo-fns'; +import { Tempo } from '@magmacomputing/tempo/core'; -```typescript -export const MyPlugin: TempoPlugin = definePlugin({ - name: 'MyPlugin', - install(TempoClass) { - TempoClass.myTool = function(arg1: any): MyPluginTypes.Instance { - const instance = new MyPluginInstance(arg1); - - const proxy = new Proxy((() => instance.doSomething()) as any, { - get: (_, prop) => { - // Map proxy properties to instance methods - if (prop in instance) return (instance as any)[prop].bind(instance); - return (instance as any)[prop]; - }, - apply: (target) => target() - }) as unknown as MyPluginTypes.Instance; - - return instance.bootstrap(proxy); - }; -}); +if (isLeapYear(new Tempo('2024-01-01'))) { ... } ``` ---- +When building complex logic, consider whether it belongs as a core Plugin extension, or as a standalone utility in `tempo-fns` (or a hybrid wrapper of both!). ## Distributing Your Plugin diff --git a/packages/tempo/doc/tempo.term.md b/packages/tempo/doc/3-extending-tempo/tempo.term.md similarity index 100% rename from packages/tempo/doc/tempo.term.md rename to packages/tempo/doc/3-extending-tempo/tempo.term.md diff --git a/packages/tempo/doc/sandbox-factory.md b/packages/tempo/doc/4-advanced-reference/sandbox-factory.md similarity index 100% rename from packages/tempo/doc/sandbox-factory.md rename to packages/tempo/doc/4-advanced-reference/sandbox-factory.md diff --git a/packages/tempo/doc/tempo.debugging.md b/packages/tempo/doc/4-advanced-reference/tempo.debugging.md similarity index 97% rename from packages/tempo/doc/tempo.debugging.md rename to packages/tempo/doc/4-advanced-reference/tempo.debugging.md index 5d459a99..90b81ca3 100644 --- a/packages/tempo/doc/tempo.debugging.md +++ b/packages/tempo/doc/4-advanced-reference/tempo.debugging.md @@ -14,10 +14,10 @@ Before an instance is even created, you can inspect the static state of the `Tem Returns the collection of current *global* configuration settings that act as the default for all new `Tempo` instances. This includes properties like the default `locale`, `timeZone`, and formatting layouts. ### `Tempo.default` -Returns the collection of *initial*, out-of-the-box defaults that `Tempo` ships with, unaffected by any modifications made via [Global Discovery](../README.md#global-discovery) or [`Tempo.init()`](./tempo.config.md). +Returns the collection of *initial*, out-of-the-box defaults that `Tempo` ships with, unaffected by any modifications made via [Global Discovery](../../README.md#global-discovery) or [`Tempo.init()`](../2-core-concepts/tempo.config.md). ### `Tempo.terms` -Returns an array of all currently registered Term plugins. This is useful to verify which plugins have been loaded via [`Tempo.extend()`](./tempo.plugin.md). +Returns an array of all currently registered Term plugins. This is useful to verify which plugins have been loaded via [`Tempo.extend()`](../3-extending-tempo/tempo.plugin.md). ### `Tempo.properties` Returns an array of the available static getters on the `Tempo` class. diff --git a/packages/tempo/doc/tempo.locale.md b/packages/tempo/doc/4-advanced-reference/tempo.locale.md similarity index 96% rename from packages/tempo/doc/tempo.locale.md rename to packages/tempo/doc/4-advanced-reference/tempo.locale.md index 2088c697..d6d54c36 100644 --- a/packages/tempo/doc/tempo.locale.md +++ b/packages/tempo/doc/4-advanced-reference/tempo.locale.md @@ -19,7 +19,7 @@ Tempo uses the active `locale` as a critical piece of metadata to resolve this: - It cross-references the locale's region or language against the internal `MONTH_DAY` registry to check its preferred layout. - If the locale inherently prefers `MDY` (like in the United States), Tempo dynamically swaps its parsing order to attempt `Month-Day-Year` patterns *before* it attempts `Day-Month-Year` patterns. -*For deeper details on layout configurations and ambiguous digits, see the [Ambiguity Resolution Guide](./tempo.parse.md).* +*For deeper details on layout configurations and ambiguous digits, see the [Ambiguity Resolution Guide](../2-core-concepts/tempo.parse.md).* --- @@ -44,7 +44,7 @@ const a = new Tempo('15 janvier 2024'); // Matches French const b = new Tempo('el prΓ³ximo lunes'); // Matches Spanish ``` -*For more details on setting up and optimizing international parsing, see [Internationalized Parsing](./tempo.parse.md#internationalized-parsing-locales).* +*For more details on setting up and optimizing international parsing, see [Internationalized Parsing](../2-core-concepts/tempo.parse.md#internationalized-parsing-locales).* --- @@ -67,7 +67,7 @@ console.log(t.format({ dateStyle: 'full', locale: 'de-DE' })); // "Donnerstag, 15. Februar 2024" ``` -*For more details on formatting features, see the [Format Guide](./tempo.format.md).* +*For more details on formatting features, see the [Format Guide](../2-core-concepts/tempo.format.md).* ### Global LOCALE Registry The easiest way to augment or override translations globally is via the `locales` configuration option. Translations added here will apply to *any* plugin that resolves the specified key: diff --git a/packages/tempo/doc/tempo.planner.md b/packages/tempo/doc/4-advanced-reference/tempo.planner.md similarity index 93% rename from packages/tempo/doc/tempo.planner.md rename to packages/tempo/doc/4-advanced-reference/tempo.planner.md index 1d338982..da6572b6 100644 --- a/packages/tempo/doc/tempo.planner.md +++ b/packages/tempo/doc/4-advanced-reference/tempo.planner.md @@ -8,7 +8,7 @@ These options are nested under the `planner` property when configuring Tempo. By default, Tempo searches through a variety of date layouts (like ISO 8601, European, American, etc.) to understand user input. The `layoutOrder` configuration allows you to explicitly list which layout patterns should be evaluated first, prioritizing formats that you expect most often. -This leads to performance gains and gives you deterministic control when overriding [Ambiguity Resolution](./tempo.parse.md) heuristics (e.g. forcing `mdy` over `dmy`). +This leads to performance gains and gives you deterministic control when overriding [Ambiguity Resolution](../2-core-concepts/tempo.parse.md) heuristics (e.g. forcing `mdy` over `dmy`). ```ts import { Tempo } from '@magmacomputing/tempo'; diff --git a/packages/tempo/doc/tempo.shorthand.md b/packages/tempo/doc/4-advanced-reference/tempo.shorthand.md similarity index 100% rename from packages/tempo/doc/tempo.shorthand.md rename to packages/tempo/doc/4-advanced-reference/tempo.shorthand.md diff --git a/packages/tempo/doc/tempo.ticker.md b/packages/tempo/doc/4-advanced-reference/tempo.ticker.md similarity index 100% rename from packages/tempo/doc/tempo.ticker.md rename to packages/tempo/doc/4-advanced-reference/tempo.ticker.md diff --git a/packages/tempo/doc/tempo.weekday.md b/packages/tempo/doc/4-advanced-reference/tempo.weekday.md similarity index 100% rename from packages/tempo/doc/tempo.weekday.md rename to packages/tempo/doc/4-advanced-reference/tempo.weekday.md diff --git a/packages/tempo/doc/architecture.md b/packages/tempo/doc/5-architecture-and-internals/architecture.md similarity index 100% rename from packages/tempo/doc/architecture.md rename to packages/tempo/doc/5-architecture-and-internals/architecture.md diff --git a/packages/tempo/doc/lazy-evaluation-pattern.md b/packages/tempo/doc/5-architecture-and-internals/lazy-evaluation-pattern.md similarity index 100% rename from packages/tempo/doc/lazy-evaluation-pattern.md rename to packages/tempo/doc/5-architecture-and-internals/lazy-evaluation-pattern.md diff --git a/packages/tempo/doc/soft_freeze_strategy.md b/packages/tempo/doc/5-architecture-and-internals/soft_freeze_strategy.md similarity index 100% rename from packages/tempo/doc/soft_freeze_strategy.md rename to packages/tempo/doc/5-architecture-and-internals/soft_freeze_strategy.md diff --git a/packages/tempo/doc/tempo.benchmarks.md b/packages/tempo/doc/5-architecture-and-internals/tempo.benchmarks.md similarity index 100% rename from packages/tempo/doc/tempo.benchmarks.md rename to packages/tempo/doc/5-architecture-and-internals/tempo.benchmarks.md diff --git a/packages/tempo/doc/tempo.decorators.md b/packages/tempo/doc/6-utility-library/tempo.decorators.md similarity index 100% rename from packages/tempo/doc/tempo.decorators.md rename to packages/tempo/doc/6-utility-library/tempo.decorators.md diff --git a/packages/tempo/doc/tempo.enumerators.md b/packages/tempo/doc/6-utility-library/tempo.enumerators.md similarity index 100% rename from packages/tempo/doc/tempo.enumerators.md rename to packages/tempo/doc/6-utility-library/tempo.enumerators.md diff --git a/packages/tempo/doc/tempo.library.md b/packages/tempo/doc/6-utility-library/tempo.library.md similarity index 100% rename from packages/tempo/doc/tempo.library.md rename to packages/tempo/doc/6-utility-library/tempo.library.md diff --git a/packages/tempo/doc/tempo.pledge.md b/packages/tempo/doc/6-utility-library/tempo.pledge.md similarity index 100% rename from packages/tempo/doc/tempo.pledge.md rename to packages/tempo/doc/6-utility-library/tempo.pledge.md diff --git a/packages/tempo/doc/tempo.serializers.md b/packages/tempo/doc/6-utility-library/tempo.serializers.md similarity index 100% rename from packages/tempo/doc/tempo.serializers.md rename to packages/tempo/doc/6-utility-library/tempo.serializers.md diff --git a/packages/tempo/doc/comparison.md b/packages/tempo/doc/7-ecosystem/comparison.md similarity index 98% rename from packages/tempo/doc/comparison.md rename to packages/tempo/doc/7-ecosystem/comparison.md index 91bc4a98..435714f1 100644 --- a/packages/tempo/doc/comparison.md +++ b/packages/tempo/doc/7-ecosystem/comparison.md @@ -64,4 +64,4 @@ Because Luxon is bowing out and Day.js cannot evolve architecturally without los - **Choose date-fns if:** You prefer using functional utilities to manipulate native Temporal objects, rather than a comprehensive class-based wrapper. - **Choose Tempo if:** You want a premium, Temporal-first architecture that provides **high precision**, **human-centric parsing**, and the ability to model **complex business-date logic** directly into your application. -[**Ready to start? See the Quick Start Guide β†’**](../README.md#πŸ› οΈ-quick-start) +[**Ready to start? See the Quick Start Guide β†’**](../../README.md#πŸ› οΈ-quick-start) diff --git a/packages/tempo/doc/extending-temporal.md b/packages/tempo/doc/7-ecosystem/extending-temporal.md similarity index 94% rename from packages/tempo/doc/extending-temporal.md rename to packages/tempo/doc/7-ecosystem/extending-temporal.md index e844fb60..66948fce 100644 --- a/packages/tempo/doc/extending-temporal.md +++ b/packages/tempo/doc/7-ecosystem/extending-temporal.md @@ -30,7 +30,7 @@ new Tempo('2026/01/24'); // Parses perfectly new Tempo('next Friday'); // Parses relative natural language perfectly ``` -For more information on handling natural language and complex strings, read the dedicated **[Parsing Guide](tempo.parse.md)**. +For more information on handling natural language and complex strings, read the dedicated **[Parsing Guide](../2-core-concepts/tempo.parse.md)**. ### 2. Formatting: Verbose vs. Expressive Tokens @@ -51,7 +51,7 @@ t.format('{dd} {mmm} {yyyy}'); // Output: "24 Jan 2026" t.fmt.date; // Output: "2026-01-24" ``` -For comprehensive examples of localized output and custom layouts, read the dedicated **[Formatting Guide](tempo.format.md)**. +For comprehensive examples of localized output and custom layouts, read the dedicated **[Formatting Guide](../2-core-concepts/tempo.format.md)**. ### 3. Extensibility: Domain-Specific Logic @@ -87,7 +87,7 @@ t.term.qtr; // β†’ 'Q1' (Calculates fiscal quarter) t.term.szn; // β†’ 'Winter' (Calculates meteorological season, respecting hemisphere) ``` -For more information on adding your own domain-specific logic, read the dedicated **[Terms Guide](tempo.term.md)**. +For more information on adding your own domain-specific logic, read the dedicated **[Terms Guide](../3-extending-tempo/tempo.term.md)**. ### 4. Duration Logic: Strict Math vs. Human Readable @@ -126,4 +126,4 @@ t.since('last Friday', 'days'); // β†’ "3 days ago" - `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"). -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 +For comprehensive examples of duration mathematics, intelligent balancing, and localization formatting, read the dedicated **[Duration Logic Guide](../2-core-concepts/tempo.duration.md)**. \ No newline at end of file diff --git a/packages/tempo/doc/vision.md b/packages/tempo/doc/7-ecosystem/vision.md similarity index 100% rename from packages/tempo/doc/vision.md rename to packages/tempo/doc/7-ecosystem/vision.md diff --git a/packages/tempo/doc/commercial.md b/packages/tempo/doc/8-project-and-support/commercial.md similarity index 100% rename from packages/tempo/doc/commercial.md rename to packages/tempo/doc/8-project-and-support/commercial.md diff --git a/packages/tempo/doc/migration-guide.md b/packages/tempo/doc/8-project-and-support/migration-guide.md similarity index 100% rename from packages/tempo/doc/migration-guide.md rename to packages/tempo/doc/8-project-and-support/migration-guide.md diff --git a/packages/tempo/doc/releases/index.md b/packages/tempo/doc/8-project-and-support/releases/index.md similarity index 100% rename from packages/tempo/doc/releases/index.md rename to packages/tempo/doc/8-project-and-support/releases/index.md diff --git a/packages/tempo/doc/releases/v0.x.md b/packages/tempo/doc/8-project-and-support/releases/v0.x.md similarity index 100% rename from packages/tempo/doc/releases/v0.x.md rename to packages/tempo/doc/8-project-and-support/releases/v0.x.md diff --git a/packages/tempo/doc/releases/v1.x.md b/packages/tempo/doc/8-project-and-support/releases/v1.x.md similarity index 100% rename from packages/tempo/doc/releases/v1.x.md rename to packages/tempo/doc/8-project-and-support/releases/v1.x.md diff --git a/packages/tempo/doc/releases/v2.x.md b/packages/tempo/doc/8-project-and-support/releases/v2.x.md similarity index 100% rename from packages/tempo/doc/releases/v2.x.md rename to packages/tempo/doc/8-project-and-support/releases/v2.x.md diff --git a/packages/tempo/doc/releases/v3.x.md b/packages/tempo/doc/8-project-and-support/releases/v3.x.md similarity index 95% rename from packages/tempo/doc/releases/v3.x.md rename to packages/tempo/doc/8-project-and-support/releases/v3.x.md index a72a27de..851df103 100644 --- a/packages/tempo/doc/releases/v3.x.md +++ b/packages/tempo/doc/8-project-and-support/releases/v3.x.md @@ -1,5 +1,16 @@ # πŸ“œ Version 3.x History +## [v3.8.0] - 2026-07-11 + +### ✨ What's New β€” Namespace Architecture +Introduced the `defineNamespace` plugin factory. While *Terms* (like `quarter` or `season`) teach Tempo how to parse natural language, *Namespaces* provide a clean way to attach grouped business logic directly to the Tempo instance without polluting the global scope. For example, `t.finance.isFiscalYearStart()` or `t.finance.taxYear()` keeps financial computations neatly isolated from standard date mechanics. + +### πŸ—οΈ Internal Refactoring +- **Strict Plugin Discrimination**: The plugin registry now enforces strict `'type'` discrimination (`'plugin' | 'namespace' | 'term' | 'module'`) across all plugin factories, replacing brittle object-sniffing and making introspection significantly more robust. +- **Documentation Architecture**: The documentation source code has been completely reorganized into numbered sub-directories (`1-getting-started`, `2-core-concepts`, etc.) to map 1:1 with the visual VitePress sidebar. + +--- + ## [v3.7.1] - 2026-07-09 ### πŸͺ² Bug Fix diff --git a/packages/tempo/doc/releases/v4.x.md b/packages/tempo/doc/8-project-and-support/releases/v4.x.md similarity index 100% rename from packages/tempo/doc/releases/v4.x.md rename to packages/tempo/doc/8-project-and-support/releases/v4.x.md diff --git a/packages/tempo/package.json b/packages/tempo/package.json index afe70369..ab8e3d7c 100644 --- a/packages/tempo/package.json +++ b/packages/tempo/package.json @@ -1,6 +1,6 @@ { "name": "@magmacomputing/tempo", - "version": "3.7.1", + "version": "3.8.0", "engines": { "node": ">=20.0.0" }, @@ -201,12 +201,12 @@ "repl:bare": "tsx --tsconfig ./src/tsconfig.repl.json -i --harmony-temporal", "repl:core": "cross-env TEMPO_LITE=true tsx --tsconfig ./src/tsconfig.repl.json -i --harmony-temporal --import ./bin/core.ts", "parse": "cross-env TEMPO_LITE=true tsx --tsconfig ./src/tsconfig.repl.json -i --harmony-temporal --import ./bin/parse.ts", - "build": "npm run clean && tsc -b && npm run build:bundle && npm run build:resolve", + "build": "npm run clean && node ../../node_modules/typescript-7/bin/tsc -b && npm run build:bundle && npm run build:resolve", "build:bundle": "rollup -c", "build:resolve": "tsx bin/resolve-types.ts", "build:version": "node bin/update-version.mjs", "prebuild": "npm run build:version", - "clean": "rm -rf dist && (tsc -b --clean || true)", + "clean": "rm -rf dist && (node ../../node_modules/typescript-7/bin/tsc -b --clean || true)", "prepublishOnly": "if [ $(git rev-parse --abbrev-ref HEAD) != main ]; then echo 'ERROR: Must be on main branch to publish.'; exit 1; fi && if [ -z \"$TEMPO_LICENSE_PATH\" ] || [ ! -f \"$TEMPO_LICENSE_PATH\" ]; then echo '🚨 ERROR: TEMPO_LICENSE_PATH is missing or invalid. Cannot publish Premium build.'; exit 1; fi && npm run build", "docs:api": "typedoc", "docs:dev": "npm run build && npm run docs:api && vitepress dev", diff --git a/packages/tempo/src/plugin/plugin.util.ts b/packages/tempo/src/plugin/plugin.util.ts index 734f331c..edc4ad6d 100644 --- a/packages/tempo/src/plugin/plugin.util.ts +++ b/packages/tempo/src/plugin/plugin.util.ts @@ -1,5 +1,5 @@ -import { isFunction, isString, isUndefined, isClass, isObject, isDefined } from '#library/assertion.library.js'; -import { secureRef } from '#library/proxy.library.js'; +import { isFunction, isString, isUndefined, isClass, isObject, isDefined, isSymbol } from '#library/assertion.library.js'; +import { secureRef, delegate } from '#library/proxy.library.js'; import { sym, isTempo } from '../support/support.symbol.js'; import { TempoError } from '../support/support.error.js'; @@ -92,9 +92,10 @@ export function interpret(t: any, module: string, methodOrFallback?: any, silent * ## defineModule * Used to register an internal modularization component. */ -export function defineModule>(module: T): T { - registerPlugin(module); - return module; +export function defineModule>(module: T): T & { type: 'module' } { + const result = Object.assign(module, { type: 'module' } as const); + registerPlugin(result); + return result; } /** @@ -175,9 +176,10 @@ export function defineInterpreterModule(name: string, logic: any, statics?: Reco * ## definePlugin * Used to register a plugin. */ -export function definePlugin>(plugin: T): T { - registerPlugin(plugin); - return plugin; +export function definePlugin>(plugin: T): T & { type: 'plugin' } { + const result = Object.assign(plugin, { type: 'plugin' } as const); + registerPlugin(result); + return result; } /** @@ -210,3 +212,48 @@ export function registerPlugin(plugin: any, state?: any) { return plugin; } +export type NamespaceConfig = { + name: string | symbol; + version?: string; + resolvers: Record any>; +}; + +/** + * ## defineNamespace + * Creates a lazy-loaded property namespace on the Tempo instance. + */ +export function defineNamespace(config: NamespaceConfig): Plugin & { type: 'namespace' } { + if (isSymbol(config.name) && !config.name.description) + throw new TempoError('Tempo Security: Symbol namespaces must have a description.'); + + const namespaceStr = isSymbol(config.name) + ? (Symbol.keyFor(config.name) ? `@@${Symbol.keyFor(config.name)}` : `@${config.name.description}`) + : config.name; + + const pluginName = `${namespaceStr}Namespace`; + + const plugin = definePlugin({ + name: pluginName, + version: config.version ?? TEMPO_VERSION, + install(this: TempoType, TempoClass: TempoType, options?: any) { + Object.defineProperty(TempoClass.prototype, config.name, { + get() { + const cacheKey = isSymbol(config.name) ? config.name : `_${String(config.name)}`; + if (!this[cacheKey]) { + const target = Object.create(null); + this[cacheKey] = delegate(target, (key) => { + const resolver = config.resolvers[key as keyof typeof config.resolvers]; + if (resolver) return resolver(this); + return undefined; + }, true); + } + return this[cacheKey]; + }, + configurable: true, + enumerable: false + }); + } + }); + + return Object.assign(plugin, { type: 'namespace' } as const) as unknown as Plugin & { type: 'namespace' }; +} diff --git a/packages/tempo/src/plugin/term/term.util.ts b/packages/tempo/src/plugin/term/term.util.ts index e71a23ce..4d7cbac5 100644 --- a/packages/tempo/src/plugin/term/term.util.ts +++ b/packages/tempo/src/plugin/term/term.util.ts @@ -17,10 +17,11 @@ import { TEMPO_VERSION } from '../../tempo.version.js'; * ## defineTerm * Helper to register a Term plugin. */ -export const defineTerm = (term: T): T => { +export const defineTerm = (term: T): T & { type: 'term' } => { if (!term.version) term.version = TEMPO_VERSION; - registerTerm(term); - return deepFreeze(term) as T; + const result = Object.assign(term, { type: 'term' } as const); + registerTerm(result); + return deepFreeze(result) as T & { type: 'term' }; } /** diff --git a/packages/tempo/src/tempo.class.ts b/packages/tempo/src/tempo.class.ts index 5952badd..57e516a5 100644 --- a/packages/tempo/src/tempo.class.ts +++ b/packages/tempo/src/tempo.class.ts @@ -515,12 +515,13 @@ export class Tempo { */ static extend(...args: any[]): typeof Tempo; static extend(...args: any[]): typeof Tempo { - let options = (args.length > 1 && isObject(args[args.length - 1]) && !Array.isArray(args[args.length - 1]) && !isFunction(args[args.length - 1]) && !isDefined(args[args.length - 1].key)) ? args.pop() : undefined; - if (!options && args.length === 1 && isObject(args[0]) && !Array.isArray(args[0]) && !isFunction(args[0]) && !isString((args[0] as any).name) && !isDefined((args[0] as any).key)) { - const isDiscovery = Object.keys(args[0]).some(k => DISCOVERY.has(k as any)); - if (!isDiscovery) - options = args.pop(); - } + const isOptionsArg = (arg: any) => + isObject(arg) && + !isString(arg.name) && + !isDefined(arg.key) && + !Object.keys(arg).some(k => DISCOVERY.has(k as any)); + + let options = (args.length > 0 && isOptionsArg(args[args.length - 1])) ? args.pop() : undefined; const licenseKey = options?.license || (args.length === 1 && isObject(args[0]) ? args[0].license : undefined); if (licenseKey) { const state = this[$Internal](); @@ -556,9 +557,10 @@ export class Tempo { } } } - else if (isObject(item) && isString((item as any).name) && isFunction((item as any).install)) { + else if (isObject(item) && ((item as any).type === 'plugin' || (item as any).type === 'namespace' || (item as any).type === 'module' || (isString((item as any).name) && isFunction((item as any).install)))) { // Plugin object form { name, install } const name = (item as any).name; + const type = (item as any).type; const state = this[$Internal](); const rt = getRuntime(); const installed = state.installed ?? rt.installed; // ScopedSet for sandboxes, global Set for Tempo @@ -570,14 +572,19 @@ export class Tempo { registerPlugin(item, state); if ((item as any).version) { - const suffix = name.endsWith('Plugin') || name.endsWith('Module') ? '' : 'Plugin'; - Tempo.#versions[`${name}${suffix}`] = (item as any).version; + let suffix = ''; + if (type === 'plugin') suffix = 'Plugin'; + else if (type === 'namespace') suffix = 'Namespace'; + else if (type === 'module') suffix = 'Module'; + else suffix = 'Plugin'; + + Tempo.#versions[`${name}${name.endsWith(suffix) ? '' : suffix}`] = (item as any).version; } (item as TempoPlugin).install.call(this as any, this); } else if (isObject(item)) { // 1. handle TermPlugin - if (isString((item as any).key) && isFunction((item as any).define)) { + if (item.type === 'term' || (isString(item.key) && isFunction(item.define))) { const config = item as TermPlugin; const state = this[$Internal](); @@ -606,7 +613,7 @@ export class Tempo { Tempo.#termMap.set(config.key, config); if (config.scope) Tempo.#termMap.set(config.scope, config); - + if (config.version) { const name = config.scope || config.key; Tempo.#versions[`${name}Term`] = config.version; diff --git a/packages/tempo/src/tempo.version.ts b/packages/tempo/src/tempo.version.ts index 4c7a6729..fc0061ba 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.7.1'; +export const TEMPO_VERSION = '3.8.0'; From 1501d327e2494f476b686b172c5e163716b7318d Mon Sep 17 00:00:00 2001 From: Michael McRae Date: Sat, 11 Jul 2026 13:28:01 +1000 Subject: [PATCH 04/18] PR 1st review --- packages/library/src/common/pledge.class.ts | 11 +++++- packages/tempo-fns/doc/README.md | 2 +- .../test/isSameFiscalQuarter.test.ts | 4 +-- .../tempo/doc/2-core-concepts/tempo.format.md | 2 +- .../doc/2-core-concepts/tempo.registry.md | 9 ++++- .../doc/3-extending-tempo/tempo.namespace.md | 36 +++++++++++++++++++ .../doc/3-extending-tempo/tempo.plugin.md | 4 +-- .../tempo/doc/3-extending-tempo/tempo.term.md | 1 + .../4-advanced-reference/sandbox-factory.md | 2 +- .../architecture.md | 4 +-- .../lazy-evaluation-pattern.md | 8 +++-- .../soft_freeze_strategy.md | 6 ++-- .../doc/6-utility-library/tempo.decorators.md | 2 +- .../doc/6-utility-library/tempo.library.md | 4 +-- .../doc/6-utility-library/tempo.pledge.md | 2 +- .../6-utility-library/tempo.serializers.md | 2 +- .../doc/7-ecosystem/extending-temporal.md | 16 +++++---- packages/tempo/doc/7-ecosystem/vision.md | 4 +-- .../8-project-and-support/releases/v2.x.md | 2 +- .../8-project-and-support/releases/v4.x.md | 6 ++-- packages/tempo/src/plugin/plugin.util.ts | 8 ++++- packages/tempo/src/plugin/term/term.util.ts | 7 ++-- packages/tempo/src/tempo.class.ts | 10 ++++-- .../tempo/test/core/discovery-extend.test.ts | 6 +++- 24 files changed, 116 insertions(+), 42 deletions(-) diff --git a/packages/library/src/common/pledge.class.ts b/packages/library/src/common/pledge.class.ts index d475e3b7..3bae9f8f 100644 --- a/packages/library/src/common/pledge.class.ts +++ b/packages/library/src/common/pledge.class.ts @@ -68,7 +68,16 @@ export class Pledge { const opts = isObject(arg) ? arg : { tag: arg as string }; const config = { ..._static, ...ifDefined({ tag: opts.tag, debug: opts.debug, catch: opts.catch, silent: opts.silent }) }; - this.#pledge = Promise.withResolvers(); + if (Promise.withResolvers) { + this.#pledge = Promise.withResolvers(); + } else { + let res: any, rej: any; + const promise = new Promise((resolve, reject) => { + res = resolve; + rej = reject; + }); + this.#pledge = { promise, resolve: res, reject: rej }; + } this.#status = markConfig({ state: _STATE.Pending, ...config }); const onResolve = asArray(_static.onResolve).concat(asArray(opts.onResolve)); diff --git a/packages/tempo-fns/doc/README.md b/packages/tempo-fns/doc/README.md index 3c7c11d1..7e740773 100644 --- a/packages/tempo-fns/doc/README.md +++ b/packages/tempo-fns/doc/README.md @@ -42,6 +42,6 @@ If you aren't using a bundler (like Vite, Webpack, or Rollup), we provide a pre- ``` diff --git a/packages/tempo-fns/test/isSameFiscalQuarter.test.ts b/packages/tempo-fns/test/isSameFiscalQuarter.test.ts index b0b85057..8eaaf00f 100644 --- a/packages/tempo-fns/test/isSameFiscalQuarter.test.ts +++ b/packages/tempo-fns/test/isSameFiscalQuarter.test.ts @@ -14,8 +14,8 @@ describe('isSameFiscalQuarter', () => { }); it('should return false for dates in different fiscal quarters', () => { - const t1 = new Tempo('2026-03-01'); // Q1 - const t2 = new Tempo('2026-04-01'); // Q2 + const t1 = new Tempo('2026-03-01'); + const t2 = new Tempo('2026-04-01'); expect(isSameFiscalQuarter(t1, t2)).toBe(false); }); diff --git a/packages/tempo/doc/2-core-concepts/tempo.format.md b/packages/tempo/doc/2-core-concepts/tempo.format.md index fd0f25e5..2f1efdc0 100644 --- a/packages/tempo/doc/2-core-concepts/tempo.format.md +++ b/packages/tempo/doc/2-core-concepts/tempo.format.md @@ -191,7 +191,7 @@ t.format('{wkd:lower} afternoon'); While Tempo's template tokens (`{dd}`, `{mon}`, etc.) combined with the `:locale` modifier are incredibly powerful for structured formats, there are times when you want the full power of the native `Intl.DateTimeFormat` API for complete, culturally-specific sentence formatting (like Arabic numerals or full-length descriptive dates). -Because Tempo's philosophy is to "humanize" the rigid `Temporal` API, you can pass an `Intl.DateTimeFormatOptions` object *directly* into the `.format()` method. Tempo will automatically align the internal timezone and calendar constraints for you, bypassing the strict `RangeError` and `TypeError` exceptions that the native spec normally throws. +Because Tempo's philosophy is to "humanize" the rigid `Temporal` API, you can pass an `Intl.DateTimeFormatOptions` object *directly* into the `.format()` method. While `Intl.DateTimeFormat` still strictly validates your locales and option values, Tempo catches Temporal mismatch failures for you. It automatically retries formatting using `zdt.epochMilliseconds`, safely injects the necessary `timeZone` and `calendar` metadata, and swallows any `withTimeZone` or `withCalendar` failures. **Tempo vs Temporal (Side-by-Side):** diff --git a/packages/tempo/doc/2-core-concepts/tempo.registry.md b/packages/tempo/doc/2-core-concepts/tempo.registry.md index 47dea5d3..ede55c36 100644 --- a/packages/tempo/doc/2-core-concepts/tempo.registry.md +++ b/packages/tempo/doc/2-core-concepts/tempo.registry.md @@ -35,7 +35,7 @@ Tempo.extend({ ## πŸ“… TIMEZONE Registry -Tempo includes a built-in registry of common timezone abbreviations. This allows users to pass simple strings like `AEST` instead of full IANA time zone identifiers (`Australia/Sydney`). +Tempo includes a built-in registry of common timezone abbreviations. **These aliases map to DST-aware regional IANA zones, not fixed UTC offsets.** For example, `gmt` maps to `Europe/London` and `est` maps to `America/New_York`, which means they will automatically adjust to British Summer Time (BST) or Eastern Daylight Time (EDT) seasonally. If your application strictly requires a literal fixed offset (e.g., standard EST year-round), you should use an appropriate fixed-offset zone instead of these regional aliases. | Alias | IANA Identifier | | :--- | :--- | @@ -74,3 +74,10 @@ Tempo leverages several other internal data dictionaries to parse and format dat - **Numbers**: Word-to-number dictionaries (e.g., `"one" -> 1`). *(Note: The overarching architectural goal for v4.0.0 is to consolidate all remaining dictionaries fully into the `registry` namespace to separate data from behavior.)* + +### Registry Merge Contracts + +Tempo applies different merge behaviors depending on how you inject registry data: + +- **`Tempo.init()` & Plugin Discovery**: These methods apply an **additive-only** merge strategy. They will deeply merge new keys into the registries, but they will **preserve existing root keys** (they do not override built-in core definitions or previously established configurations). +- **`Tempo.extend()`**: This is an **explicit override**. When you pass an options object to `Tempo.extend()`, it takes strict precedence. It will deeply merge *and* safely overwrite existing registry keys, making it the proper tool for forcefully changing standard behaviors. diff --git a/packages/tempo/doc/3-extending-tempo/tempo.namespace.md b/packages/tempo/doc/3-extending-tempo/tempo.namespace.md index 5b1c0df6..8417a231 100644 --- a/packages/tempo/doc/3-extending-tempo/tempo.namespace.md +++ b/packages/tempo/doc/3-extending-tempo/tempo.namespace.md @@ -112,6 +112,42 @@ console.log(t.finance.isFiscalYearStart(7)); // true --- +## The Dual Architecture (OOP vs FP) + +When building a Namespace plugin, you'll naturally face a choice between **Object-Oriented Programming (OOP)** and **Functional Programming (FP)**. + +* **The Namespace (OOP)**: `t.finance.isFiscalYearStart()` + * *Pros:* Incredible Developer Experience (DX). You get instant IDE autocomplete, and the `Tempo` object automatically carries your timezone and locale context. + * *Cons:* Extending Tempo means the entire namespace is attached to the prototype. It is not fully tree-shakeable. +* **The Functional Approach (FP)**: `isFiscalYearStart(t)` + * *Pros:* 100% tree-shakeable. Developers only import exactly the functions they need. Zero prototype pollution. + * *Cons:* Less discoverable. Developers must pass the `Tempo` object as the first argument manually. + +### The Best of Both Worlds +A world-class plugin doesn't force developers to chooseβ€”it provides both! You can write all your logic as **pure functions first**, and then use `defineNamespace` to wrap those functions for OOP users. + +```typescript +// 1. The Pure Functions (The FP layer) +export function isFiscalYearStart(t: Tempo, startMonth: number = 1): boolean { + return t.mm === startMonth && t.dd === 1; +} +export function nextTaxYear(t: Tempo): Tempo { ... } + +// 2. The Namespace Wrapper (The OOP layer) +export const FinancePlugin = defineNamespace({ + name: 'finance', + resolvers: { + // We simply wrap our pure functions and inject the 'tempo' context! + isFiscalYearStart: (tempo: Tempo) => (startMonth?: number) => isFiscalYearStart(tempo, startMonth), + nextTaxYear: (tempo: Tempo) => () => nextTaxYear(tempo) + } +}); +``` + +Because of this interleaving, developers can choose their preferred architecture based on their project needs (e.g. rapid prototyping with `t.finance.x` vs. strict bundle optimization with `isFiscalYearStart(t)`). + +--- + ## Advanced: Symbol Namespaces If you are building a highly-specialized internal plugin and want to mathematically guarantee zero naming collisions, you can use a `Symbol` as your namespace key! diff --git a/packages/tempo/doc/3-extending-tempo/tempo.plugin.md b/packages/tempo/doc/3-extending-tempo/tempo.plugin.md index 24da8343..4a219aba 100644 --- a/packages/tempo/doc/3-extending-tempo/tempo.plugin.md +++ b/packages/tempo/doc/3-extending-tempo/tempo.plugin.md @@ -141,10 +141,10 @@ To support teams that mandate strict 0kb bundle-impacts and functional programmi ```typescript // The Pure, Tree-shakeable approach: -import { isLeapYear } from '@magmacomputing/tempo-fns'; +import { isFirstDayOfMonth } from '@magmacomputing/tempo-fns'; import { Tempo } from '@magmacomputing/tempo/core'; -if (isLeapYear(new Tempo('2024-01-01'))) { ... } +if (isFirstDayOfMonth(new Tempo('2024-01-01'))) { ... } ``` When building complex logic, consider whether it belongs as a core Plugin extension, or as a standalone utility in `tempo-fns` (or a hybrid wrapper of both!). diff --git a/packages/tempo/doc/3-extending-tempo/tempo.term.md b/packages/tempo/doc/3-extending-tempo/tempo.term.md index e7711c4e..14e3e748 100644 --- a/packages/tempo/doc/3-extending-tempo/tempo.term.md +++ b/packages/tempo/doc/3-extending-tempo/tempo.term.md @@ -29,6 +29,7 @@ Plugin expose two views of that result via the `Tempo.term` object: |---|---| | `tempo.term.` | A short identifier string (e.g. `'qtr'`, `'szn'`, `'zdc'`) | | `tempo.term.` | The full matching range object, with all metadata fields (e.g. `key`, `day`, `month`, `year`, `sphere`, etc.) | + The `` and `` are defined by the plugin author, where the intent of the `` is to provide a short identifier value, and the intent of the `` is to provide the full matching range object. ## Provided Plugin diff --git a/packages/tempo/doc/4-advanced-reference/sandbox-factory.md b/packages/tempo/doc/4-advanced-reference/sandbox-factory.md index 29955ff2..4f334e74 100644 --- a/packages/tempo/doc/4-advanced-reference/sandbox-factory.md +++ b/packages/tempo/doc/4-advanced-reference/sandbox-factory.md @@ -44,7 +44,7 @@ const t2 = new FinTempo('market-open'); // Success: 09:30 When using sandboxes, it's important to know which configuration resolved an input. Tempo now records the **source** of every match in the `parse.result` array. ### Hierarchy of Resolution -When a conflict occurs (e.g., you redefine "noon"), Tempo uses a **"Last One Wins"** strategy: +When a conflict occurs (e.g., you redefine "noon"), Tempo resolves it by checking layers from **highest priority to lowest priority**: 1. **Local (Instance)**: Options passed to `new Tempo(val, options)`. 2. **Sandbox (Factory)**: Options passed to `Tempo.create(options)`. 3. **Plugins**: Aliases registered via `Tempo.extend()`. diff --git a/packages/tempo/doc/5-architecture-and-internals/architecture.md b/packages/tempo/doc/5-architecture-and-internals/architecture.md index 00d5b145..5ba0855b 100644 --- a/packages/tempo/doc/5-architecture-and-internals/architecture.md +++ b/packages/tempo/doc/5-architecture-and-internals/architecture.md @@ -58,13 +58,13 @@ Tempo employs two distinct methodologies for protecting its internal state. Thes ## ⚑ The "Zero-Cost Constructor" Objective Tempo is built with a militant **"Performance First"** mindset, specifically targeting the computational overhead of the class constructor. In high-frequency applications (like Tickers or real-time Dashboards), creating thousands of objects must be nearly as computationally cheap as primitive assignments. -**Status: Achieved.** We successfully meet the Zero-Cost objective. Benchmarks demonstrate an `O(1)` instantiation overhead of ~523Β΅s, and a fast-fail rejection speed of ~359Β΅s. +**Status: Achieved.** We successfully meet the Zero-Cost objective under stable conditions. Benchmarks demonstrate an instantiation overhead of ~523Β΅s, and a fast-fail rejection speed of ~359Β΅s (steady-state results after warm-up on the documented hardware setup). This objective is achieved through two primary architectural pillars: 1. **Lazy Evaluation ([Section 1](#1-lazy-evaluation-shadowing))**: Deferring the computationally expensive work of string parsing and Term calculation until the first strict property access. 2. **Master Guard ([Section 3](#3-master-guard-fast-fail-sync-point))**: Implementing a high-speed "fast-fail" gatekeeper to instantly reject invalid inputs before parsing logic is engaged. -Together, these pillars guarantee that `new Tempo()` maintains an `O(1)` constructor execution time by completely deferring `O(N)` parsing workloads, regardless of the density of registered plugins or custom terms. +Together, these pillars help ensure that `new Tempo()` maintains an extremely fast constructor execution time by completely deferring standard parsing workloads. However, note that these costs can depend on input scanning length and registry-mutation-driven wordlist rebuilds, meaning performance is not universally input- or registry-independent. --- diff --git a/packages/tempo/doc/5-architecture-and-internals/lazy-evaluation-pattern.md b/packages/tempo/doc/5-architecture-and-internals/lazy-evaluation-pattern.md index 4a9780e8..60cfa21c 100644 --- a/packages/tempo/doc/5-architecture-and-internals/lazy-evaluation-pattern.md +++ b/packages/tempo/doc/5-architecture-and-internals/lazy-evaluation-pattern.md @@ -18,10 +18,12 @@ Tempo achieves lazy evaluation in `O(1)` time using a **Delegator Proxy** that m ```javascript #setLazy(target, name, defineFunction) { + // Note: The `target` object must remain unfrozen so we can memoize properties onto it. + // It is kept secure by being stored in a private field (e.g., `#term`). const get = () => { const value = defineFunction.call(this); // Evaluate the value - // Memoize the value by defining it as a static property on the target + // Memoize the value by defining it as a static property on the mutable target Object.defineProperty(target, name, { value, enumerable: true, @@ -42,8 +44,8 @@ Tempo achieves lazy evaluation in `O(1)` time using a **Delegator Proxy** that m 1. **Proxy Entry Point:** Tempo uses a single Proxy (the `delegate` helper) to catch the very first access to a property. This Proxy doesn't store state; it just routes the request to the lazy evaluator. -2. **Private Fields Bypass the Freeze:** - The internal containers (`#term`, `#fmt`) are private fields. Native JS Private Fields don't exist as properties on the object; they are internal engine slots. Thus, even if the `Tempo` instance is frozen, we can still update the *internal* state of the container objects. +2. **Private Fields Keep the Mutable Target Secure:** + The internal containers (`#term`, `#fmt`) are private fields. Native JS Private Fields don't exist as public properties on the object. Thus, even if the public `Tempo` instance is frozen, we can safely keep the *internal* target objects unfrozen to allow for memoization without exposing them to tampering. 3. **Innate JS Engine Optimizations:** Once a property (e.g., `.quarter`) is evaluated, it is "baked" into the target object as a standard value property. Subsequent lookups bypass the Proxy and the getter entirely. The JS engine treats it as a raw property access, which is the fastest possible operation in JavaScript. diff --git a/packages/tempo/doc/5-architecture-and-internals/soft_freeze_strategy.md b/packages/tempo/doc/5-architecture-and-internals/soft_freeze_strategy.md index 6179a2f4..665863fb 100644 --- a/packages/tempo/doc/5-architecture-and-internals/soft_freeze_strategy.md +++ b/packages/tempo/doc/5-architecture-and-internals/soft_freeze_strategy.md @@ -58,11 +58,11 @@ export function proxify(target: T, frozen = true, lock = froze The `enumify` utility uses Soft Freeze by default for registries that are intended to be extensible. ```typescript -export function enumify(list, frozen = true) { +export function enumify(list, frozen = false) { const target = Object.create(proto, descriptors); - // Default to Soft Freeze (frozen=true, lock=false) - // if 'frozen' is passed as false, it signals 'extensible library registry' + // By defaulting to frozen=false, we create a Soft Freeze (proxy is frozen, target is not locked). + // This allows extensible library registries to be safely updated via the private symbol. return proxify(target, true, frozen); } ``` diff --git a/packages/tempo/doc/6-utility-library/tempo.decorators.md b/packages/tempo/doc/6-utility-library/tempo.decorators.md index 38382b5f..ca185176 100644 --- a/packages/tempo/doc/6-utility-library/tempo.decorators.md +++ b/packages/tempo/doc/6-utility-library/tempo.decorators.md @@ -3,7 +3,7 @@ Tempo includes a few custom TypeScript decorators that help enforce class behaviors such as immutability, serialization registration, and preventing instantiation. > **Note on ES2022 Target & Transpilation** -> Currently, the TypeScript compiler target for Tempo is set to ES2022. Because native ECMAScript decorators are still maturing across Javascript engines, TypeScript transpiles these decorators away into standard function calls in the emitted Javascript code. Our long-term aim is to bring these decorators to first-class, native Javascript items once the ECMAScript decorator proposal is fully mature and widely implemented in engines without requiring down-level transpilation. +> Currently, the TypeScript compiler target for Tempo is set to ES2022. Because native ECMAScript decorators are still maturing across JavaScript engines, TypeScript transpiles these decorators away into standard function calls in the emitted JavaScript code. Our long-term aim is to bring these decorators to first-class, native JavaScript items once the ECMAScript decorator proposal is fully mature and widely implemented in engines without requiring down-level transpilation.
diff --git a/packages/tempo/doc/6-utility-library/tempo.library.md b/packages/tempo/doc/6-utility-library/tempo.library.md index 5daef2e8..bf1d6eda 100644 --- a/packages/tempo/doc/6-utility-library/tempo.library.md +++ b/packages/tempo/doc/6-utility-library/tempo.library.md @@ -10,7 +10,7 @@ This document serves as an index summarizing these core library features. ## 1. Enumerators (`enumify`) -Tempo uses a custom utility called `enumify` to create heavily-protected, iteratable enum-like objects instead of relying on native TypeScript enums. +Tempo uses a custom utility called `enumify` to create heavily-protected, iterable enum-like objects instead of relying on native TypeScript enums. This allows for structural typing, easy iteration (`.keys()`, `.values()`), and runtime safety without the overhead or compilation quirks of standard TS Enums. @@ -38,7 +38,7 @@ To ensure safe data persistence across `localStorage`, `IndexedDB`, or network b Tempo utilizes several custom TypeScript class decorators internally to enforce class behaviors such as strict immutability and preventing instantiation. -Because Tempo's build target is currently ES2022, this decorator functionality is transpiled away into standard Javascript functions by the compiler rather than using native ECMAScript decorators. Our aim is to transition these to first-class native features once Javascript engines natively support the ECMAScript decorator proposal. +Because Tempo's build target is currently ES2022, this decorator functionality is transpiled away into standard JavaScript functions by the compiler rather than using native ECMAScript decorators. Our aim is to transition these to first-class native features once JavaScript engines natively support the ECMAScript decorator proposal. πŸ‘‰ **[Read the full Decorators Guide](./tempo.decorators.md)** for details on the specific decorators used within the codebase. diff --git a/packages/tempo/doc/6-utility-library/tempo.pledge.md b/packages/tempo/doc/6-utility-library/tempo.pledge.md index c2ffb519..f2d9ecc5 100644 --- a/packages/tempo/doc/6-utility-library/tempo.pledge.md +++ b/packages/tempo/doc/6-utility-library/tempo.pledge.md @@ -83,7 +83,7 @@ Pledge.init(); ## 6. Automatic Cleanup (Symbol.dispose) -`Pledge` implements the `Disposable` interface. If a `Pledge` goes out of scope while still pending, it will automatically reject to prevent "hanging" async operations. +`Pledge` implements the `Disposable` interface. You can automatically reject a pending pledge at the end of a scope by declaring it with the `using` keyword, or by explicitly invoking `[Symbol.dispose]()`. Ordinary block exit (without `using`) or garbage collection will not trigger disposal. ```typescript { diff --git a/packages/tempo/doc/6-utility-library/tempo.serializers.md b/packages/tempo/doc/6-utility-library/tempo.serializers.md index fd471b03..b264a31d 100644 --- a/packages/tempo/doc/6-utility-library/tempo.serializers.md +++ b/packages/tempo/doc/6-utility-library/tempo.serializers.md @@ -54,7 +54,7 @@ const detachedCopy = cloneify(richData); Why not just use `JSON.parse(JSON.stringify(obj))` or `structuredClone()`? * **Preservation of Rich Types:** `JSON.stringify()` instantly throws a `TypeError` if it encounters a `BigInt`. It silently strips `undefined`, reduces `Date` to a dumb string, and mangles `Set`/`Map` into empty `{}` objects. Tempo's `stringify` preserves all of these. -* **Fallback for `structuredClone`:** While `structuredClone` is great, it is not universally available in older environments and still drops functions. `cloneify` gives you a guaranteed deep-clone that resurrects your precise types reliably. +* **Fallback for `structuredClone`:** While `structuredClone` is great, it is not universally available in older environments and throws an error if it encounters functions. `cloneify` gives you a guaranteed deep-clone that resurrects your precise types reliably. * **Safe Encoding:** Strings are natively URI-encoded for control characters, ensuring safe stashing in fragile WebStorage or cache environments without injection/corruption vulnerabilities. * **Class Registration:** User-defined classes can be resurrected after stringification if they are decorated with Tempo's `@Serialize()` registry. diff --git a/packages/tempo/doc/7-ecosystem/extending-temporal.md b/packages/tempo/doc/7-ecosystem/extending-temporal.md index 66948fce..48ef70d2 100644 --- a/packages/tempo/doc/7-ecosystem/extending-temporal.md +++ b/packages/tempo/doc/7-ecosystem/extending-temporal.md @@ -12,7 +12,7 @@ To complement Temporal's explicit strictness, Tempo adds: Here is a side-by-side comparison demonstrating how Tempo drastically reduces boilerplate for standard operations, while unlocking capabilities that are difficult to achieve with native Temporal alone. -### 1. Parsing: Strict vs. Intelligent +## 1. Parsing: Strict vs. Intelligent Temporal only accepts strict ISO 8601 strings. If you have user input, database dumps, or human-readable dates, you have to write your own parser first. Tempo handles it out-of-the-box. @@ -32,7 +32,7 @@ new Tempo('next Friday'); // Parses relative natural language pe For more information on handling natural language and complex strings, read the dedicated **[Parsing Guide](../2-core-concepts/tempo.parse.md)**. -### 2. Formatting: Verbose vs. Expressive Tokens +## 2. Formatting: Verbose vs. Expressive Tokens Temporal relies on the `Intl.DateTimeFormat` API for formatting. While powerful for localization, it is incredibly verbose for simple, specific string outputs. @@ -53,7 +53,7 @@ t.fmt.date; // Output: "2026-01-24" For comprehensive examples of localized output and custom layouts, read the dedicated **[Formatting Guide](../2-core-concepts/tempo.format.md)**. -### 3. Extensibility: Domain-Specific Logic +## 3. Extensibility: Domain-Specific Logic Native Temporal deals strictly with standard calendar units (days, months, years). If you need to map a date to domain-specific logic (like a fiscal quarter or a meteorological season), you have to write and maintain your own math utilities. @@ -89,7 +89,7 @@ t.term.szn; // β†’ 'Winter' (Calculates meteorological season, respecting hemisp For more information on adding your own domain-specific logic, read the dedicated **[Terms Guide](../3-extending-tempo/tempo.term.md)**. -### 4. Duration Logic: Strict Math vs. Human Readable +## 4. Duration Logic: Strict Math vs. Human Readable 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. @@ -102,9 +102,10 @@ const now = Temporal.Now.plainDateISO(); const target = Temporal.PlainDate.from('2026-12-25'); now.until(target); // β†’ Returns a complex Duration object -// 2. Format a past date into a human-readable string +// 2. Format a past date into a human-readable string (relative to a fixed point) +const refDate = Temporal.PlainDate.from('2026-07-09'); const pastDate = Temporal.PlainDate.from('2026-07-06'); -const diff = pastDate.until(now, { largestUnit: 'days' }); +const diff = pastDate.until(refDate, { largestUnit: 'days' }); const rtf = new Intl.RelativeTimeFormat('en', { numeric: 'auto' }); rtf.format(-Math.abs(diff.days), 'day'); // β†’ "3 days ago" ``` @@ -120,7 +121,8 @@ t.until('2026-12-25').duration; // β†’ The underlying Temporal.Duration object t.until('2026-12-25', 'days'); // β†’ 219 // 2. .since() instantly returns human-readable relative strings -t.since('last Friday', 'days'); // β†’ "3 days ago" +const t2 = new Tempo('2026-07-09'); +t2.since('2026-07-06', 'days'); // β†’ "3 days ago" ``` - `t.until()` returns a highly functional Extended Data Object (EDO) or a precise decimal number depending on your arguments. diff --git a/packages/tempo/doc/7-ecosystem/vision.md b/packages/tempo/doc/7-ecosystem/vision.md index 96137925..93e3f592 100644 --- a/packages/tempo/doc/7-ecosystem/vision.md +++ b/packages/tempo/doc/7-ecosystem/vision.md @@ -12,10 +12,10 @@ Modern developers expect a "warm" API for common tasks. Native Temporal requires Data in the real world is messy. Tempo's **Layout** and **Snippet** engine allows developers to interpret human-readable strings, aliases, and custom formats without writing complex, custom utility functions. It turns "today" or "Christmas" into machine-exact time points effortlessly. ### 3. Business Intelligence via Plugin -Tempo extends beyond simple date arithmetic through its **Terms** system. By providing declarative access to complex calculationsβ€”such as fiscal quarters, meteorological seasons, and zodiac signs, Tempo moves domain-specific logic out of the application code and into a reusable, extensible plugin architecture. +Tempo extends beyond simple date arithmetic through its **Terms** system. By providing declarative access to complex calculationsβ€”such as fiscal quarters, meteorological seasons, and zodiac signsβ€”Tempo moves domain-specific logic out of the application code and into a reusable, extensible plugin architecture. ### 4. Lean but Robust -Tempo is designed to be a thin, highly capable layer. It prioritizes a lightweight API surface for the developer while maintaining robust internal logic to handle the complexities of timezones, calendars, and durations +Tempo is designed to be a thin, highly capable layer. It prioritizes a lightweight API surface for the developer while maintaining robust internal logic to handle the complexities of timezones, calendars, and durations. ## Conclusion Tempo is not intended to replace Temporal, but to humanize it. It is the tool for developers who want the future of JavaScript dates today, without the overhead of building their own high-level utility library from scratch. diff --git a/packages/tempo/doc/8-project-and-support/releases/v2.x.md b/packages/tempo/doc/8-project-and-support/releases/v2.x.md index 88396a30..a1b55981 100644 --- a/packages/tempo/doc/8-project-and-support/releases/v2.x.md +++ b/packages/tempo/doc/8-project-and-support/releases/v2.x.md @@ -150,7 +150,7 @@ Updated architecture documentation and configuration guidance. Clarified plugin/module callback parameter ordering in examples. Added v2.6.0 migration guide for season changes. -## [v2.5.0] -2026-04-25 +## [v2.5.0] - 2026-04-25 ### New Features - Added Sandbox Factory pattern for isolated Tempo configurations via Tempo.create(options). diff --git a/packages/tempo/doc/8-project-and-support/releases/v4.x.md b/packages/tempo/doc/8-project-and-support/releases/v4.x.md index 8e78ddd7..55b2f924 100644 --- a/packages/tempo/doc/8-project-and-support/releases/v4.x.md +++ b/packages/tempo/doc/8-project-and-support/releases/v4.x.md @@ -3,12 +3,12 @@ ## [v4.0.0] - (Planned) ### 🚨 Major Breaking Changes -- **Configuration Namespace Enforcement**: Removed all legacy root-level property access that was deprecated during the `v3.x` lifecycle. - - `formats` configuration key has been entirely removed from the `Options` and `Discovery` interfaces. You must use `registry: { formats: ... }` instead. +- **Configuration Namespace Enforcement**: Will remove all legacy root-level property access that was deprecated during the `v3.x` lifecycle. + - `formats` configuration key will be entirely removed from the `Options` and `Discovery` interfaces. You will need to use `registry: { formats: ... }` instead. ### πŸ—‘οΈ API Removals - **Removed Static Accessors**: - - `Tempo.formats` has been completely removed. Use `Tempo.registry.formats` instead. + - `Tempo.formats` will be completely removed. Use `Tempo.registry.formats` instead. ### πŸ—οΈ Internal Architecture - **Namespace-Only Configurations**: The internal `Config` state mapping now exclusively enforces nested schema access without mapping wrappers. diff --git a/packages/tempo/src/plugin/plugin.util.ts b/packages/tempo/src/plugin/plugin.util.ts index edc4ad6d..635e589b 100644 --- a/packages/tempo/src/plugin/plugin.util.ts +++ b/packages/tempo/src/plugin/plugin.util.ts @@ -241,11 +241,17 @@ export function defineNamespace(config: NamespaceConfig): Plugin & { const cacheKey = isSymbol(config.name) ? config.name : `_${String(config.name)}`; if (!this[cacheKey]) { const target = Object.create(null); - this[cacheKey] = delegate(target, (key) => { + const proxy = delegate(target, (key) => { const resolver = config.resolvers[key as keyof typeof config.resolvers]; if (resolver) return resolver(this); return undefined; }, true); + Object.defineProperty(this, cacheKey, { + value: proxy, + writable: true, + configurable: true, + enumerable: false + }); } return this[cacheKey]; }, diff --git a/packages/tempo/src/plugin/term/term.util.ts b/packages/tempo/src/plugin/term/term.util.ts index 4d7cbac5..cd7bad10 100644 --- a/packages/tempo/src/plugin/term/term.util.ts +++ b/packages/tempo/src/plugin/term/term.util.ts @@ -18,8 +18,11 @@ import { TEMPO_VERSION } from '../../tempo.version.js'; * Helper to register a Term plugin. */ export const defineTerm = (term: T): T & { type: 'term' } => { - if (!term.version) term.version = TEMPO_VERSION; - const result = Object.assign(term, { type: 'term' } as const); + const result = { + ...term, + type: 'term' as const, + version: term.version ?? TEMPO_VERSION + } as T & { type: 'term' }; registerTerm(result); return deepFreeze(result) as T & { type: 'term' }; } diff --git a/packages/tempo/src/tempo.class.ts b/packages/tempo/src/tempo.class.ts index 57e516a5..7ed041b1 100644 --- a/packages/tempo/src/tempo.class.ts +++ b/packages/tempo/src/tempo.class.ts @@ -519,7 +519,12 @@ export class Tempo { isObject(arg) && !isString(arg.name) && !isDefined(arg.key) && - !Object.keys(arg).some(k => DISCOVERY.has(k as any)); + !('timeZones' in arg) && + !('numbers' in arg) && + !('terms' in arg) && + !('formats' in arg) && + !('locales' in arg) && + !('options' in arg); let options = (args.length > 0 && isOptionsArg(args[args.length - 1])) ? args.pop() : undefined; const licenseKey = options?.license || (args.length === 1 && isObject(args[0]) ? args[0].license : undefined); @@ -531,7 +536,6 @@ export class Tempo { } const items = args.flat(Infinity); - if (isEmpty(items)) return this; _lifecycle.extendDepth++; // increment the re-entrant nesting counter @@ -557,7 +561,7 @@ export class Tempo { } } } - else if (isObject(item) && ((item as any).type === 'plugin' || (item as any).type === 'namespace' || (item as any).type === 'module' || (isString((item as any).name) && isFunction((item as any).install)))) { + else if (isObject(item) && isFunction(item.install) && (item.type === 'plugin' || item.type === 'namespace' || item.type === 'module' || isString(item.name))) { // Plugin object form { name, install } const name = (item as any).name; const type = (item as any).type; diff --git a/packages/tempo/test/core/discovery-extend.test.ts b/packages/tempo/test/core/discovery-extend.test.ts index 3ad76321..5a3dca3d 100644 --- a/packages/tempo/test/core/discovery-extend.test.ts +++ b/packages/tempo/test/core/discovery-extend.test.ts @@ -6,6 +6,7 @@ describe('Discovery in Extend', () => { it('should apply monthDay discovery via extend', () => { Tempo.extend({ + options: {}, monthDay: { locales: ['custom-locale'] } @@ -15,6 +16,7 @@ describe('Discovery in Extend', () => { it('should apply relativeTime discovery via extend', () => { Tempo.extend({ + options: {}, intl: { relativeTime: { style: 'narrow' @@ -26,15 +28,17 @@ describe('Discovery in Extend', () => { it('should apply formats discovery via extend', () => { Tempo.extend({ + options: {}, formats: { customFormat: '{yyyy}-{mm}' } }); - expect(Tempo.formats.customFormat).toBe('{yyyy}-{mm}'); + expect(Tempo.registry.formats.customFormat).toBe('{yyyy}-{mm}'); }); it('should apply planner discovery via extend', () => { Tempo.extend({ + options: {}, planner: { layoutOrder: ['ymd'], preFilter: true From 667a12df91ab8fdeca4e7ee8d7aea19ebcc986e4 Mon Sep 17 00:00:00 2001 From: Michael McRae Date: Sat, 11 Jul 2026 15:22:18 +1000 Subject: [PATCH 05/18] esbuild bug mangling Immutable decorator --- packages/library/src/common/pledge.class.ts | 2 ++ packages/tempo/doc/8-project-and-support/migration-guide.md | 2 +- packages/tempo/package.json | 1 - packages/tempo/src/tempo.class.ts | 2 ++ 4 files changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/library/src/common/pledge.class.ts b/packages/library/src/common/pledge.class.ts index 3bae9f8f..39a3c635 100644 --- a/packages/library/src/common/pledge.class.ts +++ b/packages/library/src/common/pledge.class.ts @@ -103,6 +103,8 @@ export class Pledge { if (this.#status.catch) this.#pledge.promise.catch(err => _dbg.warn(this.#status, err)); + + return Object.freeze(this) as this; } get [Symbol.toStringTag]() { diff --git a/packages/tempo/doc/8-project-and-support/migration-guide.md b/packages/tempo/doc/8-project-and-support/migration-guide.md index 8d4e3090..7615b232 100644 --- a/packages/tempo/doc/8-project-and-support/migration-guide.md +++ b/packages/tempo/doc/8-project-and-support/migration-guide.md @@ -8,7 +8,7 @@ The `TickerModule` has been extracted from the core open-source repository into **Action Required**: 1. If you use `Tempo.ticker()`, you must now install `@magmacomputing/tempo-plugin-ticker` alongside `@magmacomputing/tempo`. -2. **Activate your License**: Obtain your free JWT license key. +2. **Activate your License**: Obtain your JWT license key.
Tempo License Registry diff --git a/packages/tempo/package.json b/packages/tempo/package.json index ab8e3d7c..5efae189 100644 --- a/packages/tempo/package.json +++ b/packages/tempo/package.json @@ -194,7 +194,6 @@ "test:dist": "cross-env TEMPO_LICENSE_KEY=\"\" TEST_DIST=true vitest run", "test:browser": "vitest run -c vitest.browser.config.ts", "test:ci": "cross-env TEMPO_LICENSE_KEY=\"\" TZ=America/New_York LANG=en_US.UTF-8 LC_ALL=en_US.UTF-8 vitest run", - "test:ci:prefilter": "cross-env TEMPO_LICENSE_KEY=\"\" TZ=America/New_York LANG=en_US.UTF-8 LC_ALL=en_US.UTF-8 TEMPO_PREFILTER_CI=true vitest run", "repl": "cross-env TEMPO_LICENSE_KEY=\"\" tsx --tsconfig ./src/tsconfig.repl.json -i --import ./bin/temporal-polyfill.ts --import ./bin/repl.ts", "repl:dist": "tsx -i --import ./bin/temporal-polyfill.ts --import ./bin/repl.ts", "repl:node": "tsx --tsconfig ./src/tsconfig.repl.json -i --harmony-temporal --import ./bin/repl.ts", diff --git a/packages/tempo/src/tempo.class.ts b/packages/tempo/src/tempo.class.ts index 7ed041b1..f0c179a8 100644 --- a/packages/tempo/src/tempo.class.ts +++ b/packages/tempo/src/tempo.class.ts @@ -1280,6 +1280,8 @@ export class Tempo { } if (!this.#local.parse.lazy) this.#resolve(); // attempt to interpret immediately (if not lazy) + + return Object.freeze(this) as this; } /** explicitly enable/disable "catch" mode for this instance */ From 7fe11170ca6f02d42f4f3aa3dae7e3f405b43e08 Mon Sep 17 00:00:00 2001 From: Michael McRae Date: Sun, 12 Jul 2026 09:03:46 +1000 Subject: [PATCH 06/18] add doco to class.library --- packages/library/src/common/class.library.ts | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/packages/library/src/common/class.library.ts b/packages/library/src/common/class.library.ts index 3110b79e..264d17c0 100644 --- a/packages/library/src/common/class.library.ts +++ b/packages/library/src/common/class.library.ts @@ -19,6 +19,12 @@ function getClassName(value: T, contextName: string | sym /** * Shared helper to create an immutable or secure class wrapper + * + * @note **Workaround:** Due to ES decorator down-leveling issues with `__esDecorate` + * in TS 7.0 + esbuild (when targeting ES2022), the wrapper's `immutabilityStrategy` + * fails to capture the final subclass state in some bundled distributions. + * As a temporary workaround, consuming classes must also explicitly return + * `Object.freeze(this) as this;` at the end of their own constructors. */ function createImmutableWrapper( value: T, @@ -97,7 +103,11 @@ function hardenClassStaticsAndPrototypes(value: any, wrapper: any, skip: any) { } /** - * Decorator to secure a class with a mutation-throwing Proxy (noisy immutability) + * Decorator to secure a class with a mutation-throwing Proxy (noisy immutability). + * + * @note **Workaround:** Due to ES decorator down-leveling issues in TS 7.0 / esbuild, + * users must temporarily append `return Object.freeze(this) as this;` (or the `secure` equivalent) + * to their constructors to ensure immutability is maintained across production boundaries. */ export function Securable(value: T, { kind, name, addInitializer }: ClassDecoratorContext): T | void { const finalName = getClassName(value, name); @@ -110,7 +120,13 @@ export function Securable(value: T, { kind, name, addInit } } -/** decorator to freeze a Class to prevent modification (silent immutability) */ +/** + * Decorator to freeze a Class to prevent modification (silent immutability). + * + * @note **Workaround:** Due to ES decorator down-leveling issues in TS 7.0 / esbuild, + * users must temporarily append `return Object.freeze(this) as this;` to their + * constructors to ensure immutability is maintained across production boundaries. + */ export function Immutable(value: T, { kind, name, addInitializer }: ClassDecoratorContext): T | void { const finalName = getClassName(value, name); From de8a0c1a23c9f417418ea353a190f90499400fc6 Mon Sep 17 00:00:00 2001 From: Michael McRae Date: Sun, 12 Jul 2026 12:21:23 +1000 Subject: [PATCH 07/18] work on tempo-fns --- .github/workflows/deploy-docs.yml | 10 +- package-lock.json | 1034 +++++------------ packages/library/src/common/class.library.ts | 25 +- .../src/common/international.library.ts | 6 +- packages/library/src/common/pledge.class.ts | 2 +- .../library/src/common/temporal.library.ts | 12 +- packages/tempo-fns/bin/sync-docs.mjs | 33 + packages/tempo-fns/doc/.vitepress/config.mts | 56 + .../tempo-fns/doc/functions/business/index.md | 2 + .../tempo-fns/doc/functions/calendar/index.md | 2 + .../tempo-fns/doc/functions/duration/index.md | 2 + packages/tempo-fns/doc/functions/index.md | 19 + .../doc/functions/scheduling/index.md | 2 + .../tempo-fns/doc/functions/timezone/index.md | 2 + packages/tempo-fns/doc/index.md | 156 +++ .../doc/public/img/tempo-fns-logo.svg | 14 + packages/tempo-fns/doc/support.md | 17 + packages/tempo-fns/package.json | 13 +- packages/tempo-fns/src/business/README.md | 2 + .../src/{ => business}/isSameFiscalQuarter.ts | 0 .../src/business/workingHoursUntil.ts | 32 + packages/tempo-fns/src/calendar/README.md | 2 + .../src/calendar/getISOWeekOfYear.ts | 18 + .../src/{ => calendar}/isFirstDayOfMonth.ts | 0 packages/tempo-fns/src/duration/README.md | 2 + .../duration/normaliseFractionalDurations.ts | 27 + packages/tempo-fns/src/index.ts | 27 +- packages/tempo-fns/src/scheduling/Interval.ts | 51 + packages/tempo-fns/src/scheduling/README.md | 2 + packages/tempo-fns/src/scheduling/cron.ts | 18 + packages/tempo-fns/src/timezone/README.md | 2 + .../tempo-fns/src/timezone/getHemisphere.ts | 14 + packages/tempo-fns/src/timezone/getOffsets.ts | 9 + packages/tempo-fns/src/timezone/isDST.ts | 12 + .../src/timezone/normalizeUtcOffset.ts | 19 + packages/tempo/CHANGELOG.md | 2 + packages/tempo/bin/tempo.js | 70 -- packages/tempo/package.json | 2 + packages/tempo/rollup.config.js | 7 +- .../core/__fixtures__/config/tempo.config.js | 8 - 40 files changed, 869 insertions(+), 864 deletions(-) create mode 100644 packages/tempo-fns/bin/sync-docs.mjs create mode 100644 packages/tempo-fns/doc/.vitepress/config.mts create mode 100644 packages/tempo-fns/doc/functions/business/index.md create mode 100644 packages/tempo-fns/doc/functions/calendar/index.md create mode 100644 packages/tempo-fns/doc/functions/duration/index.md create mode 100644 packages/tempo-fns/doc/functions/index.md create mode 100644 packages/tempo-fns/doc/functions/scheduling/index.md create mode 100644 packages/tempo-fns/doc/functions/timezone/index.md create mode 100644 packages/tempo-fns/doc/index.md create mode 100644 packages/tempo-fns/doc/public/img/tempo-fns-logo.svg create mode 100644 packages/tempo-fns/doc/support.md create mode 100644 packages/tempo-fns/src/business/README.md rename packages/tempo-fns/src/{ => business}/isSameFiscalQuarter.ts (100%) create mode 100644 packages/tempo-fns/src/business/workingHoursUntil.ts create mode 100644 packages/tempo-fns/src/calendar/README.md create mode 100644 packages/tempo-fns/src/calendar/getISOWeekOfYear.ts rename packages/tempo-fns/src/{ => calendar}/isFirstDayOfMonth.ts (100%) create mode 100644 packages/tempo-fns/src/duration/README.md create mode 100644 packages/tempo-fns/src/duration/normaliseFractionalDurations.ts create mode 100644 packages/tempo-fns/src/scheduling/Interval.ts create mode 100644 packages/tempo-fns/src/scheduling/README.md create mode 100644 packages/tempo-fns/src/scheduling/cron.ts create mode 100644 packages/tempo-fns/src/timezone/README.md create mode 100644 packages/tempo-fns/src/timezone/getHemisphere.ts create mode 100644 packages/tempo-fns/src/timezone/getOffsets.ts create mode 100644 packages/tempo-fns/src/timezone/isDST.ts create mode 100644 packages/tempo-fns/src/timezone/normalizeUtcOffset.ts delete mode 100755 packages/tempo/bin/tempo.js delete mode 100644 packages/tempo/test/core/__fixtures__/config/tempo.config.js diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml index 3487ebf8..3c2d2941 100644 --- a/.github/workflows/deploy-docs.yml +++ b/.github/workflows/deploy-docs.yml @@ -5,6 +5,7 @@ on: # Trigger only on changes to tempo package or this workflow paths: - 'packages/tempo/**' + - 'packages/tempo-fns/**' - '.github/workflows/deploy-docs.yml' - 'package.json' - 'package-lock.json' @@ -47,9 +48,16 @@ jobs: - name: Build Tempo run: npm run build:tempo - - name: Build Documentation + - name: Build Tempo Docs run: npm run docs:build --workspace=@magmacomputing/tempo + - name: Build Tempo-Fns Docs + run: npm run docs:build --workspace=@magmacomputing/tempo-fns + + - name: Merge Documentation Sites + run: | + mkdir -p packages/tempo/.vitepress/dist/tempo-fns + cp -r packages/tempo-fns/doc/.vitepress/dist/* packages/tempo/.vitepress/dist/tempo-fns/ - name: Setup Pages uses: actions/configure-pages@v4 diff --git a/package-lock.json b/package-lock.json index ed8dccc0..cc078016 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1061,6 +1061,17 @@ "node": ">=6.0.0" } }, + "node_modules/@jridgewell/source-map": { + "version": "0.3.11", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz", + "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25" + } + }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", @@ -1531,6 +1542,56 @@ } } }, + "node_modules/@rollup/plugin-terser": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@rollup/plugin-terser/-/plugin-terser-1.0.0.tgz", + "integrity": "sha512-FnCxhTBx6bMOYQrar6C8h3scPt8/JwIzw3+AJ2K++6guogH5fYaIFia+zZuhqv0eo1RN7W1Pz630SyvLbDjhtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "serialize-javascript": "^7.0.3", + "smob": "^1.0.0", + "terser": "^5.17.4" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "rollup": "^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/plugin-typescript": { + "version": "12.3.0", + "resolved": "https://registry.npmjs.org/@rollup/plugin-typescript/-/plugin-typescript-12.3.0.tgz", + "integrity": "sha512-7DP0/p7y3t67+NabT9f8oTBFE6gGkto4SA6Np2oudYmZE/m1dt8RB0SjL1msMxFpLo631qjRCcBlAbq1ml/Big==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.1.0", + "resolve": "^1.22.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^2.14.0||^3.0.0||^4.0.0", + "tslib": "*", + "typescript": ">=3.7.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + }, + "tslib": { + "optional": true + } + } + }, "node_modules/@rollup/pluginutils": { "version": "5.4.0", "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.4.0.tgz", @@ -2265,301 +2326,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@swc/core": { - "version": "1.15.41", - "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.15.41.tgz", - "integrity": "sha512-03nQq/082QRJJiOvp3FGbgxTGyyxMxohPTjhk/W9bD2J0tk4ukITI7goOhOO2WbaHn/lsPmo/zf8+DIXhwpgYQ==", - "dev": true, - "hasInstallScript": true, - "license": "Apache-2.0", - "peer": true, - "dependencies": { - "@swc/counter": "^0.1.3", - "@swc/types": "^0.1.26" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/swc" - }, - "optionalDependencies": { - "@swc/core-darwin-arm64": "1.15.41", - "@swc/core-darwin-x64": "1.15.41", - "@swc/core-linux-arm-gnueabihf": "1.15.41", - "@swc/core-linux-arm64-gnu": "1.15.41", - "@swc/core-linux-arm64-musl": "1.15.41", - "@swc/core-linux-ppc64-gnu": "1.15.41", - "@swc/core-linux-s390x-gnu": "1.15.41", - "@swc/core-linux-x64-gnu": "1.15.41", - "@swc/core-linux-x64-musl": "1.15.41", - "@swc/core-win32-arm64-msvc": "1.15.41", - "@swc/core-win32-ia32-msvc": "1.15.41", - "@swc/core-win32-x64-msvc": "1.15.41" - }, - "peerDependencies": { - "@swc/helpers": ">=0.5.17" - }, - "peerDependenciesMeta": { - "@swc/helpers": { - "optional": true - } - } - }, - "node_modules/@swc/core-darwin-arm64": { - "version": "1.15.41", - "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.15.41.tgz", - "integrity": "sha512-kREh6J5paQFvP3i7f/4FbqRNOJREutVFVOkder4GVyCBQ39YmER55cW/y1NNjwrchzFqgYswFn0mMDCqbqKzrw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0 AND MIT", - "optional": true, - "os": [ - "darwin" - ], - "peer": true, - "engines": { - "node": ">=10" - } - }, - "node_modules/@swc/core-darwin-x64": { - "version": "1.15.41", - "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.15.41.tgz", - "integrity": "sha512-N8B56ESFazZAWZyIkecADSPCwlLEinW7QLMEeotCpv4J7VXwfH+OLkmRL8o96UZ+1355fwHxDTS6/wK7yucvkA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0 AND MIT", - "optional": true, - "os": [ - "darwin" - ], - "peer": true, - "engines": { - "node": ">=10" - } - }, - "node_modules/@swc/core-linux-arm-gnueabihf": { - "version": "1.15.41", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.15.41.tgz", - "integrity": "sha512-6XrId2fyle0mS5xxON8rU84mPd2Cq1kDJRj+4BnQKTd7u+2kSA6Ww+JkOP0iTNqOqt9OXhPOEAjBHAuonWcdCg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=10" - } - }, - "node_modules/@swc/core-linux-arm64-gnu": { - "version": "1.15.41", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.15.41.tgz", - "integrity": "sha512-ynLIarxlkVnqHn1D0fKOVht6mNU5ks6lrH+MY3kkS+XFaGGgDxFZVjWKJlkYTKm3RCvBTfA8Ng5fLufXheMRKQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "Apache-2.0 AND MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=10" - } - }, - "node_modules/@swc/core-linux-arm64-musl": { - "version": "1.15.41", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.15.41.tgz", - "integrity": "sha512-dXu/5vd4gh8symyhRF+4G7gOPkjmb4pONhh7sl+6GSiW0LOKZlfu5kXmyFbTz9smOT7jgr002qY9b1nujjXt2A==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "Apache-2.0 AND MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=10" - } - }, - "node_modules/@swc/core-linux-ppc64-gnu": { - "version": "1.15.41", - "resolved": "https://registry.npmjs.org/@swc/core-linux-ppc64-gnu/-/core-linux-ppc64-gnu-1.15.41.tgz", - "integrity": "sha512-XGO6zVPXoPE0gf/XnI4jBbafNT13AYgoh6ns0JCSdOetI/kqVf0vhpz7NuNgAzZrMVCsmieqjPoTwViDgh4mOQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "Apache-2.0 AND MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=10" - } - }, - "node_modules/@swc/core-linux-s390x-gnu": { - "version": "1.15.41", - "resolved": "https://registry.npmjs.org/@swc/core-linux-s390x-gnu/-/core-linux-s390x-gnu-1.15.41.tgz", - "integrity": "sha512-0WUglRwyZtW+iMi7J3iFdrCxreZZIKf4egTwEQfIYRsqFax69A0OrFj+NIoFSE03xBT/IFRrg+S8K6f9Ky+4hA==", - "cpu": [ - "s390x" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "Apache-2.0 AND MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=10" - } - }, - "node_modules/@swc/core-linux-x64-gnu": { - "version": "1.15.41", - "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.15.41.tgz", - "integrity": "sha512-VxkuQK59c0tHm6uJZCUrS3cyA2JhGGfdU6e41SZz0x/JS+4Sm7C1mIc97In14vkZJopEt7yXA2TouCqZDSygEA==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "Apache-2.0 AND MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=10" - } - }, - "node_modules/@swc/core-linux-x64-musl": { - "version": "1.15.41", - "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.15.41.tgz", - "integrity": "sha512-/0qXIu1ZxggLuovLb22vFfKHq2AA4n6Whw5UwmVCHk4pkw7KWnPIQpMCEqUMPsNkFJig7PPp/TSYFu8ZEb2rtQ==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "Apache-2.0 AND MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=10" - } - }, - "node_modules/@swc/core-win32-arm64-msvc": { - "version": "1.15.41", - "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.15.41.tgz", - "integrity": "sha512-Y481sMNZM6rECh9VO4+y26N1lWEDAyxnBZskUf37fl90uHE946VHfmiVQWT0uMFOhyJJFovGTRuF4W82dwewUg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0 AND MIT", - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": ">=10" - } - }, - "node_modules/@swc/core-win32-ia32-msvc": { - "version": "1.15.41", - "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.15.41.tgz", - "integrity": "sha512-BAchBD5qeUzy3hiPSLJtaaoSm4blCLyYffOF1bGE4ETcV+OisqjUAwDQMJj++4bTpvMCDzwC+Bj3PmQyBCtscw==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "Apache-2.0 AND MIT", - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": ">=10" - } - }, - "node_modules/@swc/core-win32-x64-msvc": { - "version": "1.15.41", - "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.15.41.tgz", - "integrity": "sha512-WOkA+fJ/ViVBQDsSV9JC52NACTe5PhlurA6viASDZGb7HR3KS01ZG7RZ+Bg6SVQFIoq3gSbTsskQVe6EbHFAYw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0 AND MIT", - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": ">=10" - } - }, - "node_modules/@swc/counter": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", - "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==", - "dev": true, - "license": "Apache-2.0", - "peer": true - }, - "node_modules/@swc/types": { - "version": "0.1.26", - "resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.26.tgz", - "integrity": "sha512-lyMwd7WGgG79RS7EERZV3T8wMdmPq3xwyg+1nmAM64kIhx5yl+juO2PYIHb7vTiPgPCj8LYjsNV2T5wiQHUEaw==", - "dev": true, - "license": "Apache-2.0", - "peer": true, - "dependencies": { - "@swc/counter": "^0.1.3" - } - }, "node_modules/@tootallnate/quickjs-emscripten": { "version": "0.23.0", "resolved": "https://registry.npmjs.org/@tootallnate/quickjs-emscripten/-/quickjs-emscripten-0.23.0.tgz", @@ -3341,19 +3107,6 @@ "source-map-js": "^1.2.1" } }, - "node_modules/@vue/compiler-core/node_modules/entities": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", - "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.12" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" - } - }, "node_modules/@vue/compiler-dom": { "version": "3.5.38", "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.38.tgz", @@ -4211,6 +3964,13 @@ "node": ">=8.0.0" } }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT" + }, "node_modules/call-bind": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", @@ -4941,12 +4701,25 @@ "once": "^1.4.0" } }, - "node_modules/env-paths": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-4.0.0.tgz", - "integrity": "sha512-pxP8eL2SwwaTRi/KHYwLYXinDs7gL3jxFcBYmEdYfZmZXbaVDvdppd0XBU8qVz03rDfKZMXg1omHCbsJjZrMsw==", + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", "dev": true, - "license": "MIT", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/env-paths": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-4.0.0.tgz", + "integrity": "sha512-pxP8eL2SwwaTRi/KHYwLYXinDs7gL3jxFcBYmEdYfZmZXbaVDvdppd0XBU8qVz03rDfKZMXg1omHCbsJjZrMsw==", + "dev": true, + "license": "MIT", "dependencies": { "is-safe-filename": "^0.1.0" }, @@ -7925,14 +7698,6 @@ "dev": true, "license": "MIT" }, - "node_modules/search-insights": { - "version": "2.17.3", - "resolved": "https://registry.npmjs.org/search-insights/-/search-insights-2.17.3.tgz", - "integrity": "sha512-RQPdCYTa8A68uM2jwxoY842xDhvx3E5LFL1LxvxCNMev4o5mLuokczhzjAgGwUZBAmOKZknArSxLKmXtIi2AxQ==", - "dev": true, - "license": "MIT", - "peer": true - }, "node_modules/semver": { "version": "7.8.5", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", @@ -7975,6 +7740,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/serialize-javascript": { + "version": "7.0.7", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-7.0.7.tgz", + "integrity": "sha512-YAy8Od6KV+uuwUuU50np8fGB/Aues6Y0nAhA9y/hId74PlKUcme4pXcBD46NWKr1Q4osN/iseZ17YqO1XfmI8g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/set-function-length": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", @@ -8128,6 +7903,16 @@ "npm": ">= 3.0.0" } }, + "node_modules/smob": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/smob/-/smob-1.6.2.tgz", + "integrity": "sha512-RQsvleCbF8cVHEv+xuDGaA4pOizFqJ0GgjtMSRo6oP8pnN7WsigHgVGey6aILRBKv4W2YOMHLqbKdnB6hpB9fw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/socks": { "version": "2.8.9", "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.9.tgz", @@ -8164,7 +7949,6 @@ "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", "dev": true, "license": "BSD-3-Clause", - "optional": true, "engines": { "node": ">=0.10.0" } @@ -8179,6 +7963,17 @@ "node": ">=0.10.0" } }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, "node_modules/space-separated-tokens": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", @@ -8499,6 +8294,32 @@ "streamx": "^2.12.5" } }, + "node_modules/terser": { + "version": "5.49.0", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.49.0.tgz", + "integrity": "sha512-SNiDnXyHSrxVcIOtVbULzcTmniUiwcV7Nwdyj1twVubeTmbjoa8p69KKDpfkdoOavuM4/GRm1+ykI8qqnavHoA==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@jridgewell/source-map": "^0.3.3", + "acorn": "^8.15.0", + "commander": "^2.20.0", + "source-map-support": "~0.5.20" + }, + "bin": { + "terser": "bin/terser" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/terser/node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "dev": true, + "license": "MIT" + }, "node_modules/text-decoder": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.7.tgz", @@ -9147,397 +8968,6 @@ } } }, - "node_modules/vitepress/node_modules/@esbuild/aix-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", - "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vitepress/node_modules/@esbuild/android-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", - "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vitepress/node_modules/@esbuild/android-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", - "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vitepress/node_modules/@esbuild/android-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", - "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vitepress/node_modules/@esbuild/darwin-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", - "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vitepress/node_modules/@esbuild/darwin-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", - "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vitepress/node_modules/@esbuild/freebsd-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", - "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vitepress/node_modules/@esbuild/freebsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", - "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vitepress/node_modules/@esbuild/linux-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", - "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vitepress/node_modules/@esbuild/linux-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", - "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vitepress/node_modules/@esbuild/linux-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", - "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vitepress/node_modules/@esbuild/linux-loong64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", - "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vitepress/node_modules/@esbuild/linux-mips64el": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", - "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vitepress/node_modules/@esbuild/linux-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", - "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vitepress/node_modules/@esbuild/linux-riscv64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", - "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vitepress/node_modules/@esbuild/linux-s390x": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", - "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vitepress/node_modules/@esbuild/linux-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", - "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vitepress/node_modules/@esbuild/netbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", - "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vitepress/node_modules/@esbuild/openbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", - "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vitepress/node_modules/@esbuild/sunos-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", - "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vitepress/node_modules/@esbuild/win32-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", - "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vitepress/node_modules/@esbuild/win32-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", - "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vitepress/node_modules/@esbuild/win32-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", - "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, "node_modules/vitepress/node_modules/@shikijs/types": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-2.5.0.tgz", @@ -9563,45 +8993,6 @@ "vue": "^3.2.25" } }, - "node_modules/vitepress/node_modules/esbuild": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", - "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=12" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.21.5", - "@esbuild/android-arm": "0.21.5", - "@esbuild/android-arm64": "0.21.5", - "@esbuild/android-x64": "0.21.5", - "@esbuild/darwin-arm64": "0.21.5", - "@esbuild/darwin-x64": "0.21.5", - "@esbuild/freebsd-arm64": "0.21.5", - "@esbuild/freebsd-x64": "0.21.5", - "@esbuild/linux-arm": "0.21.5", - "@esbuild/linux-arm64": "0.21.5", - "@esbuild/linux-ia32": "0.21.5", - "@esbuild/linux-loong64": "0.21.5", - "@esbuild/linux-mips64el": "0.21.5", - "@esbuild/linux-ppc64": "0.21.5", - "@esbuild/linux-riscv64": "0.21.5", - "@esbuild/linux-s390x": "0.21.5", - "@esbuild/linux-x64": "0.21.5", - "@esbuild/netbsd-x64": "0.21.5", - "@esbuild/openbsd-x64": "0.21.5", - "@esbuild/sunos-x64": "0.21.5", - "@esbuild/win32-arm64": "0.21.5", - "@esbuild/win32-ia32": "0.21.5", - "@esbuild/win32-x64": "0.21.5" - } - }, "node_modules/vitepress/node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -10556,6 +9947,8 @@ "@js-temporal/polyfill": "^0.5.1", "@magmacomputing/library": "*", "@rollup/plugin-alias": "^6.0.0", + "@rollup/plugin-terser": "^1.0.0", + "@rollup/plugin-typescript": "^12.3.0", "javascript-obfuscator": "^5.4.3", "magic-string": "^0.30.21", "rollup-plugin-esbuild": "^6.2.1", @@ -10570,11 +9963,13 @@ }, "packages/tempo-fns": { "name": "@magmacomputing/tempo-fns", - "version": "3.7.1", + "version": "0.1.0", "license": "MIT", "devDependencies": { "@rollup/plugin-node-resolve": "^16.0.3", - "rollup": "^4.62.2" + "rollup": "^4.62.2", + "vitepress": "^1.6.4", + "vue": "^3.5.39" }, "peerDependencies": { "@magmacomputing/tempo": "^3.7.0" @@ -10584,6 +9979,137 @@ "optional": true } } + }, + "packages/tempo-fns/node_modules/@vue/compiler-core": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.39.tgz", + "integrity": "sha512-16KBTEXAJCpDr0mwlw+AZyhu8iyC7R3S2vBwsI7QnWJU6X3WKc9VKeNEZpiMdZ569qWhz9574L3vV55qRL0Vtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@vue/shared": "3.5.39", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "packages/tempo-fns/node_modules/@vue/compiler-dom": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.39.tgz", + "integrity": "sha512-oQPigALqYbNxTNPvNgSOe+czwVExfbVF02lz8jP0S3AXJiu3jxYDygNUiqSep4ezzW8XgnubqH63My2A7JR/vg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.39", + "@vue/shared": "3.5.39" + } + }, + "packages/tempo-fns/node_modules/@vue/compiler-sfc": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.39.tgz", + "integrity": "sha512-d0ki86iOyN8LoZPBmk5SJWNwHP19CnDDCfuo//+2WJa2g5Ke0Jay983PIBIcSSzldC68I8DrD5GrHV3OSDfodg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@vue/compiler-core": "3.5.39", + "@vue/compiler-dom": "3.5.39", + "@vue/compiler-ssr": "3.5.39", + "@vue/shared": "3.5.39", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.15", + "source-map-js": "^1.2.1" + } + }, + "packages/tempo-fns/node_modules/@vue/compiler-ssr": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.39.tgz", + "integrity": "sha512-Ce7/wvwMHai74bdszfXExdazFigYnlF9zgCmEQUcM1j0fOymlouZ7XilTYNo8oUjhlnjYOZbGrcYKuqjz89Ucw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.39", + "@vue/shared": "3.5.39" + } + }, + "packages/tempo-fns/node_modules/@vue/reactivity": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.39.tgz", + "integrity": "sha512-TpsuBJ9gGlZa5d23XcM2y8EXanz9dZeVDQBXRwzy46ItgvM+rWpzs+UVM0wcRLxGvcav0HE5jz2gNL53xlRAog==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.39" + } + }, + "packages/tempo-fns/node_modules/@vue/runtime-core": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.39.tgz", + "integrity": "sha512-9GLtNyRvPAUMbX+7ono0RC2j0guo2LXVi8LvcmAooImACUKm0oFf0jjwbX8/H0AE/t1nxhAkn8RSl9PMCzzxZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.39", + "@vue/shared": "3.5.39" + } + }, + "packages/tempo-fns/node_modules/@vue/runtime-dom": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.39.tgz", + "integrity": "sha512-7Y6aAGboKcXAZ3ECuUy7RrS5yy2r47dhTp2SKaJmYxjopImaVFaNa5Ne66NwGovsrxVAl5S5rwc7m22UG7Lmww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.39", + "@vue/runtime-core": "3.5.39", + "@vue/shared": "3.5.39", + "csstype": "^3.2.3" + } + }, + "packages/tempo-fns/node_modules/@vue/server-renderer": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.39.tgz", + "integrity": "sha512-yZSakiAGw85rZfG7UM8akMnIF+FmeiNk47uvHf2nVBBSe+dIKUhZuZq9+XgJhbV3nS5Z4ALH23/MpXofW+mbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.39", + "@vue/shared": "3.5.39" + }, + "peerDependencies": { + "vue": "3.5.39" + } + }, + "packages/tempo-fns/node_modules/@vue/shared": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.39.tgz", + "integrity": "sha512-l1rrBtBfTnmxvtsvdQDXltUUy8S1Y+ZaqdfUzmAnJkTd8Z8rv5v/ytW+TKiqEOWyHPoqtPlNFSs0lhRmYVSHVA==", + "dev": true, + "license": "MIT" + }, + "packages/tempo-fns/node_modules/vue": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.39.tgz", + "integrity": "sha512-xmZCYabFGcirU8r0fTuvl/LICc1OU620rnqepaJDL/a141ZigkG7AyaxQLdqJ02ZRYzWe6YPaDHeQx7MfknQfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.39", + "@vue/compiler-sfc": "3.5.39", + "@vue/runtime-dom": "3.5.39", + "@vue/server-renderer": "3.5.39", + "@vue/shared": "3.5.39" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } } } } diff --git a/packages/library/src/common/class.library.ts b/packages/library/src/common/class.library.ts index 264d17c0..e3d85a23 100644 --- a/packages/library/src/common/class.library.ts +++ b/packages/library/src/common/class.library.ts @@ -1,6 +1,6 @@ import { $ImmutableSkip } from '#library/symbol.library.js'; import { secure } from '#library/proxy.library.js'; -import { isReference } from '#library/assertion.library.js'; +import { isReference, isUndefined } from '#library/assertion.library.js'; import { registerSerializable } from '#library/serialize.library.js'; import { registerType, getSafeTag } from '#library/type.library.js'; import type { Constructor, Type } from '#library/type.library.js'; @@ -14,16 +14,17 @@ import type { Constructor, Type } from '#library/type.library.js'; * minifiers and compilers from mangling the registered class name. */ function getClassName(value: T, contextName: string | symbol | undefined): string | undefined { - return getSafeTag(value) ?? (contextName === undefined ? (value.name || undefined) : String(contextName)); + return getSafeTag(value) ?? (isUndefined(contextName) ? (value.name || undefined) : String(contextName)); } /** * Shared helper to create an immutable or secure class wrapper * - * @note **Workaround:** Due to ES decorator down-leveling issues with `__esDecorate` - * in TS 7.0 + esbuild (when targeting ES2022), the wrapper's `immutabilityStrategy` - * fails to capture the final subclass state in some bundled distributions. - * As a temporary workaround, consuming classes must also explicitly return + * @note **Workaround:** When TS 7.0 (targeting ES2022) emits its `__esDecorate` IIFE, aggressive + * bundlers and minifiers (like `Rollup`, `Terser`, or `esbuild`) frequently compress the variable + * declarations into chained assignments (e.g. `var Class = _classThis = class`). This breaks JS + * evaluation order and overwrites the decorated wrapper with the original class. + * As a permanent defense against these bundler mutations, consuming classes must explicitly return * `Object.freeze(this) as this;` at the end of their own constructors. */ function createImmutableWrapper( @@ -105,9 +106,9 @@ function hardenClassStaticsAndPrototypes(value: any, wrapper: any, skip: any) { /** * Decorator to secure a class with a mutation-throwing Proxy (noisy immutability). * - * @note **Workaround:** Due to ES decorator down-leveling issues in TS 7.0 / esbuild, - * users must temporarily append `return Object.freeze(this) as this;` (or the `secure` equivalent) - * to their constructors to ensure immutability is maintained across production boundaries. + * @note **Workaround:** To protect against aggressive bundlers (Rollup/Terser) mutating the TS 7.0 + * ES2022 decorator IIFE structure, users must append `return Object.freeze(this) as this;` + * (or the `secure` equivalent) to their constructors to ensure immutability survives production bundling. */ export function Securable(value: T, { kind, name, addInitializer }: ClassDecoratorContext): T | void { const finalName = getClassName(value, name); @@ -123,9 +124,9 @@ export function Securable(value: T, { kind, name, addInit /** * Decorator to freeze a Class to prevent modification (silent immutability). * - * @note **Workaround:** Due to ES decorator down-leveling issues in TS 7.0 / esbuild, - * users must temporarily append `return Object.freeze(this) as this;` to their - * constructors to ensure immutability is maintained across production boundaries. + * @note **Workaround:** To protect against aggressive bundlers (Rollup/Terser) mutating the TS 7.0 + * ES2022 decorator IIFE structure, users must append `return Object.freeze(this) as this;` to their + * constructors to ensure immutability survives production bundling. */ export function Immutable(value: T, { kind, name, addInitializer }: ClassDecoratorContext): T | void { const finalName = getClassName(value, name); diff --git a/packages/library/src/common/international.library.ts b/packages/library/src/common/international.library.ts index 63471f5e..6ab6c854 100644 --- a/packages/library/src/common/international.library.ts +++ b/packages/library/src/common/international.library.ts @@ -113,7 +113,10 @@ export function formatUnit(value: number, unit: string, locale?: string, unitDis } } -/** try to infer hemisphere using the timezone's daylight-savings setting */ +/** + * try to infer hemisphere using the timezone's daylight-savings setting + * @note also maintained in `tempo-fns` β€” please sync changes + */ export function getHemisphere(timeZone: string = getDateTimeFormat().timeZone) { try { const { jan, jul } = getOffsets(timeZone); // using default reference-year (2024) for stability @@ -145,6 +148,7 @@ type result = { weekOfYear: number, yearOfWeek: number }; * (e.g., Chrome/Firefox behind flags) currently return `undefined` for `weekOfYear` and `yearOfWeek` * on ZonedDateTime objects. The TC39 spec moved toward calendar-dependent definitions, * causing divergence between the @js-temporal/polyfill (which returns numbers) and native browsers (which return undefined). + * @note also maintained in `tempo-fns` β€” please sync changes */ export function getISOWeekOfYear(zdt: input): result { if (isDefined(zdt.weekOfYear) && isDefined(zdt.yearOfWeek)) diff --git a/packages/library/src/common/pledge.class.ts b/packages/library/src/common/pledge.class.ts index 39a3c635..1658138b 100644 --- a/packages/library/src/common/pledge.class.ts +++ b/packages/library/src/common/pledge.class.ts @@ -114,7 +114,7 @@ export class Pledge { [Symbol.dispose]() { if (this.isPending) { try { - this.promise.catch(() => {}); + this.promise.catch(() => { }); } catch { // best-effort; preserve disposal semantics even if promise is unavailable } diff --git a/packages/library/src/common/temporal.library.ts b/packages/library/src/common/temporal.library.ts index 332e2733..39c35d61 100644 --- a/packages/library/src/common/temporal.library.ts +++ b/packages/library/src/common/temporal.library.ts @@ -26,7 +26,10 @@ export function epoch() { return instant().epochNanoseconds; } -/** return the January and July offsets (nanoseconds) for a given timezone and year */ +/** + * return the January and July offsets (nanoseconds) for a given timezone and year + * @note also maintained in `tempo-fns` β€” please sync changes + */ export function getOffsets(timeZone: string, year = 2024) { //** use a fixed reference-year (2024) for stability */ const jan = Temporal.PlainDate.from({ year, month: 1, day: 1 }).toZonedDateTime(timeZone).offsetNanoseconds; const jul = Temporal.PlainDate.from({ year, month: 7, day: 1 }).toZonedDateTime(timeZone).offsetNanoseconds; @@ -34,7 +37,10 @@ export function getOffsets(timeZone: string, year = 2024) { //** use a fixed ref return { jan, jul }; } -/** return whether the given (or current) date is in Daylight Savings */ +/** + * return whether the given (or current) date is in Daylight Savings + * @note also maintained in `tempo-fns` β€” please sync changes + */ export function isDST(date?: Temporal.ZonedDateTime | string, timeZone: string = Intl.DateTimeFormat().resolvedOptions().timeZone) { const zdt = isString(date) ? Temporal.ZonedDateTime.from(date) @@ -48,6 +54,7 @@ export function isDST(date?: Temporal.ZonedDateTime | string, timeZone: string = * Temporal rejects fractional Duration values, so normalise * fractional parts downwards, e.g. { seconds: 0.1 } β†’ { milliseconds: 100 }. * Mutates the provided duration object. + * @note also maintained in `tempo-fns` β€” please sync changes */ export function normaliseFractionalDurations(payload: Record) { const SCALE: [string, string, number][] = [ @@ -155,6 +162,7 @@ export function getTemporalIds(tzOrZdt: any, cal?: any): [string, string] { * Convert informal UTC offset strings into the `Β±HH:MM` format required by Temporal. * Accepts forms like `'UTC+8'`, `'UTC-9'`, `'UTC+08:00'`, `'UTC-05:30'`. * Returns the input unchanged if it does not match the UTCΒ± pattern. + * @note also maintained in `tempo-fns` β€” please sync changes */ export function normalizeUtcOffset(zone: string): string { const match = /^UTC([+-])(\d{1,2})(?::(\d{2}))?$/i.exec(zone); diff --git a/packages/tempo-fns/bin/sync-docs.mjs b/packages/tempo-fns/bin/sync-docs.mjs new file mode 100644 index 00000000..95bf65fd --- /dev/null +++ b/packages/tempo-fns/bin/sync-docs.mjs @@ -0,0 +1,33 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const srcDir = path.resolve(__dirname, '../src'); +const docFunctionsDir = path.resolve(__dirname, '../doc/functions'); + +const categories = fs.readdirSync(srcDir, { withFileTypes: true }) + .filter(dirent => dirent.isDirectory()) + .map(dirent => dirent.name); + +let syncedCount = 0; + +for (const category of categories) { + const readmePath = path.join(srcDir, category, 'README.md'); + if (fs.existsSync(readmePath)) { + const destDir = path.join(docFunctionsDir, category); + fs.mkdirSync(destDir, { recursive: true }); + + // Copy the README.md to index.md in the doc directory + fs.copyFileSync(readmePath, path.join(destDir, 'index.md')); + + console.log(`[sync-docs] Synced: src/${category}/README.md -> doc/functions/${category}/index.md`); + syncedCount++; + } +} + +if (syncedCount === 0) { + console.log('[sync-docs] No README.md files found in src sub-directories to sync.'); +} else { + console.log(`[sync-docs] Successfully synced ${syncedCount} documentation files.`); +} diff --git a/packages/tempo-fns/doc/.vitepress/config.mts b/packages/tempo-fns/doc/.vitepress/config.mts new file mode 100644 index 00000000..0919b676 --- /dev/null +++ b/packages/tempo-fns/doc/.vitepress/config.mts @@ -0,0 +1,56 @@ +import { defineConfig } from 'vitepress' + +export default defineConfig({ + title: "tempo-fns", + description: "The standard functional library for Temporal.", + base: "/magma/tempo-fns/", + vite: { + build: { + target: 'es2022' + }, + esbuild: { + target: 'esnext' + }, + optimizeDeps: { + esbuildOptions: { + target: 'esnext' + } + } + }, + themeConfig: { + logo: '/img/tempo-fns-logo.svg', + nav: [ + { text: 'Home', link: '/' }, + { text: 'Functions', link: '/functions/' } + ], + + sidebar: [ + { + text: 'Overview', + items: [ + { text: 'Introduction', link: '/functions/' } + ] + }, + { + text: 'Categories', + items: [ + { text: 'Business', link: '/functions/business/' }, + { text: 'Calendar', link: '/functions/calendar/' }, + { text: 'Scheduling', link: '/functions/scheduling/' }, + { text: 'Timezone', link: '/functions/timezone/' }, + { text: 'Duration', link: '/functions/duration/' } + ] + }, + { + text: 'Support', + items: [ + { text: 'Community & Support', link: '/support' } + ] + } + ], + + socialLinks: [ + { icon: 'github', link: 'https://github.com/magmacomputing/magma' } + ] + } +}) diff --git a/packages/tempo-fns/doc/functions/business/index.md b/packages/tempo-fns/doc/functions/business/index.md new file mode 100644 index 00000000..0cb76168 --- /dev/null +++ b/packages/tempo-fns/doc/functions/business/index.md @@ -0,0 +1,2 @@ +# Business Utilities +This directory contains utility functions designed for financial, business, and SLA calculations. diff --git a/packages/tempo-fns/doc/functions/calendar/index.md b/packages/tempo-fns/doc/functions/calendar/index.md new file mode 100644 index 00000000..3b932425 --- /dev/null +++ b/packages/tempo-fns/doc/functions/calendar/index.md @@ -0,0 +1,2 @@ +# Calendar Utilities +This directory contains calendar and date-oriented utility functions (e.g. week of year, first day of month). diff --git a/packages/tempo-fns/doc/functions/duration/index.md b/packages/tempo-fns/doc/functions/duration/index.md new file mode 100644 index 00000000..5f372f96 --- /dev/null +++ b/packages/tempo-fns/doc/functions/duration/index.md @@ -0,0 +1,2 @@ +# Duration Utilities +This directory contains pure functions for normalizing and evaluating durations. diff --git a/packages/tempo-fns/doc/functions/index.md b/packages/tempo-fns/doc/functions/index.md new file mode 100644 index 00000000..47016fd1 --- /dev/null +++ b/packages/tempo-fns/doc/functions/index.md @@ -0,0 +1,19 @@ +# Introduction + +Welcome to the `tempo-fns` documentation! This library provides a comprehensive suite of **pure, tree-shakable** functional utilities designed explicitly for the modern ECMAScript ecosystem. + +## Built for Temporal + +`tempo-fns` was engineered from the ground up to consume and return native `Temporal` instances. [Temporal](https://tc39.es/proposal-temporal/docs/) is the new global object coming to JavaScript that brings a modern, robust date and time API to the language, resolving decades of frustration with the legacy `Date` object. + +Because `tempo-fns` expects standard `Temporal` objects (like `Temporal.ZonedDateTime` or `Temporal.PlainDate`), you can use these functions natively in any modern JavaScript environment without requiring bulky adapters, parsers, or conversion layers. + +## Better Together with Tempo + +While `tempo-fns` is a standalone library of pure functions, it was designed in parallel with the [Tempo](https://magmacomputing.github.io/magma/) core library. + +Tempo provides a powerful, immutable, and fully extensible wrapper over the Temporal API, offering features like semantic period parsing (e.g., `"tomorrow at noon"`), advanced recurring chronologies, and a premium asynchronous `Ticker` engine. + +When you use `tempo-fns` *with* Tempo, you get the best of both worlds: the zero-cost instantiation and fluent chainable API of Tempo, seamlessly augmented by the hyper-specific, tree-shakable utility logic of `tempo-fns` (like SLA scheduling, fiscal calculations, or timezone hemisphere detection). + +[**Learn more about the Tempo Core Library β†’**](https://magmacomputing.github.io/magma/) diff --git a/packages/tempo-fns/doc/functions/scheduling/index.md b/packages/tempo-fns/doc/functions/scheduling/index.md new file mode 100644 index 00000000..424058f7 --- /dev/null +++ b/packages/tempo-fns/doc/functions/scheduling/index.md @@ -0,0 +1,2 @@ +# Scheduling Utilities +This directory contains utilities for cron parsing, scheduling intervals, and recurring logic. diff --git a/packages/tempo-fns/doc/functions/timezone/index.md b/packages/tempo-fns/doc/functions/timezone/index.md new file mode 100644 index 00000000..9df4eee8 --- /dev/null +++ b/packages/tempo-fns/doc/functions/timezone/index.md @@ -0,0 +1,2 @@ +# Timezone & Location Utilities +This directory contains utilities for manipulating offsets, calculating daylight savings, and hemisphere tracking. diff --git a/packages/tempo-fns/doc/index.md b/packages/tempo-fns/doc/index.md new file mode 100644 index 00000000..4afc1d3c --- /dev/null +++ b/packages/tempo-fns/doc/index.md @@ -0,0 +1,156 @@ +--- +layout: home +--- + + + + + + + +
+ +## Designed for the ECMAScript Ecosystem + +`tempo-fns` is a comprehensive suite of **pure, functional date-time utilities** built exclusively for the modern JavaScript and TypeScript ecosystem. + +Designed to seamlessly augment the new [ECMAScript Temporal API](https://tc39.es/proposal-temporal/docs/), this library delivers the advanced calendar, scheduling, and timezone logic that modern web applications demandβ€”without polluting your bundles. + +### Why `tempo-fns`? +- **100% Pure Functions:** Every utility is a pure, standalone function. No mutated state, no side effects. +- **Aggressively Tree-Shakable:** Keep your bundle footprint tiny. Only import exactly what you need. If you only need `isSameFiscalQuarter`, that's the only code that ships to your users. +- **Native Temporal Integration:** No legacy `Date` objects. `tempo-fns` is built from the ground up to consume, calculate, and return native `Temporal` instances. + +Whether you're building complex financial SLA calculators, recurring cron-based scheduling systems, or simply need robust calendar math, `tempo-fns` provides the modern, professional tooling you need to ship with confidence. + +
diff --git a/packages/tempo-fns/doc/public/img/tempo-fns-logo.svg b/packages/tempo-fns/doc/public/img/tempo-fns-logo.svg new file mode 100644 index 00000000..cea47eea --- /dev/null +++ b/packages/tempo-fns/doc/public/img/tempo-fns-logo.svg @@ -0,0 +1,14 @@ + + + + + + Ξ» + + + + + + + + diff --git a/packages/tempo-fns/doc/support.md b/packages/tempo-fns/doc/support.md new file mode 100644 index 00000000..42561d7e --- /dev/null +++ b/packages/tempo-fns/doc/support.md @@ -0,0 +1,17 @@ +# Community & Support + +We are building a vibrant ecosystem of developers who are passionate about modernizing JavaScript date-time logic. + +## Contributing + +We welcome community contributions! Whether it's a new astrological utility or a fix to an existing cron parser, please check out our [Contributing Guide](https://github.com/magmacomputing/magma/blob/main/packages/tempo/CONTRIBUTING.md) to get started. + +## Issues & Support + +Encountered a bug or have a feature request? Open an issue on our [GitHub Repository](https://github.com/magmacomputing/magma/issues). + +## Discussions + +Join the conversation, ask for help, or share what you've built on our [GitHub Discussions](https://github.com/magmacomputing/magma/discussions) board. + +We're excited to see what you build! diff --git a/packages/tempo-fns/package.json b/packages/tempo-fns/package.json index 3c017d4b..1e155c17 100644 --- a/packages/tempo-fns/package.json +++ b/packages/tempo-fns/package.json @@ -1,6 +1,6 @@ { "name": "@magmacomputing/tempo-fns", - "version": "3.7.1", + "version": "0.1.0", "description": "Tree-shakeable functional utilities for the Temporal API", "author": "Magma Computing Solutions", "license": "MIT", @@ -32,7 +32,9 @@ "build:esm": "tsc -b", "build:global": "rollup -c", "clean": "rm -rf dist && (tsc -b --clean || true)", - "prepublishOnly": "if [ $(git rev-parse --abbrev-ref HEAD) != main ]; then echo 'ERROR: Must be on main branch to publish.'; exit 1; fi && npm run build" + "prepublishOnly": "if [ $(git rev-parse --abbrev-ref HEAD) != main ]; then echo 'ERROR: Must be on main branch to publish.'; exit 1; fi && npm run build", + "docs:dev": "node ./bin/sync-docs.mjs && vitepress dev doc", + "docs:build": "node ./bin/sync-docs.mjs && vitepress build doc" }, "peerDependencies": { "@magmacomputing/tempo": "^3.7.0" @@ -44,7 +46,8 @@ }, "devDependencies": { "@rollup/plugin-node-resolve": "^16.0.3", - "rollup": "^4.62.2" - }, - "dependencies": {} + "rollup": "^4.62.2", + "vitepress": "^1.6.4", + "vue": "^3.5.39" + } } diff --git a/packages/tempo-fns/src/business/README.md b/packages/tempo-fns/src/business/README.md new file mode 100644 index 00000000..0cb76168 --- /dev/null +++ b/packages/tempo-fns/src/business/README.md @@ -0,0 +1,2 @@ +# Business Utilities +This directory contains utility functions designed for financial, business, and SLA calculations. diff --git a/packages/tempo-fns/src/isSameFiscalQuarter.ts b/packages/tempo-fns/src/business/isSameFiscalQuarter.ts similarity index 100% rename from packages/tempo-fns/src/isSameFiscalQuarter.ts rename to packages/tempo-fns/src/business/isSameFiscalQuarter.ts diff --git a/packages/tempo-fns/src/business/workingHoursUntil.ts b/packages/tempo-fns/src/business/workingHoursUntil.ts new file mode 100644 index 00000000..d85b9d91 --- /dev/null +++ b/packages/tempo-fns/src/business/workingHoursUntil.ts @@ -0,0 +1,32 @@ +import type { Tempo } from '@magmacomputing/tempo/core'; + +export type SLAOptions = { + /** Explicit array of holiday dates to skip */ + holidays?: string[]; + /** Expected region for the loaded remote holidays (e.g. 'AU-NSW') */ + region?: string; +} + +// Internal cache for holidays loaded via the async `preloadHolidays` method +const remoteHolidaysCache: Map = new Map(); + +export const workingHoursUntil = function (this: Tempo, deadline: any) { + // Stub logic for SLA calculation + // Production logic would: + // 1. Merge options.holidays with remoteHolidaysCache.get(options.region) + // 2. Diff `this` to `deadline` and strip out weekends + holidays + return 12; // e.g. 12 working hours +} + +export const preloadHolidays = async (region: string, year: number): Promise => { + await new Promise(resolve => setTimeout(resolve, 50)); + const cacheKey = `${region}-${year}`; + remoteHolidaysCache.set(cacheKey, ['2026-01-01', '2026-12-25']); +} + +declare module '@magmacomputing/tempo/core' { + interface Tempo { + /** Calculates the exact number of working hours from this date until the deadline. */ + workingHoursUntil(deadline: any): number; + } +} diff --git a/packages/tempo-fns/src/calendar/README.md b/packages/tempo-fns/src/calendar/README.md new file mode 100644 index 00000000..3b932425 --- /dev/null +++ b/packages/tempo-fns/src/calendar/README.md @@ -0,0 +1,2 @@ +# Calendar Utilities +This directory contains calendar and date-oriented utility functions (e.g. week of year, first day of month). diff --git a/packages/tempo-fns/src/calendar/getISOWeekOfYear.ts b/packages/tempo-fns/src/calendar/getISOWeekOfYear.ts new file mode 100644 index 00000000..e6bce090 --- /dev/null +++ b/packages/tempo-fns/src/calendar/getISOWeekOfYear.ts @@ -0,0 +1,18 @@ +import { Temporal } from '@js-temporal/polyfill'; + +/** compute the ISO week number and ISO week-numbering year for a given ZonedDateTime */ +export function getISOWeekOfYear(zdt: Temporal.ZonedDateTime | Temporal.PlainDate) { + // ISO 8601 week 1 is the week with the year's first Thursday in it. + const targetThursday = zdt.subtract({ days: zdt.dayOfWeek - 4 }); + const targetYear = targetThursday.year; + + // January 4th is always in week 1. + const firstThursday = Temporal.PlainDate.from({ year: targetYear, month: 1, day: 4 }); + const week1Thursday = firstThursday.subtract({ days: firstThursday.dayOfWeek - 4 }); + + // Compute days between target Thursday and week 1 Thursday + const daysBetween = targetThursday.since(week1Thursday).total({ unit: 'days' }); + const weekOfYear = Math.floor(daysBetween / 7) + 1; + + return { weekOfYear, yearOfWeek: targetYear }; +} diff --git a/packages/tempo-fns/src/isFirstDayOfMonth.ts b/packages/tempo-fns/src/calendar/isFirstDayOfMonth.ts similarity index 100% rename from packages/tempo-fns/src/isFirstDayOfMonth.ts rename to packages/tempo-fns/src/calendar/isFirstDayOfMonth.ts diff --git a/packages/tempo-fns/src/duration/README.md b/packages/tempo-fns/src/duration/README.md new file mode 100644 index 00000000..5f372f96 --- /dev/null +++ b/packages/tempo-fns/src/duration/README.md @@ -0,0 +1,2 @@ +# Duration Utilities +This directory contains pure functions for normalizing and evaluating durations. diff --git a/packages/tempo-fns/src/duration/normaliseFractionalDurations.ts b/packages/tempo-fns/src/duration/normaliseFractionalDurations.ts new file mode 100644 index 00000000..76b258be --- /dev/null +++ b/packages/tempo-fns/src/duration/normaliseFractionalDurations.ts @@ -0,0 +1,27 @@ +/** + * Temporal rejects fractional Duration values, so normalise + * fractional parts downwards, e.g. { seconds: 0.1 } β†’ { milliseconds: 100 }. + * Mutates the provided duration object. + */ +export function normaliseFractionalDurations(payload: Record) { + const SCALE: [string, string, number][] = [ + ['hours', 'minutes', 60], + ['minutes', 'seconds', 60], + ['seconds', 'milliseconds', 1_000], + ['milliseconds', 'microseconds', 1_000], + ['microseconds', 'nanoseconds', 1_000], + ] + + for (const [big, small, factor] of SCALE) { + const v = payload[big]; + + if (typeof v === 'number' && !Number.isNaN(v) && v !== Math.trunc(v)) { + const whole = Math.trunc(v); + const frac = v - whole; + if (whole) payload[big] = whole; else delete payload[big]; + payload[small] = (payload[small] ?? 0) + Math.round(frac * factor); + } + } + + return payload; +} diff --git a/packages/tempo-fns/src/index.ts b/packages/tempo-fns/src/index.ts index 464846e1..b8c0b580 100644 --- a/packages/tempo-fns/src/index.ts +++ b/packages/tempo-fns/src/index.ts @@ -1,6 +1,21 @@ -export { isFirstDayOfMonth } from './isFirstDayOfMonth.js'; -export { isSameFiscalQuarter } from './isSameFiscalQuarter.js'; -export { workingHoursUntil, type SLAOptions, preloadHolidays } from './workingHoursUntil.js'; -export { nextCron } from './nextCron.js'; -export { prevCron } from './prevCron.js'; -export { Interval } from './interval.js'; +// --- Business --- +export { isSameFiscalQuarter } from './business/isSameFiscalQuarter.js'; +export { workingHoursUntil, type SLAOptions, preloadHolidays } from './business/workingHoursUntil.js'; + +// --- Calendar --- +export { isFirstDayOfMonth } from './calendar/isFirstDayOfMonth.js'; +export { getISOWeekOfYear } from './calendar/getISOWeekOfYear.js'; + +// --- Scheduling --- +export { nextCron } from './scheduling/cron.js'; +export { prevCron } from './scheduling/cron.js'; +export { Interval } from './scheduling/Interval.js'; + +// --- Timezone & Location --- +export { isDST } from './timezone/isDST.js'; +export { normalizeUtcOffset } from './timezone/normalizeUtcOffset.js'; +export { getOffsets } from './timezone/getOffsets.js'; +export { getHemisphere } from './timezone/getHemisphere.js'; + +// --- Duration --- +export { normaliseFractionalDurations } from './duration/normaliseFractionalDurations.js'; diff --git a/packages/tempo-fns/src/scheduling/Interval.ts b/packages/tempo-fns/src/scheduling/Interval.ts new file mode 100644 index 00000000..938e0735 --- /dev/null +++ b/packages/tempo-fns/src/scheduling/Interval.ts @@ -0,0 +1,51 @@ +import type { Tempo } from '@magmacomputing/tempo'; + +export class Interval { + start: Tempo; + end: Tempo; + + constructor(start: Tempo, end: Tempo) { + const startNs = start.epoch.ns; + const endNs = end.epoch.ns; + + if (endNs < startNs) { + this.start = end; + this.end = start; + } else { + this.start = start; + this.end = end; + } + } + + /** Returns true if the specified Tempo instance is within this interval */ + contains(tempo: Tempo): boolean { + const t = tempo.epoch.ns; + return t >= this.start.epoch.ns && t < this.end.epoch.ns; + } + + /** Returns true if this interval overlaps with another interval */ + overlaps(other: Interval): boolean { + return this.start.epoch.ns < other.end.epoch.ns && this.end.epoch.ns > other.start.epoch.ns; + } + + /** Returns true if this interval starts exactly when the other ends, or vice versa */ + abuts(other: Interval): boolean { + return this.end.epoch.ns === other.start.epoch.ns || this.start.epoch.ns === other.end.epoch.ns; + } + + /** Returns the intersection of this interval and another, or null if they do not overlap */ + intersection(other: Interval): Interval | null { + if (!this.overlaps(other)) return null; + const maxStart = this.start.epoch.ns > other.start.epoch.ns ? this.start : other.start; + const minEnd = this.end.epoch.ns < other.end.epoch.ns ? this.end : other.end; + return new Interval(maxStart, minEnd); + } + + /** Returns the union of this interval and another, or null if they do not overlap/abut */ + union(other: Interval): Interval | null { + if (!this.overlaps(other) && !this.abuts(other)) return null; + const minStart = this.start.epoch.ns < other.start.epoch.ns ? this.start : other.start; + const maxEnd = this.end.epoch.ns > other.end.epoch.ns ? this.end : other.end; + return new Interval(minStart, maxEnd); + } +} diff --git a/packages/tempo-fns/src/scheduling/README.md b/packages/tempo-fns/src/scheduling/README.md new file mode 100644 index 00000000..424058f7 --- /dev/null +++ b/packages/tempo-fns/src/scheduling/README.md @@ -0,0 +1,2 @@ +# Scheduling Utilities +This directory contains utilities for cron parsing, scheduling intervals, and recurring logic. diff --git a/packages/tempo-fns/src/scheduling/cron.ts b/packages/tempo-fns/src/scheduling/cron.ts new file mode 100644 index 00000000..55d59ce5 --- /dev/null +++ b/packages/tempo-fns/src/scheduling/cron.ts @@ -0,0 +1,18 @@ +import type { Tempo } from '@magmacomputing/tempo/core'; + +export const nextCron = function (this: Tempo, pattern: string) { + return this; +} + +export const prevCron = function (this: Tempo, pattern: string) { + return this; +} + +declare module '@magmacomputing/tempo/core' { + interface Tempo { + /** Shifts to the next occurrence matching the given cron pattern. */ + nextCron(pattern: string): Tempo; + /** Shifts to the previous occurrence matching the given cron pattern. */ + prevCron(pattern: string): Tempo; + } +} diff --git a/packages/tempo-fns/src/timezone/README.md b/packages/tempo-fns/src/timezone/README.md new file mode 100644 index 00000000..9df4eee8 --- /dev/null +++ b/packages/tempo-fns/src/timezone/README.md @@ -0,0 +1,2 @@ +# Timezone & Location Utilities +This directory contains utilities for manipulating offsets, calculating daylight savings, and hemisphere tracking. diff --git a/packages/tempo-fns/src/timezone/getHemisphere.ts b/packages/tempo-fns/src/timezone/getHemisphere.ts new file mode 100644 index 00000000..9ef8c6a4 --- /dev/null +++ b/packages/tempo-fns/src/timezone/getHemisphere.ts @@ -0,0 +1,14 @@ +import { getOffsets } from './getOffsets.js'; + +export type Hemisphere = 'N' | 'S' | 'E'; + +/** returns the hemisphere ('N'/'S' or 'E' for equator) for a given timezone */ +export function getHemisphere(timeZone: string = Intl.DateTimeFormat().resolvedOptions().timeZone): Hemisphere { + try { + const { jan, jul } = getOffsets(timeZone); // using default reference-year (2024) for stability + if (jan === jul) return 'E'; + return (jan < jul) ? 'S' : 'N'; + } catch { + return 'N'; // Fallback for invalid timezones + } +} diff --git a/packages/tempo-fns/src/timezone/getOffsets.ts b/packages/tempo-fns/src/timezone/getOffsets.ts new file mode 100644 index 00000000..fbcd987a --- /dev/null +++ b/packages/tempo-fns/src/timezone/getOffsets.ts @@ -0,0 +1,9 @@ +import { Temporal } from '@js-temporal/polyfill'; + +/** return the January and July offsets (nanoseconds) for a given timezone and year */ +export function getOffsets(timeZone: string, year = 2024) { //** use a fixed reference-year (2024) for stability */ + const jan = Temporal.PlainDate.from({ year, month: 1, day: 1 }).toZonedDateTime(timeZone).offsetNanoseconds; + const jul = Temporal.PlainDate.from({ year, month: 7, day: 1 }).toZonedDateTime(timeZone).offsetNanoseconds; + + return { jan, jul }; +} diff --git a/packages/tempo-fns/src/timezone/isDST.ts b/packages/tempo-fns/src/timezone/isDST.ts new file mode 100644 index 00000000..00fb185a --- /dev/null +++ b/packages/tempo-fns/src/timezone/isDST.ts @@ -0,0 +1,12 @@ +import { Temporal } from '@js-temporal/polyfill'; +import { getOffsets } from './getOffsets.js'; + +/** return whether the given (or current) date is in Daylight Savings */ +export function isDST(date?: Temporal.ZonedDateTime | string, timeZone: string = Intl.DateTimeFormat().resolvedOptions().timeZone) { + const zdt = typeof date === 'string' + ? Temporal.ZonedDateTime.from(date) + : (date ?? Temporal.Now.zonedDateTimeISO(timeZone)); + const { jan, jul } = getOffsets(zdt.timeZoneId, zdt.year); + + return zdt.offsetNanoseconds !== Math.min(jan, jul); +} diff --git a/packages/tempo-fns/src/timezone/normalizeUtcOffset.ts b/packages/tempo-fns/src/timezone/normalizeUtcOffset.ts new file mode 100644 index 00000000..9bdd0f01 --- /dev/null +++ b/packages/tempo-fns/src/timezone/normalizeUtcOffset.ts @@ -0,0 +1,19 @@ +/** + * ## normalizeUtcOffset + * Convert informal UTC offset strings into the `Β±HH:MM` format required by Temporal. + * Accepts forms like `'UTC+8'`, `'UTC-9'`, `'UTC+08:00'`, `'UTC-05:30'`. + * Returns the input unchanged if it does not match the UTCΒ± pattern. + */ +export function normalizeUtcOffset(zone: string): string { + const match = /^UTC([+-])(\d{1,2})(?::(\d{2}))?$/i.exec(zone); + if (!match) return zone; + + const [, sign, hours, minutes] = match; + const h = Number(hours); + const m = Number(minutes ?? '0'); + + // Temporal-valid range: -12:00 .. +14:00, minutes 0..59 + if (h > 14 || m > 59 || (sign === '+' && h === 14 && m !== 0) || (sign === '-' && h > 12)) return zone; + + return `${sign}${hours.padStart(2, '0')}:${minutes ?? '00'}`; +} diff --git a/packages/tempo/CHANGELOG.md b/packages/tempo/CHANGELOG.md index f12a5e9c..f36f0904 100644 --- a/packages/tempo/CHANGELOG.md +++ b/packages/tempo/CHANGELOG.md @@ -14,6 +14,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Finance Sandbox (`@magmacomputing/tempo-plugin-finance`)**: Introduced the community `finance` package as the official reference implementation for Namespace plugins, complete with best-practice dual-build (ESM/DTS) architectures using `tsup`. ### Changed +- **Build Pipeline Optimization**: Completely removed `esbuild` from the core transpilation pipeline in favor of a pure `tsc` + `Rollup` + `terser` architecture. This eliminates double-transpilation penalties, resulting in a cleaner, more efficient `dist/` build. +- **TypeScript 7.0 Decorator Mitigations**: Uncovered a significant bug in TS 7.0's ES2022 `__esDecorate` down-leveling where transpiled class expressions drop decorator replacements. Maintained the `Object.freeze` constructor workarounds across the ecosystem to ensure full immutability compliance while tracking upstream compiler patches. - **Documentation Architecture**: Completely overhauled the documentation repository to utilize a strictly-numbered directory structure (`1-getting-started`, `2-core-concepts`, etc.) that mirrors the VitePress UI 1:1, drastically reducing maintenance overhead and eliminating orphaned files. ## [3.7.1] - 2026-07-08 diff --git a/packages/tempo/bin/tempo.js b/packages/tempo/bin/tempo.js deleted file mode 100755 index 0bb5f30b..00000000 --- a/packages/tempo/bin/tempo.js +++ /dev/null @@ -1,70 +0,0 @@ -#!/usr/bin/env node - -import fs from 'node:fs'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; - -// CLI Arguments -const args = process.argv.slice(2); -const command = args[0]; - -// The location of the templates relative to this bin script (bin/tempo.js -> template/) -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); -const templateDir = path.join(__dirname, '..', 'template'); -const cwd = process.cwd(); - -// Helper to copy a file safely -function safelyCopy(sourceFile, targetFile, successMessage) { - const sourcePath = path.join(templateDir, sourceFile); - const targetPath = path.join(cwd, targetFile); - - if (fs.existsSync(targetPath)) { - console.error(`\x1b[31m[Tempo Scaffold] Aborted.\x1b[0m File already exists at: ${targetPath}`); - console.error(`We refused to overwrite your existing file. If you want to scaffold a new one, please delete the existing file first.`); - process.exit(1); - } - - if (!fs.existsSync(sourcePath)) { - console.error(`\x1b[31m[Tempo Scaffold] Error.\x1b[0m Could not find template file at: ${sourcePath}`); - process.exit(1); - } - - try { - fs.copyFileSync(sourcePath, targetPath); - console.log(`\x1b[32m[Tempo Scaffold] Success!\x1b[0m ${successMessage}`); - } catch (err) { - console.error(`\x1b[31m[Tempo Scaffold] Failed to copy file:\x1b[0m ${err.message}`); - process.exit(1); - } -} - -// Route commands -if (command === 'scaffold:config') { - safelyCopy('tempo.config.sample.ts', 'tempo.config.ts', 'Created tempo.config.ts'); -} else if (command === 'scaffold:html') { - safelyCopy('index.sample.html', 'index.html', 'Created index.html boilerplate'); -} else if (command === 'scaffold:all') { - const configExists = fs.existsSync(path.join(cwd, 'tempo.config.ts')); - const htmlExists = fs.existsSync(path.join(cwd, 'index.html')); - if (configExists || htmlExists) { - const existingFiles = [configExists ? 'tempo.config.ts' : null, htmlExists ? 'index.html' : null].filter(Boolean).join(' and '); - console.error(`\x1b[31m[Tempo Scaffold] Aborted.\x1b[0m File(s) already exist: ${existingFiles}`); - console.error(`We refused to overwrite your existing file(s). If you want to scaffold new ones, please delete the existing file(s) first.`); - process.exit(1); - } - safelyCopy('tempo.config.sample.ts', 'tempo.config.ts', 'Created tempo.config.ts'); - safelyCopy('index.sample.html', 'index.html', 'Created index.html boilerplate'); -} else { - console.log(` -\x1b[36m@magmacomputing/tempo\x1b[0m CLI - -Available Commands: - scaffold:config Copies a sample tempo.config.ts into your current directory. - scaffold:html Copies a working HTML sandbox (index.html) into your current directory. - scaffold:all Copies both files. - -Usage: - npx @magmacomputing/tempo -`); -} diff --git a/packages/tempo/package.json b/packages/tempo/package.json index 5efae189..a4b87b37 100644 --- a/packages/tempo/package.json +++ b/packages/tempo/package.json @@ -229,6 +229,8 @@ "@js-temporal/polyfill": "^0.5.1", "@magmacomputing/library": "*", "@rollup/plugin-alias": "^6.0.0", + "@rollup/plugin-terser": "^1.0.0", + "@rollup/plugin-typescript": "^12.3.0", "javascript-obfuscator": "^5.4.3", "magic-string": "^0.30.21", "rollup-plugin-esbuild": "^6.2.1", diff --git a/packages/tempo/rollup.config.js b/packages/tempo/rollup.config.js index 676ea12c..fef4883c 100644 --- a/packages/tempo/rollup.config.js +++ b/packages/tempo/rollup.config.js @@ -4,6 +4,7 @@ import { fileURLToPath } from 'node:url'; import alias from '@rollup/plugin-alias'; import resolve from '@rollup/plugin-node-resolve'; +import terser from '@rollup/plugin-terser'; import esbuild from 'rollup-plugin-esbuild'; import JavaScriptObfuscator from 'javascript-obfuscator'; import MagicString from 'magic-string'; @@ -146,8 +147,7 @@ export default [ { find: '#tempo/license', replacement: path.resolve(__dirname, 'dist/plugin/license/license.validator.js') } ] }), - resolve({ extensions: ['.js', '.ts'] }), - esbuild({ target: 'esnext', minify: false }) + resolve({ extensions: ['.js', '.ts'] }) ], }, @@ -184,7 +184,7 @@ export default [ ] }), resolve({ extensions: ['.js', '.ts'] }), - esbuild({ target: 'esnext', minify: true }) + terser() ], }, @@ -243,7 +243,6 @@ export default [ extensions: ['.js', '.ts'], moduleDirectories: ['node_modules'] }), - esbuild({ target: 'esnext', minify: false }), indentFix() ], } diff --git a/packages/tempo/test/core/__fixtures__/config/tempo.config.js b/packages/tempo/test/core/__fixtures__/config/tempo.config.js deleted file mode 100644 index 2865613a..00000000 --- a/packages/tempo/test/core/__fixtures__/config/tempo.config.js +++ /dev/null @@ -1,8 +0,0 @@ -export default { - timeZone: 'Europe/Paris', - registry: { - periods: { - 'custom-bootstrap-period': '13:00' - } - } -}; From d8d995372839e27852c27092a5c92172cf7c0f8e Mon Sep 17 00:00:00 2001 From: Michael McRae Date: Sun, 12 Jul 2026 12:47:14 +1000 Subject: [PATCH 08/18] PR tempo-fns review --- packages/tempo/doc/2-core-concepts/tempo.registry.md | 7 ++++--- packages/tempo/src/plugin/plugin.type.ts | 2 +- packages/tempo/src/tempo.class.ts | 4 ++-- .../tempo/test/core/__fixtures__/config/tempo.config.js | 8 ++++++++ 4 files changed, 15 insertions(+), 6 deletions(-) create mode 100644 packages/tempo/test/core/__fixtures__/config/tempo.config.js diff --git a/packages/tempo/doc/2-core-concepts/tempo.registry.md b/packages/tempo/doc/2-core-concepts/tempo.registry.md index ede55c36..2c72dee9 100644 --- a/packages/tempo/doc/2-core-concepts/tempo.registry.md +++ b/packages/tempo/doc/2-core-concepts/tempo.registry.md @@ -77,7 +77,8 @@ Tempo leverages several other internal data dictionaries to parse and format dat ### Registry Merge Contracts -Tempo applies different merge behaviors depending on how you inject registry data: +The core internal `registryUpdate()` utility applies an **additive-only** merge strategy. It deeply merges new keys into the registries but safely preserves existing root keys (preventing accidental overrides of built-in definitions). -- **`Tempo.init()` & Plugin Discovery**: These methods apply an **additive-only** merge strategy. They will deeply merge new keys into the registries, but they will **preserve existing root keys** (they do not override built-in core definitions or previously established configurations). -- **`Tempo.extend()`**: This is an **explicit override**. When you pass an options object to `Tempo.extend()`, it takes strict precedence. It will deeply merge *and* safely overwrite existing registry keys, making it the proper tool for forcefully changing standard behaviors. +When you pass an options object to `Tempo.extend()`, it acts differently depending on the data type: +- **Enum/Proxy-Backed Registries:** For explicitly wrapped structures (like `formats` and `locales`), `Tempo.extend()` can be used to forcefully shadow existing entries, making it the proper tool to change standard behaviors. +- **General Registries:** It does not provide a blanket, universal overwrite mechanism for the entire registry system. diff --git a/packages/tempo/src/plugin/plugin.type.ts b/packages/tempo/src/plugin/plugin.type.ts index 07eb3ce5..7d377cb1 100644 --- a/packages/tempo/src/plugin/plugin.type.ts +++ b/packages/tempo/src/plugin/plugin.type.ts @@ -5,7 +5,7 @@ export interface Plugin { name: string; version?: string; - install: (this: T, t: T) => void; + install: (this: T, t: T, options?: any) => void; } /** diff --git a/packages/tempo/src/tempo.class.ts b/packages/tempo/src/tempo.class.ts index f0c179a8..7ac99c2c 100644 --- a/packages/tempo/src/tempo.class.ts +++ b/packages/tempo/src/tempo.class.ts @@ -526,7 +526,7 @@ export class Tempo { !('locales' in arg) && !('options' in arg); - let options = (args.length > 0 && isOptionsArg(args[args.length - 1])) ? args.pop() : undefined; + let options = (args.length > 1 && isOptionsArg(args[args.length - 1])) ? args.pop() : undefined; const licenseKey = options?.license || (args.length === 1 && isObject(args[0]) ? args[0].license : undefined); if (licenseKey) { const state = this[$Internal](); @@ -584,7 +584,7 @@ export class Tempo { Tempo.#versions[`${name}${name.endsWith(suffix) ? '' : suffix}`] = (item as any).version; } - (item as TempoPlugin).install.call(this as any, this); + (item as TempoPlugin).install.call(this as any, this, options); } else if (isObject(item)) { // 1. handle TermPlugin diff --git a/packages/tempo/test/core/__fixtures__/config/tempo.config.js b/packages/tempo/test/core/__fixtures__/config/tempo.config.js new file mode 100644 index 00000000..2865613a --- /dev/null +++ b/packages/tempo/test/core/__fixtures__/config/tempo.config.js @@ -0,0 +1,8 @@ +export default { + timeZone: 'Europe/Paris', + registry: { + periods: { + 'custom-bootstrap-period': '13:00' + } + } +}; From d2f74f185f40336249daff6c718550c924f4b91d Mon Sep 17 00:00:00 2001 From: Michael McRae Date: Sun, 12 Jul 2026 14:54:33 +1000 Subject: [PATCH 09/18] PR 1st review --- package-lock.json | 295 ++++++++++++++++++++++++++++ packages/tempo/.vitepress/config.ts | 3 +- packages/tempo/CHANGELOG.md | 140 +++++++++++++ packages/tempo/src/tempo.class.ts | 5 +- 4 files changed, 439 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index cc078016..fd2bcda8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2326,6 +2326,301 @@ "dev": true, "license": "MIT" }, + "node_modules/@swc/core": { + "version": "1.15.43", + "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.15.43.tgz", + "integrity": "sha512-1CuKjFkPxIgGdeHVuNbkxmBxkcbdc08u0aiI43pFq6yY1tTVKmXT9hFEooyyKs/sJ3xf1GPHyEwTtk9Xl8dvQw==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@swc/counter": "^0.1.3", + "@swc/types": "^0.1.27" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/swc" + }, + "optionalDependencies": { + "@swc/core-darwin-arm64": "1.15.43", + "@swc/core-darwin-x64": "1.15.43", + "@swc/core-linux-arm-gnueabihf": "1.15.43", + "@swc/core-linux-arm64-gnu": "1.15.43", + "@swc/core-linux-arm64-musl": "1.15.43", + "@swc/core-linux-ppc64-gnu": "1.15.43", + "@swc/core-linux-s390x-gnu": "1.15.43", + "@swc/core-linux-x64-gnu": "1.15.43", + "@swc/core-linux-x64-musl": "1.15.43", + "@swc/core-win32-arm64-msvc": "1.15.43", + "@swc/core-win32-ia32-msvc": "1.15.43", + "@swc/core-win32-x64-msvc": "1.15.43" + }, + "peerDependencies": { + "@swc/helpers": ">=0.5.17" + }, + "peerDependenciesMeta": { + "@swc/helpers": { + "optional": true + } + } + }, + "node_modules/@swc/core-darwin-arm64": { + "version": "1.15.43", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.15.43.tgz", + "integrity": "sha512-v1aVuvXdo/BHxJzco9V2xpHrvwWmhfS8t6gziY5wJxd+Z2h8AeJRnAwPD8itCDaGXVBwJ/CaKfxEzTkG0Va0OA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-darwin-x64": { + "version": "1.15.43", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.15.43.tgz", + "integrity": "sha512-lp3d4Lamc8dt5huYdGLSR+9hLxmfr1jb0l+4XXG2zPqZwYWRN9R0U2qYoTrggiU2RWW0oV9VbWM3kBnqIc2kdQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-arm-gnueabihf": { + "version": "1.15.43", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.15.43.tgz", + "integrity": "sha512-JWTQQELtsG5GgphDrr/XqqmM2pDN3cZqbMS0Mrg+iTiXL3F74sn/S2IyYE/5u4h2KLkTf9qQ7dXyxsbx7YzkeA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-arm64-gnu": { + "version": "1.15.43", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.15.43.tgz", + "integrity": "sha512-B4otJRdPWIsmiSBf0uG7Z/+vMWmkufjz5MmYxubwKuZazDW14Zd3symga1N62QR4RT+kEFeHEgsXfZGyn/w0hw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-arm64-musl": { + "version": "1.15.43", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.15.43.tgz", + "integrity": "sha512-6zB6OnpViBxYy4tgY3v2i6AZY9fwkcHZ032UOwtwUuW1d19sdT07qF0kZe6/3UR1tUaK6jjg2rmVcUIBCEYVjQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-ppc64-gnu": { + "version": "1.15.43", + "resolved": "https://registry.npmjs.org/@swc/core-linux-ppc64-gnu/-/core-linux-ppc64-gnu-1.15.43.tgz", + "integrity": "sha512-coxE1ZWdB3uSDVNoEtYNrRi/1epvckZx9cTJ8ICUxTMTxGk+yvQ/Twacp3ruZSaMPGCriUjP86C37VhaT6nyRg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-s390x-gnu": { + "version": "1.15.43", + "resolved": "https://registry.npmjs.org/@swc/core-linux-s390x-gnu/-/core-linux-s390x-gnu-1.15.43.tgz", + "integrity": "sha512-lXfLhs+LpBsD5inuYx+YDH5WsPPBQ95KPUiy8P5wq9ob9xKDZFqwNfU2QW6bGO8NqRO/H9JQomTSt5Yyh+FGfA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-x64-gnu": { + "version": "1.15.43", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.15.43.tgz", + "integrity": "sha512-07XnKwTmKy8TGOZG3D9fRnLWGynxPjwQnZLVmBFbo6F+7vHYzBIOuwXEhemrChBWb6yDNZsVCcMWCPX6FDD2xg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-x64-musl": { + "version": "1.15.43", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.15.43.tgz", + "integrity": "sha512-TJc+bsSIaBh+hZvZ5GRtW/K1bw66TJ9vsUwvVIsZdiWxU5ObLwZvfcnZ3UpgVfMnFibRes9uriJrQNBHEEogRQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-win32-arm64-msvc": { + "version": "1.15.43", + "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.15.43.tgz", + "integrity": "sha512-jfd7s2/bUQYkOHLs+LWQNKZdmDa8+sufKLllhpWAhVQ2GDCwsHe3vR/j+OSiItZNtkzFuaawa3+SAKz9y5gYfw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-win32-ia32-msvc": { + "version": "1.15.43", + "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.15.43.tgz", + "integrity": "sha512-rLAE8JvucqEW1ZGohxPQrQWPBQeJG4+ypKbWfdlU/qmKScvCkxf9/Jxnzki1dkUQCQ7P5Enp13RlvqOlvx/32g==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-win32-x64-msvc": { + "version": "1.15.43", + "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.15.43.tgz", + "integrity": "sha512-h8MLDHZcfIukwQWj03rIJZx1I0E81AYj2X7J/nGErG4nz+QAv6G1Z+peotvinL3lqpbo32tLYSMFo32/ySzxKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/counter": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", + "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==", + "dev": true, + "license": "Apache-2.0", + "peer": true + }, + "node_modules/@swc/types": { + "version": "0.1.27", + "resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.27.tgz", + "integrity": "sha512-K6h3iUlqeM946U4sXFYeahefR1YBbXJvko+hv8WS8/0BNJ4OHiHRywMnQUJCqkR7Y9+hqQ1TvEpiKqUhz7NEFg==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@swc/counter": "^0.1.3" + } + }, "node_modules/@tootallnate/quickjs-emscripten": { "version": "0.23.0", "resolved": "https://registry.npmjs.org/@tootallnate/quickjs-emscripten/-/quickjs-emscripten-0.23.0.tgz", diff --git a/packages/tempo/.vitepress/config.ts b/packages/tempo/.vitepress/config.ts index 4c3067e9..00c39cd4 100644 --- a/packages/tempo/.vitepress/config.ts +++ b/packages/tempo/.vitepress/config.ts @@ -30,7 +30,8 @@ export default defineConfig({ nav: [ { text: 'Guide', link: '/README' }, { text: 'API Reference', link: typedocSidebar[0].items[0].link }, - { text: 'Releases', link: '/doc/8-project-and-support/releases/' } + { text: 'Releases', link: '/doc/8-project-and-support/releases/' }, + { text: 'Tempo-Fns', link: 'https://magmacomputing.github.io/magma/tempo-fns/' } ], sidebar: [ { diff --git a/packages/tempo/CHANGELOG.md b/packages/tempo/CHANGELOG.md index f36f0904..9913eb41 100644 --- a/packages/tempo/CHANGELOG.md +++ b/packages/tempo/CHANGELOG.md @@ -8,6 +8,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [3.8.0] - 2026-07-11 +### Fixed +- **Plugin Argument Parsing**: Hardened `Tempo.extend` parsing logic to ensure single-argument discovery objects are not falsely popped as `options`, and fixed a bug where `options` were dropped during the Namespace Plugin `.install()` lifecycle. +- **Registry Merge Contracts**: Corrected documentation in `tempo.registry.md` to accurately define `registryUpdate()` as additive-only, clarifying that `Tempo.extend()` only shadows explicitly wrapped proxy dictionaries (like `formats`). + ### Added - **Namespace Architecture (`defineNamespace`)**: Officially launched the new Namespace Plugin architecture. This provides a clean mechanism to attach grouped API surfaces (like `t.finance.*`) onto the core Tempo instance without polluting the global scope or the natural-language parsing engine. - **Strict Plugin Discrimination**: Core registration utilities (`definePlugin`, `defineTerm`, `defineModule`, `defineNamespace`) now strictly inject a discriminator `type` key (`'plugin' | 'term' | 'module' | 'namespace'`). This ensures internal registries and debugging tools can accurately categorize plugins without relying on loose structural sniffing. @@ -25,6 +29,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [3.7.0] - 2026-07-08 +### Fixed +- **Plugin Argument Parsing**: Hardened `Tempo.extend` parsing logic to ensure single-argument discovery objects are not falsely popped as `options`, and fixed a bug where `options` were dropped during the Namespace Plugin `.install()` lifecycle. +- **Registry Merge Contracts**: Corrected documentation in `tempo.registry.md` to accurately define `registryUpdate()` as additive-only, clarifying that `Tempo.extend()` only shadows explicitly wrapped proxy dictionaries (like `formats`). + ### Added - **Runtime Versioning Registry**: Introduced a secure, static `Tempo.versions` registry. This provides zero-burden runtime observability of all loaded core modules and community plugins. - **Automated Plugin Versioning**: Community plugins now automatically inject their version via a custom ESBuild virtual module pipeline, eliminating the need for magic strings. Internal bundled terms (like `QuarterTerm`) seamlessly inherit the core `TEMPO_VERSION`. @@ -39,6 +47,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [3.6.0] - 2026-07-05 +### Fixed +- **Plugin Argument Parsing**: Hardened `Tempo.extend` parsing logic to ensure single-argument discovery objects are not falsely popped as `options`, and fixed a bug where `options` were dropped during the Namespace Plugin `.install()` lifecycle. +- **Registry Merge Contracts**: Corrected documentation in `tempo.registry.md` to accurately define `registryUpdate()` as additive-only, clarifying that `Tempo.extend()` only shadows explicitly wrapped proxy dictionaries (like `formats`). + ### Added - **Shorthand Mutation Keys**: Added native support for Tempo's shorthand format tokens (e.g., `mi`, `ss`, `yy`, `ww`) across both `.add()` and `.set()` mutations, streamlining developer experience and aligning TypeScript definitions with the underlying runtime engine. - **Shorthand Duration Keys**: Expanded shorthand token support directly into the `DurationModule`. You can now seamlessly use shorthand keys for duration instantiation (`Tempo.duration({ mi: 5 })`), comparisons (`t.until(other, 'mi')`), and strict balancing (`t.until(other).balance({ largestUnit: 'mi' })`), bringing total API consistency across the core library. @@ -51,6 +63,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [3.5.3] - 2026-07-05 +### Fixed +- **Plugin Argument Parsing**: Hardened `Tempo.extend` parsing logic to ensure single-argument discovery objects are not falsely popped as `options`, and fixed a bug where `options` were dropped during the Namespace Plugin `.install()` lifecycle. +- **Registry Merge Contracts**: Corrected documentation in `tempo.registry.md` to accurately define `registryUpdate()` as additive-only, clarifying that `Tempo.extend()` only shadows explicitly wrapped proxy dictionaries (like `formats`). + ### Added - **Flexible Epoch Getters**: Added a static `Tempo.epoch` getter that perfectly mirrors the instance `.epoch` property, enabling direct retrieval of current Unix timestamps (e.g. `Tempo.epoch.ss`). - **Static Now Modifiers**: Extended the static `Tempo.now(unit)` method to accept optional string units (`'ns'`, `'us'`, `'ms'`, `'ss'`), defaulting to nanosecond precision for strict backwards compatibility. @@ -60,6 +76,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [3.5.2] - 2026-07-04 +### Fixed +- **Plugin Argument Parsing**: Hardened `Tempo.extend` parsing logic to ensure single-argument discovery objects are not falsely popped as `options`, and fixed a bug where `options` were dropped during the Namespace Plugin `.install()` lifecycle. +- **Registry Merge Contracts**: Corrected documentation in `tempo.registry.md` to accurately define `registryUpdate()` as additive-only, clarifying that `Tempo.extend()` only shadows explicitly wrapped proxy dictionaries (like `formats`). + ### Added - **Minified Global Bundles**: The build pipeline now natively produces highly optimized, minified IIFE bundles (`*.min.js`) for both Tempo Core and all Community Plugins, significantly reducing payload size for developers using CDN ` ``` diff --git a/packages/tempo-fns/doc/functions/index.md b/packages/tempo-fns/doc/functions/index.md index 47016fd1..9952d874 100644 --- a/packages/tempo-fns/doc/functions/index.md +++ b/packages/tempo-fns/doc/functions/index.md @@ -4,9 +4,12 @@ Welcome to the `tempo-fns` documentation! This library provides a comprehensive ## Built for Temporal -`tempo-fns` was engineered from the ground up to consume and return native `Temporal` instances. [Temporal](https://tc39.es/proposal-temporal/docs/) is the new global object coming to JavaScript that brings a modern, robust date and time API to the language, resolving decades of frustration with the legacy `Date` object. +`tempo-fns` was engineered from the ground up to support modern date and time objects. For standard calendar operations, `tempo-fns` consumes and returns native `Temporal` instances. [Temporal](https://tc39.es/proposal-temporal/docs/) is the new global object coming to JavaScript that brings a modern, robust date and time API to the language, resolving decades of frustration with the legacy `Date` object. -Because `tempo-fns` expects standard `Temporal` objects (like `Temporal.ZonedDateTime` or `Temporal.PlainDate`), you can use these functions natively in any modern JavaScript environment without requiring bulky adapters, parsers, or conversion layers. +Because these core utilities expect standard `Temporal` objects (like `Temporal.ZonedDateTime` or `Temporal.PlainDate`), you can use them natively in any modern JavaScript environment without requiring bulky adapters, parsers, or conversion layers. + +> [!NOTE] +> Advanced scheduling utilities (such as the `Interval` class) explicitly require `Tempo` instances as inputs, leveraging the `tempo.epoch.ns` high-performance nanosecond identifier for lightning-fast chronological comparisons. ## Better Together with Tempo diff --git a/packages/tempo-fns/doc/preview.html b/packages/tempo-fns/doc/preview.html index 29fbefc1..d99d9308 100644 --- a/packages/tempo-fns/doc/preview.html +++ b/packages/tempo-fns/doc/preview.html @@ -4,7 +4,7 @@ README Preview - + diff --git a/packages/tempo-fns/doc/support.md b/packages/tempo-fns/doc/support.md index 42561d7e..ec042b42 100644 --- a/packages/tempo-fns/doc/support.md +++ b/packages/tempo-fns/doc/support.md @@ -4,7 +4,7 @@ We are building a vibrant ecosystem of developers who are passionate about moder ## Contributing -We welcome community contributions! Whether it's a new astrological utility or a fix to an existing cron parser, please check out our [Contributing Guide](https://github.com/magmacomputing/magma/blob/main/packages/tempo/CONTRIBUTING.md) to get started. +We welcome community contributions! Whether it's a new astrological utility or a fix to an existing cron parser, please check out our [Contributing Guide](https://github.com/magmacomputing/magma/blob/main/packages/tempo-fns/CONTRIBUTING.md) to get started. ## Issues & Support diff --git a/packages/tempo-fns/src/business/workingHoursUntil.ts b/packages/tempo-fns/src/business/workingHoursUntil.ts index d85b9d91..9a7860f4 100644 --- a/packages/tempo-fns/src/business/workingHoursUntil.ts +++ b/packages/tempo-fns/src/business/workingHoursUntil.ts @@ -10,23 +10,29 @@ export type SLAOptions = { // Internal cache for holidays loaded via the async `preloadHolidays` method const remoteHolidaysCache: Map = new Map(); -export const workingHoursUntil = function (this: Tempo, deadline: any) { +/** + * @experimental + */ +export const workingHoursUntil = function (this: Tempo, deadline: Tempo | string): number { // Stub logic for SLA calculation // Production logic would: // 1. Merge options.holidays with remoteHolidaysCache.get(options.region) // 2. Diff `this` to `deadline` and strip out weekends + holidays - return 12; // e.g. 12 working hours + throw new Error('NotImplementedError: SLA calculation logic is under development.'); } export const preloadHolidays = async (region: string, year: number): Promise => { await new Promise(resolve => setTimeout(resolve, 50)); const cacheKey = `${region}-${year}`; - remoteHolidaysCache.set(cacheKey, ['2026-01-01', '2026-12-25']); + remoteHolidaysCache.set(cacheKey, [`${year}-01-01`, `${year}-12-25`]); } declare module '@magmacomputing/tempo/core' { interface Tempo { - /** Calculates the exact number of working hours from this date until the deadline. */ - workingHoursUntil(deadline: any): number; + /** + * @experimental + * Calculates the exact number of working hours from this date until the deadline. + */ + workingHoursUntil(deadline: Tempo | string): number; } } diff --git a/packages/tempo-fns/src/calendar/getISOWeekOfYear.ts b/packages/tempo-fns/src/calendar/getISOWeekOfYear.ts index e6bce090..2577b32e 100644 --- a/packages/tempo-fns/src/calendar/getISOWeekOfYear.ts +++ b/packages/tempo-fns/src/calendar/getISOWeekOfYear.ts @@ -1,13 +1,13 @@ -import { Temporal } from '@js-temporal/polyfill'; - /** compute the ISO week number and ISO week-numbering year for a given ZonedDateTime */ -export function getISOWeekOfYear(zdt: Temporal.ZonedDateTime | Temporal.PlainDate) { +export function getISOWeekOfYear(zdt: any) { + const pd = (zdt.toPlainDate ? zdt.toPlainDate() : zdt).withCalendar('iso8601'); + // ISO 8601 week 1 is the week with the year's first Thursday in it. - const targetThursday = zdt.subtract({ days: zdt.dayOfWeek - 4 }); + const targetThursday = pd.subtract({ days: pd.dayOfWeek - 4 }); const targetYear = targetThursday.year; // January 4th is always in week 1. - const firstThursday = Temporal.PlainDate.from({ year: targetYear, month: 1, day: 4 }); + const firstThursday = targetThursday.with({ month: 1, day: 4 }); const week1Thursday = firstThursday.subtract({ days: firstThursday.dayOfWeek - 4 }); // Compute days between target Thursday and week 1 Thursday diff --git a/packages/tempo-fns/src/index.ts b/packages/tempo-fns/src/index.ts index b8c0b580..500dc99a 100644 --- a/packages/tempo-fns/src/index.ts +++ b/packages/tempo-fns/src/index.ts @@ -7,8 +7,8 @@ export { isFirstDayOfMonth } from './calendar/isFirstDayOfMonth.js'; export { getISOWeekOfYear } from './calendar/getISOWeekOfYear.js'; // --- Scheduling --- -export { nextCron } from './scheduling/cron.js'; -export { prevCron } from './scheduling/cron.js'; +// export { nextCron } from './scheduling/cron.js'; +// export { prevCron } from './scheduling/cron.js'; export { Interval } from './scheduling/Interval.js'; // --- Timezone & Location --- diff --git a/packages/tempo-fns/src/scheduling/Interval.ts b/packages/tempo-fns/src/scheduling/Interval.ts index 938e0735..13257779 100644 --- a/packages/tempo-fns/src/scheduling/Interval.ts +++ b/packages/tempo-fns/src/scheduling/Interval.ts @@ -1,20 +1,29 @@ import type { Tempo } from '@magmacomputing/tempo'; export class Interval { - start: Tempo; - end: Tempo; + readonly #start: Tempo; + readonly #end: Tempo; constructor(start: Tempo, end: Tempo) { const startNs = start.epoch.ns; const endNs = end.epoch.ns; if (endNs < startNs) { - this.start = end; - this.end = start; + this.#start = end; + this.#end = start; } else { - this.start = start; - this.end = end; + this.#start = start; + this.#end = end; } + Object.freeze(this); + } + + get start(): Tempo { + return this.#start; + } + + get end(): Tempo { + return this.#end; } /** Returns true if the specified Tempo instance is within this interval */ diff --git a/packages/tempo-fns/src/scheduling/cron.ts b/packages/tempo-fns/src/scheduling/cron.ts deleted file mode 100644 index 55d59ce5..00000000 --- a/packages/tempo-fns/src/scheduling/cron.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type { Tempo } from '@magmacomputing/tempo/core'; - -export const nextCron = function (this: Tempo, pattern: string) { - return this; -} - -export const prevCron = function (this: Tempo, pattern: string) { - return this; -} - -declare module '@magmacomputing/tempo/core' { - interface Tempo { - /** Shifts to the next occurrence matching the given cron pattern. */ - nextCron(pattern: string): Tempo; - /** Shifts to the previous occurrence matching the given cron pattern. */ - prevCron(pattern: string): Tempo; - } -} diff --git a/packages/tempo-fns/src/timezone/getHemisphere.ts b/packages/tempo-fns/src/timezone/getHemisphere.ts index 9ef8c6a4..c39b7eff 100644 --- a/packages/tempo-fns/src/timezone/getHemisphere.ts +++ b/packages/tempo-fns/src/timezone/getHemisphere.ts @@ -3,12 +3,16 @@ import { getOffsets } from './getOffsets.js'; export type Hemisphere = 'N' | 'S' | 'E'; /** returns the hemisphere ('N'/'S' or 'E' for equator) for a given timezone */ -export function getHemisphere(timeZone: string = Intl.DateTimeFormat().resolvedOptions().timeZone): Hemisphere { +export function getHemisphere(timeZone: string = Intl.DateTimeFormat().resolvedOptions().timeZone): Hemisphere | undefined { try { const { jan, jul } = getOffsets(timeZone); // using default reference-year (2024) for stability - if (jan === jul) return 'E'; - return (jan < jul) ? 'S' : 'N'; + + const southPrefixes = ['Australia', 'Antarctica', 'Africa/Johannesburg', 'America/Buenos_Aires', 'America/Sao_Paulo']; + const isSouthZone = southPrefixes.some(p => timeZone.startsWith(p)); + + if (jan === jul) return isSouthZone ? 'S' : 'E'; + return (jan < jul) ? 'N' : 'S'; } catch { - return 'N'; // Fallback for invalid timezones + return undefined; } } diff --git a/packages/tempo-fns/src/timezone/isDST.ts b/packages/tempo-fns/src/timezone/isDST.ts index 00fb185a..e9c587c1 100644 --- a/packages/tempo-fns/src/timezone/isDST.ts +++ b/packages/tempo-fns/src/timezone/isDST.ts @@ -3,9 +3,17 @@ import { getOffsets } from './getOffsets.js'; /** return whether the given (or current) date is in Daylight Savings */ export function isDST(date?: Temporal.ZonedDateTime | string, timeZone: string = Intl.DateTimeFormat().resolvedOptions().timeZone) { - const zdt = typeof date === 'string' - ? Temporal.ZonedDateTime.from(date) - : (date ?? Temporal.Now.zonedDateTimeISO(timeZone)); + let zdt: Temporal.ZonedDateTime; + + if (typeof date === 'string') { + try { + zdt = Temporal.Instant.from(date).toZonedDateTimeISO(timeZone); + } catch { + zdt = Temporal.PlainDate.from(date).toZonedDateTime(timeZone); + } + } else { + zdt = date ?? Temporal.Now.zonedDateTimeISO(timeZone); + } const { jan, jul } = getOffsets(zdt.timeZoneId, zdt.year); return zdt.offsetNanoseconds !== Math.min(jan, jul); diff --git a/packages/tempo-fns/test/isFirstDayOfMonth.test.ts b/packages/tempo-fns/test/isFirstDayOfMonth.test.ts index b4d95243..3ab6b267 100644 --- a/packages/tempo-fns/test/isFirstDayOfMonth.test.ts +++ b/packages/tempo-fns/test/isFirstDayOfMonth.test.ts @@ -1,4 +1,4 @@ -import { isFirstDayOfMonth } from '../src/isFirstDayOfMonth.js'; +import { isFirstDayOfMonth } from '../src/calendar/isFirstDayOfMonth.js'; import { Tempo } from '@magmacomputing/tempo'; describe('isFirstDayOfMonth', () => { diff --git a/packages/tempo-fns/test/isSameFiscalQuarter.test.ts b/packages/tempo-fns/test/isSameFiscalQuarter.test.ts index 8eaaf00f..992c8a49 100644 --- a/packages/tempo-fns/test/isSameFiscalQuarter.test.ts +++ b/packages/tempo-fns/test/isSameFiscalQuarter.test.ts @@ -1,4 +1,4 @@ -import { isSameFiscalQuarter } from '../src/isSameFiscalQuarter.js'; +import { isSameFiscalQuarter } from '../src/business/isSameFiscalQuarter.js'; import { Tempo } from '@magmacomputing/tempo'; describe('isSameFiscalQuarter', () => { diff --git a/packages/tempo-fns/test/setup.ts b/packages/tempo-fns/test/setup.ts new file mode 100644 index 00000000..e94faa40 --- /dev/null +++ b/packages/tempo-fns/test/setup.ts @@ -0,0 +1,10 @@ +import { Temporal } from '@js-temporal/polyfill'; + +if (typeof globalThis.Temporal === 'undefined') { + Object.defineProperty(globalThis, 'Temporal', { + value: Temporal, + enumerable: false, + configurable: true, + writable: true, + }); +} diff --git a/packages/tempo-fns/vitest.config.ts b/packages/tempo-fns/vitest.config.ts index 5d48ee3d..35f8815a 100644 --- a/packages/tempo-fns/vitest.config.ts +++ b/packages/tempo-fns/vitest.config.ts @@ -4,7 +4,7 @@ import { fileURLToPath } from 'node:url'; import swc from 'unplugin-swc'; const __dirname = dirname(fileURLToPath(import.meta.url)); -const polyfill = resolve(__dirname, '../tempo/bin/temporal-polyfill.ts'); +const polyfill = resolve(__dirname, './test/setup.ts'); export default defineConfig({ esbuild: false, diff --git a/packages/tempo/doc/1-getting-started/installation.md b/packages/tempo/doc/1-getting-started/installation.md index 773d8d36..4740415e 100644 --- a/packages/tempo/doc/1-getting-started/installation.md +++ b/packages/tempo/doc/1-getting-started/installation.md @@ -6,7 +6,7 @@ `Tempo` expects the host environment to provide `Temporal`, either through native runtime support or a user-supplied polyfill. -`Temporal` has reached Stage 4 of the [TC39 standards process](https://tc39.es/proposal-temporal/) (the committee that evolves JavaScript) and is shipping natively in modern browser environments (Deno 2.7+, Chrome 144+, Firefox 139+). For Node.js, native unflagged support is planned for a future release, but it can currently be tested via the `--harmony-temporal` flag. You can verify current browser support at [caniuse.com/temporal](https://caniuse.com/temporal). To avoid needlessly inflating package sizes for modern apps, `Tempo` does not bundle a `Temporal` polyfill by default. +`Temporal` has reached Stage 4 of the [TC39 standards process](https://tc39.es/proposal-temporal/) (the committee that evolves JavaScript) and is shipping natively in modern environments (Deno 2.7+, Node.js 26+, Chrome 144+, Firefox 139+). Note that Safari/iOS currently do not support Temporal natively and require a polyfill. You can verify current browser support at [caniuse.com/temporal](https://caniuse.com/temporal). To avoid needlessly inflating package sizes for modern apps, `Tempo` does not bundle a `Temporal` polyfill by default. ::: warning Node.js environments that ship `Temporal` behind a feature flag (`--harmony-temporal`) may have incomplete implementations. For stability, we strongly recommend using `@js-temporal/polyfill` instead of the native flag until you upgrade to an official unflagged release. @@ -43,16 +43,12 @@ const t = new Tempo('next Friday'); ### Node.js (with Native Temporal) -When native unflagged `Temporal` support is officially released in Node.js, it will be fully supported and enabled by default. - -For current Node.js releases that still ship `Temporal` behind a flag, you can enable it with: +Native unflagged `Temporal` support is available in Node.js 26+ and is enabled by default. ```bash -node --harmony-temporal my-app.js +node my-app.js ``` -*(Note: See the [Temporal Polyfill Note](#temporal-polyfill-note) for warnings about using this flag in production)* - ### Node.js (with Polyfill) The polyfill import shown here is conditional guidance, not required for all environments. @@ -174,6 +170,7 @@ To use **Tempo Premium Plugins** via a static CDN, you simply need to explicitly + +``` diff --git a/packages/tempo-fns/doc/functions/business/index.md b/packages/tempo-fns/doc/functions/business/index.md index 902bd989..2bc1e037 100644 --- a/packages/tempo-fns/doc/functions/business/index.md +++ b/packages/tempo-fns/doc/functions/business/index.md @@ -2,5 +2,41 @@ This directory contains utility functions designed for financial, business, and SLA calculations. ## Exported Functions -- `workingHoursUntil`: Calculates the exact number of SLA-eligible working hours between a date and a deadline. -- `isSameFiscalQuarter`: Determines if a date falls within the same fiscal quarter as a target date. + +### `workingHoursUntil` +Calculates the exact number of SLA-eligible working hours between a date and a deadline. + +```typescript +function workingHoursUntil( + this: Tempo, + deadline: Tempo | string, + options?: SLAOptions +): number; +``` +**Example:** +```typescript +import { Tempo } from '@magmacomputing/tempo'; +import { workingHoursUntil } from '@magmacomputing/tempo-fns/business'; + +Tempo.prototype.workingHoursUntil = workingHoursUntil; + +const start = new Tempo('2026-07-10T10:00:00'); // Friday +const end = new Tempo('2026-07-13T12:00:00'); // Monday + +start.workingHoursUntil(end); // Returns: 10 (hours) +``` + +### `isSameFiscalQuarter` +Determines if a date falls within the same fiscal quarter as a target date. + +```typescript +function isSameFiscalQuarter(date1: Tempo, date2: Tempo): boolean; +``` +**Example:** +```typescript +import { isSameFiscalQuarter } from '@magmacomputing/tempo-fns/business'; + +const d1 = new Tempo('2026-01-15'); +const d2 = new Tempo('2026-03-31'); +isSameFiscalQuarter(d1, d2); // Returns: true +``` diff --git a/packages/tempo-fns/doc/functions/calendar/index.md b/packages/tempo-fns/doc/functions/calendar/index.md index 34c4d0ce..622cfac7 100644 --- a/packages/tempo-fns/doc/functions/calendar/index.md +++ b/packages/tempo-fns/doc/functions/calendar/index.md @@ -2,5 +2,29 @@ This directory contains calendar and date-oriented utility functions (e.g. week of year, first day of month). ## Exported Functions -- `getISOWeekOfYear`: Retrieves the ISO 8601 week number for a given date. -- `isFirstDayOfMonth`: Returns a boolean indicating if the given date is the first day of its calendar month. + +### `getISOWeekOfYear` +Retrieves the ISO 8601 week number for a given date. + +```typescript +function getISOWeekOfYear(zdt: Temporal.ZonedDateTime | Tempo): number; +``` +**Example:** +```typescript +import { getISOWeekOfYear } from '@magmacomputing/tempo-fns/calendar'; + +getISOWeekOfYear(new Tempo('2026-01-01')); // Returns: 1 +``` + +### `isFirstDayOfMonth` +Returns a boolean indicating if the given date is the first day of its calendar month. + +```typescript +function isFirstDayOfMonth(input: { day: number } | Tempo): boolean; +``` +**Example:** +```typescript +import { isFirstDayOfMonth } from '@magmacomputing/tempo-fns/calendar'; + +isFirstDayOfMonth(new Tempo('2026-03-01')); // Returns: true +``` diff --git a/packages/tempo-fns/doc/functions/duration/index.md b/packages/tempo-fns/doc/functions/duration/index.md index 5d4c8d95..d8f13d3f 100644 --- a/packages/tempo-fns/doc/functions/duration/index.md +++ b/packages/tempo-fns/doc/functions/duration/index.md @@ -2,4 +2,19 @@ This directory contains pure functions for normalizing and evaluating durations. ## Exported Functions -- `normaliseFractionalDurations`: Normalizes fractional representations in a duration object. + +### `normaliseFractionalDurations` +Normalizes fractional representations in a duration object payload into smaller discrete units (e.g., 0.5 hours -> 30 minutes). + +```typescript +function normaliseFractionalDurations( + payload: Record +): Record; +``` +**Example:** +```typescript +import { normaliseFractionalDurations } from '@magmacomputing/tempo-fns/duration'; + +normaliseFractionalDurations({ hours: 1.5 }); +// Returns: { hours: 1, minutes: 30 } +``` diff --git a/packages/tempo-fns/doc/functions/scheduling/index.md b/packages/tempo-fns/doc/functions/scheduling/index.md index 1e672e5b..af2023b9 100644 --- a/packages/tempo-fns/doc/functions/scheduling/index.md +++ b/packages/tempo-fns/doc/functions/scheduling/index.md @@ -2,5 +2,47 @@ This directory contains utilities for cron parsing, scheduling intervals, and recurring logic. ## Exported Functions -- `nextCron` / `prevCron`: Evaluates zero-dependency cron expressions to find the next or previous matching date. -- `Interval`: Advanced recurring scheduling loop with precision drift-correction. + +### `nextCron` / `prevCron` +Evaluates zero-dependency cron expressions to find the next or previous matching date. + +```typescript +function nextCron(tempo: Tempo, pattern: string): Tempo; +function prevCron(tempo: Tempo, pattern: string): Tempo; +``` +**Example:** +```typescript +import { nextCron } from '@magmacomputing/tempo-fns/scheduling'; +import { Tempo } from '@magmacomputing/tempo'; + +const now = new Tempo('2026-01-01T08:00:00'); +const next = nextCron(now, '30 9 * * 1-5'); // 9:30 AM, Mon-Fri +``` + +### `Interval` +Advanced recurring scheduling loop with precision drift-correction. + +```typescript +class Interval { + constructor( + start: T, + durationObj: Record, + factory: (iso: string) => T + ); + next(): IteratorResult; + [Symbol.iterator](): IterableIterator; +} +``` +**Example:** +```typescript +import { Interval } from '@magmacomputing/tempo-fns/scheduling'; +import { Tempo } from '@magmacomputing/tempo'; + +const start = new Tempo('2026-01-01T00:00:00'); +const daily = new Interval(start, { days: 1 }, (iso) => new Tempo(iso)); + +for (const date of daily) { + console.log(date.toString()); + if (date.year > 2026) break; +} +``` diff --git a/packages/tempo-fns/doc/functions/timezone/index.md b/packages/tempo-fns/doc/functions/timezone/index.md index d6d64f94..f2e32c01 100644 --- a/packages/tempo-fns/doc/functions/timezone/index.md +++ b/packages/tempo-fns/doc/functions/timezone/index.md @@ -2,7 +2,59 @@ This directory contains utilities for manipulating offsets, calculating daylight savings, and hemisphere tracking. ## Exported Functions -- `isDST`: Determines if a given date is currently observing Daylight Saving Time in its timezone. -- `getOffsets`: Retrieves the exact nanosecond offset from UTC for a timezone. -- `getHemisphere`: Resolves whether a timezone resides in the Northern or Southern hemisphere based on DST shifts. -- `normalizeUtcOffset`: Transforms informal UTC strings into spec-compliant formats. + +### `isDST` +Determines if a given date is currently observing Daylight Saving Time in its timezone. + +```typescript +function isDST( + date?: Temporal.ZonedDateTime | string, + timeZone?: string +): boolean; +``` +**Example:** +```typescript +import { isDST } from '@magmacomputing/tempo-fns/timezone'; + +isDST('2026-07-01T12:00:00', 'America/New_York'); // Returns: true +``` + +### `getOffsets` +Retrieves the exact nanosecond offset from UTC for a timezone. + +```typescript +function getOffsets(timeZone: string, year?: number): number[]; +``` +**Example:** +```typescript +import { getOffsets } from '@magmacomputing/tempo-fns/timezone'; + +getOffsets('Australia/Sydney'); +// Returns array of offset changes for the year +``` + +### `getHemisphere` +Resolves whether a timezone resides in the Northern or Southern hemisphere based on DST shifts. + +```typescript +function getHemisphere(timeZone?: string): 'N' | 'S' | 'E' | undefined; +``` +**Example:** +```typescript +import { getHemisphere } from '@magmacomputing/tempo-fns/timezone'; + +getHemisphere('America/New_York'); // Returns: 'N' +``` + +### `normalizeUtcOffset` +Transforms informal UTC strings into spec-compliant formats. + +```typescript +function normalizeUtcOffset(zone: string): string; +``` +**Example:** +```typescript +import { normalizeUtcOffset } from '@magmacomputing/tempo-fns/timezone'; + +normalizeUtcOffset('UTC+10'); // Returns: '+10:00' +``` diff --git a/packages/tempo-fns/package.json b/packages/tempo-fns/package.json index 1e155c17..534d544b 100644 --- a/packages/tempo-fns/package.json +++ b/packages/tempo-fns/package.json @@ -4,6 +4,15 @@ "description": "Tree-shakeable functional utilities for the Temporal API", "author": "Magma Computing Solutions", "license": "MIT", + "keywords": [ + "temporal", + "date-fns", + "polyfill", + "calendar", + "time", + "scheduling", + "cron" + ], "repository": { "type": "git", "url": "git+https://github.com/magmacomputing/magma.git", diff --git a/packages/tempo-fns/src/business/README.md b/packages/tempo-fns/src/business/README.md index 902bd989..2bc1e037 100644 --- a/packages/tempo-fns/src/business/README.md +++ b/packages/tempo-fns/src/business/README.md @@ -2,5 +2,41 @@ This directory contains utility functions designed for financial, business, and SLA calculations. ## Exported Functions -- `workingHoursUntil`: Calculates the exact number of SLA-eligible working hours between a date and a deadline. -- `isSameFiscalQuarter`: Determines if a date falls within the same fiscal quarter as a target date. + +### `workingHoursUntil` +Calculates the exact number of SLA-eligible working hours between a date and a deadline. + +```typescript +function workingHoursUntil( + this: Tempo, + deadline: Tempo | string, + options?: SLAOptions +): number; +``` +**Example:** +```typescript +import { Tempo } from '@magmacomputing/tempo'; +import { workingHoursUntil } from '@magmacomputing/tempo-fns/business'; + +Tempo.prototype.workingHoursUntil = workingHoursUntil; + +const start = new Tempo('2026-07-10T10:00:00'); // Friday +const end = new Tempo('2026-07-13T12:00:00'); // Monday + +start.workingHoursUntil(end); // Returns: 10 (hours) +``` + +### `isSameFiscalQuarter` +Determines if a date falls within the same fiscal quarter as a target date. + +```typescript +function isSameFiscalQuarter(date1: Tempo, date2: Tempo): boolean; +``` +**Example:** +```typescript +import { isSameFiscalQuarter } from '@magmacomputing/tempo-fns/business'; + +const d1 = new Tempo('2026-01-15'); +const d2 = new Tempo('2026-03-31'); +isSameFiscalQuarter(d1, d2); // Returns: true +``` diff --git a/packages/tempo-fns/src/calendar/README.md b/packages/tempo-fns/src/calendar/README.md index 34c4d0ce..622cfac7 100644 --- a/packages/tempo-fns/src/calendar/README.md +++ b/packages/tempo-fns/src/calendar/README.md @@ -2,5 +2,29 @@ This directory contains calendar and date-oriented utility functions (e.g. week of year, first day of month). ## Exported Functions -- `getISOWeekOfYear`: Retrieves the ISO 8601 week number for a given date. -- `isFirstDayOfMonth`: Returns a boolean indicating if the given date is the first day of its calendar month. + +### `getISOWeekOfYear` +Retrieves the ISO 8601 week number for a given date. + +```typescript +function getISOWeekOfYear(zdt: Temporal.ZonedDateTime | Tempo): number; +``` +**Example:** +```typescript +import { getISOWeekOfYear } from '@magmacomputing/tempo-fns/calendar'; + +getISOWeekOfYear(new Tempo('2026-01-01')); // Returns: 1 +``` + +### `isFirstDayOfMonth` +Returns a boolean indicating if the given date is the first day of its calendar month. + +```typescript +function isFirstDayOfMonth(input: { day: number } | Tempo): boolean; +``` +**Example:** +```typescript +import { isFirstDayOfMonth } from '@magmacomputing/tempo-fns/calendar'; + +isFirstDayOfMonth(new Tempo('2026-03-01')); // Returns: true +``` diff --git a/packages/tempo-fns/src/duration/README.md b/packages/tempo-fns/src/duration/README.md index 5d4c8d95..d8f13d3f 100644 --- a/packages/tempo-fns/src/duration/README.md +++ b/packages/tempo-fns/src/duration/README.md @@ -2,4 +2,19 @@ This directory contains pure functions for normalizing and evaluating durations. ## Exported Functions -- `normaliseFractionalDurations`: Normalizes fractional representations in a duration object. + +### `normaliseFractionalDurations` +Normalizes fractional representations in a duration object payload into smaller discrete units (e.g., 0.5 hours -> 30 minutes). + +```typescript +function normaliseFractionalDurations( + payload: Record +): Record; +``` +**Example:** +```typescript +import { normaliseFractionalDurations } from '@magmacomputing/tempo-fns/duration'; + +normaliseFractionalDurations({ hours: 1.5 }); +// Returns: { hours: 1, minutes: 30 } +``` diff --git a/packages/tempo-fns/src/scheduling/README.md b/packages/tempo-fns/src/scheduling/README.md index 1e672e5b..af2023b9 100644 --- a/packages/tempo-fns/src/scheduling/README.md +++ b/packages/tempo-fns/src/scheduling/README.md @@ -2,5 +2,47 @@ This directory contains utilities for cron parsing, scheduling intervals, and recurring logic. ## Exported Functions -- `nextCron` / `prevCron`: Evaluates zero-dependency cron expressions to find the next or previous matching date. -- `Interval`: Advanced recurring scheduling loop with precision drift-correction. + +### `nextCron` / `prevCron` +Evaluates zero-dependency cron expressions to find the next or previous matching date. + +```typescript +function nextCron(tempo: Tempo, pattern: string): Tempo; +function prevCron(tempo: Tempo, pattern: string): Tempo; +``` +**Example:** +```typescript +import { nextCron } from '@magmacomputing/tempo-fns/scheduling'; +import { Tempo } from '@magmacomputing/tempo'; + +const now = new Tempo('2026-01-01T08:00:00'); +const next = nextCron(now, '30 9 * * 1-5'); // 9:30 AM, Mon-Fri +``` + +### `Interval` +Advanced recurring scheduling loop with precision drift-correction. + +```typescript +class Interval { + constructor( + start: T, + durationObj: Record, + factory: (iso: string) => T + ); + next(): IteratorResult; + [Symbol.iterator](): IterableIterator; +} +``` +**Example:** +```typescript +import { Interval } from '@magmacomputing/tempo-fns/scheduling'; +import { Tempo } from '@magmacomputing/tempo'; + +const start = new Tempo('2026-01-01T00:00:00'); +const daily = new Interval(start, { days: 1 }, (iso) => new Tempo(iso)); + +for (const date of daily) { + console.log(date.toString()); + if (date.year > 2026) break; +} +``` diff --git a/packages/tempo-fns/src/timezone/README.md b/packages/tempo-fns/src/timezone/README.md index d6d64f94..f2e32c01 100644 --- a/packages/tempo-fns/src/timezone/README.md +++ b/packages/tempo-fns/src/timezone/README.md @@ -2,7 +2,59 @@ This directory contains utilities for manipulating offsets, calculating daylight savings, and hemisphere tracking. ## Exported Functions -- `isDST`: Determines if a given date is currently observing Daylight Saving Time in its timezone. -- `getOffsets`: Retrieves the exact nanosecond offset from UTC for a timezone. -- `getHemisphere`: Resolves whether a timezone resides in the Northern or Southern hemisphere based on DST shifts. -- `normalizeUtcOffset`: Transforms informal UTC strings into spec-compliant formats. + +### `isDST` +Determines if a given date is currently observing Daylight Saving Time in its timezone. + +```typescript +function isDST( + date?: Temporal.ZonedDateTime | string, + timeZone?: string +): boolean; +``` +**Example:** +```typescript +import { isDST } from '@magmacomputing/tempo-fns/timezone'; + +isDST('2026-07-01T12:00:00', 'America/New_York'); // Returns: true +``` + +### `getOffsets` +Retrieves the exact nanosecond offset from UTC for a timezone. + +```typescript +function getOffsets(timeZone: string, year?: number): number[]; +``` +**Example:** +```typescript +import { getOffsets } from '@magmacomputing/tempo-fns/timezone'; + +getOffsets('Australia/Sydney'); +// Returns array of offset changes for the year +``` + +### `getHemisphere` +Resolves whether a timezone resides in the Northern or Southern hemisphere based on DST shifts. + +```typescript +function getHemisphere(timeZone?: string): 'N' | 'S' | 'E' | undefined; +``` +**Example:** +```typescript +import { getHemisphere } from '@magmacomputing/tempo-fns/timezone'; + +getHemisphere('America/New_York'); // Returns: 'N' +``` + +### `normalizeUtcOffset` +Transforms informal UTC strings into spec-compliant formats. + +```typescript +function normalizeUtcOffset(zone: string): string; +``` +**Example:** +```typescript +import { normalizeUtcOffset } from '@magmacomputing/tempo-fns/timezone'; + +normalizeUtcOffset('UTC+10'); // Returns: '+10:00' +``` From 03c547c62f9c29f6a9a941b924bbf0056f546f09 Mon Sep 17 00:00:00 2001 From: Michael McRae Date: Mon, 13 Jul 2026 09:49:24 +1000 Subject: [PATCH 13/18] PR 5th review --- .../doc/functions/scheduling/index.md | 31 ++++++------ .../src/business/workingHoursUntil.ts | 50 ++++++++++++------- packages/tempo-fns/src/calendar/README.md | 14 ++++++ .../src/calendar/getISOWeekOfYear.ts | 2 +- .../src/calendar/getPublicHolidays.ts | 42 ++++++++++++++++ .../src/calendar/isFirstDayOfMonth.ts | 1 + packages/tempo-fns/src/index.ts | 4 +- packages/tempo-fns/src/scheduling/Interval.ts | 19 +++---- packages/tempo-fns/src/scheduling/README.md | 31 ++++++------ packages/tempo-fns/src/scheduling/cron.ts | 14 ++++-- packages/tempo-fns/src/support/README.md | 17 +++++++ packages/tempo-fns/src/support/fetch.ts | 18 +++++++ packages/tempo-fns/src/support/intl.ts | 29 +++++++++++ packages/tempo-fns/src/timezone/isDST.ts | 2 +- .../test/scheduling/interval.test.ts | 14 ++++++ 15 files changed, 227 insertions(+), 61 deletions(-) create mode 100644 packages/tempo-fns/src/calendar/getPublicHolidays.ts create mode 100644 packages/tempo-fns/src/support/README.md create mode 100644 packages/tempo-fns/src/support/fetch.ts create mode 100644 packages/tempo-fns/src/support/intl.ts diff --git a/packages/tempo-fns/doc/functions/scheduling/index.md b/packages/tempo-fns/doc/functions/scheduling/index.md index af2023b9..860f1b6a 100644 --- a/packages/tempo-fns/doc/functions/scheduling/index.md +++ b/packages/tempo-fns/doc/functions/scheduling/index.md @@ -20,17 +20,17 @@ const next = nextCron(now, '30 9 * * 1-5'); // 9:30 AM, Mon-Fri ``` ### `Interval` -Advanced recurring scheduling loop with precision drift-correction. +Represents a continuous span of time with start and end boundaries, supporting set operations like intersections and unions. ```typescript class Interval { - constructor( - start: T, - durationObj: Record, - factory: (iso: string) => T - ); - next(): IteratorResult; - [Symbol.iterator](): IterableIterator; + constructor(start: T, end: T); + + contains(point: TemporalPoint): boolean; + overlaps(other: Interval): boolean; + abuts(other: Interval): boolean; + intersection(other: Interval): Interval | null; + union(other: Interval): Interval | null; } ``` **Example:** @@ -38,11 +38,14 @@ class Interval { import { Interval } from '@magmacomputing/tempo-fns/scheduling'; import { Tempo } from '@magmacomputing/tempo'; -const start = new Tempo('2026-01-01T00:00:00'); -const daily = new Interval(start, { days: 1 }, (iso) => new Tempo(iso)); +const start = new Tempo('2026-01-01T08:00'); +const end = new Tempo('2026-01-01T17:00'); +const shift = new Interval(start, end); -for (const date of daily) { - console.log(date.toString()); - if (date.year > 2026) break; -} +const meetingStart = new Tempo('2026-01-01T14:00'); +const meetingEnd = new Tempo('2026-01-01T15:00'); +const meeting = new Interval(meetingStart, meetingEnd); + +console.log(shift.contains(meetingStart)); // true +console.log(shift.overlaps(meeting)); // true ``` diff --git a/packages/tempo-fns/src/business/workingHoursUntil.ts b/packages/tempo-fns/src/business/workingHoursUntil.ts index e74e1a98..019ec418 100644 --- a/packages/tempo-fns/src/business/workingHoursUntil.ts +++ b/packages/tempo-fns/src/business/workingHoursUntil.ts @@ -1,6 +1,8 @@ import type { Tempo } from '@magmacomputing/tempo/core'; -import { Temporal } from '@js-temporal/polyfill'; +import { getPublicHolidays } from '../calendar/getPublicHolidays.js'; +import { getLocale } from '../support/intl.js'; +/** Configuration options for the workingHoursUntil SLA calculation. */ export type SLAOptions = { /** Explicit array of holiday dates to skip (YYYY-MM-DD) */ holidays?: string[]; @@ -18,9 +20,14 @@ const remoteHolidaysCache: Map = new Map(); /** * Calculates the exact number of SLA-eligible working hours from this date until the deadline. */ -export const workingHoursUntil = function (this: Tempo, deadline: Tempo | string, options?: SLAOptions): number { +export const workingHoursUntil = function (this: Tempo, deadline: Tempo.DateTime, options?: SLAOptions): number { const startZdt = this.toDateTime(); - const endZdt = (typeof deadline === 'string' ? this.set(deadline) : deadline).toDateTime(); + + // If it has .toDateTime(), it's already a Tempo instance. Otherwise, let Tempo parse the string/Temporal object. + const endTempo = typeof (deadline as any).toDateTime === 'function' + ? (deadline as Tempo) + : this.set(deadline as any); + const endZdt = endTempo.toDateTime(); let current = startZdt; let target = endZdt; @@ -35,6 +42,9 @@ export const workingHoursUntil = function (this: Tempo, deadline: Tempo | string const startWorkHour = options?.startHour ?? 9; const endWorkHour = options?.endHour ?? 17; + if (startWorkHour >= endWorkHour) + throw new Error('[Tempo-Fns] startHour must be strictly less than endHour'); + const holidaysList = [...(options?.holidays ?? [])]; if (options?.region) { const cachedStart = remoteHolidaysCache.get(`${options.region}-${current.year}`); @@ -63,21 +73,15 @@ export const workingHoursUntil = function (this: Tempo, deadline: Tempo | string const dayWorkEnd = iterDate.toZonedDateTime({ plainTime: { hour: endWorkHour }, timeZone: current.timeZoneId }); let actualStart = dayWorkStart; - if (iterDate.equals(current.toPlainDate()) && current.epochNanoseconds > dayWorkStart.epochNanoseconds) { + if (iterDate.equals(current.toPlainDate()) && current.epochNanoseconds > dayWorkStart.epochNanoseconds) actualStart = current; - } let actualEnd = dayWorkEnd; - if (iterDate.equals(target.toPlainDate()) && target.epochNanoseconds < dayWorkEnd.epochNanoseconds) { + if (iterDate.equals(target.toPlainDate()) && target.epochNanoseconds < dayWorkEnd.epochNanoseconds) actualEnd = target; - } - if (actualStart.epochNanoseconds < dayWorkStart.epochNanoseconds) actualStart = dayWorkStart; - if (actualEnd.epochNanoseconds > dayWorkEnd.epochNanoseconds) actualEnd = dayWorkEnd; - - if (actualStart.epochNanoseconds < actualEnd.epochNanoseconds) { + if (actualStart.epochNanoseconds < actualEnd.epochNanoseconds) totalNs += actualEnd.epochNanoseconds - actualStart.epochNanoseconds; - } } iterDate = iterDate.add({ days: 1 }); @@ -86,10 +90,22 @@ export const workingHoursUntil = function (this: Tempo, deadline: Tempo | string return (Number(totalNs) / 3.6e12) * multiplier; } -export const preloadHolidays = async (region: string, year: number): Promise => { - await new Promise(resolve => setTimeout(resolve, 50)); - const cacheKey = `${region}-${year}`; - remoteHolidaysCache.set(cacheKey, [`${year}-01-01`, `${year}-12-25`]); +/** Preloads and caches holiday data for a region and year. */ +export const preloadHolidays = async (region?: string, year: number = Temporal.Now.plainDateISO().year): Promise => { + const resolvedRegion = region || getLocale().region || 'US'; + const cacheKey = `${resolvedRegion}-${year}`; + + if (remoteHolidaysCache.has(cacheKey)) return; + + try { + const data = await getPublicHolidays(year, resolvedRegion); + const holidays = data.map(h => h.date); + + remoteHolidaysCache.set(cacheKey, holidays); + } catch (error) { + console.warn(`[tempo-fns] Failed to fetch holidays for ${cacheKey}, falling back to none:`, error); + remoteHolidaysCache.set(cacheKey, []); + } } declare module '@magmacomputing/tempo/core' { @@ -97,6 +113,6 @@ declare module '@magmacomputing/tempo/core' { /** * Calculates the exact number of SLA-eligible working hours from this date until the deadline. */ - workingHoursUntil(deadline: Tempo | string, options?: SLAOptions): number; + workingHoursUntil(deadline: Tempo.DateTime, options?: SLAOptions): number; } } diff --git a/packages/tempo-fns/src/calendar/README.md b/packages/tempo-fns/src/calendar/README.md index 622cfac7..48a95f12 100644 --- a/packages/tempo-fns/src/calendar/README.md +++ b/packages/tempo-fns/src/calendar/README.md @@ -28,3 +28,17 @@ import { isFirstDayOfMonth } from '@magmacomputing/tempo-fns/calendar'; isFirstDayOfMonth(new Tempo('2026-03-01')); // Returns: true ``` + +### `getPublicHolidays` +Fetches a list of public holidays for a specific region and year from the Nager.Date API. + +```typescript +function getPublicHolidays(year?: number, region?: string): Promise; +``` +**Example:** +```typescript +import { getPublicHolidays } from '@magmacomputing/tempo-fns/calendar'; + +const holidays = await getPublicHolidays(2026, 'US'); +console.log(holidays[0].name); // "New Year's Day" +``` diff --git a/packages/tempo-fns/src/calendar/getISOWeekOfYear.ts b/packages/tempo-fns/src/calendar/getISOWeekOfYear.ts index 2577b32e..c762888a 100644 --- a/packages/tempo-fns/src/calendar/getISOWeekOfYear.ts +++ b/packages/tempo-fns/src/calendar/getISOWeekOfYear.ts @@ -1,4 +1,4 @@ -/** compute the ISO week number and ISO week-numbering year for a given ZonedDateTime */ +/** Computes the ISO week number and ISO week-numbering year for a given date. */ export function getISOWeekOfYear(zdt: any) { const pd = (zdt.toPlainDate ? zdt.toPlainDate() : zdt).withCalendar('iso8601'); diff --git a/packages/tempo-fns/src/calendar/getPublicHolidays.ts b/packages/tempo-fns/src/calendar/getPublicHolidays.ts new file mode 100644 index 00000000..478459c8 --- /dev/null +++ b/packages/tempo-fns/src/calendar/getPublicHolidays.ts @@ -0,0 +1,42 @@ +import { fetchWithTimeout } from '../support/fetch.js'; +import { getLocale } from '../support/intl.js'; + +export const holidayURL = 'https://date.nager.at/api/v3/PublicHolidays'; + +/** Represents a public holiday payload returned from the Nager.Date API. */ +export type PublicHoliday = { + date: string; + localName: string; + name: string; + countryCode: string; + fixed: boolean; + global: boolean; + counties: string[] | null; + launchYear: number | null; + types: string[]; +} + +const holidaysCache = new Map(); + +/** + * Fetches public holidays for a given year and region from the Nager.Date API. + */ +export const getPublicHolidays = async ( + year: number = Temporal.Now.plainDateISO().year, + region?: string +): Promise => { + const resolvedRegion = region || getLocale().region || 'US'; + const cacheKey = `${year}-${resolvedRegion}`; + + if (holidaysCache.has(cacheKey)) return holidaysCache.get(cacheKey)!; + + // Nager.Date expects a 2-letter ISO 3166-1 alpha-2 country code (e.g. 'US', not 'en-US' or 'US-NY'). + // If a subdivision like 'AU-NSW' is provided, we extract the country code 'AU'. + const countryCode = resolvedRegion.split('-')[0].toUpperCase(); + + const response = await fetchWithTimeout(`${holidayURL}/${year}/${countryCode}`, 2000); + const data = await response.json(); + + holidaysCache.set(cacheKey, data); + return data; +} diff --git a/packages/tempo-fns/src/calendar/isFirstDayOfMonth.ts b/packages/tempo-fns/src/calendar/isFirstDayOfMonth.ts index 2beff298..09fd89ab 100644 --- a/packages/tempo-fns/src/calendar/isFirstDayOfMonth.ts +++ b/packages/tempo-fns/src/calendar/isFirstDayOfMonth.ts @@ -5,6 +5,7 @@ interface TemporalLike { day: number; } +/** Determines if the given date falls on the first day of the month. */ export const isFirstDayOfMonth = (input: TemporalLike | Tempo): boolean => { // Tempo exposes `.day` directly, making it natively compatible // with Temporal's duck-typing! diff --git a/packages/tempo-fns/src/index.ts b/packages/tempo-fns/src/index.ts index b8c0b580..987d663f 100644 --- a/packages/tempo-fns/src/index.ts +++ b/packages/tempo-fns/src/index.ts @@ -3,12 +3,12 @@ export { isSameFiscalQuarter } from './business/isSameFiscalQuarter.js'; export { workingHoursUntil, type SLAOptions, preloadHolidays } from './business/workingHoursUntil.js'; // --- Calendar --- +export { getPublicHolidays, type PublicHoliday } from './calendar/getPublicHolidays.js'; export { isFirstDayOfMonth } from './calendar/isFirstDayOfMonth.js'; export { getISOWeekOfYear } from './calendar/getISOWeekOfYear.js'; // --- Scheduling --- -export { nextCron } from './scheduling/cron.js'; -export { prevCron } from './scheduling/cron.js'; +export { nextCron, prevCron } from './scheduling/cron.js'; export { Interval } from './scheduling/Interval.js'; // --- Timezone & Location --- diff --git a/packages/tempo-fns/src/scheduling/Interval.ts b/packages/tempo-fns/src/scheduling/Interval.ts index 36560ddf..707a4a39 100644 --- a/packages/tempo-fns/src/scheduling/Interval.ts +++ b/packages/tempo-fns/src/scheduling/Interval.ts @@ -2,12 +2,13 @@ import type { Tempo } from '@magmacomputing/tempo'; export type TemporalPoint = Tempo | { epochNanoseconds: bigint }; -function getNs(point: any): bigint { - if (point?.epoch?.ns !== undefined) return point.epoch.ns as bigint; - if (point?.epochNanoseconds !== undefined) return point.epochNanoseconds as bigint; +function getNs(point: TemporalPoint | unknown): bigint { + const ns = (point as any)?.epoch?.ns ?? (point as any)?.epochNanoseconds; + if (typeof ns === 'bigint') return ns; throw new TypeError('Invalid TemporalPoint: missing epoch.ns or epochNanoseconds'); } +/** Represents a continuous span of time with start and end boundaries, supporting set operations. */ export class Interval { readonly #start: T; readonly #end: T; @@ -51,18 +52,18 @@ export class Interval { } /** Returns the intersection of this interval and another, or null if they do not overlap */ - intersection(other: Interval): Interval | null { + intersection(other: Interval): Interval | null { if (!this.overlaps(other)) return null; - const maxStart = getNs(this.start) > getNs(other.start) ? this.start : other.start; - const minEnd = getNs(this.end) < getNs(other.end) ? this.end : other.end; + const maxStart = getNs(this.start) > getNs(other.start) ? this.start : (other.start as T); + const minEnd = getNs(this.end) < getNs(other.end) ? this.end : (other.end as T); return new Interval(maxStart, minEnd); } /** Returns the union of this interval and another, or null if they do not overlap/abut */ - union(other: Interval): Interval | null { + union(other: Interval): Interval | null { if (!this.overlaps(other) && !this.abuts(other)) return null; - const minStart = getNs(this.start) < getNs(other.start) ? this.start : other.start; - const maxEnd = getNs(this.end) > getNs(other.end) ? this.end : other.end; + const minStart = getNs(this.start) < getNs(other.start) ? this.start : (other.start as T); + const maxEnd = getNs(this.end) > getNs(other.end) ? this.end : (other.end as T); return new Interval(minStart, maxEnd); } } diff --git a/packages/tempo-fns/src/scheduling/README.md b/packages/tempo-fns/src/scheduling/README.md index af2023b9..860f1b6a 100644 --- a/packages/tempo-fns/src/scheduling/README.md +++ b/packages/tempo-fns/src/scheduling/README.md @@ -20,17 +20,17 @@ const next = nextCron(now, '30 9 * * 1-5'); // 9:30 AM, Mon-Fri ``` ### `Interval` -Advanced recurring scheduling loop with precision drift-correction. +Represents a continuous span of time with start and end boundaries, supporting set operations like intersections and unions. ```typescript class Interval { - constructor( - start: T, - durationObj: Record, - factory: (iso: string) => T - ); - next(): IteratorResult; - [Symbol.iterator](): IterableIterator; + constructor(start: T, end: T); + + contains(point: TemporalPoint): boolean; + overlaps(other: Interval): boolean; + abuts(other: Interval): boolean; + intersection(other: Interval): Interval | null; + union(other: Interval): Interval | null; } ``` **Example:** @@ -38,11 +38,14 @@ class Interval { import { Interval } from '@magmacomputing/tempo-fns/scheduling'; import { Tempo } from '@magmacomputing/tempo'; -const start = new Tempo('2026-01-01T00:00:00'); -const daily = new Interval(start, { days: 1 }, (iso) => new Tempo(iso)); +const start = new Tempo('2026-01-01T08:00'); +const end = new Tempo('2026-01-01T17:00'); +const shift = new Interval(start, end); -for (const date of daily) { - console.log(date.toString()); - if (date.year > 2026) break; -} +const meetingStart = new Tempo('2026-01-01T14:00'); +const meetingEnd = new Tempo('2026-01-01T15:00'); +const meeting = new Interval(meetingStart, meetingEnd); + +console.log(shift.contains(meetingStart)); // true +console.log(shift.overlaps(meeting)); // true ``` diff --git a/packages/tempo-fns/src/scheduling/cron.ts b/packages/tempo-fns/src/scheduling/cron.ts index 3039258c..b019d974 100644 --- a/packages/tempo-fns/src/scheduling/cron.ts +++ b/packages/tempo-fns/src/scheduling/cron.ts @@ -22,21 +22,29 @@ function parseCronField(field: string, min: number, max: number): CronField { if (part.includes('/')) { const [range, stepStr] = part.split('/'); const step = parseInt(stepStr, 10); + if (isNaN(step) || step <= 0) + throw new Error(`[Tempo-Fns] Invalid step value: ${stepStr}`); + let start = min; let end = max; if (range !== '*') { const rangeParts = range.split('-'); start = parseInt(rangeParts[0], 10); end = rangeParts.length > 1 ? parseInt(rangeParts[1], 10) : start; + if (start > end) + throw new Error(`[Tempo-Fns] Invalid range: ${range}`); + } for (let i = start; i <= end; i += step) { allowed.add(i); } } else if (part.includes('-')) { const [start, end] = part.split('-').map(Number); - for (let i = start; i <= end; i++) { + if (start > end) + throw new Error(`[Tempo-Fns] Invalid range: ${part}`); + + for (let i = start; i <= end; i++) allowed.add(i); - } } else { allowed.add(parseInt(part, 10)); } @@ -61,7 +69,7 @@ export function parseCron(pattern: string): CronSchedule { function matchesDay(schedule: CronSchedule, current: Temporal.ZonedDateTime): boolean { const domMatch = schedule.daysOfMonth.allowed.has(current.day); - const dow = current.dayOfWeek; + const dow = current.dayOfWeek; const dowMatch = schedule.daysOfWeek.allowed.has(dow) || (dow === 7 && schedule.daysOfWeek.allowed.has(0)); if (schedule.daysOfMonth.restricted && schedule.daysOfWeek.restricted) { diff --git a/packages/tempo-fns/src/support/README.md b/packages/tempo-fns/src/support/README.md new file mode 100644 index 00000000..c2f5c839 --- /dev/null +++ b/packages/tempo-fns/src/support/README.md @@ -0,0 +1,17 @@ +# Support Utilities +This directory contains internal support and generic networking tools. **Functions exported from this directory are internal and should not be exported from the public barrel (`src/index.ts`).** + +## Internal Functions + +### `fetchWithTimeout` +A generic `fetch` wrapper that enforces a timeout via `AbortController`. + +```typescript +function fetchWithTimeout(url: string, timeoutMs?: number, options?: RequestInit): Promise; +``` +**Example (Internal Usage):** +```typescript +import { fetchWithTimeout } from '../support/fetch.js'; + +const response = await fetchWithTimeout('https://api.example.com', 2000); +``` diff --git a/packages/tempo-fns/src/support/fetch.ts b/packages/tempo-fns/src/support/fetch.ts new file mode 100644 index 00000000..1fad05ab --- /dev/null +++ b/packages/tempo-fns/src/support/fetch.ts @@ -0,0 +1,18 @@ +/** + * A generic fetch wrapper that adds a timeout using AbortController. + */ +export const fetchWithTimeout = async (url: string, timeoutMs = 2000, options: RequestInit = {}): Promise => { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeoutMs); + + const signal = options.signal + ? AbortSignal.any([options.signal, controller.signal]) + : controller.signal; + + const response = await fetch(url, { ...options, signal }); + clearTimeout(timeoutId); + + if (!response.ok) throw new Error(`API returned ${response.status} for ${url}`); + + return response; +} diff --git a/packages/tempo-fns/src/support/intl.ts b/packages/tempo-fns/src/support/intl.ts new file mode 100644 index 00000000..49002a43 --- /dev/null +++ b/packages/tempo-fns/src/support/intl.ts @@ -0,0 +1,29 @@ +let dateTimeFormatCache: Intl.ResolvedDateTimeFormatOptions | undefined; + +/** + * Returns the resolved environment DateTimeFormat options, caching them for performance. + */ +export const getDateTimeFormatOptions = (): Intl.ResolvedDateTimeFormatOptions => { + if (!dateTimeFormatCache) + dateTimeFormatCache = Intl.DateTimeFormat().resolvedOptions(); + + return dateTimeFormatCache; +} + +const localeInstanceCache = new Map(); + +/** + * Returns a cached Intl.Locale instance for the given locale string. + * Defaults to the environment locale if no string is provided. + */ +export const getLocale = (localeStr?: string): Intl.Locale => { + const key = localeStr || getDateTimeFormatOptions().locale; + + let instance = localeInstanceCache.get(key); + if (!instance) { + instance = new Intl.Locale(key); + localeInstanceCache.set(key, instance); + } + + return instance; +} diff --git a/packages/tempo-fns/src/timezone/isDST.ts b/packages/tempo-fns/src/timezone/isDST.ts index e9c587c1..7d39792d 100644 --- a/packages/tempo-fns/src/timezone/isDST.ts +++ b/packages/tempo-fns/src/timezone/isDST.ts @@ -1,7 +1,7 @@ import { Temporal } from '@js-temporal/polyfill'; import { getOffsets } from './getOffsets.js'; -/** return whether the given (or current) date is in Daylight Savings */ +/** Determines whether the given date is in Daylight Savings Time (DST). */ export function isDST(date?: Temporal.ZonedDateTime | string, timeZone: string = Intl.DateTimeFormat().resolvedOptions().timeZone) { let zdt: Temporal.ZonedDateTime; diff --git a/packages/tempo-fns/test/scheduling/interval.test.ts b/packages/tempo-fns/test/scheduling/interval.test.ts index c021d8bd..ed3a85e6 100644 --- a/packages/tempo-fns/test/scheduling/interval.test.ts +++ b/packages/tempo-fns/test/scheduling/interval.test.ts @@ -65,4 +65,18 @@ describe('Interval', () => { expect(union!.start.epoch.ns).toBe(new Tempo('2026-07-01').epoch.ns); expect(union!.end.epoch.ns).toBe(new Tempo('2026-07-15').epoch.ns); }); + + it('intersection should return null if not overlapping', () => { + const i1 = new Interval(new Tempo('2026-07-01'), new Tempo('2026-07-10')); + const i2 = new Interval(new Tempo('2026-07-15'), new Tempo('2026-07-20')); + + expect(i1.intersection(i2)).toBeNull(); + }); + + it('union should return null if not overlapping and not abutting', () => { + const i1 = new Interval(new Tempo('2026-07-01'), new Tempo('2026-07-10')); + const i2 = new Interval(new Tempo('2026-07-15'), new Tempo('2026-07-20')); + + expect(i1.union(i2)).toBeNull(); + }); }); From ff0e6b9b6981cd59fe8fb15be240b15785f9b58e Mon Sep 17 00:00:00 2001 From: Michael McRae Date: Mon, 13 Jul 2026 11:44:21 +1000 Subject: [PATCH 14/18] new Tempo.Interval --- packages/tempo-fns/CHANGELOG.md | 3 +- packages/tempo-fns/src/index.ts | 2 +- packages/tempo/.vitepress/config.ts | 3 +- packages/tempo/CHANGELOG.md | 1 + .../doc/2-core-concepts/tempo.interval.md | 99 +++++++++++++++++++ .../8-project-and-support/releases/v3.x.md | 7 +- packages/tempo/src/core.index.ts | 1 + .../src/interval.class.ts} | 53 +++++++--- packages/tempo/src/tempo.class.ts | 10 ++ packages/tempo/src/tempo.index.ts | 1 + .../test/core}/interval.test.ts | 56 +++++++++-- 11 files changed, 207 insertions(+), 29 deletions(-) create mode 100644 packages/tempo/doc/2-core-concepts/tempo.interval.md rename packages/{tempo-fns/src/scheduling/Interval.ts => tempo/src/interval.class.ts} (53%) rename packages/{tempo-fns/test/scheduling => tempo/test/core}/interval.test.ts (58%) diff --git a/packages/tempo-fns/CHANGELOG.md b/packages/tempo-fns/CHANGELOG.md index 286b896c..8beebe24 100644 --- a/packages/tempo-fns/CHANGELOG.md +++ b/packages/tempo-fns/CHANGELOG.md @@ -12,5 +12,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Business Functions**: `workingHoursUntil`, `isSameFiscalQuarter`. - **Calendar Functions**: `getISOWeekOfYear`, `isFirstDayOfMonth`. - **Duration Functions**: `normaliseFractionalDurations`. -- **Scheduling Functions**: `nextCron`, `prevCron`, `Interval`. +- **Scheduling Functions**: `nextCron`, `prevCron`. +- **Interval Provision**: `Interval` is provided by the Tempo core and is transparently re-exported from `@magmacomputing/tempo`. - **Timezone Functions**: `isDST`, `getOffsets`, `getHemisphere`, `normalizeUtcOffset`. diff --git a/packages/tempo-fns/src/index.ts b/packages/tempo-fns/src/index.ts index 987d663f..4e6594fe 100644 --- a/packages/tempo-fns/src/index.ts +++ b/packages/tempo-fns/src/index.ts @@ -9,7 +9,7 @@ export { getISOWeekOfYear } from './calendar/getISOWeekOfYear.js'; // --- Scheduling --- export { nextCron, prevCron } from './scheduling/cron.js'; -export { Interval } from './scheduling/Interval.js'; +export { Interval } from '@magmacomputing/tempo'; // --- Timezone & Location --- export { isDST } from './timezone/isDST.js'; diff --git a/packages/tempo/.vitepress/config.ts b/packages/tempo/.vitepress/config.ts index 2c87f660..32103178 100644 --- a/packages/tempo/.vitepress/config.ts +++ b/packages/tempo/.vitepress/config.ts @@ -50,7 +50,8 @@ export default defineConfig({ { text: 'Smart Parsing', link: '/doc/2-core-concepts/tempo.parse' }, { text: 'Smart Formatting', link: '/doc/2-core-concepts/tempo.format' }, { text: 'Layout Patterns', link: '/doc/2-core-concepts/tempo.layout' }, - { text: 'Duration Logic', link: '/doc/2-core-concepts/tempo.duration' } + { text: 'Duration Logic', link: '/doc/2-core-concepts/tempo.duration' }, + { text: 'Intervals', link: '/doc/2-core-concepts/tempo.interval' } ] }, { diff --git a/packages/tempo/CHANGELOG.md b/packages/tempo/CHANGELOG.md index b6e0916f..f67156d1 100644 --- a/packages/tempo/CHANGELOG.md +++ b/packages/tempo/CHANGELOG.md @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Registry Merge Contracts**: Corrected documentation in `tempo.registry.md` to accurately define `registryUpdate()` as additive-only, clarifying that `Tempo.extend()` only shadows explicitly wrapped proxy dictionaries (like `formats`). ### Added +- **Interval Primitive**: Introduced `Interval` as a core primitive in the `@magmacomputing/tempo` package, accessible statically via `Tempo.Interval` or as a decoupled named export. Provides robust `overlaps`, `abuts`, `contains`, `union`, and `intersection` capabilities for native Temporal and Tempo objects. - **Namespace Architecture (`defineNamespace`)**: Officially launched the new Namespace Plugin architecture. This provides a clean mechanism to attach grouped API surfaces (like `t.finance.*`) onto the core Tempo instance without polluting the global scope or the natural-language parsing engine. - **Strict Plugin Discrimination**: Core registration utilities (`definePlugin`, `defineTerm`, `defineModule`, `defineNamespace`) now strictly inject a discriminator `type` key (`'plugin' | 'term' | 'module' | 'namespace'`). This ensures internal registries and debugging tools can accurately categorize plugins without relying on loose structural sniffing. - **Finance Sandbox (`@magmacomputing/tempo-plugin-finance`)**: Introduced the community `finance` package as the official reference implementation for Namespace plugins, complete with best-practice dual-build (ESM/DTS) architectures using `tsup`. diff --git a/packages/tempo/doc/2-core-concepts/tempo.interval.md b/packages/tempo/doc/2-core-concepts/tempo.interval.md new file mode 100644 index 00000000..8c72c76e --- /dev/null +++ b/packages/tempo/doc/2-core-concepts/tempo.interval.md @@ -0,0 +1,99 @@ +# Interval + +In the realm of time mathematics, an **Interval** is a continuous segment of time bounded by two fixed points (a start and an end). While a `Duration` represents an *amount* of time (e.g., "5 hours"), an `Interval` is firmly anchored to the timeline (e.g., "9:00 AM to 2:00 PM"). + +The official [ECMAScript Temporal proposal](https://tc39.es/proposal-temporal/docs/) currently lacks a native `Interval` type. Tempo bridges this gap by providing a mathematically pure, highly optimized `Interval` primitive directly in the core package. + +## Instantiating an Interval + +You can access the `Interval` class either statically through the `Tempo` namespace (for developer convenience) or as a decoupled named export (for strict tree-shaking). + +### 1. The Ergonomic Approach (Attached to Tempo) +The easiest way to use `Interval` is directly from the `Tempo` class. + +```typescript +import { Tempo } from '@magmacomputing/tempo'; + +const meeting = new Tempo.Interval( + new Tempo('2026-07-15T14:00:00Z'), + new Tempo('2026-07-15T15:00:00Z') +); +``` + +### 2. The Purist Approach (Tree-Shakeable Export) +If you are strictly using native `Temporal` polyfill objects and want to avoid bundling the entire `Tempo` class, you can import `Interval` directly. + +```typescript +import { Interval } from '@magmacomputing/tempo'; +import { Temporal } from '@js-temporal/polyfill'; + +const meeting = new Interval( + Temporal.Instant.from('2026-07-15T14:00:00Z'), + Temporal.Instant.from('2026-07-15T15:00:00Z') +); +``` +*Note: Because `Interval` uses duck-typing to read `epochNanoseconds`, it natively supports all Temporal point-in-time types (`Instant`, `ZonedDateTime`, etc.) right out of the box.* + +## Set Operations + +The `Interval` class provides powerful, lightning-fast methods for evaluating time ranges. + +### `overlaps()` +Checks if two intervals share any common timeline segment. Perfect for finding meeting scheduling conflicts. + +```typescript +const bookedSlot = new Tempo.Interval( + new Tempo('2026-07-15T14:30:00Z'), + new Tempo('2026-07-15T16:00:00Z') +); + +if (meeting.overlaps(bookedSlot)) { + throw new Error('This time slot is already taken!'); +} +``` + +### `contains()` +Checks if a specific point in time falls within the boundaries of the interval (inclusive of `start`, exclusive of `end`). Useful for business-hours or promotional-period evaluations. + +```typescript +const businessHours = new Tempo.Interval( + new Tempo('09:00'), + new Tempo('17:00') +); + +if (businessHours.contains(Tempo.now())) { + console.log("We are open!"); +} +``` + +### `abuts()` +Checks if one interval ends *exactly* when another begins. + +```typescript +const shift1 = new Tempo.Interval(new Tempo('09:00'), new Tempo('13:00')); +const shift2 = new Tempo.Interval(new Tempo('13:00'), new Tempo('17:00')); + +shift1.abuts(shift2); // true +``` + +### `intersection()` +Returns a new `Interval` representing the shared, overlapping time between two intervals. Returns `null` if they do not overlap. Useful for finding mutual availability. + +```typescript +const aliceFreeTime = new Tempo.Interval(new Tempo('09:00'), new Tempo('12:00')); +const bobFreeTime = new Tempo.Interval(new Tempo('11:00'), new Tempo('14:00')); + +const mutualFreeTime = aliceFreeTime.intersection(bobFreeTime); +// Interval: 11:00 to 12:00 +``` + +### `union()` +Returns a new `Interval` that spans the entire duration of two overlapping or abutting intervals. Returns `null` if there is a gap between them. + +```typescript +const shift1 = new Tempo.Interval(new Tempo('09:00'), new Tempo('13:00')); +const shift2 = new Tempo.Interval(new Tempo('13:00'), new Tempo('17:00')); + +const fullDay = shift1.union(shift2); +// Interval: 09:00 to 17:00 +``` diff --git a/packages/tempo/doc/8-project-and-support/releases/v3.x.md b/packages/tempo/doc/8-project-and-support/releases/v3.x.md index 534b9b97..96467236 100644 --- a/packages/tempo/doc/8-project-and-support/releases/v3.x.md +++ b/packages/tempo/doc/8-project-and-support/releases/v3.x.md @@ -2,7 +2,12 @@ ## [v3.8.0] - 2026-07-11 -### ✨ What's New β€” Namespace Architecture +### ✨ What's New + +**Interval Primitive** +Introduced `Interval` as a first-class, tree-shakeable core primitive. It provides mathematically pure, high-performance boundary evaluations (overlaps, abuts, contains) and set operations (union, intersection) for Temporal points. `Interval` is accessible ergonomically via `Tempo.Interval` or independently as a named export for strict tree-shaking purists. + +**Namespace Architecture** Introduced the `defineNamespace` plugin factory. While *Terms* (like `quarter` or `season`) teach Tempo how to parse natural language, *Namespaces* provide a clean way to attach grouped business logic directly to the Tempo instance without polluting the global scope. For example, `t.finance.isFiscalYearStart()` or `t.finance.taxYear` keeps financial computations neatly isolated from standard date mechanics. ### πŸ—οΈ Internal Refactoring diff --git a/packages/tempo/src/core.index.ts b/packages/tempo/src/core.index.ts index b127d1d7..41fb862e 100644 --- a/packages/tempo/src/core.index.ts +++ b/packages/tempo/src/core.index.ts @@ -5,5 +5,6 @@ getRuntime().modules['Tempo'] = Tempo; export { enums, Token, Snippet, Match, Default, Guard } from '#tempo/support'; +export { Interval } from './interval.class.js'; export * from './tempo.class.js'; export default Tempo; diff --git a/packages/tempo-fns/src/scheduling/Interval.ts b/packages/tempo/src/interval.class.ts similarity index 53% rename from packages/tempo-fns/src/scheduling/Interval.ts rename to packages/tempo/src/interval.class.ts index 707a4a39..b4823e57 100644 --- a/packages/tempo-fns/src/scheduling/Interval.ts +++ b/packages/tempo/src/interval.class.ts @@ -1,7 +1,12 @@ -import type { Tempo } from '@magmacomputing/tempo'; +import type { Tempo } from './tempo.class.js'; +import { Immutable } from '#library/class.library.js'; export type TemporalPoint = Tempo | { epochNanoseconds: bigint }; +// Tempo limits (Year 1000 to Year 9999) used for open-ended boundaries +const MIN_TEMPO = -30610224000000000000n; +const MAX_TEMPO = 253402300799999999999n; + function getNs(point: TemporalPoint | unknown): bigint { const ns = (point as any)?.epoch?.ns ?? (point as any)?.epochNanoseconds; if (typeof ns === 'bigint') return ns; @@ -9,61 +14,77 @@ function getNs(point: TemporalPoint | unknown): bigint { } /** Represents a continuous span of time with start and end boundaries, supporting set operations. */ +@Immutable export class Interval { - readonly #start: T; - readonly #end: T; + readonly #start: T | null; + readonly #end: T | null; + readonly #startNs: bigint; + readonly #endNs: bigint; - constructor(start: T, end: T) { - const startNs = getNs(start); - const endNs = getNs(end); + constructor(start: T | null, end: T | null) { + const startNs = start === null ? MIN_TEMPO : getNs(start); + const endNs = end === null ? MAX_TEMPO : getNs(end); if (endNs < startNs) { this.#start = end; this.#end = start; + this.#startNs = endNs; + this.#endNs = startNs; } else { this.#start = start; this.#end = end; + this.#startNs = startNs; + this.#endNs = endNs; } - Object.freeze(this); + + return Object.freeze(this) as this; } - get start(): T { + get start(): T | null { return this.#start; } - get end(): T { + get end(): T | null { return this.#end; } + get startNs(): bigint { + return this.#startNs; + } + + get endNs(): bigint { + return this.#endNs; + } + /** Returns true if the specified temporal point is within this interval */ contains(point: TemporalPoint): boolean { const t = getNs(point); - return t >= getNs(this.start) && t < getNs(this.end); + return t >= this.#startNs && t < this.#endNs; } /** Returns true if this interval overlaps with another interval */ overlaps(other: Interval): boolean { - return getNs(this.start) < getNs(other.end) && getNs(this.end) > getNs(other.start); + return this.#startNs < other.#endNs && this.#endNs > other.#startNs; } /** Returns true if this interval starts exactly when the other ends, or vice versa */ abuts(other: Interval): boolean { - return getNs(this.end) === getNs(other.start) || getNs(this.start) === getNs(other.end); + return this.#endNs === other.#startNs || this.#startNs === other.#endNs; } /** Returns the intersection of this interval and another, or null if they do not overlap */ intersection(other: Interval): Interval | null { if (!this.overlaps(other)) return null; - const maxStart = getNs(this.start) > getNs(other.start) ? this.start : (other.start as T); - const minEnd = getNs(this.end) < getNs(other.end) ? this.end : (other.end as T); + const maxStart = this.#startNs > other.#startNs ? this.#start : (other.#start as T | null); + const minEnd = this.#endNs < other.#endNs ? this.#end : (other.#end as T | null); return new Interval(maxStart, minEnd); } /** Returns the union of this interval and another, or null if they do not overlap/abut */ union(other: Interval): Interval | null { if (!this.overlaps(other) && !this.abuts(other)) return null; - const minStart = getNs(this.start) < getNs(other.start) ? this.start : (other.start as T); - const maxEnd = getNs(this.end) > getNs(other.end) ? this.end : (other.end as T); + const minStart = this.#startNs < other.#startNs ? this.#start : (other.#start as T | null); + const maxEnd = this.#endNs > other.#endNs ? this.#end : (other.#end as T | null); return new Interval(minStart, maxEnd); } } diff --git a/packages/tempo/src/tempo.class.ts b/packages/tempo/src/tempo.class.ts index 14315c07..5df3d908 100644 --- a/packages/tempo/src/tempo.class.ts +++ b/packages/tempo/src/tempo.class.ts @@ -32,6 +32,7 @@ import { resolveMonthDay, setProperty, proto, hasOwn, resolveDisplayStatus } fro import { datePattern } from './support/support.default.js'; import { sym, markConfig, TermError, getRuntime, init, extendState, setPatterns, isTempo, registryUpdate, registryReset, onRegistryReset, Token, Snippet, Layout, Event, Period, Ignore, Default, Guard, enums, STATE, LICENSE, DISCOVERY, $Internal, $setConfig, $Identity, $setEvents, $setPeriods, $setAliases, $buildGuard, $IsBase, $Tempo, $Register, $errored, $guard, $Discover, $setDiscovery, $LogConfig, logError, logDebug, logWarn, logTempo, setLogLevel } from '#tempo/support'; import { TEMPO_VERSION } from './tempo.version.js'; +import { Interval } from './interval.class.js'; import * as t from './tempo.type.js'; // namespaced types (Tempo.*) declare module '#library/type.library.js' { @@ -78,9 +79,18 @@ let _usrCount = 0; /** flag to prevent recursion during init */ const _lifecycle = { bootstrap: true, initialising: false, extendDepth: 0, ready: false }; +const intervalProxyHandler: ProxyHandler = { + construct(target, args: any[]) { + const parse = (arg: t.DateTime | null) => arg === null ? null : (arg instanceof Tempo ? arg : new Tempo(arg)); + return new (target as any)(parse(args[0]), parse(args[1])); + } +}; + @Serializable @Immutable export class Tempo { + /** Interval class for checking overlaps and bounds between Temporal points */ static Interval = new Proxy(Interval, intervalProxyHandler) as unknown as new (start: t.DateTime | null, end: t.DateTime | null) => Interval; + /** Weekday names (short-form) */ static get WEEKDAY() { return enums.WEEKDAY } /** Weekday names (long-form) */ static get WEEKDAYS() { return enums.WEEKDAYS } /** Month names (short-form) */ static get MONTH() { return enums.MONTH } diff --git a/packages/tempo/src/tempo.index.ts b/packages/tempo/src/tempo.index.ts index fc0a1b44..8445d71e 100644 --- a/packages/tempo/src/tempo.index.ts +++ b/packages/tempo/src/tempo.index.ts @@ -31,5 +31,6 @@ export type * from '#tempo/mutate'; export type * from '#tempo/duration'; export type * from '#tempo/term'; +export { Interval } from './interval.class.js'; export * from './tempo.class.js'; export default Tempo; diff --git a/packages/tempo-fns/test/scheduling/interval.test.ts b/packages/tempo/test/core/interval.test.ts similarity index 58% rename from packages/tempo-fns/test/scheduling/interval.test.ts rename to packages/tempo/test/core/interval.test.ts index ed3a85e6..131e5640 100644 --- a/packages/tempo-fns/test/scheduling/interval.test.ts +++ b/packages/tempo/test/core/interval.test.ts @@ -1,5 +1,4 @@ -import { Interval } from '../../src/scheduling/Interval.js'; -import { Tempo } from '@magmacomputing/tempo'; +import { Tempo, Interval } from '#tempo'; describe('Interval', () => { it('should normalize start and end if provided out of order', () => { @@ -7,8 +6,26 @@ describe('Interval', () => { const t2 = new Tempo('2026-07-05'); const interval = new Interval(t2, t1); // Out of order - expect(interval.start.epoch.ns).toBe(t1.epoch.ns); - expect(interval.end.epoch.ns).toBe(t2.epoch.ns); + expect(interval.start!.epoch.ns).toBe(t1.epoch.ns); + expect(interval.end!.epoch.ns).toBe(t2.epoch.ns); + }); + + it('should work via Tempo.Interval namespace attachment', () => { + const t1 = new Tempo('2026-07-01'); + const t2 = new Tempo('2026-07-05'); + const interval = new Tempo.Interval(t1, t2); + + expect(interval.start!.epoch.ns).toBe(t1.epoch.ns); + expect(interval.end!.epoch.ns).toBe(t2.epoch.ns); + expect(interval instanceof Interval).toBe(true); + }); + + it('should parse natural language strings when using Tempo.Interval wrapper', () => { + const interval = new Tempo.Interval('2026-07-01', '2026-07-05'); + expect(interval.start).toBeInstanceOf(Tempo); + expect(interval.end).toBeInstanceOf(Tempo); + expect(interval.start!.epoch.ns).toBe(new Tempo('2026-07-01').epoch.ns); + expect(interval.end!.epoch.ns).toBe(new Tempo('2026-07-05').epoch.ns); }); it('should accept native Temporal objects', () => { @@ -16,7 +33,7 @@ describe('Interval', () => { const t2 = Temporal.Instant.from('2026-07-05T00:00:00Z'); const interval = new Interval(t1, t2); - expect(interval.start.epochNanoseconds).toBe(t1.epochNanoseconds); + expect(interval.start!.epochNanoseconds).toBe(t1.epochNanoseconds); }); it('contains should return true for points inside the interval', () => { @@ -52,8 +69,8 @@ describe('Interval', () => { const intersection = i1.intersection(i2); expect(intersection).not.toBeNull(); - expect(intersection!.start.epoch.ns).toBe(new Tempo('2026-07-05').epoch.ns); - expect(intersection!.end.epoch.ns).toBe(new Tempo('2026-07-10').epoch.ns); + expect(intersection!.start!.epoch.ns).toBe(new Tempo('2026-07-05').epoch.ns); + expect(intersection!.end!.epoch.ns).toBe(new Tempo('2026-07-10').epoch.ns); }); it('union should return the combined region if overlapping', () => { @@ -62,8 +79,8 @@ describe('Interval', () => { const union = i1.union(i2); expect(union).not.toBeNull(); - expect(union!.start.epoch.ns).toBe(new Tempo('2026-07-01').epoch.ns); - expect(union!.end.epoch.ns).toBe(new Tempo('2026-07-15').epoch.ns); + expect(union!.start!.epoch.ns).toBe(new Tempo('2026-07-01').epoch.ns); + expect(union!.end!.epoch.ns).toBe(new Tempo('2026-07-15').epoch.ns); }); it('intersection should return null if not overlapping', () => { @@ -79,4 +96,25 @@ describe('Interval', () => { expect(i1.union(i2)).toBeNull(); }); + + it('should support open-ended null boundaries', () => { + const openStart = new Interval(null, new Tempo('2026-07-10')); + const openEnd = new Interval(new Tempo('2026-07-01'), null); + const allTime = new Interval(null, null); + + expect(openStart.start).toBeNull(); + expect(openEnd.end).toBeNull(); + expect(allTime.start).toBeNull(); + expect(allTime.end).toBeNull(); + + expect(openStart.contains(new Tempo('2000-01-01'))).toBe(true); + expect(openStart.contains(new Tempo('2026-07-11'))).toBe(false); + + expect(openEnd.contains(new Tempo('2000-01-01'))).toBe(false); + expect(openEnd.contains(new Tempo('9999-12-31'))).toBe(true); + + expect(allTime.contains(new Tempo('2026-07-05'))).toBe(true); + + expect(openStart.overlaps(openEnd)).toBe(true); + }); }); From 344dec0e48840fcbb8b5e8da8497ffe1584cc8c6 Mon Sep 17 00:00:00 2001 From: Michael McRae Date: Mon, 13 Jul 2026 12:11:30 +1000 Subject: [PATCH 15/18] amend rollup for premium-build --- packages/tempo/rollup.config.js | 19 +++++++++++++++++++ .../src/plugin/license/license.validator.ts | 4 ++++ 2 files changed, 23 insertions(+) diff --git a/packages/tempo/rollup.config.js b/packages/tempo/rollup.config.js index c2f378e8..b285b6bb 100644 --- a/packages/tempo/rollup.config.js +++ b/packages/tempo/rollup.config.js @@ -4,6 +4,7 @@ import { fileURLToPath } from 'node:url'; import alias from '@rollup/plugin-alias'; import resolve from '@rollup/plugin-node-resolve'; +import ts from 'typescript'; import terser from '@rollup/plugin-terser'; import JavaScriptObfuscator from 'javascript-obfuscator'; import MagicString from 'magic-string'; @@ -96,6 +97,24 @@ export default [ /^#tempo/ ], plugins: [ + { + name: 'manual-typescript', + transform(code, id) { + if (!id.endsWith('.ts')) return null; + return { + code: ts.transpileModule(code, { + compilerOptions: { + target: ts.ScriptTarget.ESNext, + module: ts.ModuleKind.ESNext, + moduleResolution: ts.ModuleResolutionKind.NodeJs, + sourceMap: false, + declaration: false + } + }).outputText, + map: null + }; + } + }, resolve({ extensions: ['.js', '.ts'], moduleDirectories: ['node_modules'] }), { name: 'obfuscator', diff --git a/packages/tempo/src/plugin/license/license.validator.ts b/packages/tempo/src/plugin/license/license.validator.ts index d52e3371..cc8e86e8 100644 --- a/packages/tempo/src/plugin/license/license.validator.ts +++ b/packages/tempo/src/plugin/license/license.validator.ts @@ -1,6 +1,7 @@ import { isFunction } from '#library/assertion.library.js'; import { decodeJWT } from '#library/webtoken.library.js'; import { logWarn } from '../../support/support.util.js'; +import { sym } from '../../support/support.symbol.js'; /** * # Tempo Licensing Engine (Open Core) @@ -39,6 +40,8 @@ export function definePremiumPlugin(key: string, plugin: T): T { if ((plugin as any).install) (plugin as any).install = throwLicense; if ((plugin as any).define) (plugin as any).define = throwLicense; if ((plugin as any).resolve) (plugin as any).resolve = throwLicense; + + (plugin as any)[sym.$PluginType] = 'plugin'; return plugin; } @@ -64,5 +67,6 @@ export function definePremiumTerm(pluginDef: T): T { } } + (pluginDef as any)[sym.$PluginType] = 'term'; return pluginDef; } From 959fa8da3402e9252d24c6d200f87d04e1c8c32a Mon Sep 17 00:00:00 2001 From: Michael McRae Date: Mon, 13 Jul 2026 15:22:53 +1000 Subject: [PATCH 16/18] PR 6th review --- packages/tempo-fns/package.json | 3 +- .../src/business/isSameFiscalQuarter.ts | 3 + .../src/business/workingHoursUntil.ts | 33 ++++------ .../src/calendar/getISOWeekOfYear.ts | 13 +++- .../src/calendar/getPublicHolidays.ts | 9 ++- packages/tempo-fns/src/scheduling/README.md | 2 +- packages/tempo-fns/src/support/README.md | 17 ++--- packages/tempo-fns/src/support/assert.ts | 62 +++++++++++++++++++ packages/tempo-fns/src/support/fetch.ts | 13 ++-- packages/tempo-fns/src/support/index.ts | 4 ++ packages/tempo-fns/src/support/temporal.ts | 17 +++++ packages/tempo-fns/src/timezone/getOffsets.ts | 3 +- packages/tempo-fns/src/timezone/isDST.ts | 9 +-- packages/tempo/rollup.config.js | 29 ++++++--- .../src/plugin/license/license.validator.ts | 5 +- 15 files changed, 161 insertions(+), 61 deletions(-) create mode 100644 packages/tempo-fns/src/support/assert.ts create mode 100644 packages/tempo-fns/src/support/index.ts create mode 100644 packages/tempo-fns/src/support/temporal.ts diff --git a/packages/tempo-fns/package.json b/packages/tempo-fns/package.json index 534d544b..c50e0afa 100644 --- a/packages/tempo-fns/package.json +++ b/packages/tempo-fns/package.json @@ -46,7 +46,8 @@ "docs:build": "node ./bin/sync-docs.mjs && vitepress build doc" }, "peerDependencies": { - "@magmacomputing/tempo": "^3.7.0" + "@magmacomputing/tempo": "^3.7.0", + "@js-temporal/polyfill": "^0.4.4" }, "peerDependenciesMeta": { "@magmacomputing/tempo": { diff --git a/packages/tempo-fns/src/business/isSameFiscalQuarter.ts b/packages/tempo-fns/src/business/isSameFiscalQuarter.ts index 5c239bcb..b63f594e 100644 --- a/packages/tempo-fns/src/business/isSameFiscalQuarter.ts +++ b/packages/tempo-fns/src/business/isSameFiscalQuarter.ts @@ -1,10 +1,13 @@ import type { Tempo } from '@magmacomputing/tempo'; +import { isTempo } from '../support/index.js'; /** * Checks if two dates fall in the same Fiscal Quarter. * This function inherently requires the Tempo Terms engine. */ export const isSameFiscalQuarter = (date1: Tempo, date2: Tempo): boolean => { + if (!isTempo(date1) || !isTempo(date2)) + throw new TypeError("isSameFiscalQuarter requires Tempo instances."); const q1 = date1.term.quarter; const q2 = date2.term.quarter; diff --git a/packages/tempo-fns/src/business/workingHoursUntil.ts b/packages/tempo-fns/src/business/workingHoursUntil.ts index 019ec418..31379e3f 100644 --- a/packages/tempo-fns/src/business/workingHoursUntil.ts +++ b/packages/tempo-fns/src/business/workingHoursUntil.ts @@ -1,6 +1,7 @@ -import type { Tempo } from '@magmacomputing/tempo/core'; import { getPublicHolidays } from '../calendar/getPublicHolidays.js'; -import { getLocale } from '../support/intl.js'; +import { getLocale, isTempo, getTemporal } from '../support/index.js'; +import type { Temporal } from '../support/index.js'; +import type { Tempo } from '@magmacomputing/tempo/core'; /** Configuration options for the workingHoursUntil SLA calculation. */ export type SLAOptions = { @@ -18,16 +19,11 @@ export type SLAOptions = { const remoteHolidaysCache: Map = new Map(); /** - * Calculates the exact number of SLA-eligible working hours from this date until the deadline. + * Calculates the exact number of SLA-eligible working hours from the start date until the deadline. */ -export const workingHoursUntil = function (this: Tempo, deadline: Tempo.DateTime, options?: SLAOptions): number { - const startZdt = this.toDateTime(); - - // If it has .toDateTime(), it's already a Tempo instance. Otherwise, let Tempo parse the string/Temporal object. - const endTempo = typeof (deadline as any).toDateTime === 'function' - ? (deadline as Tempo) - : this.set(deadline as any); - const endZdt = endTempo.toDateTime(); +export const workingHoursUntil = function (start: Tempo | Temporal.ZonedDateTime, deadline: Tempo | Temporal.ZonedDateTime, options?: SLAOptions): number { + const startZdt = isTempo(start) ? start.toDateTime() : start; + const endZdt = isTempo(deadline) ? deadline.toDateTime() : deadline; let current = startZdt; let target = endZdt; @@ -61,7 +57,9 @@ export const workingHoursUntil = function (this: Tempo, deadline: Tempo.DateTime let totalNs = 0n; - while (Temporal.PlainDate.compare(iterDate, targetDate) <= 0) { + const TemporalAPI = getTemporal(); + + while (TemporalAPI.PlainDate.compare(iterDate, targetDate) <= 0) { const isWeekend = iterDate.dayOfWeek === 6 || iterDate.dayOfWeek === 7; const yyyy = iterDate.year.toString().padStart(4, '0'); const mm = iterDate.month.toString().padStart(2, '0'); @@ -91,7 +89,7 @@ export const workingHoursUntil = function (this: Tempo, deadline: Tempo.DateTime } /** Preloads and caches holiday data for a region and year. */ -export const preloadHolidays = async (region?: string, year: number = Temporal.Now.plainDateISO().year): Promise => { +export const preloadHolidays = async (region?: string, year: number = getTemporal().Now.plainDateISO().year): Promise => { const resolvedRegion = region || getLocale().region || 'US'; const cacheKey = `${resolvedRegion}-${year}`; @@ -107,12 +105,3 @@ export const preloadHolidays = async (region?: string, year: number = Temporal.N remoteHolidaysCache.set(cacheKey, []); } } - -declare module '@magmacomputing/tempo/core' { - interface Tempo { - /** - * Calculates the exact number of SLA-eligible working hours from this date until the deadline. - */ - workingHoursUntil(deadline: Tempo.DateTime, options?: SLAOptions): number; - } -} diff --git a/packages/tempo-fns/src/calendar/getISOWeekOfYear.ts b/packages/tempo-fns/src/calendar/getISOWeekOfYear.ts index c762888a..20bf536b 100644 --- a/packages/tempo-fns/src/calendar/getISOWeekOfYear.ts +++ b/packages/tempo-fns/src/calendar/getISOWeekOfYear.ts @@ -1,6 +1,13 @@ +import type { Tempo } from '@magmacomputing/tempo'; +import type { Temporal } from '../support/index.js'; +import { isTempo, isTemporal } from '../support/index.js'; + /** Computes the ISO week number and ISO week-numbering year for a given date. */ -export function getISOWeekOfYear(zdt: any) { - const pd = (zdt.toPlainDate ? zdt.toPlainDate() : zdt).withCalendar('iso8601'); +export function getISOWeekOfYear(zdt: Tempo | Temporal.ZonedDateTime | Temporal.PlainDateTime | Temporal.PlainDate) { + if (!isTempo(zdt) && !isTemporal(zdt)) + throw new TypeError("getISOWeekOfYear requires a Tempo or Temporal object."); + + const pd = (isTempo(zdt) || 'toPlainDate' in zdt ? zdt.toPlainDate() : (zdt as Temporal.PlainDate)).withCalendar('iso8601'); // ISO 8601 week 1 is the week with the year's first Thursday in it. const targetThursday = pd.subtract({ days: pd.dayOfWeek - 4 }); @@ -11,7 +18,7 @@ export function getISOWeekOfYear(zdt: any) { const week1Thursday = firstThursday.subtract({ days: firstThursday.dayOfWeek - 4 }); // Compute days between target Thursday and week 1 Thursday - const daysBetween = targetThursday.since(week1Thursday).total({ unit: 'days' }); + const daysBetween = targetThursday.since(week1Thursday).total('days'); const weekOfYear = Math.floor(daysBetween / 7) + 1; return { weekOfYear, yearOfWeek: targetYear }; diff --git a/packages/tempo-fns/src/calendar/getPublicHolidays.ts b/packages/tempo-fns/src/calendar/getPublicHolidays.ts index 478459c8..bb639c69 100644 --- a/packages/tempo-fns/src/calendar/getPublicHolidays.ts +++ b/packages/tempo-fns/src/calendar/getPublicHolidays.ts @@ -1,7 +1,6 @@ -import { fetchWithTimeout } from '../support/fetch.js'; -import { getLocale } from '../support/intl.js'; +import { fetchWithTimeout, getLocale, getTemporal } from '../support/index.js'; -export const holidayURL = 'https://date.nager.at/api/v3/PublicHolidays'; +const holidayURL = 'https://date.nager.at/api/v3/PublicHolidays'; /** Represents a public holiday payload returned from the Nager.Date API. */ export type PublicHoliday = { @@ -22,7 +21,7 @@ const holidaysCache = new Map(); * Fetches public holidays for a given year and region from the Nager.Date API. */ export const getPublicHolidays = async ( - year: number = Temporal.Now.plainDateISO().year, + year: number = getTemporal().Now.plainDateISO().year, region?: string ): Promise => { const resolvedRegion = region || getLocale().region || 'US'; @@ -33,7 +32,7 @@ export const getPublicHolidays = async ( // Nager.Date expects a 2-letter ISO 3166-1 alpha-2 country code (e.g. 'US', not 'en-US' or 'US-NY'). // If a subdivision like 'AU-NSW' is provided, we extract the country code 'AU'. const countryCode = resolvedRegion.split('-')[0].toUpperCase(); - + const response = await fetchWithTimeout(`${holidayURL}/${year}/${countryCode}`, 2000); const data = await response.json(); diff --git a/packages/tempo-fns/src/scheduling/README.md b/packages/tempo-fns/src/scheduling/README.md index 860f1b6a..5712b701 100644 --- a/packages/tempo-fns/src/scheduling/README.md +++ b/packages/tempo-fns/src/scheduling/README.md @@ -24,7 +24,7 @@ Represents a continuous span of time with start and end boundaries, supporting s ```typescript class Interval { - constructor(start: T, end: T); + constructor(start: T | null, end: T | null); contains(point: TemporalPoint): boolean; overlaps(other: Interval): boolean; diff --git a/packages/tempo-fns/src/support/README.md b/packages/tempo-fns/src/support/README.md index c2f5c839..320acadc 100644 --- a/packages/tempo-fns/src/support/README.md +++ b/packages/tempo-fns/src/support/README.md @@ -1,17 +1,18 @@ # Support Utilities This directory contains internal support and generic networking tools. **Functions exported from this directory are internal and should not be exported from the public barrel (`src/index.ts`).** +To keep imports clean across the codebase, all support utilities are exported through the internal barrel file: +```typescript +import { fetchWithTimeout, isTempo, getTemporal } from '../support/index.js'; +``` + ## Internal Functions ### `fetchWithTimeout` A generic `fetch` wrapper that enforces a timeout via `AbortController`. -```typescript -function fetchWithTimeout(url: string, timeoutMs?: number, options?: RequestInit): Promise; -``` -**Example (Internal Usage):** -```typescript -import { fetchWithTimeout } from '../support/fetch.js'; +### `isTempo` / `isTemporal` +Robust type guards that rely on `Symbol.toStringTag` duck-typing to safely assert the identity of a date object across cross-bundle boundaries. -const response = await fetchWithTimeout('https://api.example.com', 2000); -``` +### `getTemporal` +A dynamic runtime resolver for the `Temporal` API. Because `tempo-fns` is a utility library, it strictly avoids bundling the massive 800KB `@js-temporal/polyfill`. Instead, it intercepts `globalThis.Temporal` at runtime and gracefully throws a developer-friendly error if a polyfill has not been loaded by the consumer. diff --git a/packages/tempo-fns/src/support/assert.ts b/packages/tempo-fns/src/support/assert.ts new file mode 100644 index 00000000..e87a66bb --- /dev/null +++ b/packages/tempo-fns/src/support/assert.ts @@ -0,0 +1,62 @@ +import type { Tempo } from '@magmacomputing/tempo'; +import type { Temporal } from './temporal.js'; + +function isObject(arg: any): arg is Record { + return arg != null && typeof arg === 'object' && Symbol.toStringTag in arg; +} + +/** + * Checks if the given argument is a Tempo instance. + */ +export function isTempo(arg: any): arg is Tempo { + return isObject(arg) && arg[Symbol.toStringTag] === 'Tempo'; +} + +/** + * Checks if the given argument is ANY valid Temporal object. + */ +export function isTemporal(arg: any): boolean { + return isObject(arg) && String(arg[Symbol.toStringTag]).startsWith('Temporal.'); +} + +/** + * Checks if the given argument is a Temporal.ZonedDateTime. + */ +export function isZonedDateTime(arg: any): arg is Temporal.ZonedDateTime { + return isObject(arg) && arg[Symbol.toStringTag] === 'Temporal.ZonedDateTime'; +} + +/** + * Checks if the given argument is a Temporal.Instant. + */ +export function isInstant(arg: any): arg is Temporal.Instant { + return isObject(arg) && arg[Symbol.toStringTag] === 'Temporal.Instant'; +} + +/** + * Checks if the given argument is a Temporal.PlainDate. + */ +export function isPlainDate(arg: any): arg is Temporal.PlainDate { + return isObject(arg) && arg[Symbol.toStringTag] === 'Temporal.PlainDate'; +} + +/** + * Checks if the given argument is a Temporal.PlainTime. + */ +export function isPlainTime(arg: any): arg is Temporal.PlainTime { + return isObject(arg) && arg[Symbol.toStringTag] === 'Temporal.PlainTime'; +} + +/** + * Checks if the given argument is a Temporal.PlainDateTime. + */ +export function isPlainDateTime(arg: any): arg is Temporal.PlainDateTime { + return isObject(arg) && arg[Symbol.toStringTag] === 'Temporal.PlainDateTime'; +} + +/** + * Checks if the given argument is a Temporal.Duration. + */ +export function isDuration(arg: any): arg is Temporal.Duration { + return isObject(arg) && arg[Symbol.toStringTag] === 'Temporal.Duration'; +} diff --git a/packages/tempo-fns/src/support/fetch.ts b/packages/tempo-fns/src/support/fetch.ts index 1fad05ab..ca02f509 100644 --- a/packages/tempo-fns/src/support/fetch.ts +++ b/packages/tempo-fns/src/support/fetch.ts @@ -9,10 +9,11 @@ export const fetchWithTimeout = async (url: string, timeoutMs = 2000, options: R ? AbortSignal.any([options.signal, controller.signal]) : controller.signal; - const response = await fetch(url, { ...options, signal }); - clearTimeout(timeoutId); - - if (!response.ok) throw new Error(`API returned ${response.status} for ${url}`); - - return response; + try { + const response = await fetch(url, { ...options, signal }); + if (!response.ok) throw new Error(`API returned ${response.status} for ${url}`); + return response; + } finally { + clearTimeout(timeoutId); + } } diff --git a/packages/tempo-fns/src/support/index.ts b/packages/tempo-fns/src/support/index.ts new file mode 100644 index 00000000..2c1a4e0f --- /dev/null +++ b/packages/tempo-fns/src/support/index.ts @@ -0,0 +1,4 @@ +export * from './assert.js'; +export * from './fetch.js'; +export * from './intl.js'; +export * from './temporal.js'; diff --git a/packages/tempo-fns/src/support/temporal.ts b/packages/tempo-fns/src/support/temporal.ts new file mode 100644 index 00000000..045c96e5 --- /dev/null +++ b/packages/tempo-fns/src/support/temporal.ts @@ -0,0 +1,17 @@ +import type { Temporal as TemporalType } from '@js-temporal/polyfill'; +export type { TemporalType as Temporal }; + +/** + * Resolves the native Temporal API from the global scope at runtime. + * This guarantees that tempo-fns does not accidentally bundle the polyfill, + * while maintaining full type safety. + */ +export const getTemporal = (): typeof TemporalType => { + // @ts-ignore - Check for global Temporal + if (typeof Temporal !== 'undefined') return Temporal; + + if (typeof globalThis !== 'undefined' && 'Temporal' in globalThis) + return (globalThis as any).Temporal; + + throw new Error("[tempo-fns] Temporal API is not available in the global scope. Ensure a polyfill is loaded."); +} diff --git a/packages/tempo-fns/src/timezone/getOffsets.ts b/packages/tempo-fns/src/timezone/getOffsets.ts index fbcd987a..56f8dcc7 100644 --- a/packages/tempo-fns/src/timezone/getOffsets.ts +++ b/packages/tempo-fns/src/timezone/getOffsets.ts @@ -1,7 +1,8 @@ -import { Temporal } from '@js-temporal/polyfill'; +import { getTemporal } from '../support/index.js'; /** return the January and July offsets (nanoseconds) for a given timezone and year */ export function getOffsets(timeZone: string, year = 2024) { //** use a fixed reference-year (2024) for stability */ + const Temporal = getTemporal(); const jan = Temporal.PlainDate.from({ year, month: 1, day: 1 }).toZonedDateTime(timeZone).offsetNanoseconds; const jul = Temporal.PlainDate.from({ year, month: 7, day: 1 }).toZonedDateTime(timeZone).offsetNanoseconds; diff --git a/packages/tempo-fns/src/timezone/isDST.ts b/packages/tempo-fns/src/timezone/isDST.ts index 7d39792d..f377c9c1 100644 --- a/packages/tempo-fns/src/timezone/isDST.ts +++ b/packages/tempo-fns/src/timezone/isDST.ts @@ -1,18 +1,19 @@ -import { Temporal } from '@js-temporal/polyfill'; import { getOffsets } from './getOffsets.js'; +import { getTemporal, type Temporal } from '../support/index.js'; /** Determines whether the given date is in Daylight Savings Time (DST). */ export function isDST(date?: Temporal.ZonedDateTime | string, timeZone: string = Intl.DateTimeFormat().resolvedOptions().timeZone) { let zdt: Temporal.ZonedDateTime; + const TemporalAPI = getTemporal(); if (typeof date === 'string') { try { - zdt = Temporal.Instant.from(date).toZonedDateTimeISO(timeZone); + zdt = TemporalAPI.Instant.from(date).toZonedDateTimeISO(timeZone); } catch { - zdt = Temporal.PlainDate.from(date).toZonedDateTime(timeZone); + zdt = TemporalAPI.PlainDate.from(date).toZonedDateTime(timeZone); } } else { - zdt = date ?? Temporal.Now.zonedDateTimeISO(timeZone); + zdt = date ?? TemporalAPI.Now.zonedDateTimeISO(timeZone); } const { jan, jul } = getOffsets(zdt.timeZoneId, zdt.year); diff --git a/packages/tempo/rollup.config.js b/packages/tempo/rollup.config.js index b285b6bb..74805fff 100644 --- a/packages/tempo/rollup.config.js +++ b/packages/tempo/rollup.config.js @@ -101,16 +101,27 @@ export default [ name: 'manual-typescript', transform(code, id) { if (!id.endsWith('.ts')) return null; + const result = ts.transpileModule(code, { + compilerOptions: { + target: ts.ScriptTarget.ESNext, + module: ts.ModuleKind.ESNext, + moduleResolution: ts.ModuleResolutionKind.NodeJs, + sourceMap: false, + declaration: false + } + }); + + if (result.diagnostics && result.diagnostics.length > 0) { + const formatted = ts.formatDiagnosticsWithColorAndContext(result.diagnostics, { + getCurrentDirectory: () => process.cwd(), + getCanonicalFileName: (fileName) => fileName, + getNewLine: () => ts.sys ? ts.sys.newLine : '\n' + }); + this.error(`TypeScript compilation failed in ${id}:\n${formatted}`); + } + return { - code: ts.transpileModule(code, { - compilerOptions: { - target: ts.ScriptTarget.ESNext, - module: ts.ModuleKind.ESNext, - moduleResolution: ts.ModuleResolutionKind.NodeJs, - sourceMap: false, - declaration: false - } - }).outputText, + code: result.outputText, map: null }; } diff --git a/packages/tempo/src/plugin/license/license.validator.ts b/packages/tempo/src/plugin/license/license.validator.ts index cc8e86e8..38f0510e 100644 --- a/packages/tempo/src/plugin/license/license.validator.ts +++ b/packages/tempo/src/plugin/license/license.validator.ts @@ -36,7 +36,10 @@ export function definePremiumPlugin(key: string, plugin: T): T { throw new Error(`[${key}] Premium plugin requires a valid commercial license. Status: invalid`); } - if (isFunction(plugin)) return throwLicense as unknown as T; + if (isFunction(plugin)) { + (throwLicense as any)[sym.$PluginType] = 'plugin'; + return throwLicense as unknown as T; + } if ((plugin as any).install) (plugin as any).install = throwLicense; if ((plugin as any).define) (plugin as any).define = throwLicense; if ((plugin as any).resolve) (plugin as any).resolve = throwLicense; From b7d252179090ad224670031b324dd7cf721c4e49 Mon Sep 17 00:00:00 2001 From: Michael McRae Date: Mon, 13 Jul 2026 16:25:26 +1000 Subject: [PATCH 17/18] PR 7th review --- .../tempo-fns/src/calendar/getPublicHolidays.ts | 5 +++++ packages/tempo-fns/src/support/assert.ts | 7 +++++++ packages/tempo-fns/src/support/temporal.ts | 2 +- .../test/business/workingHoursUntil.test.ts | 14 ++++++-------- packages/tempo/src/interval.class.ts | 4 ++++ 5 files changed, 23 insertions(+), 9 deletions(-) diff --git a/packages/tempo-fns/src/calendar/getPublicHolidays.ts b/packages/tempo-fns/src/calendar/getPublicHolidays.ts index bb639c69..c603ee93 100644 --- a/packages/tempo-fns/src/calendar/getPublicHolidays.ts +++ b/packages/tempo-fns/src/calendar/getPublicHolidays.ts @@ -33,7 +33,12 @@ export const getPublicHolidays = async ( // If a subdivision like 'AU-NSW' is provided, we extract the country code 'AU'. const countryCode = resolvedRegion.split('-')[0].toUpperCase(); + if (!/^[A-Z]{2}$/.test(countryCode)) + throw new Error(`[tempo-fns] Invalid region code: '${resolvedRegion}'. Expected a 2-letter ISO 3166-1 country code.`); + const response = await fetchWithTimeout(`${holidayURL}/${year}/${countryCode}`, 2000); + if (!response.ok) + throw new Error(`[tempo-fns] Failed to fetch holidays: HTTP ${response.status} ${response.statusText}`); const data = await response.json(); holidaysCache.set(cacheKey, data); diff --git a/packages/tempo-fns/src/support/assert.ts b/packages/tempo-fns/src/support/assert.ts index e87a66bb..f788d421 100644 --- a/packages/tempo-fns/src/support/assert.ts +++ b/packages/tempo-fns/src/support/assert.ts @@ -60,3 +60,10 @@ export function isPlainDateTime(arg: any): arg is Temporal.PlainDateTime { export function isDuration(arg: any): arg is Temporal.Duration { return isObject(arg) && arg[Symbol.toStringTag] === 'Temporal.Duration'; } + +/** + * Checks if the given argument is a Tempo.Interval. + */ +export function isInterval(arg: any): boolean { + return isObject(arg) && arg[Symbol.toStringTag] === 'Tempo.Interval'; +} diff --git a/packages/tempo-fns/src/support/temporal.ts b/packages/tempo-fns/src/support/temporal.ts index 045c96e5..24560156 100644 --- a/packages/tempo-fns/src/support/temporal.ts +++ b/packages/tempo-fns/src/support/temporal.ts @@ -7,7 +7,7 @@ export type { TemporalType as Temporal }; * while maintaining full type safety. */ export const getTemporal = (): typeof TemporalType => { - // @ts-ignore - Check for global Temporal + // @ts-expect-error - Check for global Temporal if (typeof Temporal !== 'undefined') return Temporal; if (typeof globalThis !== 'undefined' && 'Temporal' in globalThis) diff --git a/packages/tempo-fns/test/business/workingHoursUntil.test.ts b/packages/tempo-fns/test/business/workingHoursUntil.test.ts index 7fc14843..2b2d1fa2 100644 --- a/packages/tempo-fns/test/business/workingHoursUntil.test.ts +++ b/packages/tempo-fns/test/business/workingHoursUntil.test.ts @@ -3,26 +3,24 @@ import { Tempo } from '@magmacomputing/tempo'; import { workingHoursUntil } from '../../src/business/workingHoursUntil.js'; describe('workingHoursUntil', () => { - Tempo.prototype.workingHoursUntil = workingHoursUntil; - test('same day within working hours', () => { const start = new Tempo('2026-07-10T10:00:00+10:00'); // Friday const end = new Tempo('2026-07-10T14:30:00+10:00'); // Friday - expect(start.workingHoursUntil(end)).toBe(4.5); + expect(workingHoursUntil(start, end)).toBe(4.5); }); test('same day starting before working hours', () => { const start = new Tempo('2026-07-10T07:00:00+10:00'); const end = new Tempo('2026-07-10T14:30:00+10:00'); // Starts at 9:00 -> 14:30 = 5.5 hours - expect(start.workingHoursUntil(end)).toBe(5.5); + expect(workingHoursUntil(start, end)).toBe(5.5); }); test('same day ending after working hours', () => { const start = new Tempo('2026-07-10T10:00:00+10:00'); const end = new Tempo('2026-07-10T19:00:00+10:00'); // Ends at 17:00. 10:00 -> 17:00 = 7 hours - expect(start.workingHoursUntil(end)).toBe(7); + expect(workingHoursUntil(start, end)).toBe(7); }); test('spanning over weekend', () => { @@ -31,7 +29,7 @@ describe('workingHoursUntil', () => { // Friday: 15 to 17 = 2 hrs // Monday: 9 to 12 = 3 hrs // Total = 5 hrs - expect(start.workingHoursUntil(end)).toBe(5); + expect(workingHoursUntil(start, end)).toBe(5); }); test('spanning over holidays', () => { @@ -42,13 +40,13 @@ describe('workingHoursUntil', () => { // Sat/Sun: weekend = 0 hrs // Monday (28th): 9 to 12 = 3 hrs // Total = 8 hrs - expect(start.workingHoursUntil(end, { holidays: ['2026-12-25'] })).toBe(8); + expect(workingHoursUntil(start, end, { holidays: ['2026-12-25'] })).toBe(8); }); test('negative SLA (deadline in past)', () => { const end = new Tempo('2026-07-10T10:00:00+10:00'); // Friday const start = new Tempo('2026-07-10T14:30:00+10:00'); // Friday // 4.5 hours difference but negative - expect(start.workingHoursUntil(end)).toBe(-4.5); + expect(workingHoursUntil(start, end)).toBe(-4.5); }); }); diff --git a/packages/tempo/src/interval.class.ts b/packages/tempo/src/interval.class.ts index b4823e57..0424fa4e 100644 --- a/packages/tempo/src/interval.class.ts +++ b/packages/tempo/src/interval.class.ts @@ -40,6 +40,10 @@ export class Interval { return Object.freeze(this) as this; } + get [Symbol.toStringTag]() { + return 'Tempo.Interval'; + } + get start(): T | null { return this.#start; } From 36aadb3319d67cad3e72eac1c150fe88f6823fb6 Mon Sep 17 00:00:00 2001 From: Michael McRae Date: Mon, 13 Jul 2026 16:45:28 +1000 Subject: [PATCH 18/18] fix polyfill version --- packages/tempo-fns/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/tempo-fns/package.json b/packages/tempo-fns/package.json index c50e0afa..21824f93 100644 --- a/packages/tempo-fns/package.json +++ b/packages/tempo-fns/package.json @@ -47,7 +47,7 @@ }, "peerDependencies": { "@magmacomputing/tempo": "^3.7.0", - "@js-temporal/polyfill": "^0.4.4" + "@js-temporal/polyfill": "^0.5.1" }, "peerDependenciesMeta": { "@magmacomputing/tempo": {