From 46dc6ab2cc81b11dcc89251d02d75ef690eb6b5d Mon Sep 17 00:00:00 2001 From: Jonathan Griggs Date: Sun, 28 Jun 2026 00:24:17 -0600 Subject: [PATCH] feat: add absolute anchor and Clock-level timezone The third and final window anchor (issue #4): { absolute } pins a distortion to a fixed real-world instant, independent of when the Clock was constructed. True multi-day spans fall out for free -- AbsoluteWindow is plain instant arithmetic, so an arbitrarily long referenceDuration (e.g. { hours: 72 }) has none of the ~24h overnight-wrap limit a time-of-day window has. Resolving an absolute wall-clock start needs a zone, so the Clock gains a read-only timeZone (default 'UTC'), threaded into window resolution and used by both time-of-day and absolute windows. This also closes a latent gap: time-of-day windows were silently UTC-only. - resolveAt(anchorMs) -> resolveAt(anchorMs, timeZone?) across the DistortionWindow contract; TimeWindow uses it with construction-zone fallback, ElapsedWindow ignores it. - New AbsoluteWindow; { absolute: Date | AbsoluteDescriptor } added to DistortionAnchor (bare descriptor still defaults to time-of-day). - Clock: new Clock(distortions, { timeZone }) + timeZone getter. - Export the AbsoluteDescriptor type. Zone is immutable (construct a new Clock to change it); mid-run zone changes are deferred -- they need an injectable run-anchor/offset and a time-of-day refire policy. Adds AbsoluteWindow, absolute-anchor (incl. a multi-day span across a sparse read), and Clock-timezone tests. Full suite green (72 tests), coverage thresholds met, tsc and eslint clean. No version bump. Refs #4 Co-Authored-By: Claude Opus 4.8 --- README.md | 43 ++++++++++++++++++++++++-- src/AbsoluteWindow.ts | 33 ++++++++++++++++++++ src/Clock.ts | 12 ++++++-- src/DistortionWindow.ts | 4 ++- src/ElapsedWindow.ts | 1 + src/RelativeTimeDistortion.ts | 14 ++++++--- src/TimeWindow.ts | 13 ++++---- src/index.ts | 3 +- test/AbsoluteAnchor.test.ts | 48 +++++++++++++++++++++++++++++ test/AbsoluteWindow.test.ts | 45 +++++++++++++++++++++++++++ test/ClockTimezone.test.ts | 41 ++++++++++++++++++++++++ test/RelativeTimeDistortion.test.ts | 14 +++++++++ test/TimeWindow.test.ts | 17 ++++++++++ 13 files changed, 271 insertions(+), 17 deletions(-) create mode 100644 src/AbsoluteWindow.ts create mode 100644 test/AbsoluteAnchor.test.ts create mode 100644 test/AbsoluteWindow.test.ts create mode 100644 test/ClockTimezone.test.ts diff --git a/README.md b/README.md index ca70d53..f317f65 100644 --- a/README.md +++ b/README.md @@ -212,13 +212,50 @@ const clock = new Clock([ ]); ``` -So the start anchor accepts either form: +(This is the building block for the `Stopwatch` and `Countdown` clocks below.) + +### Anchoring a distortion to a fixed calendar instant + +Pass `{ absolute }` to pin a distortion to a specific real-world moment, independent of when the `Clock` was constructed. Because it is plain instant arithmetic, the span can be as long as you like — true multi-day distortions, with none of the ~24h limit a time-of-day window has: ``` -export type DistortionAnchor = ClockTimeDescriptor | { elapsed: Duration }; +import { Clock, ConstantTimeDilation } from 'clockblocker'; + +// Dilate continuously for 3 real days starting at a fixed instant. +const clock = new Clock([ + new ConstantTimeDilation( + { absolute: new Date('2026-12-25T00:00:00Z') }, // a fully-specified moment + { hours: 36 }, // 36 hours of "fake" time appear to pass... + { hours: 72 }, // ...over 72 real hours + ), +]); ``` -(This is the building block for the `Stopwatch` and `Countdown` clocks below.) +The start can be a JS `Date` (an instant) or a wall-clock descriptor resolved against the clock's timezone (see below): + +``` +{ absolute: { year: 2026, month: 12, day: 25, hour: 1 } } // 1:00am, in the clock's zone +``` + +So the start anchor accepts any of these forms: + +``` +export type DistortionAnchor = + | ClockTimeDescriptor // a wall-clock time-of-day (default) + | { elapsed: Duration } // relative to the run's t=0 + | { absolute: Date | AbsoluteDescriptor }; // a fixed real-world instant +``` + +### Timezone + +A `Clock` resolves its wall-clock windows — both time-of-day and `absolute` descriptors — against a timezone you pass as a second argument (default `'UTC'`): + +``` +// "1:00am–7:00am" now means 1am–7am in Denver, DST and all. +const clock = new Clock(distortions, { timeZone: 'America/Denver' }); +``` + +The timezone is fixed for the life of the clock; to run in a different zone, construct a new `Clock`. (Run-relative `{ elapsed }` windows ignore the zone, and an `{ absolute }` start given as a `Date` is already an instant, so it ignores it too.) ## Stopwatch & Countdown diff --git a/src/AbsoluteWindow.ts b/src/AbsoluteWindow.ts new file mode 100644 index 0000000..b772e43 --- /dev/null +++ b/src/AbsoluteWindow.ts @@ -0,0 +1,33 @@ +import { Temporal } from '@js-temporal/polyfill'; +import { DistortionWindow, ResolvedWindow } from './DistortionWindow'; + +// A fully-specified wall-clock instant: a calendar date plus an optional time-of-day. Resolved +// against the Clock's timezone. (A JS Date is an instant already and needs no zone.) +export interface AbsoluteDescriptor { + year: number; + month: number; + day: number; + hour?: number; + minute?: number; + second?: number; + millisecond?: number; +} + +// A window pinned to a specific real-world instant, independent of when the Clock was started -- +// `resolveAt` ignores the run anchor entirely. True multi-day spans fall out for free: the bounds +// are plain instant arithmetic, with none of the ~24h overnight-wrap limit a TimeWindow has. +export default class AbsoluteWindow implements DistortionWindow { + constructor(private start: Date | AbsoluteDescriptor, private durationMs: number) {} + + resolveAt(_anchorMs: number, timeZone = `UTC`): ResolvedWindow { + const startMs = + this.start instanceof Date + ? this.start.getTime() + : Temporal.PlainDateTime.from(this.start).toZonedDateTime(timeZone).toInstant().epochMilliseconds; + return { startMs, endMs: startMs + this.durationMs }; + } + + clone() { + return new AbsoluteWindow(this.start, this.durationMs); + } +} diff --git a/src/Clock.ts b/src/Clock.ts index a634c41..5034d54 100644 --- a/src/Clock.ts +++ b/src/Clock.ts @@ -7,17 +7,25 @@ export default class Clock { // once at construction. Windows no longer re-resolve to "today" on each read, which is // what made overnight (cross-midnight) and sparsely-polled clocks drop their distortions. private _runAnchor: number; + // The clock's frame of reference, used to resolve wall-clock windows (time-of-day, absolute) + // to instants. Read-only: to run in a different zone, construct a new Clock. + private _timeZone: string; private _timeDistortions: Array; - constructor(timeDistortions: Array = []) { + constructor(timeDistortions: Array = [], options: { timeZone?: string } = {}) { this._lastCheck = this.referenceTimeInMillis; this._runAnchor = this._lastCheck; this._timeDistortions = timeDistortions; + this._timeZone = options.timeZone ?? `UTC`; } get referenceTimeInMillis() { return Date.now(); } + get timeZone() { + return this._timeZone; + } + // The run's t=0: the reference instant the clock was constructed at, which every elapsed // window is anchored to. Stopwatch/Countdown read this to measure warped time since start. get runStartInMillis() { @@ -27,7 +35,7 @@ export default class Clock { get offset() { const now = this.referenceTimeInMillis; this._offset = this._timeDistortions.reduce((offset, distortion) => { - const { startMs, endMs } = distortion.getTimeWindow().resolveAt(this._runAnchor); + const { startMs, endMs } = distortion.getTimeWindow().resolveAt(this._runAnchor, this._timeZone); if (now < startMs) return offset; // window has not started yet if (this._lastCheck >= endMs) return offset; // window already finished before last check const timeSinceLastCheck = now - this._lastCheck; diff --git a/src/DistortionWindow.ts b/src/DistortionWindow.ts index ef6f666..0ee0394 100644 --- a/src/DistortionWindow.ts +++ b/src/DistortionWindow.ts @@ -8,6 +8,8 @@ export interface ResolvedWindow { } export interface DistortionWindow { - resolveAt(anchorMs: number): ResolvedWindow; + // `timeZone` is the Clock's frame of reference, used to resolve wall-clock inputs + // (time-of-day, absolute descriptors) to instants. Run-relative windows ignore it. + resolveAt(anchorMs: number, timeZone?: string): ResolvedWindow; clone(): DistortionWindow; } diff --git a/src/ElapsedWindow.ts b/src/ElapsedWindow.ts index 0a47e90..69f1f99 100644 --- a/src/ElapsedWindow.ts +++ b/src/ElapsedWindow.ts @@ -7,6 +7,7 @@ import { DistortionWindow, ResolvedWindow } from './DistortionWindow'; export default class ElapsedWindow implements DistortionWindow { constructor(private startOffsetMs: number, private endOffsetMs: number) {} + // Run-relative: ignores the timezone entirely (the offsets are real time since the run start). resolveAt(anchorMs: number): ResolvedWindow { return { startMs: anchorMs + this.startOffsetMs, endMs: anchorMs + this.endOffsetMs }; } diff --git a/src/RelativeTimeDistortion.ts b/src/RelativeTimeDistortion.ts index 955545f..7243960 100644 --- a/src/RelativeTimeDistortion.ts +++ b/src/RelativeTimeDistortion.ts @@ -1,17 +1,21 @@ import ClockTime, { ClockTimeDescriptor } from './ClockTime'; import TimeWindow from './TimeWindow'; import ElapsedWindow from './ElapsedWindow'; +import AbsoluteWindow, { AbsoluteDescriptor } from './AbsoluteWindow'; import { DistortionWindow } from './DistortionWindow'; import { Duration, durationToMillis } from './duration'; export type { Duration }; -// Where a distortion is anchored. A bare ClockTimeDescriptor means a wall-clock time-of-day -// (the original, default behavior); `{ elapsed }` anchors the window to the run's own t=0, -// measured in reference (real) time since the Clock was constructed. -export type DistortionAnchor = ClockTimeDescriptor | { elapsed: Duration }; +// Where a distortion is anchored: +// - a bare ClockTimeDescriptor -> a wall-clock time-of-day (the original, default behavior); +// - { elapsed } -> the run's own t=0, in real time since the Clock started; +// - { absolute } -> a fixed real-world instant (date+time or a Date). +export type DistortionAnchor = ClockTimeDescriptor | { elapsed: Duration } | { absolute: Date | AbsoluteDescriptor }; const isElapsedAnchor = (anchor: DistortionAnchor): anchor is { elapsed: Duration } => `elapsed` in anchor; +const isAbsoluteAnchor = (anchor: DistortionAnchor): anchor is { absolute: Date | AbsoluteDescriptor } => + `absolute` in anchor; export default abstract class RelativeTimeDistortion { protected timeWindow: DistortionWindow; @@ -23,6 +27,8 @@ export default abstract class RelativeTimeDistortion { if (isElapsedAnchor(anchor)) { const startOffset = durationToMillis(anchor.elapsed); this.timeWindow = new ElapsedWindow(startOffset, startOffset + this.referenceDurationInMillis); + } else if (isAbsoluteAnchor(anchor)) { + this.timeWindow = new AbsoluteWindow(anchor.absolute, this.referenceDurationInMillis); } else { const start = new ClockTime(anchor); this.timeWindow = new TimeWindow(start, start.add(referenceDuration)); diff --git a/src/TimeWindow.ts b/src/TimeWindow.ts index 4e85428..ee676ce 100644 --- a/src/TimeWindow.ts +++ b/src/TimeWindow.ts @@ -49,19 +49,20 @@ export default class TimeWindow implements DistortionWindow { // upcoming one. For a midnight-wrapping window the containing occurrence may have started // on the prior calendar day (e.g. at 00:30 you are inside last night's 23:00->03:00 span), // so candidates span the day before and after the anchor's date, not just the anchor's day. - resolveAt(anchorMs: number): ResolvedWindow { + resolveAt(anchorMs: number, timeZone?: string): ResolvedWindow { + // The Clock supplies its frame of reference; fall back to the window's own zone for + // standalone use. The construction zone stays the default so legacy getters are unaffected. + const tz = timeZone ?? this._timeZone; const wraps = this.wrapsMidnight; - const anchorDate = Temporal.Instant.fromEpochMilliseconds(anchorMs) - .toZonedDateTimeISO(this._timeZone) - .toPlainDate(); + const anchorDate = Temporal.Instant.fromEpochMilliseconds(anchorMs).toZonedDateTimeISO(tz).toPlainDate(); const occurrences = [-1, 0, 1] .map((dayDelta) => { const startDate = anchorDate.add({ days: dayDelta }); const endDate = wraps ? startDate.add({ days: 1 }) : startDate; return { - startMs: this.start.forDateInMillis(startDate, this._timeZone), - endMs: this.end.forDateInMillis(endDate, this._timeZone), + startMs: this.start.forDateInMillis(startDate, tz), + endMs: this.end.forDateInMillis(endDate, tz), }; }) .sort((a, b) => a.startMs - b.startMs); diff --git a/src/index.ts b/src/index.ts index e2e50a1..8925892 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,7 +4,8 @@ import ConstantTimeCompression from './ConstantTimeCompression'; import Stopwatch from './Stopwatch'; import Countdown from './Countdown'; import { DistortionAnchor } from './RelativeTimeDistortion'; +import { AbsoluteDescriptor } from './AbsoluteWindow'; import { Duration } from './duration'; export { Clock, ConstantTimeCompression, ConstantTimeDilation, Stopwatch, Countdown }; -export type { DistortionAnchor, Duration }; +export type { DistortionAnchor, AbsoluteDescriptor, Duration }; diff --git a/test/AbsoluteAnchor.test.ts b/test/AbsoluteAnchor.test.ts new file mode 100644 index 0000000..3c565c1 --- /dev/null +++ b/test/AbsoluteAnchor.test.ts @@ -0,0 +1,48 @@ +import { Clock, ConstantTimeDilation } from '../src/index'; + +// An absolute-anchored window is pinned to a fixed real-world instant, independent of when the +// Clock was constructed -- and, being plain instant arithmetic, it can span many days. +describe(`absolute anchor`, () => { + const hour = 60 * 60 * 1000; + + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + const buildClockAt = (constructAtMs: number, distortions: ConstructorParameters[0]) => { + jest.setSystemTime(constructAtMs); + return new Clock(distortions); + }; + + it(`distorts across its fixed calendar window regardless of when the clock was built`, () => { + // Half speed over the 6 real hours from 2026-12-25T01:00Z. + const distortions = () => [ + new ConstantTimeDilation({ absolute: new Date(`2026-12-25T01:00:00Z`) }, { hours: 3 }, { hours: 6 }), + ]; + + const lostByWindowEnd = (constructAtMs: number) => { + const clock = buildClockAt(constructAtMs, distortions()); + jest.setSystemTime(Date.UTC(2026, 11, 25, 7)); // window end, 07:00Z + return clock.relativeTimeInMillis - clock.referenceTimeInMillis; + }; + + // Built a minute before the window, or a full day before -- same fixed window, same -3h. + expect(lostByWindowEnd(Date.UTC(2026, 11, 25, 0, 59))).toEqual(-3 * hour); + expect(lostByWindowEnd(Date.UTC(2026, 11, 24, 0))).toEqual(-3 * hour); + }); + + it(`integrates a genuine multi-day span exactly once across sparse reads`, () => { + // Half speed for 72 real hours (3 days) starting 2026-12-25T00:00Z => loses 36h. + const clock = buildClockAt(Date.UTC(2026, 11, 24, 12), [ + new ConstantTimeDilation({ absolute: new Date(`2026-12-25T00:00:00Z`) }, { hours: 36 }, { hours: 72 }), + ]); + clock.relativeTimeInMillis; // prime before the window + + jest.setSystemTime(Date.UTC(2026, 11, 28, 12)); // idle straight past the 3-day window + expect(clock.relativeTimeInMillis - clock.referenceTimeInMillis).toEqual(-36 * hour); + }); +}); diff --git a/test/AbsoluteWindow.test.ts b/test/AbsoluteWindow.test.ts new file mode 100644 index 0000000..423d716 --- /dev/null +++ b/test/AbsoluteWindow.test.ts @@ -0,0 +1,45 @@ +import AbsoluteWindow from '../src/AbsoluteWindow'; + +describe(`AbsoluteWindow class`, () => { + const hour = 60 * 60 * 1000; + + describe(`resolveAt`, () => { + it(`ignores the run anchor entirely (it is pinned to a fixed instant)`, () => { + const window = new AbsoluteWindow(new Date(`2026-12-25T01:00:00Z`), 6 * hour); + + // Wildly different anchors, identical bounds -- that is what "absolute" means. + expect(window.resolveAt(0)).toEqual(window.resolveAt(8.64e15)); + expect(window.resolveAt(0)).toEqual({ + startMs: Date.UTC(2026, 11, 25, 1), + endMs: Date.UTC(2026, 11, 25, 7), + }); + }); + + it(`resolves a wall-clock descriptor in the supplied timezone`, () => { + const window = new AbsoluteWindow({ year: 2026, month: 12, day: 25, hour: 1 }, 6 * hour); + + // Default zone is UTC. + expect(window.resolveAt(0).startMs).toEqual(Date.UTC(2026, 11, 25, 1)); + // The same wall-clock time is a *different* instant in Denver (MST, UTC-7 in December). + expect(window.resolveAt(0, `America/Denver`).startMs).toEqual(Date.UTC(2026, 11, 25, 8)); + }); + + it(`supports multi-day spans well beyond the 24h overnight wrap`, () => { + const window = new AbsoluteWindow(new Date(`2026-12-25T00:00:00Z`), 72 * hour); + const { startMs, endMs } = window.resolveAt(0); + + expect(endMs - startMs).toEqual(72 * hour); + expect(endMs).toEqual(Date.UTC(2026, 11, 28, 0)); + }); + }); + + describe(`clone`, () => { + it(`returns an independent, equivalent window`, () => { + const window = new AbsoluteWindow({ year: 2026, month: 12, day: 25, hour: 1 }, 6 * hour); + const clone = window.clone(); + + expect(clone).not.toBe(window); + expect(clone.resolveAt(0, `America/Denver`)).toEqual(window.resolveAt(0, `America/Denver`)); + }); + }); +}); diff --git a/test/ClockTimezone.test.ts b/test/ClockTimezone.test.ts new file mode 100644 index 0000000..433175f --- /dev/null +++ b/test/ClockTimezone.test.ts @@ -0,0 +1,41 @@ +import { Clock, ConstantTimeDilation } from '../src/index'; + +// A time-of-day window resolves against the Clock's configured zone. Before this option existed, +// every time-of-day window was silently UTC -- wrong for a clock that lives anywhere else. +describe(`Clock timezone`, () => { + const hour = 60 * 60 * 1000; + + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + // 1am-7am at half speed (loses 3h over the window). + const dilation = () => [new ConstantTimeDilation({ hour: 1 }, { hours: 3 }, { hours: 6 })]; + + it(`defaults to UTC`, () => { + jest.setSystemTime(Date.UTC(2026, 11, 25, 0)); + expect(new Clock().timeZone).toEqual(`UTC`); + }); + + it(`places the same wall-clock window at different instants per zone`, () => { + jest.setSystemTime(Date.UTC(2026, 11, 25, 0)); // 00:00Z, before both windows + const utc = new Clock(dilation()); + const denver = new Clock(dilation(), { timeZone: `America/Denver` }); + + expect(denver.timeZone).toEqual(`America/Denver`); + + // 07:00Z: the UTC window (01:00-07:00Z) has fully run -- 3h behind. Denver's "1am" is + // 08:00Z (MST, UTC-7), so its window hasn't started: still perfectly in sync. + jest.setSystemTime(Date.UTC(2026, 11, 25, 7)); + expect(utc.relativeTimeInMillis - utc.referenceTimeInMillis).toEqual(-3 * hour); + expect(denver.relativeTimeInMillis - denver.referenceTimeInMillis).toEqual(0); + + // 14:00Z: Denver's window (08:00-14:00Z) has now fully run too -- 3h behind, just shifted. + jest.setSystemTime(Date.UTC(2026, 11, 25, 14)); + expect(denver.relativeTimeInMillis - denver.referenceTimeInMillis).toEqual(-3 * hour); + }); +}); diff --git a/test/RelativeTimeDistortion.test.ts b/test/RelativeTimeDistortion.test.ts index 42f972a..ee40cd6 100644 --- a/test/RelativeTimeDistortion.test.ts +++ b/test/RelativeTimeDistortion.test.ts @@ -1,6 +1,7 @@ import RelativeTimeDistortion from '../src/RelativeTimeDistortion'; import TimeWindow from '../src/TimeWindow'; import ElapsedWindow from '../src/ElapsedWindow'; +import AbsoluteWindow from '../src/AbsoluteWindow'; describe(`RelativeTimeDistortion class`, () => { const start = { @@ -55,5 +56,18 @@ describe(`RelativeTimeDistortion class`, () => { // Spans [elapsed, elapsed + referenceDuration) = [5min, 25min) relative to the run anchor. expect(window.resolveAt(0)).toEqual({ startMs: 5 * minute, endMs: 25 * minute }); }); + + it(`builds an absolute window from an { absolute } anchor`, () => { + const timewarp = new ConcreteRelativeTimeDistortion( + { absolute: new Date(`2026-12-25T01:00:00Z`) }, + { hours: 3 }, + { hours: 6 }, + ); + const window = timewarp.getTimeWindow(); + + expect(window).toBeInstanceOf(AbsoluteWindow); + // Spans [start, start + referenceDuration) = [01:00Z, 07:00Z), independent of the anchor. + expect(window.resolveAt(0)).toEqual({ startMs: Date.UTC(2026, 11, 25, 1), endMs: Date.UTC(2026, 11, 25, 7) }); + }); }); }); diff --git a/test/TimeWindow.test.ts b/test/TimeWindow.test.ts index d1e2518..b56a360 100644 --- a/test/TimeWindow.test.ts +++ b/test/TimeWindow.test.ts @@ -91,4 +91,21 @@ describe(`TimeWindow class`, () => { expect(window.compareWithinWindow(windowEnd)).toEqual(TimeWindowComparison.LATER); }); }); + + // resolveAt takes the Clock's zone as an argument, falling back to the window's own + // construction zone when none is supplied (standalone use). + describe(`resolveAt timezone`, () => { + const window = new TimeWindow(new ClockTime({ hour: 1 }), new ClockTime({ hour: 2 }), `America/New_York`); + const anchor = Date.UTC(2026, 5, 1, 12); // 2026-06-01 noon UTC (EDT, UTC-4) + + it(`falls back to the construction zone when no zone is passed`, () => { + // Next 1am EDT after the anchor is 2026-06-02 05:00Z. + expect(window.resolveAt(anchor).startMs).toEqual(Date.UTC(2026, 5, 2, 5)); + }); + + it(`uses the supplied zone, overriding the construction zone`, () => { + // Next 1am UTC after the anchor is 2026-06-02 01:00Z. + expect(window.resolveAt(anchor, `UTC`).startMs).toEqual(Date.UTC(2026, 5, 2, 1)); + }); + }); });