Back to Tips & Tricks

JetEngine Add to Calendar Button for Advanced Date (Google & Apple)

Atif JanPublished on Updated
Calendar page with event markers connected to a phone and laptop, illustrating JetEngine add to calendar buttons

JetEngine has no built-in Add to Calendar button for events that use the Advanced Date field. Crocoblock's Google Calendar Dynamic Link sources only work with JetAppointment bookings and JetBooking bookings (the JetAppointment one only in Elementor), so they cannot read your event posts. The fix is a small PHP shortcode. It reads the Advanced Date meta, converts the site-local time to UTC and outputs two buttons: an Add to Google Calendar link and an .ics download that Apple Calendar and Outlook can open.

This guide covers the JetEngine settings you need, how the dates are stored, the snippet, and where to place the shortcode in Elementor, Gutenberg or Bricks.

Before you start: JetEngine settings

  1. Turn on the Calendar module. The Advanced Date field type only appears in the field list when this module is active. The field type was added in JetEngine 3.3.0 (3.3.0 release notes).
  2. Add an Advanced Date meta field to your events post type. When it launched in 3.3.0, the field was available for posts only, so attach it to a custom post type, not a CCT. The snippet below reads post meta.
  3. Enable Allow timepicker for timed events (JetEngine 3.4.0 and later). This adds time inputs next to the start date and the end date. The date and time are still saved together as one timestamp. Without the timepicker, the snippet treats events as all-day.
  4. Set the site time zone in Settings > General. Pick a city, for example New York, rather than a fixed UTC offset, so WordPress applies daylight saving time.
  5. Optional: add a location field. A plain text field works for a street address, or for a Zoom or Meet URL on online and hybrid events.

Why calendar links end up hours off

JetEngine saves Advanced Date values as Unix timestamps without time zone adjustment (3.4.0 release notes). An event at 18:00 in New York is stored as the timestamp for 18:00 UTC. If you pass that number straight to Google, the event lands at 14:00 New York time in summer.

The snippet reads the wall-clock time from the timestamp and attaches your site time zone with wp_timezone(). It then converts the result to UTC before it builds the link or the .ics file. Each visitor's calendar then shows the event in their own time zone.

Recurring events: what the buttons add

The Advanced Date field handles recurrence itself. The recurrence must be limited by a number of repetitions or an end date. When you save the post, JetEngine writes every occurrence to the database. Each occurrence also gets its own end date, offset by the same number of days as the original, so multi-day events keep their length. Since 3.4.0, the Recurrency Format option lets you choose Recurrence Rule or Manually (you enter each date yourself, with its own start date/time and end date/time).

The snippet reads the stored start and end timestamps. When there is one stored end for every start, it pairs them one by one, so manually entered dates with different times each keep their own end. When the counts differ, every occurrence uses the length of the first start/end pair.

The snippet uses those stored dates like this:

  • Google link: one event, the next occurrence that has not ended yet. If every date is in the past, it uses the last one.
  • .ics file: the next occurrence by default. With series="yes", every stored occurrence goes into the file as a separate event, so Apple Calendar imports the whole series at once.

We list the real stored dates instead of writing an RRULE. That way the calendar always matches what your site shows, including manually entered dates that have no rule behind them.

For the Dynamic Calendar widget, set the Advanced Date field name as the meta field. Turn on Allow multi-day events for events with an end date. The end date is fetched from the main field automatically. Crocoblock's guide on displaying repeating events in a Dynamic Calendar covers the setup.

Find your meta keys

Check how your install stores the dates before you configure the code. JetEngine stores every start timestamp under the field name (one row per occurrence), every end timestamp under the field name plus __end_date, and the field settings as JSON under the field name plus __config. For a field named event_date, set start_key to event_date and end_key to event_date__end_date. Run the query below on a test event to confirm the rows are there.

  1. Create a test event with a start date, an end date, a time, and a weekly recurrence limited to 3 repetitions. Save it and note the post ID.
  2. In phpMyAdmin or Adminer, run this query. Replace wp_ with your table prefix, 123 with the post ID and event_date with your field name:
