Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 10 additions & 4 deletions inc/Abilities/CalendarAbilities.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
use DataMachineEvents\Blocks\Calendar\Query\ScopeResolver;
use DataMachineEvents\Blocks\Calendar\Data\EventHydrator;
use DataMachineEvents\Blocks\Calendar\Grouping\DateGrouper;
use DataMachineEvents\Blocks\Calendar\Grouping\LateNightCutoff;
use DataMachineEvents\Blocks\Calendar\Display\EventRenderer;
use DataMachineEvents\Blocks\Calendar\Pagination;
use DataMachineEvents\Blocks\Calendar\Pagination\PageBoundary;
Expand Down Expand Up @@ -594,6 +595,11 @@ private static function compute_unique_event_dates( array $params ): array {
$has_tax_filter = ( $archive_taxonomy && $archive_term_id )
|| self::has_active_tax_filter( $tax_filters );

// SQL fragment that buckets start_datetime by display date (with
// late-night cutoff applied). Identical semantics to
// LateNightCutoff::display_date_from_strings() at the PHP layer.
$start_bucket_sql = LateNightCutoff::sql_display_date_expression( 'ed.start_datetime' );

// Fast path: no taxonomy constraint → skip posts/term joins entirely.
// event_dates already carries post_status, so we can aggregate against
// the single table + its status_start composite index.
Expand All @@ -607,10 +613,10 @@ private static function compute_unique_event_dates( array $params ): array {
}

$where = implode( ' AND ', $where_clauses );
$sql = "SELECT DATE(ed.start_datetime) AS start_date, DATE(ed.end_datetime) AS end_date, COUNT(*) AS bucket_count
$sql = "SELECT {$start_bucket_sql} AS start_date, DATE(ed.end_datetime) AS end_date, COUNT(*) AS bucket_count
FROM {$ed_table} ed
WHERE {$where}
GROUP BY DATE(ed.start_datetime), DATE(ed.end_datetime)
GROUP BY {$start_bucket_sql}, DATE(ed.end_datetime)
ORDER BY start_date ASC";

// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
Expand Down Expand Up @@ -673,12 +679,12 @@ private static function compute_unique_event_dates( array $params ): array {

// DISTINCT p.ID inside COUNT to avoid double-counting when a post
// is attached to multiple matching terms in a multi-filter join.
$sql = "SELECT DATE(ed.start_datetime) AS start_date, DATE(ed.end_datetime) AS end_date, COUNT(DISTINCT p.ID) AS bucket_count
$sql = "SELECT {$start_bucket_sql} AS start_date, DATE(ed.end_datetime) AS end_date, COUNT(DISTINCT p.ID) AS bucket_count
FROM {$wpdb->posts} p
INNER JOIN {$ed_table} ed ON p.ID = ed.post_id
{$joins}
WHERE {$where}
GROUP BY DATE(ed.start_datetime), DATE(ed.end_datetime)
GROUP BY {$start_bucket_sql}, DATE(ed.end_datetime)
ORDER BY start_date ASC";

// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared
Expand Down
3 changes: 3 additions & 0 deletions inc/Blocks/Calendar/Cache/CalendarCache.php
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,9 @@ public static function generate_key( array $params, string $prefix ): string {
'tax_filters' => $params['tax_filters'] ?? array(),
'archive_tax' => $params['archive_taxonomy'] ?? '',
'archive_term' => $params['archive_term_id'] ?? 0,
// Bucketing depends on the cutoff hour; fold it into the key so
// switching the filter at runtime invalidates stale buckets.
'cutoff_hour' => \DataMachineEvents\Blocks\Calendar\Grouping\LateNightCutoff::cutoff_hour(),
);

return self::PREFIX . $prefix . '_' . md5( wp_json_encode( $key_data ) );
Expand Down
24 changes: 21 additions & 3 deletions inc/Blocks/Calendar/Grouping/DateGrouper.php
Original file line number Diff line number Diff line change
Expand Up @@ -89,12 +89,26 @@ public static function group_events_by_date( array $paged_events, bool $show_pas
$occurrence_dates = $event_data['occurrenceDates'] ?? array();
$has_occurrence_dates = ! empty( $occurrence_dates ) && is_array( $occurrence_dates );

// effective_start_date is what we treat as the event's grouping
// "start" — same as $start_date in normal cases, but shifted back
// one day for late-night events. Used downstream for the
// is_continuation / is_start_day flags so the cutoff doesn't
// confuse the calendar's continuation rendering.
$effective_start_date = $start_date;

if ( $has_occurrence_dates ) {
$event_dates = $occurrence_dates;
} elseif ( $is_multi_day ) {
$event_dates = MultiDayResolver::get_date_range( $start_date, $end_date, $event_tz );
} else {
$event_dates = array( $start_date );
// Single-day events get a late-night cutoff shift: a 1am show
// belongs to the previous night for human-friendly grouping.
// The underlying start_datetime stays untouched.
$effective_start_date = LateNightCutoff::display_date_from_strings(
$start_date,
$event_data['startTime'] ?? ''
);
$event_dates = array( $effective_start_date );
}

// Filter out past dates when show_past is false.
Expand Down Expand Up @@ -135,12 +149,16 @@ function ( $date ) use ( $date_start, $date_end ) {
}

// Events with explicit occurrence dates are NOT continuations.
$is_continuation = $has_occurrence_dates ? false : ( $date_key !== $start_date );
// For non-occurrence events the "start" we compare against is
// the effective (cutoff-shifted) start so a 1am show on its
// own bucket is still flagged as the start day, not a
// continuation of the previous night.
$is_continuation = $has_occurrence_dates ? false : ( $date_key !== $effective_start_date );

$display_item = $event_item;
$display_item['display_context'] = array(
'is_multi_day' => $has_occurrence_dates ? false : $is_multi_day,
'is_start_day' => $has_occurrence_dates ? true : ( $date_key === $start_date ),
'is_start_day' => $has_occurrence_dates ? true : ( $date_key === $effective_start_date ),
'is_end_day' => $has_occurrence_dates ? true : ( $date_key === $end_date ),
'is_continuation' => $is_continuation,
'display_date' => $date_key,
Expand Down
155 changes: 155 additions & 0 deletions inc/Blocks/Calendar/Grouping/LateNightCutoff.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
<?php
/**
* Late-Night Calendar Cutoff
*
* Buckets after-midnight shows under the previous calendar day for display
* purposes. A 1:00 AM Sunday show is "Saturday night" to humans, even though
* its literal start_datetime is Sunday.
*
* The underlying datetime is never modified — only the calendar grouping
* key changes. Single-event pages, structured data, ICS exports, and any
* datetime-aware consumer keep seeing the real start time.
*
* @package DataMachineEvents\Blocks\Calendar\Grouping
* @since 0.31.0
*/

namespace DataMachineEvents\Blocks\Calendar\Grouping;

use DateTime;
use DateTimeZone;

if ( ! defined( 'ABSPATH' ) ) {
exit;
}

class LateNightCutoff {

/**
* Default cutoff hour. Events between 00:00 and (cutoff - 1):59 belong
* to the previous calendar day. 5 matches Bandsintown / Songkick; RA
* uses 6. 0 disables the feature.
*/
private const DEFAULT_CUTOFF_HOUR = 5;

/**
* Resolve the active cutoff hour.
*
* Filterable via `data_machine_events_late_night_cutoff_hour`. Return
* 0 to disable late-night bucketing entirely. Values >= 12 are clamped
* to 5 to prevent absurd cutoffs (a 1pm cutoff would push afternoon
* shows backward).
*
* @return int Hour in [0, 12). 0 means feature is disabled.
*/
public static function cutoff_hour(): int {
/**
* Filter the late-night calendar cutoff hour.
*
* Events with a start time between 00:00 and (cutoff - 1):59 are
* displayed under the previous calendar day. Return 0 to disable.
*
* @since 0.31.0
*
* @param int $cutoff_hour Default 5. Range [0, 12).
*/
$hour = (int) apply_filters(
'data_machine_events_late_night_cutoff_hour',
self::DEFAULT_CUTOFF_HOUR
);

if ( $hour < 0 || $hour >= 12 ) {
return self::DEFAULT_CUTOFF_HOUR;
}

return $hour;
}

/**
* Compute the calendar display date for an event datetime.
*
* Subtracts the cutoff window: events whose hour is below the cutoff
* are returned with the previous calendar day. Everything else
* returns its native date.
*
* @param DateTime $event_datetime Event start datetime (in venue tz).
* @return string Display date in Y-m-d.
*/
public static function display_date( DateTime $event_datetime ): string {
$cutoff = self::cutoff_hour();

if ( $cutoff <= 0 ) {
return $event_datetime->format( 'Y-m-d' );
}

$hour = (int) $event_datetime->format( 'G' );
if ( $hour >= $cutoff ) {
return $event_datetime->format( 'Y-m-d' );
}

// Clone to avoid mutating the caller's DateTime instance.
$shifted = clone $event_datetime;
$shifted->modify( '-1 day' );

return $shifted->format( 'Y-m-d' );
}

/**
* Compute the display date from a raw Y-m-d + H:i:s pair.
*
* Pure-string path used by the SQL bucketing layer where a DateTime
* round-trip would be wasteful. Identical semantics to display_date().
*
* @param string $start_date Y-m-d.
* @param string $start_time H:i or H:i:s. May be empty.
* @return string Display date in Y-m-d.
*/
public static function display_date_from_strings( string $start_date, string $start_time ): string {
$cutoff = self::cutoff_hour();

if ( $cutoff <= 0 || empty( $start_time ) ) {
return $start_date;
}

// Parse hour from "HH:MM" or "HH:MM:SS".
$hour = (int) substr( $start_time, 0, 2 );
if ( $hour >= $cutoff ) {
return $start_date;
}

// Subtract a day. DateTime handles month/year rollovers.
$dt = DateTime::createFromFormat( 'Y-m-d', $start_date );
if ( false === $dt ) {
return $start_date;
}
$dt->modify( '-1 day' );

return $dt->format( 'Y-m-d' );
}

/**
* Build a SQL DATE() expression that respects the cutoff.
*
* Used by aggregating queries (calendar bucket counts) that need to
* group by display date rather than literal start date. Returns the
* raw SQL fragment for the cutoff-shifted date.
*
* Equivalent to:
* DATE(start_datetime - INTERVAL <cutoff> HOUR)
*
* Falls back to plain DATE(start_datetime) when the feature is
* disabled (cutoff = 0).
*
* @param string $column Fully-qualified datetime column (e.g. "ed.start_datetime").
* @return string SQL expression yielding a DATE.
*/
public static function sql_display_date_expression( string $column ): string {
$cutoff = self::cutoff_hour();

if ( $cutoff <= 0 ) {
return "DATE({$column})";
}

return sprintf( 'DATE(%s - INTERVAL %d HOUR)', $column, $cutoff );
}
}
Loading