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
42 changes: 41 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -218,7 +218,47 @@ So the start anchor accepts either form:
export type DistortionAnchor = ClockTimeDescriptor | { elapsed: Duration };
```

(This is the building block for stopwatch- and countdown-style clocks; the ergonomic facades for those are coming.)
(This is the building block for the `Stopwatch` and `Countdown` clocks below.)

## Stopwatch & Countdown

`Stopwatch` and `Countdown` are thin clocks built over the run-relative (`elapsed`) anchor. Both take the same array of distortions a `Clock` does and start their run the moment you construct them.

A **`Stopwatch`** counts *up* in warped time. Distortions make the elapsed reading crawl or sprint while real time ticks on normally:

```
import { Stopwatch, ConstantTimeDilation } from 'clockblocker';

const stopwatch = new Stopwatch([
new ConstantTimeDilation({ elapsed: { minutes: 0 } }, { minutes: 15 }, { minutes: 30 }),
]);

// 30 real minutes later:
stopwatch.elapsedInMillis // 15 minutes — the warped reading crawled
stopwatch.referenceElapsedInMillis // 30 minutes — real, unbent elapsed time
```

A **`Countdown`** counts *down* in warped time toward a real deadline (a duration measured from the run's start). Because the offset is cumulative — a dilation is repaid by a later compression — the countdown can crawl early and sprint late yet still land on zero exactly when the real deadline arrives:

```
import { Countdown, ConstantTimeDilation, ConstantTimeCompression } from 'clockblocker';

// 45-minute deadline: crawl for the first 30 real minutes, then sprint to catch up.
const countdown = new Countdown({ minutes: 45 }, [
new ConstantTimeDilation({ elapsed: { minutes: 0 } }, { minutes: 15 }, { minutes: 30 }),
new ConstantTimeCompression({ elapsed: { minutes: 30 } }, { minutes: 30 }, { minutes: 15 }),
]);

// 30 real minutes in, only 15 of the 45 have ticked away — it crawled:
countdown.remainingInMillis // 30 minutes
countdown.isComplete // false