SELECT meta_key, meta_value
FROM wp_postmeta
WHERE post_id = 123
  AND meta_key LIKE 'event_date%';
  • The rows under your exact field name that hold numbers (one per occurrence) are your start key.
  • The rows under your field name plus __end_date hold the end timestamps and are your end key.
  • Rows holding JSON or text settings are not timestamps. The snippet skips non-numeric values anyway.

Add the snippet

Paste the code below into your child theme's functions.php file, or into a PHP snippet in a code snippets plugin set to run everywhere. It has no opening <?php tag, so it can go below the one already in functions.php. Then edit the values in bmc_atc_config() at the top.

Code

/**
 * BoostMyCroco: Add to Calendar buttons for JetEngine Advanced Date fields.
 * Shortcode: [bmc_add_to_calendar] outputs a Google Calendar link and an .ics download.
 */

// 1. Settings: change these to match your site.
function bmc_atc_config() {
	return array(
		'post_types'      => array( 'events' ), // Post type slug(s) that use the Advanced Date field.
		'start_key'       => 'event_date',      // Advanced Date field name (meta key with start timestamps).
		'end_key'         => 'event_date__end_date', // JetEngine stores end timestamps as {field}__end_date. Empty = none.
		'location_key'    => 'event_location',  // Text field with an address or meeting URL. Empty = none.
		'default_minutes' => 60,                // Length used when a timed event has no end.
	);
}

// 2. Collect numeric timestamps from meta values (single row, multiple rows or arrays).
function bmc_atc_timestamps( $values ) {
	$out = array();
	foreach ( (array) $values as $value ) {
		if ( is_array( $value ) ) {
			$out = array_merge( $out, bmc_atc_timestamps( $value ) );
		} elseif ( is_numeric( $value ) && (int) $value > 0 ) {
			$out[] = (int) $value;
		}
	}
	$out = array_values( array_unique( $out ) );
	sort( $out );
	return $out;
}

// 3. Build the list of occurrences for a post.
function bmc_atc_get_occurrences( $post_id ) {
	$cfg    = bmc_atc_config();
	$starts = bmc_atc_timestamps( get_post_meta( $post_id, $cfg['start_key'], false ) );
	if ( empty( $starts ) ) {
		return array();
	}

	$ends   = $cfg['end_key'] ? bmc_atc_timestamps( get_post_meta( $post_id, $cfg['end_key'], false ) ) : array();
	$paired = count( $ends ) === count( $starts );

	// Fallback length: first start/end pair (rule-based recurrence offsets every end the same way).
	$first_length = ( $ends && $ends[0] >= $starts[0] ) ? $ends[0] - $starts[0] : null;

	$occurrences = array();
	foreach ( $starts as $i => $start ) {
		$duration = $first_length;

		// One end per start: each date keeps its own end (needed for manually entered dates).
		if ( $paired && $ends[ $i ] >= $start ) {
			$duration = $ends[ $i ] - $start;
		}

		// Values are site-local wall-clock time, so 00:00 here means midnight on the site.
		$all_day = ( '00:00' === gmdate( 'H:i', $start ) ) && ( null === $duration || 0 === $duration % DAY_IN_SECONDS );

		if ( $all_day ) {
			$length = (int) $duration;
		} else {
			$length = $duration ? $duration : (int) $cfg['default_minutes'] * MINUTE_IN_SECONDS;
		}

		$occurrences[] = array(
			'start'   => $start,
			'end'     => $start + $length,
			'all_day' => $all_day,
		);
	}
	return $occurrences;
}

// 4. Read a site-local wall-clock timestamp as a date in the site time zone.
function bmc_atc_local( $timestamp ) {
	return new DateTimeImmutable( gmdate( 'Y-m-d H:i:s', $timestamp ), wp_timezone() );
}

