Iurii RoguliaIurii Rogulia
AboutServicesPricingProjectsSkillsStackReviewsPhrasesBlog
Contact
Iurii ships.

Iurii Rogulia, senior full-stack software engineer. Professionally building software since 2001.

Think of a number
PricingQuality checklistPrivacy PolicyCookie Policy

Business

TMI Iurii Rogulia
VAT ID: FI29845875
DUNS: 368664211
Lappeenranta, Finland 🇫🇮

  1. Home
  2. /
  3. Blog
  4. /
  5. Booking Slots Across Daylight Saving Time (Luxon)

Iurii Schedules: Booking Slots Across Daylight Saving Time (Luxon)

Luxon returns a valid DateTime for a wall-clock time that never happened. Here’s how to catch it before a skipped hour quietly becomes a duplicate of the slot an hour later.

October 2, 2026· 11 min read

Booking slots across daylight saving time: why isValid misses skipped times and why a pinned version cannot resolve autumn ambiguity. Plus how to choose an occurrence explicitly.

Stack

TypeScriptNode.js

Libraries

Luxon

Databases

PostgreSQL

Topics

SaaSOnline BookingArchitecture
Booking Slots Across Daylight Saving Time (Luxon)

A salon’s working hours are a wall-clock fact: open nine, close eight. A booking is an instant: this client, this specialist, this exact moment. Most of the time, converting between the two needs no thought. At daylight saving transitions, that conversion can have a gap or more than one answer. A wall-clock time that never happened can silently turn into a real instant – the same one as the slot an hour later. A working block’s own start or end time can be the one that doesn’t exist that day. And the day itself can be 23 or 25 hours long instead of 24.

This is the boundary KRI, the booking SaaS I designed and built for beauty businesses, has to get right on every date, in whichever IANA time zone the salon owner picked at sign-up. The examples below use Luxon 3.7.2, the version KRI runs. Its default gap handling needs an explicit check, and reviewing its autumn behaviour exposed a separate assumption in KRI that a pinned version does not make safe.

Instants in the Database, Wall Clocks in the Schedule

KRI stores two different kinds of time, and mixing them up is the entire source of the problem. A booking – its start and its end – is a UTC instant, a use case for TIMESTAMPTZ rather than a timezone-free TIMESTAMP: a moment that means the same thing regardless of who reads it. A specialist’s working hours are the opposite on purpose: "09:00"–"20:00", a wall-clock string with no offset attached, because that’s what actually stays true across a daylight saving change. A salon that opens at nine keeps opening at nine in March and in October; what changes is which UTC instant ‘nine’ happens to be that day.

This article covers one path: slot generation and the client’s online booking page. Everywhere that path turns a specialist’s stored "09:00" into a bookable UTC instant for a specific date, or turns a client-submitted instant back into the local date it falls on, it goes through one server-side conversion module. So do that path’s day windows and date-by-date scans. The chokepoint is deliberate: daylight saving time is cheap to get right once and expensive to get right N times. The salon owner’s own dashboard has a separate manual-booking path with its own client-side conversion; it’s outside the scope of this article.

Why isValid Doesn’t Catch a Skipped Hour

This is the case the chokepoint exists for. Take Europe/Warsaw on 29 March 2026, the day clocks spring forward from 02:00 to 03:00. The wall-clock times 02:00–02:59 don’t happen that day, yet asking Luxon to build a DateTime for "02:30" in that zone doesn’t fail:

Requested wall clock (Europe/Warsaw, 2026-03-29)isValidWhat Luxon actually returns
02:00true03:00+02:00 – shifted forward an hour
02:30true03:30+02:00 – shifted forward an hour
03:30 (the real one)true03:30+02:00

02:30 and the real 03:30 land on the exact same instant. In Luxon 3.7.2, DateTime.fromFormat (and fromObject behaves the same way) returns a valid DateTime for a wall-clock time inside a spring-forward gap: it silently shifts the time forward past the gap and reports the result as valid, so isValid can’t detect it. Ask for the same "02:30" in Europe/Helsinki, which is one hour ahead and has its own gap starting at 03:00 that day, and 02:30 exists where you’d expect, no shift. The failure depends on the zone whose gap you land in, not on the time 02:30 itself, and isValid gives no signal either way: it’s true in both zones, for different reasons.

So a naive ‘generate a slot for every 10 minutes between opening and closing, check isValid’ loop hands out a slot for the hour that never happened, silently pointing at the same instant as the slot an hour later.

