# Multi-Day Event Occurrences Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Support mixed recurring and multi-day event occurrences, render multi-day bars across the traditional month grid, and provide a chronological mobile agenda.
**Architecture:** Keep `event_dates` as the persisted ACF repeater and normalize all supported field formats through a focused occurrence API. Put date normalization/formatting in `includes/occurrences.php` and pure calendar segmentation/lane logic in `includes/calendar-layout.php`; templates consume those APIs without interpreting raw ACF rows. Desktop rendering uses week containers with a seven-column CSS grid, while mobile renders the same normalized occurrences as an agenda.
**Tech Stack:** PHP 7.4+, WordPress APIs, Advanced Custom Fields, semantic HTML, CSS Grid, dependency-free PHP test runner
---
## File Structure
- Create `includes/occurrences.php`: normalize ACF/legacy values, validate ranges, format occurrence labels, and migrate legacy values on save.
- Create `includes/calendar-layout.php`: calculate month bounds, split occurrences into weekly segments, and assign overlap lanes.
- Create `tests/bootstrap.php`: minimal WordPress/ACF stubs and assertion helpers for dependency-free unit tests.
- Create `tests/test-occurrences.php`: normalization, formatting, validation, and migration tests.
- Create `tests/test-calendar-layout.php`: month intersection, week splitting, and lane-allocation tests.
- Create `tests/run.php`: execute all test files and return a nonzero status on failure.
- Modify `includes/class-aa-events.php`: load the two focused helper modules before post type/template code.
- Modify `includes/acf-fields.php`: extend and relabel the repeater, register validation, and make the legacy field less prominent.
- Modify `assets/js/aa-events-admin.js`: clear/disable time inputs while an occurrence is marked all-day.
- Modify `includes/post-types.php`: replace raw/mismatched repeater reads with normalized occurrences and update admin date sorting.
- Modify `templates/single-event.php`: render formatted occurrence ranges.
- Modify `templates/archive-event.php`: render formatted occurrence ranges.
- Rewrite `templates/calendar.php`: collect intersecting occurrences and render desktop week grids plus mobile agenda markup.
- Modify `assets/css/aa-events.css`: style the week grid, spanning bars, continuations, focus states, and mobile agenda.
- Modify `templates/aa-events.css`: keep the documented theme-copy stylesheet synchronized with the bundled stylesheet.
- Modify `README.md`: document occurrence fields, compatibility, responsive behavior, and theme-override implications.
The current directory is not a Git worktree. Commit steps below are required once this plugin is placed in a repository; until then, record each checkpoint with test output and skip only the `git add`/`git commit` commands.
### Task 1: Add a Dependency-Free PHP Test Harness
**Files:**
- Create: `tests/bootstrap.php`
- Create: `tests/run.php`
- [ ] **Step 1: Create WordPress and ACF test doubles**
Create `tests/bootstrap.php` with mutable field storage and strict assertion helpers:
```php
setTimezone( $timezone ?: wp_timezone() )->format( $format );
}
function __( $text ) { return $text; }
function _x( $text ) { return $text; }
function aa_events_test_reset() {
$GLOBALS['aa_events_test_fields'] = array();
$GLOBALS['aa_events_test_updates'] = array();
}
function aa_events_assert_same( $expected, $actual, $message ) {
if ( $expected !== $actual ) {
++$GLOBALS['aa_events_test_failures'];
fwrite( STDERR, "FAIL: {$message}\nExpected: " . var_export( $expected, true ) . "\nActual: " . var_export( $actual, true ) . "\n" );
}
}
function aa_events_assert_true( $actual, $message ) {
aa_events_assert_same( true, (bool) $actual, $message );
}
```
- [ ] **Step 2: Create the test entry point**
Create `tests/run.php`:
```php
0 ) {
fwrite( STDERR, $GLOBALS['aa_events_test_failures'] . " test(s) failed.\n" );
exit( 1 );
}
fwrite( STDOUT, "All AA Events tests passed.\n" );
```
- [ ] **Step 3: Run the empty harness**
Run: `php tests/run.php`
Expected: FAIL because `includes/occurrences.php` does not exist. This confirms the test command detects missing production code.
- [ ] **Step 4: Commit the harness when Git is available**
```bash
git add tests/bootstrap.php tests/run.php
git commit -m "test: add lightweight event helper harness"
```
### Task 2: Normalize and Format Occurrences
**Files:**
- Create: `tests/test-occurrences.php`
- Create: `includes/occurrences.php`
- Modify: `includes/class-aa-events.php`
- [ ] **Step 1: Write failing normalization tests**
Create `tests/test-occurrences.php` with these cases:
```php
'2026-07-10',
'start_time' => '18:00:00',
'end_date' => '2026-07-12',
'end_time' => '14:00:00',
'all_day' => false,
),
array(
'date' => '2026-07-20',
'start_time' => '',
'end_date' => '',
'end_time' => '',
'all_day' => true,
),
);
$occurrences = aa_events_get_occurrences( 10 );
aa_events_assert_same( 2, count( $occurrences ), 'Repeater rows normalize.' );
aa_events_assert_same( '2026-07-12', $occurrences[0]['end_date'], 'Explicit end date is retained.' );
aa_events_assert_same( '2026-07-20', $occurrences[1]['end_date'], 'Missing end date defaults to start date.' );
aa_events_assert_true( $occurrences[1]['all_day'], 'All-day flag normalizes to boolean.' );
aa_events_test_reset();
$GLOBALS['aa_events_test_fields'][11]['event_datetime'] = '2026-08-03 09:30:00';
$legacy = aa_events_get_occurrences( 11 );
aa_events_assert_same( '2026-08-03', $legacy[0]['start_date'], 'Legacy datetime supplies start date.' );
aa_events_assert_same( '09:30:00', $legacy[0]['start_time'], 'Legacy datetime supplies start time.' );
aa_events_assert_same( 'event_datetime', $legacy[0]['source'], 'Legacy source is identified.' );
aa_events_test_reset();
$GLOBALS['aa_events_test_fields'][12]['date'] = '2026-09-01';
$GLOBALS['aa_events_test_fields'][12]['start_time'] = '11:00:00';
$separate = aa_events_get_occurrences( 12 );
aa_events_assert_same( 'legacy_separate', $separate[0]['source'], 'Separate legacy fields normalize.' );
aa_events_test_reset();
$GLOBALS['aa_events_test_fields'][13]['event_dates'] = array(
array( 'date' => '', 'end_date' => '2026-07-02' ),
array( 'date' => 'not-a-date' ),
);
aa_events_assert_same( array(), aa_events_get_occurrences( 13 ), 'Malformed rows are skipped.' );
```
- [ ] **Step 2: Add failing validation and formatting tests**
Append to `tests/test-occurrences.php`:
```php
aa_events_assert_same(
'End date cannot be earlier than start date.',
aa_events_validate_occurrence_row(
array( 'date' => '2026-07-12', 'end_date' => '2026-07-10' )
),
'Backwards date range is rejected.'
);
aa_events_assert_same(
'End time cannot be earlier than start time on the same date.',
aa_events_validate_occurrence_row(
array(
'date' => '2026-07-10',
'start_time' => '18:00:00',
'end_time' => '14:00:00',
)
),
'Backwards same-day time range is rejected.'
);
aa_events_assert_same(
'',
aa_events_validate_occurrence_row(
array( 'date' => '2026-07-10', 'end_date' => '2026-07-12', 'all_day' => true )
),
'Valid all-day range passes.'
);
$timed = aa_events_normalize_occurrence_row(
array(
'date' => '2026-07-10',
'start_time' => '18:00:00',
'end_date' => '2026-07-12',
'end_time' => '14:00:00',
),
'event_dates'
);
aa_events_assert_same(
'July 10, 2026, 6:00 PM–July 12, 2026, 2:00 PM',
aa_events_format_occurrence( $timed ),
'Timed multi-day range formats both endpoints.'
);
$all_day = aa_events_normalize_occurrence_row(
array( 'date' => '2026-07-10', 'end_date' => '2026-07-12', 'all_day' => true ),
'event_dates'
);
aa_events_assert_same(
'July 10–12, 2026 — All day',
aa_events_format_occurrence( $all_day ),
'All-day range uses compact localized dates.'
);
```
- [ ] **Step 3: Run tests to verify they fail**
Run: `php tests/run.php`
Expected: FAIL with undefined occurrence function errors.
- [ ] **Step 4: Implement the occurrence API**
Create `includes/occurrences.php`. Implement these exact public functions:
```php
function aa_events_normalize_occurrence_row( array $row, $source = 'event_dates', $post_id = 0 )
function aa_events_get_occurrences( $post_id )
function aa_events_validate_occurrence_row( array $row )
function aa_events_format_occurrence( array $occurrence )
function aa_events_occurrence_start( array $occurrence )
function aa_events_occurrence_end( array $occurrence )
function aa_events_migrate_legacy_occurrence( $post_id )
```
`aa_events_normalize_occurrence_row()` must return `null` for invalid start dates and otherwise return this stable shape:
```php
array(
'post_id' => (int) $post_id,
'start_date' => 'Y-m-d',
'start_time' => 'H:i:s or empty string',
'end_date' => 'Y-m-d',
'end_time' => 'H:i:s or empty string',
'all_day' => (bool) $all_day,
'source' => 'event_dates|event_datetime|legacy_separate',
)
```
Use strict `DateTimeImmutable::createFromFormat()` parsing in `wp_timezone()`, check `DateTimeImmutable::getLastErrors()` when it returns an array, ignore times when `all_day` is true, and sort normalized occurrences by `aa_events_occurrence_start()`.
`aa_events_format_occurrence()` must use `wp_date()` for every visible date/time and cover the five formats specified in the design. Use an en dash between endpoints and an em dash before “All day.”
- [ ] **Step 5: Load occurrence helpers before consumers**
In `AA_Events::includes()`, add this as the first include:
```php
include_once AA_EVENTS_PLUGIN_DIR . 'includes/occurrences.php';
```
- [ ] **Step 6: Run occurrence tests**
Run: `php tests/run.php`
Expected: occurrence assertions pass; execution then fails because calendar-layout files do not yet exist.
- [ ] **Step 7: Commit the normalized occurrence API when Git is available**
```bash
git add includes/occurrences.php includes/class-aa-events.php tests/test-occurrences.php
git commit -m "feat: normalize event occurrences"
```
### Task 3: Extend the ACF Editor, Validate Rows, and Migrate Legacy Data
**Files:**
- Modify: `includes/acf-fields.php`
- Modify: `includes/occurrences.php`
- Modify: `assets/js/aa-events-admin.js`
- Modify: `tests/test-occurrences.php`
- [ ] **Step 1: Write failing migration tests**
Append to `tests/test-occurrences.php`:
```php
aa_events_test_reset();
$GLOBALS['aa_events_test_fields'][20]['event_datetime'] = '2026-10-05 13:15:00';
aa_events_assert_true( aa_events_migrate_legacy_occurrence( 20 ), 'Legacy value migrates.' );
aa_events_assert_same(
array(
array(
'date' => '2026-10-05',
'start_time' => '13:15:00',
'end_date' => '',
'end_time' => '',
'all_day' => 0,
),
),
$GLOBALS['aa_events_test_fields'][20]['event_dates'],
'Migration writes one compatible repeater row.'
);
aa_events_assert_same( false, aa_events_migrate_legacy_occurrence( 20 ), 'Second migration is idempotent.' );
aa_events_assert_same( 1, count( $GLOBALS['aa_events_test_updates'] ), 'Second migration writes nothing.' );
```
- [ ] **Step 2: Run migration test to verify it fails**
Run: `php tests/run.php`
Expected: FAIL because migration is not yet implemented or does not write the required row shape.
- [ ] **Step 3: Implement idempotent migration and save hook**
Complete `aa_events_migrate_legacy_occurrence()` so it returns `false` when `event_dates` already has rows or no parseable legacy value exists. Otherwise, call `update_field( 'event_dates', array( $row ), $post_id )` and return its boolean result.
Register a save callback that ignores autosaves/revisions and non-event posts:
```php
function aa_events_migrate_legacy_on_save( $post_id ) {
if ( wp_is_post_autosave( $post_id ) || wp_is_post_revision( $post_id ) ) {
return;
}
if ( 'event' !== get_post_type( $post_id ) ) {
return;
}
aa_events_migrate_legacy_occurrence( $post_id );
}
add_action( 'acf/save_post', 'aa_events_migrate_legacy_on_save', 20 );
```
- [ ] **Step 4: Replace the built-in repeater definition**
In `includes/acf-fields.php`, retain field key `field_61b0c7f0a3e8f` and name `event_dates`, but change its label/button/instructions to:
```php
'label' => 'Occurrences',
'instructions' => 'Each row is one occurrence. Add an end date for a continuous multi-day event; add more rows when the event recurs.',
'layout' => 'row',
'button_label' => 'Add Occurrence',
```
Keep `date` and `start_time`, then add subfields with unique stable keys:
```php
array(
'key' => 'field_aa_events_end_date',
'label' => 'End Date',
'name' => 'end_date',
'type' => 'date_picker',
'required' => 0,
'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',
'required' => 0,
'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',
'ui' => 1,
'default_value' => 0,
),
```
Change the legacy `event_datetime` instructions to “Legacy compatibility field. Use Occurrences for new events.”
- [ ] **Step 5: Register row validation through ACF**
Add `acf/validate_value/key=field_61b0c7f0a3e8f` validation that loops submitted rows, maps ACF subfield keys to semantic names, calls `aa_events_validate_occurrence_row()`, and returns a translated row-numbered error such as `Occurrence 2: End date cannot be earlier than start date.` Return the incoming `$valid` unchanged when it is already an error.
- [ ] **Step 6: Implement all-day editor behavior**
In `assets/js/aa-events-admin.js`, add a delegated `change` handler scoped to `.acf-field[data-name="event_dates"]`. For each repeater row, toggle an `aa-events-is-all-day` class, disable the `start_time` and `end_time` inputs while checked, and clear those inputs when the checkbox changes from unchecked to checked. Run this synchronization on ACF `ready` and `append` actions.
- [ ] **Step 7: Run tests and PHP syntax checks**
Run:
```bash
php tests/run.php
php -l includes/acf-fields.php
php -l includes/occurrences.php
```
Expected: all occurrence tests pass and both files report `No syntax errors detected`.
- [ ] **Step 8: Commit editor and migration behavior when Git is available**
```bash
git add includes/acf-fields.php includes/occurrences.php assets/js/aa-events-admin.js tests/test-occurrences.php
git commit -m "feat: add occurrence ranges to event editor"
```
### Task 4: Build and Test Calendar Segmentation
**Files:**
- Create: `tests/test-calendar-layout.php`
- Create: `includes/calendar-layout.php`
- Modify: `includes/class-aa-events.php`
- [ ] **Step 1: Write failing week-segment tests**
Create `tests/test-calendar-layout.php`:
```php
format( 'Y-m-d' ), 'Monday-start grid begins before month.' );
aa_events_assert_same( '2026-08-02', $month['grid_end']->format( 'Y-m-d' ), 'Grid ends after complete final week.' );
$occurrence = aa_events_normalize_occurrence_row(
array(
'date' => '2026-07-10',
'start_time' => '18:00:00',
'end_date' => '2026-07-15',
'end_time' => '14:00:00',
),
'event_dates',
30
);
$segments = aa_events_calendar_segments( $occurrence, $month );
aa_events_assert_same( 2, count( $segments ), 'Occurrence splits at week boundary.' );
aa_events_assert_same( 4, $segments[0]['start_column'], 'Friday is column four in Monday-start grid.' );
aa_events_assert_same( 3, $segments[0]['span'], 'First segment covers Friday through Sunday.' );
aa_events_assert_true( $segments[0]['continues_after'], 'First segment continues next week.' );
aa_events_assert_same( 0, $segments[1]['start_column'], 'Second segment starts Monday.' );
aa_events_assert_same( 3, $segments[1]['span'], 'Second segment covers Monday through Wednesday.' );
aa_events_assert_true( $segments[1]['continues_before'], 'Second segment continued from prior week.' );
```
- [ ] **Step 2: Add failing clipping and lane tests**
Append:
```php
$cross_month = aa_events_normalize_occurrence_row(
array( 'date' => '2026-06-25', 'end_date' => '2026-07-03', 'all_day' => true ),
'event_dates',
31
);
$clipped = aa_events_calendar_segments( $cross_month, $month );
aa_events_assert_true( $clipped[0]['continues_before'], 'Month-clipped segment shows prior continuation.' );
$week_segments = array(
array( 'start_column' => 0, 'span' => 3, 'post_id' => 1 ),
array( 'start_column' => 2, 'span' => 2, 'post_id' => 2 ),
array( 'start_column' => 4, 'span' => 2, 'post_id' => 3 ),
);
$lanes = aa_events_assign_segment_lanes( $week_segments );
aa_events_assert_same( 0, $lanes[0]['lane'], 'First segment uses first lane.' );
aa_events_assert_same( 1, $lanes[1]['lane'], 'Overlapping segment uses second lane.' );
aa_events_assert_same( 0, $lanes[2]['lane'], 'Non-overlapping segment reuses first lane.' );
```
- [ ] **Step 3: Run tests to verify failure**
Run: `php tests/run.php`
Expected: FAIL with undefined calendar-layout functions.
- [ ] **Step 4: Implement pure layout functions**
Create `includes/calendar-layout.php` with these public functions:
```php
function aa_events_calendar_month( $year, $month, $start_of_week )
function aa_events_occurrence_intersects_range( array $occurrence, DateTimeImmutable $start, DateTimeImmutable $end )
function aa_events_calendar_segments( array $occurrence, array $month )
function aa_events_assign_segment_lanes( array $segments )
function aa_events_group_calendar_weeks( array $occurrences, array $month )
```
`aa_events_calendar_month()` must return `month_start`, `month_end`, `grid_start`, `grid_end`, `start_of_week`, and an ordered `weeks` array. Each week contains `start`, `end`, and seven `days`.
`aa_events_calendar_segments()` must clip to `month_start`/`month_end`, split at each configured week boundary, use zero-based columns, and set `continues_before`/`continues_after` relative to the true occurrence interval.
`aa_events_assign_segment_lanes()` must sort by start column, then longest span, then post ID. Assign the first lane whose occupied end column is strictly before the segment start column.
`aa_events_group_calendar_weeks()` must return each week with lane-assigned segments and `lane_count`, preserving complete occurrence data on every segment for template labels.
- [ ] **Step 5: Load the layout helper**
In `AA_Events::includes()`, immediately after the occurrence include, add:
```php
include_once AA_EVENTS_PLUGIN_DIR . 'includes/calendar-layout.php';
```
- [ ] **Step 6: Run layout tests**
Run: `php tests/run.php`
Expected: `All AA Events tests passed.`
- [ ] **Step 7: Commit layout logic when Git is available**
```bash
git add includes/calendar-layout.php includes/class-aa-events.php tests/test-calendar-layout.php
git commit -m "feat: calculate multi-day calendar segments"
```
### Task 5: Move All Existing Consumers to the Occurrence API
**Files:**
- Modify: `includes/post-types.php`
- Modify: `templates/single-event.php`
- Modify: `templates/archive-event.php`
- [ ] **Step 1: Replace the compatibility helper**
Keep `events_get_all_event_dates()` as a deprecated compatibility wrapper so theme overrides do not fatally break:
```php
function events_get_all_event_dates( $post_id ) {
$dates = array();
foreach ( aa_events_get_occurrences( $post_id ) as $occurrence ) {
$dates[] = array(
'date' => $occurrence['start_date'],
'time' => $occurrence['start_time'],
'end_date' => $occurrence['end_date'],
'end_time' => $occurrence['end_time'],
'all_day' => $occurrence['all_day'],
);
}
return $dates;
}
```
Remove direct reads of the nonexistent/mismatched `multiple_dates` field from admin display code.
- [ ] **Step 2: Update the admin column display**
Call `aa_events_get_occurrences( $post_id )`, display `aa_events_format_occurrence( $occurrences[0] )`, and retain the existing `+N` badge with escaped count. If no normalized occurrence exists, render an em dash.
- [ ] **Step 3: Correct admin SQL sorting keys**
Change the first repeater date join from `multiple_dates_0_date` to `event_dates_0_date`. Keep the `COALESCE` fallback order `event_dates_0_date`, `event_datetime`, `date`, post date. Add `$wpdb->posts.ID` as a final deterministic ordering key.
- [ ] **Step 4: Update archive and single templates**
In both templates, replace local timestamp construction with:
```php
$occurrences = aa_events_get_occurrences( get_the_ID() );
foreach ( $occurrences as $index => $occurrence ) {
$start = aa_events_occurrence_start( $occurrence );
printf(
'%3$s',
esc_attr( $start->format( DATE_W3C ) ),
esc_html( aa_events_format_occurrence( $occurrence ) ),
$index < count( $occurrences ) - 1 ? '
' : ''
);
}
```
Change the visible label from “Date:” to the plural-aware `Dates:` only when more than one occurrence exists.
- [ ] **Step 5: Run tests and syntax checks**
Run:
```bash
php tests/run.php
php -l includes/post-types.php
php -l templates/single-event.php
php -l templates/archive-event.php
```
Expected: tests pass and every PHP file reports no syntax errors.
- [ ] **Step 6: Commit consumer updates when Git is available**
```bash
git add includes/post-types.php templates/single-event.php templates/archive-event.php
git commit -m "refactor: render normalized event occurrences"
```
### Task 6: Render the Desktop Month Grid and Mobile Agenda
**Files:**
- Modify: `templates/calendar.php`
- [ ] **Step 1: Replace per-day event collection**
Retain validated navigation parameters and localized weekday construction. Replace `$events_by_day` with `$calendar_occurrences`. For each published event, append every normalized occurrence that intersects `$month['month_start']` through `$month['month_end']`, adding `title`, `permalink`, and `post_id` to the normalized array.
Sort the mobile list with start timestamp, then title:
```php
usort(
$calendar_occurrences,
function ( $a, $b ) {
$comparison = aa_events_occurrence_start( $a ) <=> aa_events_occurrence_start( $b );
return 0 !== $comparison ? $comparison : strcasecmp( $a['title'], $b['title'] );
}
);
```
Pass the occurrences into `aa_events_group_calendar_weeks()` for desktop rendering.
- [ ] **Step 2: Render semantic desktop week grids**
Replace the event-bearing table body with `.aa-events-calendar-grid`, while retaining a visually aligned weekday header. For each week render:
```php