// 5. Format one occurrence: UTC for timed events, plain dates for all-day events.
function bmc_atc_format( $occ ) {
	if ( $occ['all_day'] ) {
		return array(
			'all_day' => true,
			'start'   => gmdate( 'Ymd', $occ['start'] ),
			'end'     => gmdate( 'Ymd', $occ['end'] + DAY_IN_SECONDS ), // All-day end date is exclusive.
		);
	}

	$utc = new DateTimeZone( 'UTC' );
	return array(
		'all_day' => false,
		'start'   => bmc_atc_local( $occ['start'] )->setTimezone( $utc )->format( 'Ymd\THis\Z' ),
		'end'     => bmc_atc_local( $occ['end'] )->setTimezone( $utc )->format( 'Ymd\THis\Z' ),
	);
}

// 6. Next occurrence that has not ended yet, or the last one if all are past.
function bmc_atc_pick_next( $occurrences ) {
	foreach ( $occurrences as $occ ) {
		$end = $occ['all_day'] ? $occ['end'] + DAY_IN_SECONDS : $occ['end'];
		if ( bmc_atc_local( $end )->getTimestamp() >= time() ) {
			return $occ;
		}
	}
	return end( $occurrences );
}

// 7. Title, summary, URL and location shared by both buttons.
function bmc_atc_event_data( $post_id ) {
	$cfg     = bmc_atc_config();
	$title   = wp_strip_all_tags( html_entity_decode( get_the_title( $post_id ), ENT_QUOTES, 'UTF-8' ) );
	$excerpt = html_entity_decode( (string) get_post_field( 'post_excerpt', $post_id ), ENT_QUOTES, 'UTF-8' );

	$location = '';
	if ( $cfg['location_key'] ) {
		$raw      = get_post_meta( $post_id, $cfg['location_key'], true );
		$location = is_scalar( $raw ) ? sanitize_text_field( (string) $raw ) : '';
	}

	return array(
		'title'    => $title,
		'summary'  => trim( wp_trim_words( $excerpt, 40, '...' ) ),
		'url'      => get_permalink( $post_id ),
		'location' => $location,
	);
}

// 8. Description text. Google gets a one-line version because esc_url() strips line breaks.
function bmc_atc_details( $data, $separator ) {
	return implode( $separator, array_filter( array( $data['summary'], $data['url'] ) ) );
}

// 9. Shortcode: [bmc_add_to_calendar id="" series="no" google_label="" ics_label=""]
add_shortcode( 'bmc_add_to_calendar', 'bmc_atc_shortcode' );
function bmc_atc_shortcode( $atts ) {
	$atts = shortcode_atts(
		array(
			'id'           => 0,
			'series'       => 'no',
			'google_label' => 'Add to Google Calendar',
			'ics_label'    => 'Add to Apple Calendar',
		),
		$atts,
		'bmc_add_to_calendar'
	);

	$cfg     = bmc_atc_config();
	$post_id = absint( $atts['id'] ) ? absint( $atts['id'] ) : get_the_ID();
	if ( ! $post_id || ! in_array( get_post_type( $post_id ), $cfg['post_types'], true ) ) {
		return '';
	}

	$occurrences = bmc_atc_get_occurrences( $post_id );
	if ( empty( $occurrences ) ) {
		return '';
	}

	$data  = bmc_atc_event_data( $post_id );
	$times = bmc_atc_format( bmc_atc_pick_next( $occurrences ) );

	$google_args = array(
		'action'  => 'TEMPLATE',
		'text'    => $data['title'],
		'dates'   => $times['start'] . '/' . $times['end'],
		'details' => bmc_atc_details( $data, ' | ' ),
	);
	if ( $data['location'] ) {
		$google_args['location'] = $data['location'];
	}
	$google = 'https://calendar.google.com/calendar/render?' . http_build_query( $google_args, '', '&', PHP_QUERY_RFC3986 );

	$ics_args = array( 'bmc_ics' => 1 );
	if ( 'yes' === $atts['series'] ) {
		$ics_args['series'] = 'all';
	}
	$ics = add_query_arg( $ics_args, get_permalink( $post_id ) );

	return sprintf(
		'<div class="bmc-atc"><a class="bmc-atc__google" href="%1$s" target="_blank" rel="noopener nofollow">%2$s</a> <a class="bmc-atc__ics" href="%3$s" rel="nofollow">%4$s</a></div>',
		esc_url( $google ),
		esc_html( $atts['google_label'] ),
		esc_url( $ics ),
		esc_html( $atts['ics_label'] )
	);
}

