diff --git a/packages/tempo/CHANGELOG.md b/packages/tempo/CHANGELOG.md index 51177aad..8f06353a 100644 --- a/packages/tempo/CHANGELOG.md +++ b/packages/tempo/CHANGELOG.md @@ -6,6 +6,28 @@ 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.3.0] - 2026-06-21 + +### Added +- **Localization Modifier Registry**: Modifiers (e.g., "next", "last", "this") can now be localized via the `registry.modifiers` configuration. This allows defining custom words for temporal shifts (e.g., `'>': ['prochain']`) which seamlessly integrate with standard parsing and `#` shorthand syntaxes. + +### Changed +- **Unified Registry Architecture**: Moved the top-level `modifiers` configuration key under the `registry` namespace (`registry: { modifiers }`) to maintain architectural consistency with formats and locales. Deep-merging ensures English default keywords remain active alongside custom localized additions. +- **Lexer Token Cleanups**: Stripped hardcoded English keywords out of all internal Match regular expressions (`Match.modifier`, `Match.shorthand`, `Match.slick`), standardizing the engine on the symbolic modifiers (`>`, `<`, `=`, `+`, `-`). + +### Fixed +- **Pre-filter Guard Numeric Safety**: Refined the guard-bypass logic to trigger *only* when the input string actually contains a registered modifier keyword, restoring proper strictness and fixing numeric-safety validation gaps for nanosecond epoch inputs. +- **Lazy Evaluation Delegation**: Fixed a regression in format lazy-loading where built-in formats (like `{date}` or `{time}`) were temporarily eclipsed by empty registry instances. +- **Trailing Affix Collisions**: Added explicit safety checks and warnings when a date unit is passed both a leading modifier and a trailing affix (e.g., "next May ago"). + +## [3.2.3] - 2026-06-20 +- **Centralized Configuration**: Introduced `tempo.config.ts` and `tempo.config.js` discovery. +- **Async Bootstrap**: Added `Tempo.bootstrap()` for safe, asynchronous ES Module configuration resolution without breaking the synchronous `Tempo.init()` API. +- **CLI Scaffolding**: Added an `npx @magmacomputing/tempo scaffold:all` CLI tool to easily bootstrap `tempo.config.ts` and HTML sandboxes into projects. + +### Changed +- **Zero-Dependency Resolution**: Implemented custom filesystem traversal to ensure robust config discovery without adding external dependencies (e.g., `cosmiconfig`). + ## [3.2.2] - 2026-06-18 ### Added diff --git a/packages/tempo/bench/bench.v3.3.0.ts b/packages/tempo/bench/bench.v3.3.0.ts new file mode 100644 index 00000000..48251fe7 --- /dev/null +++ b/packages/tempo/bench/bench.v3.3.0.ts @@ -0,0 +1,164 @@ +/** + * Post-refactor (v3.3.0) representative benchmark + * + * Compares three configurations: + * A. Baseline: stock Tempo, same corpus as pre-refactor + * B. Module-based comparison using BenchmarkModule across auto/defer/strict modes + * C. Localized fr-FR modifiers through the new registry.modifiers code path + */ +import '../bin/temporal-polyfill.js'; +import { Tempo } from '../src/tempo.index.js'; +import { BenchmarkModule } from '../src/module/module.benchmark.js'; +import { performance } from 'node:perf_hooks'; +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +// ── Corpus ────────────────────────────────────────────────────────────────── + +/** Same 20-entry corpus used for the pre-refactor benchmark */ +const baseCorpus: string[] = [ + '04012026', + '310559', + '590531', + '09:30', + 'monday', + '2 days ago', + '+6', + '1234567890123', + '2026-04-25', + '2026/04/25 10:30', + '11:45pm', + 'tomorrow', + '2026-04-25T10:30:00Z', + '2026-04-25T10:30:00+05:30', + 'next Friday at 5pm', + 'last Monday', + 'in 3 weeks', + 'yesterday', + 'noon', + 'midnight', +]; + +/** New v3.3.0 corpus: French localized modifiers through registry.modifiers */ +const frCorpus: string[] = [ + 'vendredi prochain', // localized 'next Friday' + 'lundi dernier', // localized 'last Monday' + 'mercredi prochain', // localized 'next Wednesday' + '3 jours', // plain French numeric + 'vendredi', // plain French weekday + 'jeudi dernier', // localized 'last Thursday' + 'mardi suivant', // alias: suivant => > + 'dimanche prochain', // localized 'next Sunday' +]; + +// ── Benchmark helpers ─────────────────────────────────────────────────────── + +function timedRun(label: string, data: string[], iterations: number, tempoOptions: any) { + // warm-up + for (const d of data) new Tempo(d, { catch: true, ...tempoOptions }); + + let success = 0, failure = 0; + const startHeap = process.memoryUsage().heapUsed; + const start = performance.now(); + + for (let i = 0; i < iterations; i++) { + for (const d of data) { + const t = new Tempo(d, { catch: true, ...tempoOptions }); + if (t.isValid) success++; else failure++; + } + } + + const elapsed = performance.now() - start; + const endHeap = process.memoryUsage().heapUsed; + const ops = iterations * data.length; + + return { + label, + totalTimeMs: Number(elapsed.toFixed(2)), + opsPerSec: Math.round(ops / (elapsed / 1000)), + microSecPerOp: Number(((elapsed * 1000) / ops).toFixed(2)), + successCount: success, + failureCount: failure, + successRate: ((success / ops) * 100).toFixed(1) + '%', + heapDeltaMb: Number(((endHeap - startHeap) / 1024 / 1024).toFixed(2)), + }; +} + +// ── Run ────────────────────────────────────────────────────────────────────── + +const ITERATIONS = 500; + +console.log(`\n⏱ Tempo v3.3.0 Post-Refactor Benchmark (${ITERATIONS} iterations × corpus size)\n`); + +// A. Baseline — stock config, base corpus (mirrors pre-refactor run) +Tempo.init({ debug: 0, catch: true, timeZone: 'UTC' }); +const baseline = timedRun('A. Baseline (stock, 20-entry corpus)', baseCorpus, ITERATIONS, { timeZone: 'UTC' }); + +// B. Baseline with all three Tempo modes using BenchmarkModule +Tempo.init({ debug: 0, catch: true, timeZone: 'UTC' }); +const moduleResults = BenchmarkModule.run(Tempo, { + data: baseCorpus, + iterations: ITERATIONS, + modes: ['auto', 'defer', 'strict'], + baseline: true, +}); + +// C. Localized modifier corpus — exercises the new registry.modifiers code path +const localizedConfig = { + locale: 'fr-FR', + debug: 0, + catch: true, + timeZone: 'UTC', + registry: { + modifiers: { + '>': ['prochain', 'suivant'], + '<': ['dernier', 'passé'], + '=': ['ce', 'cette'], + } + } +}; +Tempo.init(localizedConfig as any); +const localized = timedRun('C. Localized fr-FR modifiers (8-entry corpus)', frCorpus, ITERATIONS, {}); + +// ── Output ─────────────────────────────────────────────────────────────────── + +const output = { + runAt: new Date().toISOString(), + version: '3.3.0', + iterations: ITERATIONS, + baselineRaw: baseline, + moduleResults, + localizedModifiers: localized, +}; + +// Pretty console table +console.log('── Module-based comparison (matches pre-refactor format) ──'); +BenchmarkModule.printTable(moduleResults); + +console.log('\n── Baseline (manual, matches pre-refactor corpus exactly) ──'); +console.table([{ + 'Engine': baseline.label, + 'Total Time (ms)': baseline.totalTimeMs, + 'µs / Op': baseline.microSecPerOp, + 'ops/sec': baseline.opsPerSec, + 'Success Rate': baseline.successRate, + 'Heap Delta (MB)': baseline.heapDeltaMb, +}]); + +console.log('\n── New: Localized modifier throughput ──'); +console.table([{ + 'Engine': localized.label, + 'Total Time (ms)': localized.totalTimeMs, + 'µs / Op': localized.microSecPerOp, + 'ops/sec': localized.opsPerSec, + 'Success Rate': localized.successRate, + 'Heap Delta (MB)': localized.heapDeltaMb, +}]); + +// Save to file +const outPath = path.join(__dirname, 'benchmark-results-v3.3.0.json'); +fs.writeFileSync(outPath, JSON.stringify(output, null, 2)); +console.log(`\n✅ Results saved to ${outPath}\n`); diff --git a/packages/tempo/bench/benchmark-results-v3.3.0.json b/packages/tempo/bench/benchmark-results-v3.3.0.json new file mode 100644 index 00000000..1082ac5f --- /dev/null +++ b/packages/tempo/bench/benchmark-results-v3.3.0.json @@ -0,0 +1,63 @@ +{ + "runAt": "2026-06-21T03:09:07.682Z", + "version": "3.3.0", + "iterations": 500, + "baselineRaw": { + "label": "A. Baseline (stock, 20-entry corpus)", + "totalTimeMs": 15517.8, + "opsPerSec": 644, + "microSecPerOp": 1551.78, + "successCount": 10000, + "failureCount": 0, + "successRate": "100.0%", + "heapDeltaMb": 113.02 + }, + "moduleResults": [ + { + "name": "Native Date", + "totalTimeMs": 2.92, + "microSecPerOp": 0.29, + "successCount": 2500, + "failureCount": 7500, + "successRate": "25.0%", + "heapUsedDeltaMb": "1.08" + }, + { + "name": "Tempo (mode: auto)", + "totalTimeMs": 15524.01, + "microSecPerOp": 1552.4, + "successCount": 10000, + "failureCount": 0, + "successRate": "100.0%", + "heapUsedDeltaMb": "10.34" + }, + { + "name": "Tempo (mode: defer)", + "totalTimeMs": 15089.25, + "microSecPerOp": 1508.93, + "successCount": 10000, + "failureCount": 0, + "successRate": "100.0%", + "heapUsedDeltaMb": "-22.66" + }, + { + "name": "Tempo (mode: strict)", + "totalTimeMs": 15784.87, + "microSecPerOp": 1578.49, + "successCount": 10000, + "failureCount": 0, + "successRate": "100.0%", + "heapUsedDeltaMb": "56.53" + } + ], + "localizedModifiers": { + "label": "C. Localized fr-FR modifiers (8-entry corpus)", + "totalTimeMs": 11330.81, + "opsPerSec": 353, + "microSecPerOp": 2832.7, + "successCount": 4000, + "failureCount": 0, + "successRate": "100.0%", + "heapDeltaMb": -9.07 + } +} \ No newline at end of file diff --git a/packages/tempo/bench/modifiers.ts b/packages/tempo/bench/modifiers.ts new file mode 100644 index 00000000..ca800766 --- /dev/null +++ b/packages/tempo/bench/modifiers.ts @@ -0,0 +1,45 @@ +import '@js-temporal/polyfill'; +import { Tempo } from '../src/tempo.class.js'; + +const ITERATIONS = 100_000; + +function runBenchmark(name: string, expression: string) { + Tempo.init({ debug: 0, catch: true, timeZone: 'UTC' }); + + // Warm-up pass for JIT stabilization + const warmupIterations = Math.min(ITERATIONS, 1000); + for (let i = 0; i < warmupIterations; i++) + new Tempo(expression, { catch: true }); + + const start = performance.now(); + for (let i = 0; i < ITERATIONS; i++) + new Tempo(expression); + + const end = performance.now(); + const duration = end - start; + const opsPerSec = Math.round((ITERATIONS / duration) * 1000); + + console.log(`[${name}]`); + console.log(` Expression : "${expression}"`); + console.log(` Duration : ${duration.toFixed(2)} ms`); + console.log(` Speed : ${opsPerSec.toLocaleString()} ops/sec`); + console.log(''); +} + +console.log('============================================='); +console.log(`Tempo Modifier Benchmark (${ITERATIONS.toLocaleString()} iterations)`); +console.log('=============================================\n'); + +// 1. Standard Prefix Modifier (Weekday) +runBenchmark('Prefix Weekday', 'next Friday'); + +// 2. Relative Offset (Ago/Hence) +runBenchmark('Relative Offset', '3 days ago'); + +// 3. Complex Expression +runBenchmark('Complex Expression', 'next Friday at 5:00pm'); + +// 4. Baseline (No modifier) +runBenchmark('Baseline Date', '2025-01-01'); + +console.log('Benchmark complete.\n'); diff --git a/packages/tempo/bench/results/modifiers_post_refactor.txt b/packages/tempo/bench/results/modifiers_post_refactor.txt new file mode 100644 index 00000000..e69de29b diff --git a/packages/tempo/bench/results/modifiers_pre_refactor_20260621_075533.txt b/packages/tempo/bench/results/modifiers_pre_refactor_20260621_075533.txt new file mode 100644 index 00000000..0528b8af --- /dev/null +++ b/packages/tempo/bench/results/modifiers_pre_refactor_20260621_075533.txt @@ -0,0 +1,27 @@ +Criteria: Pre Tempo Modifiers refactor +============================================= +Tempo Modifier Benchmark (100,000 iterations) +============================================= + +[Prefix Weekday] + Expression : "next Friday" + Duration : 1300.42 ms + Speed : 76,898 ops/sec + +[Relative Offset] + Expression : "3 days ago" + Duration : 1265.60 ms + Speed : 79,014 ops/sec + +[Complex Expression] + Expression : "next Friday at 5:00pm" + Duration : 1580.71 ms + Speed : 63,263 ops/sec + +[Baseline Date] + Expression : "2025-01-01" + Duration : 1657.52 ms + Speed : 60,331 ops/sec + +Benchmark complete. + diff --git a/packages/tempo/bin/tempo.js b/packages/tempo/bin/tempo.js new file mode 100755 index 00000000..0bb5f30b --- /dev/null +++ b/packages/tempo/bin/tempo.js @@ -0,0 +1,70 @@ +#!/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/doc/releases/v3.x.md b/packages/tempo/doc/releases/v3.x.md index 22aae295..8aaf6b81 100644 --- a/packages/tempo/doc/releases/v3.x.md +++ b/packages/tempo/doc/releases/v3.x.md @@ -1,5 +1,76 @@ # 📜 Version 3.x History +## [v3.3.0] - 2026-06-21 + +### ✨ What's New — Localized Modifier Registry + +Tempo's relative-date keywords (`next`, `last`, `this`, `ago`, `hence`) were previously hardcoded as English-only constructs built into the core regex engine. **v3.3.0 opens this up completely.** + +You can now register your own locale-specific words for any directional operator using `registry.modifiers`. These words integrate transparently with all standard parsing paths — prefix position, suffix position, and the high-performance `#` slick shorthand: + +```typescript +Tempo.init({ + locale: 'fr-FR', // teaches Tempo French months & weekdays via Intl + registry: { + modifiers: { + '>': ['prochain', 'suivant'], // "next" synonyms + '<': ['dernier', 'passé'], // "last / previous" synonyms + '=': ['ce', 'cette'], // "this" synonyms + } + } +}); + +new Tempo('vendredi prochain'); // ✅ "next Friday" — fully French +new Tempo('1 mai prochain'); // ✅ "next May 1st" +new Tempo('#qtr.dernier'); // ✅ "previous quarter" via slick shorthand +``` + +English keywords (`next`, `last`, `ago`, `hence`, `this`) remain active by default and are **additively merged** — you never lose built-in behaviour when adding your own. + +### 🏗️ Internal Refactoring + +- **Frozen Default Registry Fixed**: The internal `Default` configuration object is wrapped in a deep-freeze Proxy (`secure()`). Previously, every `Tempo.init()` call silently failed to write `formats`, `locales`, and `modifiers` into the frozen registry sub-object, logging three `setProperty` warnings per call and abandoning the writes. The registry is now correctly shallow-cloned into a mutable copy on initialization. + +- **Lexer Token Cleanup**: All hardcoded English modifier keywords have been stripped from the core regex tokens (`Match.modifier`, `Match.shorthand`, `Match.slick`). The engine now operates purely on symbolic operators (`>`, `<`, `=`, `+`, `-`) internally and resolves all natural-language words through the registry at runtime. + +- **Pre-filter Guard Accuracy**: Refined the numeric-safety guard bypass to trigger only when the input *actually contains* a registered modifier keyword. Previously the guard was bypassed unconditionally whenever modifier config was present, which could silently accept invalid nanosecond epoch strings. + +### ⚡ Slick Modifier Semantics + +Localized slick modifiers behave identically to their symbolic counterparts. `#qtr.prochain` is an exact alias for `#qtr.>`: + +- From **inside** Q2 (any day from April 1 to June 29) → **July 1** (start of Q3) +- From **June 30** (last day of Q2) → **July 1** (start of Q3) +- From **July 1** (first day of Q3) → **October 1** (start of Q4) + +This mirrors how `next Friday` works: from any non-Friday you get the very next Friday; if you are already on Friday, you get the *following* Friday. Fully deterministic — "next" always means *"the start of the next occurrence of this term"*. + +--- + +## [v3.2.3] - 2026-06-20 + +### ✨ What's New — Project Scaffolding + +- **`tempo.config.ts` Pattern**: Introduced centralized project configuration via a discoverable `tempo.config.ts` / `tempo.config.js` file. This mirrors the `vite.config.ts` / `tailwind.config.js` convention — one file, one place, loaded once. +- **`Tempo.bootstrap()`**: A new async entry point that auto-discovers and loads your `tempo.config.ts` before any domain logic runs. Safe to `await` at application startup. +- **CLI Scaffold**: `npx @magmacomputing/tempo scaffold:all` bootstraps a `tempo.config.ts` and HTML sandbox into your project in seconds. + +--- + +## [v3.2.2] - 2026-06-18 + +### ✨ What's New + +- **Compact Date Tokens**: New 6-digit compact format tokens `{dmy6}`, `{mdy6}`, `{ymd6}` (e.g. `200626`), plus ISO week-of-year helpers `{yywy}` and `{yyww}`. +- **`{wy}` Rename**: The former `{ww}` token is now `{wy}` (week-of-year) to eliminate visual ambiguity with structural format tokens. + +### 🏗️ Internal Refactoring + +- **Recursive `deepMerge` Hardened**: The Intl options merge pipeline now uses a fully recursive `deepMerge` rather than a shallow spread, preventing nested keys like `intl.dateTimeFormat` from clobbering `intl.relativeTimeFormat`. +- **Prototype Pollution Guards**: Added strict guards against `__proto__`, `constructor`, and `prototype` key assignments in `deepMerge` and `deepFreeze` utilities. + +--- + ## [v3.2.1] - 2026-06-17 ### ✨ What's New diff --git a/packages/tempo/doc/tempo.config.md b/packages/tempo/doc/tempo.config.md index 0b2b9a05..16841a8b 100644 --- a/packages/tempo/doc/tempo.config.md +++ b/packages/tempo/doc/tempo.config.md @@ -20,13 +20,17 @@ Rather than scattering `Tempo.init()` or `Tempo.extend()` calls throughout your This mirrors modern ecosystem standards (like `vite.config.ts` or `tailwind.config.js`) and ensures that plugins, timezones, and custom aliases are consistently applied before any domain logic executes. +::: info +**Target Environment**: This automatic configuration discovery pattern relies on Node.js file system capabilities and is designed for Server, Fullstack, or Bundled environments (like Vite or Webpack). If you are using Tempo via a ` + + + +

Tempo Hello World

+
+ Initializing Tempo... +
+ + + + + \ No newline at end of file diff --git a/packages/tempo/template/locales.sample.ts b/packages/tempo/template/locales.sample.ts new file mode 100644 index 00000000..f40ce80c --- /dev/null +++ b/packages/tempo/template/locales.sample.ts @@ -0,0 +1,79 @@ +/** + * Tempo Locale Configuration Cookbook + * + * This file provides sample localized dictionaries and format patterns for Tempo. + * You can mix these configurations into your `tempo.config.ts` to add multi-language support. + * + * Localized Modifiers Map: + * - '>' maps to "next", "future" + * - '<' maps to "last", "previous", "past" + * - '=' maps to "this", "current" + */ + + + +// ========================================== +// French (fr) +// ========================================== +export const fr_FR = { + locale: 'fr-FR', + // Localized TimeZone resolution + timeZones: { + 'cet': 'Europe/Paris', + 'cest': 'Europe/Paris' + }, + // Localized custom formats & modifiers + registry: { + formats: { + 'frenchDate': '{dd} {mmm} {yyyy}', + 'frenchTime': '{hh}h{mi}', + }, + modifiers: { + '>': ['prochain', 'prochaine', 'dans', 'suivant', 'suivante'], + '<': ['dernier', 'dernière', 'il y a', 'passé', 'passée'], + '=': ['ce', 'cette'] + } + } +} + +// ========================================== +// Spanish (es) +// ========================================== +export const es_ES = { + locale: 'es-ES', + // Localized TimeZone resolution + timeZones: { + 'cet': 'Europe/Madrid', + 'cest': 'Europe/Madrid', + 'wet': 'Atlantic/Canary' + }, + // Localized custom formats & modifiers + registry: { + formats: { + 'spanishDate': '{dd} {mmm} {yyyy}', + 'spanishTime': '{hh}:{mi}', + }, + modifiers: { + '>': ['próximo', 'próxima', 'en', 'siguiente'], + '<': ['pasado', 'pasada', 'hace', 'último', 'última'], + '=': ['este', 'esta'] + } + } +} + +// ========================================== +// Usage Example +// ========================================== +/* +import { defineConfig } from '@magmacomputing/tempo'; +import { deepMerge } from '@magmacomputing/tempo/library'; +import { fr_FR, es_ES } from './locales.sample.js'; + +// To merge multiple locales: +// const combinedLocales = deepMerge(fr_FR, es_ES); + +export default defineConfig({ + // ... your other config + ...fr_FR +}); +*/ diff --git a/packages/tempo/template/tempo.config.sample.ts b/packages/tempo/template/tempo.config.sample.ts new file mode 100644 index 00000000..f8d48c04 --- /dev/null +++ b/packages/tempo/template/tempo.config.sample.ts @@ -0,0 +1,39 @@ +import { defineConfig } from '@magmacomputing/tempo'; + +/** + * Tempo Configuration + * + * This file acts as a centralized configuration for your application. + * When `Tempo.init()` is called without arguments, it will automatically + * discover and apply the settings below. + * + * Note: These settings act as overrides to the robust defaults in `support.default.ts`. + */ +export default defineConfig({ + // ------------------------------------------------------------------------- + // ------------------------------------------------------------------------- + // Core Engine Settings + // ------------------------------------------------------------------------- + /** Defines the default time zone (e.g. 'America/New_York' or 'UTC') */ + // timeZone: 'UTC', + + /** Defines the default locale used when formatting / parsing */ + // locale: 'en-US', + + // ------------------------------------------------------------------------- + // Layouts (Format Presets) + // ------------------------------------------------------------------------- + layouts: { + /** Example of a custom layout accessible via now.format('iso_date') */ + // iso_date: '{yyyy}-{mm}-{dd}', + // time_short: '{hh}:{mi} {mer}' + }, + + // ------------------------------------------------------------------------- + // Plugins + // ------------------------------------------------------------------------- + plugins: [ + // Import and instantiate your plugins here + // TickerPlugin({ interval: 1000 }) + ] +}); 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' + } + } +}; diff --git a/packages/tempo/test/core/bootstrap.test.ts b/packages/tempo/test/core/bootstrap.test.ts new file mode 100644 index 00000000..807615be --- /dev/null +++ b/packages/tempo/test/core/bootstrap.test.ts @@ -0,0 +1,34 @@ +import { Tempo } from '#tempo'; +import path from 'node:path'; + +describe('Tempo.bootstrap()', () => { + afterEach(() => { + Tempo.init(); + }); + + 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'); + + await Tempo.bootstrap({ cwd: fixtureDir }); + + expect(Tempo.config.timeZone).toBe('Europe/Paris'); + + const t = new Tempo('custom-bootstrap-period'); + expect(t.isValid).toBe(true); + expect(t.format('{h24}:{mi}')).toBe('13:00'); + }); + + it('should allow explicitly passing a configFile', async () => { + const fixtureDir = path.resolve('./test/core/__fixtures__/config'); + + // Pass the explicit file path + await Tempo.bootstrap({ configFile: path.join(fixtureDir, 'tempo.config.js') }); + + expect(Tempo.config.timeZone).toBe('Europe/Paris'); + + const t = new Tempo('custom-bootstrap-period'); + expect(t.isValid).toBe(true); + expect(t.format('{h24}:{mi}')).toBe('13:00'); + }); +}); diff --git a/packages/tempo/test/engine/modifiers.test.ts b/packages/tempo/test/engine/modifiers.test.ts new file mode 100644 index 00000000..575b9aad --- /dev/null +++ b/packages/tempo/test/engine/modifiers.test.ts @@ -0,0 +1,99 @@ +import { Tempo, defineConfig } from '#tempo'; + +describe('Localized Modifiers', () => { + + test('should parse localized prefix and suffix modifiers for weekdays', () => { + const config = defineConfig({ + timeZone: 'UTC', + registry: { + modifiers: { + '>': ['prochain'], + '<': ['dernier'] + } + } + }); + + // anchor: 2026-06-20T02:00:00Z → Saturday in UTC + const now = new Tempo('2026-06-20T12:00:00+10:00', config); + + const prochainFriday = new Tempo('prochain friday', { ...config, anchor: now }); + expect(prochainFriday.dow).toBe(5); + expect(prochainFriday.toPlainDate().toString() > now.toPlainDate().toString()).toBe(true); + + const fridayDernier = new Tempo('friday dernier', { ...config, anchor: now }); + expect(fridayDernier.dow).toBe(5); + expect(fridayDernier.toPlainDate().toString() < now.toPlainDate().toString()).toBe(true); + }); + + test('should parse localized prefix and suffix modifiers for explicit dates', () => { + const config = defineConfig({ + timeZone: 'UTC', + registry: { + modifiers: { + '>': ['prochain'], + '<': ['dernier'] + } + } + }); + + // anchor: 2026-06-20T02:00:00Z → Saturday in UTC + const now = new Tempo('2026-06-20T12:00:00+10:00', config); + + const nextMay = new Tempo('1 May prochain', { ...config, anchor: now }); + if (nextMay.yy !== 2027) throw new Error("nextMay.yy is " + nextMay.yy + " month: " + nextMay.mm + " day: " + nextMay.day); + expect(nextMay.mm).toBe(5); + expect(nextMay.day).toBe(1); + expect(nextMay.yy).toBeGreaterThan(2026); + + const pastMay = new Tempo('1 May dernier', { ...config, anchor: now }); + expect(pastMay.mm).toBe(5); + expect(pastMay.day).toBe(1); + expect(pastMay.yy).toBe(2026); + + const pastAugust = new Tempo('1 August dernier', { ...config, anchor: now }); + expect(pastAugust.mm).toBe(8); + expect(pastAugust.day).toBe(1); + expect(pastAugust.yy).toBe(2025); + }); + + test('should respect partial match fallback sorting (longest-match first)', () => { + const config = defineConfig({ + timeZone: 'UTC', + registry: { + modifiers: { + '>': ['dans', 'dans la', 'en'] + } + } + }); + + // anchor: 2026-06-20T02:00:00Z → Saturday in UTC + const now = new Tempo('2026-06-20T12:00:00+10:00', config); + + const futureDate = new Tempo('1 May dans la', { ...config, anchor: now }); + expect(futureDate.mm).toBe(5); + expect(futureDate.day).toBe(1); + expect(futureDate.yy).toBeGreaterThan(2026); + }); + + test('should map shorthand localized terms correctly', () => { + const config = defineConfig({ + timeZone: 'UTC', + sphere: 'north', + registry: { + modifiers: { + '>': ['prochain'] + } + } + }); + + // anchor: Saturday June 20 in UTC (pure UTC timestamp to avoid tz-leak into term resolver) + const now = new Tempo('2026-06-20T12:00:00Z', config); + + // shorthand shift using localized term. + // '#qtr.prochain' (>) is equivalent to '#qtr.next' / '#qtr.>'. + // From anywhere inside Q2, '>' resolves to July 1 (start of Q3). + // It only advances further (Oct 1) once the anchor is already ON July 1. + const nextQtr = new Tempo('#qtr.prochain', { ...config, anchor: now }); + expect(nextQtr.mm).toBeGreaterThan(6); // July (7) or later = Q3+ + }); +});