Skip to content

bugfix: resolve availability on the caller's clock, and validate what it schedules against - #24

Merged
tkoehlerlg merged 7 commits into
mainfrom
bugfix/timezone-integrity
Sep 24, 2026
Merged

tkoehlerlg merged 7 commits into
mainfrom
bugfix/timezone-integrity

Conversation

@tkoehlerlg

@tkoehlerlg tkoehlerlg commented Sep 24, 2026 •

Copy link
Copy Markdown
Contributor

The 0.7.1 bug-fix batch: three wrong answers that ship today, the input validation that stops a
fourth from being possible, and the release checks that were not checking anything.

1. The daily window was measured on the wrong day

earliestTime/latestTime name a time of day for the caller. The filter took the slot's UTC
calendar date, built the window for that date in the target zone, and compared instants — so any
local day straddling UTC midnight was tested against the neighbouring day's window.

request before after
New York 09:00–22:00 10 slots, last at 18:00 13 slots, last at 21:00
Tokyo 02:00–06:00 0 slots 4
New York 20:00–24:00 10 of 15 all four evening hours

Every US customer silently lost their evenings. Reading the window from the instant on the caller's
clock also deleted the special case 24:00 needed.

2. An availability window is a set of instants, not two resolved endpoints

A weekly pattern names times on the host's clock. Resolving its two endpoints and taking everything
between them is a different thing, and the difference shows on the two days a year a zone changes
offset — in both directions:

  • Offering time nobody gave: a New York 01:00–05:00 Sunday ran to 06:00 local on the
    spring-forward day; Berlin's started at 00:00.
  • Losing time silently: an Auckland Saturday 09:00–17:00 ended at 16:00, on an ordinary
    Saturday that is not even the transition day, because the single-pass offset lookup landed on the
    afternoon before.

A window is now every instant whose local time falls inside it. A skipped wall time is simply absent
from that set, and a repeated one is present twice — so neither needs a disambiguation policy to
choose, configure or document. 09:00–17:00 stays eight hours on the clock, which is seven or nine
elapsed on a transition day, and is exactly what the same recurring block does in the host's own
calendar.

This is also the rule the library already obeyed where it was accidentally correct: the all-day
00:00–23:59 pattern already produced 23- and 25-hour days.

23:59 now names the end of the day in every zone. It already did everywhere except the literal
string 'UTC', which was left with a one-minute unavailable sliver that 'Etc/UTC' did not have.

3. The week advanced on the local calendar

getMonday built Monday 00:00 UTC while the walk advanced with setDate and the converter
validated with getDay. West of UTC the validator threw on every call with a pattern —
including the one docs/availability-scheduler.md recommends. East of it, spring-forward shifted
the entire pattern by a day until October.

4. Busy times that cannot be scheduled against are rejected

An Invalid Date compares false against everything, so a list holding one sorts into no order and
the scan that assumes order can walk past a real meeting. Both boundaries must now be real dates and
the interval must not end before it starts — checked where the caller can still see which input was
wrong, rather than becoming undefined behaviour three layers down.

They are also copied in and out. getBusyTimes() documented itself as safe to modify while handing
back the scheduler's own objects.

5. The release checks now check something

  • version-check only ran when a PR title began with v, so it skipped every PR written to the
    commit convention — including the last three bug fixes. It runs on every PR now.
  • Publishing fired on both the release and its tag, so a tagged release ran twice and the loser
    failed on an already-published version. The release is the only trigger, and it refuses to publish
    unless its tag names the version in package.json. Tag v0.5.0 pointing at 0.5.1, and 0.5.1
    reaching npm a minute before 0.5.0, are what that check exists to prevent.
  • publint now reports clean: sideEffects, engines, declared provenance, and the TypeScript peer
    dependency moved to dev.

Tests