// 10. iCalendar text escaping and line folding.
function bmc_atc_ics_text( $text ) {
	$text = str_replace( '\\', '\\\\', $text );
	$text = str_replace( array( ';', ',' ), array( '\;', '\,' ), $text );
	return str_replace( array( "\r\n", "\r", "\n" ), '\n', $text );
}

function bmc_atc_fold( $line ) {
	if ( ! function_exists( 'mb_strcut' ) ) {
		return $line;
	}
	$out   = '';
	$limit = 75;
	while ( strlen( $line ) > $limit ) {
		$chunk = mb_strcut( $line, 0, $limit, 'UTF-8' );
		$out  .= $chunk . "\r\n ";
		$line  = substr( $line, strlen( $chunk ) );
		$limit = 74;
	}
	return $out . $line;
}

// 11. Serve the .ics file: /your-event/?bmc_ics=1 (add &series=all for every stored date).
add_action( 'template_redirect', 'bmc_atc_serve_ics' );
function bmc_atc_serve_ics() {
	if ( ! isset( $_GET['bmc_ics'] ) ) {
		return;
	}

	$cfg = bmc_atc_config();
	if ( ! is_singular( $cfg['post_types'] ) ) {
		return;
	}

	$post = get_queried_object();
	if ( ! ( $post instanceof WP_Post ) || 'publish' !== $post->post_status || post_password_required( $post ) ) {
		return;
	}

	$occurrences = bmc_atc_get_occurrences( $post->ID );
	if ( empty( $occurrences ) ) {
		return;
	}

	$series = isset( $_GET['series'] ) ? sanitize_key( wp_unslash( $_GET['series'] ) ) : '';
	if ( 'all' !== $series ) {
		$occurrences = array( bmc_atc_pick_next( $occurrences ) );
	}

	$data  = bmc_atc_event_data( $post->ID );
	$host  = wp_parse_url( home_url(), PHP_URL_HOST );
	$stamp = gmdate( 'Ymd\THis\Z' );

	$lines = array(
		'BEGIN:VCALENDAR',
		'VERSION:2.0',
		'PRODID:-//BoostMyCroco//Add to Calendar//EN',
		'CALSCALE:GREGORIAN',
		'METHOD:PUBLISH',
	);

	foreach ( $occurrences as $occ ) {
		$times   = bmc_atc_format( $occ );
		$lines[] = 'BEGIN:VEVENT';
		$lines[] = 'UID:' . $post->ID . '-' . $occ['start'] . '@' . $host;
		$lines[] = 'DTSTAMP:' . $stamp;
		if ( $times['all_day'] ) {
			$lines[] = 'DTSTART;VALUE=DATE:' . $times['start'];
			$lines[] = 'DTEND;VALUE=DATE:' . $times['end'];
		} else {
			$lines[] = 'DTSTART:' . $times['start'];
			$lines[] = 'DTEND:' . $times['end'];
		}
		$lines[] = 'SUMMARY:' . bmc_atc_ics_text( $data['title'] );
		$lines[] = 'DESCRIPTION:' . bmc_atc_ics_text( bmc_atc_details( $data, "\n\n" ) );
		if ( $data['location'] ) {
			$lines[] = 'LOCATION:' . bmc_atc_ics_text( $data['location'] );
		}
		$lines[] = 'URL:' . esc_url_raw( $data['url'] );
		$lines[] = 'END:VEVENT';
	}
	$lines[] = 'END:VCALENDAR';

	$filename = sanitize_file_name( $post->post_name ? $post->post_name : 'event' ) . '.ics';

	nocache_headers();
	header( 'Content-Type: text/calendar; charset=utf-8' );
	header( 'Content-Disposition: attachment; filename="' . $filename . '"' );
	echo implode( "\r\n", array_map( 'bmc_atc_fold', $lines ) ) . "\r\n"; // phpcs:ignore WordPress.Security.EscapeOutput -- iCalendar body, values escaped above.
	exit;
}

