`).
diff --git a/packages/plugins/geo/src/index.ts b/packages/plugins/geo/src/index.ts
index 33527efc..fdc1d755 100644
--- a/packages/plugins/geo/src/index.ts
+++ b/packages/plugins/geo/src/index.ts
@@ -1,5 +1,5 @@
import { Tempo } from '@magmacomputing/tempo';
-import { definePlugin, type TempoPlugin } from '@magmacomputing/tempo/plugin/sdk';
+import { definePlugin, deepFreeze, type TempoPlugin } from '@magmacomputing/tempo/plugin/sdk';
import {
geoLookup,
resolveGeoCoordinates,
@@ -88,7 +88,7 @@ export const GeoPlugin: TempoPlugin = definePlugin({
}
Object.defineProperty(TempoClass, 'geo', {
- value: Object.freeze(geoNamespace),
+ value: deepFreeze(geoNamespace),
writable: false,
configurable: false,
enumerable: false,
diff --git a/packages/tempo/.vitepress/theme/data/catalog.json b/packages/tempo/.vitepress/theme/data/catalog.json
index df3c659d..5a5cab59 100644
--- a/packages/tempo/.vitepress/theme/data/catalog.json
+++ b/packages/tempo/.vitepress/theme/data/catalog.json
@@ -24,7 +24,7 @@
"packageName": "@magmacomputing/tempo-plugin-batch",
"plan": "community",
"status": "active",
- "version": "1.0.2"
+ "version": "1.1.0"
},
{
"id": "finance",
@@ -60,7 +60,7 @@
"packageName": "@magmacomputing/tempo-plugin-ai",
"plan": "community",
"status": "active",
- "version": "1.2.1"
+ "version": "2.0.0"
},
{
"id": "ticker",
diff --git a/packages/tempo/README.md b/packages/tempo/README.md
index ccbce593..795aeb3a 100644
--- a/packages/tempo/README.md
+++ b/packages/tempo/README.md
@@ -15,6 +15,7 @@
**Tempo** is a premium, high-performance wrapper for the ECMAScript `Temporal` API. Designed for professionals, it combines **immutable** state-management with a **fluent**, natural-language engine. It is the modern, type-safe successor to legacy libraries like Moment.js and Luxon.
+> **Our Mission: Humanizing Temporal.** Bringing developer intuition and expressive natural-language ergonomics to JavaScript dates—anchored in strict ISO 8601 standards. [Read our vision & architecture →](https://magmacomputing.github.io/magma/doc/7-ecosystem/vision.html)
diff --git a/packages/tempo/doc/7-ecosystem/vision.md b/packages/tempo/doc/7-ecosystem/vision.md
index 93e3f592..e59cd4e5 100644
--- a/packages/tempo/doc/7-ecosystem/vision.md
+++ b/packages/tempo/doc/7-ecosystem/vision.md
@@ -1,21 +1,26 @@
# The Vision for Tempo
-## Introduction
-While the `Temporal` API provides a mathematically sound foundation for dates in JavaScript, it is intentionally low-level and strict. **Tempo** acts as a developer-friendly wrapper that bridges the "onboarding gap" between raw inputs and Temporal's precision.
+## Introduction: Humanizing Temporal
+
+The ECMAScript `Temporal` specification represents a monumental leap forward for JavaScript dates, providing mathematical rigor, timezone correctness, and nanosecond precision. However, as an engine-level foundation, native `Temporal` is intentionally low-level, verbose, and rigid. Answering everyday human questions—such as *"What time is it where our client is right now?"*, *"Is it daylight or business hours at their location?"*, or *"How many business days until the next fiscal quarter?"*—can feel mechanically clunky, requiring developers to stitch together multiple disjoint types, offset normalizers, calendar converters, and external APIs.
+
+Tempo's primary mission is to **humanize Temporal**: transforming low-level precision into an intuitive, expressive, and effortless developer experience.
+
+We believe that developer ergonomics should never compromise mathematical correctness. Tempo achieves this balance through an unyielding commitment to **strict ISO 8601 standards**—ensuring deterministic calendar arithmetic, unambiguous week calculations (where Monday is always day 1), and complete immutability—while providing a warm, fluent API that understands human intent.
## Core Value Proposition
### 1. Closing the Onboarding Gap
-Modern developers expect a "warm" API for common tasks. Native Temporal requires explicit types and ISO formats for almost every operation. Tempo provides a familiar, intuitive entry point (similar to the ease of Day.js) while maintaining the rock-solid reliability of Temporal under the hood.
+Modern developers expect a "warm" API for common tasks. Native Temporal requires explicit types and strict ISO formats for almost every operation. Tempo provides a familiar, intuitive entry point (similar to the ease of Day.js) while maintaining the rock-solid reliability of Temporal under the hood.
### 2. Human-Centric Parsing
-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.
+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", "next Friday 3pm", 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.
+### 3. Contextual & Business Intelligence via Plugins
+Time and physical reality are inseparable: civil time only has meaning relative to a location, a business calendar, or natural cycles. Through its modular plugin architecture, Tempo bridges time with real-world context—such as IP and GPS geolocation (`@magmacomputing/tempo-plugin-geo`), fiscal quarters, meteorological seasons, and astronomical cycles—moving domain-specific complexity out of application code and into reusable, extensible plugins.
-### 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.
+### 4. Lean, Deterministic, and Hardened
+Tempo is designed to be a thin, highly capable layer. It prioritizes a lightweight, ergonomic API surface for the developer while maintaining robust internal logic to handle the complexities of timezones, calendars, and durations with zero mutable side effects.
## 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/index.md b/packages/tempo/index.md
index 817d7f13..9c3bd9e1 100644
--- a/packages/tempo/index.md
+++ b/packages/tempo/index.md
@@ -57,7 +57,7 @@ const features = [
{ title: 'Tempo AI Engine', details: 'Natural language date parsing, multi-provider dispatch (OpenAI, Gemini, Groq, Mistral), and slot resolution.', icon: '🤖' },
{ title: 'Tempo.ticker()', details: 'Community Plugin: State-of-the-art timing engine with AsyncGenerator support and native Daylight Saving Time resolution.', icon: '⏱️' },
{ title: 'Modular Plugins', details: 'Extend Tempo with specialized plugins for Snapping, High-Throughput Batching, and AI dispatch.', icon: '🔌' },
- { title: 'Temporal Inside', details: 'Built on the ECMAScript Temporal API. Inherit the reliability of the modern standard.', icon: '🏗️' },
+ { title: 'Humanizing Temporal', details: 'Transforming low-level Temporal complexity into intuitive, expressive APIs—anchored in strict ISO 8601 standards.', icon: '🧭' },
{ title: 'Cycle Persistence', details: 'Shift by semantic terms while preserving your relative day-of-period offset.', icon: '🔄' },
{ title: 'Business & Financial', details: 'Native support for fiscal quarters, zodiac cycles, and meteorological seasons for finance and analytics.', icon: '📈' }
]
diff --git a/packages/tempo/src/library.index.ts b/packages/tempo/src/library.index.ts
index 9a097f5b..5d16dd75 100644
--- a/packages/tempo/src/library.index.ts
+++ b/packages/tempo/src/library.index.ts
@@ -11,7 +11,7 @@ export * as webToken from '#library/webtoken.library.js';
export { enumify, type Enum } from '#library/enumerate.library.js';
export { stringify, objectify, cloneify } from '#library/serialize.library.js';
export { parseJSONC, stripJSONC, cleanify, isJSON, rawJSON, isRawJSON } from '#library/json.library.js';
-export { getContext, CONTEXT } from '#library/utility.library.js';
+export { getContext, CONTEXT, deepFreeze } from '#library/utility.library.js';
export { Interval } from '#library/scheduling/interval.class.js';
export * from '#library/proxy.library.js';
diff --git a/packages/tempo/src/support/support.index.ts b/packages/tempo/src/support/support.index.ts
index ba35fcf3..fe3fff6e 100644
--- a/packages/tempo/src/support/support.index.ts
+++ b/packages/tempo/src/support/support.index.ts
@@ -33,6 +33,7 @@ export {
} from './support.enum.js';
export { markConfig } from '#library/symbol.library.js';
+export { BoundedCache } from '#library/cache.class.js';
export { sym, isTempo, Token, TermError, type TempoBrand } from './support.symbol.js';
export { $Tempo, $Register, $Interpreter, $guard, $errored, $Internal, $Bridge, $RuntimeBrand, $Descriptor, $setConfig, $setDiscovery, $setEvents, $setPeriods, $setAliases, $buildGuard, $IsBase, $Identity, $LogConfig, $Discover } from './support.symbol.js';
export { registryUpdate, registryReset, onRegistryReset } from './support.register.js';
From 3d92d30df4261d9b5721031e166fab1b6fd05ae4 Mon Sep 17 00:00:00 2001
From: Michael McRae
Date: Wed, 9 Sep 2026 14:13:05 +1000
Subject: [PATCH 08/11] PR 4th review, geo plugin features
---
packages/functions/src/celestial/index.ts | 44 +-
packages/functions/test/celestial.test.ts | 21 +
packages/library/package.json | 2 +-
.../library/src/browser/mapper.library.ts | 2 +-
.../src/common/runtime/mapper.library.ts | 397 +++++++++++++++---
.../test/common/runtime/mapper.common.test.ts | 157 ++++++-
packages/plugins/celestial/src/index.ts | 48 ++-
.../plugins/celestial/test/celestial.test.ts | 10 +
packages/plugins/geo/plan/README.md | 251 +++++------
packages/plugins/geo/src/index.ts | 112 ++++-
packages/plugins/geo/test/geo.test.ts | 238 +++++++++--
packages/tempo/CHANGELOG.md | 18 +
.../tempo/doc/2-core-concepts/tempo.config.md | 33 ++
.../tempo/doc/2-core-concepts/tempo.format.md | 51 +++
.../tempo/doc/2-core-concepts/tempo.parse.md | 28 ++
packages/tempo/package.json | 2 +-
packages/tempo/public/esm_sh.index.html | 2 +-
packages/tempo/public/llms.txt | 4 +-
packages/tempo/src/module/module.format.ts | 38 +-
packages/tempo/src/support/support.enum.ts | 7 +-
packages/tempo/src/tempo.class.ts | 17 +-
packages/tempo/src/tempo.type.ts | 3 +-
packages/tempo/src/tempo.version.ts | 2 +-
.../tempo/test/core/constructor.core.test.ts | 14 +-
.../test/instance/instance.format.test.ts | 59 +++
25 files changed, 1237 insertions(+), 323 deletions(-)
diff --git a/packages/functions/src/celestial/index.ts b/packages/functions/src/celestial/index.ts
index e3032d52..6058fd17 100644
--- a/packages/functions/src/celestial/index.ts
+++ b/packages/functions/src/celestial/index.ts
@@ -427,6 +427,13 @@ export interface SolarOptions {
long?: number;
lng?: number;
lon?: number;
+ elevation?: number;
+ geo?: {
+ latitude?: number;
+ longitude?: number;
+ elevation?: number;
+ [key: string]: any;
+ };
}
export interface SolarTwilightWindow {
@@ -437,6 +444,7 @@ export interface SolarTwilightWindow {
export interface SunriseSunsetResult {
latitude: number;
longitude: number;
+ elevation?: number;
sunriseMs: number;
sunsetMs: number;
solarNoonMs: number;
@@ -451,39 +459,27 @@ export interface SunriseSunsetResult {
}
/**
- * Resolves latitude and longitude from positional arguments or a coordinate options object.
+ * Resolves latitude, longitude, and elevation from positional arguments or a coordinate options object.
*
* @param latOrOptions - A latitude value or options containing coordinate fields
* @param lngInput - The longitude used when `latOrOptions` is a numeric latitude
- * @returns An object containing the resolved `lat` and `lng` values
+ * @returns An object containing the resolved `lat`, `lng`, and `elevation` values
*/
-/**
- * Resolves latitude and longitude from positional arguments or a coordinate options object.
- *
- * @param latOrOptions - A latitude value or options containing coordinate fields
- * @param lngInput - The longitude used when `latOrOptions` is a numeric latitude
- * @returns An object containing the resolved `lat` and `lng` values
- */
-function resolveCoordinates(latOrOptions: number | SolarOptions = 0, lngInput = 0): { lat: number; lng: number } {
+function resolveCoordinates(latOrOptions: number | SolarOptions = 0, lngInput = 0): { lat: number; lng: number; elevation: number } {
if (typeof latOrOptions === 'number')
- return { lat: latOrOptions, lng: lngInput };
+ return { lat: latOrOptions, lng: lngInput, elevation: 0 };
if (latOrOptions && typeof latOrOptions === 'object') {
const geo = (latOrOptions as any).geo ?? latOrOptions;
const lat = geo.latitude ?? geo.lat ?? (latOrOptions as any).latitude ?? (latOrOptions as any).lat ?? 0;
const lng = geo.longitude ?? geo.lng ?? geo.lon ?? geo.long ?? (latOrOptions as any).longitude ?? (latOrOptions as any).lng ?? (latOrOptions as any).lon ?? (latOrOptions as any).long ?? 0;
- return { lat, lng };
+ const rawElevation = geo.elevation ?? (latOrOptions as any).elevation;
+ const elevation = typeof rawElevation === 'number' && !isNaN(rawElevation) ? rawElevation : 0;
+ return { lat, lng, elevation };
}
- return { lat: 0, lng: 0 };
+ return { lat: 0, lng: 0, elevation: 0 };
}
-/**
- * Determines the UTC start of the calendar day at a specified longitude.
- *
- * @param epochMs - The input timestamp in milliseconds since the Unix epoch
- * @param lng - The longitude in degrees used to determine the local date
- * @returns The UTC start timestamp, local date, and longitude-adjusted timestamp
- */
/**
* Determines the UTC start of the calendar day at a specified longitude.
*
@@ -517,10 +513,11 @@ export function getSunriseSunset(
? new Date(dateInput).getTime()
: dateInput.getTime();
- const { lat, lng } = resolveCoordinates(latOrOptions, lonInput);
+ const { lat, lng, elevation } = resolveCoordinates(latOrOptions, lonInput);
const { startOfDayMs, localDate, localMs } = getStartOfLocalDayMs(epochMs, lng);
- // Solar calculations using standard zenith (90.833°)
+ // Solar calculations using standard zenith (90.833°) adjusted for atmospheric horizon dip
+ const dipDeg = elevation > 0 ? 0.0347 * Math.sqrt(elevation) : 0;
const dayOfYear = Math.floor((localMs - Date.UTC(localDate.getUTCFullYear(), 0, 0)) / 86400000);
const gamma = (2 * Math.PI / 365) * (dayOfYear - 1);
@@ -543,7 +540,7 @@ export function getSunriseSunset(
return Math.acos(cosHA) * (180 / Math.PI);
};
- const haDeg = calcHaDeg(90.833);
+ const haDeg = calcHaDeg(90.833 + dipDeg);
const haMin = haDeg * 4;
const sunriseMs = startOfDayMs + ((solarNoonMin - haMin) * 60000);
const sunsetMs = startOfDayMs + ((solarNoonMin + haMin) * 60000);
@@ -589,6 +586,7 @@ export function getSunriseSunset(
return {
latitude: lat,
longitude: lng,
+ ...(elevation > 0 ? { elevation } : {}),
sunriseMs,
sunsetMs,
solarNoonMs,
diff --git a/packages/functions/test/celestial.test.ts b/packages/functions/test/celestial.test.ts
index 90393572..cc502b8f 100644
--- a/packages/functions/test/celestial.test.ts
+++ b/packages/functions/test/celestial.test.ts
@@ -82,6 +82,27 @@ describe('Astro Pure Functions (tempo-fns)', () => {
expect(nightRes.index).toBe(1); // 1-based (1 = night)
});
+ it('factors elevation into apparent sunrise/sunset and daylight duration via horizon dip', () => {
+ const date = new Date('2026-06-21T12:00:00Z');
+ // Sea level in Denver (lat 39.7392, lng -104.9903, elevation 0m)
+ const seaLevel = getSunriseSunset(date, { lat: 39.7392, lng: -104.9903, elevation: 0 });
+ // Actual Mile High City elevation (1600m above sea level)
+ const highAltitude = getSunriseSunset(date, { lat: 39.7392, lng: -104.9903, elevation: 1600 });
+
+ // Higher elevation causes horizon dip: sunrise is earlier, sunset is later
+ expect(highAltitude.sunriseMs).toBeLessThan(seaLevel.sunriseMs);
+ expect(highAltitude.sunsetMs).toBeGreaterThan(seaLevel.sunsetMs);
+ expect(highAltitude.daylightDurationMs).toBeGreaterThan(seaLevel.daylightDurationMs);
+ // Solar noon remains unchanged by elevation
+ expect(highAltitude.solarNoonMs).toBe(seaLevel.solarNoonMs);
+
+ // Difference in sunrise/sunset is approx 8.5 minutes for 1600m at latitude ~40°N
+ const diffMin = (seaLevel.sunriseMs - highAltitude.sunriseMs) / 60000;
+ expect(diffMin).toBeGreaterThan(7);
+ expect(diffMin).toBeLessThan(10);
+ expect(highAltitude.elevation).toBe(1600);
+ });
+
it('calculates Western Tropical Zodiac sign', () => {
expect(getZodiacSign('2026-03-25')).toBe('Aries');
expect(getZodiacSign('2026-07-25')).toBe('Leo');
diff --git a/packages/library/package.json b/packages/library/package.json
index bf0b8b45..eaca4101 100644
--- a/packages/library/package.json
+++ b/packages/library/package.json
@@ -1,6 +1,6 @@
{
"name": "@magmacomputing/library",
- "version": "4.1.2",
+ "version": "4.2.0",
"description": "Shared utility library for Tempo",
"author": "Magma Computing Solutions",
"license": "MIT",
diff --git a/packages/library/src/browser/mapper.library.ts b/packages/library/src/browser/mapper.library.ts
index 2ecb45e6..70a3cf5a 100644
--- a/packages/library/src/browser/mapper.library.ts
+++ b/packages/library/src/browser/mapper.library.ts
@@ -27,7 +27,7 @@ interface MapStore { // a localStorage object
const defaults = { catch: true, debug: 0 } as MapOpts; // default Options
const context = getContext(); // browser / nodejs / google-apps
const mapStore = {} as MapStore; // static object to hold last position
-const MAP_KEY = '_magma_geo_'; // localStorage key
+const MAP_KEY = '_magma_browser_map_'; // distinct browser MapStore localStorage key
const log = new Logger('[Mapper]');
let storePromise: Promise | null = null;
diff --git a/packages/library/src/common/runtime/mapper.library.ts b/packages/library/src/common/runtime/mapper.library.ts
index 0fdd2e94..7b00aad7 100644
--- a/packages/library/src/common/runtime/mapper.library.ts
+++ b/packages/library/src/common/runtime/mapper.library.ts
@@ -1,16 +1,60 @@
import { CONTEXT, getContext } from '#library/utility.library.js';
-import { isNullish, isNumber, isString, isSafeKey, isObject, isEmpty } from '#library/assertion.library.js';
+import { isNullish, isNumber, isString, isSafeKey, isObject, isEmpty, isReference, isPrimitive } from '#library/assertion.library.js';
import { getStorage, setStorage } from '#library/storage.library.js';
+import { evaluate } from '#library/evaluation.library.js';
+
+/**
+ * Supported hemisphere zones including the equatorial band.
+ */
+export type GeoSphere = 'north' | 'south' | 'equator';
+
+/**
+ * Supported units for geographic distance calculation.
+ */
+export type DistanceUnit = 'km' | 'miles' | 'm';
+
+/**
+ * Supported units for solar offset calculation.
+ */
+export type SolarOffsetUnit = 'minutes' | 'seconds' | 'hours';
+
+/**
+ * Configuration options for natural solar time offset calculation.
+ */
+export interface SolarOffsetOptions {
+ /** Unit of return value (default: 'minutes') */
+ unit?: SolarOffsetUnit;
+ /** Number of decimal places to round (default: 2) */
+ precision?: number;
+ /** Whether to compute apparent solar time incorporating Equation of Time (default: false for mean solar time) */
+ apparent?: boolean;
+ /** Explicit IANA timezone override (defaults to coords.timezone or ambient timezone) */
+ timeZone?: string;
+ /** Reference date/time for DST offset and Equation of Time calculations (default: Date.now()) */
+ date?: Date | number | any;
+}
export interface GeoLookupResult {
lat?: number | undefined;
lng?: number | undefined;
latitude?: number | undefined;
longitude?: number | undefined;
+ elevation?: number | undefined;
+ sphere?: GeoSphere | undefined;
+ country?: string | undefined;
+ city?: string | undefined;
+ timezone?: string | undefined;
+ status?: string | undefined;
error?: string | undefined;
[key: string]: any;
}
+export interface ResolvedCoordinates extends GeoConfig {
+ lat: number;
+ lng: number;
+ [key: string]: any;
+}
+
export interface GeoOptions {
/** Latitude coordinate in degrees */
latitude?: number | undefined;
@@ -26,10 +70,16 @@ export interface GeoOptions {
long?: number | undefined;
/** Altitude / Elevation in meters above sea level */
elevation?: number | undefined;
+ /** Inferred or explicit hemisphere ('north' | 'south' | 'equator') */
+ sphere?: GeoSphere | undefined;
/** ISO country code (e.g. 'US', 'AU') */
country?: string | undefined;
/** City or locality name */
city?: string | undefined;
+ /** IANA Time Zone ID (e.g. 'Australia/Sydney') */
+ timezone?: string | undefined;
+ /** Time zone alias @internal */
+ tz?: string | undefined;
/** Custom or future string/number key property */
[key: string]: any;
/** Custom symbol key property */
@@ -37,18 +87,20 @@ export interface GeoOptions {
}
export interface GeoConfig {
- /** Latitude coordinate in degrees */
+ /** Latitude coordinate in degrees (-90 to 90) */
latitude?: number | undefined;
- /** Longitude coordinate in degrees */
+ /** Longitude coordinate in degrees (-180 to 180) */
longitude?: number | undefined;
/** Altitude / Elevation in meters above sea level */
elevation?: number | undefined;
- /** Inferred or explicit hemisphere ('north' | 'south') */
- sphere?: 'north' | 'south' | undefined;
+ /** Inferred or explicit hemisphere ('north' | 'south' | 'equator') */
+ sphere?: GeoSphere | undefined;
/** ISO country code (e.g. 'US', 'AU') */
country?: string | undefined;
/** City or locality name */
city?: string | undefined;
+ /** IANA Time Zone ID (e.g. 'Australia/Sydney') */
+ timezone?: string | undefined;
/** Custom or future string/number key property */
[key: string]: any;
/** Custom symbol key property */
@@ -70,7 +122,11 @@ export interface CoordinateInput {
const MAP_KEY = '_magma_geo_';
const DEFAULT_GEO_TTL = 24 * 60 * 60 * 1000; // 24 hours
-const GEO_PROPERTIES = [
+/**
+ * Canonical geographic property keys used for property segregation and custom key preservation.
+ * @internal
+ */
+export const GEO_PROPERTIES = [
'latitude',
'lat',
'longitude',
@@ -81,50 +137,124 @@ const GEO_PROPERTIES = [
'sphere',
'country',
'city',
+ 'timezone',
+ 'tz',
] as const;
+/**
+ * Normalizes latitude to 3 decimal places if within [-90, 90] bounds.
+ * @internal
+ */
+const normalizeLat = (lat: any): number | undefined =>
+ isNumber(lat) && lat >= -90 && lat <= 90 ? Math.round(lat * 1000) / 1000 : undefined;
+
+/**
+ * Normalizes longitude to 3 decimal places if within [-180, 180] bounds.
+ * @internal
+ */
+const normalizeLng = (lng: any): number | undefined =>
+ isNumber(lng) && lng >= -180 && lng <= 180 ? Math.round(lng * 1000) / 1000 : undefined;
+
+/**
+ * Validates and normalizes coordinate pairs to 3 decimal places within Earth boundaries.
+ * @internal
+ */
+const normalizeCoords = (lat: any, lng: any): { lat: number; lng: number } | undefined => {
+ const nLat = normalizeLat(lat);
+ const nLng = normalizeLng(lng);
+ return (nLat !== undefined && nLng !== undefined) ? { lat: nLat, lng: nLng } : undefined;
+};
+
+/**
+ * Resolves hemisphere: preserves explicit sphere if valid, or infers from latitude (+/- 0.001 band).
+ * @internal
+ */
+const resolveSphere = (sphere?: any, lat?: number): GeoSphere | undefined => {
+ if (sphere === 'north' || sphere === 'south' || sphere === 'equator') return sphere;
+ if (isNumber(lat)) return lat > 0.001 ? 'north' : (lat < -0.001 ? 'south' : 'equator');
+ return undefined;
+};
+
/**
* Extracts and coerces latitude and longitude from input object (options, config, or instance)
* into a canonical `{ latitude, longitude, ... }` GeoConfig object.
+ * Enforces coordinate boundaries (-90 to 90 for lat, -180 to 180 for lng), 3-decimal rounding,
+ * and automatic hemisphere inference ('north' | 'south' | 'equator').
*
* @param input - Optional object containing coordinate or geo properties
*/
export const coerceGeo = (input?: any): GeoConfig | undefined => {
- if (isNullish(input) || !isObject(input)) return undefined;
+ if (isString(input) && input.includes(',')) {
+ const parts = input.split(',').map((s: string) => Number(s.trim()));
+ if (parts.length >= 2 && !isNaN(parts[0]) && !isNaN(parts[1])) {
+ const coords = normalizeCoords(parts[0], parts[1]);
+ if (coords) {
+ const result: GeoConfig = { latitude: coords.lat, longitude: coords.lng };
+ const sphere = resolveSphere(undefined, coords.lat);
+ if (sphere) result.sphere = sphere;
+ return result;
+ }
+ }
+ return undefined;
+ }
+
+ if (isPrimitive(input)) return undefined;
+
+ if (Array.isArray(input) && input.length >= 2) {
+ const coords = normalizeCoords(input[0], input[1]);
+ if (coords) {
+ const result: GeoConfig = { latitude: coords.lat, longitude: coords.lng };
+ const sphere = resolveSphere(undefined, coords.lat);
+ if (sphere) result.sphere = sphere;
+ return result;
+ }
+ return undefined;
+ }
- const geoObj = input.geo ?? input;
- const cfgGeo = input.config?.geo ?? input.config;
+ const geo = input.geo ?? input;
+ const cfg = input.config?.geo ?? input.config;
- const lat = geoObj?.latitude ?? geoObj?.lat
- ?? input.latitude ?? input.lat
- ?? cfgGeo?.latitude ?? cfgGeo?.lat;
+ const lat = evaluate(
+ geo?.latitude, geo?.lat,
+ input.latitude, input.lat,
+ cfg?.latitude, cfg?.lat
+ );
- const lng = geoObj?.longitude ?? geoObj?.lng ?? geoObj?.lon ?? geoObj?.long
- ?? input.longitude ?? input.lng ?? input.lon ?? input.long
- ?? cfgGeo?.longitude ?? cfgGeo?.lng ?? cfgGeo?.lon ?? cfgGeo?.long;
+ const lng = evaluate(
+ geo?.longitude, geo?.lng, geo?.lon, geo?.long,
+ input.longitude, input.lng, input.lon, input.long,
+ cfg?.longitude, cfg?.lng, cfg?.lon, cfg?.long
+ );
- const elevation = geoObj?.elevation ?? input.elevation ?? cfgGeo?.elevation;
- const sphere = geoObj?.sphere ?? input.sphere ?? cfgGeo?.sphere;
- const country = geoObj?.country ?? input.country ?? cfgGeo?.country;
- const city = geoObj?.city ?? input.city ?? cfgGeo?.city;
+ const elevation = evaluate(geo?.elevation, input.elevation, cfg?.elevation);
+ const sphere = evaluate(geo?.sphere, input.sphere, cfg?.sphere);
+ const country = evaluate(geo?.country, input.country, cfg?.country);
+ const city = evaluate(geo?.city, input.city, cfg?.city);
+ const timezone = evaluate(geo?.timezone, geo?.tz, input.timezone, input.tz, cfg?.timezone, cfg?.tz);
const result: GeoConfig = {};
- if (isNumber(lat)) result.latitude = lat;
- if (isNumber(lng)) result.longitude = lng;
- if (isNumber(elevation)) result.elevation = elevation;
- if (sphere === 'north' || sphere === 'south') result.sphere = sphere;
+ const nLat = normalizeLat(lat);
+ const nLng = normalizeLng(lng);
+ if (nLat !== undefined) result.latitude = nLat;
+ if (nLng !== undefined) result.longitude = nLng;
+ if (isNumber(elevation)) result.elevation = Math.round(elevation * 1000) / 1000;
+
+ const resolvedSphere = resolveSphere(sphere, result.latitude);
+ if (resolvedSphere) result.sphere = resolvedSphere;
+
if (isString(country)) result.country = country;
if (isString(city)) result.city = city;
+ if (isString(timezone)) result.timezone = timezone;
- if (isObject(geoObj)) {
- for (const key of Object.keys(geoObj)) {
+ if (isReference(geo)) {
+ for (const key of Object.keys(geo)) {
if (isSafeKey(key) && !GEO_PROPERTIES.includes(key as any))
- (result as any)[key] = geoObj[key];
+ (result as any)[key] = geo[key];
}
}
return Object.keys(result).length > 0 ? result : undefined;
-}
+};
/**
* Helper to resolve storage cache keys for single-tenant default or multi-tenant scoped lookups.
@@ -172,22 +302,29 @@ export const getStashedGeo = (keyOrOpts?: string | Record): GeoConf
if (isString(parsed) && parsed.includes(',')) {
const parts = parsed.split(',').map(s => parseFloat(s.trim()));
- if (parts.length >= 2 && isNumber(parts[0]) && isNumber(parts[1])) {
- return { latitude: parts[0], longitude: parts[1] };
+ const coords = normalizeCoords(parts[0], parts[1]);
+ if (coords) {
+ return {
+ latitude: coords.lat,
+ longitude: coords.lng,
+ sphere: resolveSphere(undefined, coords.lat),
+ };
}
}
if (isObject(parsed)) {
- const coords = parsed.geolocation?.coords ?? parsed.coords ?? parsed;
- const lat = coords?.latitude ?? coords?.lat;
- const lng = coords?.longitude ?? coords?.lng ?? coords?.lon ?? coords?.long;
- if (isNumber(lat) && isNumber(lng)) {
- const result: GeoConfig = { latitude: lat, longitude: lng };
- const elevation = parsed.elevation ?? coords.elevation;
- if (isNumber(elevation)) result.elevation = elevation;
- if (parsed.sphere === 'north' || parsed.sphere === 'south') result.sphere = parsed.sphere;
+ const rawCoords = parsed.geolocation?.coords ?? parsed.coords ?? parsed;
+ const lat = rawCoords?.latitude ?? rawCoords?.lat;
+ const lng = rawCoords?.longitude ?? rawCoords?.lng ?? rawCoords?.lon ?? rawCoords?.long;
+ const coords = normalizeCoords(lat, lng);
+ if (coords) {
+ const result: GeoConfig = { latitude: coords.lat, longitude: coords.lng };
+ const elevation = parsed.elevation ?? rawCoords.elevation;
+ if (isNumber(elevation)) result.elevation = Math.round(elevation * 1000) / 1000;
+ result.sphere = resolveSphere(parsed.sphere, coords.lat);
if (isString(parsed.country)) result.country = parsed.country;
if (isString(parsed.city)) result.city = parsed.city;
+ if (isString(parsed.timezone)) result.timezone = parsed.timezone;
return result;
}
}
@@ -272,7 +409,7 @@ export const geoLookup = async (opts: Record = {}): Promise = {}): Promise = {}): Promise = {}): Promise = {}
-): Promise<{ lat: number; lng: number } | null> => {
+): Promise => {
const coerced = coerceGeo(input);
if (coerced && isNumber(coerced.latitude) && isNumber(coerced.longitude))
- return { lat: coerced.latitude, lng: coerced.longitude };
+ return {
+ ...coerced,
+ lat: coerced.latitude,
+ lng: coerced.longitude,
+ };
const stashed = getStashedGeo(opts);
if (stashed && isNumber(stashed.latitude) && isNumber(stashed.longitude))
- return { lat: stashed.latitude, lng: stashed.longitude };
+ return {
+ ...stashed,
+ lat: stashed.latitude,
+ lng: stashed.longitude,
+ };
const lookup = await geoLookup(opts);
- if (isNullish(lookup.error) && isNumber(lookup.lat) && isNumber(lookup.lng))
- return { lat: lookup.lat, lng: lookup.lng };
+ const coords = normalizeCoords(lookup.lat, lookup.lng);
+ if (isNullish(lookup.error) && coords) {
+ const sphere = resolveSphere(lookup.sphere, coords.lat);
+ return {
+ ...lookup,
+ lat: coords.lat,
+ lng: coords.lng,
+ latitude: coords.lat,
+ longitude: coords.lng,
+ sphere,
+ };
+ }
return null;
};
+/**
+ * Calculates the Great-Circle distance between two coordinates using the Haversine formula.
+ * Accepts coordinate objects, [lat, lng] tuples, strings, or instances exposing .geo.
+ *
+ * @param from - Origin coordinate, object, tuple, or instance
+ * @param to - Destination coordinate, object, tuple, or instance
+ * @param unit - Distance unit ('km', 'miles', or 'm'; default: 'km')
+ * @returns Calculated distance, or NaN if either coordinate pair is invalid
+ */
+export function haversineDistance(from: any, to: any, unit: DistanceUnit = 'km'): number {
+ const c1 = coerceGeo(from);
+ const c2 = coerceGeo(to);
+
+ if (!c1 || !c2 || !isNumber(c1.latitude) || !isNumber(c1.longitude) || !isNumber(c2.latitude) || !isNumber(c2.longitude)) {
+ return NaN;
+ }
+
+ const toRad = Math.PI / 180;
+ const lat1 = c1.latitude * toRad;
+ const lng1 = c1.longitude * toRad;
+ const lat2 = c2.latitude * toRad;
+ const lng2 = c2.longitude * toRad;
+
+ const dLat = lat2 - lat1;
+ const dLng = lng2 - lng1;
+
+ const a = Math.sin(dLat / 2) ** 2 + Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLng / 2) ** 2;
+ const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
+
+ let radius: number;
+ if (unit === 'miles') {
+ radius = 3958.7613; // Mean Earth radius in miles
+ } else if (unit === 'm') {
+ radius = 6371008.8; // Mean Earth radius in meters
+ } else {
+ radius = 6371.0088; // Mean Earth radius in kilometers (default)
+ }
+
+ const dist = radius * c;
+ return unit === 'm' ? Math.round(dist) : Math.round(dist * 1000) / 1000;
+}
+
+/**
+ * Calculates the Natural Solar Time Offset between civil clock time and actual solar time.
+ * Based on longitude (approx 4 minutes per 1° offset from standard timezone meridian).
+ *
+ * @param coords - Coordinate input, object, tuple, or instance exposing .geo
+ * @param options - Configuration options for units, precision, apparent time, timezone, and date
+ * @returns Solar offset in requested unit (default: minutes, rounded to 2 decimal places), or NaN if invalid
+ */
+export function solarOffset(coords: any, options?: SolarOffsetOptions): number {
+ const geo = coerceGeo(coords);
+ if (!geo || !isNumber(geo.latitude) || !isNumber(geo.longitude) || isNaN(geo.latitude) || isNaN(geo.longitude)) {
+ return NaN;
+ }
+
+ const lng = geo.longitude;
+ const tz = options?.timeZone
+ ?? (typeof coords === 'object' && coords !== null ? (coords.timezone ?? coords.tz) : undefined)
+ ?? geo.timezone;
+
+ const dateVal = options?.date
+ ?? (typeof coords === 'object' && coords !== null && typeof (coords as any).epoch?.ms === 'number' ? (coords as any).epoch.ms : Date.now());
+
+ let offsetMinutes: number | undefined;
+
+ const epochMs = typeof dateVal === 'number'
+ ? dateVal
+ : (typeof dateVal === 'string'
+ ? Date.parse(dateVal)
+ : (dateVal instanceof Date
+ ? dateVal.getTime()
+ : (typeof dateVal?.epoch?.ms === 'number' ? dateVal.epoch.ms : Date.now())));
+
+ if (isString(tz) && tz.trim() !== '') {
+ try {
+ if (typeof Temporal !== 'undefined' && typeof Temporal.Instant?.fromEpochMilliseconds === 'function') {
+ const zdt = Temporal.Instant.fromEpochMilliseconds(epochMs).toZonedDateTimeISO(tz);
+ offsetMinutes = zdt.offsetNanoseconds / 60_000_000_000;
+ } else {
+ const parts = new Intl.DateTimeFormat('en-US', {
+ timeZone: tz,
+ timeZoneName: 'longOffset',
+ year: 'numeric'
+ }).formatToParts(new Date(epochMs));
+ const tzPart = parts.find(p => p.type === 'timeZoneName')?.value;
+ if (tzPart) {
+ const match = tzPart.match(/GMT([+-])(\d{2}):(\d{2})/);
+ if (match) {
+ const sign = match[1] === '-' ? -1 : 1;
+ offsetMinutes = sign * (parseInt(match[2], 10) * 60 + parseInt(match[3], 10));
+ }
+ }
+ }
+ } catch {
+ // invalid timezone, fall back to natural meridian
+ }
+ }
+
+ // If no civil timezone, use natural solar timezone meridian (round(lng / 15) * 15)
+ const refMeridian = offsetMinutes !== undefined
+ ? (offsetMinutes / 60) * 15
+ : Math.round(lng / 15) * 15;
+
+ const deltaLng = lng - refMeridian;
+ let offsetMin = deltaLng * 4;
+
+ if (options?.apparent) {
+ const d = new Date(epochMs);
+ const startOfYear = Date.UTC(d.getUTCFullYear(), 0, 1);
+ const dayOfYear = Math.floor((epochMs - startOfYear) / 86400000) + 1;
+ const gamma = (2 * Math.PI / 365) * (dayOfYear - 1);
+ const eqTime = 229.18 * (0.000075 + 0.001868 * Math.cos(gamma) - 0.032077 * Math.sin(gamma) - 0.014615 * Math.cos(2 * gamma) - 0.040849 * Math.sin(2 * gamma));
+ offsetMin += eqTime;
+ }
+
+ const unit = options?.unit ?? 'minutes';
+ let res: number;
+ if (unit === 'seconds') {
+ res = offsetMin * 60;
+ } else if (unit === 'hours') {
+ res = offsetMin / 60;
+ } else {
+ res = offsetMin;
+ }
+
+ const precision = options?.precision ?? 2;
+ const factor = Math.pow(10, precision);
+ return Math.round(res * factor) / factor;
+}
+
diff --git a/packages/library/test/common/runtime/mapper.common.test.ts b/packages/library/test/common/runtime/mapper.common.test.ts
index f4aaa3f1..59a7d01f 100644
--- a/packages/library/test/common/runtime/mapper.common.test.ts
+++ b/packages/library/test/common/runtime/mapper.common.test.ts
@@ -1,4 +1,4 @@
-import { coerceGeo, geoLookup, resolveGeoCoordinates, getStashedGeo, stashGeo, clearStashedGeo } from '../../../src/common/runtime/mapper.library.js';
+import { coerceGeo, geoLookup, resolveGeoCoordinates, getStashedGeo, stashGeo, clearStashedGeo, haversineDistance, solarOffset } from '../../../src/common/runtime/mapper.library.js';
import { setStorage, clearStorage } from '#library/storage.library.js';
describe('common/runtime/mapper.library', () => {
@@ -13,12 +13,48 @@ describe('common/runtime/mapper.library', () => {
it('resolveGeoCoordinates extracts coordinates synchronously if present', async () => {
const coords = await resolveGeoCoordinates({ latitude: -33.8688, longitude: 151.2093 });
- expect(coords).toEqual({ lat: -33.8688, lng: 151.2093 });
+ expect(coords).toMatchObject({ lat: -33.869, lng: 151.209, sphere: 'south' });
});
it('resolveGeoCoordinates extracts coordinates from config sub-object', async () => {
const coords = await resolveGeoCoordinates({ config: { lat: 40.7128, lng: -74.0060 } });
- expect(coords).toEqual({ lat: 40.7128, lng: -74.0060 });
+ expect(coords).toMatchObject({ lat: 40.713, lng: -74.006, sphere: 'north' });
+ });
+
+ it('enforces coordinate boundary validation in coerceGeo', () => {
+ // Valid boundary edges
+ expect(coerceGeo({ lat: 90, lng: 180 })).toMatchObject({ latitude: 90, longitude: 180 });
+ expect(coerceGeo({ lat: -90, lng: -180 })).toMatchObject({ latitude: -90, longitude: -180 });
+
+ // Out of bounds latitude (> 90 or < -90) is excluded
+ expect(coerceGeo({ lat: 90.001, lng: 0 })?.latitude).toBeUndefined();
+ expect(coerceGeo({ lat: -90.001, lng: 0 })?.latitude).toBeUndefined();
+ expect(coerceGeo({ lat: 100 })).toBeUndefined();
+
+ // Out of bounds longitude (> 180 or < -180) is excluded
+ expect(coerceGeo({ lat: 0, lng: 180.001 })?.longitude).toBeUndefined();
+ expect(coerceGeo({ lat: 0, lng: -180.001 })?.longitude).toBeUndefined();
+ expect(coerceGeo({ lng: -200 })).toBeUndefined();
+ });
+
+ it('infers hemisphere (north | south | equator) with 3-decimal precision', () => {
+ // North (> 0.001)
+ expect(coerceGeo({ lat: 0.002, lng: 10 })?.sphere).toBe('north');
+ expect(coerceGeo({ lat: 45.0, lng: 10 })?.sphere).toBe('north');
+
+ // South (< -0.001)
+ expect(coerceGeo({ lat: -0.002, lng: 10 })?.sphere).toBe('south');
+ expect(coerceGeo({ lat: -33.869, lng: 151.209 })?.sphere).toBe('south');
+
+ // Equator (abs(lat) <= 0.001)
+ expect(coerceGeo({ lat: 0, lng: 10 })?.sphere).toBe('equator');
+ expect(coerceGeo({ lat: 0.001, lng: 10 })?.sphere).toBe('equator');
+ expect(coerceGeo({ lat: -0.001, lng: 10 })?.sphere).toBe('equator');
+ expect(coerceGeo({ lat: 0.0005, lng: 10 })?.sphere).toBe('equator');
+
+ // Explicit sphere override wins
+ expect(coerceGeo({ lat: 45, lng: 10, sphere: 'south' })?.sphere).toBe('south');
+ expect(coerceGeo({ lat: -45, lng: 10, sphere: 'equator' })?.sphere).toBe('equator');
});
it('coerceGeo excludes dangerous prototype keys (__proto__, constructor, prototype)', () => {
@@ -42,7 +78,7 @@ describe('common/runtime/mapper.library', () => {
try {
const coords = await resolveGeoCoordinates({ city: 'Sydney', elevation: 150 });
- expect(coords).toEqual({ lat: -33.8688, lng: 151.2093 });
+ expect(coords).toMatchObject({ lat: -33.869, lng: 151.209, sphere: 'south' });
expect(fetchSpy).not.toHaveBeenCalled();
} finally {
vi.unstubAllGlobals();
@@ -65,21 +101,22 @@ describe('common/runtime/mapper.library', () => {
try {
// First call queries the network and stashes coordinates
const result = await geoLookup();
- expect(result.lat).toBe(-33.8688);
- expect(result.lng).toBe(151.2093);
+ expect(result.lat).toBe(-33.869);
+ expect(result.lng).toBe(151.209);
expect(result.city).toBe('Sydney');
+ expect(result.sphere).toBe('south');
expect(mockFetch).toHaveBeenCalledTimes(1);
// Second call returns cached coordinates without hitting fetch
const cached = await geoLookup();
- expect(cached.lat).toBe(-33.8688);
+ expect(cached.lat).toBe(-33.869);
expect(cached.city).toBe('Sydney');
expect(cached.status).toBe('cached');
expect(mockFetch).toHaveBeenCalledTimes(1);
// refresh: true forces a fresh network call
const refreshed = await geoLookup({ refresh: true });
- expect(refreshed.lat).toBe(-33.8688);
+ expect(refreshed.lat).toBe(-33.869);
expect(mockFetch).toHaveBeenCalledTimes(2);
} finally {
vi.unstubAllGlobals();
@@ -116,8 +153,8 @@ describe('common/runtime/mapper.library', () => {
const stashed = getStashedGeo();
expect(stashed).toBeDefined();
- expect(stashed?.latitude).toBe(-33.8688);
- expect(stashed?.longitude).toBe(151.2093);
+ expect(stashed?.latitude).toBe(-33.869);
+ expect(stashed?.longitude).toBe(151.209);
expect(stashed?.city).toBe('Sydney');
});
@@ -126,8 +163,8 @@ describe('common/runtime/mapper.library', () => {
const stashed = getStashedGeo();
expect(stashed).toBeDefined();
- expect(stashed?.latitude).toBe(37.7749);
- expect(stashed?.longitude).toBe(-122.4194);
+ expect(stashed?.latitude).toBe(37.775);
+ expect(stashed?.longitude).toBe(-122.419);
expect(stashed?.city).toBe('San Francisco');
});
@@ -136,8 +173,8 @@ describe('common/runtime/mapper.library', () => {
const stashed = getStashedGeo();
expect(stashed).toBeDefined();
- expect(stashed?.latitude).toBe(51.5074);
- expect(stashed?.longitude).toBe(-0.1278);
+ expect(stashed?.latitude).toBe(51.507);
+ expect(stashed?.longitude).toBe(-0.128);
});
it('getStashedGeo does not read legacy _map_ storage key', () => {
@@ -184,5 +221,97 @@ describe('common/runtime/mapper.library', () => {
vi.unstubAllGlobals();
});
+
+ describe('haversineDistance', () => {
+ const sydney = { latitude: -33.8688, longitude: 151.2093 };
+ const melbourne = { lat: -37.8136, lng: 144.9631 };
+
+ it('calculates great-circle distance between two cities in kilometers', () => {
+ const dist = haversineDistance(sydney, melbourne);
+ // Sydney to Melbourne is approximately 713.4 km
+ expect(dist).toBeGreaterThan(710);
+ expect(dist).toBeLessThan(720);
+ expect(dist).toBe(713.426);
+ });
+
+ it('calculates great-circle distance in miles', () => {
+ const distMiles = haversineDistance(sydney, melbourne, 'miles');
+ expect(distMiles).toBeGreaterThan(440);
+ expect(distMiles).toBeLessThan(450);
+ expect(distMiles).toBe(443.303);
+ });
+
+ it('calculates great-circle distance in meters with integer rounding', () => {
+ const distMeters = haversineDistance(sydney, melbourne, 'm');
+ expect(Number.isInteger(distMeters)).toBe(true);
+ expect(distMeters).toBe(713426);
+ });
+
+ it('returns 0 for identical points', () => {
+ expect(haversineDistance(sydney, sydney)).toBe(0);
+ expect(haversineDistance(sydney, sydney, 'miles')).toBe(0);
+ expect(haversineDistance(sydney, sydney, 'm')).toBe(0);
+ });
+
+ it('accepts various coordinate input formats including tuples, strings, and geo containers', () => {
+ const tupleCoords = [-33.8688, 151.2093];
+ const container = { geo: { lat: -37.8136, lng: 144.9631 } };
+ const dist = haversineDistance(tupleCoords, container);
+ expect(dist).toBe(713.426);
+
+ const strCoords = '-33.8688, 151.2093';
+ expect(haversineDistance(strCoords, container)).toBe(713.426);
+ });
+
+ it('returns NaN for missing or invalid coordinates', () => {
+ expect(haversineDistance(null, sydney)).toBeNaN();
+ expect(haversineDistance(sydney, undefined)).toBeNaN();
+ expect(haversineDistance({ lat: 100, lng: 50 }, sydney)).toBeNaN(); // out-of-bounds lat
+ expect(haversineDistance({}, {})).toBeNaN();
+ });
+ });
+
+ describe('solarOffset', () => {
+ it('calculates natural solar time offset for civil timezone in minutes', () => {
+ // Sydney (lng 151.209, AEST UTC+10, meridian 150°) -> (151.209 - 150) * 4 = +4.84 min
+ const sydney = { lat: -33.8688, lng: 151.2093, timezone: 'Australia/Sydney' };
+ expect(solarOffset(sydney)).toBe(4.84);
+
+ // Denver on standard time (lng -104.99, MST UTC-7, meridian -105°) -> (-104.99 - -105) * 4 = +0.04 min
+ const denver = { lat: 39.7392, lng: -104.9903, timezone: 'America/Denver' };
+ expect(solarOffset(denver, { date: '2026-01-15T12:00:00Z' })).toBe(0.04);
+ });
+
+ it('supports unit conversion to seconds and hours with configurable precision', () => {
+ const sydney = { lat: -33.8688, lng: 151.2093, timezone: 'Australia/Sydney' };
+ expect(solarOffset(sydney, { unit: 'seconds' })).toBe(290.16);
+ expect(solarOffset(sydney, { unit: 'hours', precision: 3 })).toBe(0.081);
+ });
+
+ it('falls back to natural 15-degree solar timezone meridian when no timezone is supplied', () => {
+ // lng 151.209 -> nearest meridian 150° -> +4.84 min
+ expect(solarOffset({ lat: -33.8688, lng: 151.2093 })).toBe(4.84);
+
+ // lng 7° -> nearest meridian 0° -> 7 * 4 = 28 min
+ expect(solarOffset({ lat: 51.5, lng: 7 })).toBe(28);
+ });
+
+ it('calculates apparent solar time offset incorporating Equation of Time', () => {
+ const sydney = { lat: -33.8688, lng: 151.2093, timezone: 'Australia/Sydney' };
+ const mean = solarOffset(sydney, { date: '2026-06-21T12:00:00Z' });
+ const apparent = solarOffset(sydney, { date: '2026-06-21T12:00:00Z', apparent: true });
+
+ expect(typeof apparent).toBe('number');
+ expect(apparent).not.toBe(mean);
+ expect(apparent).toBe(3.51);
+ });
+
+ it('returns NaN for invalid or unresolvable coordinates', () => {
+ expect(solarOffset(null)).toBeNaN();
+ expect(solarOffset(undefined)).toBeNaN();
+ expect(solarOffset({})).toBeNaN();
+ expect(solarOffset({ lat: 100, lng: 50 })).toBeNaN();
+ });
+ });
});
diff --git a/packages/plugins/celestial/src/index.ts b/packages/plugins/celestial/src/index.ts
index 3701de85..474ced4b 100644
--- a/packages/plugins/celestial/src/index.ts
+++ b/packages/plugins/celestial/src/index.ts
@@ -69,6 +69,7 @@ declare module '@magmacomputing/tempo' {
millisecond: number;
microsecond: number;
nanosecond: number;
+ elevation: number | null;
sunrise: Tempo | null;
sunset: Tempo | null;
noon: Tempo | null;
@@ -118,34 +119,27 @@ declare module '@magmacomputing/tempo' {
function getLunarScopeRange(t: Tempo, anchor?: any) {
const coords = getCelestialCoordinates(t, anchor);
const { refTempo, lat, lng, hasGeo, geo, timeZone, sphere } = coords;
- const currentMs = refTempo.epoch.ms;
+ const { startOfDayMs } = (refTempo as any).startOfDay ? (refTempo as any).startOfDay() : { startOfDayMs: refTempo.epoch.ms };
- const range = getLunarPhaseRange(currentMs, { sphere });
- const moonEvents = hasGeo ? getMoonriseMoonset(currentMs, lat!, lng!) : { moonriseMs: undefined, moonsetMs: undefined };
+ const lunarDetails = getLunarDetails(t, coords);
- const startTempo = new Tempo(range.startMs, { timeZone, timeStamp: 'ms', sphere });
- const endTempo = new Tempo(range.endMs, { timeZone, timeStamp: 'ms', sphere });
- const moonrise = hasGeo ? toTempoOrNull(moonEvents.moonriseMs, timeZone) : null;
- const moonset = hasGeo ? toTempoOrNull(moonEvents.moonsetMs, timeZone) : null;
+ const moonEvents = hasGeo ? getMoonriseMoonset(refTempo.epoch.ms, lat!, lng!) : null;
+ const moonrise = moonEvents ? toTempoOrNull(moonEvents.moonriseMs, timeZone, sphere) : null;
+ const moonset = moonEvents ? toTempoOrNull(moonEvents.moonsetMs, timeZone, sphere) : null;
- const details = getLunarDetails(t, coords);
+ const { startMs, endMs } = getLunarPhaseRange(refTempo.epoch.ms, { sphere });
+ const start = new Tempo(startMs, { timeZone, timeStamp: 'ms', ...(sphere ? { sphere } : {}) });
+ const end = new Tempo(endMs, { timeZone, timeStamp: 'ms', ...(sphere ? { sphere } : {}) });
return {
- key: details.key,
- phase: details.phase,
- index: details.index,
- illumination: details.illumination,
- ageDays: details.ageDays,
- isWaxing: details.isWaxing,
- ...(details.emoji !== undefined ? { emoji: details.emoji } : {}),
- phases: LUNAR_PHASE_KEYS,
+ ...lunarDetails,
+ group: 'lunar' as const,
+ geo: hasGeo ? geo : null,
+ ...toDateTimeFields(start),
moonrise,
moonset,
- group: 'lunar' as const,
- geo,
- ...toDateTimeFields(startTempo),
- start: startTempo,
- end: endTempo,
+ start,
+ end,
};
}
@@ -180,6 +174,7 @@ function getSolarScopeRange(t: Tempo, anchor?: any) {
group: 'solar' as const,
geo: null,
...toDateTimeFields(refTempo),
+ elevation: null,
sunrise: null,
sunset: null,
noon: null,
@@ -193,7 +188,15 @@ function getSolarScopeRange(t: Tempo, anchor?: any) {
};
}
- const res = getSunriseSunset(refTempo.epoch.ms, lat!, lng!);
+ const elevation = typeof (geo as any)?.elevation === 'number'
+ ? (geo as any).elevation
+ : (typeof (t.config?.geo as any)?.elevation === 'number' ? (t.config?.geo as any).elevation : undefined);
+
+ const res = getSunriseSunset(refTempo.epoch.ms, {
+ latitude: lat!,
+ longitude: lng!,
+ ...(elevation !== undefined ? { elevation } : {}),
+ });
const sunrise = toTempoOrNull(res.sunriseMs, timeZone)!;
const sunset = toTempoOrNull(res.sunsetMs, timeZone)!;
@@ -250,6 +253,7 @@ function getSolarScopeRange(t: Tempo, anchor?: any) {
index: res.index,
group: 'solar' as const,
geo,
+ elevation: (typeof geo?.elevation === 'number') ? geo.elevation : (typeof (t.config?.geo as any)?.elevation === 'number' ? (t.config?.geo as any).elevation : null),
...toDateTimeFields(start),
sunrise,
sunset,
diff --git a/packages/plugins/celestial/test/celestial.test.ts b/packages/plugins/celestial/test/celestial.test.ts
index 7c307170..b39e2758 100644
--- a/packages/plugins/celestial/test/celestial.test.ts
+++ b/packages/plugins/celestial/test/celestial.test.ts
@@ -39,6 +39,16 @@ describe('CelestialPlugin (Solar & Lunar Terms)', () => {
expect(t.term.lunar.geo).toBe(t.geo);
});
+ it('factors elevation into solar sunrise/sunset and exposes elevation on solar term', () => {
+ const seaLevel = new Tempo('2026-06-21T12:00:00Z', { geo: { lat: 39.7392, lng: -104.9903, elevation: 0 } });
+ const highAlt = new Tempo('2026-06-21T12:00:00Z', { geo: { lat: 39.7392, lng: -104.9903, elevation: 1600 } });
+
+ expect(highAlt.term.solar.elevation).toBe(1600);
+ expect(highAlt.term.solar.sunrise!.epoch.ms).toBeLessThan(seaLevel.term.solar.sunrise!.epoch.ms);
+ expect(highAlt.term.solar.sunset!.epoch.ms).toBeGreaterThan(seaLevel.term.solar.sunset!.epoch.ms);
+ expect(highAlt.term.solar.daylightDurationMs).toBeGreaterThan(seaLevel.term.solar.daylightDurationMs!);
+ });
+
it('honors numeric anchor 0 in LunarTerm and SolarTerm resolvers', () => {
const t = new Tempo('2026-06-21T12:00:00Z', { geo: { lat: 40.7128, lng: -74.006 } });
diff --git a/packages/plugins/geo/plan/README.md b/packages/plugins/geo/plan/README.md
index 354cfb04..cfe9f839 100644
--- a/packages/plugins/geo/plan/README.md
+++ b/packages/plugins/geo/plan/README.md
@@ -1,204 +1,167 @@
# Tempo Plugin Geo: Gap Analysis & Feature Roadmap
-> **Status:** Planning / Future Backlog
-> **Target Release:** `v1.0.0` (Pre-publish review) & `v1.1.0+`
-> **Related Source:** [`packages/library/src/browser/mapper.library.ts`](file:///home/michael/Project/magma/packages/library/src/browser/mapper.library.ts), [`packages/library/src/server/mapper.library.ts`](file:///home/michael/Project/magma/packages/library/src/server/mapper.library.ts)
+> **Status:** v1.0.0 Foundation Delivered / v1.1.0+ Backlog Planned
+> **Target Release:** `v1.0.0` (Foundation Delivered) & `v1.1.0+` (Feature Enhancements)
+> **Source of Truth:** [`packages/library/src/common/runtime/mapper.library.ts`](../../../library/src/common/runtime/mapper.library.ts)
+> **Platform Drivers:** [`packages/library/src/browser/mapper.library.ts`](../../../library/src/browser/mapper.library.ts), [`packages/library/src/server/mapper.library.ts`](../../../library/src/server/mapper.library.ts)
---
## 1. Executive Summary & Guiding Philosophy
### The Mission: "Humanizing Temporal"
-The standard JavaScript `Temporal` API is mathematically rigorous, but it is notoriously low-level and clumsy. Answering simple human questions—such as *"What time is it where the client is right now?"*, *"Is it daylight or business hours at their location?"*, or *"What season is it there?"*—requires stitching together multiple disjoint APIs, external IP fetches, timezone resolvers, and coordinate conversions.
+The standard JavaScript `Temporal` API is mathematically rigorous, but as a low-level engine specification, it is intentionally verbose and mechanically clunky. Answering simple human questions—such as *"What time is it where the client is right now?"*, *"Is it daylight at their location?"*, or *"What season is it there?"*—requires stitching together multiple disjoint types, external IP fetches, timezone resolvers, and coordinate conversions.
-Tempo's primary mission is to **humanize Temporal**: making date-times intuitive, expressive, and effortless to work with.
+Tempo's primary mission is to **humanize Temporal**: making date-times intuitive, expressive, and effortless to work with.
-Geolocation is fundamental to this mission. **Time and space are inseparable**: civil time only has meaning relative to a physical location on Earth. By anchoring Tempo's deterministic, immutable API to spatial awareness—**while strictly adhering to ISO 8601 standards**—`tempo-plugin-geo` elevates Tempo from an abstract clock calculator into a deeply context-aware temporal tool.
+### Overarching Architecture: Single Source of Truth & Repeatable Outcomes
+Ease-of-use and repeatable outcomes dictate our architecture:
+1. **`mapper.library.ts` as the Pure Source of Truth:** All coordinate coercion, format normalization, hemisphere deduction, and validation reside in `@magmacomputing/library`.
+2. **Zero-Dependency Universal Behavior:** Sibling plugins (such as `tempo-plugin-celestial` and `tempo-plugin-astro`) and core Tempo consume `mapper.library.ts` directly. A user who passes coordinates via `new Tempo({ geo: { lat: -33.86, lng: 151.2 } })` gets the exact same normalized `GeoConfig` (with inferred `sphere`, 3-decimal alignment, and validation) **whether or not `tempo-plugin-geo` is installed**.
+3. **The Role of `tempo-plugin-geo`:** This plugin is the dynamic execution layer—providing ambient storage, network IP resolution, browser hardware GPS querying, distance math, and instance mutation (`t.geoLocate()`).
---
-## 2. Gap Analysis & Humanizing Opportunities
-
-### 2.1 Auto-Syncing Instance Timezone (`t.tz`) via Resolved Geolocation
-* **Current State in `mapper.library.ts`:**
- * Server IP queries (`https://ipwho.is/`) return IANA timezone identifiers (e.g. `"timezone": "America/New_York"` or `"timezone": "Australia/Sydney"`).
- * Google Maps Geocoder responses can be coupled with Google TimeZone API or reverse lookup tables.
-* **Current State in `tempo-plugin-geo`:**
- * `t.geoLocate()` only assigns `this.config.geo = { latitude, longitude }`.
- * The instance timezone (`this.tz`) remains unchanged (typically the ambient system local timezone or UTC).
-* **Opportunity & Value:**
- * Allow `t.geoLocate({ setTimezone: true })` (or configurable default).
- * When geolocating across timezones or client IPs, the instance's wall-clock time automatically aligns to the local civil time of that geographic coordinate:
- ```ts
- const event = tempo('2026-09-08T12:00:00Z');
- const clientEvent = await event.geoLocate({ ip: req.clientIp, setTimezone: true });
- // clientEvent.tz === 'Australia/Sydney'
- // clientEvent.format('h:mm a z') -> '10:00 pm AEST'
- ```
+## 2. Gap Analysis & Feature Specifications
+
+### 2.1 Complete Instance Context Synchronization on `t.geoLocate()`
+* **Principle:** When a developer explicitly awaits `t.geoLocate()`, the primary intent is to anchor the date-time instance to physical reality.
+* **Specification:**
+ * `t.geoLocate()` updates `latitude`, `longitude`, `tz`, and `sphere` simultaneously.
+ * **`setTimezone` defaults to `true`:** Automatically converts the instance's wall-clock time to the IANA timezone of the resolved coordinates (`clientEvent.tz === 'Australia/Sydney'`). An explicit `{ setTimezone: false }` option allows opting out when callers wish to record coordinates while retaining UTC or original timezone.
+ ```typescript
+ const event = tempo('2026-09-08T12:00:00Z');
+ const clientTime = await event.geoLocate({ ip: req.clientIp });
+ // clientTime.tz === 'Australia/Sydney'
+ // clientTime.geo.sphere === 'south'
+ // clientTime.format('{h12}:{mi} {mer} [{tz}]') -> '10:00 pm [Australia/Sydney]'
+ ```
---
-### 2.2 Stationary Drift Filtering & GPS Noise Protection
-* **Current State in `browser/mapper.library.ts`:**
- * Implements coordinate delta testing before refreshing or invalidating cached geocoder results:
- ```ts
- const prevLat = coords?.latitude?.toFixed(3);
- const prevLng = coords?.longitude?.toFixed(3);
- const test1 = value.coords.latitude.toFixed(3) !== prevLat;
- const test2 = value.coords.longitude.toFixed(3) !== prevLng;
- const test3 = prevTime < (instant().epochMilliseconds - 3_600_000); // 1 hour TTL
- ```
-* **Current State in `tempo-plugin-geo`:**
- * Coordinates are cached and looked up as exact floating-point numbers.
- * In mobile/browser environments, raw GPS jitter in the 4th–6th decimal places (~10m to 10cm) creates arbitrary cache misses.
-* **Opportunity & Value:**
- * Introduce stationary drift suppression in `Tempo.geo.coerce` and cache key generation.
- * Rounding to 3 decimal places (~111 meters at equator) or providing a configurable threshold (`driftToleranceMeters: 100`) prevents cache thrashing, rate-limit exhaustion, and unnecessary battery drain.
+### 2.2 Deterministic 3-Decimal Precision Alignment (GPS Noise Protection)
+* **Principle:** Keep the interface clean and zero-config.
+* **Specification:**
+ * Coordinates in `coerceGeo` are deterministically normalized to **3 decimal places** (`~111m` resolution at the equator).
+ * No config switches or drift threshold options: 3-decimal alignment eliminates GPS noise, prevents storage cache thrashing, and ensures identical key hashing without exposing configuration bloat to developers.
---
-### 2.3 Hemisphere Awareness & Astronomical Seasons
-* **Current State in `browser/mapper.library.ts`:**
- * `mapHemisphere(coords)` returns `'north' | 'south' | null`.
- * Evaluates `lat >= 0 ? 'north' : 'south'`, falling back to `getHemisphere()` based on timezone offset.
-* **Current State in `tempo-plugin-geo`:**
- * Latitude is stored, but no hemisphere helpers or properties exist on `Tempo` or `Tempo.geo`.
-* **Opportunity & Value:**
- * Seasons are flipped between Northern and Southern hemispheres:
- * Solstices & Equinoxes: June is Summer Solstice in the North, Winter Solstice in the South.
- * Meteorological & Astronomical Quarters: Q1 is Winter in North, Summer in South.
- * Direct synergy with sibling plugins:
- * `tempo-plugin-astro`: Zodiac signs, solar declination, equinox offsets.
- * `tempo-plugin-celestial`: Sunrise, sunset, twilight calculations (which are inverted by hemisphere).
- * **Proposed API:**
- ```ts
- Tempo.geo.hemisphere(coords: CoordinateInput): 'north' | 'south' | 'equator' | null;
- tempoInstance.hemisphere; // getter -> 'north' | 'south'
- ```
+### 2.3 Automatic Hemisphere Inference (`'north' | 'south' | 'equator'`)
+* **Principle:** Passing coordinates anywhere in the ecosystem should immediately unlock hemisphere awareness with zero plugin dependencies.
+* **Specification in `mapper.library.ts`:**
+ * `coerceGeo()` automatically derives `sphere`:
+ * $\text{lat} > 0.001^\circ \implies \text{'north'}$
+ * $\text{lat} < -0.001^\circ \implies \text{'south'}$
+ * $|\text{lat}| \le 0.001^\circ \implies \text{'equator'}$
+ * **Cross-Plugin Synergy (Workflow without `tempo-plugin-geo`):**
+ What happens if a developer does **not** install `tempo-plugin-geo` and passes `{ lat, lng }` to `tempo-plugin-celestial` or `tempo-plugin-astro`?
+ 1. **Initialization:** User writes `const t = tempo({ geo: { lat: -33.8688, lng: 151.2093 } })` or passes coordinates to a celestial method.
+ 2. **Foundational Coercion:** Core Tempo calls `coerceGeo()` in `@magmacomputing/library`.
+ 3. **Zero-Plugin Deduction:** Coordinates are normalized to 3 decimal places (`-33.869`, `151.209`) and `sphere: 'south'` is derived and frozen onto `t.geo`.
+ 4. **Downstream Execution:** Sibling plugins (`celestial`, `astro`) read `t.geo.sphere` directly to invert solar curves and season cycles.
+ * **Conclusion:** `tempo-plugin-geo` is strictly required only for **dynamic network/hardware queries** (`await t.geoLocate()`, `Tempo.geo.lookup()`, ambient IP stashing, and `Tempo.geo.distance()`). All coordinate math, rounding, and hemisphere awareness are 100% universal and self-contained.
---
-### 2.4 Explicit Anti-Goal: Strict ISO 8601 Week Invariant (Out of Scope)
-* **Architectural Invariant:**
- * Tempo is intentionally and strictly opinionated: it is architected directly around **ISO 8601** standards (Monday is day 1, standard ISO week numbering 1–53).
-* **Boundary Rule for `tempo-plugin-geo`:**
- * While reverse geocoding provides country codes (`US`, `AU`, `GB`, `AE`), **`tempo-plugin-geo` must NEVER alter Tempo's week alignments, day indexing, or calendar math.**
- * Altering week starts (e.g. attempting Sunday-start or Saturday-start weeks) compromises Tempo's deterministic ISO-8601 contract and creates conflicting calendar state across instances.
-* **Acceptable Scope (Informational Only):**
- * Country codes should remain purely informational metadata (e.g. `t.geo.countryCode`), enabling external consumer logic (such as looking up regional public holiday sets or fiscal quarter offsets) without modifying Tempo's internal calendar rules.
+### 2.4 ISO 8601 Calendar Invariant & Regional Metadata
+* **Strict Boundary Rule:**
+ * Tempo is strictly opinionated around **ISO 8601** standards (Monday is always day 1, standard ISO week numbering 1–53).
+ * `tempo-plugin-geo` will **NEVER** alter Tempo's week alignments, day indexing, or calendar math.
+* **Regional Metadata:**
+ * Reverse geocoded country codes (`US`, `AU`, `GB`, etc.) are retained purely as informational metadata on `t.geo.country`.
+ * Future specialized plugins (e.g. localized holiday engines or regional fiscal calendars) can inspect `t.geo.country` if needed.
---
-### 2.5 Great-Circle / Haversine Distance & Transit Duration
-* **Current State in `mapper.library.ts`:**
- * Raw coordinates are retrieved, but distance math is not provided.
-* **Opportunity & Value:**
- * A lightweight, zero-dependency Haversine formula calculation directly within `Tempo.geo`:
- ```ts
- Tempo.geo.distance(t1: Tempo | CoordinateInput, t2: Tempo | CoordinateInput, opts?: { unit?: 'km' | 'mi' | 'nm' }): number;
+### 2.5 Great-Circle / Haversine Distance (`Tempo.geo.distance`)
+* **Specification:**
+ * Provide a zero-dependency Haversine distance calculator:
+ ```typescript
+ Tempo.geo.distance(t1: Tempo | CoordinateInput, t2: Tempo | CoordinateInput, unit?: 'km' | 'miles' | 'm'): number;
```
- * **Applications for Date-Time Workflows:**
+ * **Use Cases:**
* **Transit Velocity:** Calculate average travel speed between two timestamped events:
- $$\text{speed} = \frac{\text{Tempo.geo.distance}(t_1, t_2)}{\text{t2.diff}(t1, 'hours')}$$
+ $$\text{speed} = \frac{\text{Tempo.geo.distance}(t_1, t_2, \text{'km'})}{t_2.\text{diff}(t_1, \text{'hours'})}$$
* **Impossible Travel Detection:** Flag security anomalies (e.g., login from London at 10:00 and Tokyo at 11:00).
- * **Flight / Travel Time Validation:** Validate schedule consistency across time zones and physical locations.
+ * **Logistics & Scheduling:** Verify route consistency between deliveries or appointments.
---
-### 2.6 Rich Spatial Metadata Retention on `Tempo` Instances
-* **Current State in `browser/mapper.library.ts` & `server/mapper.library.ts`:**
- * Server responses include `country`, `city`, `timezone`, `query` (IP).
- * Google Maps responses include `formatted_address`, `locality`, `administrative_area_level_1`, `postal_code`.
-* **Current State in `tempo-plugin-geo`:**
- * `t.geo` is strictly `{ latitude: number, longitude: number }`.
-* **Opportunity & Value:**
- * Extend `GeoConfig` to preserve rich metadata when available:
- ```ts
+### 2.6 Rich Spatial Metadata Retention & Layout Format Tokens
+* **Specification:**
+ * `GeoConfig` retains metadata returned from IP lookup or Google Maps:
+ ```typescript
export interface GeoConfig {
latitude: number;
longitude: number;
elevation?: number;
- sphere?: 'north' | 'south';
+ sphere?: 'north' | 'south' | 'equator';
country?: string;
- countryCode?: string;
city?: string;
region?: string;
postalCode?: string;
timezone?: string;
- formattedAddress?: string;
}
```
- * Enables format tokens and metadata logging:
- ```ts
- t.format('[Event at] {city}, {country} ({lat}, {lng})');
+ * **Integration with `format()` Tokens:**
+ When geo metadata is present on an instance, `t.format()` resolves bracketed dotted tokens:
+ ```typescript
+ t.format('{geo.city}, {geo.country} · {h12}:{mi} {mer} [{tz}]');
+ // "Sydney, Australia · 10:00 pm [Australia/Sydney]"
```
+ * **Resolution Mechanics & Graceful Fallback:**
+ * The format engine checks `token.startsWith('geo.')`:
+ * Extract key: `key = token.slice(4)`.
+ * Lookup value on instance: `val = t.geo?.[key]`.
+ * If resolved and non-empty: replace with `String(val)`.
+ * If `t.geo` is absent or `key` is undefined: cleanly replace with `''` (empty string) so formatting does not throw or print `undefined`.
+ * **Developer Ergonomics:** Allows building user-facing timestamps, localized event headers, and notifications without manual string stitching.
---
-### 2.7 Elevation & Solar Horizon Adjustments
-* **Current State in `browser/mapper.library.ts`:**
- * Google Maps Geocoding and Elevation APIs supply elevation in meters.
-* **Opportunity & Value:**
- * Elevation alters apparent sunrise, sunset, and solar noon times due to horizon dip:
+### 2.7 Elevation Integration in Solar & Astro Calculations
+* **Specification:**
+ * When `elevation` (meters above sea level) is present in `t.geo.elevation`, downstream plugins (`tempo-plugin-celestial`, `tempo-plugin-astro`) incorporate it into apparent sunrise/sunset and solar noon calculations via atmospheric horizon dip:
$$\Delta\theta \approx 0.0347^\circ \times \sqrt{h_{\text{meters}}}$$
- * Locations at significant altitude (e.g., Denver at 1,600m, Mexico City at 2,240m) see sunrises several minutes earlier and sunsets several minutes later than sea level.
- * Preserving `elevation` in `t.geo.elevation` allows downstream plugins (`tempo-plugin-astro`, `tempo-plugin-celestial`) to produce high-precision solar calculations.
+ * High-altitude locations (e.g. Denver at 1,600m) accurately reflect earlier sunrises and later sunsets.
---
-### 2.8 Unified Browser-Server Storage & Stash Semantics
-* **Current State:**
- * Browser uses `WebStore('local')` with key `_magma_geo_`.
- * Server uses `BoundedCache` with key `_magma_geo_:` or `_magma_geo_:`.
-* **Opportunity & Value:**
- * Standardize storage abstraction so that cache keys, TTL expiration (24h default), and multi-tenant partitioning operate identically across Node.js, Deno, Bun, and browser environments.
-
-### 2.9 Human-Centric "Business Hours" Checker (`t.isBusinessHours()`)
-* **The "Humanizing" Need:**
- * Distributed teams and automated workflows constantly ask: *"Can I notify this user right now, or is it 3:00 AM there?"* / *"Is the client in their local business hours?"*
- * Raw Temporal requires manual zone conversions, extracting `dayOfWeek`, checking bounds, and formatting.
-* **ISO 8601 Compliant Design:**
- * Default definition strictly adheres to ISO 8601: **Monday through Friday (days 1–5)**, 09:00 to 17:00 local time.
- * Simple, expressive API:
- ```ts
- const clientNow = await tempo().geoLocate({ ip: req.clientIp });
- if (clientNow.isBusinessHours()) {
- // Send real-time notification
- }
- ```
-
----
-
-### 2.10 Natural Solar Time vs. Civil Clock Time (`Tempo.geo.solarOffset`)
-* **The "Humanizing" Need:**
- * Civil timezones are political artifacts. In many regions, the clock reads 12:00 PM, but the sun is nowhere near its zenith (e.g. western Spain, western China, or borders of time zones where solar noon differs by 1–2 hours from clock noon).
- * For humans tracking circadian health, natural light exposure, or outdoor activities, understanding natural solar time humanizes the relationship between the clock and nature.
-* **ISO 8601 Compliant Design:**
- * Does not alter ISO timestamps or civil date math.
- * Calculates solar time offset based on longitude ($\approx 4\text{ minutes per } 1^\circ \text{ offset from standard meridian}$):
- ```ts
- const offsetMinutes = Tempo.geo.solarOffset(coords); // e.g. -42 minutes
+### 2.8 Natural Solar Time Offset (`Tempo.geo.solarOffset`)
+* **Specification:**
+ * Quantifies the physical delta between civil clock time and actual solar noon based on longitude ($\approx 4\text{ minutes per } 1^\circ \text{ offset from standard timezone meridian}$):
+ ```typescript
+ const minutes = Tempo.geo.solarOffset(coords); // e.g. -38 minutes
```
+ * **Human Value:** Supports circadian health, natural light tracking, and outdoor activities where solar position matters more than political clock boundaries.
---
## 3. Prioritized Implementation Roadmap
-| Priority | Feature | Effort | Target | Humanizing Impact | Description |
+| Priority | Feature | Status | Target | Scope / Target Package | Description |
| :--- | :--- | :--- | :--- | :--- | :--- |
-| **P1** | **Timezone Auto-Sync** (`setTimezone: true`) | Low | `v1.0.0` / `v1.0.1` | **High** | Syncs `t.tz` to local civil time at resolved location. |
-| **P1** | **Stationary Drift Filtering** | Low | `v1.0.0` / `v1.0.1` | Medium | Prevents GPS noise cache thrashing (`toFixed(3)`). |
-| **P2** | **Hemisphere Awareness** (`Tempo.geo.hemisphere`) | Low | `v1.1.0` | **High** | Flipped seasonal awareness (`t.season`) for southern hemisphere humans. |
-| **P2** | **Rich Metadata Preservation** | Medium | `v1.1.0` | **High** | Retains `city`, `country`, `formattedAddress` for template formatting. |
-| **P3** | **Business Hours Helper** (`t.isBusinessHours()`) | Low | `v1.1.0` | **High** | Instant check if local time is 9–5 Mon–Fri (ISO 8601). |
-| **P3** | **Haversine Distance Utility** | Low | `v1.1.0` | Medium | `Tempo.geo.distance(t1, t2)` for transit speed and impossible travel. |
-| **P4** | **Elevation Horizon Correction** | Medium | `v1.2.0` | Medium | Horizon dip correction for high-precision sunrise/sunset. |
-| **P4** | **Natural Solar Offset** | Low | `v1.2.0` | Medium | Quantifies delta between civil clock time and actual solar zenith. |
+| **P1** | **`t.geoLocate()` Full Context Sync** | ✅ **Complete** | `v1.0.0` | `tempo-plugin-geo` | Default `setTimezone: true`, Option B reality sync, Option C call-site overrides. |
+| **P1** | **Deterministic 3-Decimal Precision** | ✅ **Complete** | `v1.0.0` | `mapper.library` | Standardize lat/lng to 3 decimal places; enforce $\text{lat} \in [-90, 90], \text{lng} \in [-180, 180]$. |
+| **P1** | **Hemisphere Inference (`north`/`south`/`equator`)** | ✅ **Complete** | `v1.0.0` | `mapper.library` / `tempo` | Automatic `sphere` deduction in `coerceGeo` and `t.sphere` across all packages. |
+| **P2** | **Haversine Distance Utility** | ✅ **Complete** | `v1.0.0` | `tempo-plugin-geo` | `Tempo.geo.distance(t1, t2, unit?)` and `t.geoDistance(other, unit?)` for impossible travel & transit. |
+| **P2** | **Rich Metadata Layout Tokens** | ✅ **Complete** | `v1.0.0` | `tempo` / `tempo-plugin-geo` | Dynamic format tokens (`{geo.city}`, `{geo.country}`, `{geo.sphere}`) in `t.format()`. |
+| **P3** | **Elevation Horizon Dip** | ✅ **Complete** | `v1.0.0` | `tempo-fns` / `celestial` | Factor `t.geo.elevation` into solar sunrise/sunset times via horizon dip ($\Delta\theta \approx 0.0347^\circ \times \sqrt{h_{\text{meters}}}$). |
+| **P3** | **Natural Solar Time Offset** | ✅ **Complete** | `v1.0.0` | `mapper.library` / `tempo-plugin-geo` | `Tempo.geo.solarOffset(coords)` and `t.geoSolarOffset()` calculating solar delta. |
---
-## 4. Architectural Invariants & Non-Goals
-
-1. **Strict ISO 8601 Week Invariant:** Tempo is strictly opinionated. Monday is ALWAYS day 1, and ISO week numbering rules are absolute. `tempo-plugin-geo` must never modify, reconfigure, or override week start or weekend alignments.
-2. **Immutable Tempo Attachment:** As established in `packages/plugins/.setup/community-plugin-template.md`, the `Tempo.geo` namespace must remain locked-down (`deepFreeze()` via `@magmacomputing/tempo/plugin/sdk` + `writable: false`, `configurable: false`), and all instance methods (`geoLocate`) must return new Tempo instances.
-3. **Partitioned Isolation:** All stashing and caching must preserve multi-tenant cache keys (`_magma_geo_:`).
+## 4. Completed Foundation (Delivered in `v1.0.0`)
+
+- [x] **Deterministic 3-Decimal Precision & Boundary Validation:** Coordinates in `coerceGeo` are validated against physical bounds ($\text{lat} \in [-90, 90]$, $\text{lng} \in [-180, 180]$) and rounded to 3 decimal places ($\approx 111\text{m}$ resolution) with zero configuration flags.
+- [x] **Automatic Hemisphere Inference:** `coerceGeo()` and `Tempo.prototype.sphere` automatically derive `'north'`, `'south'`, or `'equator'` (within the $\pm 0.001^\circ$ equatorial band) across all packages without requiring `tempo-plugin-geo`.
+- [x] **Instance Full Context Synchronization:** `t.geoLocate()` synchronizes `latitude`, `longitude`, `tz`, and `sphere` simultaneously. Defaults `setTimezone: true` (with `{ setTimezone: false }` opt-out), applies Option B (physical reality updates location while preserving custom non-geographic keys), and respects Option C (authoritative call-site overrides).
+- [x] **Great-Circle Haversine Distance Utility:** Pure calculation via `haversineDistance()` and OOP dispatch via `Tempo.geo.distance(t1, t2, unit?)` and `t.geoDistance(other, unit?)`. Supports `'km'`, `'miles'`, and `'m'`. Enables transit velocity and impossible travel anomaly detection.
+- [x] **Rich Spatial Metadata Layout Tokens:** Core format engine resolves `{geo.}` bracketed tokens (e.g. `{geo.city}`, `{geo.country}`, `{geo.sphere}`, `{geo.elevation}`) with full modifier support (e.g. `:upper`, `:title`) and graceful empty-string fallback.
+- [x] **Atmospheric Horizon Dip (`elevation`):** Downstream solar calculations in `tempo-fns` and `tempo-plugin-celestial` incorporate `t.geo.elevation` into apparent sunrise, sunset, and daylight duration ($\Delta\theta \approx 0.0347^\circ \times \sqrt{h_{\text{meters}}}$) with unchanged solar noon transit.
+- [x] **Natural Solar Time Offset (`solarOffset`):** Computes physical delta between civil clock time and actual solar noon ($\Delta\lambda \times 4\text{ min}$) with support for civil timezones, natural 15-degree solar meridians, units (`'minutes'`, `'seconds'`, `'hours'`), and apparent solar time (Equation of Time). Exposed via `Tempo.geo.solarOffset` and `t.geoSolarOffset()`.
+- [x] **Universal Geolocation Caching:** 24-hour TTL caching in `Tempo.geo.lookup()` with `{ refresh: true }` bypass.
+- [x] **Scoped Multi-Tenant Isolation:** Standardized on `_magma_geo_` with tenant key isolation (`_magma_geo_:`).
+- [x] **Immutability Hardening:** `Tempo.geo` is recursively frozen via `deepFreeze()`.
+- [x] **Clean Non-Polluted Root:** Removed flat root methods in favor of cohesive `Tempo.geo.*`.
diff --git a/packages/plugins/geo/src/index.ts b/packages/plugins/geo/src/index.ts
index fdc1d755..4f05fc5b 100644
--- a/packages/plugins/geo/src/index.ts
+++ b/packages/plugins/geo/src/index.ts
@@ -7,9 +7,16 @@ import {
getStashedGeo,
stashGeo,
clearStashedGeo,
+ GEO_PROPERTIES,
+ haversineDistance,
+ solarOffset,
type GeoLookupResult,
+ type ResolvedCoordinates,
type GeoConfig,
type CoordinateInput,
+ type DistanceUnit,
+ type SolarOffsetOptions,
+ type SolarOffsetUnit,
} from '@magmacomputing/library/runtime/mapper.library.js';
import {
serverGeoLocation,
@@ -21,6 +28,7 @@ import {
import {
geoLocation,
} from '@magmacomputing/library/browser/mapper.library.js';
+import { isString, isNumber, isEmpty, isSafeKey } from '@magmacomputing/library/primitives/assertion.library.js';
export {
geoLookup,
@@ -29,6 +37,9 @@ export {
getStashedGeo,
stashGeo,
clearStashedGeo,
+ GEO_PROPERTIES,
+ haversineDistance,
+ solarOffset,
serverGeoLocation,
serverGeoCoords,
serverMapHemisphere,
@@ -37,8 +48,12 @@ export {
export type {
GeoLookupResult,
+ ResolvedCoordinates,
GeoConfig,
CoordinateInput,
+ DistanceUnit,
+ SolarOffsetOptions,
+ SolarOffsetUnit,
ServerMapOpts,
ServerGeolocationResult,
};
@@ -53,6 +68,10 @@ export interface TempoGeoNamespace {
readonly resolve: typeof resolveGeoCoordinates;
/** Coerces coordinates and configurations into a canonical GeoConfig object */
readonly coerce: typeof coerceGeo;
+ /** Calculates Great-Circle distance between two coordinates using Haversine formula */
+ readonly distance: typeof haversineDistance;
+ /** Calculates Natural Solar Time Offset between civil clock time and actual solar noon */
+ readonly solarOffset: typeof solarOffset;
/** Explicitly stashes coordinates into storage with optional TTL (default 24h) and multi-tenant partitioning */
readonly stash: typeof stashGeo;
/** Clears stashed coordinates from storage */
@@ -77,6 +96,8 @@ export const GeoPlugin: TempoPlugin = definePlugin({
lookup: geoLookup,
resolve: resolveGeoCoordinates,
coerce: coerceGeo,
+ distance: haversineDistance,
+ solarOffset,
stash: stashGeo,
clear: clearStashedGeo,
get: getStashedGeo,
@@ -85,7 +106,7 @@ export const GeoPlugin: TempoPlugin = definePlugin({
get current(): GeoConfig | undefined {
return getStashedGeo() ?? TempoClass.config?.geo;
},
- }
+ };
Object.defineProperty(TempoClass, 'geo', {
value: deepFreeze(geoNamespace),
@@ -96,19 +117,66 @@ export const GeoPlugin: TempoPlugin = definePlugin({
/**
* Asynchronously resolves coordinates for the current instance (or uses existing coordinates),
- * returning a new Tempo instance with the resolved `geo` configuration attached.
+ * returning a new Tempo instance with full context synchronization.
+ *
+ * - setTimezone defaults to true: automatically shifts instance wall-clock time to the resolved location.
+ * - Option B (Physical Reality): Fresh location metadata updates geographic fields (lat, lng, country, city, sphere, timezone).
+ * - Custom non-geographic metadata (e.g. { venue: 'HQ', officeId: 42 }) is preserved.
+ * - Option C (Call-Site Overrides): Explicit parameters passed to .geoLocate(opts) take absolute precedence.
*/
TempoClass.prototype.geoLocate = async function (this: Tempo, opts?: Record): Promise {
+ const setTimezone = opts?.setTimezone !== false;
const coords = await resolveGeoCoordinates(this, opts);
if (coords) {
const existingGeo = (typeof this.config.geo === 'object' && this.config.geo !== null) ? this.config.geo : {};
- return new TempoClass(this, {
- ...this.config,
- geo: {
- ...existingGeo,
- latitude: coords.lat,
- longitude: coords.lng,
- },
+
+ // 1. Separate custom non-geo keys from existingGeo to preserve them (Option B)
+ const customKeys: Record = {};
+ for (const key of Object.keys(existingGeo)) {
+ if (isSafeKey(key) && !GEO_PROPERTIES.includes(key as any))
+ customKeys[key] = (existingGeo as any)[key];
+ }
+
+ // 2. Extract call-site overrides (Option C)
+ const callSiteGeo = coerceGeo(opts) ?? {};
+ if (opts && typeof opts === 'object') {
+ for (const key of Object.keys(opts)) {
+ if (isSafeKey(key) && !['setTimezone', 'refresh', 'ttl', 'endpoint', 'timeout', 'catch', 'debug', 'geo'].includes(key)) {
+ if (!GEO_PROPERTIES.includes(key as any))
+ customKeys[key] = (opts as any)[key];
+ }
+ }
+ }
+
+ // Preserve existing elevation if network query does not provide elevation data
+ const preservedElevation = isNumber((coords as any)?.elevation)
+ ? (coords as any).elevation
+ : (isNumber(existingGeo.elevation) ? existingGeo.elevation : undefined);
+
+ // 3. Compose final merged geo object: custom keys + physical reality (Option B) + call-site overrides (Option C)
+ const mergedGeo: GeoConfig = {
+ ...customKeys,
+ ...coords,
+ ...(isNumber(preservedElevation) ? { elevation: preservedElevation } : {}),
+ ...callSiteGeo,
+ };
+
+ // Clean up coordinates and infer sphere
+ if (isNumber(mergedGeo.latitude)) mergedGeo.latitude = Math.round(mergedGeo.latitude * 1000) / 1000;
+ if (isNumber(mergedGeo.longitude)) mergedGeo.longitude = Math.round(mergedGeo.longitude * 1000) / 1000;
+ if (!mergedGeo.sphere && isNumber(mergedGeo.latitude)) {
+ mergedGeo.sphere = mergedGeo.latitude > 0.001 ? 'north' : (mergedGeo.latitude < -0.001 ? 'south' : 'equator');
+ }
+
+ let instance: Tempo = this;
+ const targetTz = mergedGeo.timezone;
+ if (setTimezone && isString(targetTz) && !isEmpty(targetTz))
+ instance = this.set({ timeZone: targetTz });
+
+ return new TempoClass(instance, {
+ ...instance.config,
+ geo: deepFreeze(mergedGeo),
+ ...(mergedGeo.sphere ? { sphere: mergedGeo.sphere } : {}),
});
}
return this;
@@ -117,9 +185,23 @@ export const GeoPlugin: TempoPlugin = definePlugin({
/**
* Resolves coordinates for this instance via explicit coordinates or automatic IP/hardware lookup.
*/
- TempoClass.prototype.geoLookup = async function (this: Tempo, opts?: Record): Promise<{ lat: number; lng: number } | null> {
+ TempoClass.prototype.geoLookup = async function (this: Tempo, opts?: Record): Promise {
return resolveGeoCoordinates(this, opts);
};
+
+ /**
+ * Calculates Great-Circle distance from this instance's coordinates to target coordinates using Haversine formula.
+ */
+ TempoClass.prototype.geoDistance = function (this: Tempo, other: any, unit?: DistanceUnit): number {
+ return haversineDistance(this, other, unit);
+ };
+
+ /**
+ * Calculates Natural Solar Time Offset between civil clock time and actual solar noon for this instance.
+ */
+ TempoClass.prototype.geoSolarOffset = function (this: Tempo, options?: SolarOffsetOptions): number {
+ return solarOffset(this, options);
+ };
},
});
@@ -135,7 +217,15 @@ declare module '@magmacomputing/tempo' {
/**
* Resolves coordinates for this instance via explicit coordinates or automatic IP/hardware lookup.
*/
- geoLookup(opts?: Record): Promise<{ lat: number; lng: number } | null>;
+ geoLookup(opts?: Record): Promise;
+ /**
+ * Calculates Great-Circle distance from this instance's coordinates to target coordinates using Haversine formula.
+ */
+ geoDistance(other: any, unit?: DistanceUnit): number;
+ /**
+ * Calculates Natural Solar Time Offset between civil clock time and actual solar noon for this instance.
+ */
+ geoSolarOffset(options?: SolarOffsetOptions): number;
}
namespace Tempo {
diff --git a/packages/plugins/geo/test/geo.test.ts b/packages/plugins/geo/test/geo.test.ts
index 589ecf09..519b595b 100644
--- a/packages/plugins/geo/test/geo.test.ts
+++ b/packages/plugins/geo/test/geo.test.ts
@@ -8,6 +8,8 @@ import {
stashGeo,
clearStashedGeo,
getStashedGeo,
+ haversineDistance,
+ solarOffset,
} from '../src/index.js';
describe('Tempo Plugin: Geo', () => {
@@ -25,15 +27,24 @@ describe('Tempo Plugin: Geo', () => {
});
describe('Pure functional coordinate utilities', () => {
- it('should coerce various coordinate input formats', () => {
+ it('should coerce various coordinate input formats with 3-decimal precision and sphere inference', () => {
expect(coerceGeo({ lat: -33.8688, lng: 151.2093 })).toEqual({
- latitude: -33.8688,
- longitude: 151.2093,
+ latitude: -33.869,
+ longitude: 151.209,
+ sphere: 'south',
});
expect(coerceGeo({ latitude: 40.7128, longitude: -74.006 })).toEqual({
- latitude: 40.7128,
+ latitude: 40.713,
longitude: -74.006,
+ sphere: 'north',
+ });
+
+ // Equator boundary band
+ expect(coerceGeo({ lat: 0.0005, lng: 100 })).toEqual({
+ latitude: 0.001,
+ longitude: 100,
+ sphere: 'equator',
});
expect(coerceGeo(undefined)).toBeUndefined();
@@ -46,9 +57,10 @@ describe('Tempo Plugin: Geo', () => {
});
const coords = await resolveGeoCoordinates(t);
- expect(coords).toEqual({
- lat: 51.5074,
- lng: -0.1278,
+ expect(coords).toMatchObject({
+ lat: 51.507,
+ lng: -0.128,
+ sphere: 'north',
});
});
@@ -74,7 +86,7 @@ describe('Tempo Plugin: Geo', () => {
expect(result.city).toBe('Mountain View');
});
- it('should perform mocked geoLookup on server', async () => {
+ it('should perform mocked geoLookup on server with 3-decimal rounding', async () => {
const mockPayload = {
ip: '1.1.1.1',
success: true,
@@ -90,8 +102,9 @@ describe('Tempo Plugin: Geo', () => {
);
const result = await geoLookup();
- expect(result.lat).toBe(-33.8688);
- expect(result.lng).toBe(151.2093);
+ expect(result.lat).toBe(-33.869);
+ expect(result.lng).toBe(151.209);
+ expect(result.sphere).toBe('south');
});
});
@@ -125,14 +138,15 @@ describe('Tempo Plugin: Geo', () => {
});
const coords = await t.geoLookup();
- expect(coords).toEqual({
- lat: 35.6762,
- lng: 139.6503,
+ expect(coords).toMatchObject({
+ lat: 35.676,
+ lng: 139.65,
+ sphere: 'north',
});
});
- it('should allow instance method .geoLocate() to return a new Tempo instance with geo set', async () => {
- const t = new Tempo('2026-01-01');
+ it('should allow instance method .geoLocate() to return a new Tempo instance with full context sync', async () => {
+ const t = new Tempo('2026-01-01T12:00:00Z');
expect(t.geo).toBeUndefined();
const mockPayload = {
@@ -140,6 +154,9 @@ describe('Tempo Plugin: Geo', () => {
success: true,
lat: 48.8566,
lon: 2.3522,
+ city: 'Paris',
+ country: 'France',
+ timezone: 'Europe/Paris',
};
vi.spyOn(globalThis, 'fetch').mockImplementation(
@@ -149,17 +166,22 @@ describe('Tempo Plugin: Geo', () => {
const located = await t.geoLocate();
expect(located).toBeInstanceOf(Tempo);
expect(located.geo).toBeDefined();
- expect(located.geo?.latitude).toBe(48.8566);
- expect(located.geo?.longitude).toBe(2.3522);
+ expect(located.geo?.latitude).toBe(48.857);
+ expect(located.geo?.longitude).toBe(2.352);
+ expect(located.geo?.city).toBe('Paris');
+ expect(located.geo?.country).toBe('France');
+ expect(located.geo?.sphere).toBe('north');
+ expect(located.tz).toBe('Europe/Paris');
+ expect(located.sphere).toBe('north');
});
- it('should preserve existing geo properties (elevation, sphere, custom keys) when calling .geoLocate()', async () => {
+ it('should preserve custom non-geo keys while physical reality (Option B) updates location', async () => {
const t = new Tempo('2026-01-01', {
geo: {
elevation: 150,
- sphere: 'south',
- city: 'Sydney',
+ city: 'OldCity',
customKey: 'customValue',
+ venue: 'Harbour',
} as any,
});
@@ -168,6 +190,9 @@ describe('Tempo Plugin: Geo', () => {
success: true,
lat: -33.8688,
lon: 151.2093,
+ city: 'Sydney',
+ country: 'Australia',
+ timezone: 'Australia/Sydney',
};
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
@@ -175,12 +200,64 @@ describe('Tempo Plugin: Geo', () => {
);
const located = await t.geoLocate();
- expect(located.geo?.latitude).toBe(-33.8688);
- expect(located.geo?.longitude).toBe(151.2093);
+ expect(located.geo?.latitude).toBe(-33.869);
+ expect(located.geo?.longitude).toBe(151.209);
expect((located.geo as any)?.elevation).toBe(150);
- expect((located.geo as any)?.sphere).toBe('south');
- expect((located.geo as any)?.city).toBe('Sydney');
- expect((located.geo as any)?.customKey).toBe('customValue');
+ expect(located.geo?.city).toBe('Sydney'); // Option B: fresh city overwrites OldCity
+ expect(located.geo?.country).toBe('Australia');
+ expect(located.geo?.sphere).toBe('south');
+ expect((located.geo as any)?.customKey).toBe('customValue'); // Custom non-geo keys preserved
+ expect((located.geo as any)?.venue).toBe('Harbour');
+ expect(located.tz).toBe('Australia/Sydney');
+ });
+
+ it('should allow call-site overrides (Option C) to win over network reality', async () => {
+ const t = new Tempo('2026-01-01T12:00:00Z');
+
+ const mockPayload = {
+ ip: '8.8.8.8',
+ success: true,
+ lat: 37.4223,
+ lon: -122.0848,
+ city: 'Mountain View',
+ country: 'United States',
+ timezone: 'America/Los_Angeles',
+ };
+
+ vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
+ new Response(JSON.stringify(mockPayload), { status: 200 })
+ );
+
+ // Call-site override: specify custom country and city
+ const located = await t.geoLocate({ country: 'GB', city: 'London' });
+ expect(located.geo?.country).toBe('GB'); // Option C wins
+ expect(located.geo?.city).toBe('London'); // Option C wins
+ expect(located.geo?.latitude).toBe(37.422);
+ expect(located.geo?.longitude).toBe(-122.085);
+ });
+
+ it('should support setTimezone: false opt-out to retain original timezone', async () => {
+ const t = new Tempo('2026-01-01T12:00:00Z', { timeZone: 'UTC' });
+
+ const mockPayload = {
+ ip: '1.1.1.1',
+ success: true,
+ lat: 35.6762,
+ lon: 139.6503,
+ city: 'Tokyo',
+ country: 'Japan',
+ timezone: 'Asia/Tokyo',
+ };
+
+ vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
+ new Response(JSON.stringify(mockPayload), { status: 200 })
+ );
+
+ const located = await t.geoLocate({ setTimezone: false });
+ expect(located.tz).toBe('UTC'); // Original tz preserved
+ expect(located.geo?.latitude).toBe(35.676);
+ expect(located.geo?.city).toBe('Tokyo');
+ expect(located.geo?.timezone).toBe('Asia/Tokyo');
});
});
@@ -191,8 +268,9 @@ describe('Tempo Plugin: Geo', () => {
expect(typeof Tempo.geo.get).toBe('function');
Tempo.geo.stash({ latitude: -33.8688, longitude: 151.2093, city: 'Sydney' });
expect(Tempo.geo.current).toEqual({
- latitude: -33.8688,
- longitude: 151.2093,
+ latitude: -33.869,
+ longitude: 151.209,
+ sphere: 'south',
city: 'Sydney',
});
Tempo.geo.clear();
@@ -217,17 +295,17 @@ describe('Tempo Plugin: Geo', () => {
);
const first = await geoLookup();
- expect(first.lat).toBe(34.0522);
+ expect(first.lat).toBe(34.052);
expect(mockFetch).toHaveBeenCalledTimes(1);
// Second lookup should be served from BoundedCache (zero network calls)
const cached = await geoLookup();
- expect(cached.lat).toBe(34.0522);
+ expect(cached.lat).toBe(34.052);
expect(mockFetch).toHaveBeenCalledTimes(1);
// Calling with { refresh: true } should bypass cache
const refreshed = await geoLookup({ refresh: true });
- expect(refreshed.lat).toBe(34.0522);
+ expect(refreshed.lat).toBe(34.052);
expect(mockFetch).toHaveBeenCalledTimes(2);
});
@@ -236,13 +314,15 @@ describe('Tempo Plugin: Geo', () => {
stashGeo({ latitude: 48.8566, longitude: 2.3522, city: 'Paris' }, undefined, 'tenant-fr');
expect(getStashedGeo('tenant-uk')).toEqual({
- latitude: 51.5074,
- longitude: -0.1278,
+ latitude: 51.507,
+ longitude: -0.128,
+ sphere: 'north',
city: 'London',
});
expect(getStashedGeo('tenant-fr')).toEqual({
- latitude: 48.8566,
- longitude: 2.3522,
+ latitude: 48.857,
+ longitude: 2.352,
+ sphere: 'north',
city: 'Paris',
});
@@ -266,10 +346,96 @@ describe('Tempo Plugin: Geo', () => {
);
const result = await Tempo.geo.lookup();
- expect(result.lat).toBe(-37.8136);
- expect(result.lng).toBe(144.9631);
+ expect(result.lat).toBe(-37.814);
+ expect(result.lng).toBe(144.963);
expect(result.city).toBe('Melbourne');
expect(Tempo.geo.current?.city).toBe('Melbourne');
});
});
+
+ describe('Great-Circle Distance & Layout Formatting', () => {
+ const sydney = new Tempo('2026-10-24T10:00:00', {
+ geo: {
+ latitude: -33.8688,
+ longitude: 151.2093,
+ city: 'sydney',
+ country: 'au',
+ sphere: 'south',
+ elevation: 42,
+ },
+ });
+
+ const melbourne = new Tempo('2026-10-24T12:00:00', {
+ geo: {
+ latitude: -37.8136,
+ longitude: 144.9631,
+ city: 'melbourne',
+ country: 'au',
+ sphere: 'south',
+ },
+ });
+
+ it('should calculate distance via pure haversineDistance function', () => {
+ const dist = haversineDistance(sydney, melbourne, 'km');
+ expect(dist).toBe(713.426);
+ });
+
+ it('should calculate distance via Tempo.geo.distance static method', () => {
+ const km = Tempo.geo.distance(sydney, melbourne, 'km');
+ expect(km).toBe(713.426);
+
+ const miles = Tempo.geo.distance(sydney, melbourne, 'miles');
+ expect(miles).toBe(443.303);
+
+ const meters = Tempo.geo.distance(sydney, melbourne, 'm');
+ expect(meters).toBe(713426);
+ });
+
+ it('should calculate distance via instance method t.geoDistance()', () => {
+ const km = sydney.geoDistance(melbourne, 'km');
+ expect(km).toBe(713.426);
+
+ const miles = sydney.geoDistance(melbourne, 'miles');
+ expect(miles).toBe(443.303);
+ });
+
+ it('should detect velocity / impossible travel between timestamped instances', () => {
+ const km = sydney.geoDistance(melbourne, 'km');
+ const hours = sydney.until(melbourne, 'hours');
+ expect(hours).toBe(2);
+
+ const speedKmH = km / hours;
+ expect(speedKmH).toBeCloseTo(356.713, 2);
+ });
+
+ it('should format geographic layout tokens with modifiers and fallback on geo-enabled instances', () => {
+ const formatted = sydney.format('{geo.city:title}, {geo.country:upper} ({geo.sphere}) @ {h12}:{mi} {mer}');
+ expect(formatted).toBe('Sydney, AU (south) @ 10:00 am');
+
+ // Missing custom token cleanly returns empty string
+ expect(sydney.format('{geo.venue}')).toBe('');
+ });
+
+ it('should calculate natural solar time offset via Tempo.geo.solarOffset and t.geoSolarOffset()', () => {
+ // Sydney on October 24 observes AEDT (UTC+11, meridian 165°) -> (151.209 - 165) * 4 = -55.16 min
+ const dstOffset = sydney.geoSolarOffset();
+ expect(dstOffset).toBe(-55.16);
+
+ // Sydney on standard time June 21 observes AEST (UTC+10, meridian 150°) -> (151.209 - 150) * 4 = +4.84 min
+ const staticOffset = Tempo.geo.solarOffset(sydney, { date: '2026-06-21T12:00:00Z' });
+ expect(staticOffset).toBe(4.84);
+
+ const instanceOffset = sydney.geoSolarOffset({ date: '2026-06-21T12:00:00Z' });
+ expect(instanceOffset).toBe(4.84);
+
+ // Unit conversions on standard time
+ expect(sydney.geoSolarOffset({ date: '2026-06-21T12:00:00Z', unit: 'seconds' })).toBe(290.16);
+ expect(sydney.geoSolarOffset({ date: '2026-06-21T12:00:00Z', unit: 'hours', precision: 3 })).toBe(0.081);
+
+ // Apparent solar time on June 21 (incorporating Equation of Time: ~ -1.33 min)
+ const apparent = sydney.geoSolarOffset({ date: '2026-06-21T12:00:00Z', apparent: true });
+ expect(typeof apparent).toBe('number');
+ expect(apparent).toBe(3.51);
+ });
+ });
});
diff --git a/packages/tempo/CHANGELOG.md b/packages/tempo/CHANGELOG.md
index e2dff00f..b87b1c67 100644
--- a/packages/tempo/CHANGELOG.md
+++ b/packages/tempo/CHANGELOG.md
@@ -6,6 +6,24 @@ 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).
+## [4.2.0] - 2026-09-09
+
+### Added
+- **Dynamic Namespace & Spatial Layout Format Tokens (`{namespace.key}`)**:
+ - Generalized token resolution in `t.format()` to dynamically interpolate any active plugin namespace (`t.finance`, `t.astro`, `t.term`) or custom property container attached to the `Tempo` instance.
+ - Dedicated spatial formatting: `{geo.city}`, `{geo.country}`, `{geo.sphere}`, `{geo.elevation}`, `{geo.timezone}`, and custom location metadata (e.g. `{geo.venue}`).
+ - Full compatibility with format modifiers: e.g. `{geo.country:upper}`, `{geo.city:title}`, `{finance.taxYear}`.
+ - Graceful fallback: Safely resolves missing properties in known contexts (like `geo`) to `''` (empty string) while leaving unhandled tokens intact.
+ - Compile-time format validation: Updated `_ValidToken` in `tempo.type.ts` to accept `${string}.${string}`.
+- **Universal Geographic Context in Parsing & Construction**:
+ - Support for `options.geo` (`latitude`, `longitude`, `city`, `country`, `elevation`, `timezone`, etc.) in `new Tempo(input, options)` and `parse(input, options)`.
+ - Enforces deterministic 3-decimal rounding and Earth boundary limits ($\text{lat} \in [-90, 90]$, $\text{lng} \in [-180, 180]$) on input coordinates.
+ - Inferred hemisphere (`'north'`, `'south'`, `'equator'`) automatically derived and exposed on `t.sphere` and `t.geo.sphere`.
+ - Canonical read-only getter `t.geo` returning frozen `GeoConfig`.
+- **Equator Compass Alignment**:
+ - Added `Equator: 'equator'` to `COMPASS` enum.
+ - Updated `t.sphere` to return `'equator'` when coordinates fall within the $\pm 0.001^\circ$ equatorial band.
+
## [4.1.1] - 2026-09-07
### Added
diff --git a/packages/tempo/doc/2-core-concepts/tempo.config.md b/packages/tempo/doc/2-core-concepts/tempo.config.md
index e304cfb6..f1492c2e 100644
--- a/packages/tempo/doc/2-core-concepts/tempo.config.md
+++ b/packages/tempo/doc/2-core-concepts/tempo.config.md
@@ -304,6 +304,39 @@ When a `Tempo` instance is constructed:
---
+### 4.2 Geographic Coordinates & Location Configuration (`geo`)
+
+You can configure geographic coordinates and location metadata globally or override them on individual instances:
+
+```typescript
+// Global baseline in tempo.config.ts or Tempo.init():
+Tempo.init({
+ geo: {
+ lat: -33.8688,
+ lng: 151.2093,
+ city: 'Sydney',
+ country: 'Australia'
+ }
+});
+
+// Or per-instance override:
+const t = new Tempo('now', {
+ geo: {
+ lat: 51.5074,
+ lng: -0.1278,
+ city: 'London',
+ country: 'United Kingdom'
+ }
+});
+```
+
+* **Deterministic Validation**: Latitudes outside $[-90, 90]$ and longitudes outside $[-180, 180]$ are rejected.
+* **3-Decimal Precision**: Automatically rounded (`Math.round(val * 1000) / 1000`) to guarantee cache hit consistency.
+* **Hemisphere Inference**: Automatically sets `t.sphere` (`'north'`, `'south'`, or `'equator'`).
+* **Canonical Access**: Read through `t.geo` (recursively frozen `GeoConfig`) and `t.sphere`.
+
+---
+
## 5. Advanced Parsing Rules
Beyond basic settings, Tempo's parsing engine can be extended with custom rules and behaviors to handle specialized natural language or high-volume processing requirements.
diff --git a/packages/tempo/doc/2-core-concepts/tempo.format.md b/packages/tempo/doc/2-core-concepts/tempo.format.md
index e44b3031..dddfb369 100644
--- a/packages/tempo/doc/2-core-concepts/tempo.format.md
+++ b/packages/tempo/doc/2-core-concepts/tempo.format.md
@@ -187,6 +187,57 @@ t.format('{wkd:lower} afternoon');
---
+## 📍 Dynamic Namespace & Spatial Tokens (`{namespace.key}`)
+
+Tempo's format engine dynamically resolves bracketed dot-notated tokens (`{namespace.key}`) against any active plugin namespace or property container on the `Tempo` instance (such as `t.geo`, `t.finance`, `t.astro`, `t.term`, or custom attached metadata).
+
+### Geographic Tokens (`{geo.*}`)
+When geographic coordinates or metadata are attached to a Tempo instance (via `options.geo`, `new Tempo('...', { geo: { city: 'Sydney', country: 'AU', lat: -33.8688, lng: 151.2093 } })`, or resolved via `t.geoLocate()`), you can format location fields directly using `{geo.}` tokens:
+
+| Token | Description | Example |
+| :--- | :--- | :--- |
+| `{geo.city}` | Locality or City name | `Sydney` |
+| `{geo.country}` | ISO Country code or Country name | `AU` / `Australia` |
+| `{geo.sphere}` | Hemisphere (`north`, `south`, or `equator`) | `south` |
+| `{geo.elevation}`| Altitude / Elevation in meters above sea level | `42` |
+| `{geo.timezone}` | IANA Timezone resolved from coordinates | `Australia/Sydney` |
+| `{geo.}` | Any custom metadata key attached to `t.geo` | `Opera House` |
+
+### Custom & Plugin Namespace Tokens
+Any namespace plugin mounted via `defineNamespace` or property object on the instance can be formatted directly:
+
+```typescript
+// Plugin namespace (e.g. tempo-plugin-finance):
+t.format('Fiscal Quarter: Q{finance.fiscalQuarter} ({finance.taxYear})');
+
+// Term plugin (e.g. season or solar terms):
+t.format('Current Season: {term.season} · {geo.city:title}');
+```
+
+### Modifiers & Graceful Fallback
+All standard formatting modifiers apply seamlessly:
+* `{geo.country:upper}` → `AU`
+* `{geo.city:title}` → `Sydney`
+* `{geo.sphere:upper}` → `SOUTH`
+
+For known property namespaces (like `geo`), if the context is unpopulated or the requested property is missing, the token cleanly resolves to an empty string (`''`) rather than throwing an exception or printing `undefined`:
+
+```typescript
+const t = new Tempo('2026-10-24T15:30:00', {
+ geo: { city: 'Sydney', country: 'AU', lat: -33.869, lng: 151.209 }
+});
+
+t.format('{geo.city}, {geo.country} · {h12}:{mi} {mer}');
+// "Sydney, AU · 03:30 pm"
+
+// Graceful fallback for instances without geo:
+const tNoGeo = new Tempo('2026-10-24T15:30:00');
+tNoGeo.format('Time: {hh}:{mi} [{geo.city}]');
+// "Time: 15:30 []"
+```
+
+---
+
## 🌍 Complex Native Intl Formatting
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).
diff --git a/packages/tempo/doc/2-core-concepts/tempo.parse.md b/packages/tempo/doc/2-core-concepts/tempo.parse.md
index 11ff23cf..0c4bbd14 100644
--- a/packages/tempo/doc/2-core-concepts/tempo.parse.md
+++ b/packages/tempo/doc/2-core-concepts/tempo.parse.md
@@ -324,6 +324,34 @@ Tempo.init({
---
+## 📍 Geographic Context & Coordinates (`geo` Option)
+
+When constructing a `Tempo` instance or calling `parse()`, you can supply a `geo` object directly in the options:
+
+```typescript
+const t = new Tempo('2026-10-24 15:30', {
+ geo: {
+ lat: -33.8688,
+ lng: 151.2093,
+ city: 'Sydney',
+ country: 'Australia'
+ }
+});
+
+console.log(t.geo?.latitude); // -33.869 (rounded to 3 decimals)
+console.log(t.geo?.longitude); // 151.209
+console.log(t.sphere); // 'south'
+```
+
+### Deterministic Normalization & Validation
+Tempo applies strict validation and standardization to all incoming coordinate payloads with **zero external network dependencies**:
+* **Boundary Validation**: Coordinates outside physical Earth limits ($\text{lat} \in [-90, 90]$, $\text{lng} \in [-180, 180]$) are strictly rejected.
+* **3-Decimal Precision**: Coordinates are rounded deterministically to 3 decimal places ($\approx 111\,\text{m}$ resolution at the equator), eliminating sensor noise and storage cache fragmentation.
+* **Automatic Hemisphere Inference**: The hemisphere (`'north'`, `'south'`, or `'equator'`) is automatically deduced from latitude, recognizing the $\pm 0.001^\circ$ equatorial band.
+* **Immutable Access**: Normalized geographic metadata is accessible on the instance via `t.geo` (recursively frozen `GeoConfig`) and `t.sphere`.
+
+---
+
## 🛡️ Performance: The Master Guard
Tempo uses a "Scan-and-Consume" engine called the **Master Guard**. This allows it to check your input string against dozens of patterns (weekdays, months, custom events) in a single pass.
diff --git a/packages/tempo/package.json b/packages/tempo/package.json
index 92d6fc42..bf34bab6 100644
--- a/packages/tempo/package.json
+++ b/packages/tempo/package.json
@@ -1,6 +1,6 @@
{
"name": "@magmacomputing/tempo",
- "version": "4.1.2",
+ "version": "4.2.0",
"engines": {
"node": ">=20.0.0"
},
diff --git a/packages/tempo/public/esm_sh.index.html b/packages/tempo/public/esm_sh.index.html
index 48a0f6af..bf661927 100644
--- a/packages/tempo/public/esm_sh.index.html
+++ b/packages/tempo/public/esm_sh.index.html
@@ -270,7 +270,7 @@ Tempo
{
"imports": {
"@js-temporal/polyfill": "https://esm.sh/@js-temporal/polyfill@0.5.1",
- "@magmacomputing/tempo": "https://esm.sh/@magmacomputing/tempo@4.1.2"
+ "@magmacomputing/tempo": "https://esm.sh/@magmacomputing/tempo@4.2.0"
}
}
diff --git a/packages/tempo/public/llms.txt b/packages/tempo/public/llms.txt
index d519347f..d34b9e8a 100644
--- a/packages/tempo/public/llms.txt
+++ b/packages/tempo/public/llms.txt
@@ -1,6 +1,6 @@
-# Tempo: Immutable Date-Time Engine & AI Syntax Rules (v4.1.2)
+# Tempo: Immutable Date-Time Engine & AI Syntax Rules (v4.2.0)
-> Tempo (v4.1.2) is an immutable TypeScript date-time engine built around the ECMAScript Temporal API. It provides type-safe parsing, formatting, relative time arithmetic, and extensible layout matching across Browser and Node.js environments.
+> Tempo (v4.2.0) is an immutable TypeScript date-time engine built around the ECMAScript Temporal API. It provides type-safe parsing, formatting, relative time arithmetic, and extensible layout matching across Browser and Node.js environments.
## Core Architectural Rules & Philosophy
- **Temporal Engine**: Tempo uses native `Temporal` in modern runtimes or `@js-temporal/polyfill`. Never instantiate legacy JavaScript `Date`.
diff --git a/packages/tempo/src/module/module.format.ts b/packages/tempo/src/module/module.format.ts
index fceb438a..b07141d1 100644
--- a/packages/tempo/src/module/module.format.ts
+++ b/packages/tempo/src/module/module.format.ts
@@ -2,7 +2,7 @@ import '#library/temporal.polyfill.js';
import { pad, toTitleCase } from '#library/string.library.js';
import { deepMerge } from '#library/object.library.js';
import { suffix } from '#library/number.library.js';
-import { isString, isObject, isZonedDateTime, isInstant, isPlainDate, isPlainDateTime, isUndefined, isDefined, isFunction } from '#library/assertion.library.js';
+import { isString, isObject, isZonedDateTime, isInstant, isPlainDate, isPlainDateTime, isUndefined, isDefined, isFunction, isSafeKey, isNullish } from '#library/assertion.library.js';
import { formatDayPeriod, getDTF, getPR, getISOWeekOfYear } from '#library/international.library.js';
import { delegator } from '#library/proxy.library.js';
@@ -34,6 +34,32 @@ declare module '../tempo.class.js' {
}
}
+/**
+ * Resolves dot-delimited namespace tokens (e.g. '{geo.city}', '{custom.tag.name}')
+ * against a Tempo instance with safety guards and graceful fallback.
+ */
+function resolveNamespaceToken(obj: unknown, token: string): string {
+ const parts = token.split('.');
+ if (!parts.every(isSafeKey)) return `{${token}}`;
+
+ const [root, ...rest] = parts;
+ let curr = (obj as any)[root];
+
+ if (isNullish(curr)) {
+ return root === 'geo' ? '' : `{${token}}`;
+ }
+
+ for (const key of rest) {
+ if (isNullish(curr)) return '';
+ curr = curr[key];
+ }
+
+ if (isNullish(curr) || isFunction(curr)) return '';
+ return isObject(curr)
+ ? String(curr.label ?? curr.key ?? curr.name ?? curr.value ?? curr)
+ : String(curr);
+}
+
/**
* Standalone Formatter
* Returns a formatted string from a Temporal.ZonedDateTime or Tempo instance.
@@ -254,11 +280,11 @@ export function format(obj?: any, fmt?: any, options?: any): any {
res = customTokenFn(zdt, { modifiers, config });
} else if (token.startsWith('#') && isTempo(obj)) {
const termObj = (obj as unknown as Tempo).term[token.slice(1)];
- if (isObject(termObj)) {
- res = termObj.label ?? termObj.key ?? `{${token}}`;
- } else {
- res = termObj ?? `{${token}}`;
- }
+ res = isObject(termObj)
+ ? (termObj.label ?? termObj.key ?? `{${token}}`)
+ : (termObj ?? `{${token}}`);
+ } else if (token.includes('.') && isTempo(obj)) {
+ res = resolveNamespaceToken(obj, token);
} else {
res = `{${token}}`;
}
diff --git a/packages/tempo/src/support/support.enum.ts b/packages/tempo/src/support/support.enum.ts
index a2637b1c..cd2e656e 100644
--- a/packages/tempo/src/support/support.enum.ts
+++ b/packages/tempo/src/support/support.enum.ts
@@ -10,7 +10,7 @@ export const SEASON = enumify({
/** Spring season */ Spring: 'spring',
/** Summer season */ Summer: 'summer',
/** Autumn season */ Autumn: 'autumn',
- /** Winter season */ Winter: 'winter'
+ /** Winter season */ Winter: 'winter',
}, false);
export type SEASON = ValueOf
@@ -19,7 +19,8 @@ export const COMPASS = looseIndex()(enumify({
/** North direction */ North: 'north',
/** South direction */ South: 'south',
/** East direction */ East: 'east',
- /** West direction */ West: 'west'
+ /** West direction */ West: 'west',
+ /** Equator zone */ Equator: 'equator',
}, false));
export type COMPASS = ValueOf
@@ -41,7 +42,7 @@ export const DEFAULTS = {
/** number value 7 */ seven: 7,
/** number value 8 */ eight: 8,
/** number value 9 */ nine: 9,
- /** number value 10 */ ten: 10
+ /** number value 10 */ ten: 10,
},
DURATION: {
/** approx number of seconds in a year */ year: 31_536_000,
diff --git a/packages/tempo/src/tempo.class.ts b/packages/tempo/src/tempo.class.ts
index 7f3ea26a..7ff54e90 100644
--- a/packages/tempo/src/tempo.class.ts
+++ b/packages/tempo/src/tempo.class.ts
@@ -239,7 +239,7 @@ export class Tempo {
const geo = coerceGeo(options) ?? shape.config.geo ?? getStashedGeo();
if (isDefined(geo?.sphere)) return geo.sphere as t.COMPASS;
if (isNumber(geo?.latitude))
- return geo.latitude >= 0 ? 'north' : 'south';
+ return (geo.latitude > 0.001 ? 'north' : (geo.latitude < -0.001 ? 'south' : 'equator')) as t.COMPASS;
const resolvedTz = options.timeZone ?? shape.config.timeZone;
if (isDefined(resolvedTz) && String(resolvedTz).toLowerCase() !== 'utc') {
@@ -1664,7 +1664,7 @@ export class Tempo {
const res = evaluate(
this.#local.options && hasOwn(this.#local.options, 'sphere') ? this.#local.options.sphere : undefined,
geoSphere,
- isNumber(lat) ? (lat >= 0 ? 'north' : 'south') : undefined,
+ isNumber(lat) ? (lat > 0.001 ? 'north' : (lat < -0.001 ? 'south' : 'equator')) : undefined,
hasInstanceTzOverride ? () => getHemisphere(String(this.tz)) : undefined,
hasOwn(this.#local.config, 'sphere') ? this.#local.config.sphere : undefined,
() => (isDefined(this.tz) && String(this.tz).toLowerCase() !== 'utc' ? getHemisphere(String(this.tz)) : undefined),
@@ -1876,12 +1876,25 @@ export class Tempo {
setProperty(this.#local.config, 'sphere', evalSphere);
}
+ const explicitGeo = coerceGeo(options);
+ if (isDefined(explicitGeo)) {
+ setProperty(this.#local.config, 'geo', explicitGeo);
+ this.#local.userProvidedKeys.add('geo');
+ if (isUndefined(evalSphere) && isDefined(explicitGeo.sphere)) {
+ evalSphere = explicitGeo.sphere as t.COMPASS;
+ setProperty(this.#local.config, 'sphere', evalSphere);
+ }
+ } else if (classState.userProvidedKeys?.has('geo')) {
+ setProperty(this.#local.config, 'geo', classState.config.geo);
+ }
+
const optionsSnapshot = {
...options,
...(isDefined(explicitTz) ? { timeZone: resolvedZone } : {}),
...(isDefined(explicitCal) ? { calendar: String(evaluatedCal) } : {}),
...(isDefined(explicitLoc) ? { locale: finalLocale } : {}),
...(isDefined(explicitSphere) ? { sphere: explicitSphere } : {}),
+ ...(isDefined(explicitGeo) ? { geo: explicitGeo } : {}),
};
this.#local.options = optionsSnapshot;
diff --git a/packages/tempo/src/tempo.type.ts b/packages/tempo/src/tempo.type.ts
index 2eee79b2..415bd7ce 100644
--- a/packages/tempo/src/tempo.type.ts
+++ b/packages/tempo/src/tempo.type.ts
@@ -240,8 +240,9 @@ type _CoreToken = keyof TempoFormatTokens;
* - a core token alone: `{yyyy}`
* - a core token with one or more modifiers: `{dd:ord}`, `{tz:zzzzz}`
* - a term-plugin key (always accepted, cannot be statically known): `{#season.key}`
+ * - a dynamic namespace or property path token: `{geo.city}`, `{finance.taxYear}`, `{term.quarter}`
*/
-type _ValidToken = _CoreToken | `${_CoreToken}:${string}` | `#${string}` | `term.${string}`;
+type _ValidToken = _CoreToken | `${_CoreToken}:${string}` | `#${string}` | `${string}.${string}`;
/**
* Compile-time validator for Tempo format strings.
diff --git a/packages/tempo/src/tempo.version.ts b/packages/tempo/src/tempo.version.ts
index 59fe4e59..c45a4a32 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 = '4.1.2';
+export const TEMPO_VERSION = '4.2.0';
diff --git a/packages/tempo/test/core/constructor.core.test.ts b/packages/tempo/test/core/constructor.core.test.ts
index cb2f63a9..5927995d 100644
--- a/packages/tempo/test/core/constructor.core.test.ts
+++ b/packages/tempo/test/core/constructor.core.test.ts
@@ -74,14 +74,20 @@ describe('Tempo Core', () => {
it('infers sphere as south when latitude is negative', () => {
const t = new Tempo('2026-09-02', { geo: { lat: -33.8688, lng: 151.2093 } });
expect(t.sphere).toBe('south');
- expect(t.geo).toEqual({ latitude: -33.8688, longitude: 151.2093 });
+ expect(t.geo).toEqual({ latitude: -33.869, longitude: 151.209, sphere: 'south' });
expect(Object.isFrozen(t.geo)).toBe(true);
});
it('infers sphere as north when latitude is positive', () => {
const t = new Tempo('2026-09-02', { geo: { latitude: 40.7128, longitude: -74.006 } });
expect(t.sphere).toBe('north');
- expect(t.geo).toEqual({ latitude: 40.7128, longitude: -74.006 });
+ expect(t.geo).toEqual({ latitude: 40.713, longitude: -74.006, sphere: 'north' });
+ });
+
+ it('infers sphere as equator when latitude is within +/- 0.001', () => {
+ const t = new Tempo('2026-09-02', { geo: { lat: 0, lng: 100 } });
+ expect(t.sphere).toBe('equator');
+ expect(t.geo).toEqual({ latitude: 0, longitude: 100, sphere: 'equator' });
});
it('allows explicit sphere to override latitude inference', () => {
@@ -93,14 +99,14 @@ describe('Tempo Core', () => {
Tempo.init({ geo: { latitude: -33.8688, longitude: 151.2093 } });
const t = new Tempo('2026-09-02');
expect(t.sphere).toBe('south');
- expect(t.geo).toEqual({ latitude: -33.8688, longitude: 151.2093 });
+ expect(t.geo).toEqual({ latitude: -33.869, longitude: 151.209, sphere: 'south' });
});
it('infers sphere from sandbox Tempo.create config', () => {
const CustomTempo = Tempo.create({ geo: { latitude: -33.8688, longitude: 151.2093 } });
const t = new CustomTempo('2026-09-02');
expect(t.sphere).toBe('south');
- expect(t.geo).toEqual({ latitude: -33.8688, longitude: 151.2093 });
+ expect(t.geo).toEqual({ latitude: -33.869, longitude: 151.209, sphere: 'south' });
});
});
});
diff --git a/packages/tempo/test/instance/instance.format.test.ts b/packages/tempo/test/instance/instance.format.test.ts
index 4765e248..15bbf0b5 100644
--- a/packages/tempo/test/instance/instance.format.test.ts
+++ b/packages/tempo/test/instance/instance.format.test.ts
@@ -132,4 +132,63 @@ describe(`${label} format method`, () => {
expect(t.format('{ymd:year}')).toBe('240520');
});
+ test('formats spatial tokens {geo.*} with modifiers and graceful fallback', () => {
+ const tWithGeo = new Tempo('2026-10-24T15:30:00', {
+ geo: {
+ latitude: -33.8688,
+ longitude: 151.2093,
+ city: 'sydney',
+ country: 'au',
+ sphere: 'south',
+ elevation: 42,
+ timezone: 'Australia/Sydney',
+ venue: 'opera house',
+ },
+ });
+
+ expect(tWithGeo.format('{geo.city}')).toBe('sydney');
+ expect(tWithGeo.format('{geo.city:title}')).toBe('Sydney');
+ expect(tWithGeo.format('{geo.country:upper}')).toBe('AU');
+ expect(tWithGeo.format('{geo.sphere}')).toBe('south');
+ expect(tWithGeo.format('{geo.elevation}')).toBe('42');
+ expect(tWithGeo.format('{geo.venue:title}')).toBe('Opera house');
+ expect(tWithGeo.format('{geo.city:title}, {geo.country:upper} · {h12}:{mi} {mer}')).toBe('Sydney, AU · 03:30 pm');
+
+ // Missing property returns empty string
+ expect(tWithGeo.format('{geo.nonexistent}')).toBe('');
+
+ // Instance without geo returns empty string without error
+ const tNoGeo = new Tempo('2026-10-24T15:30:00');
+ expect(tNoGeo.format('{geo.city}')).toBe('');
+ expect(tNoGeo.format('{geo.country}')).toBe('');
+ expect(tNoGeo.format('Time: {hh}:{mi} [{geo.city}]')).toBe('Time: 15:30 []');
+ });
+
+ test('dynamically resolves arbitrary {namespace.key} properties on instance', () => {
+ Object.defineProperty(Tempo.prototype, 'custom', {
+ value: {
+ project: 'apollo',
+ iteration: 11,
+ tag: { name: 'core-team' },
+ },
+ configurable: true,
+ writable: true,
+ });
+
+ try {
+ const t = new Tempo('2026-10-24T15:30:00');
+ expect(t.format('Project: {custom.project:upper} (Sprint {custom.iteration})')).toBe('Project: APOLLO (Sprint 11)');
+ expect(t.format('{custom.tag.name}')).toBe('core-team');
+ // Undefined property in known container resolves cleanly to empty string
+ expect(t.format('{custom.missing}')).toBe('');
+ // Entirely unknown namespace without object on instance stays intact
+ expect(t.format('{unknown.token}')).toBe('{unknown.token}');
+ // Security: Prototype pollution or unsafe keys are rejected and left intact
+ expect(t.format('{__proto__.polluted}')).toBe('{__proto__.polluted}');
+ expect(t.format('{constructor.name}')).toBe('{constructor.name}');
+ } finally {
+ delete (Tempo.prototype as any).custom;
+ }
+ });
+
});
From 154636caa731ad0a8a8aec1ede2a2762580e1f51 Mon Sep 17 00:00:00 2001
From: Michael McRae
Date: Wed, 9 Sep 2026 15:29:27 +1000
Subject: [PATCH 09/11] PR 5th review
---
packages/functions/CHANGELOG.md | 6 +-
packages/functions/src/celestial/index.ts | 4 +-
packages/functions/test/celestial.test.ts | 18 +++
.../src/common/runtime/mapper.library.ts | 19 +--
.../test/common/runtime/mapper.common.test.ts | 28 ++++-
packages/plugins/.setup/catalog.json | 4 +-
packages/plugins/celestial/CHANGELOG.md | 6 +
packages/plugins/celestial/README.md | 3 -
packages/plugins/celestial/doc/index.md | 3 -
packages/plugins/celestial/package.json | 2 +-
packages/plugins/geo/test/geo.test.ts | 4 +
.../tempo/.vitepress/theme/data/catalog.json | 4 +-
packages/tempo/CHANGELOG.md | 6 +
.../tempo/doc/2-core-concepts/tempo.config.md | 3 +-
packages/tempo/src/config/config.resolve.ts | 118 +++++++++++++-----
15 files changed, 168 insertions(+), 60 deletions(-)
diff --git a/packages/functions/CHANGELOG.md b/packages/functions/CHANGELOG.md
index c40e4621..28509222 100644
--- a/packages/functions/CHANGELOG.md
+++ b/packages/functions/CHANGELOG.md
@@ -5,9 +5,13 @@ 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).
-## [0.2.0] - 2026-09-02
+## [0.2.0] - 2026-09-09
### Added
+- **Elevation Horizon Dip**: Integrated observer elevation (meters above sea level) into `getSunriseSunset()` apparent solar timing calculations:
+ - Added optional `elevation` parameter to `SolarOptions` and included resolved `elevation` in `SunriseSunsetResult`.
+ - Factors atmospheric horizon dip ($\Delta\theta \approx 0.0347^\circ \times \sqrt{\max(0, \text{elevation})}$) into the solar zenith angle ($90.833^\circ + \Delta\theta$).
+ - Correctly shifts sunrise earlier, sunset later, and expands daylight duration for elevated observers while keeping true solar noon transit invariant.
- **Celestial Utilities**: Introduced new pure astronomical, celestial, solar, lunar, and zodiac utility module (`@magmacomputing/tempo-fns/celestial`):
- `getLunarPhase`: Calculates lunar phase name, 1-based index (1..8), illumination 0.0–1.0 fraction, age in days, waxing status, and hemisphere-aware emojis.
- `getLunarPhaseRange`: Resolves start/end boundaries for active lunar phase cycles.
diff --git a/packages/functions/src/celestial/index.ts b/packages/functions/src/celestial/index.ts
index 6058fd17..ddd04798 100644
--- a/packages/functions/src/celestial/index.ts
+++ b/packages/functions/src/celestial/index.ts
@@ -474,7 +474,7 @@ function resolveCoordinates(latOrOptions: number | SolarOptions = 0, lngInput =
const lat = geo.latitude ?? geo.lat ?? (latOrOptions as any).latitude ?? (latOrOptions as any).lat ?? 0;
const lng = geo.longitude ?? geo.lng ?? geo.lon ?? geo.long ?? (latOrOptions as any).longitude ?? (latOrOptions as any).lng ?? (latOrOptions as any).lon ?? (latOrOptions as any).long ?? 0;
const rawElevation = geo.elevation ?? (latOrOptions as any).elevation;
- const elevation = typeof rawElevation === 'number' && !isNaN(rawElevation) ? rawElevation : 0;
+ const elevation = typeof rawElevation === 'number' && Number.isFinite(rawElevation) ? rawElevation : 0;
return { lat, lng, elevation };
}
return { lat: 0, lng: 0, elevation: 0 };
@@ -586,7 +586,7 @@ export function getSunriseSunset(
return {
latitude: lat,
longitude: lng,
- ...(elevation > 0 ? { elevation } : {}),
+ elevation,
sunriseMs,
sunsetMs,
solarNoonMs,
diff --git a/packages/functions/test/celestial.test.ts b/packages/functions/test/celestial.test.ts
index cc502b8f..0d3352c3 100644
--- a/packages/functions/test/celestial.test.ts
+++ b/packages/functions/test/celestial.test.ts
@@ -103,6 +103,24 @@ describe('Astro Pure Functions (tempo-fns)', () => {
expect(highAltitude.elevation).toBe(1600);
});
+ it('handles non-finite elevation gracefully and produces valid sunrise/sunset timings', () => {
+ const date = '2026-06-21T12:00:00Z';
+ const baseline = getSunriseSunset(date, { lat: 39.7392, lng: -104.9903, elevation: 0 });
+ const infResult = getSunriseSunset(date, { lat: 39.7392, lng: -104.9903, elevation: Infinity });
+ const negInfResult = getSunriseSunset(date, { lat: 39.7392, lng: -104.9903, elevation: -Infinity });
+ const nanResult = getSunriseSunset(date, { lat: 39.7392, lng: -104.9903, elevation: NaN });
+
+ expect(infResult.elevation).toBe(0);
+ expect(Number.isFinite(infResult.sunriseMs)).toBe(true);
+ expect(Number.isFinite(infResult.sunsetMs)).toBe(true);
+ expect(Number.isFinite(infResult.daylightDurationMs)).toBe(true);
+ expect(infResult.sunriseMs).toBe(baseline.sunriseMs);
+ expect(infResult.sunsetMs).toBe(baseline.sunsetMs);
+
+ expect(negInfResult.elevation).toBe(0);
+ expect(nanResult.elevation).toBe(0);
+ });
+
it('calculates Western Tropical Zodiac sign', () => {
expect(getZodiacSign('2026-03-25')).toBe('Aries');
expect(getZodiacSign('2026-07-25')).toBe('Leo');
diff --git a/packages/library/src/common/runtime/mapper.library.ts b/packages/library/src/common/runtime/mapper.library.ts
index 7b00aad7..98710186 100644
--- a/packages/library/src/common/runtime/mapper.library.ts
+++ b/packages/library/src/common/runtime/mapper.library.ts
@@ -246,10 +246,11 @@ export const coerceGeo = (input?: any): GeoConfig | undefined => {
if (isString(city)) result.city = city;
if (isString(timezone)) result.timezone = timezone;
- if (isReference(geo)) {
- for (const key of Object.keys(geo)) {
+ const explicitGeo = isReference(input.geo) ? input.geo : (isReference(input.config?.geo) ? input.config.geo : undefined);
+ if (isReference(explicitGeo)) {
+ for (const key of Object.keys(explicitGeo)) {
if (isSafeKey(key) && !GEO_PROPERTIES.includes(key as any))
- (result as any)[key] = geo[key];
+ (result as any)[key] = explicitGeo[key];
}
}
@@ -591,10 +592,14 @@ export function solarOffset(coords: any, options?: SolarOffsetOptions): number {
}).formatToParts(new Date(epochMs));
const tzPart = parts.find(p => p.type === 'timeZoneName')?.value;
if (tzPart) {
- const match = tzPart.match(/GMT([+-])(\d{2}):(\d{2})/);
- if (match) {
- const sign = match[1] === '-' ? -1 : 1;
- offsetMinutes = sign * (parseInt(match[2], 10) * 60 + parseInt(match[3], 10));
+ if (tzPart === 'GMT' || tzPart === 'UTC') {
+ offsetMinutes = 0;
+ } else {
+ const match = tzPart.match(/GMT([+-])(\d{2}):(\d{2})/);
+ if (match) {
+ const sign = match[1] === '-' ? -1 : 1;
+ offsetMinutes = sign * (parseInt(match[2], 10) * 60 + parseInt(match[3], 10));
+ }
}
}
}
diff --git a/packages/library/test/common/runtime/mapper.common.test.ts b/packages/library/test/common/runtime/mapper.common.test.ts
index 59a7d01f..d2fb1b77 100644
--- a/packages/library/test/common/runtime/mapper.common.test.ts
+++ b/packages/library/test/common/runtime/mapper.common.test.ts
@@ -66,6 +66,21 @@ describe('common/runtime/mapper.library', () => {
expect(({} as any).polluted).toBeUndefined();
});
+ it('coerceGeo only copies custom non-geo keys when an explicit geo object is provided', () => {
+ const directWithCustom = { latitude: 10, longitude: 20, venue: 'HQ', locale: 'en' };
+ const coercedDirect = coerceGeo(directWithCustom);
+ expect(coercedDirect?.latitude).toBe(10);
+ expect(coercedDirect?.longitude).toBe(20);
+ expect((coercedDirect as any)?.venue).toBeUndefined();
+ expect((coercedDirect as any)?.locale).toBeUndefined();
+
+ const explicitWithCustom = { geo: { latitude: 10, longitude: 20, venue: 'HQ' } };
+ const coercedExplicit = coerceGeo(explicitWithCustom);
+ expect(coercedExplicit?.latitude).toBe(10);
+ expect(coercedExplicit?.longitude).toBe(20);
+ expect((coercedExplicit as any)?.venue).toBe('HQ');
+ });
+
it('resolveGeoCoordinates falls back to getStashedGeo when input only has metadata without coordinates', async () => {
const fetchSpy = vi.fn();
vi.stubGlobal('fetch', fetchSpy);
@@ -273,9 +288,9 @@ describe('common/runtime/mapper.library', () => {
describe('solarOffset', () => {
it('calculates natural solar time offset for civil timezone in minutes', () => {
- // Sydney (lng 151.209, AEST UTC+10, meridian 150°) -> (151.209 - 150) * 4 = +4.84 min
+ // Sydney on standard time (lng 151.209, AEST UTC+10, meridian 150°) -> (151.209 - 150) * 4 = +4.84 min
const sydney = { lat: -33.8688, lng: 151.2093, timezone: 'Australia/Sydney' };
- expect(solarOffset(sydney)).toBe(4.84);
+ expect(solarOffset(sydney, { date: '2026-06-21T12:00:00Z' })).toBe(4.84);
// Denver on standard time (lng -104.99, MST UTC-7, meridian -105°) -> (-104.99 - -105) * 4 = +0.04 min
const denver = { lat: 39.7392, lng: -104.9903, timezone: 'America/Denver' };
@@ -284,8 +299,13 @@ describe('common/runtime/mapper.library', () => {
it('supports unit conversion to seconds and hours with configurable precision', () => {
const sydney = { lat: -33.8688, lng: 151.2093, timezone: 'Australia/Sydney' };
- expect(solarOffset(sydney, { unit: 'seconds' })).toBe(290.16);
- expect(solarOffset(sydney, { unit: 'hours', precision: 3 })).toBe(0.081);
+ expect(solarOffset(sydney, { date: '2026-06-21T12:00:00Z', unit: 'seconds' })).toBe(290.16);
+ expect(solarOffset(sydney, { date: '2026-06-21T12:00:00Z', unit: 'hours', precision: 3 })).toBe(0.081);
+ });
+
+ it('handles plain GMT and UTC timezones correctly', () => {
+ expect(solarOffset({ lat: 51.5, lng: 0, timezone: 'UTC' })).toBe(0);
+ expect(solarOffset({ lat: 51.5, lng: 0, timezone: 'GMT' })).toBe(0);
});
it('falls back to natural 15-degree solar timezone meridian when no timezone is supplied', () => {
diff --git a/packages/plugins/.setup/catalog.json b/packages/plugins/.setup/catalog.json
index 5a5cab59..3b54a6e8 100644
--- a/packages/plugins/.setup/catalog.json
+++ b/packages/plugins/.setup/catalog.json
@@ -14,8 +14,8 @@
"description": "Tempo plugin for location-aware solar twilight events and lunar phase calculations",
"packageName": "@magmacomputing/tempo-plugin-celestial",
"plan": "community",
- "status": "experimental",
- "version": "0.1.0"
+ "status": "active",
+ "version": "0.2.0"
},
{
"id": "batch",
diff --git a/packages/plugins/celestial/CHANGELOG.md b/packages/plugins/celestial/CHANGELOG.md
index 22319578..224c4850 100644
--- a/packages/plugins/celestial/CHANGELOG.md
+++ b/packages/plugins/celestial/CHANGELOG.md
@@ -5,6 +5,12 @@ 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).
+## [0.2.0] - 2026-09-09
+
+### Added
+- **Elevation Support on Solar Term**: Exposed `elevation: number | null` on `t.term.solar` ([`TempoTermRegistry['solar']`](file:///home/michael/Project/magma/packages/plugins/celestial/src/index.ts#L52)).
+- **Atmospheric Horizon Dip**: Factored `t.geo.elevation` into solar sunrise, sunset, and daylight duration calculations via `@magmacomputing/tempo-fns`. Observers at high elevations experience earlier sunrise and later sunset due to geometric horizon dip.
+
## [0.1.0] - 2026-09-02
### Added
diff --git a/packages/plugins/celestial/README.md b/packages/plugins/celestial/README.md
index 2508e008..882a54c8 100644
--- a/packages/plugins/celestial/README.md
+++ b/packages/plugins/celestial/README.md
@@ -2,9 +2,6 @@
# @magmacomputing/tempo-plugin-celestial
-> [!WARNING]
-> **Experimental Plugin**: `@magmacomputing/tempo-plugin-celestial` is currently in active development (`v0.1.0`). Ephemeris calculation algorithms and API properties are subject to refinement prior to a stable `1.0.0` release.
-
diff --git a/packages/plugins/celestial/doc/index.md b/packages/plugins/celestial/doc/index.md
index 329660c2..100f5b3c 100644
--- a/packages/plugins/celestial/doc/index.md
+++ b/packages/plugins/celestial/doc/index.md
@@ -2,9 +2,6 @@
# @magmacomputing/tempo-plugin-celestial
-> [!WARNING]
-> **Experimental Plugin**: `@magmacomputing/tempo-plugin-celestial` is currently in active development (`v0.1.0`). Ephemeris calculation algorithms and API properties are subject to refinement prior to a stable `1.0.0` release.
-
diff --git a/packages/plugins/celestial/package.json b/packages/plugins/celestial/package.json
index 7b2befcb..4ade3463 100644
--- a/packages/plugins/celestial/package.json
+++ b/packages/plugins/celestial/package.json
@@ -1,6 +1,6 @@
{
"name": "@magmacomputing/tempo-plugin-celestial",
- "version": "0.1.0",
+ "version": "0.2.0",
"description": "Tempo plugin for location-aware solar twilight events and lunar phase calculations",
"type": "module",
"main": "dist/index.js",
diff --git a/packages/plugins/geo/test/geo.test.ts b/packages/plugins/geo/test/geo.test.ts
index 519b595b..9f4054fc 100644
--- a/packages/plugins/geo/test/geo.test.ts
+++ b/packages/plugins/geo/test/geo.test.ts
@@ -355,6 +355,7 @@ describe('Tempo Plugin: Geo', () => {
describe('Great-Circle Distance & Layout Formatting', () => {
const sydney = new Tempo('2026-10-24T10:00:00', {
+ timeZone: 'Australia/Sydney',
geo: {
latitude: -33.8688,
longitude: 151.2093,
@@ -362,16 +363,19 @@ describe('Tempo Plugin: Geo', () => {
country: 'au',
sphere: 'south',
elevation: 42,
+ timezone: 'Australia/Sydney',
},
});
const melbourne = new Tempo('2026-10-24T12:00:00', {
+ timeZone: 'Australia/Melbourne',
geo: {
latitude: -37.8136,
longitude: 144.9631,
city: 'melbourne',
country: 'au',
sphere: 'south',
+ timezone: 'Australia/Melbourne',
},
});
diff --git a/packages/tempo/.vitepress/theme/data/catalog.json b/packages/tempo/.vitepress/theme/data/catalog.json
index 5a5cab59..3b54a6e8 100644
--- a/packages/tempo/.vitepress/theme/data/catalog.json
+++ b/packages/tempo/.vitepress/theme/data/catalog.json
@@ -14,8 +14,8 @@
"description": "Tempo plugin for location-aware solar twilight events and lunar phase calculations",
"packageName": "@magmacomputing/tempo-plugin-celestial",
"plan": "community",
- "status": "experimental",
- "version": "0.1.0"
+ "status": "active",
+ "version": "0.2.0"
},
{
"id": "batch",
diff --git a/packages/tempo/CHANGELOG.md b/packages/tempo/CHANGELOG.md
index b87b1c67..61f8c4d4 100644
--- a/packages/tempo/CHANGELOG.md
+++ b/packages/tempo/CHANGELOG.md
@@ -24,6 +24,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Added `Equator: 'equator'` to `COMPASS` enum.
- Updated `t.sphere` to return `'equator'` when coordinates fall within the $\pm 0.001^\circ$ equatorial band.
+### Security
+- **Configuration Resolution Decoupling (Socket.dev Anomaly Mitigation)**:
+ - Strictly decoupled recursive `extends` loading from top-level project config resolution in `config.resolve.ts`.
+ - Dedicated `resolveExtendsTarget()` function exclusively reads and parses static `.json` and `.jsonc` files via `fs.promises.readFile` and `parseJSONC`, completely eliminating dynamic module imports (`import()`) from the `extends` pipeline.
+ - Added strict file extension allowlisting (`.json`, `.jsonc`, `.js`, `.mjs`, `.cjs`, `.ts`, `.mts`) to top-level configuration file discovery.
+
## [4.1.1] - 2026-09-07
### Added
diff --git a/packages/tempo/doc/2-core-concepts/tempo.config.md b/packages/tempo/doc/2-core-concepts/tempo.config.md
index f1492c2e..51340c21 100644
--- a/packages/tempo/doc/2-core-concepts/tempo.config.md
+++ b/packages/tempo/doc/2-core-concepts/tempo.config.md
@@ -228,7 +228,8 @@ Tempo.init({
| `pivot` | `number` | `75` | Cutoff for parsing two-digit years. |
| `monthDay` | `MonthDay \| boolean` | `undefined` | Regional date-parsing configuration (grouped). Includes `active`, `locales`, `layouts`, and `timezones`. |
| `timeStamp`| `'ss' \| 'ms' \| 'us' \| 'ns'` | `'ms'` | Precision for numeric inputs and the `.ts` property. |
-| `sphere` | `Evaluable<'north' \| 'south'>`| Auto-inferred | Hemisphere for seasonal plugins or dynamic supplier. |
+| `sphere` | `Evaluable<'north' \| 'south' \| 'equator'>`| Auto-inferred | Hemisphere for seasonal plugins or dynamic supplier. |
+| `geo` | `GeoOptions` | `undefined` | Geographic coordinates, city, country, elevation, and timezone metadata. |
| `intl` | `IntlOptions` | `undefined` | Internationalization configuration grouping `relativeTimeFormat`, `numberFormat`, and `durationFormat`. |
| `registry` | `{ formats?, locales?, numbers?, events?, periods?, snippets?, layouts?, ignores?, modifiers? }` | Built-in registries | Custom data augmentation registries (e.g., format aliases, number-to-word mappings, parsing logic, localization). |
| `extends` | `string \| string[]` | `undefined` | Local file path(s) or `file://` URL(s) to inherit base configuration from. |
diff --git a/packages/tempo/src/config/config.resolve.ts b/packages/tempo/src/config/config.resolve.ts
index 3b450977..d9fe865d 100644
--- a/packages/tempo/src/config/config.resolve.ts
+++ b/packages/tempo/src/config/config.resolve.ts
@@ -189,7 +189,7 @@ async function processExtends(
const currentDir = path ? path.dirname(baseLocation) : (typeof process !== 'undefined' ? process.cwd() : '');
const branchLoadedSet = new Set(loadedSet);
- const parentConfig = await loadConfigTarget(targetUrlOrPath, currentDir, fs, path, urlMod, branchLoadedSet, depth + 1, budget);
+ const parentConfig = await resolveExtendsTarget(targetUrlOrPath, currentDir, fs, path, urlMod, branchLoadedSet, depth + 1, budget);
if (parentConfig)
mergedParentConfig = mergeConfigs(mergedParentConfig, parentConfig);
}
@@ -199,20 +199,50 @@ async function processExtends(
}
/**
- * Loads and resolves a configuration target from a local path or `file://` URL.
- *
- * @param target - The configuration path or URL to load
- * @param currentDir - The base directory for resolving relative paths
- * @returns The resolved configuration, or `undefined` if loading fails or the target is already being processed
+ * Allowed file extensions for top-level Tempo configuration files.
+ */
+const ALLOWED_CONFIG_EXTENSIONS = new Set(['.json', '.jsonc', '.js', '.mjs', '.cjs', '.ts', '.mts']);
+
+/**
+ * Resolves a local path from a file URL or relative path, ensuring non-remote security.
+ */
+function resolveLocalConfigPath(target: string, currentDir: string, path?: any, urlMod?: any): string | undefined {
+ if (isFileUrl(target)) {
+ try {
+ const parsed = new URL(target);
+ if (parsed.hostname && parsed.hostname !== 'localhost') {
+ console.warn(`[Tempo] Remote file URL with host is not supported: ${target}`);
+ return undefined;
+ }
+ } catch {
+ console.warn(`[Tempo] Invalid file URL config target: ${target}`);
+ return undefined;
+ }
+
+ if (urlMod?.fileURLToPath)
+ return urlMod.fileURLToPath(target);
+ return target.replace(/^file:\/\//i, '');
+ }
+
+ if (path && !path.isAbsolute(target)) {
+ return path.resolve(currentDir, target);
+ }
+
+ return target;
+}
+
+/**
+ * Strictly loads and resolves a static inherited configuration target for `extends`.
+ * Config extends only supports static .json and .jsonc data files, NEVER dynamic modules.
*/
-async function loadConfigTarget(
+async function resolveExtendsTarget(
target: string,
currentDir: string,
fs?: any,
path?: any,
urlMod?: any,
loadedSet = new Set(),
- depth = 0,
+ depth = 1,
budget: ExtendsBudget = { remaining: MAX_EXTENDS_BUDGET },
): Promise {
if (loadedSet.has(target)) return undefined;
@@ -222,52 +252,72 @@ async function loadConfigTarget(
return undefined;
}
- if (depth > 0) {
- if (checkAndWarnBudget(budget)) return undefined;
- budget.remaining--;
- }
+ if (checkAndWarnBudget(budget)) return undefined;
+ budget.remaining--;
loadedSet.add(target);
- let localPath = target;
- if (isFileUrl(target)) {
+ const localPath = resolveLocalConfigPath(target, currentDir, path, urlMod);
+ if (!localPath) return undefined;
+
+ if (fs && fs.existsSync(localPath)) {
try {
- const parsed = new URL(target);
- if (parsed.hostname && parsed.hostname !== 'localhost') {
- console.warn(`[Tempo] Remote file URL with host is not supported: ${target}`);
+ const ext = path ? path.extname(localPath) : '.json';
+ if (ext !== '.json' && ext !== '.jsonc') {
+ console.warn(`[Tempo] Config extends only supports static .json and .jsonc data files, skipping: ${localPath}`);
return undefined;
}
- } catch {
- console.warn(`[Tempo] Invalid file URL config target: ${target}`);
- return undefined;
+
+ const content = await fs.promises.readFile(localPath, 'utf8');
+ const loaded = parseJSONC(content) as Options;
+ if (loaded)
+ return processExtends(loaded, localPath, fs, path, urlMod, loadedSet, depth, budget);
+ } catch (err) {
+ console.warn(`[Tempo] Failed to load config file at ${localPath}:`, err);
}
+ }
- if (urlMod?.fileURLToPath)
- localPath = urlMod.fileURLToPath(target);
- else
- localPath = target.replace(/^file:\/\//i, '');
- } else if (path && !path.isAbsolute(localPath)) {
- localPath = path.resolve(currentDir, localPath);
+ return undefined;
+}
+
+/**
+ * Loads a top-level configuration file discovered in the project or passed via `options.configFile`.
+ * Only permits explicitly supported configuration extensions.
+ */
+async function loadTopLevelConfig(
+ target: string,
+ currentDir: string,
+ fs?: any,
+ path?: any,
+ urlMod?: any,
+): Promise {
+ if (isHttpUrl(target)) {
+ console.warn(`[Tempo] Remote HTTP(S) config target is not supported: ${target}`);
+ return undefined;
}
+ const localPath = resolveLocalConfigPath(target, currentDir, path, urlMod);
+ if (!localPath) return undefined;
+
if (fs && fs.existsSync(localPath)) {
try {
const ext = path ? path.extname(localPath) : '.json';
- let loaded: Options | undefined = undefined;
+ if (!ALLOWED_CONFIG_EXTENSIONS.has(ext)) {
+ console.warn(`[Tempo] Unsupported configuration file format, skipping: ${localPath}`);
+ return undefined;
+ }
+ let loaded: Options | undefined = undefined;
if (ext === '.json' || ext === '.jsonc') {
const content = await fs.promises.readFile(localPath, 'utf8');
loaded = parseJSONC(content) as Options;
- } else if (depth === 0 && urlMod) {
- // Only allow JS/TS module execution for top-level project config files, never for extends targets
+ } else if (urlMod) {
const imported = await import(/* @vite-ignore */ urlMod.pathToFileURL(localPath).href);
loaded = imported.default || imported;
- } else {
- console.warn(`[Tempo] Config extends only supports static .json and .jsonc data files, skipping: ${localPath}`);
}
if (loaded)
- return processExtends(loaded, localPath, fs, path, urlMod, loadedSet, depth, budget);
+ return processExtends(loaded, localPath, fs, path, urlMod, new Set([target]), 0);
} catch (err) {
console.warn(`[Tempo] Failed to load config file at ${localPath}:`, err);
}
@@ -443,7 +493,7 @@ export async function resolveConfig(options?: { cwd?: string, configFile?: strin
const target = isFileUrl(options.configFile)
? options.configFile
: path.resolve(currentDir, options.configFile);
- return await loadConfigTarget(target, currentDir, fs, path, urlMod);
+ return await loadTopLevelConfig(target, currentDir, fs, path, urlMod);
}
const rootPath = path.parse(currentDir).root;
@@ -456,7 +506,7 @@ export async function resolveConfig(options?: { cwd?: string, configFile?: strin
const configPath = path.join(currentDir, `tempo.config${ext}`);
if (fs.existsSync(configPath)) {
try {
- const loaded = await loadConfigTarget(configPath, currentDir, fs, path, urlMod);
+ const loaded = await loadTopLevelConfig(configPath, currentDir, fs, path, urlMod);
if (loaded) return loaded;
} catch (err) {
console.warn(`[Tempo] Found config file at ${configPath} but failed to load it:`, err);
From e3b1e9f0b053c8b13d5f6e0d8e50690bc843e51f Mon Sep 17 00:00:00 2001
From: Michael McRae
Date: Wed, 9 Sep 2026 15:38:39 +1000
Subject: [PATCH 10/11] PR 6th review
---
packages/library/CHANGELOG.md | 8 ++---
.../src/common/runtime/storage.library.ts | 5 +++
packages/library/src/server/mapper.library.ts | 2 ++
.../library/test/server/mapper.server.test.ts | 35 +++++++++++++++++++
packages/plugins/ai/CHANGELOG.md | 8 +++++
.../plugins/batch/src/BatchOrchestrator.ts | 10 +++---
packages/plugins/celestial/CHANGELOG.md | 2 +-
packages/tempo-cli/commands/version-sync.js | 5 ++-
.../tempo/doc/2-core-concepts/tempo.config.md | 2 +-
9 files changed, 64 insertions(+), 13 deletions(-)
diff --git a/packages/library/CHANGELOG.md b/packages/library/CHANGELOG.md
index c585e3cd..eee2f232 100644
--- a/packages/library/CHANGELOG.md
+++ b/packages/library/CHANGELOG.md
@@ -5,16 +5,16 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
-## [4.1.2] - 2026-09-08
+## [4.2.0] - 2026-09-09
### Added
- **Generic Bounded LRU & TTL Cache Engine (`BoundedCache`)**:
- - Implemented high-performance `BoundedCache` in `#library/common/runtime/cache.class.js` supporting configurable capacity constraints (`maxSize`, default 1000) and time-to-live expiration (`ttl`, default `Infinity`).
+ - Implemented high-performance `BoundedCache` in `#library/cache.class.js` supporting configurable capacity constraints (`maxSize`, default 1000) and time-to-live expiration (`ttl`, default 24 hours / `86,400,000 ms`).
- Added per-entry TTL override support in `set(key, val, ttl?)` with precomputed absolute expiration deadlines (`expiresAt = Date.now() + ttl`).
- - Implemented $O(1)$ fast-path expiration checks: bypasses `#expires` map checks and eliminates clock reads (`Date.now()`) when `ttl === Infinity` or `#expires.size === 0`.
+ - Implemented $O(1)$ fast-path expiration checks: eliminates clock reads (`Date.now()`) when keys have no entry-level expiration deadline recorded in `#expires`.
- Added bulk clear (`clear()`), lazy eviction (`evictExpired()`), iteration (`keys()`, `values()`, `entries()`, `forEach()`, `[Symbol.iterator]()`), and size inspection (`size`).
- **Bounded In-Memory Server Storage (`storage.library`)**:
- - Replaced unbounded `Map` backing `nodeStorage` with `BoundedCache(1000, Infinity)`.
+ - Replaced unbounded `Map` backing `nodeStorage` with `BoundedCache(1000, Infinity)` via `#library/cache.class.js`.
- Added `ServerStorageOptions` interface and updated `setStorage(key, value, options?)` overload to support optional custom `ttl`.
- Added `clearStorage()` utility to purge in-memory storage entries across test environments and lifecycle boundaries while preserving tombstone deletion semantics (`undefined` value).
- **Geolocation Caching & Multi-Tenant Partitioning (`mapper.library`)**:
diff --git a/packages/library/src/common/runtime/storage.library.ts b/packages/library/src/common/runtime/storage.library.ts
index ecd92f84..fabbd8f4 100644
--- a/packages/library/src/common/runtime/storage.library.ts
+++ b/packages/library/src/common/runtime/storage.library.ts
@@ -229,6 +229,11 @@ export function clearStorage(): void {
case CONTEXT.NodeJS:
nodeStorage.clear();
+ storage.clear();
+ break;
+
+ default:
+ storage.clear();
break;
}
}
diff --git a/packages/library/src/server/mapper.library.ts b/packages/library/src/server/mapper.library.ts
index b41a1786..958aa6a2 100644
--- a/packages/library/src/server/mapper.library.ts
+++ b/packages/library/src/server/mapper.library.ts
@@ -59,6 +59,8 @@ export const serverGeoLocation = async (opts = {} as ServerMapOpts): Promise {
vi.unstubAllGlobals();
});
+
+ it('serverGeoLocation replaces {ip} in custom endpoint URL', async () => {
+ const mockFetch = vi.fn().mockResolvedValue({
+ ok: true,
+ json: async () => ({
+ status: 'success',
+ lat: 34.0522,
+ lon: -118.2437,
+ country: 'United States',
+ city: 'Los Angeles',
+ query: '1.1.1.1',
+ }),
+ });
+
+ vi.stubGlobal('fetch', mockFetch);
+
+ const geo = await serverGeoLocation({ endpoint: 'https://custom.geo.api/v1/{ip}/json', ip: '1.1.1.1' });
+ expect(mockFetch).toHaveBeenCalledWith(
+ 'https://custom.geo.api/v1/1.1.1.1/json',
+ expect.anything()
+ );
+ expect(geo.status).toBe('success');
+
+ vi.unstubAllGlobals();
+ });
+
+ it('serverGeoLocation fails fast when custom endpoint lacks {ip} placeholder and ip is provided', async () => {
+ const geo = await serverGeoLocation({ endpoint: 'https://custom.geo.api/lookup', ip: '1.1.1.1' });
+ expect(geo.status).toBe('fail');
+ expect(geo.error).toContain("Custom endpoint must include an '{ip}' placeholder");
+
+ await expect(
+ serverGeoLocation({ endpoint: 'https://custom.geo.api/lookup', ip: '1.1.1.1', catch: false })
+ ).rejects.toThrow("Custom endpoint must include an '{ip}' placeholder");
+ });
});
diff --git a/packages/plugins/ai/CHANGELOG.md b/packages/plugins/ai/CHANGELOG.md
index 75504373..84294500 100644
--- a/packages/plugins/ai/CHANGELOG.md
+++ b/packages/plugins/ai/CHANGELOG.md
@@ -15,6 +15,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Decoupled network transport entirely from `@magmacomputing/tempo/library` (removing reliance on core internal `HttpError` and `fetchRequest`).
- Implemented proactive stream consumption via `res.body.getReader()` with strict byte accounting and upfront `Content-Length` enforcement against `maxBytes` to prevent memory exhaustion.
+## [1.2.1] - 2026-09-07
+
+### Security & Reliability
+- **Self-Contained Network Transport (`fetch.ts`)**:
+ - Decoupled network request utilities from `@magmacomputing/tempo/library` into a self-contained local transport helper.
+ - Added chunk-by-chunk stream consumption via `res.body.getReader()` with proactive byte accounting and immediate reader cancellation (`await reader.cancel()`).
+ - Enforced upfront `Content-Length` checks against `maxBytes` and strictly bounded stream reads to prevent unbounded memory allocation and OS thread starvation.
+
## [1.2.0] - 2026-09-06
### Added
diff --git a/packages/plugins/batch/src/BatchOrchestrator.ts b/packages/plugins/batch/src/BatchOrchestrator.ts
index 75fc01ff..124eb10c 100644
--- a/packages/plugins/batch/src/BatchOrchestrator.ts
+++ b/packages/plugins/batch/src/BatchOrchestrator.ts
@@ -136,8 +136,7 @@ export class BatchOrchestrator {
const workers: Promise[] = [];
const actualThreads = Math.min(threadCount, Math.ceil(epochs.length / chunkSize));
- const sanitized = this.sanitizeExecArgv();
- const execArgv = sanitized.length > 0 ? sanitized : undefined;
+ const execArgv = this.sanitizeExecArgv();
for (let i = 0; i < actualThreads; i++) {
const startIdx = i * chunkSize;
@@ -153,7 +152,7 @@ export class BatchOrchestrator {
endIdx,
operation,
},
- ...(execArgv ? { execArgv } : {}),
+ execArgv,
});
worker.on('message', (msg: any) => {
if (msg.status === 'done') resolve();
@@ -193,8 +192,7 @@ export class BatchOrchestrator {
private static async _transformWithPostMessage(epochs: number[], operation: string, threadCount: number, chunkSize: number, options: BatchOptions): Promise {
const workers: Promise[] = [];
const actualThreads = Math.min(threadCount, Math.ceil(epochs.length / chunkSize));
- const sanitized = this.sanitizeExecArgv();
- const execArgv = sanitized.length > 0 ? sanitized : undefined;
+ const execArgv = this.sanitizeExecArgv();
for (let i = 0; i < actualThreads; i++) {
const startIdx = i * chunkSize;
@@ -208,7 +206,7 @@ export class BatchOrchestrator {
chunk,
operation,
},
- ...(execArgv ? { execArgv } : {}),
+ execArgv,
});
worker.on('message', (msg: any) => {
if (msg.status === 'done') resolve(msg.result);
diff --git a/packages/plugins/celestial/CHANGELOG.md b/packages/plugins/celestial/CHANGELOG.md
index 224c4850..2639ad3a 100644
--- a/packages/plugins/celestial/CHANGELOG.md
+++ b/packages/plugins/celestial/CHANGELOG.md
@@ -8,7 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [0.2.0] - 2026-09-09
### Added
-- **Elevation Support on Solar Term**: Exposed `elevation: number | null` on `t.term.solar` ([`TempoTermRegistry['solar']`](file:///home/michael/Project/magma/packages/plugins/celestial/src/index.ts#L52)).
+- **Elevation Support on Solar Term**: Exposed `elevation: number | null` on `t.term.solar` ([`TempoTermRegistry['solar']`](src/index.ts)).
- **Atmospheric Horizon Dip**: Factored `t.geo.elevation` into solar sunrise, sunset, and daylight duration calculations via `@magmacomputing/tempo-fns`. Observers at high elevations experience earlier sunrise and later sunset due to geometric horizon dip.
## [0.1.0] - 2026-09-02
diff --git a/packages/tempo-cli/commands/version-sync.js b/packages/tempo-cli/commands/version-sync.js
index e9991231..e9d83e8e 100644
--- a/packages/tempo-cli/commands/version-sync.js
+++ b/packages/tempo-cli/commands/version-sync.js
@@ -49,7 +49,10 @@ export async function versionSync(_args) {
if (existsSync(wsPkgPath)) {
try {
const rawContent = readFileSync(wsPkgPath, 'utf8');
- const updatedContent = rawContent.replace(/"version":\s*"[^"]+"/, `"version": "${version}"`);
+ const updatedContent = rawContent.replace(/^([ \t]*"version"[ \t]*:[ \t]*)"[^"]+"/m, `$1"${version}"`);
+ if (updatedContent === rawContent)
+ throw new Error(`Top-level version key was not found or already matched in ${wsPkgPath}`);
+
writeFileSync(wsPkgPath, updatedContent, 'utf8');
console.log(`✅ Synced ${ws} to ${version} (file fallback)`);
syncedCount++;
diff --git a/packages/tempo/doc/2-core-concepts/tempo.config.md b/packages/tempo/doc/2-core-concepts/tempo.config.md
index 51340c21..c9dbc0c4 100644
--- a/packages/tempo/doc/2-core-concepts/tempo.config.md
+++ b/packages/tempo/doc/2-core-concepts/tempo.config.md
@@ -305,7 +305,7 @@ When a `Tempo` instance is constructed:
---
-### 4.2 Geographic Coordinates & Location Configuration (`geo`)
+## 4.2 Geographic Coordinates & Location Configuration (`geo`)
You can configure geographic coordinates and location metadata globally or override them on individual instances:
From 3ea04a37f2f207700fb094b7bb6e9abc145718d1 Mon Sep 17 00:00:00 2001
From: Michael McRae
Date: Wed, 9 Sep 2026 17:07:47 +1000
Subject: [PATCH 11/11] PR local review
---
package.json | 5 +-
.../library/src/common/runtime/cache.class.ts | 6 +-
.../src/common/runtime/mapper.library.ts | 98 +++++++++----------
.../test/common/runtime/cache.class.test.ts | 64 ++++++++----
.../test/common/runtime/mapper.common.test.ts | 43 ++++++++
.../common/runtime/storage.library.test.ts | 11 ++-
packages/plugins/geo/CHANGELOG.md | 2 +-
packages/plugins/geo/README.md | 2 +-
packages/plugins/geo/doc/index.md | 2 +-
packages/tempo/test/support/cache.test.ts | 8 +-
10 files changed, 161 insertions(+), 80 deletions(-)
diff --git a/package.json b/package.json
index 13862ef2..e6f6e7a9 100644
--- a/package.json
+++ b/package.json
@@ -17,6 +17,9 @@
],
"scripts": {
"test": "vitest run",
+ "review:agent": "coderabbit review --agent",
+ "review:cli": "coderabbit review",
+ "review:findings": "coderabbit review findings",
"build:tempo": "npm run build:library && npm run build:std && npm run build --workspace=@magmacomputing/tempo",
"build:library": "npm run build --workspace=@magmacomputing/library",
"build:std": "npm run build --workspace=@magmacomputing/tempo-std",
@@ -77,4 +80,4 @@
"magic-string": "^1.1.1",
"typescript-7": "npm:typescript@^7.0.2"
}
-}
+}
\ No newline at end of file
diff --git a/packages/library/src/common/runtime/cache.class.ts b/packages/library/src/common/runtime/cache.class.ts
index 50137b59..05ac4e8c 100644
--- a/packages/library/src/common/runtime/cache.class.ts
+++ b/packages/library/src/common/runtime/cache.class.ts
@@ -1,4 +1,4 @@
-import { isDefined, isString, isUndefined } from '#library/assertion.library.js';
+import { isDefined, isEmpty, isString, isUndefined } from '#library/assertion.library.js';
import { StringTag } from '#library/decorator.library.js';
/**
@@ -172,7 +172,7 @@ export class BoundedCache extends Map {
this.#ttls.delete(key);
}
- while (this.size > this.maxSize) {
+ while ((this.size - this.#staticKeys.size) > this.maxSize) {
let evicted = false;
const keysIter = super.keys();
let res = keysIter.next();
@@ -213,6 +213,8 @@ export class BoundedCache extends Map {
*/
deletePrefix(prefix: string): number {
const normalizedPrefix = String(prefix).trim().toLowerCase();
+ if (isEmpty(normalizedPrefix)) return 0;
+
const toDelete: K[] = [];
for (const key of super.keys()) {
if (isString(key) && key.toLowerCase().startsWith(normalizedPrefix))
diff --git a/packages/library/src/common/runtime/mapper.library.ts b/packages/library/src/common/runtime/mapper.library.ts
index 98710186..c55141b7 100644
--- a/packages/library/src/common/runtime/mapper.library.ts
+++ b/packages/library/src/common/runtime/mapper.library.ts
@@ -1,5 +1,5 @@
import { CONTEXT, getContext } from '#library/utility.library.js';
-import { isNullish, isNumber, isString, isSafeKey, isObject, isEmpty, isReference, isPrimitive } from '#library/assertion.library.js';
+import { isNullish, isNumber, isString, isSafeKey, isObject, isEmpty, isReference, isPrimitive, isDate, isText } from '#library/assertion.library.js';
import { getStorage, setStorage } from '#library/storage.library.js';
import { evaluate } from '#library/evaluation.library.js';
@@ -185,8 +185,9 @@ const resolveSphere = (sphere?: any, lat?: number): GeoSphere | undefined => {
*/
export const coerceGeo = (input?: any): GeoConfig | undefined => {
if (isString(input) && input.includes(',')) {
- const parts = input.split(',').map((s: string) => Number(s.trim()));
- if (parts.length >= 2 && !isNaN(parts[0]) && !isNaN(parts[1])) {
+ const segments = input.split(',').map((s: string) => s.trim());
+ const parts = segments.map((s: string) => s === '' ? NaN : Number(s));
+ if (parts.length >= 2 && !isNaN(parts[0]!) && !isNaN(parts[1]!)) {
const coords = normalizeCoords(parts[0], parts[1]);
if (coords) {
const result: GeoConfig = { latitude: coords.lat, longitude: coords.lng };
@@ -403,20 +404,11 @@ export const geoLookup = async (opts: Record = {}): Promise {
+ try {
+ if (typeof Temporal !== 'undefined' && typeof Temporal.Instant?.fromEpochMilliseconds === 'function') {
+ const zdt = Temporal.Instant.fromEpochMilliseconds(epochMs).toZonedDateTimeISO(tz);
+ return zdt.offsetNanoseconds / 60_000_000_000;
+ }
+
+ const parts = new Intl.DateTimeFormat('en-US', {
+ timeZone: tz,
+ timeZoneName: 'longOffset',
+ year: 'numeric'
+ }).formatToParts(new Date(epochMs));
+
+ const tzPart = parts.find(p => p.type === 'timeZoneName')?.value;
+ if (tzPart) {
+ if (tzPart === 'GMT' || tzPart === 'UTC') return 0;
+ const match = tzPart.match(/GMT([+-])(\d{2}):(\d{2})/);
+ if (match) {
+ const sign = match[1] === '-' ? -1 : 1;
+ return sign * (parseInt(match[2], 10) * 60 + parseInt(match[3], 10));
+ }
+ }
+ } catch {
+ // Invalid timezone strings gracefully fall back to natural solar meridian
+ }
+ return undefined;
+};
+
/**
* Calculates the Natural Solar Time Offset between civil clock time and actual solar time.
* Based on longitude (approx 4 minutes per 1° offset from standard timezone meridian).
@@ -563,50 +588,23 @@ export function solarOffset(coords: any, options?: SolarOffsetOptions): number {
const lng = geo.longitude;
const tz = options?.timeZone
- ?? (typeof coords === 'object' && coords !== null ? (coords.timezone ?? coords.tz) : undefined)
+ ?? (isReference(coords) ? ((coords as any).timezone ?? (coords as any).tz) : undefined)
?? geo.timezone;
const dateVal = options?.date
- ?? (typeof coords === 'object' && coords !== null && typeof (coords as any).epoch?.ms === 'number' ? (coords as any).epoch.ms : Date.now());
-
- let offsetMinutes: number | undefined;
+ ?? (isReference(coords) && isNumber((coords as any).epoch?.ms) ? (coords as any).epoch.ms : Date.now());
const epochMs = typeof dateVal === 'number'
? dateVal
- : (typeof dateVal === 'string'
+ : (isString(dateVal)
? Date.parse(dateVal)
- : (dateVal instanceof Date
+ : (isDate(dateVal)
? dateVal.getTime()
- : (typeof dateVal?.epoch?.ms === 'number' ? dateVal.epoch.ms : Date.now())));
+ : (isReference(dateVal) && isNumber((dateVal as any)?.epoch?.ms) ? (dateVal as any).epoch.ms : Date.now())));
- if (isString(tz) && tz.trim() !== '') {
- try {
- if (typeof Temporal !== 'undefined' && typeof Temporal.Instant?.fromEpochMilliseconds === 'function') {
- const zdt = Temporal.Instant.fromEpochMilliseconds(epochMs).toZonedDateTimeISO(tz);
- offsetMinutes = zdt.offsetNanoseconds / 60_000_000_000;
- } else {
- const parts = new Intl.DateTimeFormat('en-US', {
- timeZone: tz,
- timeZoneName: 'longOffset',
- year: 'numeric'
- }).formatToParts(new Date(epochMs));
- const tzPart = parts.find(p => p.type === 'timeZoneName')?.value;
- if (tzPart) {
- if (tzPart === 'GMT' || tzPart === 'UTC') {
- offsetMinutes = 0;
- } else {
- const match = tzPart.match(/GMT([+-])(\d{2}):(\d{2})/);
- if (match) {
- const sign = match[1] === '-' ? -1 : 1;
- offsetMinutes = sign * (parseInt(match[2], 10) * 60 + parseInt(match[3], 10));
- }
- }
- }
- }
- } catch {
- // invalid timezone, fall back to natural meridian
- }
- }
+ if (!Number.isFinite(epochMs)) return NaN;
+
+ const offsetMinutes = isText(tz) ? resolveCivilTimezoneOffset(tz, epochMs) : undefined;
// If no civil timezone, use natural solar timezone meridian (round(lng / 15) * 15)
const refMeridian = offsetMinutes !== undefined
diff --git a/packages/library/test/common/runtime/cache.class.test.ts b/packages/library/test/common/runtime/cache.class.test.ts
index 25e1e09b..492ae993 100644
--- a/packages/library/test/common/runtime/cache.class.test.ts
+++ b/packages/library/test/common/runtime/cache.class.test.ts
@@ -45,19 +45,30 @@ describe('common/runtime/cache.class', () => {
expect(cache.size).toBe(2);
});
- it('protects static keys from LRU capacity eviction and TTL expiration', () => {
- const cache = new BoundedCache(2, 10);
+ it('protects static keys from LRU capacity eviction and does not allow static keys to consume non-static capacity', () => {
+ const cache = new BoundedCache(2, 60000);
cache.setStatic('static_term', 'IMMORTAL');
expect(cache.isStatic('static_term')).toBe(true);
expect(cache.isStatic('other')).toBe(false);
+ // With maxSize = 2, 2 non-static entries should both fit alongside the static key
cache.set('a', '1');
- cache.set('b', '2'); // 'a' should be evicted, static_term must remain
+ cache.set('b', '2');
expect(cache.has('static_term')).toBe(true);
expect(cache.get('static_term')).toBe('IMMORTAL');
+ expect(cache.has('a')).toBe(true);
+ expect(cache.get('a')).toBe('1');
+ expect(cache.has('b')).toBe(true);
+ expect(cache.get('b')).toBe('2');
+ expect(cache.size).toBe(3); // 1 static + 2 non-static
+
+ // Adding a 3rd non-static entry evicts the oldest non-static key ('a'), while static_term remains
+ cache.set('c', '3');
+ expect(cache.has('static_term')).toBe(true);
expect(cache.has('a')).toBe(false);
expect(cache.get('b')).toBe('2');
+ expect(cache.get('c')).toBe('3');
});
it('evicts expired entries after TTL has elapsed', async () => {
@@ -84,6 +95,11 @@ describe('common/runtime/cache.class', () => {
expect(cache.has('geo:sydney')).toBe(false);
expect(cache.has('geo:melbourne')).toBe(false);
expect(cache.has('user:alice')).toBe(true);
+
+ // Empty and whitespace prefixes return 0 and do not modify the cache
+ expect(cache.deletePrefix('')).toBe(0);
+ expect(cache.deletePrefix(' ')).toBe(0);
+ expect(cache.has('user:alice')).toBe(true);
});
it('clears all entries or only up to count oldest non-static entries', () => {
@@ -157,24 +173,30 @@ describe('common/runtime/cache.class', () => {
expect(cache.get('short_lived')).toBeUndefined();
});
- it('preserves absolute expiration deadline without renewal on get() or set() updates', async () => {
- const cache = new BoundedCache(10, 40); // 40ms TTL
- cache.set('item', 'v1');
-
- // Access item at 20ms
- await new Promise(resolve => setTimeout(resolve, 20));
- expect(cache.get('item')).toBe('v1');
-
- // Update item value at 25ms without specifying a new TTL
- cache.set('item', 'v2');
- expect(cache.get('item')).toBe('v2');
-
- // Wait past original 40ms deadline (e.g. at 55ms total)
- await new Promise(resolve => setTimeout(resolve, 35));
-
- // Must be expired because original absolute deadline was not renewed
- expect(cache.has('item')).toBe(false);
- expect(cache.get('item')).toBeUndefined();
+ it('preserves absolute expiration deadline without renewal on get() or set() updates', () => {
+ vi.useFakeTimers();
+ try {
+ const cache = new BoundedCache(10, 40); // 40ms TTL
+ cache.set('item', 'v1');
+
+ // Access item at 20ms
+ vi.advanceTimersByTime(20);
+ expect(cache.get('item')).toBe('v1');
+
+ // Update item value at 25ms without specifying a new TTL
+ vi.advanceTimersByTime(5);
+ cache.set('item', 'v2');
+ expect(cache.get('item')).toBe('v2');
+
+ // Advance past original 40ms deadline (at 55ms total)
+ vi.advanceTimersByTime(30);
+
+ // Must be expired because original absolute deadline was not renewed
+ expect(cache.has('item')).toBe(false);
+ expect(cache.get('item')).toBeUndefined();
+ } finally {
+ vi.useRealTimers();
+ }
});
});
diff --git a/packages/library/test/common/runtime/mapper.common.test.ts b/packages/library/test/common/runtime/mapper.common.test.ts
index d2fb1b77..0b897ff0 100644
--- a/packages/library/test/common/runtime/mapper.common.test.ts
+++ b/packages/library/test/common/runtime/mapper.common.test.ts
@@ -81,6 +81,44 @@ describe('common/runtime/mapper.library', () => {
expect((coercedExplicit as any)?.venue).toBe('HQ');
});
+ it('coerceGeo rejects comma-delimited strings with empty or whitespace-only coordinate segments', () => {
+ expect(coerceGeo(',')).toBeUndefined();
+ expect(coerceGeo(' , ')).toBeUndefined();
+ expect(coerceGeo('10,')).toBeUndefined();
+ expect(coerceGeo(',20')).toBeUndefined();
+ expect(coerceGeo('10, 20')).toEqual({ latitude: 10, longitude: 20, sphere: 'north' });
+ });
+
+ it('resolveGeoCoordinates bypasses stashed coordinates when refresh is true', async () => {
+ const mockFetch = vi.fn().mockResolvedValue({
+ ok: true,
+ json: async () => ({
+ status: 'success',
+ lat: 40.7128,
+ lon: -74.0060,
+ city: 'New York',
+ }),
+ });
+ vi.stubGlobal('fetch', mockFetch);
+
+ stashGeo({ latitude: -33.8688, longitude: 151.2093, city: 'Sydney' });
+
+ try {
+ // Normal call returns stashed coordinates
+ const cached = await resolveGeoCoordinates();
+ expect(cached?.city).toBe('Sydney');
+ expect(mockFetch).not.toHaveBeenCalled();
+
+ // Refresh call bypasses stashed coordinates and invokes geoLookup
+ const refreshed = await resolveGeoCoordinates(undefined, { refresh: true });
+ expect(refreshed?.city).toBe('New York');
+ expect(refreshed?.lat).toBe(40.713);
+ expect(mockFetch).toHaveBeenCalledTimes(1);
+ } finally {
+ vi.unstubAllGlobals();
+ }
+ });
+
it('resolveGeoCoordinates falls back to getStashedGeo when input only has metadata without coordinates', async () => {
const fetchSpy = vi.fn();
vi.stubGlobal('fetch', fetchSpy);
@@ -308,6 +346,11 @@ describe('common/runtime/mapper.library', () => {
expect(solarOffset({ lat: 51.5, lng: 0, timezone: 'GMT' })).toBe(0);
});
+ it('returns NaN immediately when date input is invalid or non-finite', () => {
+ expect(solarOffset({ lat: 51.5, lng: 0, timezone: 'UTC' }, { date: 'invalid-date-string' })).toBeNaN();
+ expect(solarOffset({ lat: 51.5, lng: 0, timezone: 'UTC' }, { date: NaN as any })).toBeNaN();
+ });
+
it('falls back to natural 15-degree solar timezone meridian when no timezone is supplied', () => {
// lng 151.209 -> nearest meridian 150° -> +4.84 min
expect(solarOffset({ lat: -33.8688, lng: 151.2093 })).toBe(4.84);
diff --git a/packages/library/test/common/runtime/storage.library.test.ts b/packages/library/test/common/runtime/storage.library.test.ts
index 7f887338..57e5fb86 100644
--- a/packages/library/test/common/runtime/storage.library.test.ts
+++ b/packages/library/test/common/runtime/storage.library.test.ts
@@ -123,10 +123,17 @@ describe('Storage Tombstone & BoundedCache Integration (NodeJS)', () => {
setStorage('d1', 'dyn1');
setStorage('d2', 'dyn2');
- // glossary_term is static so it must NOT be evicted even though capacity=2 is exceeded by dynamic keys
+ // Static keys do not consume non-static capacity
expect(getStorage('glossary_term')).toBe('immutable_definition');
- expect(getStorage('d1')).toBeUndefined(); // d1 evicted
+ expect(getStorage('d1')).toBe('dyn1');
expect(getStorage('d2')).toBe('dyn2');
+
+ // Adding 3rd dynamic key exceeds maxSize=2 and evicts oldest non-static key (d1)
+ setStorage('d3', 'dyn3');
+ expect(getStorage('glossary_term')).toBe('immutable_definition');
+ expect(getStorage('d1')).toBeUndefined();
+ expect(getStorage('d2')).toBe('dyn2');
+ expect(getStorage('d3')).toBe('dyn3');
});
});
diff --git a/packages/plugins/geo/CHANGELOG.md b/packages/plugins/geo/CHANGELOG.md
index 4d5fc1fe..0a4b8fa1 100644
--- a/packages/plugins/geo/CHANGELOG.md
+++ b/packages/plugins/geo/CHANGELOG.md
@@ -11,7 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Consolidated `Tempo.geo` Namespace**:
- Removed loose static methods mounted directly on the `Tempo` root class (`Tempo.geoLookup`, `Tempo.resolveGeoCoordinates`, `Tempo.serverGeoLocation`, `Tempo.geoLocation`) to prevent flat base class pollution.
- All static geolocation utilities are now organized under the dedicated, immutable **`Tempo.geo`** namespace (`Tempo.geo.lookup`, `Tempo.geo.resolve`, `Tempo.geo.server`, `Tempo.geo.browser`, `Tempo.geo.stash`, `Tempo.geo.clear`, `Tempo.geo.get`, and `Tempo.geo.current`).
- - Standalone pure functions (`geoLookup`, `resolveGeoCoordinates`, `serverGeoLocation`, etc.) remain available as direct named exports from the package for tree-shaking.
+ - Standalone tree-shakeable functions (`geoLookup`, `resolveGeoCoordinates`, `serverGeoLocation`, etc.) remain available as direct named exports from the package for tree-shaking.
- **Storage Key Scoping**:
- Standardized ambient coordinate storage on `_magma_geo_`.
diff --git a/packages/plugins/geo/README.md b/packages/plugins/geo/README.md
index 0dd96f49..e5f84be2 100644
--- a/packages/plugins/geo/README.md
+++ b/packages/plugins/geo/README.md
@@ -52,7 +52,7 @@ console.log(localTime.geo?.latitude, localTime.geo?.longitude);
### 2. Functional Tree-Shakeable APIs
-All underlying utilities can be imported as standalone pure functions without augmenting `Tempo`:
+All underlying utilities can be imported as standalone tree-shakeable functions without augmenting `Tempo`:
```typescript
import { Tempo } from '@magmacomputing/tempo';
diff --git a/packages/plugins/geo/doc/index.md b/packages/plugins/geo/doc/index.md
index 9c3baeac..c2d308a8 100644
--- a/packages/plugins/geo/doc/index.md
+++ b/packages/plugins/geo/doc/index.md
@@ -75,7 +75,7 @@ console.log(localTime.geo?.latitude, localTime.geo?.longitude);
### 2. Functional Tree-Shakeable APIs
-All underlying utilities can be imported as standalone pure functions:
+All underlying utilities can be imported as standalone tree-shakeable functions:
```typescript
import { Tempo } from '@magmacomputing/tempo';
diff --git a/packages/tempo/test/support/cache.test.ts b/packages/tempo/test/support/cache.test.ts
index be140f9f..517a462e 100644
--- a/packages/tempo/test/support/cache.test.ts
+++ b/packages/tempo/test/support/cache.test.ts
@@ -40,12 +40,18 @@ describe('Tempo Core Caching Architecture', () => {
const cache = new BoundedCache(2, 10000);
cache.setStatic('static_term', 'IMMORTAL');
cache.set('a', '1');
- cache.set('b', '2'); // 'a' should be evicted, not static_term
+ cache.set('b', '2');
expect(cache.has('static_term')).toBe(true);
expect(cache.get('static_term')).toBe('IMMORTAL');
+ expect(cache.has('a')).toBe(true);
+ expect(cache.get('b')).toBe('2');
+
+ cache.set('c', '3'); // 'a' should be evicted, not static_term
+ expect(cache.has('static_term')).toBe(true);
expect(cache.has('a')).toBe(false);
expect(cache.get('b')).toBe('2');
+ expect(cache.get('c')).toBe('3');
});
it('should serialize and rehydrate via entries() and fromEntries()', () => {