✨ feature: Add calendar layout and occurrence handling functionality
- Introduced `calendar-layout.php` to manage month grid generation, occurrence intersection checks, and segment assignments for events. - Added `occurrences.php` for normalizing, validating, and displaying event occurrences, including migration from legacy formats. - Implemented functions for parsing dates, validating occurrence ranges, and formatting occurrences for display. - Established hooks for synchronizing event occurrence data upon saving posts and validating ACF repeater fields.
This commit is contained in:
@@ -1,2 +1,3 @@
|
||||
notes/
|
||||
phpcs-results.txt
|
||||
tests/
|
||||
|
||||
Vendored
+9
-1
@@ -3,6 +3,14 @@
|
||||
"tree.indentGuidesStroke": "#3d92ec",
|
||||
"activityBar.background": "#123509",
|
||||
"titleBar.activeBackground": "#1A4A0D",
|
||||
"titleBar.activeForeground": "#F5FDF3"
|
||||
"titleBar.activeForeground": "#F5FDF3",
|
||||
"titleBar.inactiveBackground": "#123509",
|
||||
"titleBar.inactiveForeground": "#F5FDF3",
|
||||
"statusBar.background": "#123509",
|
||||
"statusBar.foreground": "#F5FDF3",
|
||||
"statusBar.debuggingBackground": "#123509",
|
||||
"statusBar.debuggingForeground": "#F5FDF3",
|
||||
"statusBar.noFolderBackground": "#123509",
|
||||
"statusBar.noFolderForeground": "#F5FDF3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,24 +6,25 @@ An approachable, theme‑friendly events plugin for WordPress. It adds an `Event
|
||||
|
||||
- Event post type with archive at `/events`.
|
||||
- Two taxonomies: `Event Types` (hierarchical) and `Event Tags` (non‑hierarchical).
|
||||
- Event details via ACF: date/time, cost, location (online URL vs in‑person address).
|
||||
- Event details via ACF: occurrences, cost, location (online URL vs in‑person address).
|
||||
- Front‑end templates: archive list, single view, and a calendar page template.
|
||||
- Theme overrides for templates and styles with simple folder conventions.
|
||||
- Sensible defaults: admin column for date, sortable by event date; taxonomy views sort upcoming first; main events archive sorts newest first.
|
||||
- Sensible defaults: the admin date column shows and sorts by the earliest normalized occurrence.
|
||||
|
||||
---
|
||||
|
||||
## Requirements
|
||||
|
||||
- WordPress 5.8+ (uses modern APIs like `wp_date()` and template filters).
|
||||
- Advanced Custom Fields (free) is required for the event fields. The plugin will load without ACF, but the Event fields and related UI will not be available until ACF is active.
|
||||
- PHP 7.4+.
|
||||
- Advanced Custom Fields PRO is required for the event fields. **Occurrences** uses the Repeater field, which is an ACF PRO feature. The plugin will load without ACF PRO, but its built-in Event fields and related editing UI will not be available.
|
||||
|
||||
---
|
||||
|
||||
## Installation
|
||||
|
||||
1. Copy the `aa-events` folder into `wp-content/plugins/`.
|
||||
2. (Required) Install and activate the “Advanced Custom Fields” plugin.
|
||||
2. (Required) Install and activate **Advanced Custom Fields PRO** so the Occurrences repeater is available.
|
||||
3. In WordPress Admin → Plugins, activate “AA Events”.
|
||||
4. Visit Settings → Permalinks and click Save if your events archive doesn’t appear (activation also flushes rewrites).
|
||||
|
||||
@@ -33,13 +34,28 @@ An approachable, theme‑friendly events plugin for WordPress. It adds an `Event
|
||||
|
||||
1. Create events: Admin → Events → Add New.
|
||||
2. Fill in the “Event Details” fields:
|
||||
- Event Date & Time (required)
|
||||
- **Occurrences**: Add one row for each occurrence. A start date is required in every row; start time, end date, end time, and All Day are optional.
|
||||
- **Event Date & Time** (optional compatibility field only; use Occurrences when creating or editing event dates)
|
||||
- Event Cost (optional)
|
||||
- Online/In‑Person toggle (required)
|
||||
- Online/In‑Person toggle (optional)
|
||||
- Event URL (required when Online)
|
||||
- Event Address (required when In‑Person)
|
||||
- Contact Email (optional; displayed on the single-event page)
|
||||
3. View your list of events at `/events` and single event pages at their permalinks.
|
||||
4. For a calendar view, create a new Page and choose the “Events Calendar” template, then publish.
|
||||
4. For a calendar view, create a new Page and choose the "Events Calendar" template, then publish.
|
||||
|
||||
### Event Occurrences
|
||||
|
||||
The **Occurrences** repeater is the primary schedule editor. Each row describes one continuous occurrence:
|
||||
|
||||
1. Click **Add Occurrence** and enter its required Date.
|
||||
2. Optionally add a Start Time. Add an End Date and/or End Time when the occurrence continues beyond its starting point; leaving the end blank is valid.
|
||||
3. Enable **All Day** for an all-day occurrence. This hides the time controls, and saved time values are ignored.
|
||||
4. Add another row when the event occurs again. Rows may freely mix single-day, multi-day, timed, and all-day occurrences; the plugin does not generate recurrence rules.
|
||||
|
||||
For example, one event can have a row starting Friday at 6:00 PM and ending Sunday at 2:00 PM, plus a later row with a different date and **All Day** enabled. That is one continuous weekend occurrence followed by one separate all-day occurrence.
|
||||
|
||||
**Backward compatibility:** Existing `event_dates` rows continue to work. The plugin also reads the optional legacy **Event Date & Time** (`event_datetime`) value and older standalone `date`/`start_time` fields when no occurrence rows are present. Saving a legacy-only event in the editor creates one equivalent occurrence row. No bulk migration is required, and later saves do not add duplicate rows.
|
||||
|
||||
---
|
||||
|
||||
@@ -47,15 +63,11 @@ An approachable, theme‑friendly events plugin for WordPress. It adds an `Event
|
||||
|
||||
- Post type: `event`
|
||||
- Public, has archive (`/events`), supports title, editor, author, thumbnail.
|
||||
- Default sort:
|
||||
- Admin list: ascending by Event Date & Time.
|
||||
- Taxonomy views: ascending (upcoming first).
|
||||
- Main events archive: descending (newest first).
|
||||
- Taxonomies
|
||||
- `event_type` (hierarchical, slug `event-type`)
|
||||
- `event_tag` (non‑hierarchical, slug `event-tag`)
|
||||
- Admin UX
|
||||
- Adds a “Date & Time” column on Events list, sortable by the event date.
|
||||
- Adds a “Date & Time” column on Events list, showing the earliest normalized occurrence and a count of additional occurrences; sorting uses the earliest occurrence.
|
||||
|
||||
---
|
||||
|
||||
@@ -64,7 +76,13 @@ An approachable, theme‑friendly events plugin for WordPress. It adds an `Event
|
||||
- Archive: `/events` uses `templates/archive-event.php`.
|
||||
- Single: each event uses `templates/single-event.php`.
|
||||
- Calendar page: Page → Template → “Events Calendar” renders `templates/template-calendar.php` which includes `templates/calendar.php`.
|
||||
- Calendar navigation: query args `?month=MM&year=YYYY` change the visible month; “Today” jumps back to the current month.
|
||||
- Calendar navigation: query args `?aa_month=MM&aa_year=YYYY` change the visible month; “Today” jumps back to the current month. (`month`/`year` still work for legacy links.)
|
||||
- Archive and single templates show each occurrence as a formatted date or date/time range.
|
||||
- Single-event pages show a clickable Contact Email when a valid address is provided.
|
||||
- On desktop and tablet, the calendar is a traditional month grid. Multi-day occurrences render as bars spanning each week they cross, with the event title repeated on each weekly segment.
|
||||
- At `640px` wide and below, the calendar becomes a chronological agenda.
|
||||
- Occurrences that cross into the visible month are included even when they start before it or end after it.
|
||||
- Calendar dates use the site timezone and the week layout respects Settings → General → “Start of week”.
|
||||
|
||||
---
|
||||
|
||||
@@ -82,6 +100,8 @@ Example: to override the archive, create `your-theme/aa-events/archive-event.php
|
||||
|
||||
Page template in a theme: the “Events Calendar” page template also respects theme overrides at `your-theme/aa-events/template-calendar.php`.
|
||||
|
||||
An existing `your-theme/aa-events/calendar.php` remains authoritative. It does not automatically acquire the plugin template’s week-spanning bars or mobile agenda markup. Theme authors can update overrides using `aa_events_get_occurrences()`, `aa_events_format_occurrence()`, and `aa_events_occurrence_time_markup()`. A CSS override must also account for any new calendar and agenda markup it adopts.
|
||||
|
||||
---
|
||||
|
||||
## Styling
|
||||
@@ -95,6 +115,10 @@ Note: The bundled CSS is intentionally minimal. Use the override to match your t
|
||||
|
||||
## Developer Notes
|
||||
|
||||
- Occurrence helpers:
|
||||
- `aa_events_get_occurrences( $post_id )` returns chronologically sorted, normalized rows from the occurrence repeater or a legacy fallback. Rows include `source`, `start_inferred`, and `end_inferred`; the inference flags identify legacy-derived starts and defaulted ends.
|
||||
- `aa_events_format_occurrence( $occurrence )` returns the localized visible range label.
|
||||
- `aa_events_occurrence_time_markup( $occurrence )` returns escaped, accessible `<time>` markup for a normalized occurrence.
|
||||
- Template loader helpers:
|
||||
- `events_get_template( $template_name, $args = [], $template_path = 'aa-events/', $default_path = AA_EVENTS_PLUGIN_DIR . 'templates/' )`
|
||||
- `events_locate_template( $template_name, $template_path = 'aa-events/', $default_path = AA_EVENTS_PLUGIN_DIR . 'templates/' )`
|
||||
@@ -105,6 +129,19 @@ Note: The bundled CSS is intentionally minimal. Use the override to match your t
|
||||
- Page template registration (no theme file required):
|
||||
- Registered as “Events Calendar” and loads from the plugin when selected.
|
||||
|
||||
### Custom ACF Field Groups
|
||||
|
||||
When an admin-created ACF field group targets the `event` post type, the plugin skips its built-in field group so the site owner retains control. That custom group must include the new occurrence subfields for the editor to support the current schedule model:
|
||||
|
||||
- `event_dates`: Repeater.
|
||||
- `date`: required Date Picker subfield; return format `Y-m-d`.
|
||||
- `start_time`: optional Time Picker subfield; return format `H:i:s`.
|
||||
- `end_date`: optional Date Picker subfield; return format `Y-m-d`.
|
||||
- `end_time`: optional Time Picker subfield; return format `H:i:s`.
|
||||
- `all_day`: optional True/False subfield; expected value `1` or `0` (boolean-like).
|
||||
|
||||
Use these exact field names. Existing custom groups are not automatically expanded, so their owner must add `end_date`, `end_time`, and `all_day` as well as ensure the repeater and original subfields are present. Normalization, display, and range validation work by field name. The plugin validates any `event_dates` repeater whose subfield names match those above, including custom groups with generated field keys.
|
||||
|
||||
---
|
||||
|
||||
## Screenshots
|
||||
@@ -137,15 +174,18 @@ get_header(); ?>
|
||||
<div id="primary" class="content-area">
|
||||
<main id="main" class="site-main">
|
||||
<?php if ( have_posts() ) : ?>
|
||||
<header class="page-header"><h1>Upcoming Events</h1></header>
|
||||
<header class="page-header"><h1>Events</h1></header>
|
||||
<?php while ( have_posts() ) : the_post(); ?>
|
||||
<article <?php post_class('aa-event-item'); ?>>
|
||||
<h2 class="aa-event-title"><a href="<?php the_permalink(); ?>"><?php the_title(); ?></a></h2>
|
||||
<p class="aa-event-when">
|
||||
<strong>Date:</strong>
|
||||
<time datetime="<?php echo esc_attr( get_field('event_datetime') ); ?>">
|
||||
<?php echo esc_html( get_field('event_datetime') ); ?>
|
||||
</time>
|
||||
<?php
|
||||
foreach ( aa_events_get_occurrences( get_the_ID() ) as $occurrence ) {
|
||||
echo aa_events_occurrence_time_markup( $occurrence ); // Helper returns escaped markup.
|
||||
echo '<br>';
|
||||
}
|
||||
?>
|
||||
</p>
|
||||
<div class="aa-event-excerpt"><?php the_excerpt(); ?></div>
|
||||
</article>
|
||||
@@ -160,30 +200,38 @@ get_sidebar();
|
||||
get_footer();
|
||||
```
|
||||
|
||||
Example: tweak the event card at `your-theme/aa-events/single-event.php`:
|
||||
Example: override the single-event content at `your-theme/aa-events/single-event.php`:
|
||||
|
||||
```php
|
||||
<article <?php post_class('aa-event-item'); ?>>
|
||||
<h2 class="aa-event-title"><a href="<?php the_permalink(); ?>"><?php the_title(); ?></a></h2>
|
||||
<p class="aa-event-when">
|
||||
<strong>Date:</strong>
|
||||
<time datetime="<?php echo esc_attr( get_field('event_datetime') ); ?>">
|
||||
<?php echo esc_html( get_field('event_datetime') ); ?>
|
||||
</time>
|
||||
</p>
|
||||
<?php the_excerpt(); ?>
|
||||
<p><a class="button" href="<?php the_permalink(); ?>">View details</a></p>
|
||||
</article>
|
||||
<?php get_header(); ?>
|
||||
<main id="primary" class="site-main">
|
||||
<?php while ( have_posts() ) : the_post(); ?>
|
||||
<article <?php post_class('aa-event-item'); ?>>
|
||||
<h1 class="aa-event-title"><?php the_title(); ?></h1>
|
||||
<p class="aa-event-when">
|
||||
<strong>Date:</strong>
|
||||
<?php
|
||||
foreach ( aa_events_get_occurrences( get_the_ID() ) as $occurrence ) {
|
||||
echo aa_events_occurrence_time_markup( $occurrence ); // Helper returns escaped markup.
|
||||
echo '<br>';
|
||||
}
|
||||
?>
|
||||
</p>
|
||||
<div class="aa-event-content"><?php the_content(); ?></div>
|
||||
</article>
|
||||
<?php endwhile; ?>
|
||||
</main>
|
||||
<?php get_footer(); ?>
|
||||
```
|
||||
|
||||
Example: replace styles by adding `your-theme/aa-events/aa-events.css`:
|
||||
|
||||
```css
|
||||
/* Theme override for AA Events */
|
||||
.aa-events-calendar th,
|
||||
.aa-events-calendar td { border-color: #ddd; }
|
||||
.aa-events-calendar .today-cell { background: #e6f7ff; border-color: #1890ff; }
|
||||
.aa-event-title { font-size: 1.25rem; margin: .25rem 0 .5rem; }
|
||||
.aa-events-calendar-weekday,
|
||||
.aa-events-calendar-day { border-color: #ddd; }
|
||||
.aa-events-calendar-day.today-cell { background: #e6f7ff; border-color: #1890ff; }
|
||||
.aa-events-calendar-event { font-size: 1rem; }
|
||||
```
|
||||
|
||||
---
|
||||
@@ -193,7 +241,7 @@ Example: replace styles by adding `your-theme/aa-events/aa-events.css`:
|
||||
- The “Events Calendar” template doesn’t show up?
|
||||
- Ensure the plugin is active. The template is registered by the plugin and appears in the Page Template dropdown. If you still don’t see it, check the Page sidebar and expand the Template panel.
|
||||
- Calendar shows no events?
|
||||
- Make sure you created Event posts and set the “Event Date & Time”. The calendar shows events within the selected month using site timezone.
|
||||
- Make sure you created published Event posts and added at least one valid **Occurrences** row with a Date. Older events can also appear through the optional legacy **Event Date & Time** (`event_datetime`) or standalone date fallback. The calendar includes occurrences overlapping the selected month and uses the site timezone.
|
||||
- Can I use only the archive without the calendar?
|
||||
- Yes. The archive at `/events` works independently of the calendar page template.
|
||||
|
||||
|
||||
+5
-2
@@ -3,7 +3,7 @@
|
||||
* Plugin Name: AA Events
|
||||
* Plugin URI: https://github.com/automattic/aa-events
|
||||
* Description: An Events management plugin for WordPress, with accessible calendar (WCAG 2.2AA) and card grid views.
|
||||
* Version: 1.0.0
|
||||
* Version: 1.2.0
|
||||
* Author: Keith Solomon
|
||||
* Author URI: https://vincentdesign.ca
|
||||
* License: GPL-2.0+
|
||||
@@ -26,6 +26,9 @@ function activate_aa_events() {
|
||||
events_register_post_type();
|
||||
events_register_event_type_taxonomy();
|
||||
events_register_event_tag_taxonomy();
|
||||
if ( function_exists( 'aa_events_schedule_occurrence_index_backfill' ) ) {
|
||||
aa_events_schedule_occurrence_index_backfill();
|
||||
}
|
||||
|
||||
// Flush rewrite rules.
|
||||
flush_rewrite_rules();
|
||||
@@ -46,7 +49,7 @@ register_deactivation_hook( __FILE__, 'deactivate_aa_events' );
|
||||
* Define Constants
|
||||
*/
|
||||
define( 'AA_EVENTS_PLUGIN_FILE', __FILE__ );
|
||||
define( 'AA_EVENTS_VERSION', '1.0.0' );
|
||||
define( 'AA_EVENTS_VERSION', '1.2.0' );
|
||||
define( 'AA_EVENTS_PLUGIN_DIR', plugin_dir_path( __FILE__ ) );
|
||||
define( 'AA_EVENTS_PLUGIN_URL', plugin_dir_url( __FILE__ ) );
|
||||
|
||||
|
||||
+168
-32
@@ -13,6 +13,8 @@
|
||||
.aa-events-calendar-header {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.625rem;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 0.625rem;
|
||||
}
|
||||
@@ -26,46 +28,129 @@
|
||||
padding: 0.5rem 1rem;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover { background: #eee; }
|
||||
}
|
||||
|
||||
.aa-events-calendar {
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed;
|
||||
width: 100%;
|
||||
.aa-events-calendar-today-btn:hover,
|
||||
.aa-events-calendar-today-btn:focus-visible {
|
||||
background: #eee;
|
||||
}
|
||||
|
||||
th, td {
|
||||
border: 1px solid #aaa;
|
||||
padding: 0.625rem;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
.aa-events-calendar-grid {
|
||||
border-left: 1px solid #aaa;
|
||||
border-top: 1px solid #aaa;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
th {
|
||||
background-color: #f5f5f5;
|
||||
text-align: center;
|
||||
}
|
||||
.aa-events-calendar-weekdays {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
td { height: 9.375rem; }
|
||||
.aa-events-calendar-weekday {
|
||||
background: #f5f5f5;
|
||||
border-bottom: 1px solid #aaa;
|
||||
border-right: 1px solid #aaa;
|
||||
font-weight: bold;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
padding: 0.625rem 0.25rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.day-number {
|
||||
font-weight: bold;
|
||||
margin-bottom: 0.3125rem;
|
||||
}
|
||||
.aa-events-calendar-weekday abbr {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.today-cell {
|
||||
background: #ffe9b3;
|
||||
border: 2px solid #f7b500;
|
||||
font-weight: bold;
|
||||
}
|
||||
.aa-events-calendar-week {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, minmax(0, 1fr));
|
||||
min-height: calc(7.5rem + (var(--aa-event-lanes, 1) * 1.75rem));
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.events-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
.aa-events-calendar-day {
|
||||
border-bottom: 1px solid #aaa;
|
||||
border-right: 1px solid #aaa;
|
||||
min-width: 0;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
li { margin-bottom: 0.3125rem; }
|
||||
}
|
||||
.aa-events-calendar-day .day-number {
|
||||
display: block;
|
||||
font-weight: bold;
|
||||
margin-bottom: 0.3125rem;
|
||||
}
|
||||
|
||||
.aa-events-calendar-day.is-outside-month {
|
||||
background: #f7f7f7;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.aa-events-calendar-day.today-cell {
|
||||
background: #fff3cf;
|
||||
box-shadow: inset 0 0 0 2px #b57900;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.aa-events-calendar-event {
|
||||
align-items: center;
|
||||
background: #245b87;
|
||||
border-radius: 0.25rem;
|
||||
box-sizing: border-box;
|
||||
color: #fff;
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
height: 1.5rem;
|
||||
left: calc(((100% / 7) * var(--aa-event-column)) + 0.2rem);
|
||||
line-height: 1.5rem;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
padding: 0 0.375rem;
|
||||
position: absolute;
|
||||
text-decoration: none;
|
||||
top: calc(2.25rem + (var(--aa-event-lane) * 1.75rem));
|
||||
white-space: nowrap;
|
||||
width: calc(((100% / 7) * var(--aa-event-span)) - 0.4rem);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.aa-events-calendar-event > span:not(.aa-events-calendar-continuation) {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.aa-events-calendar-event.is-continuing-before {
|
||||
border-bottom-left-radius: 0;
|
||||
border-top-left-radius: 0;
|
||||
}
|
||||
|
||||
.aa-events-calendar-event.is-continuing-after {
|
||||
border-bottom-right-radius: 0;
|
||||
border-top-right-radius: 0;
|
||||
}
|
||||
|
||||
.aa-events-calendar-continuation {
|
||||
flex: 0 0 auto;
|
||||
font-weight: bold;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.aa-events-calendar-event:hover {
|
||||
background: #163d5d;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.aa-events-calendar-event:focus-visible {
|
||||
background: #163d5d;
|
||||
outline: 3px solid #111;
|
||||
outline-offset: 2px;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.aa-events-calendar-agenda {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.aa-event-item .aa-event-details {
|
||||
@@ -89,7 +174,58 @@
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.aa-events-wrap .aa-events-grid { grid-template-columns: 1fr; }
|
||||
.aa-events-wrap .aa-events-grid { grid-template-columns: 1fr; }
|
||||
|
||||
.aa-events-calendar-desktop { display: none; }
|
||||
|
||||
.aa-events-calendar-agenda { display: block; }
|
||||
|
||||
.aa-events-calendar-agenda-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.aa-events-agenda-item + .aa-events-agenda-item { margin-top: 0.75rem; }
|
||||
|
||||
.aa-events-agenda-item > a {
|
||||
border: 1px solid #aaa;
|
||||
border-left: 0.25rem solid #245b87;
|
||||
border-radius: 0.25rem;
|
||||
display: block;
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
padding: 0.75rem;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.aa-events-agenda-item > a:hover { background: #f5f5f5; }
|
||||
|
||||
.aa-events-agenda-item > a:focus-visible {
|
||||
outline: 3px solid currentColor;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.aa-events-agenda-title,
|
||||
.aa-events-agenda-item time {
|
||||
display: block;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.aa-events-agenda-title { font-weight: bold; }
|
||||
|
||||
.aa-events-agenda-item .screen-reader-text {
|
||||
border: 0;
|
||||
clip: rect(1px, 1px, 1px, 1px);
|
||||
clip-path: inset(50%);
|
||||
height: 1px;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
word-wrap: normal !important;
|
||||
}
|
||||
}
|
||||
|
||||
.aa-events-wrap .entry-header:has(.entry-thumbnail),
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
(function($) {
|
||||
'use strict';
|
||||
|
||||
// Guard against a jQuery UI datepicker crash in the block editor meta box area.
|
||||
// In some contexts, _findPos receives a node with no positioned parent and throws.
|
||||
if ($.datepicker && $.datepicker._findPos) {
|
||||
var originalFindPos = $.datepicker._findPos;
|
||||
$.datepicker._findPos = function(obj) {
|
||||
try {
|
||||
if (!obj || !obj.ownerDocument || !obj.ownerDocument.documentElement) {
|
||||
return [0, 0];
|
||||
}
|
||||
return originalFindPos.call(this, obj);
|
||||
} catch (e) {
|
||||
return [0, 0];
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// After repeater rows are appended, remove accidental focus from date inputs
|
||||
// so datepicker doesn't try to open against a transient/hidden element.
|
||||
if (window.acf && window.acf.addAction) {
|
||||
window.acf.addAction('append', function($el) {
|
||||
var $dateInputs = $el.find('.acf-date-picker input.hasDatepicker');
|
||||
if ($dateInputs.length) {
|
||||
setTimeout(function() {
|
||||
$dateInputs.first().trigger('blur');
|
||||
}, 0);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function syncAllDayRows($scope) {
|
||||
var $repeaters = $scope.find('.acf-field[data-name="event_dates"]')
|
||||
.addBack('.acf-field[data-name="event_dates"]')
|
||||
.add($scope.closest('.acf-field[data-name="event_dates"]'));
|
||||
|
||||
$repeaters.find('.acf-row').each(function() {
|
||||
var $row = $(this);
|
||||
var checked = $row.find('.acf-field[data-name="all_day"] input[type="checkbox"]').first().is(':checked');
|
||||
var $timeFields = $row.find('.acf-field[data-name="start_time"], .acf-field[data-name="end_time"]');
|
||||
$row.toggleClass('aa-events-is-all-day', checked);
|
||||
$timeFields.toggle(!checked).attr('aria-hidden', checked ? 'true' : 'false');
|
||||
$timeFields.find('input').prop('disabled', checked);
|
||||
});
|
||||
}
|
||||
|
||||
$(document).on('change', '.acf-field[data-name="event_dates"] .acf-field[data-name="all_day"] input[type="checkbox"]', function() {
|
||||
var $checkbox = $(this);
|
||||
var $row = $checkbox.closest('.acf-row');
|
||||
if ($checkbox.is(':checked')) {
|
||||
$row.find('.acf-field[data-name="start_time"] input, .acf-field[data-name="end_time"] input').val('');
|
||||
}
|
||||
syncAllDayRows($row.closest('.acf-field[data-name="event_dates"]'));
|
||||
});
|
||||
|
||||
$(function() {
|
||||
syncAllDayRows($(document));
|
||||
});
|
||||
|
||||
if (window.acf && window.acf.addAction) {
|
||||
window.acf.addAction('ready', function($el) {
|
||||
syncAllDayRows($el || $(document));
|
||||
});
|
||||
window.acf.addAction('append', function($el) {
|
||||
syncAllDayRows($el);
|
||||
});
|
||||
}
|
||||
})(jQuery);
|
||||
+185
-2
@@ -9,10 +9,74 @@ if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit; // Exit if accessed directly.
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether an ACF field group created in the admin targets the event post type.
|
||||
*
|
||||
* @return bool True if an external field group is assigned to events.
|
||||
*/
|
||||
function events_has_admin_field_group() {
|
||||
if ( ! function_exists( 'acf_get_field_groups' ) ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$groups = acf_get_field_groups( array( 'post_type' => 'event' ) );
|
||||
|
||||
foreach ( $groups as $group ) {
|
||||
if ( isset( $group['active'] ) && ! $group['active'] ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
isset( $group['key'], $group['local'] )
|
||||
&& 'group_61b0c5f5a3e7e' === $group['key']
|
||||
&& 'php' === $group['local']
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$targets_events = false;
|
||||
foreach ( $group['location'] ?? array() as $location_group ) {
|
||||
foreach ( $location_group as $rule ) {
|
||||
if (
|
||||
isset( $rule['param'], $rule['operator'], $rule['value'] )
|
||||
&& 'post_type' === $rule['param']
|
||||
&& '==' === $rule['operator']
|
||||
&& 'event' === $rule['value']
|
||||
) {
|
||||
$targets_events = true;
|
||||
break 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ( ! $targets_events || ! function_exists( 'acf_get_fields' ) ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ( (array) acf_get_fields( $group ) as $field ) {
|
||||
if (
|
||||
isset( $field['name'] )
|
||||
&& in_array( $field['name'], array( 'event_datetime', 'event_dates', 'multiple_dates', 'date' ), true )
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the ACF field group.
|
||||
*
|
||||
* Skips registration when an admin-created field group is already
|
||||
* assigned to the event post type, allowing full editorial control.
|
||||
*/
|
||||
function events_register_acf_fields() {
|
||||
if ( events_has_admin_field_group() ) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ( function_exists( 'acf_add_local_field_group' ) ) :
|
||||
|
||||
acf_add_local_field_group(
|
||||
@@ -25,8 +89,8 @@ function events_register_acf_fields() {
|
||||
'label' => 'Event Date & Time',
|
||||
'name' => 'event_datetime',
|
||||
'type' => 'date_time_picker',
|
||||
'instructions' => '',
|
||||
'required' => 1,
|
||||
'instructions' => 'Legacy compatibility field. Use Occurrences for new events.',
|
||||
'required' => 0,
|
||||
'conditional_logic' => 0,
|
||||
'wrapper' => array(
|
||||
'width' => '',
|
||||
@@ -56,6 +120,109 @@ function events_register_acf_fields() {
|
||||
'append' => '',
|
||||
'maxlength' => '',
|
||||
),
|
||||
array(
|
||||
'key' => 'field_61b0c7f0a3e8f',
|
||||
'label' => 'Occurrences',
|
||||
'name' => 'event_dates',
|
||||
'type' => 'repeater',
|
||||
'instructions' => 'Each row is one occurrence. Add an end date for a continuous multi-day event; add more rows when the event recurs.',
|
||||
'required' => 0,
|
||||
'conditional_logic' => 0,
|
||||
'wrapper' => array(
|
||||
'width' => '',
|
||||
'class' => '',
|
||||
'id' => '',
|
||||
),
|
||||
'collapsed' => '',
|
||||
'min' => 0,
|
||||
'max' => 0,
|
||||
'layout' => 'row',
|
||||
'button_label' => 'Add Occurrence',
|
||||
'sub_fields' => array(
|
||||
array(
|
||||
'key' => 'field_61b0c7f1a3e90',
|
||||
'label' => 'Date',
|
||||
'name' => 'date',
|
||||
'type' => 'date_picker',
|
||||
'instructions' => '',
|
||||
'required' => 1,
|
||||
'conditional_logic' => 0,
|
||||
'wrapper' => array(
|
||||
'width' => '25',
|
||||
'class' => '',
|
||||
'id' => '',
|
||||
),
|
||||
'display_format' => 'F j, Y',
|
||||
'return_format' => 'Y-m-d',
|
||||
'first_day' => 1,
|
||||
),
|
||||
array(
|
||||
'key' => 'field_61b0c7f2a3e91',
|
||||
'label' => 'Start Time',
|
||||
'name' => 'start_time',
|
||||
'type' => 'time_picker',
|
||||
'instructions' => '',
|
||||
'required' => 0,
|
||||
'conditional_logic' => 0,
|
||||
'wrapper' => array(
|
||||
'width' => '20',
|
||||
'class' => '',
|
||||
'id' => '',
|
||||
),
|
||||
'display_format' => 'g:i a',
|
||||
'return_format' => 'H:i:s',
|
||||
),
|
||||
array(
|
||||
'key' => 'field_aa_events_end_date',
|
||||
'label' => 'End Date',
|
||||
'name' => 'end_date',
|
||||
'type' => 'date_picker',
|
||||
'instructions' => '',
|
||||
'required' => 0,
|
||||
'conditional_logic' => 0,
|
||||
'wrapper' => array(
|
||||
'width' => '25',
|
||||
'class' => '',
|
||||
'id' => '',
|
||||
),
|
||||
'display_format' => 'F j, Y',
|
||||
'return_format' => 'Y-m-d',
|
||||
'first_day' => 1,
|
||||
),
|
||||
array(
|
||||
'key' => 'field_aa_events_end_time',
|
||||
'label' => 'End Time',
|
||||
'name' => 'end_time',
|
||||
'type' => 'time_picker',
|
||||
'instructions' => '',
|
||||
'required' => 0,
|
||||
'conditional_logic' => 0,
|
||||
'wrapper' => array(
|
||||
'width' => '20',
|
||||
'class' => '',
|
||||
'id' => '',
|
||||
),
|
||||
'display_format' => 'g:i a',
|
||||
'return_format' => 'H:i:s',
|
||||
),
|
||||
array(
|
||||
'key' => 'field_aa_events_all_day',
|
||||
'label' => 'All Day',
|
||||
'name' => 'all_day',
|
||||
'type' => 'true_false',
|
||||
'instructions' => '',
|
||||
'required' => 0,
|
||||
'conditional_logic' => 0,
|
||||
'wrapper' => array(
|
||||
'width' => '10',
|
||||
'class' => '',
|
||||
'id' => '',
|
||||
),
|
||||
'default_value' => 0,
|
||||
'ui' => 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
array(
|
||||
'key' => 'field_61b0c6cba3e83',
|
||||
'label' => 'Online/In-Person',
|
||||
@@ -126,6 +293,22 @@ function events_register_acf_fields() {
|
||||
'rows' => '',
|
||||
'new_lines' => '',
|
||||
),
|
||||
array(
|
||||
'key' => 'field_aa_events_contact_email',
|
||||
'label' => 'Contact Email',
|
||||
'name' => 'event_contact_email',
|
||||
'type' => 'email',
|
||||
'instructions' => '',
|
||||
'required' => 0,
|
||||
'conditional_logic' => 0,
|
||||
'wrapper' => array(
|
||||
'width' => '',
|
||||
'class' => '',
|
||||
'id' => '',
|
||||
),
|
||||
'default_value' => '',
|
||||
'placeholder' => '',
|
||||
),
|
||||
),
|
||||
'location' => array(
|
||||
array(
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
<?php
|
||||
/**
|
||||
* Pure calendar layout helpers.
|
||||
*
|
||||
* @package AA_Events
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the complete week grid for one month.
|
||||
*
|
||||
* @param mixed $year Four-digit year.
|
||||
* @param mixed $month Month number.
|
||||
* @param mixed $start_of_week WordPress weekday number (0 Sunday through 6 Saturday).
|
||||
* @return array
|
||||
* @throws InvalidArgumentException For invalid arguments.
|
||||
*/
|
||||
function aa_events_calendar_month( $year, $month, $start_of_week ) {
|
||||
if ( ! is_int( $year ) || ! is_int( $month ) || ! is_int( $start_of_week ) ) {
|
||||
throw new InvalidArgumentException( 'Calendar year, month, and start of week must be integers.' );
|
||||
}
|
||||
if ( $year < 1 || $year > 9999 || $month < 1 || $month > 12 || $start_of_week < 0 || $start_of_week > 6 ) {
|
||||
throw new InvalidArgumentException( 'Invalid calendar year, month, or start of week.' );
|
||||
}
|
||||
|
||||
$month_value = sprintf( '%04d-%02d-01', $year, $month );
|
||||
$month_start = DateTimeImmutable::createFromFormat( '!Y-m-d', $month_value, wp_timezone() );
|
||||
$errors = DateTimeImmutable::getLastErrors();
|
||||
if ( false === $month_start || ( is_array( $errors ) && ( $errors['warning_count'] || $errors['error_count'] ) ) || $month_start->format( 'Y-m-d' ) !== $month_value ) {
|
||||
throw new InvalidArgumentException( 'Invalid calendar month.' );
|
||||
}
|
||||
$month_end = $month_start->modify( 'last day of this month' );
|
||||
$leading_days = ( (int) $month_start->format( 'w' ) - $start_of_week + 7 ) % 7;
|
||||
$grid_start = $month_start->modify( '-' . $leading_days . ' days' );
|
||||
$trailing_days = ( $start_of_week + 6 - (int) $month_end->format( 'w' ) + 7 ) % 7;
|
||||
$grid_end = $month_end->modify( '+' . $trailing_days . ' days' );
|
||||
$weeks = array();
|
||||
$week_start = $grid_start;
|
||||
$week_index = 0;
|
||||
while ( $week_start <= $grid_end ) {
|
||||
$days = array();
|
||||
for ( $day = 0; $day < 7; ++$day ) {
|
||||
$days[] = $week_start->modify( '+' . $day . ' days' );
|
||||
}
|
||||
$weeks[] = array(
|
||||
'index' => $week_index,
|
||||
'key' => $week_start->format( 'Y-m-d' ),
|
||||
'start' => $week_start,
|
||||
'end' => $week_start->modify( '+6 days' ),
|
||||
'days' => $days,
|
||||
);
|
||||
$week_start = $week_start->modify( '+7 days' );
|
||||
++$week_index;
|
||||
}
|
||||
|
||||
return compact( 'month_start', 'month_end', 'grid_start', 'grid_end', 'start_of_week', 'weeks' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether an occurrence overlaps an inclusive date range.
|
||||
*
|
||||
* @param array $occurrence Occurrence metadata.
|
||||
* @param DateTimeImmutable $start Range start.
|
||||
* @param DateTimeImmutable $end Range end.
|
||||
* @return bool
|
||||
*/
|
||||
function aa_events_occurrence_intersects_range( array $occurrence, DateTimeImmutable $start, DateTimeImmutable $end ) {
|
||||
$parse_date = function ( $value ) {
|
||||
if ( ! is_string( $value ) ) {
|
||||
return null;
|
||||
}
|
||||
$date = DateTimeImmutable::createFromFormat( '!Y-m-d', $value, wp_timezone() );
|
||||
$errors = DateTimeImmutable::getLastErrors();
|
||||
return false !== $date && ( ! is_array( $errors ) || ( ! $errors['warning_count'] && ! $errors['error_count'] ) ) && $date->format( 'Y-m-d' ) === $value ? $date : null;
|
||||
};
|
||||
$occurrence_start = $parse_date( $occurrence['start_date'] ?? null );
|
||||
$occurrence_end = $parse_date( $occurrence['end_date'] ?? ( $occurrence['start_date'] ?? null ) );
|
||||
return null !== $occurrence_start && null !== $occurrence_end && $occurrence_end >= $occurrence_start && $end >= $start && $occurrence_start <= $end && $occurrence_end >= $start;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clip an occurrence to a month and split it at week boundaries.
|
||||
*
|
||||
* @param array $occurrence Occurrence metadata.
|
||||
* @param array $month Calendar month shape.
|
||||
* @return array
|
||||
*/
|
||||
function aa_events_calendar_segments( array $occurrence, array $month ) {
|
||||
if ( ! isset( $month['month_start'], $month['month_end'], $month['weeks'] ) || ! $month['month_start'] instanceof DateTimeImmutable || ! $month['month_end'] instanceof DateTimeImmutable || ! aa_events_occurrence_intersects_range( $occurrence, $month['month_start'], $month['month_end'] ) ) {
|
||||
return array();
|
||||
}
|
||||
$parse_date = function ( $value ) {
|
||||
if ( ! is_string( $value ) ) {
|
||||
return null;
|
||||
}
|
||||
$date = DateTimeImmutable::createFromFormat( '!Y-m-d', $value, wp_timezone() );
|
||||
$errors = DateTimeImmutable::getLastErrors();
|
||||
return false !== $date && ( ! is_array( $errors ) || ( ! $errors['warning_count'] && ! $errors['error_count'] ) ) && $date->format( 'Y-m-d' ) === $value ? $date : null;
|
||||
};
|
||||
$true_start = $parse_date( $occurrence['start_date'] );
|
||||
$true_end = $parse_date( $occurrence['end_date'] ?? $occurrence['start_date'] );
|
||||
$visible_start = max( $true_start, $month['month_start'] );
|
||||
$visible_end = min( $true_end, $month['month_end'] );
|
||||
$segments = array();
|
||||
|
||||
foreach ( $month['weeks'] as $week_index => $week ) {
|
||||
if ( $visible_start > $week['end'] || $visible_end < $week['start'] ) {
|
||||
continue;
|
||||
}
|
||||
$segment_start = max( $visible_start, $week['start'] );
|
||||
$segment_end = min( $visible_end, $week['end'] );
|
||||
$segment = $occurrence;
|
||||
$segment['week_index'] = (int) $week_index;
|
||||
$segment['week_key'] = $week['key'] ?? $week['start']->format( 'Y-m-d' );
|
||||
$segment['segment_start'] = $segment_start;
|
||||
$segment['segment_end'] = $segment_end;
|
||||
$segment['start_column'] = (int) $week['start']->diff( $segment_start )->days;
|
||||
$segment['span'] = (int) $segment_start->diff( $segment_end )->days + 1;
|
||||
$segment['continues_before'] = $true_start < $segment_start;
|
||||
$segment['continues_after'] = $true_end > $segment_end;
|
||||
$segments[] = $segment;
|
||||
}
|
||||
return $segments;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort segments and assign the first non-overlapping lane.
|
||||
*
|
||||
* @param array $segments Week segments.
|
||||
* @return array
|
||||
*/
|
||||
function aa_events_assign_segment_lanes( array $segments ) {
|
||||
$decorated = array();
|
||||
foreach ( $segments as $index => $segment ) {
|
||||
$decorated[] = array(
|
||||
'segment' => $segment,
|
||||
'order' => $index,
|
||||
);
|
||||
}
|
||||
usort(
|
||||
$decorated,
|
||||
function ( $left, $right ) {
|
||||
$comparison = (int) ( $left['segment']['start_column'] ?? 0 ) <=> (int) ( $right['segment']['start_column'] ?? 0 );
|
||||
if ( 0 === $comparison ) {
|
||||
$comparison = (int) ( $right['segment']['span'] ?? 0 ) <=> (int) ( $left['segment']['span'] ?? 0 );
|
||||
}
|
||||
if ( 0 === $comparison ) {
|
||||
$comparison = (int) ( $left['segment']['post_id'] ?? 0 ) <=> (int) ( $right['segment']['post_id'] ?? 0 );
|
||||
}
|
||||
return 0 === $comparison ? $left['order'] <=> $right['order'] : $comparison;
|
||||
}
|
||||
);
|
||||
|
||||
$lane_ends = array();
|
||||
$segments = array();
|
||||
foreach ( $decorated as $item ) {
|
||||
$segment = $item['segment'];
|
||||
$start = (int) ( $segment['start_column'] ?? 0 );
|
||||
$end = $start + max( 1, (int) ( $segment['span'] ?? 1 ) ) - 1;
|
||||
$lane = 0;
|
||||
while ( isset( $lane_ends[ $lane ] ) && $lane_ends[ $lane ] >= $start ) {
|
||||
++$lane;
|
||||
}
|
||||
$segment['lane'] = $lane;
|
||||
$lane_ends[ $lane ] = $end;
|
||||
$segments[] = $segment;
|
||||
}
|
||||
return $segments;
|
||||
}
|
||||
|
||||
/**
|
||||
* Group all occurrence segments into the month's complete week list.
|
||||
*
|
||||
* @param array $occurrences Occurrences to lay out.
|
||||
* @param array $month Calendar month shape.
|
||||
* @return array
|
||||
*/
|
||||
function aa_events_group_calendar_weeks( array $occurrences, array $month ) {
|
||||
$weeks = array();
|
||||
foreach ( $month['weeks'] ?? array() as $index => $week ) {
|
||||
$week['segments'] = array();
|
||||
$week['lane_count'] = 0;
|
||||
$weeks[ $index ] = $week;
|
||||
}
|
||||
foreach ( $occurrences as $occurrence ) {
|
||||
if ( ! is_array( $occurrence ) ) {
|
||||
continue;
|
||||
}
|
||||
foreach ( aa_events_calendar_segments( $occurrence, $month ) as $segment ) {
|
||||
if ( isset( $weeks[ $segment['week_index'] ] ) ) {
|
||||
$weeks[ $segment['week_index'] ]['segments'][] = $segment;
|
||||
}
|
||||
}
|
||||
}
|
||||
foreach ( $weeks as &$week ) {
|
||||
$week['segments'] = aa_events_assign_segment_lanes( $week['segments'] );
|
||||
if ( $week['segments'] ) {
|
||||
$week['lane_count'] = max( array_column( $week['segments'], 'lane' ) ) + 1;
|
||||
}
|
||||
}
|
||||
unset( $week );
|
||||
return array_values( $weeks );
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort calendar occurrences for the mobile agenda.
|
||||
*
|
||||
* @param array $occurrences Enriched normalized occurrences.
|
||||
* @return array
|
||||
*/
|
||||
function aa_events_sort_calendar_occurrences( array $occurrences ) {
|
||||
$decorated = array();
|
||||
foreach ( $occurrences as $index => $occurrence ) {
|
||||
if ( ! is_array( $occurrence ) ) {
|
||||
continue;
|
||||
}
|
||||
$decorated[] = array(
|
||||
'occurrence' => $occurrence,
|
||||
'index' => $index,
|
||||
);
|
||||
}
|
||||
|
||||
usort(
|
||||
$decorated,
|
||||
function ( $left, $right ) {
|
||||
$left_occurrence = $left['occurrence'];
|
||||
$right_occurrence = $right['occurrence'];
|
||||
$comparison = aa_events_occurrence_start( $left_occurrence )->getTimestamp() <=> aa_events_occurrence_start( $right_occurrence )->getTimestamp();
|
||||
if ( 0 === $comparison ) {
|
||||
$comparison = strcasecmp( (string) ( $left_occurrence['title'] ?? '' ), (string) ( $right_occurrence['title'] ?? '' ) );
|
||||
}
|
||||
if ( 0 === $comparison ) {
|
||||
$comparison = (int) ( $left_occurrence['post_id'] ?? 0 ) <=> (int) ( $right_occurrence['post_id'] ?? 0 );
|
||||
}
|
||||
foreach ( array( 'end_date', 'end_time' ) as $key ) {
|
||||
if ( 0 !== $comparison ) {
|
||||
break;
|
||||
}
|
||||
$comparison = strcmp( (string) ( $left_occurrence[ $key ] ?? '' ), (string) ( $right_occurrence[ $key ] ?? '' ) );
|
||||
}
|
||||
return 0 === $comparison ? $left['index'] <=> $right['index'] : $comparison;
|
||||
}
|
||||
);
|
||||
|
||||
return array_column( $decorated, 'occurrence' );
|
||||
}
|
||||
@@ -50,6 +50,8 @@ final class AA_Events {
|
||||
* Include required core files.
|
||||
*/
|
||||
public function includes() {
|
||||
include_once AA_EVENTS_PLUGIN_DIR . 'includes/occurrences.php';
|
||||
include_once AA_EVENTS_PLUGIN_DIR . 'includes/calendar-layout.php';
|
||||
include_once AA_EVENTS_PLUGIN_DIR . 'includes/post-types.php';
|
||||
include_once AA_EVENTS_PLUGIN_DIR . 'includes/acf-fields.php';
|
||||
include_once AA_EVENTS_PLUGIN_DIR . 'includes/template-loader.php';
|
||||
@@ -61,8 +63,10 @@ final class AA_Events {
|
||||
private function init_hooks() {
|
||||
add_action( 'init', array( $this, 'load_plugin_textdomain' ) );
|
||||
add_action( 'wp_enqueue_scripts', array( $this, 'enqueue_styles' ) );
|
||||
add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_admin_assets' ) );
|
||||
// Ensure the active theme supports thumbnails for the event post type.
|
||||
add_action( 'after_setup_theme', array( $this, 'ensure_post_thumbnails_support' ) );
|
||||
add_filter( 'query_vars', array( $this, 'register_calendar_query_vars' ) );
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -88,6 +92,31 @@ final class AA_Events {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enqueue admin assets for event editor UX fixes.
|
||||
*
|
||||
* @param string $hook_suffix Current admin page hook.
|
||||
* @return void
|
||||
*/
|
||||
public function enqueue_admin_assets( $hook_suffix ) {
|
||||
if ( 'post.php' !== $hook_suffix && 'post-new.php' !== $hook_suffix ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$screen = get_current_screen();
|
||||
if ( ! $screen || 'event' !== $screen->post_type ) {
|
||||
return;
|
||||
}
|
||||
|
||||
wp_enqueue_script(
|
||||
'aa-events-admin',
|
||||
AA_EVENTS_PLUGIN_URL . 'assets/js/aa-events-admin.js',
|
||||
array( 'jquery' ),
|
||||
AA_EVENTS_VERSION,
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load Localisation files.
|
||||
*/
|
||||
@@ -125,4 +154,17 @@ final class AA_Events {
|
||||
}
|
||||
// If theme support is boolean true (all types), nothing to do.
|
||||
}
|
||||
|
||||
/**
|
||||
* Allow custom AA Events calendar query vars to persist through canonical redirects.
|
||||
*
|
||||
* @param array $vars Public query vars.
|
||||
* @return array
|
||||
*/
|
||||
public function register_calendar_query_vars( $vars ) {
|
||||
$vars[] = 'aa_month';
|
||||
$vars[] = 'aa_year';
|
||||
|
||||
return $vars;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,755 @@
|
||||
<?php
|
||||
/**
|
||||
* Event occurrence normalization, validation, and display helpers.
|
||||
*
|
||||
* @package AA_Events
|
||||
*/
|
||||
|
||||
/**
|
||||
* Safely convert scalar or stringable field input to a trimmed string.
|
||||
*
|
||||
* @param mixed $value Raw field value.
|
||||
* @return string|null
|
||||
*/
|
||||
function aa_events_occurrence_string( $value ) {
|
||||
if ( is_scalar( $value ) ) {
|
||||
return trim( (string) $value );
|
||||
}
|
||||
if ( is_object( $value ) && method_exists( $value, '__toString' ) ) {
|
||||
try {
|
||||
return trim( (string) $value );
|
||||
} catch ( Throwable $error ) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strictly parse and normalize a date or time value.
|
||||
*
|
||||
* @param mixed $value Value to parse.
|
||||
* @param string $format Input format.
|
||||
* @param string $output Output format.
|
||||
* @return string|null
|
||||
*/
|
||||
function aa_events_parse_occurrence_value( $value, $format, $output ) {
|
||||
$value = aa_events_occurrence_string( $value );
|
||||
if ( null === $value || '' === $value ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$date = DateTimeImmutable::createFromFormat( '!' . $format, $value, wp_timezone() );
|
||||
$errors = DateTimeImmutable::getLastErrors();
|
||||
if ( false === $date || ( is_array( $errors ) && ( $errors['warning_count'] || $errors['error_count'] ) ) || $date->format( $format ) !== $value ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $date->format( $output );
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize an ACF-style boolean without treating the string "false" as true.
|
||||
*
|
||||
* @param mixed $value Raw value.
|
||||
* @return bool
|
||||
*/
|
||||
function aa_events_occurrence_boolean( $value ) {
|
||||
$value = aa_events_occurrence_string( $value );
|
||||
return null !== $value && in_array( strtolower( $value ), array( '1', 'true', 'yes', 'on' ), true );
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a time with or without seconds.
|
||||
*
|
||||
* @param mixed $value Raw time.
|
||||
* @return string|null
|
||||
*/
|
||||
function aa_events_parse_occurrence_time( $value ) {
|
||||
$time = aa_events_parse_occurrence_value( $value, 'H:i:s', 'H:i:s' );
|
||||
return null === $time ? aa_events_parse_occurrence_value( $value, 'H:i', 'H:i:s' ) : $time;
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirm a normalized time exists on its local calendar date.
|
||||
*
|
||||
* PHP deterministically selects an offset for ambiguous fall-back wall times;
|
||||
* exact round-trip checking rejects only nonexistent spring-forward wall times.
|
||||
*
|
||||
* @param string $date Date in Y-m-d format.
|
||||
* @param string $time Time in H:i:s format.
|
||||
* @return bool
|
||||
*/
|
||||
function aa_events_occurrence_local_time_is_valid( $date, $time ) {
|
||||
$value = $date . ' ' . $time;
|
||||
$parsed = DateTimeImmutable::createFromFormat( '!Y-m-d H:i:s', $value, wp_timezone() );
|
||||
$errors = DateTimeImmutable::getLastErrors();
|
||||
return false !== $parsed && ( ! is_array( $errors ) || ( ! $errors['warning_count'] && ! $errors['error_count'] ) ) && $parsed->format( 'Y-m-d H:i:s' ) === $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate already-parsed occurrence values without calling public validation.
|
||||
*
|
||||
* @param string $start_date Start date.
|
||||
* @param string $start_time Optional start time.
|
||||
* @param string $end_date Effective end date.
|
||||
* @param string $end_time Optional end time.
|
||||
* @param bool $all_day Whether times are ignored.
|
||||
* @return bool
|
||||
*/
|
||||
function aa_events_occurrence_range_is_valid( $start_date, $start_time, $end_date, $end_time, $all_day ) {
|
||||
if ( $end_date < $start_date ) {
|
||||
return false;
|
||||
}
|
||||
if ( $all_day ) {
|
||||
return true;
|
||||
}
|
||||
if ( $start_time && ! aa_events_occurrence_local_time_is_valid( $start_date, $start_time ) ) {
|
||||
return false;
|
||||
}
|
||||
if ( $end_time && ! aa_events_occurrence_local_time_is_valid( $end_date, $end_time ) ) {
|
||||
return false;
|
||||
}
|
||||
return $start_date !== $end_date || ! $start_time || ! $end_time || $end_time >= $start_time;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a single occurrence row.
|
||||
*
|
||||
* The public shape records provenance without temporary keys. start_inferred is
|
||||
* true when the start came from a legacy combined/separate source rather than
|
||||
* a current repeater row. end_inferred is true when the effective end was
|
||||
* defaulted because no explicit end was supplied, and for all legacy sources.
|
||||
*
|
||||
* @param array $row Raw occurrence fields.
|
||||
* @param string $source Source field format.
|
||||
* @param int $post_id Event post ID.
|
||||
* @return array|null
|
||||
*/
|
||||
function aa_events_normalize_occurrence_row( array $row, $source = 'event_dates', $post_id = 0 ) {
|
||||
$source = in_array( $source, array( 'event_dates', 'event_datetime', 'legacy_separate' ), true ) ? $source : 'event_dates';
|
||||
$start_inferred = 'event_dates' !== $source;
|
||||
$start_date = aa_events_parse_occurrence_value( $row['start_date'] ?? $row['date'] ?? '', 'Y-m-d', 'Y-m-d' );
|
||||
if ( null === $start_date ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$end_input = aa_events_occurrence_string( $row['end_date'] ?? '' );
|
||||
if ( null === $end_input ) {
|
||||
return null;
|
||||
}
|
||||
$end_date = '' === $end_input ? $start_date : aa_events_parse_occurrence_value( $end_input, 'Y-m-d', 'Y-m-d' );
|
||||
if ( null === $end_date ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$all_day = aa_events_occurrence_boolean( $row['all_day'] ?? false );
|
||||
$end_time_input = aa_events_occurrence_string( $row['end_time'] ?? '' );
|
||||
$end_inferred = $start_inferred || ( '' === $end_input && ( null === $end_time_input || '' === $end_time_input ) );
|
||||
$start_time = '';
|
||||
$end_time = '';
|
||||
if ( ! $all_day ) {
|
||||
if ( ! empty( $row['start_time'] ) ) {
|
||||
$start_time = aa_events_parse_occurrence_time( $row['start_time'] );
|
||||
if ( null === $start_time ) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
if ( ! empty( $row['end_time'] ) ) {
|
||||
$end_time = aa_events_parse_occurrence_time( $row['end_time'] );
|
||||
if ( null === $end_time ) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
if ( ! aa_events_occurrence_range_is_valid( $start_date, $start_time, $end_date, $end_time, $all_day ) ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return array(
|
||||
'post_id' => (int) $post_id,
|
||||
'start_date' => $start_date,
|
||||
'start_time' => $start_time ? $start_time : '',
|
||||
'end_date' => $end_date,
|
||||
'end_time' => $end_time ? $end_time : '',
|
||||
'all_day' => (bool) $all_day,
|
||||
'source' => $source,
|
||||
'start_inferred' => $start_inferred,
|
||||
'end_inferred' => $end_inferred,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read all occurrences for an event, preferring the current repeater format.
|
||||
*
|
||||
* @param int $post_id Event post ID.
|
||||
* @return array
|
||||
*/
|
||||
function aa_events_get_occurrences( $post_id ) {
|
||||
$post_id = (int) $post_id;
|
||||
$rows = get_field( 'event_dates', $post_id );
|
||||
$occurrences = array();
|
||||
|
||||
if ( is_array( $rows ) && $rows ) {
|
||||
foreach ( $rows as $row ) {
|
||||
if ( is_array( $row ) ) {
|
||||
$occurrence = aa_events_normalize_occurrence_row( $row, 'event_dates', $post_id );
|
||||
if ( null !== $occurrence ) {
|
||||
$occurrences[] = $occurrence;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
$legacy = aa_events_occurrence_string( get_field( 'event_datetime', $post_id ) );
|
||||
if ( null !== $legacy && '' !== $legacy ) {
|
||||
$date = DateTimeImmutable::createFromFormat( '!Y-m-d H:i:s', $legacy, wp_timezone() );
|
||||
$errors = DateTimeImmutable::getLastErrors();
|
||||
if ( false !== $date && ( ! is_array( $errors ) || ( ! $errors['warning_count'] && ! $errors['error_count'] ) ) && $date->format( 'Y-m-d H:i:s' ) === $legacy ) {
|
||||
$occurrences[] = aa_events_normalize_occurrence_row(
|
||||
array(
|
||||
'date' => $date->format( 'Y-m-d' ),
|
||||
'start_time' => $date->format( 'H:i:s' ),
|
||||
),
|
||||
'event_datetime',
|
||||
$post_id
|
||||
);
|
||||
}
|
||||
} else {
|
||||
$date = get_field( 'date', $post_id );
|
||||
if ( null !== aa_events_occurrence_string( $date ) && '' !== aa_events_occurrence_string( $date ) ) {
|
||||
$occurrence = aa_events_normalize_occurrence_row(
|
||||
array(
|
||||
'date' => $date,
|
||||
'start_time' => get_field( 'start_time', $post_id ),
|
||||
),
|
||||
'legacy_separate',
|
||||
$post_id
|
||||
);
|
||||
if ( null !== $occurrence ) {
|
||||
$occurrences[] = $occurrence;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$decorated = array();
|
||||
foreach ( $occurrences as $index => $occurrence ) {
|
||||
$decorated[] = array(
|
||||
'occurrence' => $occurrence,
|
||||
'index' => $index,
|
||||
);
|
||||
}
|
||||
|
||||
usort(
|
||||
$decorated,
|
||||
function ( $left, $right ) {
|
||||
$left_occurrence = $left['occurrence'];
|
||||
$right_occurrence = $right['occurrence'];
|
||||
$comparison = aa_events_occurrence_start( $left_occurrence )->getTimestamp() <=> aa_events_occurrence_start( $right_occurrence )->getTimestamp();
|
||||
if ( 0 !== $comparison ) {
|
||||
return $comparison;
|
||||
}
|
||||
foreach ( array( 'end_date', 'end_time' ) as $key ) {
|
||||
$comparison = strcmp( $left_occurrence[ $key ], $right_occurrence[ $key ] );
|
||||
if ( 0 !== $comparison ) {
|
||||
return $comparison;
|
||||
}
|
||||
}
|
||||
$comparison = (int) $left_occurrence['all_day'] <=> (int) $right_occurrence['all_day'];
|
||||
if ( 0 !== $comparison ) {
|
||||
return $comparison;
|
||||
}
|
||||
$comparison = strcmp( $left_occurrence['source'], $right_occurrence['source'] );
|
||||
return 0 !== $comparison ? $comparison : $left['index'] <=> $right['index'];
|
||||
}
|
||||
);
|
||||
|
||||
return array_column( $decorated, 'occurrence' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Get event dates in the legacy theme-helper shape.
|
||||
*
|
||||
* @deprecated Use aa_events_get_occurrences() instead.
|
||||
*
|
||||
* @param int $post_id Event post ID.
|
||||
* @return array<int,array{date:string,time:string,end_date:string,end_time:string,all_day:bool}>
|
||||
*/
|
||||
function events_get_all_event_dates( $post_id ) {
|
||||
return array_map(
|
||||
function ( $occurrence ) {
|
||||
return array(
|
||||
'date' => $occurrence['start_date'],
|
||||
'time' => $occurrence['start_time'],
|
||||
'end_date' => $occurrence['end_date'],
|
||||
'end_time' => $occurrence['end_time'],
|
||||
'all_day' => $occurrence['all_day'],
|
||||
);
|
||||
},
|
||||
aa_events_get_occurrences( $post_id )
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a raw or normalized occurrence row.
|
||||
*
|
||||
* @param array $row Occurrence fields.
|
||||
* @return string Empty when valid, otherwise an error message.
|
||||
*/
|
||||
function aa_events_validate_occurrence_row( array $row ) {
|
||||
$start = aa_events_parse_occurrence_value( $row['start_date'] ?? $row['date'] ?? '', 'Y-m-d', 'Y-m-d' );
|
||||
if ( null === $start ) {
|
||||
return __( 'Start date is required and must be valid.', 'aa-events' );
|
||||
}
|
||||
$end_input = aa_events_occurrence_string( $row['end_date'] ?? '' );
|
||||
if ( null === $end_input ) {
|
||||
return __( 'End date must be valid.', 'aa-events' );
|
||||
}
|
||||
$end = '' === $end_input ? $start : aa_events_parse_occurrence_value( $end_input, 'Y-m-d', 'Y-m-d' );
|
||||
if ( null === $end ) {
|
||||
return __( 'End date must be valid.', 'aa-events' );
|
||||
}
|
||||
if ( $end < $start ) {
|
||||
return __( 'End date cannot be earlier than start date.', 'aa-events' );
|
||||
}
|
||||
if ( aa_events_occurrence_boolean( $row['all_day'] ?? false ) ) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$start_time = empty( $row['start_time'] ) ? '' : aa_events_parse_occurrence_time( $row['start_time'] );
|
||||
$end_time = empty( $row['end_time'] ) ? '' : aa_events_parse_occurrence_time( $row['end_time'] );
|
||||
if ( ! empty( $row['start_time'] ) && null === $start_time ) {
|
||||
return __( 'Start time must be valid.', 'aa-events' );
|
||||
}
|
||||
if ( ! empty( $row['end_time'] ) && null === $end_time ) {
|
||||
return __( 'End time must be valid.', 'aa-events' );
|
||||
}
|
||||
if ( $start_time && ! aa_events_occurrence_local_time_is_valid( $start, $start_time ) ) {
|
||||
return __( 'Start time must be valid.', 'aa-events' );
|
||||
}
|
||||
if ( $end_time && ! aa_events_occurrence_local_time_is_valid( $end, $end_time ) ) {
|
||||
return __( 'End time must be valid.', 'aa-events' );
|
||||
}
|
||||
if ( $start === $end && $start_time && $end_time && $end_time < $start_time ) {
|
||||
return __( 'End time cannot be earlier than start time on the same date.', 'aa-events' );
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an occurrence start value.
|
||||
*
|
||||
* @param array $occurrence Normalized occurrence.
|
||||
* @return DateTimeImmutable
|
||||
*/
|
||||
function aa_events_occurrence_start( array $occurrence ) {
|
||||
$time = empty( $occurrence['all_day'] ) && ! empty( $occurrence['start_time'] ) ? $occurrence['start_time'] : '00:00:00';
|
||||
return new DateTimeImmutable( $occurrence['start_date'] . ' ' . $time, wp_timezone() );
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an occurrence end value.
|
||||
*
|
||||
* @param array $occurrence Normalized occurrence.
|
||||
* @return DateTimeImmutable
|
||||
*/
|
||||
function aa_events_occurrence_end( array $occurrence ) {
|
||||
$end_date = $occurrence['end_date'] ? $occurrence['end_date'] : $occurrence['start_date'];
|
||||
if ( ! empty( $occurrence['all_day'] ) ) {
|
||||
$time = '00:00:00';
|
||||
} elseif ( ! empty( $occurrence['end_time'] ) ) {
|
||||
$time = $occurrence['end_time'];
|
||||
} elseif ( $end_date === $occurrence['start_date'] && ! empty( $occurrence['start_time'] ) ) {
|
||||
$time = $occurrence['start_time'];
|
||||
} else {
|
||||
$time = '00:00:00';
|
||||
}
|
||||
return new DateTimeImmutable( $end_date . ' ' . $time, wp_timezone() );
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a normalized occurrence for display.
|
||||
*
|
||||
* @param array $occurrence Normalized occurrence.
|
||||
* @return string
|
||||
*/
|
||||
function aa_events_format_occurrence( array $occurrence ) {
|
||||
$timezone = wp_timezone();
|
||||
$start = aa_events_occurrence_start( $occurrence );
|
||||
$end = aa_events_occurrence_end( $occurrence );
|
||||
$same_day = $occurrence['start_date'] === $occurrence['end_date'];
|
||||
|
||||
if ( ! empty( $occurrence['all_day'] ) ) {
|
||||
if ( $same_day ) {
|
||||
$label = wp_date( 'F j, Y', $start->getTimestamp(), $timezone );
|
||||
} elseif ( $start->format( 'Y-m' ) === $end->format( 'Y-m' ) ) {
|
||||
$label = wp_date( 'F j', $start->getTimestamp(), $timezone ) . '–' . wp_date( 'j, Y', $end->getTimestamp(), $timezone );
|
||||
} elseif ( $start->format( 'Y' ) === $end->format( 'Y' ) ) {
|
||||
$label = wp_date( 'F j', $start->getTimestamp(), $timezone ) . '–' . wp_date( 'F j, Y', $end->getTimestamp(), $timezone );
|
||||
} else {
|
||||
$label = wp_date( 'F j, Y', $start->getTimestamp(), $timezone ) . '–' . wp_date( 'F j, Y', $end->getTimestamp(), $timezone );
|
||||
}
|
||||
return $label . ' — ' . __( 'All day', 'aa-events' );
|
||||
}
|
||||
|
||||
$start_date = wp_date( 'F j, Y', $start->getTimestamp(), $timezone );
|
||||
$start_time = empty( $occurrence['start_time'] ) ? '' : wp_date( 'g:i A', $start->getTimestamp(), $timezone );
|
||||
$end_time = empty( $occurrence['end_time'] ) ? '' : wp_date( 'g:i A', $end->getTimestamp(), $timezone );
|
||||
if ( $same_day ) {
|
||||
if ( $start_time && $end_time ) {
|
||||
return $start_date . ', ' . $start_time . '–' . $end_time;
|
||||
}
|
||||
if ( $end_time ) {
|
||||
/* translators: 1: localized occurrence date, 2: localized end time. */
|
||||
return sprintf( __( '%1$s, until %2$s', 'aa-events' ), $start_date, $end_time );
|
||||
}
|
||||
return $start_date . ( $start_time ? ', ' . $start_time : '' );
|
||||
}
|
||||
$start_label = $start_date . ( $start_time ? ', ' . $start_time : '' );
|
||||
$end_label = wp_date( 'F j, Y', $end->getTimestamp(), $timezone ) . ( $end_time ? ', ' . $end_time : '' );
|
||||
return $start_label . '–' . $end_label;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render safe, accessible time markup for one normalized occurrence.
|
||||
*
|
||||
* Points return one visible <time>. Ranges return an aria-hidden visible
|
||||
* formatted label plus a screen-reader-only, localized pair of endpoint
|
||||
* <time> elements. Each endpoint is date-only unless that endpoint has an
|
||||
* explicit time; all returned dynamic text and attributes are escaped.
|
||||
*
|
||||
* @param array $occurrence Normalized occurrence from aa_events_get_occurrences().
|
||||
* @return string Escaped HTML safe to print directly.
|
||||
*/
|
||||
function aa_events_occurrence_time_markup( array $occurrence ) {
|
||||
$start = aa_events_occurrence_start( $occurrence );
|
||||
$end = aa_events_occurrence_end( $occurrence );
|
||||
$all_day = ! empty( $occurrence['all_day'] );
|
||||
$has_start_time = ! $all_day && ! empty( $occurrence['start_time'] );
|
||||
$has_end_time = ! $all_day && ! empty( $occurrence['end_time'] );
|
||||
$is_range = $occurrence['start_date'] !== $occurrence['end_date'] || $has_end_time;
|
||||
$visible = esc_html( aa_events_format_occurrence( $occurrence ) );
|
||||
$start_value = $has_start_time ? $start->format( DATE_W3C ) : $occurrence['start_date'];
|
||||
|
||||
if ( ! $is_range ) {
|
||||
return '<time datetime="' . esc_attr( $start_value ) . '">' . $visible . '</time>';
|
||||
}
|
||||
|
||||
$end_value = $has_end_time ? $end->format( DATE_W3C ) : $occurrence['end_date'];
|
||||
$start_label = wp_date( $has_start_time ? 'F j, Y, g:i A' : 'F j, Y', $start->getTimestamp(), wp_timezone() );
|
||||
$end_label = wp_date( $has_end_time ? 'F j, Y, g:i A' : 'F j, Y', $end->getTimestamp(), wp_timezone() );
|
||||
|
||||
return '<span aria-hidden="true">' . $visible . '</span>'
|
||||
. '<span class="screen-reader-text">' . esc_html( __( 'Starts:', 'aa-events' ) )
|
||||
. ' <time datetime="' . esc_attr( $start_value ) . '">' . esc_html( $start_label ) . '</time>. '
|
||||
. esc_html( __( 'Ends:', 'aa-events' ) )
|
||||
. ' <time datetime="' . esc_attr( $end_value ) . '">' . esc_html( $end_label ) . '</time>.</span>';
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrate the first parseable legacy occurrence into the repeater.
|
||||
*
|
||||
* @param int $post_id Event post ID.
|
||||
* @return bool Whether a repeater row was written.
|
||||
*/
|
||||
function aa_events_migrate_legacy_occurrence( $post_id ) {
|
||||
$post_id = (int) $post_id;
|
||||
$rows = get_field( 'event_dates', $post_id );
|
||||
if ( is_array( $rows ) && $rows ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$occurrences = aa_events_get_occurrences( $post_id );
|
||||
if ( ! isset( $occurrences[0] ) || ! in_array( $occurrences[0]['source'], array( 'event_datetime', 'legacy_separate' ), true ) ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$occurrence = $occurrences[0];
|
||||
$row = array(
|
||||
'date' => $occurrence['start_date'],
|
||||
'start_time' => $occurrence['start_time'],
|
||||
'end_date' => '',
|
||||
'end_time' => '',
|
||||
'all_day' => 0,
|
||||
);
|
||||
|
||||
return (bool) update_field( 'event_dates', array( $row ), $post_id );
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrate legacy occurrence data after ACF has saved an event.
|
||||
*
|
||||
* @param int $post_id Saved post ID.
|
||||
* @return void
|
||||
*/
|
||||
function aa_events_migrate_legacy_on_save( $post_id ) {
|
||||
$post_id = (int) $post_id;
|
||||
if ( ! $post_id || wp_is_post_autosave( $post_id ) || wp_is_post_revision( $post_id ) || 'event' !== get_post_type( $post_id ) ) {
|
||||
return;
|
||||
}
|
||||
aa_events_migrate_legacy_occurrence( $post_id );
|
||||
}
|
||||
|
||||
/**
|
||||
* Maintain the canonical earliest occurrence value used for event sorting.
|
||||
*
|
||||
* @param int $post_id Saved post ID.
|
||||
* @return void
|
||||
*/
|
||||
function aa_events_sync_sort_start_on_save( $post_id ) {
|
||||
$post_id = (int) $post_id;
|
||||
if ( ! $post_id || wp_is_post_autosave( $post_id ) || wp_is_post_revision( $post_id ) || 'event' !== get_post_type( $post_id ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$occurrences = aa_events_get_occurrences( $post_id );
|
||||
if ( ! isset( $occurrences[0] ) ) {
|
||||
delete_post_meta( $post_id, '_aa_events_sort_start' );
|
||||
delete_post_meta( $post_id, '_aa_events_sort_end' );
|
||||
update_post_meta( $post_id, '_aa_events_occurrence_index_version', AA_EVENTS_VERSION );
|
||||
return;
|
||||
}
|
||||
|
||||
$occurrence = $occurrences[0];
|
||||
$sort_start = $occurrence['start_date'] . ' ' . ( $occurrence['start_time'] ? $occurrence['start_time'] : '00:00:00' );
|
||||
$sort_end = aa_events_occurrence_end( $occurrence );
|
||||
foreach ( $occurrences as $candidate ) {
|
||||
$candidate_end = aa_events_occurrence_end( $candidate );
|
||||
if ( $candidate_end > $sort_end ) {
|
||||
$sort_end = $candidate_end;
|
||||
}
|
||||
}
|
||||
update_post_meta( $post_id, '_aa_events_sort_start', $sort_start );
|
||||
update_post_meta( $post_id, '_aa_events_sort_end', $sort_end->format( 'Y-m-d H:i:s' ) );
|
||||
update_post_meta( $post_id, '_aa_events_occurrence_index_version', AA_EVENTS_VERSION );
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule one bounded occurrence-index backfill batch when none is pending.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
function aa_events_schedule_occurrence_index_backfill() {
|
||||
if ( AA_EVENTS_VERSION !== get_option( 'aa_events_occurrence_index_version', '' ) && ! wp_next_scheduled( 'aa_events_occurrence_index_backfill' ) ) {
|
||||
wp_schedule_single_event( time() + 60, 'aa_events_occurrence_index_backfill' );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Backfill one bounded batch of published events with missing/outdated indexes.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
function aa_events_run_occurrence_index_backfill() {
|
||||
$batch = new WP_Query(
|
||||
array(
|
||||
'post_type' => 'event',
|
||||
'post_status' => 'publish',
|
||||
'posts_per_page' => 50,
|
||||
'fields' => 'ids',
|
||||
'no_found_rows' => true,
|
||||
'meta_query' => array(
|
||||
'relation' => 'OR',
|
||||
array(
|
||||
'key' => '_aa_events_occurrence_index_version',
|
||||
'value' => AA_EVENTS_VERSION,
|
||||
'compare' => '!=',
|
||||
),
|
||||
array(
|
||||
'key' => '_aa_events_occurrence_index_version',
|
||||
'compare' => 'NOT EXISTS',
|
||||
),
|
||||
),
|
||||
)
|
||||
);
|
||||
foreach ( $batch->posts as $post_id ) {
|
||||
aa_events_sync_sort_start_on_save( $post_id );
|
||||
}
|
||||
if ( 50 === count( $batch->posts ) ) {
|
||||
aa_events_schedule_occurrence_index_backfill();
|
||||
} else {
|
||||
update_option( 'aa_events_occurrence_index_version', AA_EVENTS_VERSION );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a metadata key can affect an event occurrence start.
|
||||
*
|
||||
* @param string $meta_key Metadata key.
|
||||
* @return bool
|
||||
*/
|
||||
function aa_events_sort_source_meta_key( $meta_key ) {
|
||||
if ( in_array( $meta_key, array( 'event_dates', 'event_datetime', 'date', 'start_time' ), true ) ) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return (bool) preg_match( '/^event_dates_[0-9]+_(date|start_time|end_date|end_time|all_day)$/', $meta_key );
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue an event for canonical sort synchronization after a metadata mutation.
|
||||
*
|
||||
* ACF and direct WordPress metadata updates can emit several hooks per logical
|
||||
* change. The queue coalesces those writes so fresh values are read once at
|
||||
* request shutdown, after all related row mutations have completed.
|
||||
*
|
||||
* @param mixed $meta_id Metadata ID or IDs.
|
||||
* @param int $object_id Post ID.
|
||||
* @param string $meta_key Metadata key.
|
||||
* @param mixed $meta_value Metadata value.
|
||||
* @return void
|
||||
*/
|
||||
function aa_events_queue_sort_sync_for_meta_change( $meta_id, $object_id, $meta_key, $meta_value ) {
|
||||
unset( $meta_id, $meta_value );
|
||||
$object_id = (int) $object_id;
|
||||
if ( ! aa_events_sort_source_meta_key( (string) $meta_key ) || ! $object_id || 'event' !== get_post_type( $object_id ) || wp_is_post_autosave( $object_id ) || wp_is_post_revision( $object_id ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ( ! isset( $GLOBALS['aa_events_sort_sync_queue'] ) || ! is_array( $GLOBALS['aa_events_sort_sync_queue'] ) ) {
|
||||
$GLOBALS['aa_events_sort_sync_queue'] = array();
|
||||
}
|
||||
$GLOBALS['aa_events_sort_sync_queue'][ $object_id ] = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronize all canonical event start values queued during this request.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
function aa_events_run_queued_sort_sync() {
|
||||
$queue = isset( $GLOBALS['aa_events_sort_sync_queue'] ) && is_array( $GLOBALS['aa_events_sort_sync_queue'] ) ? $GLOBALS['aa_events_sort_sync_queue'] : array();
|
||||
$GLOBALS['aa_events_sort_sync_queue'] = array();
|
||||
|
||||
foreach ( array_keys( $queue ) as $post_id ) {
|
||||
if ( function_exists( 'acf_flush_value_cache' ) ) {
|
||||
foreach ( array( 'event_dates', 'event_datetime', 'date', 'start_time' ) as $field_name ) {
|
||||
acf_flush_value_cache( $post_id, $field_name );
|
||||
}
|
||||
}
|
||||
aa_events_sync_sort_start_on_save( $post_id );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether an ACF occurrence repeater belongs to an event edit form.
|
||||
*
|
||||
* @param array $field ACF field definition.
|
||||
* @return bool
|
||||
*/
|
||||
function aa_events_occurrence_validation_applies( $field ) {
|
||||
if ( ! is_array( $field ) || ( isset( $field['type'] ) && 'repeater' !== $field['type'] ) ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ( 'field_61b0c7f0a3e8f' === ( $field['key'] ?? '' ) ) {
|
||||
return true;
|
||||
}
|
||||
if ( 'event_dates' !== ( $field['name'] ?? '' ) ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$post_id = function_exists( 'acf_get_form_data' ) ? acf_get_form_data( 'post_id' ) : 0;
|
||||
if ( is_string( $post_id ) && 0 === strpos( $post_id, 'post_' ) ) {
|
||||
$post_id = substr( $post_id, 5 );
|
||||
}
|
||||
|
||||
return (int) $post_id > 0 && 'event' === get_post_type( (int) $post_id );
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a submitted ACF date picker value for occurrence validation.
|
||||
*
|
||||
* @param mixed $value Submitted date value.
|
||||
* @return mixed
|
||||
*/
|
||||
function aa_events_normalize_submitted_date( $value ) {
|
||||
$string = aa_events_occurrence_string( $value );
|
||||
if ( null === $string || ! preg_match( '/^[0-9]{8}$/', $string ) ) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
$normalized = aa_events_parse_occurrence_value( $string, 'Ymd', 'Y-m-d' );
|
||||
return null === $normalized ? $value : $normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate submitted ACF repeater rows using stable subfield keys.
|
||||
*
|
||||
* @param mixed $valid Existing validation state.
|
||||
* @param mixed $value Submitted repeater value.
|
||||
* @param array $field ACF field definition.
|
||||
* @param string $input Submitted input name.
|
||||
* @return mixed
|
||||
*/
|
||||
function aa_events_validate_occurrence_repeater( $valid, $value, $field, $input ) {
|
||||
unset( $input );
|
||||
if ( true !== $valid || ! aa_events_occurrence_validation_applies( $field ) ) {
|
||||
return $valid;
|
||||
}
|
||||
if ( '' === $value || null === $value ) {
|
||||
return true;
|
||||
}
|
||||
if ( ! is_array( $value ) ) {
|
||||
return __( 'Occurrences data is malformed.', 'aa-events' );
|
||||
}
|
||||
unset( $value['acfcloneindex'] );
|
||||
|
||||
$key_map = array(
|
||||
'field_61b0c7f1a3e90' => 'date',
|
||||
'field_61b0c7f2a3e91' => 'start_time',
|
||||
'field_aa_events_end_date' => 'end_date',
|
||||
'field_aa_events_end_time' => 'end_time',
|
||||
'field_aa_events_all_day' => 'all_day',
|
||||
);
|
||||
foreach ( $field['sub_fields'] ?? array() as $sub_field ) {
|
||||
if ( is_array( $sub_field ) && ! empty( $sub_field['key'] ) && ! empty( $sub_field['name'] ) ) {
|
||||
$key_map[ $sub_field['key'] ] = $sub_field['name'];
|
||||
}
|
||||
}
|
||||
foreach ( array( 'date', 'start_time', 'end_date', 'end_time', 'all_day' ) as $name ) {
|
||||
$key_map[ $name ] = $name;
|
||||
}
|
||||
foreach ( array_values( $value ) as $index => $submitted_row ) {
|
||||
if ( ! is_array( $submitted_row ) ) {
|
||||
/* translators: %d: one-based occurrence row number. */
|
||||
return sprintf( __( 'Occurrence %d: Occurrence data is malformed.', 'aa-events' ), $index + 1 );
|
||||
}
|
||||
$row = array(
|
||||
'date' => '',
|
||||
'start_time' => '',
|
||||
'end_date' => '',
|
||||
'end_time' => '',
|
||||
'all_day' => '',
|
||||
);
|
||||
foreach ( $key_map as $key => $name ) {
|
||||
if ( array_key_exists( $key, $submitted_row ) ) {
|
||||
$row[ $name ] = $submitted_row[ $key ];
|
||||
}
|
||||
}
|
||||
$row['date'] = aa_events_normalize_submitted_date( $row['date'] );
|
||||
$row['end_date'] = aa_events_normalize_submitted_date( $row['end_date'] );
|
||||
$error = aa_events_validate_occurrence_row( $row );
|
||||
if ( '' !== $error ) {
|
||||
/* translators: 1: one-based occurrence row number, 2: validation error. */
|
||||
return sprintf( __( 'Occurrence %1$d: %2$s', 'aa-events' ), $index + 1, $error );
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
if ( function_exists( 'add_action' ) ) {
|
||||
add_action( 'acf/save_post', 'aa_events_migrate_legacy_on_save', 20 );
|
||||
add_action( 'acf/save_post', 'aa_events_sync_sort_start_on_save', 30 );
|
||||
add_action( 'added_post_meta', 'aa_events_queue_sort_sync_for_meta_change', 10, 4 );
|
||||
add_action( 'updated_post_meta', 'aa_events_queue_sort_sync_for_meta_change', 10, 4 );
|
||||
add_action( 'deleted_post_meta', 'aa_events_queue_sort_sync_for_meta_change', 10, 4 );
|
||||
add_action( 'shutdown', 'aa_events_run_queued_sort_sync' );
|
||||
add_action( 'init', 'aa_events_schedule_occurrence_index_backfill' );
|
||||
add_action( 'aa_events_occurrence_index_backfill', 'aa_events_run_occurrence_index_backfill' );
|
||||
}
|
||||
if ( function_exists( 'add_filter' ) ) {
|
||||
add_filter( 'acf/validate_value/key=field_61b0c7f0a3e8f', 'aa_events_validate_occurrence_repeater', 10, 4 );
|
||||
add_filter( 'acf/validate_value/name=event_dates', 'aa_events_validate_occurrence_repeater', 10, 4 );
|
||||
}
|
||||
+146
-19
@@ -146,10 +146,15 @@ add_filter( 'manage_edit-event_columns', 'events_add_admin_columns' );
|
||||
*/
|
||||
function events_display_admin_column_content( $column, $post_id ) {
|
||||
if ( 'event_datetime' === $column ) {
|
||||
$datetime = get_field( 'event_datetime', $post_id );
|
||||
$occurrences = aa_events_get_occurrences( $post_id );
|
||||
if ( ! $occurrences ) {
|
||||
echo esc_html( '—' );
|
||||
return;
|
||||
}
|
||||
|
||||
if ( $datetime ) {
|
||||
echo esc_html( gmdate( 'F j, Y g:i a', strtotime( $datetime ) ) );
|
||||
echo esc_html( aa_events_format_occurrence( $occurrences[0] ) );
|
||||
if ( count( $occurrences ) > 1 ) {
|
||||
echo ' <span class="aa-event-occurrence-count">+' . esc_html( count( $occurrences ) - 1 ) . '</span>';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -193,13 +198,27 @@ function events_get_sortable_meta_map() {
|
||||
* @param WP_Query $query The WordPress query object.
|
||||
*/
|
||||
function events_sort_by_custom_column( $query ) {
|
||||
if ( ! is_admin() || ! $query->is_main_query() ) {
|
||||
if ( ! is_admin() || ! $query->is_main_query() || 'event' !== $query->get( 'post_type' ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$orderby = $query->get( 'orderby' );
|
||||
$map = events_get_sortable_meta_map();
|
||||
|
||||
// Special handling for the event_datetime column in admin.
|
||||
// Use a custom SQL sort across multiple possible date fields while keeping all posts visible.
|
||||
if ( 'event_datetime' === $orderby ) {
|
||||
$query->set( 'aa_events_date_sort', 1 );
|
||||
$query->set( 'orderby', 'none' );
|
||||
|
||||
$order = strtoupper( (string) $query->get( 'order' ) );
|
||||
if ( 'DESC' !== $order ) {
|
||||
$order = 'ASC';
|
||||
}
|
||||
$query->set( 'order', $order );
|
||||
return;
|
||||
}
|
||||
|
||||
if ( isset( $map[ $orderby ] ) ) {
|
||||
$meta_type = strtoupper( $map[ $orderby ] );
|
||||
$query->set( 'meta_key', $orderby );
|
||||
@@ -213,6 +232,115 @@ function events_sort_by_custom_column( $query ) {
|
||||
}
|
||||
add_action( 'pre_get_posts', 'events_sort_by_custom_column' );
|
||||
|
||||
/**
|
||||
* Build a safe SQL expression for supported stored date formats.
|
||||
*
|
||||
* @param string $column Static SQL column expression.
|
||||
* @param bool $allow_datetime Whether to accept a full legacy datetime.
|
||||
* @return string
|
||||
*/
|
||||
function events_admin_date_meta_expression( $column, $allow_datetime = false ) {
|
||||
$datetime = $allow_datetime ? "WHEN {$column} REGEXP '^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$' THEN STR_TO_DATE({$column}, '%Y-%m-%d %H:%i:%s') " : '';
|
||||
return "CASE {$datetime}WHEN {$column} REGEXP '^[0-9]{8}$' THEN STR_TO_DATE({$column}, '%Y%m%d') WHEN {$column} REGEXP '^[0-9]{4}-[0-9]{2}-[0-9]{2}$' THEN STR_TO_DATE({$column}, '%Y-%m-%d') ELSE NULL END";
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the deterministic admin occurrence ORDER BY expression.
|
||||
*
|
||||
* @param string $posts_table Static posts table name.
|
||||
* @param string $order Requested direction.
|
||||
* @return string
|
||||
*/
|
||||
function events_admin_date_sort_orderby_sql( $posts_table, $order ) {
|
||||
$order = 'DESC' === strtoupper( (string) $order ) ? 'DESC' : 'ASC';
|
||||
$postmeta_table = events_date_sort_postmeta_table();
|
||||
$canonical_expression = events_admin_date_meta_expression( 'aa_sort.meta_value', true );
|
||||
$date_expression = events_admin_date_meta_expression( 'aa_er.meta_value' );
|
||||
$datetime_expression = events_admin_date_meta_expression( 'aa_edt.meta_value', true );
|
||||
$legacy_expression = events_admin_date_meta_expression( 'aa_d.meta_value' );
|
||||
$start_time_value = "(SELECT aa_time.meta_value FROM {$postmeta_table} AS aa_time WHERE aa_time.post_id = aa_er.post_id AND aa_time.meta_key = CONCAT(LEFT(aa_er.meta_key, LENGTH(aa_er.meta_key) - 4), 'start_time') ORDER BY aa_time.meta_id ASC LIMIT 1)";
|
||||
$all_day_value = "(SELECT aa_all_day.meta_value FROM {$postmeta_table} AS aa_all_day WHERE aa_all_day.post_id = aa_er.post_id AND aa_all_day.meta_key = CONCAT(LEFT(aa_er.meta_key, LENGTH(aa_er.meta_key) - 4), 'all_day') ORDER BY aa_all_day.meta_id ASC LIMIT 1)";
|
||||
$start_time_expression = "CASE WHEN LOWER(TRIM(COALESCE({$all_day_value}, ''))) IN ('1', 'true', 'yes', 'on') THEN STR_TO_DATE('00:00:00', '%H:%i:%s') WHEN {$start_time_value} REGEXP '^([01][0-9]|2[0-3]):[0-5][0-9](:[0-5][0-9])?$' THEN CASE WHEN CHAR_LENGTH({$start_time_value}) = 5 THEN STR_TO_DATE({$start_time_value}, '%H:%i') ELSE STR_TO_DATE({$start_time_value}, '%H:%i:%s') END ELSE STR_TO_DATE('00:00:00', '%H:%i:%s') END";
|
||||
$repeater = "(SELECT MIN(TIMESTAMP({$date_expression}, {$start_time_expression})) FROM {$postmeta_table} AS aa_er WHERE aa_er.post_id = {$posts_table}.ID AND aa_er.meta_key REGEXP '^event_dates_[0-9]+_date$')";
|
||||
$legacy_datetime = "(SELECT MIN({$datetime_expression}) FROM {$postmeta_table} AS aa_edt WHERE aa_edt.post_id = {$posts_table}.ID AND aa_edt.meta_key = 'event_datetime')";
|
||||
$legacy_date = "(SELECT MIN({$legacy_expression}) FROM {$postmeta_table} AS aa_d WHERE aa_d.post_id = {$posts_table}.ID AND aa_d.meta_key = 'date')";
|
||||
return "COALESCE({$canonical_expression}, {$repeater}, {$legacy_datetime}, {$legacy_date}, {$posts_table}.post_date) {$order}, {$posts_table}.post_date {$order}, {$posts_table}.ID {$order}";
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the postmeta table used by date-sort SQL helpers.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
function events_date_sort_postmeta_table() {
|
||||
global $wpdb;
|
||||
return isset( $wpdb->postmeta ) ? $wpdb->postmeta : 'wp_postmeta';
|
||||
}
|
||||
|
||||
/**
|
||||
* Build grouped joins for normalized event date sort values.
|
||||
*
|
||||
* @param string $postmeta_table Static postmeta table name.
|
||||
* @param string $posts_table Static posts table name.
|
||||
* @return string
|
||||
*/
|
||||
function events_admin_date_sort_join_sql( $postmeta_table, $posts_table ) {
|
||||
return " LEFT JOIN {$postmeta_table} AS aa_sort ON aa_sort.meta_id = (SELECT MIN(aa_sort_pick.meta_id) FROM {$postmeta_table} AS aa_sort_pick WHERE aa_sort_pick.post_id = {$posts_table}.ID AND aa_sort_pick.meta_key = '_aa_events_sort_start')";
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether an occurrence sort flag belongs to a supported event query.
|
||||
*
|
||||
* @param WP_Query $query Query object.
|
||||
* @return bool
|
||||
*/
|
||||
function events_date_sort_query_applies( $query ) {
|
||||
if ( ! $query->is_main_query() || ! $query->get( 'aa_events_date_sort' ) ) {
|
||||
return false;
|
||||
}
|
||||
if ( is_admin() ) {
|
||||
return 'event' === $query->get( 'post_type' );
|
||||
}
|
||||
return $query->is_post_type_archive( 'event' ) || $query->is_tax( 'event_type' ) || $query->is_tax( 'event_tag' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Add occurrence date sort joins to a flagged main query.
|
||||
*
|
||||
* @param string $join SQL JOIN clause.
|
||||
* @param WP_Query $query Query object.
|
||||
* @return string
|
||||
*/
|
||||
function events_date_sort_posts_join( $join, $query ) {
|
||||
global $wpdb;
|
||||
|
||||
if ( ! events_date_sort_query_applies( $query ) ) {
|
||||
return $join;
|
||||
}
|
||||
|
||||
return $join . events_admin_date_sort_join_sql( $wpdb->postmeta, $wpdb->posts );
|
||||
}
|
||||
add_filter( 'posts_join', 'events_date_sort_posts_join', 10, 2 );
|
||||
|
||||
/**
|
||||
* Apply deterministic occurrence date ordering to a flagged main query.
|
||||
*
|
||||
* @param string $orderby SQL ORDER BY clause.
|
||||
* @param WP_Query $query Query object.
|
||||
* @return string
|
||||
*/
|
||||
function events_date_sort_posts_orderby( $orderby, $query ) {
|
||||
global $wpdb;
|
||||
|
||||
if ( ! events_date_sort_query_applies( $query ) ) {
|
||||
return $orderby;
|
||||
}
|
||||
|
||||
// Priority: earliest repeater date -> legacy datetime -> legacy date -> post date.
|
||||
return events_admin_date_sort_orderby_sql( $wpdb->posts, $query->get( 'order' ) );
|
||||
}
|
||||
add_filter( 'posts_orderby', 'events_date_sort_posts_orderby', 10, 2 );
|
||||
|
||||
/**
|
||||
* Extend REST API to allow ordering events by meta value.
|
||||
* Adds support for `orderby=meta_value|meta_value_num` and `meta_key`.
|
||||
@@ -287,8 +415,7 @@ function events_rest_adjust_query_for_meta_ordering( $args, $request ) {
|
||||
add_filter( 'rest_event_query', 'events_rest_adjust_query_for_meta_ordering', 10, 2 );
|
||||
|
||||
/**
|
||||
* Set default sort order for event post type by event_datetime ascending.
|
||||
* Applies to both admin and frontend queries.
|
||||
* Set occurrence-aware default sort order for event list queries.
|
||||
*
|
||||
* @param WP_Query $query The WordPress query object.
|
||||
*/
|
||||
@@ -298,17 +425,19 @@ function events_set_default_sort_order( $query ) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Admin list table
|
||||
// Admin list table: default to event start-date sorting.
|
||||
if ( is_admin() && $query->get( 'post_type' ) === 'event' ) {
|
||||
if ( ! $query->get( 'orderby' ) ) {
|
||||
$query->set( 'meta_key', 'event_datetime' );
|
||||
$query->set( 'orderby', 'meta_value' );
|
||||
$query->set( 'aa_events_date_sort', 1 );
|
||||
$query->set( 'orderby', 'none' );
|
||||
$query->set( 'order', 'ASC' );
|
||||
}
|
||||
}
|
||||
|
||||
// Frontend: event archives and taxonomies
|
||||
if ( ! is_admin() && $query->get( 'post_type' ) === 'event' ) {
|
||||
// Frontend: event archives and taxonomies. Taxonomy queries do not always expose post_type.
|
||||
$is_event_archive = ! is_admin() && $query->is_post_type_archive( 'event' );
|
||||
$is_event_taxonomy = ! is_admin() && ( $query->is_tax( 'event_type' ) || $query->is_tax( 'event_tag' ) );
|
||||
if ( $is_event_archive || $is_event_taxonomy ) {
|
||||
$map = events_get_sortable_meta_map();
|
||||
$orderby = $query->get( 'orderby' );
|
||||
|
||||
@@ -326,20 +455,18 @@ function events_set_default_sort_order( $query ) {
|
||||
}
|
||||
|
||||
// Main event archive: newest first (descending by date/time)
|
||||
if ( $query->is_post_type_archive( 'event' ) ) {
|
||||
if ( $is_event_archive ) {
|
||||
if ( ! $query->get( 'orderby' ) ) {
|
||||
$query->set( 'meta_key', 'event_datetime' );
|
||||
$query->set( 'meta_type', 'DATETIME' );
|
||||
$query->set( 'orderby', 'meta_value' );
|
||||
$query->set( 'aa_events_date_sort', 1 );
|
||||
$query->set( 'orderby', 'none' );
|
||||
$query->set( 'order', 'DESC' );
|
||||
}
|
||||
}
|
||||
|
||||
// Taxonomy views: upcoming first (ascending)
|
||||
if ( ( $query->is_tax( 'event_type' ) || $query->is_tax( 'event_tag' ) ) && ! $query->get( 'orderby' ) ) {
|
||||
$query->set( 'meta_key', 'event_datetime' );
|
||||
$query->set( 'meta_type', 'DATETIME' );
|
||||
$query->set( 'orderby', 'meta_value' );
|
||||
if ( $is_event_taxonomy && ! $query->get( 'orderby' ) ) {
|
||||
$query->set( 'aa_events_date_sort', 1 );
|
||||
$query->set( 'orderby', 'none' );
|
||||
$query->set( 'order', 'ASC' );
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,23 @@ if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit; // Exit if accessed directly.
|
||||
}
|
||||
|
||||
/**
|
||||
* Build safe contact-email markup for event templates.
|
||||
*
|
||||
* @param mixed $value Raw contact email value.
|
||||
* @return string Escaped mailto anchor, or an empty string for invalid input.
|
||||
*/
|
||||
function aa_events_contact_email_markup( $value ) {
|
||||
$raw = trim( (string) $value );
|
||||
$email = sanitize_email( $raw );
|
||||
if ( '' === $email || $email !== $raw ) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$protected = antispambot( $email );
|
||||
return '<a href="' . esc_attr( 'mailto:' . $protected ) . '">' . wp_kses_post( $protected ) . '</a>';
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a template.
|
||||
*
|
||||
|
||||
+208
-34
@@ -4,10 +4,18 @@
|
||||
|
||||
.aa-events-calendar-wrap { margin-bottom: 1.25rem; }
|
||||
|
||||
.aa-events-wrap {
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
max-width: 96rem;
|
||||
}
|
||||
|
||||
.aa-events-calendar-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.625rem;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 0.625rem;
|
||||
}
|
||||
|
||||
@@ -20,52 +28,218 @@
|
||||
padding: 0.5rem 1rem;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover { background: #eee; }
|
||||
}
|
||||
|
||||
.aa-events-calendar {
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed;
|
||||
width: 100%;
|
||||
.aa-events-calendar-today-btn:hover,
|
||||
.aa-events-calendar-today-btn:focus-visible {
|
||||
background: #eee;
|
||||
}
|
||||
|
||||
th, td {
|
||||
border: 1px solid #aaa;
|
||||
padding: 0.625rem;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
.aa-events-calendar-grid {
|
||||
border-left: 1px solid #aaa;
|
||||
border-top: 1px solid #aaa;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
th {
|
||||
background-color: #f5f5f5;
|
||||
text-align: center;
|
||||
}
|
||||
.aa-events-calendar-weekdays {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
td { height: 9.375rem; }
|
||||
.aa-events-calendar-weekday {
|
||||
background: #f5f5f5;
|
||||
border-bottom: 1px solid #aaa;
|
||||
border-right: 1px solid #aaa;
|
||||
font-weight: bold;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
padding: 0.625rem 0.25rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.day-number {
|
||||
font-weight: bold;
|
||||
margin-bottom: 0.3125rem;
|
||||
}
|
||||
.aa-events-calendar-weekday abbr {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.today-cell {
|
||||
background: #ffe9b3;
|
||||
border: 2px solid #f7b500;
|
||||
font-weight: bold;
|
||||
}
|
||||
.aa-events-calendar-week {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, minmax(0, 1fr));
|
||||
min-height: calc(7.5rem + (var(--aa-event-lanes, 1) * 1.75rem));
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.events-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
.aa-events-calendar-day {
|
||||
border-bottom: 1px solid #aaa;
|
||||
border-right: 1px solid #aaa;
|
||||
min-width: 0;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
li { margin-bottom: 0.3125rem; }
|
||||
}
|
||||
.aa-events-calendar-day .day-number {
|
||||
display: block;
|
||||
font-weight: bold;
|
||||
margin-bottom: 0.3125rem;
|
||||
}
|
||||
|
||||
.aa-events-calendar-day.is-outside-month {
|
||||
background: #f7f7f7;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.aa-events-calendar-day.today-cell {
|
||||
background: #fff3cf;
|
||||
box-shadow: inset 0 0 0 2px #b57900;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.aa-events-calendar-event {
|
||||
align-items: center;
|
||||
background: #245b87;
|
||||
border-radius: 0.25rem;
|
||||
box-sizing: border-box;
|
||||
color: #fff;
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
height: 1.5rem;
|
||||
left: calc(((100% / 7) * var(--aa-event-column)) + 0.2rem);
|
||||
line-height: 1.5rem;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
padding: 0 0.375rem;
|
||||
position: absolute;
|
||||
text-decoration: none;
|
||||
top: calc(2.25rem + (var(--aa-event-lane) * 1.75rem));
|
||||
white-space: nowrap;
|
||||
width: calc(((100% / 7) * var(--aa-event-span)) - 0.4rem);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.aa-events-calendar-event > span:not(.aa-events-calendar-continuation) {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.aa-events-calendar-event.is-continuing-before {
|
||||
border-bottom-left-radius: 0;
|
||||
border-top-left-radius: 0;
|
||||
}
|
||||
|
||||
.aa-events-calendar-event.is-continuing-after {
|
||||
border-bottom-right-radius: 0;
|
||||
border-top-right-radius: 0;
|
||||
}
|
||||
|
||||
.aa-events-calendar-continuation {
|
||||
flex: 0 0 auto;
|
||||
font-weight: bold;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.aa-events-calendar-event:hover {
|
||||
background: #163d5d;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.aa-events-calendar-event:focus-visible {
|
||||
background: #163d5d;
|
||||
outline: 3px solid #111;
|
||||
outline-offset: 2px;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.aa-events-calendar-agenda {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.aa-event-item .aa-event-details {
|
||||
border-top: 1px solid #ddd;
|
||||
border-top: 1px solid #333;
|
||||
margin-top: 1.25rem;
|
||||
padding-top: 1.25rem;
|
||||
|
||||
div { margin-bottom: 0.625rem; }
|
||||
}
|
||||
|
||||
/* Events archive layout */
|
||||
.aa-events-wrap .aa-events-grid {
|
||||
display: grid;
|
||||
gap: 1.5rem;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
/* Optional: responsive adjustments */
|
||||
@media (max-width: 1024px) {
|
||||
.aa-events-wrap .aa-events-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.aa-events-wrap .aa-events-grid { grid-template-columns: 1fr; }
|
||||
|
||||
.aa-events-calendar-desktop { display: none; }
|
||||
|
||||
.aa-events-calendar-agenda { display: block; }
|
||||
|
||||
.aa-events-calendar-agenda-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.aa-events-agenda-item + .aa-events-agenda-item { margin-top: 0.75rem; }
|
||||
|
||||
.aa-events-agenda-item > a {
|
||||
border: 1px solid #aaa;
|
||||
border-left: 0.25rem solid #245b87;
|
||||
border-radius: 0.25rem;
|
||||
display: block;
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
padding: 0.75rem;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.aa-events-agenda-item > a:hover { background: #f5f5f5; }
|
||||
|
||||
.aa-events-agenda-item > a:focus-visible {
|
||||
outline: 3px solid currentColor;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.aa-events-agenda-title,
|
||||
.aa-events-agenda-item time {
|
||||
display: block;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.aa-events-agenda-title { font-weight: bold; }
|
||||
|
||||
.aa-events-agenda-item .screen-reader-text {
|
||||
border: 0;
|
||||
clip: rect(1px, 1px, 1px, 1px);
|
||||
clip-path: inset(50%);
|
||||
height: 1px;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
word-wrap: normal !important;
|
||||
}
|
||||
}
|
||||
|
||||
.aa-events-wrap .entry-header:has(.entry-thumbnail),
|
||||
.aa-events-wrap .entry-wrap:has(.entry-thumbnail) {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
|
||||
.entry-thumbnail {
|
||||
margin-left: 2rem;
|
||||
width: 100%;
|
||||
|
||||
img {
|
||||
height: auto;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,21 +30,20 @@ get_header(); ?>
|
||||
|
||||
<footer class="entry-footer">
|
||||
<div class="aa-event-details">
|
||||
<?php $occurrences = aa_events_get_occurrences( get_the_ID() ); ?>
|
||||
<?php if ( $occurrences ) : ?>
|
||||
<div class="aa-event-date">
|
||||
<strong><?php esc_html_e( 'Date:', 'aa-events' ); ?></strong>
|
||||
<strong><?php echo esc_html( 1 === count( $occurrences ) ? __( 'Date:', 'aa-events' ) : __( 'Dates:', 'aa-events' ) ); ?></strong>
|
||||
<?php
|
||||
$event_raw = get_field( 'event_datetime' );
|
||||
if ( $event_raw ) {
|
||||
$tz = wp_timezone();
|
||||
$ts = ( new DateTimeImmutable( $event_raw, $tz ) )->getTimestamp();
|
||||
$human = wp_date( 'F j, Y, g:i A', $ts );
|
||||
$iso_attr = wp_date( 'c', $ts );
|
||||
?>
|
||||
<time datetime="<?php echo esc_attr( $iso_attr ); ?>"><?php echo esc_html( $human ); ?></time>
|
||||
<?php
|
||||
echo '<span class="aa-event-multiple-dates">';
|
||||
foreach ( $occurrences as $index => $occurrence ) {
|
||||
echo aa_events_occurrence_time_markup( $occurrence ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Helper returns escaped markup.
|
||||
echo ( $index < count( $occurrences ) - 1 ) ? '<br />' : '';
|
||||
}
|
||||
echo '</span>';
|
||||
?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ( get_field( 'event_cost' ) ) { ?>
|
||||
<div class="aa-event-cost">
|
||||
|
||||
+244
-200
@@ -6,226 +6,270 @@
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit; // Exit if accessed directly.
|
||||
exit;
|
||||
}
|
||||
|
||||
// Get current month and year (read-only query vars; validate instead of nonce)
|
||||
$month_param = 'aa_month';
|
||||
$year_param = 'aa_year';
|
||||
$month_input = filter_input(
|
||||
INPUT_GET,
|
||||
'month',
|
||||
FILTER_VALIDATE_INT,
|
||||
array(
|
||||
'options' => array(
|
||||
'min_range' => 1,
|
||||
'max_range' => 12,
|
||||
),
|
||||
)
|
||||
INPUT_GET,
|
||||
$month_param,
|
||||
FILTER_VALIDATE_INT,
|
||||
array(
|
||||
'options' => array(
|
||||
'min_range' => 1,
|
||||
'max_range' => 12,
|
||||
),
|
||||
)
|
||||
);
|
||||
|
||||
if ( false === $month_input || null === $month_input ) {
|
||||
$month_input = filter_input(
|
||||
INPUT_GET,
|
||||
'month',
|
||||
FILTER_VALIDATE_INT,
|
||||
array(
|
||||
'options' => array(
|
||||
'min_range' => 1,
|
||||
'max_range' => 12,
|
||||
),
|
||||
)
|
||||
);
|
||||
}
|
||||
$year_input = filter_input(
|
||||
INPUT_GET,
|
||||
'year',
|
||||
FILTER_VALIDATE_INT,
|
||||
array(
|
||||
'options' => array(
|
||||
// Choose a sane range for years.
|
||||
'min_range' => 1970,
|
||||
'max_range' => 2200,
|
||||
),
|
||||
)
|
||||
INPUT_GET,
|
||||
$year_param,
|
||||
FILTER_VALIDATE_INT,
|
||||
array(
|
||||
'options' => array(
|
||||
'min_range' => 1970,
|
||||
'max_range' => 2200,
|
||||
),
|
||||
)
|
||||
);
|
||||
|
||||
$curMonth = ( false !== $month_input && null !== $month_input ) ? (int) $month_input : (int) wp_date( 'n' );
|
||||
$curYear = ( false !== $year_input && null !== $year_input ) ? (int) $year_input : (int) wp_date( 'Y' );
|
||||
|
||||
// Get previous and next month and year.
|
||||
$prev_month = $curMonth - 1;
|
||||
$prev_year = $curYear;
|
||||
|
||||
if ( $prev_month < 1 ) {
|
||||
$prev_month = 12;
|
||||
--$prev_year;
|
||||
if ( false === $year_input || null === $year_input ) {
|
||||
$year_input = filter_input(
|
||||
INPUT_GET,
|
||||
'year',
|
||||
FILTER_VALIDATE_INT,
|
||||
array(
|
||||
'options' => array(
|
||||
'min_range' => 1970,
|
||||
'max_range' => 2200,
|
||||
),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
$next_month = $curMonth + 1;
|
||||
$next_year = $curYear;
|
||||
if ( $next_month > 12 ) {
|
||||
$next_month = 1;
|
||||
++$next_year;
|
||||
}
|
||||
|
||||
// Get the number of days in the month.
|
||||
$days_in_month = cal_days_in_month( CAL_GREGORIAN, $curMonth, $curYear );
|
||||
|
||||
// Get the first day of the month in site timezone.
|
||||
$cur_month = ( false !== $month_input && null !== $month_input ) ? (int) $month_input : (int) wp_date( 'n' );
|
||||
$cur_year = ( false !== $year_input && null !== $year_input ) ? (int) $year_input : (int) wp_date( 'Y' );
|
||||
$tz = wp_timezone();
|
||||
$first_ts = ( new DateTimeImmutable( sprintf( '%04d-%02d-01 00:00:00', $curYear, $curMonth ), $tz ) )->getTimestamp();
|
||||
$first_day = (int) wp_date( 'N', $first_ts );
|
||||
|
||||
// Get events for the current month.
|
||||
$events = new WP_Query(
|
||||
array(
|
||||
'post_type' => 'event',
|
||||
'posts_per_page' => -1,
|
||||
'meta_query' => array(
|
||||
array(
|
||||
'key' => 'event_datetime',
|
||||
'value' => array( "$curYear-$curMonth-01 00:00:00", "$curYear-$curMonth-$days_in_month 23:59:59" ),
|
||||
'compare' => 'BETWEEN',
|
||||
'type' => 'DATETIME',
|
||||
),
|
||||
),
|
||||
)
|
||||
$start_of_week = (int) get_option( 'start_of_week', 1 );
|
||||
if ( $start_of_week < 0 || $start_of_week > 6 ) {
|
||||
$start_of_week = 0;
|
||||
}
|
||||
$month = aa_events_calendar_month( $cur_year, $cur_month, $start_of_week );
|
||||
|
||||
$weekdays = array(
|
||||
array(
|
||||
'abbr' => __( 'Sun', 'aa-events' ),
|
||||
'label' => __( 'Sunday', 'aa-events' ),
|
||||
),
|
||||
array(
|
||||
'abbr' => __( 'Mon', 'aa-events' ),
|
||||
'label' => __( 'Monday', 'aa-events' ),
|
||||
),
|
||||
array(
|
||||
'abbr' => __( 'Tue', 'aa-events' ),
|
||||
'label' => __( 'Tuesday', 'aa-events' ),
|
||||
),
|
||||
array(
|
||||
'abbr' => __( 'Wed', 'aa-events' ),
|
||||
'label' => __( 'Wednesday', 'aa-events' ),
|
||||
),
|
||||
array(
|
||||
'abbr' => __( 'Thu', 'aa-events' ),
|
||||
'label' => __( 'Thursday', 'aa-events' ),
|
||||
),
|
||||
array(
|
||||
'abbr' => __( 'Fri', 'aa-events' ),
|
||||
'label' => __( 'Friday', 'aa-events' ),
|
||||
),
|
||||
array(
|
||||
'abbr' => __( 'Sat', 'aa-events' ),
|
||||
'label' => __( 'Saturday', 'aa-events' ),
|
||||
),
|
||||
);
|
||||
$ordered_weekdays = array_merge( array_slice( $weekdays, $start_of_week ), array_slice( $weekdays, 0, $start_of_week ) );
|
||||
|
||||
$events_by_day = array();
|
||||
|
||||
if ( $events->have_posts() ) {
|
||||
while ( $events->have_posts() ) {
|
||||
$events->the_post();
|
||||
$event_date = get_field( 'event_datetime' );
|
||||
$event_ts = ( new DateTimeImmutable( $event_date, $tz ) )->getTimestamp();
|
||||
$day = (int) wp_date( 'j', $event_ts );
|
||||
$events_by_day[ $day ][] = '<a href="' . get_permalink() . '">' . get_the_title() . '</a>';
|
||||
}
|
||||
|
||||
wp_reset_postdata();
|
||||
$calendar_occurrences = array();
|
||||
$events = new WP_Query(
|
||||
array(
|
||||
'post_type' => 'event',
|
||||
'posts_per_page' => -1,
|
||||
'post_status' => 'publish',
|
||||
'fields' => 'ids',
|
||||
'no_found_rows' => true,
|
||||
'update_post_meta_cache' => true,
|
||||
'update_post_term_cache' => false,
|
||||
'meta_query' => array(
|
||||
'relation' => 'OR',
|
||||
array(
|
||||
'relation' => 'AND',
|
||||
array(
|
||||
'key' => '_aa_events_occurrence_index_version',
|
||||
'value' => AA_EVENTS_VERSION,
|
||||
'compare' => '=',
|
||||
),
|
||||
array(
|
||||
'key' => '_aa_events_sort_start',
|
||||
'value' => $month['month_end']->format( 'Y-m-d 23:59:59' ),
|
||||
'compare' => '<=',
|
||||
'type' => 'DATETIME',
|
||||
),
|
||||
array(
|
||||
'key' => '_aa_events_sort_end',
|
||||
'value' => $month['month_start']->format( 'Y-m-d 00:00:00' ),
|
||||
'compare' => '>=',
|
||||
'type' => 'DATETIME',
|
||||
),
|
||||
),
|
||||
array(
|
||||
'relation' => 'OR',
|
||||
array(
|
||||
'key' => '_aa_events_occurrence_index_version',
|
||||
'compare' => 'NOT EXISTS',
|
||||
),
|
||||
array(
|
||||
'key' => '_aa_events_occurrence_index_version',
|
||||
'value' => AA_EVENTS_VERSION,
|
||||
'compare' => '!=',
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
);
|
||||
foreach ( $events->posts as $event_id ) {
|
||||
$event_title = get_the_title( $event_id );
|
||||
$event_permalink = get_permalink( $event_id );
|
||||
foreach ( aa_events_get_occurrences( $event_id ) as $occurrence ) {
|
||||
if ( ! is_array( $occurrence ) || ! aa_events_occurrence_intersects_range( $occurrence, $month['month_start'], $month['month_end'] ) ) {
|
||||
continue;
|
||||
}
|
||||
$occurrence['post_id'] = (int) $event_id;
|
||||
$occurrence['title'] = $event_title;
|
||||
$occurrence['permalink'] = $event_permalink;
|
||||
$calendar_occurrences[] = $occurrence;
|
||||
}
|
||||
}
|
||||
|
||||
// Get today's date.
|
||||
$todayDay = intval( wp_date( 'j' ) );
|
||||
$todayMonth = intval( wp_date( 'n' ) );
|
||||
$todayYear = intval( wp_date( 'Y' ) );
|
||||
$calendar_occurrences = aa_events_sort_calendar_occurrences( $calendar_occurrences );
|
||||
$calendar_weeks = aa_events_group_calendar_weeks( $calendar_occurrences, $month );
|
||||
|
||||
// Helper to check if today is in current view.
|
||||
$isTodayInCurrentMonth = ( $curMonth === $todayMonth && $curYear === $todayYear );
|
||||
?>
|
||||
|
||||
<?php
|
||||
// Modify the day cell output to add highlight for today.
|
||||
$originalEchoDayCell = function ( $day, $firstDay, $eventsByDay ) use ( $isTodayInCurrentMonth, $todayDay ) {
|
||||
$cellClass = '';
|
||||
if ( $day === $todayDay && $curMonth === $todayMonth && $curYear === $todayYear ) {
|
||||
$cellClass = 'today-cell';
|
||||
}
|
||||
echo '<td' . ( $cellClass ? ' class="' . esc_attr( $cellClass ) . '"' : '' ) . '>';
|
||||
echo '<div class="day-number">' . esc_html( $day ) . '</div>';
|
||||
if ( isset( $eventsByDay[ $day ] ) ) {
|
||||
echo '<ul class="events-list">';
|
||||
foreach ( $eventsByDay[ $day ] as $event ) {
|
||||
echo '<li>' . wp_kses_post( $event ) . '</li>';
|
||||
}
|
||||
echo '</ul>';
|
||||
}
|
||||
echo '</td>';
|
||||
$today = new DateTimeImmutable( wp_date( 'Y-m-d' ) . ' 00:00:00', $tz );
|
||||
$prev = $month['month_start']->modify( '-1 month' );
|
||||
$next = $month['month_start']->modify( '+1 month' );
|
||||
$heading_id = wp_unique_id( 'aa-events-calendar-heading-' );
|
||||
$agenda_heading_id = wp_unique_id( 'aa-events-calendar-agenda-heading-' );
|
||||
$month_label = wp_date( 'F Y', $month['month_start']->getTimestamp(), $tz );
|
||||
$current_url = add_query_arg( array() );
|
||||
$calendar_base_url = remove_query_arg( array( $month_param, $year_param, 'month', 'year' ), $current_url );
|
||||
$calendar_url = static function ( DateTimeImmutable $date ) use ( $calendar_base_url, $month_param, $year_param ) {
|
||||
return add_query_arg(
|
||||
array(
|
||||
$month_param => (int) $date->format( 'n' ),
|
||||
$year_param => (int) $date->format( 'Y' ),
|
||||
),
|
||||
$calendar_base_url
|
||||
);
|
||||
};
|
||||
|
||||
// Overwrite the default day cell output in the table loop.
|
||||
ob_start();
|
||||
for ( $day = 1; $day <= $days_in_month; $day++ ) {
|
||||
if ( ( $day + $first_day - 2 ) % 7 === 0 && $day > 1 ) {
|
||||
echo '</tr><tr>';
|
||||
}
|
||||
$originalEchoDayCell( $day, $first_day, $events_by_day );
|
||||
}
|
||||
$calendarRows = ob_get_clean();
|
||||
|
||||
// Replace the original loop output with the new one.
|
||||
$calendarOutput = preg_replace(
|
||||
'/for\s*\(\s*\$day\s*=\s*1;\s*\$day\s*<=\s*\$days_in_month;\s*\$day\+\+\s*\)\s*\{.*?\}/s',
|
||||
$calendarRows,
|
||||
ob_get_contents()
|
||||
);
|
||||
?>
|
||||
|
||||
<div class="aa-events-calendar-wrap container">
|
||||
<div class="aa-events-calendar-today-link" style="margin-bottom: 1em;">
|
||||
<a href="?month=<?php echo esc_attr( $todayMonth ); ?>&year=<?php echo esc_attr( $todayYear ); ?>" class="aa-events-calendar-today-btn">
|
||||
<?php esc_html_e( 'Today', 'aa-events' ); ?>
|
||||
</a>
|
||||
</div>
|
||||
<div class="aa-events-calendar-today-link">
|
||||
<a href="<?php echo esc_url( $calendar_url( $today ) ); ?>" class="aa-events-calendar-today-btn"><?php esc_html_e( 'Today', 'aa-events' ); ?></a>
|
||||
</div>
|
||||
|
||||
<div class="aa-events-calendar-header">
|
||||
<?php
|
||||
// Calculate previous and next month names and years for aria-labels (site timezone)
|
||||
$prev_ts = ( new DateTimeImmutable( sprintf( '%04d-%02d-01 00:00:00', $prev_year, $prev_month ), $tz ) )->getTimestamp();
|
||||
$next_ts = ( new DateTimeImmutable( sprintf( '%04d-%02d-01 00:00:00', $next_year, $next_month ), $tz ) )->getTimestamp();
|
||||
$prevMonthName = wp_date( 'F', $prev_ts );
|
||||
$nextMonthName = wp_date( 'F', $next_ts );
|
||||
?>
|
||||
<a
|
||||
href="?month=<?php echo esc_attr( $prev_month ); ?>&year=<?php echo esc_attr( $prev_year ); ?>"
|
||||
aria-label="<?php echo esc_attr( "Go to $prevMonthName $prev_year" ); ?>"
|
||||
>« <?php esc_html_e( 'Previous Month', 'aa-events' ); ?></a>
|
||||
<div class="aa-events-calendar-header">
|
||||
<?php /* translators: %s: localized month and year. */ ?>
|
||||
<a href="<?php echo esc_url( $calendar_url( $prev ) ); ?>" aria-label="<?php echo esc_attr( sprintf( __( 'Go to %s', 'aa-events' ), wp_date( 'F Y', $prev->getTimestamp(), $tz ) ) ); ?>">« <?php esc_html_e( 'Previous Month', 'aa-events' ); ?></a>
|
||||
<h2 id="<?php echo esc_attr( $heading_id ); ?>"><?php echo esc_html( $month_label ); ?></h2>
|
||||
<?php /* translators: %s: localized month and year. */ ?>
|
||||
<a href="<?php echo esc_url( $calendar_url( $next ) ); ?>" aria-label="<?php echo esc_attr( sprintf( __( 'Go to %s', 'aa-events' ), wp_date( 'F Y', $next->getTimestamp(), $tz ) ) ); ?>"><?php esc_html_e( 'Next Month', 'aa-events' ); ?> »</a>
|
||||
</div>
|
||||
|
||||
<h2><?php echo esc_html( wp_date( 'F Y', $first_ts ) ); ?></h2>
|
||||
<div class="aa-events-calendar-desktop">
|
||||
<div class="aa-events-calendar-grid" role="grid" aria-labelledby="<?php echo esc_attr( $heading_id ); ?>">
|
||||
<div class="aa-events-calendar-weekdays" role="row">
|
||||
<?php foreach ( $ordered_weekdays as $weekday ) : ?>
|
||||
<div class="aa-events-calendar-weekday" role="columnheader"><abbr title="<?php echo esc_attr( $weekday['label'] ); ?>"><?php echo esc_html( $weekday['abbr'] ); ?></abbr></div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php foreach ( $calendar_weeks as $week ) : ?>
|
||||
<?php
|
||||
$segments_by_column = array_fill( 0, 7, array() );
|
||||
foreach ( $week['segments'] as $segment ) {
|
||||
$segment_column = (int) $segment['start_column'];
|
||||
if ( isset( $segments_by_column[ $segment_column ] ) ) {
|
||||
$segments_by_column[ $segment_column ][] = $segment;
|
||||
}
|
||||
}
|
||||
?>
|
||||
<div class="aa-events-calendar-week" role="row" style="--aa-event-lanes: <?php echo esc_attr( max( 1, (int) $week['lane_count'] ) ); ?>;">
|
||||
<?php foreach ( $week['days'] as $day_column => $day ) : ?>
|
||||
<?php
|
||||
$day_classes = array( 'aa-events-calendar-day' );
|
||||
if ( $day < $month['month_start'] || $day > $month['month_end'] ) {
|
||||
$day_classes[] = 'is-outside-month';
|
||||
}
|
||||
if ( $day->format( 'Y-m-d' ) === $today->format( 'Y-m-d' ) ) {
|
||||
$day_classes[] = 'today-cell';
|
||||
}
|
||||
?>
|
||||
<div class="<?php echo esc_attr( implode( ' ', $day_classes ) ); ?>" role="gridcell" aria-label="<?php echo esc_attr( wp_date( 'F j, Y', $day->getTimestamp(), $tz ) ); ?>">
|
||||
<span class="day-number"><?php echo esc_html( wp_date( 'j', $day->getTimestamp(), $tz ) ); ?></span>
|
||||
<?php foreach ( $segments_by_column[ $day_column ] as $segment ) : ?>
|
||||
<?php
|
||||
$event_classes = array( 'aa-events-calendar-event' );
|
||||
if ( ! empty( $segment['continues_before'] ) ) {
|
||||
$event_classes[] = 'is-continuing-before';
|
||||
}
|
||||
if ( ! empty( $segment['continues_after'] ) ) {
|
||||
$event_classes[] = 'is-continuing-after';
|
||||
}
|
||||
/* translators: 1: event title, 2: complete event date and time range. */
|
||||
$event_label = sprintf( __( '%1$s: %2$s', 'aa-events' ), $segment['title'], aa_events_format_occurrence( $segment ) );
|
||||
?>
|
||||
<a class="<?php echo esc_attr( implode( ' ', $event_classes ) ); ?>" style="--aa-event-column: <?php echo esc_attr( (int) $segment['start_column'] ); ?>; --aa-event-span: <?php echo esc_attr( (int) $segment['span'] ); ?>; --aa-event-lane: <?php echo esc_attr( (int) $segment['lane'] ); ?>;" href="<?php echo esc_url( $segment['permalink'] ); ?>" aria-label="<?php echo esc_attr( $event_label ); ?>">
|
||||
<?php
|
||||
if ( ! empty( $segment['continues_before'] ) ) :
|
||||
?>
|
||||
<span class="aa-events-calendar-continuation" aria-hidden="true">←</span><?php endif; ?>
|
||||
<span aria-hidden="true"><?php echo esc_html( $segment['title'] ); ?></span>
|
||||
<?php
|
||||
if ( ! empty( $segment['continues_after'] ) ) :
|
||||
?>
|
||||
<span class="aa-events-calendar-continuation" aria-hidden="true">→</span><?php endif; ?>
|
||||
</a>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a
|
||||
href="?month=<?php echo esc_attr( $next_month ); ?>&year=<?php echo esc_attr( $next_year ); ?>"
|
||||
aria-label="<?php echo esc_attr( "Go to $nextMonthName $next_year" ); ?>"
|
||||
><?php esc_html_e( 'Next Month', 'aa-events' ); ?> »</a>
|
||||
</div>
|
||||
|
||||
<table class="aa-events-calendar" role="grid" aria-labelledby="calendar-heading">
|
||||
<caption id="calendar-heading" class="screen-reader-text"><?php echo esc_html( gmdate( 'F Y', strtotime( "$curYear-$curMonth-01" ) ) ); ?> <?php esc_html_e( 'Events Calendar', 'aa-events' ); ?></caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col" abbr="<?php esc_attr_e( 'Mon', 'aa-events' ); ?>"><?php esc_html_e( 'Monday', 'aa-events' ); ?></th>
|
||||
<th scope="col" abbr="<?php esc_attr_e( 'Tue', 'aa-events' ); ?>"><?php esc_html_e( 'Tuesday', 'aa-events' ); ?></th>
|
||||
<th scope="col" abbr="<?php esc_attr_e( 'Wed', 'aa-events' ); ?>"><?php esc_html_e( 'Wednesday', 'aa-events' ); ?></th>
|
||||
<th scope="col" abbr="<?php esc_attr_e( 'Thu', 'aa-events' ); ?>"><?php esc_html_e( 'Thursday', 'aa-events' ); ?></th>
|
||||
<th scope="col" abbr="<?php esc_attr_e( 'Fri', 'aa-events' ); ?>"><?php esc_html_e( 'Friday', 'aa-events' ); ?></th>
|
||||
<th scope="col" abbr="<?php esc_attr_e( 'Sat', 'aa-events' ); ?>"><?php esc_html_e( 'Saturday', 'aa-events' ); ?></th>
|
||||
<th scope="col" abbr="<?php esc_attr_e( 'Sun', 'aa-events' ); ?>"><?php esc_html_e( 'Sunday', 'aa-events' ); ?></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<?php
|
||||
// Add empty cells for the first week.
|
||||
for ( $i = 1; $i < $first_day; $i++ ) {
|
||||
echo '<td role="gridcell" class="empty-cell"></td>';
|
||||
}
|
||||
|
||||
// Loop through the days of the month.
|
||||
for ( $day = 1; $day <= $days_in_month; $day++ ) {
|
||||
if ( ( $day + $first_day - 2 ) % 7 === 0 && $day > 1 ) {
|
||||
echo '</tr><tr>';
|
||||
}
|
||||
|
||||
$cellClass = '';
|
||||
|
||||
if ( $isTodayInCurrentMonth && $day === $todayDay ) {
|
||||
$cellClass = 'today-cell';
|
||||
}
|
||||
|
||||
$cell_ts = ( new DateTimeImmutable( sprintf( '%04d-%02d-%02d 00:00:00', $curYear, $curMonth, $day ), $tz ) )->getTimestamp();
|
||||
echo '<td role="gridcell" aria-label="' . esc_attr( wp_date( 'F j, Y', $cell_ts ) ) . '" ' . ( $cellClass ? ' class="' . esc_attr( $cellClass ) . '"' : '' ) . '>';
|
||||
|
||||
echo '<div class="day-number">' . esc_html( $day ) . '</div>';
|
||||
|
||||
if ( isset( $events_by_day[ $day ] ) ) {
|
||||
echo '<ul class="events-list">';
|
||||
|
||||
foreach ( $events_by_day[ $day ] as $event ) {
|
||||
echo '<li>' . wp_kses_post( $event ) . '</li>';
|
||||
}
|
||||
|
||||
echo '</ul>';
|
||||
}
|
||||
echo '</td>';
|
||||
}
|
||||
|
||||
// Add empty cells for the last week.
|
||||
$remaining_days = 7 - ( ( $days_in_month + $first_day - 1 ) % 7 );
|
||||
|
||||
if ( $remaining_days < 7 ) {
|
||||
for ( $i = 0; $i < $remaining_days; $i++ ) {
|
||||
echo '<td class="empty-cell"></td>';
|
||||
}
|
||||
}
|
||||
?>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<section class="aa-events-calendar-agenda" aria-labelledby="<?php echo esc_attr( $agenda_heading_id ); ?>">
|
||||
<?php /* translators: %s: localized month and year. */ ?>
|
||||
<h3 id="<?php echo esc_attr( $agenda_heading_id ); ?>"><?php echo esc_html( sprintf( __( 'Events for %s', 'aa-events' ), $month_label ) ); ?></h3>
|
||||
<?php if ( $calendar_occurrences ) : ?>
|
||||
<ol class="aa-events-calendar-agenda-list">
|
||||
<?php foreach ( $calendar_occurrences as $occurrence ) : ?>
|
||||
<li class="aa-events-agenda-item"><a href="<?php echo esc_url( $occurrence['permalink'] ); ?>"><span class="aa-events-agenda-title"><?php echo esc_html( $occurrence['title'] ); ?></span> <?php echo aa_events_occurrence_time_markup( $occurrence ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Helper returns escaped markup. ?></a></li>
|
||||
<?php endforeach; ?>
|
||||
</ol>
|
||||
<?php else : ?>
|
||||
<p class="aa-events-calendar-no-events"><?php esc_html_e( 'No events this month.', 'aa-events' ); ?></p>
|
||||
<?php endif; ?>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
+16
-10
@@ -35,21 +35,19 @@ get_header(); ?>
|
||||
|
||||
<footer class="entry-footer">
|
||||
<div class="aa-event-details">
|
||||
<?php $occurrences = aa_events_get_occurrences( get_the_ID() ); ?>
|
||||
<?php if ( $occurrences ) : ?>
|
||||
<div class="aa-event-date">
|
||||
<strong><?php esc_html_e( 'Date:', 'aa-events' ); ?></strong>
|
||||
<strong><?php echo esc_html( 1 === count( $occurrences ) ? __( 'Date:', 'aa-events' ) : __( 'Dates:', 'aa-events' ) ); ?></strong>
|
||||
<?php
|
||||
$event_raw = get_field( 'event_datetime' );
|
||||
if ( $event_raw ) {
|
||||
$tz = wp_timezone();
|
||||
$ts = ( new DateTimeImmutable( $event_raw, $tz ) )->getTimestamp();
|
||||
$human = wp_date( 'F j, Y, g:i A', $ts );
|
||||
$iso_attr = wp_date( 'c', $ts );
|
||||
?>
|
||||
<time datetime="<?php echo esc_attr( $iso_attr ); ?>"><?php echo esc_html( $human ); ?></time>
|
||||
<?php
|
||||
|
||||
foreach ( $occurrences as $index => $occurrence ) {
|
||||
echo aa_events_occurrence_time_markup( $occurrence ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Helper returns escaped markup.
|
||||
echo ( $index < count( $occurrences ) - 1 ) ? '<br />' : '';
|
||||
}
|
||||
?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ( get_field( 'event_cost' ) ) { ?>
|
||||
<div class="aa-event-cost">
|
||||
@@ -73,6 +71,14 @@ get_header(); ?>
|
||||
?>
|
||||
</div>
|
||||
|
||||
<?php $contact_email_markup = aa_events_contact_email_markup( get_field( 'event_contact_email' ) ); ?>
|
||||
<?php if ( $contact_email_markup ) : ?>
|
||||
<div class="aa-event-contact-email">
|
||||
<strong><?php echo esc_html( __( 'Contact Email:', 'aa-events' ) ); ?></strong>
|
||||
<?php echo wp_kses_post( $contact_email_markup ); ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ( get_the_terms( get_the_ID(), 'event_type' ) ) { ?>
|
||||
<div class="aa-event-type">
|
||||
<strong><?php esc_html_e( 'Type:', 'aa-events' ); ?></strong>
|
||||
|
||||
Reference in New Issue
Block a user