Place the buttons

First set the five values in bmc_atc_config():

  • post_types: your events post type slug, for example events.
  • start_key: the Advanced Date field name, which is the key holding the start timestamps.
  • end_key: your field name plus __end_date, for example event_date__end_date. Leave it empty if your events have no end date.
  • location_key: your location text field, or an empty string.
  • default_minutes: the event length used when a timed event has no end.

Then add [bmc_add_to_calendar] to your single event template or your listing item:

  • Elementor: a Shortcode widget.
  • Gutenberg: a Shortcode block.
  • Bricks: a Shortcode element.

Inside a JetEngine listing item, the shortcode reads the current post in the loop. If your events listing shows the wrong posts, our guide to common JetEngine Query Builder mistakes covers the usual causes. To show the formatted start and end dates next to the buttons, use a Dynamic Field with the Format date callback, as shown in Crocoblock's guide How to Show Advanced Date From Custom Field.

The shortcode accepts these attributes:

  • id="123": show the buttons for a specific event instead of the current post.
  • series="yes": the .ics file contains every stored occurrence. The Google link always adds one occurrence.
  • google_label and ics_label: the button text.

Inside the Dynamic Calendar widget, the listing item does not tell the shortcode which day cell it is rendered in. The links point to the next upcoming occurrence, not to that cell's date. Use series="yes" there if visitors should get every date.

The output is a div.bmc-atc wrapper with two links. Basic styling:

