Skip to content

This is part of Build log, a running series on the WordPress plugins I maintain for venue and community sites.

The hard part of an events plugin isn’t the admin screen. It’s that an event and a date are not the same thing! Unless it’s an actual date! Some examples are…

  • A gig that runs every Tuesday until December isn’t one event with one date — it’s dozens.
  • A three-night festival is three.
  • A weekly class that skips half term has a gap in it.
  • A rescheduled show suddenly needs its own one-off time.

Rules, not rows

The obvious implementation is one database row per date. It’s also the one that hurts later: editing a twenty-week residency becomes twenty separate edits, and a typo is twenty corrections!

So an event stores its rule once — a single date, a date range, or a recurrence pattern — and that rule is only expanded into real calendar dates at the moment something asks for them, for the window it asked about. The branching is as plain as it sounds:

if ($raw['is_one_day']) {
    if ($raw['recurrence_frequency'] !== '') {
        return $this->expandRecurrence($startDate, $raw, $rangeStart, $rangeEnd, $defaultTimes);
    }

    return [/* the single date, if it falls in range */];
}

return $this->expandDateRange($startDate, $raw, $rangeStart, $rangeEnd, $defaultTimes);

Nothing is precomputed, so nothing can drift out of sync with the thing it was derived from. Change the venue for a whole run and it’s one edit, because there only ever was one record to edit.

There’s a cap on how far a single event can expand in one request, for the reason you’d expect: someone will eventually type a recurrence end date of 2126 instead of 2026, and the failure mode for that shouldn’t be a request that never returns.

One shape of data, three interfaces

Expansion produces a plain array — an “occurrence” — and that array is the only thing anything else in the plugin consumes. The public listings shortcode renders occurrences. The admin month calendar renders occurrences. The REST endpoint serialises occurrences.

That’s why a gig marked Cancelled greys out in the admin calendar in the same pass that strikes it through on the public site: it isn’t two implementations kept in agreement by discipline, it’s one array being drawn twice.

Events Manager admin calendar screenshot
Events Manager admin calendar screenshot… Looking a little empty right now!

I drew that month grid by hand rather than reaching for one of the established JavaScript calendar libraries. They’re good, but they carry a lot of weight I’d never use — drag-to-reschedule, week and resource views, timezone handling for a building that exists in exactly one place. Mine draws a month, colours each event by status, opens a panel when you click a day, and gets out of the way.

The same shape leaves the plugin three ways, all backed by that one query: a plain PHP function for a theme on the same site, a versioned REST endpoint for anything that isn’t, and a documentation page in wp-admin covering both. That last one is generated from the same field list the code itself consumes, so it physically cannot describe a field the code doesn’t return.

The version number on that endpoint isn’t decoration either. It’s a commitment: if the response shape ever has to change, that’s a v2, not a quiet edit to v1 that breaks whatever someone built on top of it six months ago.

Putting the dates back together

Expansion creates a display problem in the other direction. A three-day festival that correctly expanded into three occurrences shows up as three identical cards on a listings page, which looks broken to everyone except the person who wrote it.

So the listing groups occurrences back up by event before rendering — but only collapses the ones that should collapse:

if (count($group) > 1 && $first['is_multi_day_range']) {
    $first['end_date'] = $group[count($group) - 1]['date'];
}

A festival becomes one card with a range. A weekly night stays as separate cards, because it does happen more than once and a visitor wants to see the next one, not a range spanning until Christmas.

Then there’s formatting the range itself, which took a bit of fiddling:

DatesRendered
Same month12–15 Sep 2026
Crosses a month28 Aug – 3 Sep 2026
Crosses a year28 Dec 2026 – 2 Jan 2027

Repeating the month when it hasn’t changed reads as clumsy; omitting it when it has reads as wrong. It’s a small thing, and it’s exactly the small thing a client notices!

Events Manager plugin event date/time scheduling selection screenshot.
Events Manager plugin event date/time scheduling selection screenshot.

Handing the theme the state

One more thing falls out of occurrences being a single shape: the listing can describe each card in CSS without the theme having to ask any questions. Every card renders with modifier classes for its own state — past or future, on today or tomorrow, falling in this week or this month, a multi-day run, a recurring night, sold out, has a ticket link — plus its position in the list, and one class per term it’s tagged with.

So making tonight’s gig look different from next month’s is a CSS rule and not a template override. The theme developer never has to touch the plugin — and that developer is very often me, a year later, on a different site, with no memory of how any of this works!

The date-relative ones sit in a try/catch that skips them rather than fataling if an event somehow holds an unparseable date. A malformed date should cost you a CSS hook, not the page.

Store the rule and derive the dates. Anything you precompute is a second copy of the truth, and second copies are where the bugs live.