Remove docs folder

This commit is contained in:
Keith Solomon
2026-07-30 12:44:50 -05:00
parent 2de5491661
commit 0fc424b035
11 changed files with 0 additions and 2731 deletions
-155
View File
@@ -1,155 +0,0 @@
# Multiple Event Dates Implementation Plan
> **Goal:** Add support for events with multiple dates/times using an ACF repeater field, with backward compatibility for legacy `event_datetime` and `date`/`start_time` fields.
> **Architecture:** Add a new ACF repeater field group `event_dates` containing date and start_time sub-fields. Update display logic to prioritize repeater, then fall back to legacy fields. Update sorting to use earliest date from repeater. Add migration helper for data consolidation.
> **Tech Stack:** Advanced Custom Fields (ACF), WordPress query (WP_Query), ACF repeater fields
---
## File Structure Changes
**Modified Files:**
- `includes/acf-fields.php` - Add repeater field to ACF group
- `includes/post-types.php` - Update display and sorting logic
- `templates/calendar.php` - Update to use repeater field (if needed)
**No new files needed initially** - Keep changes focused.
---
## Task 1: Add ACF Repeater Field Group
**Files:**
- Modify: `includes/acf-fields.php`
- [ ] **Step 1: Add repeater field definition to ACF group**
Insert a new field in the `$fields` array that defines a repeater group with `date` and `start_time` sub-fields. The repeater should:
- Be named `event_dates`
- Allow multiple rows
- Contain sub-fields: `date` (date picker) and `start_time` (time picker)
- Be positioned after existing fields but before location fields
- Have conditional logic to display only if no external admin field group exists
Insert this after `event_cost` field and before `event_location_type`.
- [ ] **Step 2: Verify ACF field appears in post editor**
Test that the field group loads and the repeater appears in the post editor for event CPT.
---
## Task 2: Update Admin Column Display
**Files:**
- Modify: `includes/post-types.php` - function `events_display_admin_column_content()`
- [ ] **Step 1: Update display logic to use repeater**
Modify the column display function to:
1. Check if `event_dates` repeater has rows
- If yes: display first date/time + badge showing total count (e.g., "Jan 5, 2025 2:00 PM (3 dates)")
- If no: fall back to legacy `event_datetime` field
- If no legacy field: fall back to new `date`/`start_time` fields
Logic flow:
```
if has repeater rows:
show first date/time + " (X dates)" badge
elif has event_datetime:
show event_datetime
elif has date field:
show date + start_time
else:
show nothing
```
- [ ] **Step 2: Test display in admin list**
Verify the column shows:
- First date with count badge for events with repeater data
- Legacy format for old events
- Nothing for events with no dates
---
## Task 3: Update Sorting Logic
**Files:**
- Modify: `includes/post-types.php` - functions `events_get_sortable_meta_map()` and `events_sort_by_custom_column()`
- [ ] **Step 1: Add repeater field to sortable map**
Note: ACF repeater fields cannot be directly sorted by `orderby=meta_value`. Instead, we'll sort by the earliest date.
- [ ] **Step 2: Update sort handler**
When sorting by `event_datetime` column:
1. If repeater has data: query by first row's `event_dates_0_date` meta key (ACF stores repeater rows as sequential meta keys)
2. If repeater empty: use legacy sort (event_datetime or date field)
This ensures posts sort by their earliest event date.
- [ ] **Step 3: Test sorting**
Verify that:
- Posts with repeater data sort by earliest date
- Legacy posts sort correctly
- Mixed results display in correct order
---
## Task 4: Update Calendar Template (if needed)
**Files:**
- Check: `templates/calendar.php`
- [ ] **Step 1: Inspect calendar usage**
Review how calendar.php queries and displays events. Determine if it needs updates to handle repeater field.
- [ ] **Step 2: Update if necessary**
If calendar shows single events per date:
- Update query to use earliest date from repeater
- Add note in template comments about repeater structure
---
## Task 5: Document Migration Path (Optional)
**Files:**
- Create reference docs in README or code comments
- [ ] **Step 1: Document field deprecation**
Add comments in `acf-fields.php` explaining:
- Legacy `event_datetime` field is deprecated but still supported
- New events should use `event_dates` repeater
- Old events continue to work via fallback logic
- [ ] **Step 2: Add helper function comment**
Document that a future migration tool could consolidate old data into repeater format, but it's not urgent since fallback works.
---
## Implementation Notes
- **ACF Repeater Meta Storage:** ACF stores repeater rows as numbered meta keys: `event_dates_0_date`, `event_dates_0_start_time`, `event_dates_1_date`, etc.
- **Backward Compatibility:** Never remove old fields—just add fallback logic in display/sort functions.
- **Performance:** Querying repeater fields for sorting can be slow on large datasets. Consider date-based sorting as primary option if performance degrades.
- **Frontend:** Templates on the frontend may also need updates to loop through all dates instead of showing one. This is separate from admin display.
---
## Testing Checklist
- [ ] Post with repeater data displays first date + badge in admin list
- [ ] Post with old `event_datetime` field displays correctly (fallback)
- [ ] Post with new `date`/`start_time` fields displays correctly (second fallback)
- [ ] Admin list sorts correctly by earliest date when column clicked
- [ ] Posts without any date fields show nothing (don't error)
- [ ] Calendar template still works (if uses event dates)
@@ -1,133 +0,0 @@
# Event Contact Email 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:** Add an optional event contact email field and display valid values as protected email links on single-event pages.
**Architecture:** Register `event_contact_email` in the built-in ACF group and read the same field name from built-in or admin-created groups. Keep output in the single-event template, sanitizing before conditional rendering and applying WordPress escaping at the attribute and visible-HTML boundaries.
**Tech Stack:** PHP 7.4+, WordPress template APIs, ACF PRO, existing dependency-free PHP tests
---
### Task 1: Register the Contact Email Field
**Files:**
- Modify: `includes/acf-fields.php`
- Modify: `tests/bootstrap.php`
- Modify: `tests/test-acf-fields.php`
- [ ] **Step 1: Write the failing registration test**
Extend the ACF test double to capture `acf_add_local_field_group()` arguments. In `tests/test-acf-fields.php`, clear admin groups, call `events_register_acf_fields()`, locate the field named `event_contact_email`, and assert this exact contract:
```php
array(
'key' => 'field_aa_events_contact_email',
'label' => 'Contact Email',
'name' => 'event_contact_email',
'type' => 'email',
'required' => 0,
)
```
- [ ] **Step 2: Run the suite and verify RED**
Run: `php tests/run.php`
Expected: FAIL because no field named `event_contact_email` is registered.
- [ ] **Step 3: Add the built-in field**
Add the optional ACF email field after location/URL/address details in `includes/acf-fields.php`, using the stable key and contract above plus standard empty wrapper/default/placeholder settings.
- [ ] **Step 4: Verify GREEN**
Run:
```bash
php tests/run.php
php -l includes/acf-fields.php
```
Expected: all tests pass and syntax is valid.
### Task 2: Render Contact Email on Single Events
**Files:**
- Modify: `templates/single-event.php`
- Modify: `tests/bootstrap.php`
- Create: `tests/test-contact-email.php`
- Modify: `tests/run.php`
- [ ] **Step 1: Write failing output tests**
Add minimal `sanitize_email()` and `antispambot()` doubles if absent. Test a focused helper named `aa_events_contact_email_markup()` with:
```php
aa_events_assert_same( '', aa_events_contact_email_markup( '' ), 'Empty email renders nothing.' );
aa_events_assert_same( '', aa_events_contact_email_markup( 'not an email' ), 'Invalid email renders nothing.' );
$markup = aa_events_contact_email_markup( 'events@example.com' );
aa_events_assert_true( false !== strpos( $markup, 'mailto:' ), 'Valid email has a mailto link.' );
aa_events_assert_true( false !== strpos( $markup, 'events@example.com' ), 'Valid email remains readable.' );
```
Also assert `templates/single-event.php` reads `event_contact_email`, calls the helper, and renders the `Contact Email:` label only when markup is non-empty.
- [ ] **Step 2: Run the suite and verify RED**
Run: `php tests/run.php`
Expected: FAIL because `aa_events_contact_email_markup()` does not exist.
- [ ] **Step 3: Implement safe markup and template output**
Add this focused template helper to `includes/template-loader.php`:
```php
function aa_events_contact_email_markup( $value ) {
$email = sanitize_email( (string) $value );
if ( '' === $email || $email !== trim( (string) $value ) ) {
return '';
}
$protected = antispambot( $email );
return '<a href="' . esc_attr( 'mailto:' . $protected ) . '">' . wp_kses_post( $protected ) . '</a>';
}
```
In `templates/single-event.php`, call the helper with `get_field( 'event_contact_email' )`. When non-empty, render an `.aa-event-contact-email` detail row with localized label `Contact Email:` and output only the helper's already constrained anchor through `wp_kses_post()`.
- [ ] **Step 4: Run complete verification**
Run:
```bash
php tests/run.php
php -l includes/template-loader.php
php -l templates/single-event.php
phpcs --standard=WordPress includes/acf-fields.php includes/template-loader.php templates/single-event.php
```
Expected: tests and syntax pass. Record unrelated pre-existing PHPCS findings separately rather than changing unrelated template code.
### Task 3: Documentation and Final Check
**Files:**
- Modify: `README.md`
- [ ] **Step 1: Document the optional field**
Add Contact Email to the Event Details list and state that it appears only on single-event pages when valid.
- [ ] **Step 2: Run final verification**
Run:
```bash
php tests/run.php
find . -path './.superpowers' -prune -o -name '*.php' -print0 | xargs -0 -n1 php -l
```
Expected: all tests and PHP syntax checks pass.
The workspace is not a Git repository, so commit steps are unavailable.
@@ -1,879 +0,0 @@
# 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
<?php
define( 'ABSPATH', __DIR__ . '/' );
$GLOBALS['aa_events_test_fields'] = array();
$GLOBALS['aa_events_test_updates'] = array();
$GLOBALS['aa_events_test_failures'] = 0;
function get_field( $name, $post_id = false ) {
return $GLOBALS['aa_events_test_fields'][ (int) $post_id ][ $name ] ?? null;
}
function update_field( $name, $value, $post_id = false ) {
$GLOBALS['aa_events_test_fields'][ (int) $post_id ][ $name ] = $value;
$GLOBALS['aa_events_test_updates'][] = array( $name, $value, (int) $post_id );
return true;
}
function wp_timezone() {
return new DateTimeZone( 'America/Winnipeg' );
}
function wp_date( $format, $timestamp = null, $timezone = null ) {
$date = new DateTimeImmutable( '@' . ( null === $timestamp ? time() : $timestamp ) );
return $date->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
<?php
require __DIR__ . '/bootstrap.php';
require dirname( __DIR__ ) . '/includes/occurrences.php';
require dirname( __DIR__ ) . '/includes/calendar-layout.php';
require __DIR__ . '/test-occurrences.php';
require __DIR__ . '/test-calendar-layout.php';
if ( $GLOBALS['aa_events_test_failures'] > 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
<?php
aa_events_test_reset();
$GLOBALS['aa_events_test_fields'][10]['event_dates'] = array(
array(
'date' => '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 PMJuly 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 1012, 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
<?php
$month = aa_events_calendar_month( 2026, 7, 1 );
aa_events_assert_same( '2026-06-29', $month['grid_start']->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(
'<time datetime="%1$s">%2$s</time>%3$s',
esc_attr( $start->format( DATE_W3C ) ),
esc_html( aa_events_format_occurrence( $occurrence ) ),
$index < count( $occurrences ) - 1 ? '<br />' : ''
);
}
```
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
<div class="aa-events-calendar-week" role="row" style="--aa-event-lanes: <?php echo esc_attr( max( 1, $week['lane_count'] ) ); ?>">
<div class="aa-events-calendar-days">
<?php foreach ( $week['days'] as $day ) : ?>
<div role="gridcell" class="aa-events-calendar-day ..." aria-label="...">
<span class="day-number"><?php echo esc_html( $day->format( 'j' ) ); ?></span>
</div>
<?php endforeach; ?>
</div>
<div class="aa-events-calendar-bars">
<!-- segment links -->
</div>
</div>
```
Render every segment link with CSS variables `--aa-event-column`, `--aa-event-span`, and `--aa-event-lane`. Add `is-continuing-before` and `is-continuing-after` classes. Repeat the visible title on every segment and build `aria-label` from title plus the complete `aa_events_format_occurrence()` result. Mark visible arrows `aria-hidden="true"`.
Days outside the selected month remain visible as muted grid cells so week geometry stays intact. Keep the current-day class based on site timezone.
- [ ] **Step 3: Render mobile agenda markup**
After the desktop grid, render `.aa-events-calendar-agenda` with a heading and `<ol>`. Each intersecting occurrence appears once:
```php
<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>
<time datetime="<?php echo esc_attr( aa_events_occurrence_start( $occurrence )->format( DATE_W3C ) ); ?>">
<?php echo esc_html( aa_events_format_occurrence( $occurrence ) ); ?>
</time>
</a>
</li>
```
Render a localized “No events this month.” message instead of an empty list.
- [ ] **Step 4: Remove dead output-buffer code**
Delete `$originalEchoDayCell`, `$calendarRows`, `$calendarOutput`, and the unused `preg_replace()` block. Confirm no output buffering remains in the template.
- [ ] **Step 5: Run syntax and helper tests**
Run:
```bash
php tests/run.php
php -l templates/calendar.php
```
Expected: tests pass and calendar template has no syntax errors.
- [ ] **Step 6: Commit calendar markup when Git is available**
```bash
git add templates/calendar.php
git commit -m "feat: render spanning calendar event bars"
```
### Task 7: Style Spanning Bars and the Mobile Agenda
**Files:**
- Modify: `assets/css/aa-events.css`
- Modify: `templates/aa-events.css`
- [ ] **Step 1: Replace table-only calendar styling**
Implement these layout contracts in `assets/css/aa-events.css`:
```css
.aa-events-calendar-grid { border-left: 1px solid #aaa; border-top: 1px solid #aaa; }
.aa-events-calendar-week { position: relative; }
.aa-events-calendar-days,
.aa-events-calendar-weekdays { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); }
.aa-events-calendar-day {
border-bottom: 1px solid #aaa;
border-right: 1px solid #aaa;
min-height: calc(4.5rem + var(--aa-event-lanes, 1) * 2rem);
padding: .625rem;
}
.aa-events-calendar-bars {
display: grid;
gap: .25rem 0;
grid-template-columns: repeat(7, minmax(0, 1fr));
grid-template-rows: repeat(var(--aa-event-lanes), 1.75rem);
left: 0;
padding: 2.25rem .2rem .5rem;
pointer-events: none;
position: absolute;
right: 0;
top: 0;
}
.aa-events-calendar-event {
background: #0969da;
border-radius: .25rem;
color: #fff;
grid-column: calc(var(--aa-event-column) + 1) / span var(--aa-event-span);
grid-row: calc(var(--aa-event-lane) + 1);
overflow: hidden;
padding: .2rem .45rem;
pointer-events: auto;
text-decoration: none;
text-overflow: ellipsis;
white-space: nowrap;
}
.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-event:focus-visible { outline: 3px solid #111; outline-offset: 2px; z-index: 2; }
.aa-events-calendar-day.is-outside-month { background: #f6f7f7; color: #646970; }
.aa-events-calendar-day.today-cell { background: #ffe9b3; box-shadow: inset 0 0 0 2px #f7b500; }
.aa-events-calendar-agenda { display: none; }
```
Adjust the week minimum height from `--aa-event-lanes` so absolute bars cannot overlap the following week. Preserve existing header, Today button, archive, and card-grid styles.
- [ ] **Step 2: Add the mobile breakpoint**
At `max-width: 640px`, hide `.aa-events-calendar-desktop`, show `.aa-events-calendar-agenda`, and style agenda links as full-width blocks with a visible left border, readable title/date hierarchy, and `:focus-visible`. Do not rely on color alone to identify links.
- [ ] **Step 3: Synchronize the theme-copy stylesheet**
Run:
```bash
cp assets/css/aa-events.css templates/aa-events.css
cmp assets/css/aa-events.css templates/aa-events.css
```
Expected: `cmp` exits with status 0.
- [ ] **Step 4: Verify CSS structure**
Run:
```bash
rg -n "aa-events-calendar-(grid|week|event|agenda)" assets/css/aa-events.css
cmp assets/css/aa-events.css templates/aa-events.css
```
Expected: all four component selectors are present and both stylesheets are identical.
- [ ] **Step 5: Commit responsive styling when Git is available**
```bash
git add assets/css/aa-events.css templates/aa-events.css
git commit -m "style: add responsive multi-day calendar layout"
```
### Task 8: Document the New Editing and Theme Behavior
**Files:**
- Modify: `README.md`
- [ ] **Step 1: Rewrite the multiple-date documentation**
Rename “Multiple Event Dates” to “Event Occurrences.” Document that each row supports a required start date, optional times/end date, and all-day mode. Include one example combining a FridaySunday occurrence with a separate later occurrence.
- [ ] **Step 2: Document responsive calendar behavior**
State explicitly that desktop/tablet retain the traditional month grid with week-spanning bars and screens at or below 640px use an agenda list.
- [ ] **Step 3: Document custom ACF groups and theme overrides**
List required repeater/subfield names: `event_dates`, `date`, `start_time`, `end_date`, `end_time`, `all_day`. Explain that an existing theme override of `aa-events/calendar.php` remains authoritative and must be updated to opt into spanning bars, while it can use `aa_events_get_occurrences()` immediately.
- [ ] **Step 4: Check documentation terminology**
Run:
```bash
rg -n "multiple_dates|Event Dates repeater|multi-day|Occurrences|640px" README.md
```
Expected: no instructions tell editors to use `multiple_dates`; occurrence and responsive behavior are documented.
- [ ] **Step 5: Commit documentation when Git is available**
```bash
git add README.md
git commit -m "docs: explain event occurrence ranges"
```
### Task 9: Final Verification
**Files:**
- Verify all modified PHP, JavaScript, CSS, tests, templates, and documentation.
- [ ] **Step 1: Run the complete automated suite**
Run:
```bash
php tests/run.php
find . -path './.superpowers' -prune -o -name '*.php' -print0 | xargs -0 -n1 php -l
cmp assets/css/aa-events.css templates/aa-events.css
```
Expected: all AA Events tests pass, every PHP file reports no syntax errors, and CSS files compare equal.
- [ ] **Step 2: Verify field-name consistency**
Run:
```bash
rg -n "multiple_dates" --glob '!docs/IMPLEMENTATION_PLAN_REPEATER.md' .
rg -n "event_dates|end_date|end_time|all_day" includes templates README.md
```
Expected: no production references to `multiple_dates`; all new fields are present in registration, normalization, templates, and documentation.
- [ ] **Step 3: Perform WordPress editor checks**
In the local WordPress site:
1. Create one event with a timed Friday 6 PM through Sunday 2 PM occurrence.
2. Add a second all-day occurrence to the same event.
3. Confirm all-day disables/clears time inputs.
4. Attempt an end before the start and confirm a row-numbered save error.
5. Save a legacy-only event and confirm exactly one occurrence row is created.
6. Save it again and confirm no duplicate row appears.
Expected: editing, validation, and migration match the design.
- [ ] **Step 4: Perform calendar and accessibility checks**
1. View a month where an occurrence crosses a week and confirm the title repeats on both segments.
2. View occurrences crossing previous/next month boundaries and confirm continuation indicators.
3. Create overlapping ranges and confirm separate lanes with no covering.
4. Change WordPress “Week Starts On” between Sunday and Monday and confirm segment columns remain correct.
5. Keyboard-tab through event bars and confirm visible focus and complete accessible labels.
6. At 641px confirm the traditional grid; at 640px confirm the agenda and one item per occurrence.
7. Confirm Today and previous/next navigation update both presentations.
Expected: all visual, responsive, and keyboard behaviors match the approved specification.
- [ ] **Step 5: Commit final corrections when Git is available**
```bash
git add -A
git commit -m "fix: complete multi-day occurrence verification"
```
Only create this commit if verification required corrections; otherwise leave the previous focused commits as the complete history.
@@ -1,64 +0,0 @@
# ACF Field-Key Switching Compatibility 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:** Make event occurrence rows load and remain editable when switching between the built-in and MNBC local ACF field groups.
**Architecture:** Resolve schedule values through the active ACF field definition so stale hidden reference keys cannot block reads. Add an idempotent legacy repeater migration, then synchronize the existing MNBC JSON group through ACF's import API and migrate live events without deleting old metadata.
**Tech Stack:** PHP 7.4+, WordPress, ACF PRO, WP-CLI, custom PHP test harness
---
### Task 1: Load Through the Active ACF Definition
**Files:**
- Modify: `tests/bootstrap.php`
- Modify: `tests/test-occurrences.php`
- Modify: `includes/occurrences.php`
- [ ] Add test doubles for `acf_get_field()`, `acf_get_value()`, and
`acf_format_value()` that can model an active field definition.
- [ ] Add a regression test where normal `get_field('event_dates')` returns a
raw repeater count but the active ACF definition returns rows.
- [ ] Run `php tests/run.php` and verify the regression fails.
- [ ] Add `aa_events_get_active_field_value()` and use it for
`event_dates`/`multiple_dates` reads.
- [ ] Run `php tests/run.php` and verify all assertions pass.
### Task 2: Migrate Legacy Repeater Rows
**Files:**
- Modify: `tests/test-occurrences.php`
- Modify: `includes/occurrences.php`
- [ ] Add tests proving a legacy-only event writes canonical rows, an event
with canonical rows is untouched, and legacy metadata is not deleted.
- [ ] Run `php tests/run.php` and verify the new assertions fail.
- [ ] Implement `aa_events_migrate_multiple_dates_occurrences()` and invoke it
from the existing event-save migration hook before legacy scalar migration.
- [ ] Run `php tests/run.php` and verify all assertions pass.
### Task 3: Synchronize and Migrate the Live Site
**Files:**
- Read: `../../themes/MNBC/acf/group_69fb58894f83f.json`
- Mutate: the local WordPress ACF field-group database records through WP-CLI
- [ ] Import the decoded JSON with `acf_import_field_group()`.
- [ ] Run the legacy repeater migration for every event post.
- [ ] Verify the runtime local repeater is named `event_dates`.
- [ ] Verify event 415 loads canonical occurrence rows and retain a snapshot of
its legacy schedule metadata for comparison.
### Task 4: Final Verification
**Files:**
- Verify: `includes/occurrences.php`
- Verify: `tests/bootstrap.php`
- Verify: `tests/test-occurrences.php`
- [ ] Run PHP syntax checks on all changed PHP files.
- [ ] Run `php tests/run.php`.
- [ ] Run a read-only live WP-CLI check for field definitions, event 415
occurrence source, and schedule metadata.
@@ -1,160 +0,0 @@
# Local ACF Override Compatibility 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:** Keep existing MNBC events visible with the legacy local ACF override while normalizing that override for future edits.
**Architecture:** Extend occurrence input normalization at the plugin boundary, then add `multiple_dates` as a lower-priority repeater source. Extend metadata synchronization to observe that legacy source. Finally, update the theme ACF JSON to the canonical AA Events names and formats without changing stable field keys or migrating stored metadata.
**Tech Stack:** PHP 7.4+, WordPress, ACF PRO, JSON, the plugin's custom PHP test harness
---
### Task 1: Parse Existing Override Values
**Files:**
- Modify: `tests/test-occurrences.php`
- Modify: `includes/occurrences.php`
- [ ] **Step 1: Write failing parser regression tests**
Add assertions showing that `aa_events_normalize_occurrence_row()` accepts
`July 30, 2026` and `2:15 pm`, normalizes them to `2026-07-30` and
`14:15:00`, and rejects invalid calendar/time strings.
- [ ] **Step 2: Run the focused test and verify RED**
Run: `php tests/run.php`
Expected: failures report that the formatted override row was not normalized.
- [ ] **Step 3: Implement minimal multi-format parsing**
Add focused date and time parsing helpers. Dates must try `Y-m-d`, `Ymd`, and
`F j, Y` with exact round-trip validation. Times must try `H:i:s`, `H:i`, and
`g:i a`, normalizing accepted values to `H:i:s`. Route occurrence-row date
parsing through the date helper while retaining existing strict validation.
- [ ] **Step 4: Run tests and verify GREEN**
Run: `php tests/run.php`
Expected: all assertions pass.
### Task 2: Read Legacy `multiple_dates` Rows
**Files:**
- Modify: `tests/test-occurrences.php`
- Modify: `includes/occurrences.php`
- [ ] **Step 1: Write failing source-precedence tests**
Create an event containing only `multiple_dates` formatted rows and assert that
`aa_events_get_occurrences()` returns normalized occurrences. Create another
event containing both repeaters and assert that `event_dates` is authoritative.
Also assert the formatted standalone `date`/`start_time` fallback works.
- [ ] **Step 2: Run the focused test and verify RED**
Run: `php tests/run.php`
Expected: the `multiple_dates` event returns no occurrence.
- [ ] **Step 3: Implement repeater fallback**
Read `event_dates` first. Only when it is empty, read `multiple_dates`. Feed
either repeater through the existing row normalization path and preserve the
existing public `event_dates` source label.
- [ ] **Step 4: Run tests and verify GREEN**
Run: `php tests/run.php`
Expected: all assertions pass.
### Task 3: Synchronize Legacy Repeater Metadata
**Files:**
- Modify: `tests/test-occurrences.php`
- Modify: `includes/occurrences.php`
- Modify: `includes/post-types.php`
- [ ] **Step 1: Write failing synchronization tests**
Assert that `aa_events_sort_source_meta_key()` accepts `multiple_dates`, its
supported row subfield keys, and rejects unrelated keys. Queue a legacy-row
change and assert that `aa_events_run_queued_sort_sync()` flushes the
`multiple_dates` ACF cache and writes the canonical sort metadata.
- [ ] **Step 2: Run the focused test and verify RED**
Run: `php tests/run.php`
Expected: the legacy key is ignored or its ACF cache is not flushed.
- [ ] **Step 3: Extend metadata recognition and SQL fallback**
Add `multiple_dates` to exact-key checks, row-key patterns, and cache-flush
lists. Extend the unsynchronized SQL sorting fallback to consider
`multiple_dates_N_date` rows after canonical `event_dates` rows while keeping
the canonical indexed metadata first.
- [ ] **Step 4: Run tests and verify GREEN**
Run: `php tests/run.php`
Expected: all assertions pass.
### Task 4: Normalize the MNBC ACF Field Group
**Files:**
- Modify: `../../themes/MNBC/acf/group_69fb58894f83f.json`
- [ ] **Step 1: Update only the schedule contract**
Keep all field keys stable. Change the repeater name from `multiple_dates` to
`event_dates`; change both date-picker return formats to `Y-m-d`; change
the standalone and repeater start/end time return formats to `H:i:s`.
- [ ] **Step 2: Validate the JSON and contract**
Run:
```bash
php -r '$p="../../themes/MNBC/acf/group_69fb58894f83f.json"; $j=json_decode(file_get_contents($p), true, 512, JSON_THROW_ON_ERROR); $fields=$j["fields"]; $repeater=array_values(array_filter($fields, fn($f) => ($f["key"] ?? "") === "field_69fb5b1aac54a"))[0]; if (($repeater["name"] ?? "") !== "event_dates") { exit(1); } echo "valid\n";'
```
from `wp-content/plugins/AA-Events`.
Expected: `valid`
### Task 5: Complete Verification
**Files:**
- Verify: `includes/occurrences.php`
- Verify: `includes/post-types.php`
- Verify: `tests/test-occurrences.php`
- Verify: `../../themes/MNBC/acf/group_69fb58894f83f.json`
- [ ] **Step 1: Lint changed PHP**
Run:
```bash
php -l includes/occurrences.php
php -l includes/post-types.php
php -l tests/test-occurrences.php
```
Expected: no syntax errors.
- [ ] **Step 2: Run the complete test suite**
Run: `php tests/run.php`
Expected: `All tests passed.`
- [ ] **Step 3: Review the final diff without Git**
Because this installation is not a Git worktree, inspect the exact changed
sections and confirm that no unrelated theme fields or plugin behavior changed.
@@ -1,919 +0,0 @@
# Desktop Calendar View Toggle 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:** Add an accessible desktop Calendar/List switcher that remembers the visitor's preference while keeping mobile permanently in List view.
**Architecture:** Keep the existing grid and agenda rendered from one occurrence collection. Add semantic template hooks and a dependency-free browser controller that applies one `localStorage` preference to every calendar instance, while CSS preserves the current no-JavaScript responsive fallback.
**Tech Stack:** WordPress/PHP templates and enqueue APIs, CSS, browser JavaScript, Node's built-in test runner, and the plugin's custom PHP test harness.
---
## File Map
- Modify `templates/calendar.php`: render the switcher and stable hooks for each view.
- Modify `assets/css/aa-events.css`: style the utility row, switcher, desktop List state, shared agenda cards, and forced mobile state.
- Modify `templates/aa-events.css`: remain byte-identical to the frontend stylesheet.
- Create `assets/js/aa-events-calendar-view.js`: own preference persistence, multi-instance synchronization, accessible state, and breakpoint changes.
- Create `tests/test-calendar-view.js`: exercise the browser controller with dependency-free DOM/storage/media-query doubles.
- Modify `tests/test-calendar-render.php`: verify rendered switcher semantics and instance-safe hooks.
- Modify `tests/test-calendar-css.php`: verify view-switcher and responsive contracts.
- Modify `tests/bootstrap.php`: capture frontend asset enqueues and align the test version constant with the plugin header.
- Create `tests/test-frontend-assets.php`: verify the frontend controller is enqueued.
- Modify `tests/run.php`: include the frontend asset test.
- Modify `includes/class-aa-events.php`: enqueue the new frontend controller.
- Modify `aa-events.php`: align the asset version constant with the existing `1.2.1` plugin header.
- Modify `tests/test-admin-consumers.php`: align its expected release version with `1.2.1`.
- Modify `README.md`: document the switcher, persistence, mobile behavior, and override requirements.
The current workspace is not a Git worktree. Each commit step below is retained for use when the plugin is placed in Git, but must be reported as skipped in this workspace rather than treated as a test failure.
### Task 1: Render the Accessible View Switcher
**Files:**
- Modify: `tests/test-calendar-render.php`
- Modify: `templates/calendar.php`
- [ ] **Step 1: Add failing rendered-markup assertions**
After `$xpath` is created in `tests/test-calendar-render.php`, add:
```php
$calendar_instance = $xpath->query( '//*[@data-aa-events-calendar]' )->item( 0 );
$switcher = $xpath->query( '//*[@data-aa-events-view-switcher and @role="group"]' )->item( 0 );
$view_buttons = $xpath->query( '//*[@data-aa-events-view-switcher]//button[@data-aa-events-view-button]' );
$calendar_panel = $xpath->query( '//*[@data-aa-events-view-panel="calendar"]' )->item( 0 );
$list_panel = $xpath->query( '//*[@data-aa-events-view-panel="list"]' )->item( 0 );
aa_events_assert_true( $calendar_instance instanceof DOMElement, 'Rendered calendar exposes an instance hook.' );
aa_events_assert_true( $switcher instanceof DOMElement, 'Rendered calendar exposes a grouped view switcher.' );
aa_events_assert_same( 'Calendar view', $switcher instanceof DOMElement ? $switcher->getAttribute( 'aria-label' ) : null, 'View switcher has an accessible name.' );
aa_events_assert_same( 2, $view_buttons->length, 'View switcher contains exactly two buttons.' );
aa_events_assert_same( 'calendar', $view_buttons->item( 0 ) instanceof DOMElement ? $view_buttons->item( 0 )->getAttribute( 'data-aa-events-view-button' ) : null, 'Calendar control is first.' );
aa_events_assert_same( 'true', $view_buttons->item( 0 ) instanceof DOMElement ? $view_buttons->item( 0 )->getAttribute( 'aria-pressed' ) : null, 'Calendar is the initial desktop selection.' );
aa_events_assert_same( 'list', $view_buttons->item( 1 ) instanceof DOMElement ? $view_buttons->item( 1 )->getAttribute( 'data-aa-events-view-button' ) : null, 'List control is second.' );
aa_events_assert_same( 'false', $view_buttons->item( 1 ) instanceof DOMElement ? $view_buttons->item( 1 )->getAttribute( 'aria-pressed' ) : null, 'List is initially unselected.' );
aa_events_assert_true( $calendar_panel instanceof DOMElement, 'Calendar grid exposes its view-panel hook.' );
aa_events_assert_true( $list_panel instanceof DOMElement, 'Agenda exposes its view-panel hook.' );
```
After `$multi_xpath` is created, add:
```php
aa_events_assert_same( 2, $multi_xpath->query( '//*[@data-aa-events-calendar]' )->length, 'Two calendars expose two independent instance hooks.' );
aa_events_assert_same( 4, $multi_xpath->query( '//*[@data-aa-events-view-button]' )->length, 'Two calendars each render both view controls.' );
aa_events_assert_same( 2, $multi_xpath->query( '//*[@data-aa-events-view-panel="calendar"]' )->length, 'Two calendars each expose a grid panel.' );
aa_events_assert_same( 2, $multi_xpath->query( '//*[@data-aa-events-view-panel="list"]' )->length, 'Two calendars each expose a list panel.' );
```
- [ ] **Step 2: Run the PHP suite and verify the new assertions fail**
Run:
```bash
php tests/run.php
```
Expected: the new instance/switcher assertions fail. The two already-known version-consistency assertions also fail until Task 4.
- [ ] **Step 3: Add the switcher and view hooks**
In `templates/calendar.php`, change the outer and utility markup to:
```php
<div class="aa-events-calendar-wrap container" data-aa-events-calendar>
<div class="aa-events-calendar-utility">
<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-view-switcher" data-aa-events-view-switcher role="group" aria-label="<?php echo esc_attr( __( 'Calendar view', 'aa-events' ) ); ?>">
<button type="button" class="aa-events-calendar-view-button" data-aa-events-view-button="calendar" aria-pressed="true"><?php esc_html_e( 'Calendar', 'aa-events' ); ?></button>
<button type="button" class="aa-events-calendar-view-button" data-aa-events-view-button="list" aria-pressed="false"><?php esc_html_e( 'List', 'aa-events' ); ?></button>
</div>
</div>
```
Add the panel attributes to the existing view containers:
```php
<div class="aa-events-calendar-desktop" data-aa-events-view-panel="calendar">
```
```php
<section class="aa-events-calendar-agenda" data-aa-events-view-panel="list" aria-labelledby="<?php echo esc_attr( $agenda_heading_id ); ?>">
```
- [ ] **Step 4: Run the rendered tests**
Run:
```bash
php tests/run.php
```
Expected: all calendar rendering assertions pass; only the two pre-existing version assertions remain failing.
- [ ] **Step 5: Commit the template slice when Git is available**
```bash
git add templates/calendar.php tests/test-calendar-render.php
git commit -m "feat: render calendar view switcher"
```
In the current non-Git workspace, verify `git rev-parse --show-toplevel` reports no worktree and record this commit as skipped.
### Task 2: Style Both Desktop Views and Preserve the Mobile Fallback
**Files:**
- Modify: `tests/test-calendar-css.php`
- Modify: `assets/css/aa-events.css`
- Modify: `templates/aa-events.css`
- [ ] **Step 1: Add failing CSS contracts**
Add these entries to `$calendar_css_contracts` in `tests/test-calendar-css.php`:
```php
'/\.aa-events-calendar-utility\s*\{[^}]*(?=.*?align-items\s*:\s*center)(?=.*?display\s*:\s*flex)(?=.*?flex-wrap\s*:\s*wrap)(?=.*?justify-content\s*:\s*space-between)[^}]*\}/s' => 'Today and the view switcher share a wrapping utility row.',
'/\.aa-events-calendar-view-switcher\s*\{[^}]*(?=.*?display\s*:\s*inline-flex)[^}]*\}/s' => 'View buttons render as one segmented control.',
'/\.aa-events-calendar-view-button\s*\{[^}]*(?=.*?border\s*:)(?=.*?cursor\s*:\s*pointer)(?=.*?padding\s*:)[^}]*\}/s' => 'View buttons have a usable control surface.',
'/\.aa-events-calendar-view-button\[aria-pressed="true"\]\s*\{[^}]*(?=.*?background\s*:)(?=.*?color\s*:)(?=.*?font-weight\s*:)[^}]*\}/s' => 'The selected view has a visible state.',
'/\.aa-events-calendar-view-button:focus-visible\s*\{[^}]*(?=.*?outline\s*:)(?=.*?outline-offset\s*:)[^}]*\}/s' => 'View buttons have visible keyboard focus.',
'/\.aa-events-calendar-wrap\.is-list-view\s+\.aa-events-calendar-desktop\s*\{[^}]*display\s*:\s*none[^}]*\}/s' => 'Desktop List state hides the grid.',
'/\.aa-events-calendar-wrap\.is-list-view\s+\.aa-events-calendar-agenda\s*\{[^}]*display\s*:\s*block[^}]*\}/s' => 'Desktop List state exposes the agenda.',
'/\.aa-events-calendar-wrap\s+\[hidden\]\s*\{[^}]*display\s*:\s*none\s*!important[^}]*\}/s' => 'Inactive views are removed visually when hidden.',
'/@media\s*\(max-width\s*:\s*640px\)[\s\S]*?\.aa-events-calendar-view-switcher\s*\{[^}]*display\s*:\s*none[\s\S]*?\.aa-events-calendar-desktop\s*\{[^}]*display\s*:\s*none[\s\S]*?\.aa-events-calendar-agenda\s*\{[^}]*display\s*:\s*block/s' => 'Mobile hides the switcher and always presents the agenda.',
```
Keep the existing agenda-list/card/focus contracts; they will guard moving those rules outside the media query.
- [ ] **Step 2: Run the CSS contract test and verify failure**
Run:
```bash
php tests/run.php
```
Expected: the new utility, switcher, state, and hidden-selector contracts fail.
- [ ] **Step 3: Implement the frontend styles**
In `assets/css/aa-events.css`, add before `.aa-events-calendar-header`:
```css
.aa-events-calendar-utility {
align-items: center;
display: flex;
flex-wrap: wrap;
gap: 0.625rem;
justify-content: space-between;
margin-bottom: 0.625rem;
}
.aa-events-calendar-view-switcher {
display: inline-flex;
}
.aa-events-calendar-view-button {
background: #fff;
border: 1px solid #767676;
color: #222;
cursor: pointer;
font: inherit;
margin: 0;
padding: 0.5rem 0.75rem;
}
.aa-events-calendar-view-button + .aa-events-calendar-view-button {
border-left: 0;
}
.aa-events-calendar-view-button:first-child {
border-radius: 0.25rem 0 0 0.25rem;
}
.aa-events-calendar-view-button:last-child {
border-radius: 0 0.25rem 0.25rem 0;
}
.aa-events-calendar-view-button[aria-pressed="true"] {
background: #245b87;
color: #fff;
font-weight: bold;
}
.aa-events-calendar-view-button:hover {
text-decoration: underline;
}
.aa-events-calendar-view-button:focus-visible {
outline: 3px solid currentColor;
outline-offset: 2px;
position: relative;
z-index: 1;
}
.aa-events-calendar-wrap [hidden] {
display: none !important;
}
```
After the base `.aa-events-calendar-agenda { display: none; }` rule, add:
```css
.aa-events-calendar-wrap.is-list-view .aa-events-calendar-desktop {
display: none;
}
.aa-events-calendar-wrap.is-list-view .aa-events-calendar-agenda {
display: block;
}
```
Move these existing agenda rules out of `@media (max-width: 640px)` so the desktop List view receives the same presentation:
```css
.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;
}
```
At the start of the existing 640-pixel media query, add:
```css
.aa-events-calendar-view-switcher { display: none; }
```
Keep the existing mobile grid/agenda rules in their current order.
- [ ] **Step 4: Synchronize the bundled CSS copy**
Run:
```bash
cp assets/css/aa-events.css templates/aa-events.css
```
Expected: `cmp -s assets/css/aa-events.css templates/aa-events.css` exits with status 0.
- [ ] **Step 5: Run the PHP suite**
Run:
```bash
php tests/run.php
```
Expected: all calendar rendering and CSS assertions pass; only the two pre-existing version assertions remain failing.
- [ ] **Step 6: Commit the styling slice when Git is available**
```bash
git add assets/css/aa-events.css templates/aa-events.css tests/test-calendar-css.php
git commit -m "style: support desktop calendar list view"
```
In the current non-Git workspace, record the commit as skipped.
### Task 3: Implement and Test the Persistent View Controller
**Files:**
- Create: `tests/test-calendar-view.js`
- Create: `assets/js/aa-events-calendar-view.js`
- [ ] **Step 1: Create the failing browser-controller test**
Create `tests/test-calendar-view.js`:
```js
'use strict';
const test = require( 'node:test' );
const assert = require( 'node:assert/strict' );
const { init } = require( '../assets/js/aa-events-calendar-view.js' );
class FakeClassList {
constructor() {
this.values = new Set();
}
add( value ) {
this.values.add( value );
}
remove( value ) {
this.values.delete( value );
}
toggle( value, force ) {
force ? this.add( value ) : this.remove( value );
}
contains( value ) {
return this.values.has( value );
}
}
class FakeElement {
constructor( attributes = {} ) {
this.attributes = { ...attributes };
this.classList = new FakeClassList();
this.hidden = false;
this.listeners = {};
}
getAttribute( name ) {
return this.attributes[ name ] ?? null;
}
setAttribute( name, value ) {
this.attributes[ name ] = String( value );
}
addEventListener( type, callback ) {
this.listeners[ type ] = callback;
}
click() {
this.listeners.click();
}
}
function makeCalendar() {
const calendarButton = new FakeElement( { 'data-aa-events-view-button': 'calendar', 'aria-pressed': 'true' } );
const listButton = new FakeElement( { 'data-aa-events-view-button': 'list', 'aria-pressed': 'false' } );
const calendarPanel = new FakeElement();
const listPanel = new FakeElement();
const root = new FakeElement();
root.querySelectorAll = ( selector ) => selector === '[data-aa-events-view-button]' ? [ calendarButton, listButton ] : [];
root.querySelector = ( selector ) => {
if ( selector === '[data-aa-events-view-panel="calendar"]' ) {
return calendarPanel;
}
if ( selector === '[data-aa-events-view-panel="list"]' ) {
return listPanel;
}
return null;
};
return { root, calendarButton, listButton, calendarPanel, listPanel };
}
function makeDocument( calendars ) {
return {
querySelectorAll( selector ) {
return selector === '[data-aa-events-calendar]' ? calendars.map( ( calendar ) => calendar.root ) : [];
},
};
}
function makeMediaQuery( matches = false ) {
return {
matches,
listener: null,
addEventListener( type, callback ) {
if ( type === 'change' ) {
this.listener = callback;
}
},
change( matchesMobile ) {
this.matches = matchesMobile;
this.listener( { matches: matchesMobile } );
},
};
}
function makeStorage( initialValue = null ) {
return {
value: initialValue,
getItem() {
return this.value;
},
setItem( key, value ) {
assert.equal( key, 'aa-events-calendar-view' );
this.value = value;
},
};
}
test( 'defaults to Calendar and synchronizes List across instances', () => {
const calendars = [ makeCalendar(), makeCalendar() ];
const storage = makeStorage();
const mediaQuery = makeMediaQuery();
init( makeDocument( calendars ), storage, mediaQuery );
assert.equal( calendars[ 0 ].calendarPanel.hidden, false );
assert.equal( calendars[ 0 ].listPanel.hidden, true );
calendars[ 0 ].listButton.click();
for ( const calendar of calendars ) {
assert.equal( calendar.root.classList.contains( 'is-list-view' ), true );
assert.equal( calendar.calendarPanel.hidden, true );
assert.equal( calendar.listPanel.hidden, false );
assert.equal( calendar.calendarButton.getAttribute( 'aria-pressed' ), 'false' );
assert.equal( calendar.listButton.getAttribute( 'aria-pressed' ), 'true' );
}
assert.equal( storage.value, 'list' );
} );
test( 'restores a valid preference and rejects an invalid value', () => {
const savedList = makeCalendar();
init( makeDocument( [ savedList ] ), makeStorage( 'list' ), makeMediaQuery() );
assert.equal( savedList.listPanel.hidden, false );
const invalid = makeCalendar();
init( makeDocument( [ invalid ] ), makeStorage( 'tiles' ), makeMediaQuery() );
assert.equal( invalid.calendarPanel.hidden, false );
assert.equal( invalid.listPanel.hidden, true );
} );
test( 'forces List on mobile without overwriting the saved desktop preference', () => {
const calendar = makeCalendar();
const storage = makeStorage( 'calendar' );
const mediaQuery = makeMediaQuery();
init( makeDocument( [ calendar ] ), storage, mediaQuery );
mediaQuery.change( true );
assert.equal( calendar.calendarPanel.hidden, true );
assert.equal( calendar.listPanel.hidden, false );
assert.equal( storage.value, 'calendar' );
mediaQuery.change( false );
assert.equal( calendar.calendarPanel.hidden, false );
assert.equal( calendar.listPanel.hidden, true );
} );
test( 'switches the current page when storage access fails', () => {
const calendar = makeCalendar();
const brokenStorage = {
getItem() {
throw new Error( 'blocked' );
},
setItem() {
throw new Error( 'blocked' );
},
};
assert.doesNotThrow( () => init( makeDocument( [ calendar ] ), brokenStorage, makeMediaQuery() ) );
calendar.listButton.click();
assert.equal( calendar.listPanel.hidden, false );
assert.equal( calendar.listButton.getAttribute( 'aria-pressed' ), 'true' );
} );
test( 'does nothing on pages without calendars and supports legacy media listeners', () => {
const mediaQuery = {
matches: false,
listener: null,
addListener( callback ) {
this.listener = callback;
},
};
assert.doesNotThrow( () => init( makeDocument( [] ), makeStorage(), mediaQuery ) );
const calendar = makeCalendar();
init( makeDocument( [ calendar ] ), makeStorage(), mediaQuery );
mediaQuery.matches = true;
mediaQuery.listener( { matches: true } );
assert.equal( calendar.listPanel.hidden, false );
} );
```
- [ ] **Step 2: Run the Node test and verify module failure**
Run:
```bash
node --test tests/test-calendar-view.js
```
Expected: FAIL because `assets/js/aa-events-calendar-view.js` does not exist.
- [ ] **Step 3: Implement the controller**
Create `assets/js/aa-events-calendar-view.js`:
```js
( function ( globalObject, factory ) {
'use strict';
const api = factory();
if ( typeof module === 'object' && module.exports ) {
module.exports = api;
}
if ( ! globalObject || ! globalObject.document ) {
return;
}
const start = function () {
let storage = null;
try {
storage = globalObject.localStorage;
} catch ( error ) {
storage = null;
}
api.init(
globalObject.document,
storage,
globalObject.matchMedia( '(max-width: 640px)' )
);
};
if ( 'loading' === globalObject.document.readyState ) {
globalObject.document.addEventListener( 'DOMContentLoaded', start );
} else {
start();
}
}( typeof window === 'undefined' ? null : window, function () {
'use strict';
const storageKey = 'aa-events-calendar-view';
const validViews = [ 'calendar', 'list' ];
const readPreference = function ( storage ) {
try {
const storedView = storage && storage.getItem( storageKey );
return validViews.includes( storedView ) ? storedView : 'calendar';
} catch ( error ) {
return 'calendar';
}
};
const writePreference = function ( storage, view ) {
try {
if ( storage ) {
storage.setItem( storageKey, view );
}
} catch ( error ) {
// Persistence is optional; current-page switching still works.
}
};
const init = function ( documentObject, storage, mediaQuery ) {
const calendars = Array.from( documentObject.querySelectorAll( '[data-aa-events-calendar]' ) );
if ( ! calendars.length ) {
return;
}
let preference = readPreference( storage );
const applyView = function () {
const isMobile = Boolean( mediaQuery && mediaQuery.matches );
calendars.forEach( function ( calendar ) {
const buttons = Array.from( calendar.querySelectorAll( '[data-aa-events-view-button]' ) );
const calendarPanel = calendar.querySelector( '[data-aa-events-view-panel="calendar"]' );
const listPanel = calendar.querySelector( '[data-aa-events-view-panel="list"]' );
if ( ! calendarPanel || ! listPanel ) {
return;
}
buttons.forEach( function ( button ) {
const isSelected = button.getAttribute( 'data-aa-events-view-button' ) === preference;
button.setAttribute( 'aria-pressed', isSelected ? 'true' : 'false' );
} );
if ( isMobile ) {
calendar.classList.remove( 'is-list-view' );
calendarPanel.hidden = true;
listPanel.hidden = false;
return;
}
const showList = 'list' === preference;
calendar.classList.toggle( 'is-list-view', showList );
calendarPanel.hidden = showList;
listPanel.hidden = ! showList;
} );
};
const selectView = function ( view ) {
if ( ! validViews.includes( view ) ) {
return;
}
preference = view;
writePreference( storage, preference );
applyView();
};
calendars.forEach( function ( calendar ) {
Array.from( calendar.querySelectorAll( '[data-aa-events-view-button]' ) ).forEach( function ( button ) {
button.addEventListener( 'click', function () {
selectView( button.getAttribute( 'data-aa-events-view-button' ) );
} );
} );
} );
if ( mediaQuery ) {
if ( 'function' === typeof mediaQuery.addEventListener ) {
mediaQuery.addEventListener( 'change', applyView );
} else if ( 'function' === typeof mediaQuery.addListener ) {
mediaQuery.addListener( applyView );
}
}
applyView();
};
return { init };
} ) );
```
- [ ] **Step 4: Run controller tests**
Run:
```bash
node --test tests/test-calendar-view.js
```
Expected: 9 tests pass.
- [ ] **Step 5: Run a JavaScript syntax check**
Run:
```bash
node --check assets/js/aa-events-calendar-view.js
```
Expected: exit status 0 with no output.
- [ ] **Step 6: Commit the controller slice when Git is available**
```bash
git add assets/js/aa-events-calendar-view.js tests/test-calendar-view.js
git commit -m "feat: persist calendar view preference"
```
In the current non-Git workspace, record the commit as skipped.
### Task 4: Enqueue the Controller and Restore Version Consistency
**Files:**
- Modify: `tests/bootstrap.php`
- Create: `tests/test-frontend-assets.php`
- Modify: `tests/run.php`
- Modify: `includes/class-aa-events.php`
- Modify: `aa-events.php`
- Modify: `tests/test-admin-consumers.php`
- [ ] **Step 1: Add enqueue test doubles**
In `tests/bootstrap.php`, change the test version and add capture globals near the top:
```php
define( 'AA_EVENTS_VERSION', '1.2.1' );
$GLOBALS['aa_events_test_styles'] = array();
$GLOBALS['aa_events_test_scripts'] = array();
```
Add these functions before the assertion helpers:
```php
function get_stylesheet_directory() {
return __DIR__ . '/fixtures/theme-without-aa-events-override';
}
function get_stylesheet_directory_uri() {
return 'https://example.test/theme';
}
function wp_enqueue_style( $handle, $src, $dependencies = array(), $version = false ) {
$GLOBALS['aa_events_test_styles'][ $handle ] = array( $src, $dependencies, $version );
}
function wp_enqueue_script( $handle, $src, $dependencies = array(), $version = false, $in_footer = false ) {
$GLOBALS['aa_events_test_scripts'][ $handle ] = array( $src, $dependencies, $version, $in_footer );
}
```
- [ ] **Step 2: Create the failing frontend enqueue test**
Create `tests/test-frontend-assets.php`:
```php
<?php
if ( ! defined( 'WPINC' ) ) {
define( 'WPINC', 'tests' );
}
if ( ! defined( 'AA_EVENTS_PLUGIN_DIR' ) ) {
define( 'AA_EVENTS_PLUGIN_DIR', dirname( __DIR__ ) . '/' );
}
if ( ! defined( 'AA_EVENTS_PLUGIN_URL' ) ) {
define( 'AA_EVENTS_PLUGIN_URL', 'https://example.test/plugins/aa-events/' );
}
require_once __DIR__ . '/../includes/class-aa-events.php';
$GLOBALS['aa_events_test_styles'] = array();
$GLOBALS['aa_events_test_scripts'] = array();
AA_Events::instance()->enqueue_styles();
aa_events_assert_same(
array(
'https://example.test/plugins/aa-events/assets/js/aa-events-calendar-view.js',
array(),
AA_EVENTS_VERSION,
true,
),
$GLOBALS['aa_events_test_scripts']['aa-events-calendar-view'] ?? null,
'Frontend assets enqueue the dependency-free calendar view controller in the footer.'
);
```
Add this line to `tests/run.php` after `test-calendar-css.php`:
```php
require_once __DIR__ . '/test-frontend-assets.php';
```
- [ ] **Step 3: Run the PHP suite and verify the enqueue assertion fails**
Run:
```bash
php tests/run.php
```
Expected: FAIL because `aa-events-calendar-view` has not been enqueued. The two known version-consistency assertions also remain failing until Step 5.
- [ ] **Step 4: Enqueue the browser controller**
At the end of `AA_Events::enqueue_styles()` in `includes/class-aa-events.php`, add:
```php
wp_enqueue_script(
'aa-events-calendar-view',
AA_EVENTS_PLUGIN_URL . 'assets/js/aa-events-calendar-view.js',
array(),
AA_EVENTS_VERSION,
true
);
```
Update the method docblock summary from `Enqueue styles.` to:
```php
/**
* Enqueue frontend assets.
*/
```
- [ ] **Step 5: Align the existing release version**
In `aa-events.php`, change:
```php
define( 'AA_EVENTS_VERSION', '1.2.1' );
```
In `tests/test-admin-consumers.php`, change the hard-coded release assertion to:
```php
aa_events_assert_same( '1.2.1', $header_match[1] ?? '', 'Plugin header uses the feature release version.' );
```
This resolves the baseline mismatch where the existing plugin header is already `1.2.1` but the asset constant and test fixtures are still `1.2.0`.
- [ ] **Step 6: Run the PHP suite**
Run:
```bash
php tests/run.php
```
Expected: `All AA Events tests passed.`
- [ ] **Step 7: Commit the integration slice when Git is available**
```bash
git add tests/bootstrap.php tests/test-frontend-assets.php tests/run.php includes/class-aa-events.php aa-events.php tests/test-admin-consumers.php
git commit -m "feat: load calendar view controller"
```
In the current non-Git workspace, record the commit as skipped.
### Task 5: Document and Verify the Feature
**Files:**
- Modify: `README.md`
- [ ] **Step 1: Update frontend-view documentation**
Replace the desktop/mobile calendar bullets in `README.md` with:
```markdown
- On desktop and tablet, visitors can switch between Calendar and List. Calendar is the initial default; the selected view is saved in that browser and restored after month navigation or a later visit.
- Calendar view uses 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 switcher is hidden and the calendar always uses the chronological List view, regardless of the saved desktop preference.
```
Replace the final theme-override paragraph with:
```markdown
An existing `your-theme/aa-events/calendar.php` remains authoritative. It does not automatically acquire the plugin templates week-spanning bars, List markup, or desktop view switcher. 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 calendar, agenda, and switcher markup it adopts.
```
- [ ] **Step 2: Run all automated verification**
Run:
```bash
php tests/run.php
node --test tests/test-calendar-view.js
php -l templates/calendar.php
php -l includes/class-aa-events.php
node --check assets/js/aa-events-calendar-view.js
cmp -s assets/css/aa-events.css templates/aa-events.css
```
Expected:
- PHP suite prints `All AA Events tests passed.`
- Node reports 9 passing tests and 0 failures.
- Both PHP syntax checks report no syntax errors.
- JavaScript syntax check exits 0.
- CSS comparison exits 0.
- [ ] **Step 3: Perform focused source checks**
Run:
```bash
rg -n "data-aa-events-(calendar|view-switcher|view-button|view-panel)" templates/calendar.php
rg -n "aa-events-calendar-view|localStorage|matchMedia" assets/js/aa-events-calendar-view.js includes/class-aa-events.php
rg -n "switch|saved|640px|override" README.md
```
Expected: template hooks, persistence/breakpoint code, enqueue path, and documentation are all present.
- [ ] **Step 4: Manually verify in WordPress**
On a page using the Events Calendar template:
1. At a width above 640 pixels, confirm Calendar is initially selected.
2. Use the keyboard to select List and confirm focus remains visible.
3. Navigate to the next month and reload; confirm List remains selected.
4. Return to Calendar and confirm that preference persists.
5. Resize to exactly 640 pixels; confirm the switcher and grid are hidden and List is visible.
6. Resize above 640 pixels; confirm the saved desktop choice returns.
7. Render two calendars on one page if the host theme permits it; selecting either switcher must update both.
8. Disable site storage or make it unavailable; confirm switching still works for the current page without console errors.
- [ ] **Step 5: Commit documentation when Git is available**
```bash
git add README.md
git commit -m "docs: explain persistent calendar views"
```
In the current non-Git workspace, record the commit as skipped.
@@ -1,31 +0,0 @@
# Event Contact Email Design
## Objective
Add an optional contact email to event editing and display it as a clickable email link on single-event pages only.
## Field Model
The built-in Event Details ACF group adds an optional field with:
- Label: `Contact Email`
- Name: `event_contact_email`
- Type: `email`
The field is stored at event level because it applies to the event rather than an individual occurrence. Admin-created override groups may add a field with the same name to use the same front-end output.
## Front-End Output
`templates/single-event.php` adds a Contact Email row within the existing event details. The row is omitted when the value is empty or invalid. Valid addresses render as a `mailto:` link. The visible address is protected with WordPress's `antispambot()` helper, and the address and URL are escaped for their output contexts.
Archive cards and calendar views do not display the contact email.
## Compatibility and Testing
Existing events require no migration because the field is optional. Tests verify the built-in field registration contract and the single-event template's conditional, sanitized email output. PHP syntax and the complete test suite must pass.
## Out of Scope
- Contact names, phone numbers, or reusable contact records.
- Sending email through WordPress.
- Displaying contact email on archives or calendars.
@@ -1,153 +0,0 @@
# Multi-Day Event Occurrences Design
## Objective
Add an intuitive event-occurrence model that supports single-day events, continuous multi-day events, recurring events, and combinations of those patterns. Display multi-day occurrences as continuous bars across a traditional month-grid calendar and use a chronological agenda on phone-sized screens.
## Existing Behavior
The plugin stores discrete dates in the optional `event_dates` ACF repeater and falls back to the legacy `event_datetime` field. Calendar rendering places the event independently inside each matching day cell. Treating a multi-day event as several discrete dates therefore produces repeated links rather than one visually continuous event.
## Occurrence Data Model
Keep the `event_dates` field name for storage compatibility, but change its editor label to **Occurrences**. Each repeater row represents one occurrence and contains:
- `date`: required start date in `Y-m-d` format.
- `start_time`: optional start time.
- `end_date`: optional end date in `Y-m-d` format.
- `end_time`: optional end time.
- `all_day`: boolean toggle.
An omitted `end_date` means the occurrence ends on its start date. An `end_time` without an `end_date` therefore applies to the start date. When `all_day` is enabled, the time controls are hidden and stored time values are ignored by readers.
Every row is independent. A single event may contain any mixture of single-day and multi-day occurrences, allowing clients to represent recurrence and duration together.
### Validation
- A start date is required for every row.
- An end date cannot precede the start date.
- When both times apply to the same date, the end time cannot precede the start time.
- Time comparison uses the WordPress site timezone.
- Missing optional end values are valid and must not prevent saving.
Validation messages identify the affected occurrence row and field. Invalid ranges are rejected rather than silently reordered.
## Normalization and Compatibility
Introduce a normalized occurrence helper as the sole read interface used by plugin templates and admin features. Each normalized item contains the post ID, start date/time, effective end date/time, all-day status, and whether the start or end was inferred from legacy data.
The read priority is:
1. Rows in `event_dates`.
2. The legacy `event_datetime` field.
3. Older standalone `date` and `start_time` fields, where present.
Existing `event_dates` rows remain valid because all new subfields are optional. When an editor saves an event that has no repeater rows but does have legacy date data, the plugin creates one equivalent occurrence row. Untouched events continue to render through the read fallback, so migration does not require a bulk operation.
The legacy fields remain registered for compatibility but should be visually de-emphasized in the built-in editor once occurrence editing is available. Migration must be idempotent and must never append a duplicate row on subsequent saves.
Sites using their own admin-created ACF event field group are responsible for adding the new optional subfields with the documented names. Normalization tolerates absent subfields.
## Editor Experience
The built-in ACF repeater is labeled **Occurrences**, with an **Add Occurrence** button. Rows expose start and end values together so editors understand that a row is one continuous occurrence rather than one day.
The `all_day` toggle conditionally hides `start_time` and `end_time`. The end controls remain optional. Concise field instructions explain that additional rows create recurrence and that an end date creates a continuous multi-day occurrence.
## Calendar Architecture
Desktop and tablet retain a traditional month grid: seven weekday columns, date-numbered cells, and one row per calendar week. Event bars are rendered in week-level lanes layered across the date columns rather than independently inside each cell.
For each normalized occurrence that intersects the viewed month:
1. Clip its visible interval to the month-grid range.
2. Split the visible interval at week boundaries.
3. Convert each weekly segment into a start column and column span.
4. Assign segments to the first available lane that does not overlap another segment in that week.
5. Render the segment as a linked bar spanning the relevant date columns.
Each weekly segment repeats the event title. A segment has rounded edges at the occurrence's true visible start or end. An edge is squared and displays a continuation indicator when the occurrence continues before or after that weekly segment. Month-boundary clipping also uses a continuation indicator so the user can see that the event extends beyond the current view.
Single-day occurrences use the same bar component with a one-column span. Multiple occurrences belonging to the same event are treated independently. Overlapping occurrences occupy separate lanes and never cover one another.
Day cells grow vertically to accommodate the required event lanes. The current-day highlight remains behind the date cell and does not obscure event bars.
## Mobile Agenda
At a maximum width of approximately 640 pixels, matching the plugin's existing responsive breakpoint, hide the month grid and show a chronological agenda for the selected month.
Each occurrence intersecting the selected month appears once, including occurrences that start in an earlier month or end in a later month. The agenda item displays the complete start/end range, not one item per day. Items sort by occurrence start, then event title for deterministic ordering. Calendar month navigation and the Today action control both desktop and mobile views.
## Date and Time Presentation
Formatting uses WordPress localization functions and the configured site timezone.
- All-day, one-day: one localized date followed by “All day.”
- All-day, multi-day: localized start and end dates followed by “All day.”
- Timed, same-day with an end: date plus startend times.
- Timed, same-day without an end: date plus start time.
- Timed, multi-day: full start date/time through full end date/time.
Archive and single-event templates render one formatted range per occurrence. They do not expand a multi-day occurrence into one line per day.
## Accessibility
- Each event bar is a normal keyboard-focusable link.
- The accessible label includes the event title and complete occurrence range, even when the visible segment is clipped.
- Continuation arrows are decorative and hidden from assistive technology.
- The grid retains weekday headings and meaningful date labels.
- The mobile agenda uses semantic list markup and headings or time elements as appropriate.
- Color is not the only indication that an event continues across a boundary.
## Admin and Query Behavior
The Events admin column displays the earliest normalized occurrence and a count of additional occurrences. Admin sorting uses the earliest occurrence start, then falls back to legacy values and post date. Existing REST ordering behavior remains compatible; the occurrence normalization work does not introduce a public recurrence-generation API.
Calendar data collection continues to include published events. Filtering occurs against occurrence intervals so an event is included when any part of an occurrence intersects the selected month.
## Theme Compatibility
The plugin's bundled templates and stylesheet implement the new layout. Theme overrides continue to resolve through the existing loader. A theme-provided `aa-events/calendar.php` remains authoritative and will not automatically acquire week-spanning bars. This limitation must be documented in the release notes or README.
The normalized occurrence helper is public plugin code so theme overrides can adopt the same data model without duplicating compatibility logic.
## Error Handling
- Malformed or incomplete repeater rows are skipped safely during rendering and do not break the calendar.
- Save-time validation reports actionable row-level errors.
- Legacy values that cannot be parsed are left unchanged and are not migrated.
- A calendar with no valid occurrences renders its normal empty state.
- Duplicate rows are preserved because clients may intentionally create simultaneous occurrences; only automatic legacy migration performs duplicate prevention.
## Testing Strategy
Unit-level tests should cover:
- Normalization of new, existing-repeater, and legacy field formats.
- Idempotent legacy conversion on save.
- Optional ends, all-day behavior, and invalid range rejection.
- Same-day and multi-day range formatting.
- Month intersection and clipping at both boundaries.
- Week splitting with configurable WordPress week starts.
- Deterministic lane assignment for overlapping segments.
- Site-timezone and daylight-saving boundaries.
- Admin earliest-occurrence selection and sorting fallbacks.
Rendering tests should cover:
- One-column and multi-column bars.
- Week and month continuation indicators.
- Repeated titles on every weekly segment.
- Escaped titles and accessible full-range labels.
- Desktop month-grid semantics.
- Mobile agenda ordering and one-entry-per-occurrence behavior.
Manual verification should include the built-in editor's conditional controls, a mixture of recurring and multi-day rows on one event, keyboard navigation, a theme override, and layouts at widths immediately above and below the mobile breakpoint.
## Out of Scope
- Rule-based recurrence generation such as “every Tuesday until September.” Editors continue adding occurrence rows explicitly.
- Drag-and-drop calendar editing.
- Per-event colors or category-based color configuration.
- A bulk migration command for all legacy events.
- Changes to calendar month-navigation URLs.
@@ -1,62 +0,0 @@
# ACF Field-Key Switching Compatibility Design
## Problem
The built-in and theme-local field groups use different ACF field keys. Event
415 currently has canonical `event_dates` metadata whose hidden reference
points to the built-in repeater key, while the active database copy of the
theme group still defines its repeater as `multiple_dates`. ACF cannot resolve
the inactive key through `get_field()`, so normal field loading can return only
the raw repeater count instead of its rows. The front end remains visible
because AA Events falls back to the standalone `date` field.
The updated theme JSON has not yet been synchronized into ACF's database field
definitions.
## Field-Group Synchronization
Import the existing theme JSON group through ACF's field-group import API. The
import retains every stable theme field key while updating the database copy
to use the canonical `event_dates`, `Y-m-d`, and `H:i:s` contract.
The import is a one-time site synchronization, not an automatic plugin
mutation of arbitrary external ACF groups.
## Runtime Field Loading
AA Events will resolve a supported field through the currently registered ACF
field definition when one is available, rather than relying only on the hidden
metadata reference. This lets `event_dates` load through whichever field key
is active:
- Built-in: `field_61b0c7f0a3e8f`.
- MNBC local group: `field_69fb5b1aac54a`.
The existing `get_field()` path remains the fallback for tests, legacy fields,
and installations where the lower-level ACF APIs are unavailable.
## Legacy Repeater Migration
Add an idempotent migration helper for event posts:
1. Do nothing when canonical `event_dates` contains rows.
2. Read and normalize legacy `multiple_dates` rows.
3. Write equivalent canonical rows to the currently registered
`event_dates` field.
4. Preserve the legacy metadata rather than deleting it.
Run this migration once for the live MNBC events after synchronizing the field
group. Keep the helper available on event saves so a legacy-only event becomes
canonical when edited.
Canonical rows remain authoritative if both formats exist.
## Verification
- Add regression tests for loading a canonical repeater through an active ACF
definition even when its hidden reference belongs to the inactive group.
- Add idempotent migration tests for legacy-only and already-canonical events.
- Run the complete plugin test suite and PHP syntax checks.
- Confirm the live ACF database field definition is named `event_dates`.
- Confirm event 415 returns an occurrence from `event_dates`.
- Inspect the raw metadata and verify no legacy schedule metadata was deleted.
@@ -1,86 +0,0 @@
# Local ACF Override Compatibility Design
## Problem
AA Events suppresses its built-in ACF field group when the MNBC theme's local
field group is enabled. The theme group stores repeating dates under
`multiple_dates` and returns dates and times as `F j, Y` and `g:i a`. The
plugin's occurrence reader only reads `event_dates` and strictly parses
`Y-m-d` dates and 24-hour times. As a result, the plugin detects the local
group as an override but cannot turn its schedule values into occurrences, so
the events do not appear in schedule-driven views.
## Scope
This change makes the plugin compatible with the existing MNBC schedule fields
and normalizes the theme's local ACF JSON to the documented AA Events schedule
contract. It does not rename or otherwise change unrelated cost, location, or
contact fields.
## Plugin Compatibility
`aa_events_get_occurrences()` will use schedule sources in this order:
1. The canonical `event_dates` repeater when it contains rows.
2. The legacy local-override `multiple_dates` repeater when it contains rows.
3. The existing `event_datetime` fallback.
4. The existing standalone `date` and `start_time` fallback.
Canonical rows take precedence when both repeaters contain data. Legacy
`multiple_dates` rows will normalize to the same public occurrence shape as
canonical rows, without adding a new public source type.
The occurrence parsing boundary will accept the plugin's canonical return
formats and the formats emitted by the existing theme override:
- Dates: `Y-m-d`, compact ACF `Ymd`, and `F j, Y`.
- Times: `H:i:s`, `H:i`, and `g:i a`.
All accepted values will be normalized to `Y-m-d` and `H:i:s` before existing
range validation, sorting, and rendering logic runs. Invalid or ambiguous
values will continue to be rejected rather than guessed.
Metadata-change synchronization will recognize both repeater names and their
schedule subfields. ACF value-cache flushing will include `multiple_dates`, so
direct metadata changes cannot leave stale canonical occurrence indexes.
## Theme ACF JSON
The MNBC theme field group at
`wp-content/themes/MNBC/acf/group_69fb58894f83f.json` will be updated as
follows:
- Rename the repeater field from `multiple_dates` to `event_dates`, retaining
its stable ACF field key.
- Change the standalone and repeater Date fields' return format to `Y-m-d`.
- Change the standalone and repeater Start Time and End Time fields' return
format to `H:i:s`.
The existing field keys remain unchanged. No unrelated field definitions will
be modified.
## Existing Data
Renaming the ACF field does not rename previously saved WordPress metadata.
Existing rows stored as `multiple_dates_*` therefore remain in place and are
read through the plugin compatibility fallback. Newly edited schedule data
will be saved under canonical `event_dates_*` keys.
No destructive or automatic bulk database migration is included. If both old
and new repeater data exist on an event, canonical `event_dates` data is
authoritative.
## Testing
Regression tests will first reproduce the current theme override:
- `multiple_dates` rows using `F j, Y` dates and `g:i a` times produce
normalized occurrences.
- Standalone legacy fields in those formats produce an occurrence.
- Canonical `event_dates` rows win when both repeater names contain data.
- Invalid formatted values remain excluded.
- Metadata synchronization recognizes and flushes the legacy repeater.
The focused occurrence tests and complete plugin test suite must pass after
the implementation. The theme JSON will also be parsed to confirm it remains
valid JSON and contains the canonical schedule names and return formats.
@@ -1,89 +0,0 @@
# Desktop Calendar View Toggle Design
## Goal
Let visitors switch between the traditional month grid and the existing chronological agenda on desktop and tablet. Remember the visitor's selection across month navigation and later visits while keeping phone-sized screens permanently in agenda view.
## Scope
This change applies to the plugin's bundled calendar template. It reuses the grid and agenda markup already rendered from the same normalized occurrence data.
The feature includes:
- A desktop "Calendar / List" view switcher.
- Browser-local preference persistence.
- Accessible view state and keyboard operation.
- Responsive behavior that preserves the existing mobile agenda.
- Automated coverage and README updates.
The feature does not add a new event query, server-side user preference, URL parameter, WordPress setting, or admin control.
## User Interface
The switcher sits in the existing utility row opposite the Today button. It contains two text buttons:
- Calendar
- List
The active button is visually distinct and exposes `aria-pressed="true"`; the inactive button exposes `aria-pressed="false"`. Both controls have visible keyboard focus styles. The utility row can wrap when space is constrained.
Above 640 pixels, the selected view is visible and the other view is hidden. At 640 pixels and below, the switcher is hidden, the grid is hidden, and the list is always visible regardless of the saved desktop preference.
## Rendering and State
`templates/calendar.php` continues to render both views from `$calendar_occurrences`. The template adds:
- A switcher associated with its containing calendar instance.
- Stable data attributes that identify the Calendar and List controls.
- Stable data attributes on the grid and agenda view containers.
- Initial accessible button state matching the no-JavaScript desktop default.
A small dependency-free frontend script finds every bundled calendar instance on the page. It reads one plugin-wide preference from `localStorage`, so a visitor's selection applies consistently to every AA Events calendar.
The stored values are limited to `calendar` and `list`. A missing or invalid value defaults to `calendar`. Selecting a control updates all instances on the page, adjusts each button's `aria-pressed` state, hides the inactive view with the `hidden` attribute, and stores the new preference.
Month navigation remains unchanged. Because the preference is browser-local rather than encoded in the URL, existing Previous Month, Next Month, and Today links retain their current URLs and the saved preference is reapplied after navigation.
## Responsive Behavior
CSS remains the authoritative mobile fallback:
- At 640 pixels and below, the desktop grid and switcher are hidden and the agenda is shown.
- Above 640 pixels, JavaScript applies the saved desktop preference.
The script listens for changes to the 640-pixel media query. Crossing into mobile ensures the agenda is exposed even when Calendar is saved. Crossing back to desktop reapplies the saved selection. Resizing does not overwrite the visitor's preference.
## Progressive Enhancement and Failure Handling
Without JavaScript, behavior remains unchanged: desktop and tablet show the month grid, while phone-sized screens show the agenda.
Access to `localStorage` can fail in restricted browsing contexts. Reads and writes are wrapped in error handling. If reading fails, Calendar is the initial desktop view. The switcher still changes the current page's view when saving fails.
The frontend script is safe to enqueue on pages without a bundled calendar and exits without side effects when no matching instance exists. A theme-provided `aa-events/calendar.php` remains authoritative and does not automatically gain the switcher unless it adopts the new markup contract.
## Assets and Compatibility
Add a dedicated frontend calendar script and enqueue it without external dependencies. Keep `assets/css/aa-events.css` and the bundled `templates/aa-events.css` copy synchronized.
The new markup and script must support multiple calendar instances without duplicate IDs or state divergence. Existing calendar query, occurrence sorting, grid layout, month navigation, and mobile agenda content are unchanged.
## Testing
Automated tests will verify:
- The rendered switcher contains Calendar and List buttons with correct initial accessible state.
- Calendar instances and their two views expose the expected script hooks without duplicate IDs.
- CSS places and styles the utility-row switcher, provides selected and focus states, and preserves the 640-pixel forced-list behavior.
- The frontend script defaults to Calendar for missing or invalid stored values.
- Selecting either control updates every rendered calendar, view visibility, and `aria-pressed` state.
- The preference survives script initialization and is not overwritten by the forced mobile layout.
- Storage read and write failures do not prevent current-page switching.
- Pages without calendar markup are unaffected.
Run the existing PHP test suite, PHP syntax checks for changed PHP files, and the JavaScript tests introduced for the view controller. Manual verification should cover keyboard use, month navigation, page reload, multiple calendars, resizing immediately above and below 640 pixels, and a browser context where storage is unavailable.
## Documentation
Update the README to state that desktop and tablet visitors can switch between Calendar and List, that the selection persists in that browser, and that screens at 640 pixels and below always use List.
Document that theme template and stylesheet overrides remain authoritative and need to adopt the new markup and styles to receive the switcher.