From e7a7688761cac61f1e4e3d1a113ac41d281e5684 Mon Sep 17 00:00:00 2001 From: Jonathan Griggs Date: Sat, 27 Jun 2026 23:22:50 -0600 Subject: [PATCH 1/2] feat: add Stopwatch and Countdown facades over the elapsed anchor Thin presentation clocks built on the run-relative (elapsed) anchor. Both take the same distortions array a Clock does and start their run at construction. - Stopwatch counts up in warped time: elapsedInMillis (warped) and referenceElapsedInMillis (real), via relativeTimeInMillis - runStart. - Countdown counts down to a real-time target: remainingInMillis (clamped at zero), isComplete, elapsedInMillis, targetInMillis. Because the offset is cumulative (a dilation repaid by a later compression), it can crawl early and sprint late yet still reach zero on the real deadline. - Clock exposes runStartInMillis (the run anchor) so the facades measure warped time since start. - Extract the shared Duration interface and durationToMillis helper into src/duration.ts; RelativeTimeDistortion re-exports Duration for compat. - Export Stopwatch, Countdown, and the Duration type. Adds Stopwatch and Countdown tests (including the crawl-early/sprint-late countdown that lands on zero at the real deadline) and a README section. Full suite green (61 tests), tsc and eslint clean. No version bump. Refs #4 Co-Authored-By: Claude Opus 4.8 --- README.md | 42 ++++++++++++++++++++++- src/Clock.ts | 6 ++++ src/Countdown.ts | 42 +++++++++++++++++++++++ src/RelativeTimeDistortion.ts | 12 ++----- src/Stopwatch.ts | 30 ++++++++++++++++ src/duration.ts | 11 ++++++ src/index.ts | 7 ++-- test/Countdown.test.ts | 64 +++++++++++++++++++++++++++++++++++ test/Stopwatch.test.ts | 42 +++++++++++++++++++++++ 9 files changed, 243 insertions(+), 13 deletions(-) create mode 100644 src/Countdown.ts create mode 100644 src/Stopwatch.ts create mode 100644 src/duration.ts create mode 100644 test/Countdown.test.ts create mode 100644 test/Stopwatch.test.ts diff --git a/README.md b/README.md index 4c1421b..ca70d53 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/Clock.ts b/src/Clock.ts index 7679a6e..a634c41 100644 --- a/src/Clock.ts +++ b/src/Clock.ts @@ -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) => { diff --git a/src/Countdown.ts b/src/Countdown.ts new file mode 100644 index 0000000..1c571cf --- /dev/null +++ b/src/Countdown.ts @@ -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 = []) { + 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; + } +} diff --git a/src/RelativeTimeDistortion.ts b/src/RelativeTimeDistortion.ts index 1beab78..955545f 100644 --- a/src/RelativeTimeDistortion.ts +++ b/src/RelativeTimeDistortion.ts @@ -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 { diff --git a/src/Stopwatch.ts b/src/Stopwatch.ts new file mode 100644 index 0000000..f16e9b3 --- /dev/null +++ b/src/Stopwatch.ts @@ -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 = []) { + 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; + } +} diff --git a/src/duration.ts b/src/duration.ts new file mode 100644 index 0000000..9b957e7 --- /dev/null +++ b/src/duration.ts @@ -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; diff --git a/src/index.ts b/src/index.ts index 5102aa9..e2e50a1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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 }; diff --git a/test/Countdown.test.ts b/test/Countdown.test.ts new file mode 100644 index 0000000..c2e2eca --- /dev/null +++ b/test/Countdown.test.ts @@ -0,0 +1,64 @@ +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[0], + distortions: ConstructorParameters[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.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); + }); +}); diff --git a/test/Stopwatch.test.ts b/test/Stopwatch.test.ts new file mode 100644 index 0000000..f8ca943 --- /dev/null +++ b/test/Stopwatch.test.ts @@ -0,0 +1,42 @@ +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[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); + }); + + 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); + }); +}); From f9e27dfebd6fc53f154964b0ed9cbc4d62f6ac54 Mon Sep 17 00:00:00 2001 From: Jonathan Griggs Date: Sat, 27 Jun 2026 23:29:22 -0600 Subject: [PATCH 2/2] Meet coverage thresholds --- test/Countdown.test.ts | 2 ++ test/Stopwatch.test.ts | 2 ++ 2 files changed, 4 insertions(+) diff --git a/test/Countdown.test.ts b/test/Countdown.test.ts index c2e2eca..004b24c 100644 --- a/test/Countdown.test.ts +++ b/test/Countdown.test.ts @@ -24,6 +24,8 @@ describe(`Countdown`, () => { 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); diff --git a/test/Stopwatch.test.ts b/test/Stopwatch.test.ts index f8ca943..f4e8599 100644 --- a/test/Stopwatch.test.ts +++ b/test/Stopwatch.test.ts @@ -25,6 +25,8 @@ describe(`Stopwatch`, () => { 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`, () => {