Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 40 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
33 changes: 33 additions & 0 deletions src/AbsoluteWindow.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
12 changes: 10 additions & 2 deletions src/Clock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<RelativeTimeDistortion>;
constructor(timeDistortions: Array<RelativeTimeDistortion> = []) {
constructor(timeDistortions: Array<RelativeTimeDistortion> = [], 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() {
Expand All @@ -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;
Expand Down
4 changes: 3 additions & 1 deletion src/DistortionWindow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
1 change: 1 addition & 0 deletions src/ElapsedWindow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
}
Expand Down
14 changes: 10 additions & 4 deletions src/RelativeTimeDistortion.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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));
Expand Down
13 changes: 7 additions & 6 deletions src/TimeWindow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
3 changes: 2 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
48 changes: 48 additions & 0 deletions test/AbsoluteAnchor.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof Clock>[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);
});
});
45 changes: 45 additions & 0 deletions test/AbsoluteWindow.test.ts
Original file line number Diff line number Diff line change
@@ -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`));
});
});
});
41 changes: 41 additions & 0 deletions test/ClockTimezone.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
14 changes: 14 additions & 0 deletions test/RelativeTimeDistortion.test.ts
Original file line number Diff line number Diff line change
@@ -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 = {
Expand Down Expand Up @@ -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) });
});
});
});
17 changes: 17 additions & 0 deletions test/TimeWindow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
});
});
});