A tour operator wants to block Thanksgiving night. Nothing books between 8 PM and midnight. Easy, right? You add a rule to the availability engine, start_local: "20:00", end_local: "24:00", done.
Then you look at the clock in your tiny localTimeToUtc helper and realize that "24:00" is not a valid time. The hour field only goes 0 through 23. There is no 24th hour. There is only midnight on the next day.
So you add a special case. If someone passes "24:00", treat it as midnight tomorrow. Add a day. Convert to UTC. Move on with your life.
Except two days a year, a day isn’t 24 hours long.
Why “24:00” exists at all
Users expect the end of a window to be exclusive. “Block me from 8 PM to midnight” means a boat tour starting at 11:59 PM should be blocked, but a tour starting at 12:00 AM the next morning should not. If you write that as 20:00 to 00:00, your string comparison breaks — "00:00" is less than "20:00", so the window appears to be negative length.
The clean fix in rule engines is to let the author write "24:00" and mean midnight at the top of the next day. It parses, it compares, it reads. Every booking platform I’ve looked at that lets humans define recurring blackout windows has some version of this escape hatch.
The ugly fix is to let the author write "23:59" and pretend it’s the same thing. It isn’t. A tour that starts at 23:59:30 passes the check and then the operator spends an hour yelling at their calendar.
The naive implementation
My first pass at supporting "24:00" in Freebo’s blackout rule evaluator looked like this:
const windowEndUtc = endLocal === '24:00'
? DateTime.fromISO(ds.date).startOf('day').plus({ hours: 24 }).toUTC().toISO()
: localTimeToUtc(endLocal, ds);
Read it fast and it looks fine. The day in question starts at midnight local, you add 24 hours, you get midnight the next day. You convert to UTC. The window ends when it should.
Read it slowly on November 2nd and November 3rd.
Those are the days daylight saving ends in the US. The 24-hour period that starts at midnight on November 2nd actually contains 25 real hours, because at 2 AM the clock jumps back to 1 AM and you get the 1 AM hour twice. So “midnight plus 24 hours” lands at 11 PM, not midnight. Your blackout window ends an hour early. A tour that was supposed to be blocked at 11:30 PM slips through.
In the spring, when daylight saving starts, the day is 23 hours long. Midnight plus 24 hours lands at 1 AM the next day. Now your blackout window overshoots by an hour and you block a tour that was supposed to be bookable.
Neither one of these is a crash. Both are silent. The operator sees the blocked slot vanish or appear and has no idea why. The ticket gets filed as “the calendar is broken” and lives in a backlog for a month.
What Luxon actually wants from you
The fix is to let the time zone do the math, not do it yourself. Luxon’s DateTime knows that November 2nd in America/New_York is 25 hours long. You just have to ask it the right question.
const windowEndUtc = endLocal === '24:00'
? DateTime.fromISO(ds.date)
.startOf('day')
.plus({ hours: 24 })
.toUTC()
.toISO()
: localTimeToUtc(endLocal, ds); const windowEndUtc = endLocal === '24:00'
? DateTime.fromISO(ds.date, { zone: ds.timezone })
.startOf('day')
.plus({ days: 1 })
.toUTC()
.toISO()
: localTimeToUtc(endLocal, ds); Two changes, both load-bearing.
First, fromISO(ds.date, { zone: ds.timezone }) tells Luxon that this date lives in the operator’s local zone, not UTC. If an operator in New York wants to block Thanksgiving night, “Thanksgiving” means something different to them than it does to a server in Virginia running on UTC. The day boundary is a local thing.
Second, plus({ days: 1 }) is calendar arithmetic, not clock arithmetic. Luxon walks the day forward in that zone, which means it crosses whatever DST transition sits in the middle. A 23-hour day advances 23 real hours. A 25-hour day advances 25 real hours. Either way you land on true local midnight.
Then you convert to UTC and store the real instant.
Why I almost missed it
A reviewer caught this one. The original code worked for the test case I’d written, which was Thanksgiving — November 27 in the US does not cross a DST boundary. It worked for Christmas. It worked for every holiday I had actually sat down and tested.
It would have broken quietly on November 2nd in states that observe daylight saving. Not with an exception. Not with a 500. Just with a window that ended at the wrong second, and a booking that should have been blocked ending up on an operator’s dashboard the next morning.

The useful rule I pulled out of this: anywhere in a scheduling system where you’re adding “a day” to something, ask yourself whether you mean 24 real hours or the next calendar day in a specific zone. They are almost always different concepts. The language usually hides the difference. The compiler never catches it. You have to catch it yourself, with the time zone in your hand.
The non-obvious part
The reason this bug is interesting isn’t the DST math. Everyone who has shipped a calendar app knows about DST. The interesting part is that the bug lives inside a feature that was supposed to be a convenience — letting a human write "24:00" instead of forcing them to pick the right exclusive end time.
Every piece of “let the user say the natural thing” convenience in a date system has a shape like this. "end of day", "next Monday", "this evening", "24:00" — these are all ambiguity you’ve chosen to absorb. The place you absorb it is a translation layer, and the translation layer is where the DST bugs live.
The fix isn’t to remove the convenience. The fix is to remember that the translation is where you look when the calendar lies, and to let the time zone library do the arithmetic you can’t be trusted to get right.
That, and to run your tests on November 2nd.