Catching It: Convert, Then Convert Back

The fix needs no application-maintained table of transitions; Luxon uses the runtime’s time zone data. Convert the requested wall clock to an instant, then format that instant back into a wall clock in the same zone and compare the two strings:

function localToUtc(date: string, time: string, zone: string): Date | null {
  const dt = DateTime.fromFormat(`${date} ${time}`, "yyyy-MM-dd HH:mm", { zone });
  if (!dt.isValid) return null;
 
  // Luxon shifts a non-existent wall clock forward instead of reporting it
  // invalid. Compare the result with the requested local date and time.
  if (dt.toFormat("yyyy-MM-dd HH:mm") !== `${date} ${time}`) return null;
 
  return dt.toJSDate();
}

Detection comes down to formatting the result back in the same zone and comparing. If the wall clock you get back differs from the one you asked for, the time you asked for never existed – you’re looking at the silent forward shift, not a coincidence. That’s the whole detection, with no table of DST transition dates.

This doesn’t produce a working slot where there wasn’t one; there still isn’t one. What it changes is the failure: an explicit null the caller has to handle instead of a slot quietly pointing at the wrong instant that nothing downstream would flag. That matters most where a naive loop is least likely to notice: at the edges of a working block, when a specialist’s stored start or end time falls inside the gap.

What a Gap Does to a Slot List

KRI’s slot generator walks each working block in fixed 10-minute wall-clock steps and converts every step through the function above. Two different things can go wrong with a block on a given date, and they’re handled differently:

  • A single step inside an otherwise normal block lands in the gap. That one candidate slot is skipped; the rest of the block still offers its normal slots.
  • The block’s start or end time itself is the one that doesn’t exist that day – a specialist scheduled to start at 02:00 on a morning where 02:00 never happens. The entire block for that date is dropped, not clipped to whatever fragment might remain. Once a non-existent boundary is shifted, the generator has no principled way to guess what it was supposed to mean, so it refuses the whole block rather than invent a partial one.

A service’s duration is added to a converted slot start in real elapsed milliseconds, not by adding minutes to a wall clock, so a duration that spans a transition is measured correctly even though the wall clock reads a full hour later than a naive add would show. In practice this only matters late at night or in the small hours. In the European zones above, the spring gap sits between 02:00 and 04:00 local time (02:00–02:59 in Warsaw, 03:00–03:59 in Helsinki), well outside a salon open 09:00–20:00. Some zones shift at midnight instead – a different case, covered below. For a salon open only during the day, those particular slot-level cases will not occur.

The Repeated Hour: Pinning the Version Is Not a Policy

On 25 October 2026, Europe/Warsaw’s clocks fall back from 03:00 to 02:00. The local time 02:30 occurs twice: at 00:30Z with offset +02:00, and at 01:30Z with offset +01:00. Both pass the round-trip check because both are real occurrences of the requested wall clock.

KRI’s conversion code assumed Luxon 3.7.2 would choose the earlier occurrence. Reviewing that assumption produced this result, with the same library version and input:

Current time supplied to LuxonRequested local timeResult
January 20262026-10-25 02:30, Europe/Warsaw02:30+01:00, the later occurrence
July 20262026-10-25 02:30, Europe/Warsaw02:30+02:00, the earlier occurrence

This is reproducible by changing only Settings.now in a test:

import { DateTime, Settings } from "luxon";
 
const originalNow = Settings.now;
try {
  for (const now of ["2026-01-15T12:00:00Z", "2026-07-15T12:00:00Z"]) {
    Settings.now = () => Date.parse(now);
    Settings.resetCaches();
    const dt = DateTime.fromFormat("2026-10-25 02:30", "yyyy-MM-dd HH:mm", {
      zone: "Europe/Warsaw",
    });
    console.log(now, dt.toISO(), dt.toUTC().toISO());
  }
} finally {
  Settings.now = originalNow;
  Settings.resetCaches();
}

Run this in isolation: Settings.now is global test state. The 3.7.2 implementation uses the zone’s offset at the current time as an initial guess when constructing this value. Luxon’s documentation makes no promise about which ambiguous occurrence a plain lookup chooses. The dependency is therefore broader than a library upgrade: the result can change while the version stays fixed.

If the product’s policy is to offer the earlier occurrence only, select it explicitly after the validity and round-trip checks:

