The itinerary that started a day early

Title card: The itinerary that started a day early

There are two kinds of off-by-one. The one that skips the last row of a table is annoying and obvious and fixed before lunch. The one that lives in a date is a different animal — it waits. It looks right in every test you run at your desk, and then a customer opens a seven-night cruise and the itinerary swears the ship sailed the day before it actually did.

This is a story about the second kind. And it’s the second time I’ve fixed it, which is the part that stings.

What the plugin does

It’s one of ours — a plugin that pulls cruise and tour packages out of a supplier’s feed and renders them into WordPress: the price, the inclusions, the day-by-day itinerary. The itinerary is the fiddly bit. The API hands you a sailing date and a run of days, and the plugin has to turn that into “Day 1 — Sydney, board this afternoon. Day 2 — at sea. Day 3 — Noumea…” and so on, each with a real calendar date next to it.

Get the maths right and nobody ever thinks about it. Get it wrong by a single day and the whole thing quietly lies to everyone who reads it.

The symptom

A package that departed on a Saturday was showing Day 1 as the Friday.

Not every package. That’s what made it interesting, and what made me distrust my own eyes for the first hour. Some sailings rendered perfectly. Others were a day light at the front. Same code, same plugin, different result — which almost always means the input isn’t as uniform as you assumed it was.

Back in July I’d already been here once. There’s a commit in the history, in my own hand, that says “Fixed minor date reset bug in itinerary calculation.” Minor. I actually wrote that. Reader, it was not minor, it was merely quiet, and quiet is not the same thing.

The cause, which was of course a timezone

Here is roughly what the day-numbering was doing. The API gives you a date string. You turn it into something you can add days to, then you loop:

$start = new DateTime( $package['sail_date'] ); // e.g. "2026-08-15"

foreach ( $days as $i => $day ) {
    $date = clone $start;
    $date->modify( "+{$i} days" );
    $day['date'] = $date->format( 'D j M' );
}

Looks fine. Reads fine. The problem is what new DateTime() does with a bare date and no timezone: it builds the date at midnight in the server’s timezone, and the moment you start formatting or comparing against anything built in UTC — which the rest of the feed handling was — you’re one timezone offset away from a clean midnight. On our boxes, that offset is enough to drag the date back across the midnight line and render the day before.

So the packages that broke weren’t random at all. They were the ones whose handling touched a UTC comparison somewhere downstream. The ones that rendered fine never crossed the line. Same code, genuinely different inputs — the inputs were just different in a way that didn’t show up until midnight got involved.

The maddening thing about this class of bug is that it is invisible in exactly the conditions you test under. You’re at your desk, your server clock and your idea of “today” agree, and everything lines up. It only tips over for the sailing dates and the viewing times where the offset happens to matter. Which is to say: in production, for some customers, on some packages, at some times of day. The worst possible surface area.

The fix

Pin the timezone. Stop letting DateTime guess, and stop mixing UTC-built dates with server-built ones in the same comparison:

$tz    = new DateTimeZone( 'UTC' );
$start = new DateTime( $package['sail_date'], $tz );

foreach ( $days as $i => $day ) {
    $date = ( clone $start )->modify( "+{$i} days" );
    $day['date'] = $date->format( 'D j M' );
}

One timezone argument, applied consistently, and the day-one drift is gone. The plugin and the two cruise themes that render the same data all got the same treatment in the same pass, because the bug had been copied along with the rendering code. That’s the tax on a shared pattern you didn’t extract into one place: when it’s wrong, it’s wrong in three files.

While I had the patient open

Two other things went in the same day, and they’re worth a line each because they’re the sort of change you only make once the immediate fire is out.

The first: I dropped the old getToken calls. The itinerary rendering had been reaching out to grab an auth token it no longer needed — a leftover from an earlier version of the feed that had quietly stopped being necessary. Dead auth handshakes are easy to leave lying around because they don’t hurt anything, right up until they do. Out they went.

The second: I merged the data fetch to run in-process rather than as a REST round-trip. The plugin had been calling its own REST endpoint over HTTP to get data it already had access to internally — a little server talking to itself over the network, adding a request and a point of failure for no benefit. Calling the handler directly instead is faster and one less thing to go wrong on a page load. If your plugin is making an HTTP request to localhost, it’s worth asking whether it needs to.

The lesson, again

A date is not a number, however much it looks like one. The second you construct one without saying which timezone you mean, you’ve made a decision — you’ve just let the server make it for you, and the server will make a different decision than the next line of code that assumed UTC. Two reasonable defaults, disagreeing by a few hours, and a few hours is all it takes to land on the wrong side of midnight.

I’d “fixed” this in July. What I’d actually done was patch one path and leave the underlying ambiguity in place, so it came back the moment a different sailing date walked through a different door. This time the fix is the boring one that should have gone in first: decide the timezone, write it down in the code, and make every date in the flow agree.

If you take one thing from this: search your codebase for new DateTime( with a bare string and no second argument. Every one of those is a small bet that the server’s clock and your assumptions line up. Some of those bets you are quietly losing right now.

Leave a Reply

Your email address will not be published. Required fields are marked *