// ...and at the real 45-minute mark it lands on zero, having sprinted the rest:
countdown.remainingInMillis // 0 (rests at zero, never negative)
countdown.isComplete // true
```

Both expose a `clock` accessor onto the underlying `Clock` (for `relativeTimeInMillis` / `referenceTimeInMillis`) if you need it.

## Contributing

Expand Down
6 changes: 6 additions & 0 deletions src/Clock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,12 @@ export default class Clock {
return Date.now();
}

// 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() {
return this._runAnchor;
}

get offset() {
const now = this.referenceTimeInMillis;
this._offset = this._timeDistortions.reduce((offset, distortion) => {
Expand Down
42 changes: 42 additions & 0 deletions src/Countdown.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import Clock from './Clock';
import RelativeTimeDistortion from './RelativeTimeDistortion';
import { Duration, durationToMillis } from './duration';

// A countdown counts DOWN in warped time toward a real deadline. Distortions can make it crawl
// early and sprint late, yet -- because the offset is cumulative and a dilation is repaid by a
// later compression -- it still reaches zero exactly when the real deadline arrives. The target
// is a real-time duration measured from the run's t=0.
export default class Countdown {
private _clock: Clock;
private _runStartInMillis: number;
private _targetInMillis: number;

constructor(target: Duration, timeDistortions: Array<RelativeTimeDistortion> = []) {
this._targetInMillis = durationToMillis(target);
this._clock = new Clock(timeDistortions);
this._runStartInMillis = this._clock.runStartInMillis;
}

// Escape hatch onto the underlying Clock (relativeTimeInMillis, referenceTimeInMillis, ...).
get clock() {
return this._clock;
}

get targetInMillis() {
return this._targetInMillis;
}

// Warped time elapsed since the countdown started.
get elapsedInMillis() {
return this._clock.relativeTimeInMillis - this._runStartInMillis;
}

// Warped time left until the deadline, resting at zero once reached (never negative).
get remainingInMillis() {
return Math.max(0, this._targetInMillis - this.elapsedInMillis);
}

get isComplete() {
return this.elapsedInMillis >= this._targetInMillis;
}
}
12 changes: 2 additions & 10 deletions src/RelativeTimeDistortion.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,16 @@
import { Temporal } from '@js-temporal/polyfill';
import ClockTime, { ClockTimeDescriptor } from './ClockTime';
import TimeWindow from './TimeWindow';
import ElapsedWindow from './ElapsedWindow';
import { DistortionWindow } from './DistortionWindow';
import { Duration, durationToMillis } from './duration';

export interface Duration {
hours?: number;
minutes?: number;
seconds?: number;
milliseconds?: number;
}
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 };

const durationToMillis = (duration: Duration) =>
Temporal.Duration.from(duration).round({ largestUnit: 'millisecond' }).milliseconds;

const isElapsedAnchor = (anchor: DistortionAnchor): anchor is { elapsed: Duration } => `elapsed` in anchor;

export default abstract class RelativeTimeDistortion {
Expand Down
30 changes: 30 additions & 0 deletions src/Stopwatch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import Clock from './Clock';
import RelativeTimeDistortion from './RelativeTimeDistortion';

// A stopwatch counts UP in warped time from the moment it is started. It is a thin view over a
// Clock: the warped elapsed time is just the relative ("fraudulent") time minus the run's t=0,
// so distortions make the elapsed reading crawl or sprint while real time ticks on normally.
export default class Stopwatch {
private _clock: Clock;
private _runStartInMillis: number;

constructor(timeDistortions: Array<RelativeTimeDistortion> = []) {
this._clock = new Clock(timeDistortions);
this._runStartInMillis = this._clock.runStartInMillis;
}

// Escape hatch onto the underlying Clock (relativeTimeInMillis, referenceTimeInMillis, ...).
get clock() {
return this._clock;
}

// Warped time elapsed since the stopwatch started.
get elapsedInMillis() {
return this._clock.relativeTimeInMillis - this._runStartInMillis;
}

// Real time elapsed since the stopwatch started (undistorted).
get referenceElapsedInMillis() {
return this._clock.referenceTimeInMillis - this._runStartInMillis;
}
}
11 changes: 11 additions & 0 deletions src/duration.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { Temporal } from '@js-temporal/polyfill';

export interface Duration {
hours?: number;
minutes?: number;
seconds?: number;
milliseconds?: number;
}

export const durationToMillis = (duration: Duration) =>
Temporal.Duration.from(duration).round({ largestUnit: 'millisecond' }).milliseconds;
7 changes: 5 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import Clock from './Clock';
import ConstantTimeDilation from './ConstantTimeDilation';
import ConstantTimeCompression from './ConstantTimeCompression';
import Stopwatch from './Stopwatch';
import Countdown from './Countdown';
import { DistortionAnchor } from './RelativeTimeDistortion';
import { Duration } from './duration';

export { Clock, ConstantTimeCompression, ConstantTimeDilation };
export type { DistortionAnchor };
export { Clock, ConstantTimeCompression, ConstantTimeDilation, Stopwatch, Countdown };
export type { DistortionAnchor, Duration };
66 changes: 66 additions & 0 deletions test/Countdown.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { Countdown, ConstantTimeCompression, ConstantTimeDilation } from '../src/index';

describe(`Countdown`, () => {
const minute = 60 * 1000;
const start = Date.UTC(2026, 0, 1, 9, 0);

beforeEach(() => {
jest.useFakeTimers();
});

afterEach(() => {
jest.useRealTimers();
});

// Build the countdown *after* setting the system time so its t=0 is `start`.
const buildAt = (
constructAtMs: number,
target: ConstructorParameters<typeof Countdown>[0],
distortions: ConstructorParameters<typeof Countdown>[1] = [],
) => {
jest.setSystemTime(constructAtMs);
return new Countdown(target, distortions);
};

it(`counts down in real time when undistorted`, () => {
const countdown = buildAt(start, { minutes: 10 });
expect(countdown.targetInMillis).toEqual(10 * minute);
expect(countdown.clock.referenceTimeInMillis).toEqual(start);
expect(countdown.remainingInMillis).toEqual(10 * minute);
expect(countdown.isComplete).toBe(false);

jest.setSystemTime(start + 4 * minute);
expect(countdown.remainingInMillis).toEqual(6 * minute);

jest.setSystemTime(start + 10 * minute);
expect(countdown.remainingInMillis).toEqual(0);
expect(countdown.isComplete).toBe(true);
});

it(`rests at zero (never negative) past the deadline`, () => {
const countdown = buildAt(start, { minutes: 10 });

jest.setSystemTime(start + 25 * minute); // well past the deadline
expect(countdown.remainingInMillis).toEqual(0);
expect(countdown.isComplete).toBe(true);
});

it(`crawls early and sprints late but still hits zero on the real deadline`, () => {
// 45-minute deadline. Half speed for the first 30 real minutes (the countdown crawls),
// then double speed for the next 15 (it sprints) -- the dilation debt is fully repaid.
const countdown = buildAt(start, { minutes: 45 }, [
new ConstantTimeDilation({ elapsed: { minutes: 0 } }, { minutes: 15 }, { minutes: 30 }),
new ConstantTimeCompression({ elapsed: { minutes: 30 } }, { minutes: 30 }, { minutes: 15 }),
]);

// 30 real minutes in, the clock has only ticked down 15 of the 45 -- it crawled.
jest.setSystemTime(start + 30 * minute);
expect(countdown.remainingInMillis).toEqual(30 * minute);
expect(countdown.isComplete).toBe(false);

// ...and at the real 45-minute deadline it lands exactly on zero, having sprinted the rest.
jest.setSystemTime(start + 45 * minute);
expect(countdown.remainingInMillis).toEqual(0);
expect(countdown.isComplete).toBe(true);
});
});
44 changes: 44 additions & 0 deletions test/Stopwatch.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { Stopwatch, ConstantTimeDilation } from '../src/index';

describe(`Stopwatch`, () => {
const minute = 60 * 1000;
const start = Date.UTC(2026, 0, 1, 9, 0);

beforeEach(() => {
jest.useFakeTimers();
});

afterEach(() => {
jest.useRealTimers();
});

// Build the stopwatch *after* setting the system time so its t=0 is `start`.
const buildAt = (constructAtMs: number, distortions: ConstructorParameters<typeof Stopwatch>[0] = []) => {
jest.setSystemTime(constructAtMs);
return new Stopwatch(distortions);
};

it(`counts up in real time when undistorted`, () => {
const stopwatch = buildAt(start);
expect(stopwatch.elapsedInMillis).toEqual(0);

jest.setSystemTime(start + 10 * minute);
expect(stopwatch.elapsedInMillis).toEqual(10 * minute);
expect(stopwatch.referenceElapsedInMillis).toEqual(10 * minute);
// The underlying Clock is reachable for absolute warped/real readings.
expect(stopwatch.clock.relativeTimeInMillis).toEqual(start + 10 * minute);
});

it(`crawls while an elapsed dilation window is active`, () => {
// Half speed for the first 30 real minutes of the run.
const stopwatch = buildAt(start, [
new ConstantTimeDilation({ elapsed: { minutes: 0 } }, { minutes: 15 }, { minutes: 30 }),
]);

jest.setSystemTime(start + 30 * minute);
// 30 real minutes have passed, but the stopwatch only shows 15 warped minutes...
expect(stopwatch.elapsedInMillis).toEqual(15 * minute);
// ...while real elapsed time is unbent.
expect(stopwatch.referenceElapsedInMillis).toEqual(30 * minute);
});
});