const candidates = dt.getPossibleOffsets();
const earlier = candidates.reduce((a, b) => (a.toMillis() <= b.toMillis() ? a : b));
return earlier.toJSDate();

This is the proposed correction to KRI’s assumption, not a claim that its current conversion module already contains this selection. A product that offers both occurrences needs a different interface: two distinct instants with enough offset or time-zone information for the client to tell them apart. Either policy can be deliberate; leaving it to a default constructor cannot guarantee either one.

A Day Isn’t Always 24 Hours

Date math has the same trap wall-clock math does. Advance a UTC instant by a fixed 24 hours across the Warsaw autumn transition and the local calendar date it lands on doesn’t advance cleanly – two consecutive 24-hour hops can land on the same local date, because one of those days was 25 real hours long. That’s why KRI’s own day-by-day iteration (walking a booking window date by date) advances a "YYYY-MM-DD" string by calendar arithmetic, never by adding a fixed duration to an instant. In a DST-observing zone, ‘add a day’ and ‘the next calendar day’ don’t always mean the same thing.

The same reasoning extends to where a day starts. A window for a given date is built from startOf('day') in the salon’s zone rather than assuming "00:00" – because in some zones, on some dates, midnight itself is the wall clock the DST shift removes. Africa/Cairo, America/Havana, America/Santiago, Asia/Beirut, and Atlantic/Azores all skip midnight on at least one date in 2026. A strict conversion asked for "00:00" on one of those dates correctly returns null – there is no such instant – so building the day window from a fixed "00:00" lookup would fail on exactly the dates it needs to work. startOf('day') sidesteps the question and returns the first instant that exists that day, whatever its wall-clock time. Unlike the gap and duration cases above, which only bite at hours outside a typical salon’s working day, this applies to every day window and date-by-date scan behind slot generation and the booking page, in every zone, on every date.

Failing Loudly on a Bad Time Zone

One more failure mode is silent in a way that looks nothing like a bug: an invalid or unset IANA zone. Luxon doesn’t throw when you ask it to convert into a time zone it doesn’t recognise – DateTime.local().setZone("Europe/Wrsaw") (one letter off) comes back with isValid: false and no exception. Feed that into the conversion function above and every wall-clock lookup for that salon returns null, on every date, for every slot. The calendar isn’t broken – it’s just empty. To a client on the salon’s booking page – or the owner checking that same page – ‘no availability’ and ‘the time zone is misconfigured’ look identical.

So the zone itself is checked at the start of every conversion, and a bad one throws rather than falls through:

function assertTimezone(zone: string | null | undefined): string {
  if (!zone || !DateTime.local().setZone(zone).isValid) {
    throw new Error(`Invalid tenant timezone: ${String(zone)}`);
  }
  return zone;
}

The time zone is a business value here, not a formatting detail – it decides which real-world instant a booking lands on – so an unknown or missing one fails the request instead of quietly defaulting. An unset zone option would fall back to whatever the process’s own local zone happens to be, which is a different kind of wrong: not empty, but confidently pointing at the wrong instants.

What This Doesn’t Cover

Everything above covers slot generation and the client’s online booking page, not every path in the product. The salon owner’s own dashboard has a separate manual-booking flow that converts wall clock to instant with its own code. It isn’t covered here, and this article makes no claim that one module handles every booking-related conversion in the system.

Dropping a working block with an invalid boundary is KRI’s current conservative policy. Autumn ambiguity is different: the earlier-occurrence assumption needs the explicit selection above and regression checks with both winter and summer current dates.

The checks are small, but they answer different questions: does this local time exist, which occurrence do we mean, where does the day begin, and is the configured zone valid? Treating those as separate decisions makes the calendar easier to reason about and test.


Further reading:

  • kri.rocks – Booking Website and Online Scheduling SaaS for Beauty Businesses – the project this article’s time zone handling is drawn from
  • PostgreSQL Production Checklist: UUIDs, RLS, Indexes, Pooling – the TIMESTAMPTZ convention this article’s storage model relies on
  • A Green Test Can Still Miss the Race: Testing Concurrency in PostgreSQL – a different kind of correctness problem in the same booking system
  • Luxon – Time Zones and Offsets – the official documentation on invalid and ambiguous local times cited above
Iurii RoguliaAvailable

MVP Development

A scheduling or booking feature that only handles daylight saving time correctly in one time zone isn’t finished – getting it right for whichever zone the customer happens to be in is part of the MVP work I do.

