Skip to content

Commit 5898edd

Browse files
feat(event-details): add-to-calendar dropdown (Google / Outlook.com / .ics) (closes #312) (#313)
Adds an 'Add to Calendar' button on single-event pages with a dropdown menu offering three destinations: - Google Calendar (calendar.google.com deep-link, dates in UTC with ctz hint) - Outlook.com (outlook.live.com deep-link, ISO 8601 with timezone offset) - Apple Calendar / Other (.ics download via REST endpoint) The button hooks `data_machine_events_action_buttons` at priority 7 so it renders between the existing attendance button (priority 5) and the share button (priority 10). New code: - inc/EventActions/CalendarUrlBuilder.php Pure PHP URL builders. Timezone-aware: converts local event time to UTC for Google's `dates=` param, emits ISO-8601-with-offset for Outlook. Defaults to a 3-hour duration when endTime is empty. - inc/EventActions/IcsBuilder.php Hand-rolled VCALENDAR/VEVENT/VTIMEZONE generator (~330 LOC). RFC 5545 compliant: CRLF line endings, 75-octet line folding, TEXT escaping for `\`, `;`, `,`, and newlines. VTIMEZONE block built from DateTimeZone::getTransitions() with proper DAYLIGHT + STANDARD sub-components. UID is the event permalink so re-imports are idempotent. - inc/Api/Controllers/EventIcs.php + Routes.php GET /wp-json/datamachine/v1/events/{id}/ics. Public endpoint (`permission_callback: __return_true`). Returns text/calendar with Content-Disposition: attachment via rest_pre_serve_request to bypass JSON serialization. BrowserNavigationGuard NOT applied — this endpoint is meant to be hit as a top-level navigation. - inc/Blocks/EventDetails/add-to-calendar-button.php Action callback that emits the button + dropdown HTML. - inc/Blocks/EventDetails/src/add-to-calendar.js Vanilla JS (no jQuery, no React) WAI-ARIA menu button pattern. Document-delegated handlers: click to toggle, click outside to close, Escape returns focus to toggle, ArrowUp/Down/Home/End navigate items. Right-aligns the menu via .is-right-aligned class when it would overflow the viewport. - CSS additions in src/frontend.css Dropdown positioning + visuals using existing --data-machine-* tokens. EventDetails block.json gains `viewScript` so the frontend bundle (which now imports add-to-calendar.js) is enqueued on the frontend. Verified end-to-end against real events (event IDs 236481/236482): - Google URL produces correct UTC `dates=` and `ctz` hint - Outlook URL produces ISO 8601 with the venue's offset - .ics body round-trips through the bundled johngrogg/ics-parser cleanly - VTIMEZONE DAYLIGHT/STANDARD sub-blocks have correct TZOFFSETFROM/TZOFFSETTO for America/New_York - 3-hour default duration applied when endTime is empty Co-authored-by: homeboy-ci[bot] <266378653+homeboy-ci[bot]@users.noreply.github.com>
1 parent b10bada commit 5898edd

10 files changed

Lines changed: 1280 additions & 0 deletions

File tree

data-machine-events.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,9 @@ function data_machine_events_sanitize_query_params( $value ) {
5555
// See docs/integration-api.md.
5656
require_once DATA_MACHINE_EVENTS_PLUGIN_DIR . 'inc/public-api.php';
5757

58+
// Add-to-Calendar button + dropdown for single-event pages (issue #312).
59+
require_once DATA_MACHINE_EVENTS_PLUGIN_DIR . 'inc/Blocks/EventDetails/add-to-calendar-button.php';
60+
5861
// Load event dates sync (monitors Event Details block saves → datamachine_event_dates table).
5962
require_once DATA_MACHINE_EVENTS_PLUGIN_DIR . 'inc/Core/event-dates-sync.php';
6063
require_once DATA_MACHINE_EVENTS_PLUGIN_DIR . 'inc/Core/EventDatesTable.php';

inc/Api/Controllers/EventIcs.php

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
<?php
2+
/**
3+
* EventIcs REST controller.
4+
*
5+
* Serves a single event as an iCalendar (.ics) download. This endpoint
6+
* EXPECTS to be hit as a top-level browser navigation (the "Apple Calendar /
7+
* Download (.ics)" item in the Add-to-Calendar dropdown is a plain `<a download>`
8+
* link). The BrowserNavigationGuard pattern used by /events/calendar does
9+
* NOT apply here.
10+
*
11+
* Returns raw `text/calendar` (not JSON), so we short-circuit the REST
12+
* serializer via `rest_pre_serve_request`.
13+
*
14+
* @package DataMachineEvents\Api\Controllers
15+
* @since 0.40.0
16+
*/
17+
18+
namespace DataMachineEvents\Api\Controllers;
19+
20+
defined( 'ABSPATH' ) || exit;
21+
22+
use DataMachineEvents\EventActions\IcsBuilder;
23+
use WP_REST_Request;
24+
use WP_REST_Response;
25+
use WP_Error;
26+
27+
class EventIcs {
28+
29+
/**
30+
* GET /datamachine/v1/events/{id}/ics
31+
*
32+
* @param WP_REST_Request $request
33+
* @return WP_REST_Response|WP_Error
34+
*/
35+
public function download( WP_REST_Request $request ) {
36+
$post_id = (int) $request->get_param( 'id' );
37+
if ( $post_id <= 0 ) {
38+
return new WP_Error(
39+
'invalid_event_id',
40+
__( 'Invalid event ID.', 'data-machine-events' ),
41+
array( 'status' => 404 )
42+
);
43+
}
44+
45+
$post = get_post( $post_id );
46+
if ( ! $post || 'publish' !== $post->post_status ) {
47+
return new WP_Error(
48+
'event_not_found',
49+
__( 'Event not found.', 'data-machine-events' ),
50+
array( 'status' => 404 )
51+
);
52+
}
53+
54+
if ( defined( 'DATA_MACHINE_EVENTS_POST_TYPE' ) && DATA_MACHINE_EVENTS_POST_TYPE !== $post->post_type ) {
55+
return new WP_Error(
56+
'event_not_found',
57+
__( 'Event not found.', 'data-machine-events' ),
58+
array( 'status' => 404 )
59+
);
60+
}
61+
62+
if ( ! class_exists( IcsBuilder::class ) ) {
63+
require_once DATA_MACHINE_EVENTS_PLUGIN_DIR . 'inc/EventActions/IcsBuilder.php';
64+
}
65+
66+
$body = IcsBuilder::build( $post_id );
67+
if ( '' === $body ) {
68+
return new WP_Error(
69+
'event_ics_unavailable',
70+
__( 'Could not build .ics for this event.', 'data-machine-events' ),
71+
array( 'status' => 404 )
72+
);
73+
}
74+
75+
$slug = $post->post_name ? $post->post_name : ( 'event-' . $post_id );
76+
$filename = sanitize_file_name( $slug . '.ics' );
77+
78+
// Short-circuit the JSON serializer: emit text/calendar directly.
79+
add_filter(
80+
'rest_pre_serve_request',
81+
static function ( $served, $response ) use ( $body, $filename ) {
82+
if ( $served ) {
83+
return $served;
84+
}
85+
if ( ! headers_sent() ) {
86+
header( 'Content-Type: text/calendar; charset=utf-8' );
87+
header( 'Content-Disposition: attachment; filename="' . $filename . '"' );
88+
header( 'Cache-Control: public, max-age=300' );
89+
}
90+
echo $body; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
91+
return true;
92+
},
93+
10,
94+
2
95+
);
96+
97+
// The body of this response is ignored because the filter above
98+
// already echoed the real payload; it just needs to be a 200.
99+
return new WP_REST_Response( null, 200 );
100+
}
101+
}

inc/Api/Routes.php

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,12 @@
1212
DATA_MACHINE_EVENTS_PLUGIN_DIR . 'inc/Api/Controllers/Calendar.php',
1313
DATA_MACHINE_EVENTS_PLUGIN_DIR . 'inc/Api/Controllers/Venues.php',
1414
DATA_MACHINE_EVENTS_PLUGIN_DIR . 'inc/Api/Controllers/Events.php',
15+
DATA_MACHINE_EVENTS_PLUGIN_DIR . 'inc/Api/Controllers/EventIcs.php',
1516
DATA_MACHINE_EVENTS_PLUGIN_DIR . 'inc/Api/Controllers/Filters.php',
1617
DATA_MACHINE_EVENTS_PLUGIN_DIR . 'inc/Api/Controllers/Geocoding.php',
1718
DATA_MACHINE_EVENTS_PLUGIN_DIR . 'inc/Api/Controllers/VenueMap.php',
19+
DATA_MACHINE_EVENTS_PLUGIN_DIR . 'inc/EventActions/CalendarUrlBuilder.php',
20+
DATA_MACHINE_EVENTS_PLUGIN_DIR . 'inc/EventActions/IcsBuilder.php',
1821
);
1922
foreach ( $controllers as $file ) {
2023
if ( file_exists( $file ) ) {
@@ -25,6 +28,7 @@
2528

2629
use DataMachineEvents\Api\Controllers\Calendar;
2730
use DataMachineEvents\Api\Controllers\Venues;
31+
use DataMachineEvents\Api\Controllers\EventIcs;
2832
use DataMachineEvents\Api\Controllers\Filters;
2933
use DataMachineEvents\Api\Controllers\Geocoding;
3034
use DataMachineEvents\Api\Controllers\VenueMap;
@@ -233,6 +237,28 @@ function register_routes() {
233237
)
234238
);
235239

240+
$event_ics = new EventIcs();
241+
242+
register_rest_route(
243+
API_NAMESPACE,
244+
'/events/(?P<id>\d+)/ics',
245+
array(
246+
'methods' => 'GET',
247+
'callback' => array( $event_ics, 'download' ),
248+
'permission_callback' => '__return_true',
249+
'args' => array(
250+
'id' => array(
251+
'type' => 'integer',
252+
'required' => true,
253+
'sanitize_callback' => 'absint',
254+
'validate_callback' => function ( $param ) {
255+
return is_numeric( $param ) && (int) $param > 0;
256+
},
257+
),
258+
),
259+
)
260+
);
261+
236262
$geocoding = new Geocoding();
237263

238264
register_rest_route(
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
<?php
2+
/**
3+
* Add to Calendar button + dropdown for single-event pages.
4+
*
5+
* Hooks onto `data_machine_events_action_buttons` (fired inside the
6+
* EventDetails block render) and emits a button that opens a dropdown
7+
* with three calendar destinations: Google Calendar, Outlook.com, and
8+
* a downloadable .ics file (for Apple Calendar / Thunderbird / etc.).
9+
*
10+
* Hook priority 7: this puts the button between the existing attendance
11+
* button (priority 5, from extrachill-events) and the share button
12+
* (priority 10, from extrachill-events). Order on a page that has all
13+
* three: Ticket → Attendance → Add to Calendar → Share.
14+
*
15+
* @package DataMachineEvents\Blocks\EventDetails
16+
* @since 0.40.0
17+
*/
18+
19+
defined( 'ABSPATH' ) || exit;
20+
21+
if ( ! class_exists( '\\DataMachineEvents\\EventActions\\CalendarUrlBuilder' ) ) {
22+
require_once DATA_MACHINE_EVENTS_PLUGIN_DIR . 'inc/EventActions/CalendarUrlBuilder.php';
23+
}
24+
25+
add_action(
26+
'data_machine_events_action_buttons',
27+
'data_machine_events_render_add_to_calendar_button',
28+
7,
29+
2
30+
);
31+
32+
if ( ! function_exists( 'data_machine_events_render_add_to_calendar_button' ) ) {
33+
/**
34+
* Emit the Add-to-Calendar button + dropdown HTML.
35+
*
36+
* @param int $post_id Event post ID.
37+
* @param string $ticket_url Ticket URL (unused here, present for hook signature parity).
38+
*/
39+
function data_machine_events_render_add_to_calendar_button( $post_id, $ticket_url ) {
40+
unset( $ticket_url );
41+
42+
$post_id = (int) $post_id;
43+
if ( $post_id <= 0 ) {
44+
return;
45+
}
46+
47+
$post = get_post( $post_id );
48+
if ( ! $post ) {
49+
return;
50+
}
51+
52+
if ( ! function_exists( 'data_machine_events_parse_event_data' ) ) {
53+
return;
54+
}
55+
56+
$event = \data_machine_events_parse_event_data( $post );
57+
if ( ! $event ) {
58+
return;
59+
}
60+
61+
// Inject post_id so URL builders can resolve title + permalink.
62+
$event['post_id'] = $post_id;
63+
64+
$google_url = \DataMachineEvents\EventActions\CalendarUrlBuilder::google( $event );
65+
$outlook_url = \DataMachineEvents\EventActions\CalendarUrlBuilder::outlook( $event );
66+
$ics_url = \DataMachineEvents\EventActions\CalendarUrlBuilder::ics_url( $post_id );
67+
68+
if ( ! $google_url && ! $outlook_url && ! $ics_url ) {
69+
return;
70+
}
71+
72+
$menu_id = 'dm-atc-menu-' . $post_id;
73+
?>
74+
<div class="dm-events-add-to-calendar" data-dm-add-to-calendar>
75+
<button
76+
type="button"
77+
class="dm-events-action-btn dm-events-add-to-calendar-toggle ticket-button"
78+
aria-haspopup="true"
79+
aria-expanded="false"
80+
aria-controls="<?php echo esc_attr( $menu_id ); ?>"
81+
>
82+
<span class="dashicons dashicons-calendar-alt" aria-hidden="true"></span>
83+
<?php esc_html_e( 'Add to Calendar', 'data-machine-events' ); ?>
84+
</button>
85+
<ul
86+
id="<?php echo esc_attr( $menu_id ); ?>"
87+
class="dm-events-add-to-calendar-menu"
88+
role="menu"
89+
hidden
90+
>
91+
<?php if ( $google_url ) : ?>
92+
<li role="none">
93+
<a
94+
role="menuitem"
95+
href="<?php echo esc_url( $google_url ); ?>"
96+
target="_blank"
97+
rel="noopener noreferrer"
98+
><?php esc_html_e( 'Google Calendar', 'data-machine-events' ); ?></a>
99+
</li>
100+
<?php endif; ?>
101+
<?php if ( $outlook_url ) : ?>
102+
<li role="none">
103+
<a
104+
role="menuitem"
105+
href="<?php echo esc_url( $outlook_url ); ?>"
106+
target="_blank"
107+
rel="noopener noreferrer"
108+
><?php esc_html_e( 'Outlook.com', 'data-machine-events' ); ?></a>
109+
</li>
110+
<?php endif; ?>
111+
<?php if ( $ics_url ) : ?>
112+
<li role="none">
113+
<a
114+
role="menuitem"
115+
href="<?php echo esc_url( $ics_url ); ?>"
116+
download
117+
><?php esc_html_e( 'Apple Calendar / Download (.ics)', 'data-machine-events' ); ?></a>
118+
</li>
119+
<?php endif; ?>
120+
</ul>
121+
</div>
122+
<?php
123+
}
124+
}

inc/Blocks/EventDetails/block.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
"style": ["data-machine-events-root", "leaflet"],
1212
"editorStyle": "file:./style.css",
1313
"viewStyle": "file:./build/frontend.css",
14+
"viewScript": "file:./build/frontend.js",
1415
"attributes": {
1516
"startDate": {
1617
"type": "string",

0 commit comments

Comments
 (0)