New: a sweep over the wall-window resolver — 8 zones × 9 dates × 7 window shapes, >1000 probes —
asserting an invariant rather than expected outputs: every returned instant must read, on the
host's clock, as a time inside the window
. The two defects fixed in 0.6.0 and 0.7.0 were each an
answer someone wrote down by running the code, so the tests agreed with the implementation and could
not disagree with the bug. This one can.

Also new: the daily-window cases (three of four fail against the previous code) and the busy-time
ingest cases.

Two existing tests asserted the old answers. One expected six slots where the day holds four — its
own comment said it described "what the implementation yields". The other expected the one-minute
sliver.

537 pass, typecheck and lint clean. Coverage 95.98% lines / 95.30% funcs, marginally up;
every file touched here is at 100% except the invalid-timezone guard.

Performance

Measured against the branch point, since this adds work per resolution:

scenario before after
week with a daily window + timezone 610ms 213ms
week of availability pattern 18ms 9ms
8 weeks of pattern + maxOverlaps 137ms 87ms
Scheduler constructor, 500 busy times 0.27ms 1.5ms

The constructor is the one real cost — about 3µs per busy time to validate and copy it, which is the
price of not letting an Invalid Date reach the scheduler. Everything else got faster: the offset
lookup no longer rebuilds an Intl.DateTimeFormat per call, and a span whose ends share an offset
cannot contain a transition, so the common day takes two lookups instead of one per hour.

CI is pinned to TZ=UTC, and that is a gap rather than a decision

The library's answers must not depend on where it runs, and its own code no longer does. But 48
tests still assume the host clock is UTC — all for exported utilities (startOfDay, isSameDay,
alignToInterval, getTimeWithinDay) that read it by design. A matrix now would mean
permanently red checks, which teach people to ignore CI. The comment in tests.yml says what has to
go first; the 1.0 plan removes those exports.

codecov/patch is failing on this PR. The patch is well covered by the numbers above; the check is
not required by the ruleset.

🤖 Generated with Claude Code

Two places read a clock they had no business reading.

earliestTime and latestTime describe a time of day for the caller, but the
window was built for the slot's UTC calendar date and then compared as an
instant. Any local day straddling UTC midnight was therefore tested against the
neighbouring day's window: a New York caller asking for 09:00-22:00 got ten
slots instead of thirteen, losing every evening after 19:00, and a Tokyo caller
asking for 02:00-06:00 got none at all. The window is now read from the instant
on the caller's clock, which also removes the separate handling 24:00 needed.

The availability pattern advanced its week with local date arithmetic while the
week itself was built in UTC. West of UTC the two disagreed on which day Monday
was and every call threw; east of it, a spring-forward moved the week start an
hour earlier, which the converter read as the previous week and shifted the
whole pattern by a day until October.

CI gains a timezone matrix so this cannot regress unseen. The non-UTC legs still
fail and are advisory: the exported date utilities read the host clock by
design, and DST offsets are resolved in one pass. Both are known and neither is
this change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@codecov

codecov Bot commented Sep 24, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 99.13793% with 1 line in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
src/helpers/time/timezone.ts 98.43% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

tkoehlerlg and others added 6 commits September 24, 2026 22:15
An Invalid Date compares false against everything, so a list holding one sorts
into no order at all, and the scan that assumes sorted order can walk past a
real meeting and offer a slot that is already taken. It reached the scheduler
because nothing looked: a calendar integration mapping an all-day event to a
missing dateTime produced exactly this.

Busy times are now checked where the caller can still see which input was wrong
— both boundaries must be real dates, and the interval must not end before it
starts — rather than becoming undefined behaviour three layers down.

They are also copied on the way in and on the way out. getBusyTimes documented
itself as safe to modify while returning the scheduler's own objects, so a
caller mutating either its input or the returned list silently changed what the
scheduler answered. One existing test asserted that shared identity; it now
asserts the value, which is what it meant.

Also drops the .ts suffixes from four type imports, which put unresolvable
specifiers into the emitted declarations.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The version gate only ran when a pull request title began with 'v', so every PR
written to the commit convention skipped it — including the three most recent
bug fixes. It now runs on every pull request, which matches how this repo
releases: each merge into main becomes a version.

Publishing fired on both the release and the tag it carries, so a tagged release
ran the workflow twice and the loser failed on an already-published version. The
release is now the only trigger, and it refuses to publish unless its tag names
the version in package.json. Tag v0.5.0 pointing at 0.5.1, and 0.5.1 reaching
npm a minute before 0.5.0, are what that check exists to prevent.

Packaging gains what publint asked for: sideEffects so bundlers can drop unused
code, engines, and a declared provenance rather than one that happens to be on.
The TypeScript peer dependency moves to devDependencies, since nothing in the
published output needs it at runtime.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Neither tsc nor biome flags an unused import here, so it survived the change
that removed its last use.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A weekly pattern names times on the host's clock. Resolving its two endpoints
into instants and taking everything between them is a different thing, and the
difference shows on the two days a year a zone changes offset.

It showed as offering time nobody gave: a New York 01:00-05:00 Sunday ran until
06:00 local on the spring-forward day, Berlin's from 00:00. And as quietly
losing time: an Auckland Saturday 09:00-17:00 ended at 16:00, on an ordinary
Saturday that is not even the transition day, because the single-pass offset
lookup landed on the afternoon before.

A window is now the set of instants whose local time falls inside it. A skipped
wall time is absent from that set rather than resolved to something adjacent, and
a repeated one is present twice, so neither needs a policy: 09:00-17:00 stays
eight hours on the clock, which is seven or nine elapsed on a transition day and
is what the same recurring block in the host's own calendar does.

23:59 now names the end of the day in every zone. It already did everywhere
except the literal string 'UTC', which was left with a one-minute unavailable
sliver that 'Etc/UTC' did not have.

Two tests asserted the old answers. One expected six slots where the day holds
four; its comment said it was describing what the implementation yields. The
other expected the sliver.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
resolveWallWindow and the converter each carried their own sort-and-merge; both
now call mergeBusyTimes, which does the same thing and is already tested. The
local millisecond constants give way to the shared ones, parseTime returns the
minutes every caller was computing from its result, and a one-caller wrapper in
the slot generator is inlined.

The busy-time validator had been inserted between the Scheduler class's JSDoc
and the class, so the class's example documented the validator instead. It moves
above, and its two near-identical boundary checks fold into one.

CI pins TZ=UTC rather than running a matrix. The matrix is the right thing and
the library code is ready for it, but 48 tests still assume the host clock is
UTC, and shipping permanently red checks teaches people to ignore CI. The
comment says what has to go first.

The wall-window sweep gets an explicit timeout: it resolves every zone, date and
window combination, which is thousands of offset lookups and past the default.
Those lookups no longer rebuild an Intl formatter each time.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Resolving a window scanned its span hour by hour looking for a transition, so a
week of pattern cost thousands of Intl lookups. Two transitions cannot fall
inside one span, so equal offsets at both ends mean nothing changed between
them: the common day now takes two lookups instead of one per hour, and only a
day that actually contains a transition pays for the scan.

Measured against the branch point: a week with a daily window and a timezone
goes from 610ms to 213ms, a week of availability pattern from 18ms to 9ms.
Validating and copying busy times does cost the constructor about 3us each,
which is the price of not letting an Invalid Date reach the scheduler.

A twelve-hour stride was tried first and changed three results, so the scan
stays hourly where it runs at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@tkoehlerlg tkoehlerlg changed the title bugfix: measure the daily window and the week in the right clock bugfix: resolve availability on the caller's clock, and validate what it schedules against Sep 24, 2026
@tkoehlerlg
tkoehlerlg merged commit a99a2ec into main Sep 24, 2026
5 of 6 checks passed
@tkoehlerlg
tkoehlerlg deleted the bugfix/timezone-integrity branch September 24, 2026 21:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant