Repository navigation
bugfix: resolve availability on the caller's clock, and validate what it schedules against - #24
Merged
Merged
Conversation
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 Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
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>
This was referenced Sep 24, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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/latestTimename a time of day for the caller. The filter took the slot's UTCcalendar 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.
24:00Every US customer silently lost their evenings. Reading the window from the instant on the caller's
clock also deleted the special case
24:00needed.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:
01:00–05:00Sunday ran to 06:00 local on thespring-forward day; Berlin's started at 00:00.
Saturday 09:00–17:00ended at 16:00, on an ordinarySaturday 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:00stays eight hours on the clock, which is seven or nineelapsed 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:59pattern already produced 23- and 25-hour days.23:59now names the end of the day in every zone. It already did everywhere except the literalstring
'UTC', which was left with a one-minute unavailable sliver that'Etc/UTC'did not have.3. The week advanced on the local calendar
getMondaybuilt Monday 00:00 UTC while the walk advanced withsetDateand the convertervalidated with
getDay. West of UTC the validator threw on every call with a pattern —including the one
docs/availability-scheduler.mdrecommends. East of it, spring-forward shiftedthe entire pattern by a day until October.
4. Busy times that cannot be scheduled against are rejected
An
Invalid Datecompares false against everything, so a list holding one sorts into no order andthe 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 handingback the scheduler's own objects.
5. The release checks now check something
version-checkonly ran when a PR title began withv, so it skipped every PR written to thecommit convention — including the last three bug fixes. It runs on every PR now.
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. Tagv0.5.0pointing at 0.5.1, and 0.5.1reaching npm a minute before 0.5.0, are what that check exists to prevent.
publintnow reports clean:sideEffects,engines, declared provenance, and the TypeScript peerdependency 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,
typecheckandlintclean. 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:
maxOverlapsSchedulerconstructor, 500 busy timesThe 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 Datereach the scheduler. Everything else got faster: the offsetlookup no longer rebuilds an
Intl.DateTimeFormatper call, and a span whose ends share an offsetcannot 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 decisionThe 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 meanpermanently red checks, which teach people to ignore CI. The comment in
tests.ymlsays what has togo first; the 1.0 plan removes those exports.
codecov/patchis failing on this PR. The patch is well covered by the numbers above; the check isnot required by the ruleset.
🤖 Generated with Claude Code