Skip to content

Generalize the distortion window anchor (unlocks multi-day, stopwatch, and countdown modes) #4

Description

@boatmeme

Summary

Distortion windows are currently hardwired to a time-of-day anchor: TimeWindow resolves its bounds via ClockTime.forTodayInMillis() / forTomorrowInMillis(), re-resolving to "today" on every read. This is elegant for the canonical bedside-clock use case, but it blocks three things people will reasonably want — multi-day spans, a stopwatch, and a countdown — all of which turn out to be the same missing abstraction.

This issue proposes generalizing the anchor of a TimeWindow so a window can be pinned to one of:

  • time-of-day (today's behavior, unchanged) — e.g. "1:00am–7:00am"
  • absolute instant — e.g. "starting 2026-12-25T01:00Z, for 6h"
  • elapsed-since-start — e.g. "from 0:00 to 0:30 of this run"

One seam, three payoffs.

Motivation

a) Multi-day

"Multi-day" is really two features, and the current model is ambiguous about both:

  • Windows are times-of-day and re-anchor to today on every read (windowStartInMillisforTodayInMillis), which implicitly means they recur daily.
  • But Clock._offset accumulates monotonically and is never reset, which implies a single continuous timeline.

These conflict across midnight. Concretely:

  1. Cross-midnight integration bug. When Date.now() and Clock._lastCheck straddle a midnight re-anchoring boundary, the offset integration in Clock.offset misbehaves. (The companion clockblocker-web demo only avoids this by pinning the whole simulation to a single calendar day.)
  2. Sparse-read correctness gap. Clock.offset clamps each window's contribution with Math.min(window.durationInMillis, now - windowStart, timeSinceLastCheck, windowEnd - lastCheck) against today's re-anchored window. So if the clock is read sparsely (read → idle 3 days → read), it integrates only today's instance of a daily window and silently skips the intervening days.
  3. No true multi-day span. windowEndInMillis only rolls forward to tomorrow when end < start; it cannot express a window longer than ~24h (e.g. "dilate continuously for 3 days").

b) Stopwatch mode

A stopwatch is counting up with warped elapsed time: relativeTimeInMillis - startInMillis. The Clock already computes exactly the warped-elapsed-time offset this needs — a stopwatch is a thinner abstraction than the wall clock, not a heavier one. The only blocker is that windows are time-of-day; a stopwatch wants windows anchored to its own zero ("from 0:00–0:30 of the run, dilate").

c) Countdown mode

A countdown is targetInMillis - relativeTimeInMillis, counting down to zero. This is highly compelling: distortions can make a countdown crawl early and sprint at the end while still hitting zero on the real deadline (when dilation debt is fully repaid by later compression). The clockblocker-web "Escape Room" scenario demonstrates this behavior today using the wall-clock API; a first-class countdown mode would express it directly. Same blocker: windows need an elapsed/relative anchor instead of time-of-day.

Proposed direction

Abstract the anchor out of TimeWindow (today it implicitly calls ClockTime.forTodayInMillis). Sketch:

type WindowAnchor =
  | { kind: 'timeOfDay'; start: ClockTime; /* recurrence: 'none' | 'daily' */ }
  | { kind: 'absolute';  startInstant: Temporal.ZonedDateTime }
  | { kind: 'elapsed';   startOffsetMs: number }; // relative to a run's t=0
  • time-of-day → preserves current behavior. Consider making recurrence explicit (none | daily) so "just tonight" vs "every night" is a choice rather than a side effect of re-anchoring — this also resolves the cross-midnight ambiguity.
  • absolute → enables multi-day spans and unambiguous one-shot windows.
  • elapsed → enables stopwatch and countdown, where windows are relative to the run's start.

Clock keeps its offset integrator largely as-is; what changes is how a window resolves windowStartInMillis / windowEndInMillis and how compareWithinWindow is evaluated. Wall clock / stopwatch / countdown then become thin presentation layers over the same engine:

  • wall clock: now + offset
  • stopwatch: (now + offset) - runStart
  • countdown: target - (now + offset)

Suggested sequencing

  1. Fix cross-midnight integration + sparse-read gap (worth doing on its own; latent correctness bug regardless of new features).
  2. Introduce the anchor abstraction with timeOfDay (current behavior) + elapsed.
  3. Add Stopwatch and Countdown facades over the elapsed anchor.
  4. Add absolute anchor for true multi-day spans; extend windowEndInMillis beyond the single overnight wrap.

Out of scope / open questions

  • Recurrence semantics for time-of-day windows (none | daily, and whether _offset resets per recurrence).
  • Whether absolute windows should also support recurrence (e.g. weekly), or stay one-shot.
  • Backward-compatibility: the current ClockTimeDescriptor constructor signature should keep working (default to timeOfDay).

Metadata

Metadata

Assignees

Labels

No labels
No labels

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions