Invite-only beta · 9 free places left · Managed hosting on AWS

AceMedia HostTalk to us

SCHEDULING · 28 OCTOBER 2026

Four clock bugs that hide in scheduling code

Bookings and recurring events pass every test in January and fail quietly in April. These are the clock bugs I see most, with the test that catches each one.

Scheduling code looks simple until the clocks change. Bookings, opening hours, recurring events and reminders all depend on dates and times, and the bugs they produce are the worst kind: they pass every test in January and fail quietly in April. These are the four I find most often, in my own code and in code I review, with the symptom, the test that catches each one and the fix.

1. Dropping the UTC offset

Symptom: slots saved in summer appear an hour early. A booking made for 10:00 shows as 09:00 on the confirmation, but only between late March and late October.

The cause is nearly always a time written out without its offset. A form posts 2026-07-14T10:00, the code parses it as UTC (or the server's zone), stores it, and later formats it in Europe/London. In winter, London and UTC agree, so nobody notices. In British Summer Time they differ by an hour.

The test: always include one date in summer and one in winter.

$tz = new DateTimeZone( 'Europe/London' );
foreach ( [ '2026-01-14 10:00', '2026-07-14 10:00' ] as $local ) {
    $saved  = save_slot( $local );
    $loaded = new DateTimeImmutable( $saved, new DateTimeZone( 'UTC' ) );
    assert( $loaded->setTimezone( $tz )->format( 'H:i' ) === '10:00' );
}

The fix: parse local input in the site's own zone, convert to UTC once for storage, and keep the zone or offset whenever a time crosses a boundary. In WordPress, wp_timezone() gives you the site zone, and new DateTimeImmutable( $input, wp_timezone() ) is the safe way in. Store ISO 8601 with an offset (2026-07-14T10:00:00+01:00) or a UTC timestamp, never a bare local string.

2. Writing buckets in site time, reading them in UTC

Symptom: daily counts that are slightly wrong, with late-evening activity landing on the next day, but again only in summer. Reports look fine in winter.

A typical case: a counter writes to a key such as clicks:2026-07-14 using wp_date( 'Y-m-d' ), which uses site time. The report reads the same keys using gmdate( 'Y-m-d' ), which uses UTC. Between 23:00 and midnight in summer, the writer and reader disagree about what day it is.

The test: freeze the clock at 23:30 London time in July, record an event, then read today's bucket.

$now = new DateTimeImmutable( '2026-07-14 23:30', new DateTimeZone( 'Europe/London' ) );
record_click( $now );
assert( 1 === clicks_for_day( $now ) );

That means the code has to accept an injected time rather than calling time() itself, which is worth doing anyway.

The fix: pick one zone for buckets and use one helper for both writing and reading. I put the format in a single function, day_key( DateTimeInterface $when ), and grep for any other place that builds the key by hand.

3. "Last Friday" drifting to "fourth Friday"

Symptom: a monthly event meant for the last Friday of the month is right for a while, then lands a week early in months with five Fridays.

The bug is computing the next instance from the previous one. If the rule is "last Friday" and January's instance was the 30th, the code adds a month, finds the nearest Friday, or counts which Friday it was (the fifth, say, or the fourth) and repeats that. Some months have four Fridays and some have five, so "the same position as last time" is not the same rule as "the last one".

Month (2026)Last FridayFrom previous instance
January30th30th
February27th27th (fourth Friday)
May29th (fifth Friday)22nd (fourth Friday)

The test: generate twelve months from the rule and compare each date with an independent calculation for that month.

foreach ( range( 1, 12 ) as $m ) {
    $expected = new DateTimeImmutable( "last friday of 2026-$m" );
    assert( next_instance( $rule, $m )->format( 'Y-m-d' ) === $expected->format( 'Y-m-d' ) );
}

The fix: always compute each instance from the rule and the target month, never from the previous instance. PHP's relative formats do the hard part: 'last friday of next month' is correct by construction.

4. The midnight wrap

Symptom: a late-night opening window, say 23:30 to 00:15, shows as closed all night, or a "starts within 30 minutes" reminder never fires for events just after midnight.

The code compares times of day as strings or minutes: $now >= '23:30' && $now <= '00:15'. No time satisfies both, because 00:15 sorts before 23:30.

The test: check 23:45, 00:05 and 00:30 against the 23:30 to 00:15 window. The first two should be open, the third closed.

The fix: either compare full date-times (attach the window to a real date, and push the end to the next day when it is earlier than the start), or handle the wrap explicitly:

function in_window( int $now, int $start, int $end ): bool {
    // Minutes since midnight.
    return $start <= $end
        ? ( $now >= $start && $now < $end )
        : ( $now >= $start || $now < $end );
}

Full date-times are better when you can manage them, because they also handle the night the clocks change, when the wall clock hour from 01:00 to 02:00 either happens twice or not at all.

A short checklist

  • Every test file for scheduling code has at least one date in summer and one in winter.
  • Code takes the current time as an argument, so tests can freeze it at 23:30 or on the last Sunday in March.
  • Times are stored in UTC or with an offset, and converted to the site zone only for display.
  • Recurring rules are evaluated per target period, not chained from the last result.
  • Any window that can cross midnight has a test that crosses midnight.

None of these fixes is clever. The hard part is noticing, which is why the tests matter more than the code: once the summer date and the 23:30 case are in the suite, the bugs stay fixed.

More hosting tipsTalk about your hosting

INVITE-ONLY BETA

Tell us about your site.

Nine free hosting beta invites remain for open-source projects, charities, community organisations and other public-benefit work. Tell us what you’re building; we’ll review the fit and reply personally. Fields marked * are required.

Please do not send passwords or private customer data. We’ll use these details to reply. Privacy notice.

Prefer email? Write to [email protected].