diff --git a/inc/Abilities/CalendarAbilities.php b/inc/Abilities/CalendarAbilities.php index 7b72bb04..2f7de490 100644 --- a/inc/Abilities/CalendarAbilities.php +++ b/inc/Abilities/CalendarAbilities.php @@ -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; @@ -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. @@ -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 @@ -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 diff --git a/inc/Blocks/Calendar/Cache/CalendarCache.php b/inc/Blocks/Calendar/Cache/CalendarCache.php index 954fb9f2..b95ba540 100644 --- a/inc/Blocks/Calendar/Cache/CalendarCache.php +++ b/inc/Blocks/Calendar/Cache/CalendarCache.php @@ -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 ) ); diff --git a/inc/Blocks/Calendar/Grouping/DateGrouper.php b/inc/Blocks/Calendar/Grouping/DateGrouper.php index 21cd1e60..52cf0a52 100644 --- a/inc/Blocks/Calendar/Grouping/DateGrouper.php +++ b/inc/Blocks/Calendar/Grouping/DateGrouper.php @@ -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. @@ -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, diff --git a/inc/Blocks/Calendar/Grouping/LateNightCutoff.php b/inc/Blocks/Calendar/Grouping/LateNightCutoff.php new file mode 100644 index 00000000..f3648049 --- /dev/null +++ b/inc/Blocks/Calendar/Grouping/LateNightCutoff.php @@ -0,0 +1,155 @@ += 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 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 ); + } +}