Open Now badge and filter, timezone-aware opening hours

Open Now is a timezone-aware “is this place open right now?” check that shows a green pill on every result card and an “Open now” toggle in the filter sidebar. It works even when listings live in different timezones, handles overnight hours (a bar that closes at 02:00 is still open at 01:00), and handles split shifts (lunch break, then dinner service). The implementation lives in src/Frontend/OpeningHours.php as a stateless helper that returns true, false, or null per listing.

What Open Now does for the visitor

  • A green “Open now” pill renders top-left on every result card whose listing reports as currently open.
  • A grey “Closed” pill renders on listings reporting as currently closed.
  • Listings that have no opening hours configured render no pill at all (we don’t lie about availability we don’t actually know).
  • Above the filter sidebar’s Features section, an “Open now” toggle filters the result set to only the listings that pass the live check.
Open Now status pill rendered on a search result card listing photo 🕒 Open now Café Mosaic 📍 Bedford · Restaurant · ★ 4.7 (84) 📶 🅿 🥬 +4 Specialist coffee and brunch in central Bedford, consistent weekend queues. Booking advised. 📞 Call 🧭 Directions

Configuring a listing

Edit any listing and open the Hours tab. You’ll see a per-day grid (Mon to Sun) with an “Open” / “Closed” checkbox and two time pickers per row. The timezone selector at the top defaults to “Use site default” which reads wp_timezone(). If a listing operates in a different timezone, pick a named IANA zone (Europe/London, America/Los_Angeles, Asia/Tokyo, etc.) and only that listing uses it.

Hours tab on the listing edit screen showing the optional timezone field Edit Listing — Café Mosaic Save draft | Publish Contact Social Hours Location Ratings Facilities Custom Fields SEO Timezone Use site default (Europe/London) Powers the “Open now” badge on cards and the single listing. Only set this if the business operates in a different timezone from the site. Set open and close times for each day. 📋 Copy Monday to all Day Closed Opens Closes Today Monday 09:00 17:00 Tuesday 09:00 17:00 Wednesday 09:00 17:00 Thursday (closed) Friday 09:00 17:00 Saturday 10:00 15:00 Sunday (closed) Publish Status: Draft Visibility: Public Publish
Storage. Hours are kept in postmeta _sdp_hours as an array keyed by day. Each day is ['open' => '09:00', 'close' => '17:00'] or ['closed' => true]. The optional _sdp_timezone postmeta stores the IANA zone identifier. Both fields are validated server-side: invalid timezone strings get rejected back to “site default.”

How the timezone logic works

The OpeningHours class builds a single DateTimeImmutable in the listing’s effective timezone at every check. From that one object it pulls the current day-of-week, the current minute-of-day, and the previous day-of-week. That trick lets us evaluate today’s windows and any overnight spillover from yesterday in the same pass, without having to re-instantiate or re-parse the schedule.

Overnight handling

A Monday window of 22:00 → 02:00 is interpreted as overnight: the close time is numerically smaller than the open time. At 23:00 on Monday, we’re still inside Monday’s overnight window. At 01:00 on Tuesday, we’re inside Monday’s overnight spillover. Both register as open.

Split shifts

If a listing has a lunch break (open 09:00 to 12:00, then 14:00 to 18:00), the postmeta day value is an array of two pairs. The class iterates each pair and returns true if any window contains the current time.

Legacy formats

Older imports often stored hours as plain strings (“9-5”, “9am – 5pm”, “Closed”). The class parses each string into open and close minutes, accepting both 12-hour (with am/pm suffix) and 24-hour notation. Unparseable strings return no windows for that day, so the listing reports as closed.

Adapted from Listeo. The timezone-aware single-DateTime pattern, including the legacy “UTC±N” to “Etc/GMT∓N” normalisation, was modelled on Listeo’s listeo_check_if_open. Our version is shorter (about 200 lines), uses immutable date types, and unifies the same-day and yesterday-overnight branches behind one minute-of-day comparison.

How the badge renders on cards

Every result returned by /sdp/v1/search includes an open_status field set to "open", "closed", or null. The JS card renderer in sdp-search.js reads that field and appends a pill element with class sdp-d__card-open--open or sdp-d__card-open--closed to the card thumbnail. The pill positions absolute, top-left, with a soft drop shadow.

// Card render snippet (real shipped code, lightly trimmed)
if (row.open_status === 'open' || row.open_status === 'closed') {
 const pill = document.createElement('span');
 pill.className = `sdp-d__card-open sdp-d__card-open--${row.open_status}`;
 const label = row.open_status === 'open'
 ? i18n.openNow // "Open now"
 : i18n.closedNow; // "Closed"
 pill.innerHTML = `<i class="ph ph-clock"></i>${label}`;
 wrap.appendChild(pill);
}

Both pill colours and the clock icon are theme-token-aware (using the --sdp-color-accent CSS custom property). Pill copy is translatable via the standard i18n bag, so a French site shows “Ouvert” instead of “Open now.”

How the Open Now filter works

The “Open now” toggle in the filter sidebar is a checkbox with data-filter="open_now". When checked, the JS adds open_now=1 to the search payload, and Search::ajax_search() applies a post-query filter (after WP_Query has run) that drops every row whose open_status is not "open".

Why post-query rather than a SQL WHERE clause? Because the check is per-listing-timezone-aware. SQL can’t compute “is the current time in this listing’s timezone inside any of its opening windows” without serialising the entire schedule into a comparable shape, which would be slower than just looping the result set in PHP. For typical pages (12-100 results), the post-query filter is a few-millisecond operation.

The discovery-facets endpoint (/sdp/v1/discovery/facets) reports the Open Now badge count alongside the Featured and Verified counts, so the sidebar can render "Open now (12)" for the current filter set.

FAQ

What if a listing has no hours set?

It reports open_status: null. No pill renders on the card. The Open Now filter excludes it. We don’t guess.

Does the badge update live without a page reload?

Yes, every time the user runs a search, the freshly-computed status comes back from the API. If a listing transitions from open to closed at 17:00, the next search the user runs after that minute will reflect it. There’s no per-listing client-side ticker.

Can I show different colours per listing or per directory?

Yes, the pill backgrounds use CSS custom properties (--sdp-color-accent and friends). Set those on a per-theme or per-page scope and the pill picks them up.

How does this work in the search-this-area pan flow?

The pan triggers a fresh search request with the new bounding box, which re-runs the Open Now post-query filter on the new result set. Pills update in place.

In section: Core Features Updated May 30, 2026