More about this service

Relevant client work

View all projects
kri.rocks – Booking Website and Online Scheduling SaaS for Beauty Businesses
kri.rocks – Booking Website and Online Scheduling SaaS for Beauty Businesses
September 28, 2026

Vertical SaaS that gives a salon, barbershop or solo beauty specialist their own booking website in minutes.

vatnode.dev – EU VAT Validation API
vatnode.dev – EU VAT Validation API
January 19, 2026
vatnode.dev – EU VAT Validation API

Developer-first SaaS API for EU VAT validation via VIES with Redis caching, change monitoring, and webhook notifications.

HTPBE.TECH – Internal Admin Dashboard
HTPBE.TECH – Internal Admin Dashboard
March 15, 2026
HTPBE.TECH – Internal Admin Dashboard

Role-gated admin dashboard for the HTPBE? SaaS platform – real-time KPIs, per-user quota tracking, and a zero-dependency bar chart.

What clients say

“

I had a validated idea and a deadline tied to an accelerator demo day, but no product.

Aino Virtanen 🇫🇮

Founder

Stack

Next.jsTypeScript

Databases

PostgreSQL

Topics

MVPProductScopeSaaS
“

I’d built most of our MVP with Cursor and it looked finished – it compiled, the tests were green, the demo worked. It just wouldn’t survive real users.

Sebastian Falk 🇸🇪

Founder

Stack

Next.jsTypeScript

Topics

Technical DebtAICode ReviewArchitecture
“

Before we closed on a seed-stage SaaS we asked Iurii to look under the hood. He gave us a written report in five days: what was solid, what was held together with tape, and roughly what it would cost

Florian Berg 🇨🇭

Investment Principal

Databases

PostgreSQL

Topics

Due DiligenceArchitectureScalabilityCode Review

Related articles

Preventing Overselling: Inventory Locks Under Concurrent Checkouts
July 31, 2026· 13 min
Preventing Overselling: Inventory Locks Under Concurrent Checkouts

Prevent overselling under concurrent checkouts: reservations vs hard decrements, SELECT FOR UPDATE, deadlock-safe multi-line carts, and the payment window.

Stack

Next.jsTypeScriptNode.js

Databases

PostgreSQLRedis

Topics

E-commercePaymentsArchitectureSaaS
Multi-Tenant SaaS Database Design: Shared Schema vs Schema-per-Tenant
June 19, 2026· 18 min
Multi-Tenant SaaS Database Design: Shared Schema vs Schema-per-Tenant

Multi-tenant SaaS database design: shared schema with RLS, schema-per-tenant, and database-per-tenant – trade-offs, real pitfalls, and when to use each.

Stack

TypeScriptNode.js

Libraries

Drizzle ORM

Databases

PostgreSQL

Topics

SaaSArchitectureSecurity
PostgreSQL Production Checklist: UUIDs, RLS, Indexes, Pooling
May 21, 2026· 15 min
PostgreSQL Production Checklist: UUIDs, RLS, Indexes, Pooling

PostgreSQL production patterns: UUID v7, soft deletes, RLS for multi-tenant SaaS, partial and covering indexes, PgBouncer pooling and migration discipline.

Stack

TypeScriptNode.js

Libraries

Drizzle ORM

Databases

PostgreSQLRedis

Topics

SaaSArchitecture
BullMQ vs pg-boss vs Cron: Node.js Background Jobs Compared
May 18, 2026· 15 min
BullMQ vs pg-boss vs Cron: Node.js Background Jobs Compared

BullMQ vs pg-boss vs node-cron for Node.js background jobs. Trade-offs between Redis and Postgres queues, retries, deduplication, and production monitoring.

Stack

Node.jsTypeScript

Libraries

BullMQpg-boss

Databases

PostgreSQLRedis

Topics

ArchitectureSaaSAutomation
JWT vs Sessions vs OAuth: Which to Use for SaaS Auth
May 14, 2026· 15 min
JWT vs Sessions vs OAuth: Which to Use for SaaS Auth

JWT vs sessions vs OAuth for SaaS: token invalidation, mobile clients, refresh token rotation, and a decision matrix to pick the right auth pattern.

Stack

Next.jsTypeScriptNode.js

Libraries

Better Auth

Databases

PostgreSQLRedis

Topics

SaaSAuthArchitectureSecurity