.bmc-atc { display: flex; flex-wrap: wrap; gap: 10px; }
.bmc-atc a { padding: 10px 16px; border-radius: 6px; background: #0f766e; color: #fff; text-decoration: none; }

How the code works

  • bmc_atc_get_occurrences() reads every numeric start and end timestamp and sorts them. When the number of ends matches the number of starts, each start is paired with its own end, so manually entered dates (Recurrency Format set to Manually, JetEngine 3.4.0 and later) keep their own times. When the counts differ, every occurrence uses the length of the first start/end pair, which matches how rule-based recurrence offsets each end date.
  • bmc_atc_local() reads each timestamp as wall-clock time in your site time zone. bmc_atc_format() then converts timed events to UTC, for example 20260614T220000Z.
  • An event that starts at midnight and has no end, or a length in whole days, is treated as all-day. It gets date-only values, and the end date is set to the day after the last day because calendars treat that end date as exclusive.
  • The description is the excerpt plus the event URL. The Google link joins them with | on one line, because esc_url() strips line breaks from URLs. The .ics file keeps a blank line between them.
  • The .ics handler runs on template_redirect when the event URL has ?bmc_ics=1. It only serves published, non-password-protected posts of your configured types, escapes the text the way the iCalendar format requires, and sends the file as text/calendar.

Test it

  1. Open a timed test event on the front end. You should see both buttons.
  2. Hover the Google button and check the dates value in the URL. For an 18:00 event on a New York site in summer, the start part of the dates value should end in T220000Z (for example 20260614T220000Z).
  3. Click the Google button. Google Calendar opens a pre-filled event with the title, description, location and correct local time.
  4. Click the Apple button. A file named after the post slug downloads. Opening it in Apple Calendar or Outlook shows an import prompt with the same details.
  5. Add series="yes" to a recurring event and download the file again. Every stored date should import as its own event.

Common problems

  • Nothing renders. The post type is not in post_types, or start_key does not hold numeric timestamps. Run the SQL query again and compare the key names.
  • Times are off by a few hours. The site time zone in Settings > General is wrong or set to UTC. If you change it after events were created, review those events, because the stored wall-clock times are now read in the new zone.
  • Every event lasts one hour. end_key is empty or points to the wrong key, so the code falls back to default_minutes.
  • Manually entered dates all get the same length. The number of stored ends does not match the number of starts, so the code falls back to the first pair. Check the end key rows for that post in wp_postmeta.
  • Overlapping manual dates get swapped end times. The snippet pairs sorted starts with sorted ends, so when one manually entered date starts later but ends earlier than another (for example 09:00 to 17:00 and 10:00 to 11:00 on the same day), the two swap their end times. Keep manual entries from nesting inside each other, or make them separate events.
  • A real midnight event shows as all-day. Midnight starts with whole-day lengths are treated as all-day by design. Give the event a non-midnight end time, or start it at 00:01.
  • The .ics link opens the web page instead of downloading. A page cache is serving the cached HTML. Exclude URLs containing bmc_ics from your caching plugin.
  • The Advanced Date field is missing from the field list. The Calendar module is off, or you are adding the field to something other than a post type.
  • Events created through a front-end form behave differently. Run the SQL check on a form-created event as well. Form submissions must store the same timestamp keys as events created in the admin.

With the settings right and the meta keys checked, one shortcode gives every event correct Google and Apple calendar buttons, including recurring and all-day events. If you would rather have this built and styled for your events site, post a request on BoostMyCroco.

Frequently Asked Questions

Does JetEngine have a built-in Add to Calendar button?
Not for Advanced Date event posts. Crocoblock's Google Calendar Dynamic Link sources only work with JetAppointment and JetBooking bookings (the JetAppointment one only in Elementor). For JetEngine event posts, use a custom shortcode like the one in this guide.
Why does my Google Calendar link show the wrong time?
JetEngine stores Advanced Date values as Unix timestamps without time zone adjustment. If you send that number straight to Google, it is read as UTC and the event shifts by your UTC offset. Convert the value from your site time zone to UTC first, and make sure the time zone is set in Settings > General.
Can users add a whole recurring event series to their calendar?
Yes, through the .ics file. With series="yes", the snippet puts every stored occurrence into one file as separate events, and Apple Calendar or Outlook imports them all. A Google Calendar template link adds one event at a time, so it uses the next upcoming date.
Does the Add to Calendar shortcode work with Bricks and Gutenberg?
Yes. It is a standard WordPress shortcode, so you can place it with the Shortcode element in Bricks, the Shortcode block in Gutenberg or the Shortcode widget in Elementor. It also works inside JetEngine listing items, where it reads the current post in the loop.
Can I use the Advanced Date field in a Custom Content Type?
When the field launched in JetEngine 3.3.0, it was available for posts only, and Crocoblock said other object types might come later. Check the field options in your JetEngine version before planning a CCT setup. The snippet in this guide reads post meta, so it is built for post types.

Sources

  1. How to Add Event to Google Calendar — JetAppointment (crocoblock.com)
  2. JetEngine v3.3.0 Beta · Issue #7140 · Crocoblock/suggestions · GitHub (github.com)
  3. JetEngine v3.4.0 Beta · Issue #7361 · Crocoblock/suggestions · GitHub (github.com)
  4. How to Display Repeating Events in a Dynamic Calendar — JetEngine (crocoblock.com)
  5. How to Show Advanced Date From Custom Field ㅡJetEngine l Crocoblock (crocoblock.com)
  6. Integrate "Add to calendar" functionality to Jetengine Calendar & additional widget to Elementor · Issue #1028 · Crocoblock/suggestions · GitHub (github.com)
  7. Please Add an "Add to Calendar" Button/Link that Uses Dynamic Content · Issue #3501 · Crocoblock/suggestions · GitHub (github.com)
  8. JetEngine v3.7.0 Beta · Issue #8143 · Crocoblock/suggestions · GitHub (github.com)
  9. How to Display Repeating Events in a Dynamic Calendar with Bricks (crocoblock.com)
  10. Jetengine - Allow Dynamic Calendar to Use CCT's + Advanced Dates · Issue #7715 · Crocoblock/suggestions · GitHub (github.com)
  11. JetFormBuilder Version 3.6.5 may have broken advanced date field validation · Issue #694 · Crocoblock/jetformbuilder · GitHub (github.com)
  12. How to Create an "Add to Google Calendar" Link (usecarly.com)

Related tips