A Drupal 11 site running Webform Booking showed an empty calendar — "No slots available" — to some visitors and a working one to others. The variable was the visitor's browser timezone. West of UTC, the calendar rendered the previous month, which is entirely past dates, so nothing was selectable. The fix is one line. The rest of this is how it got there.
Narrowing the field
The report was specific enough to do most of the work:
Slots show for other people, but not for me. Tried Chrome, Firefox, Edge, and my phone, cleared cache on all of them. Still nothing.
Three cases fall out of that:
- Broken for everyone → config or data.
- Fixed by clearing cache → caching.
- Works for them, not for me, and cache-clearing does nothing → something that varies per viewer and runs client-side.
For a calendar, one client-side variable changes behavior without touching the account or the server: the browser's timezone.
Reproducing it
Clean Drupal 11 install, the module, a booking form. Loaded in a browser pinned to America/New_York (UTC−4): the header read July 2026, every day was greyed out, and the slot panel said No slots available.
Same form, same login, same minute, browser set to UTC: July rendered, days 24–31 open, slots listed.
One difference between the two runs — the timezone. That is the bug.
The cause
The month grid is built in JavaScript. The relevant line in fetchDays():
const currentDate = toLocalDate(new Date(date));
date is already a string like "2026-07-01". This is the old footgun:
new Date("2026-07-01") // parsed as UTC midnight, per the ECMAScript spec
A date-only ISO string is interpreted as UTC. In a negative-offset timezone, UTC midnight on the 1st is still the previous evening — and the previous month — locally:
// Browser in America/New_York (UTC-4):
new Date("2026-07-01").getMonth() // 5 — June. It's 8pm on June 30th locally.
new Date("2026-07-01T00:00").getMonth() // 6 — July. A date-TIME string parses as local.
new Date(2026, 6, 1).getMonth() // 6 — July. Numeric args are local. Month is 0-indexed.
So getMonth() returns June. The grid renders June, June is in the past, every cell gets the past-date class, and you get "No slots available." The month dropdown still reads July, because that code path handled the string correctly. The grid and the label disagree by exactly one month. That disagreement is the fingerprint.
Month navigation always requests the first of the month, so the calendar is one month behind for every negative-offset viewer, not just on load.
The helper that guarded nothing
The module already had a function written for this exact problem:
function toLocalDate(dateString) {
if (dateString instanceof Date) {
return dateString; // returns the input untouched
}
const parts = dateString.split('-');
if (parts.length === 3) {
const [y, m, d] = parts.map(Number);
return new Date(y, m - 1, d); // local date, constructed correctly
}
return new Date(dateString);
}
toLocalDate("2026-07-01") does the right thing. The call site wrapped the string in new Date() first, which handed the helper a Date — the one input its guard returns unchanged. The safeguard was written to catch this and was routed around before it could. It guarded nothing.
No setting fixes this
Worth stating plainly, because it is where the time goes on a live site. This is not configurable:
- Not the module's "Default Country" setting. That drives PayPal and locale strings.
- Not the global Drupal timezone, and not per-user account timezones. Those are server-side PHP. The server returned correct data throughout.
The wrong month is computed in the browser, from the browser's clock. The only non-code workaround is asking every affected user to change their operating system timezone, which is not a fix.
The fix
- const currentDate = toLocalDate(new Date(date));
+ const currentDate = toLocalDate(date);
Stop wrapping the string; let the helper parse it. Verified correct for negative and positive offsets, and it touches no toISOString() call. That last point matters here: the module has a history of timezone corrections that each broke a different region — APAC in #3472486, Europe in #3525113. A one-line change that leaves the other date paths alone is deliberate. It does not restart that cycle.
The rule worth keeping
new Date() on a date string parses differently depending on the string:
new Date("2026-07-01")→ UTC midnight (date-only).new Date("2026-07-01T00:00")→ local midnight (date-time, no offset).new Date(2026, 6, 1)→ local (numeric args, month 0-indexed).
If you parse date-only strings for anything that draws a calendar, build the Date from numeric parts or append an explicit time. Don't round a string through new Date() on the way into a "make this local" helper. The round trip is the bug.
Sending it back
The bug is not specific to this one site. It is a live regression in the module, already reported for US users with no fix attached. Webform Booking runs on a lot of small sites where nobody is reading server logs, and a private edit to the module would be erased by the next composer update. So the fix went up as a merge request against the maintainer's 1.1.x branch, on issue #3531803, instead.
The client gets a fix that survives updates. The next person who installs the module in Denver or São Paulo gets a calendar that renders. That is the return on a one-line diff.
Summary: a booking calendar showed no slots, but only in timezones west of UTC, because new Date("2026-07-01") parses as UTC midnight and lands in the previous month. The module's own toLocalDate() would have handled the string; the call site wrapped it in new Date() first and skipped past the safeguard. One line. It is now a merge request upstream.