From 04d5203e055c42761783320c2f771b603c1b5e48 Mon Sep 17 00:00:00 2001 From: "planetrenox@protonmail.com" Date: Mon, 19 Oct 2026 19:46:17 +0000 Subject: [PATCH] PRT (Planet Renox Time): ms since 1998-03-14T03:00:00Z Zero-dependency ESM package with types. now, fromDate, toDate, fromUnixMs, toUnixMs; units ms|s|m|h|d|w, floored toward -Infinity, exact across the full JS Date range. 0BSD. Co-Authored-By: Claude Opus 5.5 --- .gitignore | 2 + LICENSE | 12 ++++++ README.md | 43 +++++++++++++++++++ index.d.ts | 17 ++++++++ index.js | 37 ++++++++++++++++ index.test.js | 116 ++++++++++++++++++++++++++++++++++++++++++++++++++ package.json | 33 ++++++++++++++ 7 files changed, 260 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 index.d.ts create mode 100644 index.js create mode 100644 index.test.js create mode 100644 package.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2d98f97 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +*.tgz diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..ce09249 --- /dev/null +++ b/LICENSE @@ -0,0 +1,12 @@ +Copyright (C) 2026 by planetrenox + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..701ac77 --- /dev/null +++ b/README.md @@ -0,0 +1,43 @@ +# ortus + +**PRT (Planet Renox Time)**: milliseconds since `1998-03-14T03:00:00.000Z`. + +```sh +npm i ortus +``` + +```js +import { PRT } from 'ortus' + +PRT.now() // PRT ms, the official unit +PRT.now('d') // days +PRT.fromDate(new Date('2010-06-01'), 'd') // 4461 +PRT.toDate(1e9, 's') // 2029-11-20T04:46:40.000Z +PRT.fromUnixMs(Date.now()) // same as PRT.now() +PRT.toUnixMs(0) // 889844400000 +``` + +## API + +| | | +|---|---| +| `PRT.now(unit = 'ms')` | Current PRT | +| `PRT.fromDate(date, unit = 'ms')` | `Date` → PRT | +| `PRT.toDate(n, unit = 'ms')` | PRT → `Date` | +| `PRT.fromUnixMs(ms, unit = 'ms')` | Unix ms → PRT | +| `PRT.toUnixMs(n, unit = 'ms')` | PRT → Unix ms | + +Units: `ms` (official), `s`, `m`, `h`, `d`, `w`. + +## Rules + +- **Always an integer, rounded down toward −∞.** `1.5s → 1`, `-1.5s → -2`, so every unit is the same length on both sides of the epoch. +- **Before the epoch is negative.** +- **Strict input.** `fromDate` takes only valid `Date`s. `fromUnixMs`, `toDate`, and `toUnixMs` take only integers. Anything else throws. +- **No overflow.** Every value is exact across the full JS `Date` range (±275,000 years). Anything outside it throws. +- **Leap seconds are ignored**, the same as JS `Date`. +- **No dependencies.** + +## License + +0BSD. Use it however you want, no attribution needed. diff --git a/index.d.ts b/index.d.ts new file mode 100644 index 0000000..85cf787 --- /dev/null +++ b/index.d.ts @@ -0,0 +1,17 @@ +/** PRT units: milliseconds (official), seconds, minutes, hours, days, weeks */ +export type Unit = 'ms' | 's' | 'm' | 'h' | 'd' | 'w' + +export declare const PRT: Readonly<{ + /** Current PRT, rounded down. Defaults to ms. */ + now(unit?: Unit): number + /** PRT at the given Date, rounded down. Defaults to ms. */ + fromDate(date: Date, unit?: Unit): number + /** Date at the given integer PRT value. Defaults to ms. */ + toDate(value: number, unit?: Unit): Date + /** PRT at the given integer Unix ms, rounded down. Defaults to ms. */ + fromUnixMs(unixMs: number, unit?: Unit): number + /** Unix ms at the given integer PRT value. Defaults to ms. */ + toUnixMs(value: number, unit?: Unit): number +}> + +export default PRT diff --git a/index.js b/index.js new file mode 100644 index 0000000..9f5d3ab --- /dev/null +++ b/index.js @@ -0,0 +1,37 @@ +// PRT — Planet Renox Time: ms since 1998-03-14T03:00:00.000Z +const E = 889844400000 +const MAX = 8.64e15 // JS Date range, ±275k years +const U = { __proto__: null, ms: 1, s: 1e3, m: 6e4, h: 36e5, d: 864e5, w: 6048e5 } +const T = Date.prototype.getTime + +const unit = u => { + if (typeof u != 'string' || !U[u]) throw new RangeError(`PRT: unknown unit ${String(u)}, use ms|s|m|h|d|w`) + return U[u] +} + +// Math.floor rounds toward -Infinity, exact since every in-range ms diff is an integer below 2^53 +const fromUnixMs = (t, u = 'ms') => { + if (!Number.isInteger(t)) throw new TypeError('PRT.fromUnixMs: expected an integer') + if (Math.abs(t) > MAX) throw new RangeError('PRT.fromUnixMs: out of Date range') + return Math.floor((t - E) / unit(u)) +} + +const fromDate = (date, u = 'ms') => { + let t + try { t = T.call(date) } catch {} // brand check: real Dates only, works across realms + if (!Number.isFinite(t)) throw new TypeError('PRT.fromDate: expected a valid Date') + return fromUnixMs(t, u) +} + +const toUnixMs = (n, u = 'ms') => { + if (!Number.isSafeInteger(n)) throw new TypeError('PRT: expected an integer PRT value') + const t = n * unit(u) + E + if (!(Math.abs(t) <= MAX)) throw new RangeError('PRT: out of Date range') + return t +} + +const now = (u = 'ms') => fromUnixMs(Date.now(), u) +const toDate = (n, u = 'ms') => new Date(toUnixMs(n, u)) + +export const PRT = Object.freeze({ now, fromDate, toDate, fromUnixMs, toUnixMs }) +export default PRT diff --git a/index.test.js b/index.test.js new file mode 100644 index 0000000..5155335 --- /dev/null +++ b/index.test.js @@ -0,0 +1,116 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { runInNewContext } from 'node:vm' +import PRT, { PRT as named } from './index.js' + +const E = 889844400000 +const EPOCH = new Date('1998-03-14T03:00:00.000Z') +const at = ms => new Date(E + ms) +const U = { ms: 1, s: 1e3, m: 6e4, h: 36e5, d: 864e5, w: 6048e5 } +const big = (a, b) => { const q = a / b; return a % b < 0n ? q - 1n : q } +const exact = (t, u) => Number(big(BigInt(t) - BigInt(E), BigInt(U[u]))) + +test('exports', () => { + assert.equal(PRT, named) + assert.ok(Object.isFrozen(PRT)) + assert.deepEqual(Object.keys(PRT), ['now', 'fromDate', 'toDate', 'fromUnixMs', 'toUnixMs']) +}) + +test('epoch is 0 in every unit', () => { + assert.equal(EPOCH.getTime(), E) + for (const u in U) { + assert.equal(PRT.fromDate(EPOCH, u), 0) + assert.equal(PRT.fromUnixMs(E, u), 0) + } +}) + +test('defaults to ms', () => { + assert.equal(PRT.fromDate(at(1234)), 1234) + assert.equal(PRT.fromUnixMs(E + 1234), 1234) + assert.equal(PRT.toDate(1234).getTime(), E + 1234) + assert.equal(PRT.toUnixMs(1234), E + 1234) +}) + +test('units', () => { + assert.deepEqual(Object.keys(U).map(u => PRT.fromDate(at(6048e5), u)), [6048e5, 604800, 10080, 168, 7, 1]) +}) + +test('rounds down toward -Infinity', () => { + assert.equal(PRT.fromDate(at(1999), 's'), 1) + assert.equal(PRT.fromDate(at(-1), 's'), -1) + assert.equal(PRT.fromDate(at(-1500), 's'), -2) + assert.equal(PRT.fromDate(at(-1000), 's'), -1) + assert.equal(PRT.fromUnixMs(E - 1, 'w'), -1) + assert.equal(PRT.fromUnixMs(0, 'd'), -10300) // -10299.125 +}) + +test('exact at the edges of Date range', () => { + for (const t of [8.64e15, -8.64e15, 8.64e15 - 1, -8.64e15 + 1]) + for (const u in U) { + assert.equal(PRT.fromDate(new Date(t), u), exact(t, u), `${t} ${u}`) + assert.equal(PRT.fromUnixMs(t, u), exact(t, u), `${t} ${u}`) + } +}) + +test('exact for 60k random times across the full range', () => { + for (let i = 0; i < 1e4; i++) { + const t = Math.round((Math.random() * 2 - 1) * 8.64e15) + for (const u in U) assert.equal(PRT.fromUnixMs(t, u), exact(t, u), `${t} ${u}`) + } +}) + +test('exact one ms before every unit boundary at huge values', () => { + for (const u in U) { + const b = U[u], k = Math.floor((8.64e15 - E) / b) + assert.equal(PRT.fromUnixMs(E + k * b - 1, u), k - 1, u) + assert.equal(PRT.fromUnixMs(E + k * b, u), k, u) + } +}) + +test('now', () => { + const a = Date.now(), n = PRT.now(), b = Date.now() + assert.ok(n >= a - E && n <= b - E) + assert.ok(Number.isSafeInteger(PRT.now('w'))) +}) + +test('round trips', () => { + for (const ms of [0, 1, -1, 123456789, -987654321, 8.64e15 - E, -8.64e15 - E]) { + assert.equal(PRT.fromDate(PRT.toDate(ms)), ms) + assert.equal(PRT.fromUnixMs(PRT.toUnixMs(ms)), ms) + } + for (const u in U) for (const n of [0, 1, -1, 4461]) + assert.equal(PRT.toUnixMs(n, u), PRT.toDate(n, u).getTime()) + assert.equal(PRT.toUnixMs(1, 'w'), E + 6048e5) + assert.equal(PRT.toUnixMs(-1, 'd'), E - 864e5) +}) + +test('accepts Dates from other realms', () => { + assert.equal(PRT.fromDate(runInNewContext('new Date("1998-03-14T03:00:01Z")'), 's'), 1) +}) + +test('fromDate rejects non-Dates, including numbers', () => { + for (const x of [Date.now(), 0, '1998-03-14', null, undefined, {}, { getTime: () => 0 }, new Date(NaN), Object.create(Date.prototype)]) + assert.throws(() => PRT.fromDate(x), TypeError) +}) + +test('fromUnixMs rejects non-integers and out of range', () => { + for (const x of [1.5, NaN, Infinity, -Infinity, '0', null, undefined, 0n, new Date(0), {}]) + assert.throws(() => PRT.fromUnixMs(x), TypeError) + for (const x of [8.64e15 + 1, -8.64e15 - 1, 2 ** 53, 1e300]) + assert.throws(() => PRT.fromUnixMs(x), RangeError) +}) + +test('rejects unknown units', () => { + for (const u of ['y', 'mo', 'us', 'MS', 'toString', '__proto__', null, 1, {}, Symbol('s')]) + for (const f of [() => PRT.now(u), () => PRT.fromDate(EPOCH, u), () => PRT.fromUnixMs(E, u), () => PRT.toDate(0, u), () => PRT.toUnixMs(0, u)]) + assert.throws(f, RangeError) +}) + +test('toDate and toUnixMs reject bad values', () => { + for (const f of [PRT.toDate, PRT.toUnixMs]) { + for (const n of [1.5, NaN, Infinity, -Infinity, '1', null, 0n, 2 ** 53]) + assert.throws(() => f(n), TypeError) + for (const [n, u] of [[1e15, 'w'], [9e15, 'ms'], [8.64e15 - E + 1, 'ms'], [-8.64e15 - E - 1, 'ms'], [Number.MAX_SAFE_INTEGER, 'w']]) + assert.throws(() => f(n, u), RangeError) + } +}) diff --git a/package.json b/package.json new file mode 100644 index 0000000..9493074 --- /dev/null +++ b/package.json @@ -0,0 +1,33 @@ +{ + "name": "ortus", + "version": "0.1.1", + "description": "PRT (Planet Renox Time): milliseconds since 1998-03-14T03:00:00Z", + "type": "module", + "exports": { + ".": { + "types": "./index.d.ts", + "default": "./index.js" + } + }, + "types": "./index.d.ts", + "files": [ + "index.js", + "index.d.ts" + ], + "sideEffects": false, + "scripts": { + "test": "node --test" + }, + "keywords": [ + "prt", + "planet renox time", + "time", + "timestamp", + "epoch" + ], + "license": "0BSD", + "repository": { + "type": "git", + "url": "git+https://github.com/planetrenox/ortus.git" + } +}