Skip to content

Simple, de-coupled events plugin for WordPress and Timber

License

Notifications You must be signed in to change notification settings

sitecrafting/greg

Repository files navigation

Greg

I mean, do you really want to deal with The Events Calendar?

-- Pope Gregory XIII

Build Status

A de-coupled calendar solution for WordPress and Timber. Leverage RRULE to store only the recurrence rules for your recurring events. Supports one-time and recurring events.

Contents

Rationale

Let's get one thing out of the way: Greg is not a drop-in replacement for The Events Calendar. Greg is designed to be much more flexible code-wise, with the trade-off of being a little less "plug-n-play" from an end-user's perspective. Some key differences:

  • Rather than the plugin rendering its own WP Admin user interface (UI), you build your own backend fields (using ACF or something similar) and tell Greg where to find that data (see Basic Usage for details).
  • The Events Calendar is completely standalone (Note: The Events Calendar PRO is a paid add-on to The Events Calendar), whereas Greg relies on Timber and, by extension, Twig.
  • The Events Calendar (and most other WP event management plugins out there) store one post per event recurrence. This causes all kinds of problems, including slow database queries, confusing and burdensome data management issues, and tons of incidental complexity. Greg cuts away all of that.

Use Greg if:

  • You want to manage recurring events in a simple, reasonably performant way
  • You are OK building out your own backend UI for Events using ACF or similar (a default backend UI is on the Roadmap but is not implemented yet)
  • You are already running Timber/Twig and want to integrate your frontend Event code with that

Do NOT use Greg if:

  • You want a plug-n-play event management system that renders your event calendar/listing/detail pages for you

  • You want a WP Admin page with tons of settings you can control without code updates

  • You want a standalone solution (not reliant on Timber)

Requirements

  • PHP >= 7.4
  • WordPress Core >= 5.5.1
  • Timber 1
    • If you need Timber 2 support, use Greg v0.4.x

Installation

Manual Installation

Go to the GitHub releases page and download the .zip archive of the latest release. Make sure you download the release archive, not the source code archive. For example, if the latest release is called v0.x.x, click the download link that says greg-v0.x.x.zip. (You can also use the tar.gz archive if you want - they are the same code.)

Once downloaded and unzipped, place the extracted directory in wp-content/plugins. Activate the plugin from the WP Admin as you normally would.

Composer (advanced)

Add to composer.json:

composer require sitecrafting/greg

Greg is PSR-4 compliant, so assuming you've required your vendor/autoload.php as you normally would, you can use Greg\* classes from anywhere.

Quick Start

Event Archive

/* archive-greg_event.php */

use Timber\Timber;

$data = Timber::context();

$data['events'] = Greg\get_events();

Timber::render('archive-greg_event.twig', $data);
{# views/archive-greg_event.twig #}
{% extends 'layouts/my-main-layout.twig' %}

{% block main_content %}
  <main class="event-listing">
    <h1>Events </h1>

    {% for event in events %}
      <article>
        <h2><a href="{{ event.link }}">{{ event.title }}</a></h2>
        {# October 31, 11:10 am - 1:30 pm #}
        <h3>{{ event.range('F j, g:ia', 'g:ia') }}</h2>
        <section>{{ event.content }}</section>
      </article>
    {% endfor %}

    <div class="pagination">
      <a href="{{ greg_prev_month_query_string() }}">{{ greg_prev_month('F') }} Events</a>
      <a href="{{ greg_next_month_query_string() }}">{{ greg_next_month('F') }} Events</a>
    </div>

  </main>
{% endblock %}

Singular Event template

/* single-greg_event.php */

use Greg\Event;
use Timber\Timber;

$data = Timber::context();

// Wrap post in an Event object, so we get access to methods like range()
$data['event'] = Event::from_post($data['post']);

Timber::render('single-greg_event.twig', $data);
{# views/archive-event.twig #}
{% extends 'layouts/my-main-layout.twig' %}

{% block main_content %}
  <main class="event-details">
    <h1>{{ event.title }}</h1>{# post.title will also work #}
    
    {% if event.recurring() %}
    	{# If no special human-written recurrence_description exists,
    	   will default to something like "November 1st thru 10th at 9:30am" #}
    	<h2>{{ event.recurrence_description
    	    | default(
    	    	event.recurrence_range('F jS', 'jS', ' thru ')
    	    	~ ' at ' ~ event.start('g:ia')
    	    ) }}</h2>
    {% else %}
    	{# i.e. "November 3rd 9:00am - 11:30am" #}
    	<h2>{{ event.range('F jS g:ia', 'g:ia') }}
    {% endif %}
		
		<div class="rtecontent">
			{{ event.content }} {# post.content will also work #}
		</div>

  </main>
{% endblock %}

Event Category archive template

/* archive-greg_event_category.php */

use Timber\Timber;

$data = Timber::context();

// NOTE: get_events() knows when the main WP query is an Event Category query.
$data['events'] = Greg\get_events();

Timber::render('archive-greg_event_category.twig', $data);
{# views/archive-greg_event_category.twig #}
{% extends 'layouts/my-main-layout.twig' %}

{% block main_content %}
  <main class="event-listing">
    <h1>Events </h1>

		{# This part is the same as archive-greg_event_category.twig, above #}
    {% for event in events %}
      <article>
        <h2><a href="{{ event.link }}">{{ event.title }}</a></h2>
        {# October 31, 11:10 am - 1:30 pm #}
        <h3>{{ event.range('F j, g:ia', 'g:ia') }}</h2>
        <section>{{ event.content }}</section>
      </article>
    {% endfor %}

		{# Persist any already-applied Event filters #}
    <div class="pagination">
      <a href="{{ greg_prev_month_query_string() }}">{{ greg_prev_month('F') }} Events</a>
      <a href="{{ greg_next_month_query_string() }}">{{ greg_next_month('F') }} Events</a>
    </div>

  </main>
{% endblock %}

Basic Usage

Greg is built to have no opinion about what your site looks like, and is solely concerned with making your event data easier to work with. You store RRULE data for each event describing:

  • start/end dates
  • recurrence frequency
  • recurrence length (i.e. recurs until)
  • any exceptions to the recurrence rules (e.g. every Friday except 2020-11-13)

The only assumption about your data is that it's stored in post meta fields. Using a simple API, you tell Greg how to find this data. Later, at query time, you ask Greg for certain events (matching time-based/category criteria, and any other criteria you like) and it gives you back all recurrences of all matching events it finds.

Conceptual example

Say you have the following events:

  • Hip-hop Dance Nights: starts Jan 1 and recurs weekly at 5pm until Jan 22
  • Karate Tournament: starts Jan 3 and recurs daily at 10am until Jan 6
  • Troll 2 Screening: one night only on Jan 4 at 9pm

When you query for events in January, Greg gives you a Greg\Event object back for each recurrence:

  • Hip-hop Dance on Jan 1
  • Karate Tournament on Jan 3
  • Karate Tournament on Jan 4
  • Troll 2 Screening (after Karate Tournament, since it's later in the day)
  • Karate Tournament on Jan 5
  • Karate Tournament on Jan 6
  • Hip-hop Dance on Jan 8
  • Hip-hop Dance on Jan 15
  • Hip-hop Dance on Jan 22

If you want to, you can also tell Greg to give you just the three Events, in case you want to display info about each series as a whole.

API example

add_filter('greg/meta_keys', function() : array {
  return [
    'start'                  => 'my_start_date',
    'end'                    => 'my_end_date',
    'frequency'              => 'my_frequency',
    'until'                  => 'my_until',
    'exceptions'             => 'my_exceptions',
    'overrides'              => 'my_overrides',
    'recurrence_description' => 'my_recurrence_description',
  ];
});

Greg uses this info to figure out how to do time-based queries for events, and also how to fetch recurrence rules a given event post.

Now we're ready to actually fetch our posts. To do that we call Greg\get_events(), which is just like Timber::get_posts(), but with some syntax sugar for nice, concise queries:

/* archive-greg_event.php */

use Timber\Timber;

$event = Greg\get_events();

$context = Timber::context();
$context['posts'] = $events;

Timber::render('archive-greg_event.twig', $context);

You can also specify month and/or event category:

$events = Greg\get_events([
  'event_month'    => '2020-11',
  'event_category' => 'cool',
  // OR by term_id:
  // 'event_category' => 123,
]);

// Assuming the greg/meta_keys hook in the example above,
// this query expands to:
$events = Timber::get_posts([
  'tax_query'  => [
    [
      'taxonomy' => 'greg_event_category',
      'terms'    => ['cool'],
      'field'    => 'slug',

      // OR if you passed an int:
      // 'terms' => [123],
      // 'field' => 'term_id',

      // OR if you passed an array of strings:
      // 'terms' => ['array', 'of', 'slugs'],
      // 'field' => 'slug',

      // OR an array of ints:
      // 'terms' => [123, 456],
      // 'field' => 'term_id',
    ],
  ],
  // NOTE: it uses meta_query and not date_query,
  // since this is completetly different from post_date
  'meta_query'   => [
    'relation'   => 'AND',
    [
      'key'      => 'my_start_date',
      'value'    => '2020-11-01',
      'compare'  => '>=',
      'type'     => 'DATETIME',
    ],
    // ... additional constraints by my_end_date_key here ...
  ],
]);

This returns an array of Greg\Event objects. These are wrappers around Timber\Post objects that acts like a Post object but has some extra data/methods for things like date ranges. Each Event represents a single recurrence of an event, so a Post can expand to one or more Events.

Passing a string for event_category indicates it's a term slug; int means it's a term_id. You can also pass an array of strings or ints to query for events by any (inclusive) corresponding slugs/term_ids, respectively.

Instead of month, you can also specify a date range via separate start and end params:

Greg\get_events([
  // Just picking some dates at random here, totally not preoccupied with anything...
  'start'  => '2020-11-03',
  'end'    => '2021-01-20',

  // Other params...
]);

The time-based filters month and start/end work independently of category, as you'd expect.

Querying for event series only

By default, Greg will parse out any recurrence rules it finds and expand all recurring events out into their respective individual recurrences, finally sorting all recurrences by start date. But that may not be what you want all the time. You can tell Greg you only want the series, not the individual recurrences, using the special expand_recurrences param set to false:

Greg\get_events([
  'expand_recurrences' => false
]);

The expand_recurrences param composes with any other valid Greg/WP_Query params: that is, it doesn't actually affect the database query logic in any way, it just tells Greg to skip the step of expanding recurrences.

Additional params

Any parameters not explicitly mentioned above get passed through as-is, so you can for example use the WordPress native offset param to exclude the first five events from your results:

Greg\get_events([
  'event_month'  => '2020-11', // Greg expands this into a meta_query as usual.
  'offset'       => 5,         // This gets passed straight through to WordPress.
]);

If you specify any custom meta_query or tax_query params, Greg will merge them in with its own time-based/category sub-queries.

Performance

Greg is currently not as optimized as it could be. Specifically, to fetch the actual meta data it currently does a few database lookups for each recurring event in a result set. This is pretty typical of WordPress code, where you don't usually fetch meta data until after you've already queried for your post(s). But a faster way would be to map between a post ID and a recurrence ruleset in a single query result, so that for a collection of events of any size, we only need one round trip to the database. This would require some advanced logic to compile custom SQL clauses at query time, and may become available in a future version.

Templates & Views

Greg takes full advantage of Timber's use of the Twig template engine. While Greg's frontend code is completely optional, the basic views provided are useful out of the box and are completely customizable.

In PHP: Greg\render()

You can render a Twig view from PHP with the Greg\render() function:

Greg\render('event-categories.twig');

Note that unlike Timber::render(), a static method on the Timber class, this is a plain PHP function in the Greg namespace, hence the backslash notation Greg\render() instead of the double-colon.

Like Timber::render(), it takes an optional array of data to pass to the Twig view:

Greg\render('event-categories.twig', [
  'extra' => 'stuff',
]);

You don't need to do this unless you're overriding Greg's views from your theme (and in fact passing extra data to the default views has no effect, since they don't care about the extra data passed to them). More on that below.

There is also a Greg\compile() method which returns the compiled string instead of just echoing it, in a way exactly analogous to the Timber::compile() method.

In Twig: greg_render()

To render a view straight from Twig code, use the greg_render() Twig function:

<aside class="event-cats-container">
  {{ greg_render('event-categories.twig') }}
</aside>

As with the Greg\render() PHP function, you can pass extra data:

<aside class="event-cats-container">
  {{ greg_render('event-categories.twig'), { extra: 'stuff' } }}
</aside>

Available views

Any of the following views can be rendered directly (e.g. passed to greg_render()):

events-list.twig

List all events.

Params:
  • params: query params passed to Greg\get_events(). Default: []
  • events: all events
Example
{{ greg_render('events-list.twig', { params: { event_month: '2020-11' } }) }}

event-categories-list.twig

List all Event Categories.

Params
  • term: Timber\Term object for overriding the "current" term
  • terms: array of Timber\Term objects to override the listed categories
Example
{{ greg_render('event-categories-list.twig', { term: { my_term } }) }}

event-details.twig

Display Event Details.

Params
  • term: Timber\Term object for overriding the "current" term
  • terms: array of Timber\Term objects to override the listed categories
Example
{{ greg_render('event-categories-list.twig', { term: { my_term } }) }}

Overriding Greg's views

A view can be overridden from your theme simply by placing it at a specific path relative to your theme route. For example, by placing a file at ./views/greg/event-categories.twig, you tell Greg to render your theme's event-categories view instead of Greg's built-in one.

Greg transparently passes any extra data you pass to greg_render/Greg\render():

{# views/greg.twig #}
<div class="event-cats">
  {# render each event cat here... #}

  {# extra data passed to greg_render() #}
  <p>{{ extra }}</p>
</div>

Providing and overriding view data

Use the greg/render/$view_name.twig filter to override data that gets passed to any Greg view, any time it's rendered:

add_filter('greg/render/event-categories.twig', function(array $data) : array {
  // add some extra stuff any time event-categories.twig gets rendered
  return array_merge($data, [
    'extra' => 'stuff',
  ]);
});

Again, since the default views don't care about any extra data passed to them, you don't need to worry about this unless you are overriding Greg's views from your theme. Because you can always pass a data array directly to greg_render()/Greg\render(), this is an advanced use-case that you really only need if you want to customize view data across all usage of a given view.

The Event class

The Greg\Event class is a wrapper around Timber\Post that can be treated mostly interchangeably as a regular Post object. It delegates any non-Greg functionality to the Post instance it is wrapping:

/* my-event-template.php */
use Greg\Event;
use Timber\Timber;

$event = Event::from_post(Timber::get_post(123));

Timber::render('my-event-template.twig', [
  'event' => $event,
]);

This means that in your template code you mostly use Event instances as you would a Post:

{# my-event-template.twig #}
<a href="{{ event.link }}">{{ event.title }}</a>
<span class="byline">organized by {{ event.author }}</span>

However, its main purpose is to provide data specifically suited to displaying date and time information.

Start, end, and range

The Event::range() is the easiest way to output start and end time together:

{{ event.range }}
{{ event.range('Y-m-d h:i', 'h:i') }}
{{ event.range('Y-m-d h:i', 'h:i', ' until ') }}

This will output something like:

January 1 9am - 3pm // NOTE: depends on global WP time_format
2021-01-01 09:00 - 15:00
2021-01-01 09:00 until 15:00

This is equivalent to:

{{ event.start }} - {{ event.end }}
{{ event.start('Y-m-d h:i') }} - {{ event.end('h:i') }}
{{ event.start('Y-m-d h:i') }} until {{ event.end('h:i') }}

Recurrence information

recurring()

You may sometimes want to check whether an Event recurs or not:

{% if event.recurring %}
  <span>Recurring</span>
{% else %}
  <span>One-time only!</span>
{% endif %}

Currently this only considers the until and frequency meta fields passed to greg/meta_keys, but this may change in a future version of Greg.

recurrence_range() and until()

The Event::recurrence_range() method is like ::range() except that it deals with until, the end date of the recurrence (if any):

{{ event.recurrence_range }}
{{ event.recurrence_range('M jS', 'jS') }}
{{ event.recurrence_range('M jS', 'jS', ' thru ') }}

This will output something like:

January 1 - 3, 2020
Jan 1st - 3rd
Jan 1st thru 3rd

This is equivalent to:

{{ event.start('M j') }} - {{ event.until('M j, Y') }}
{{ event.start('M jS') }} - {{ event.until('M jS') }}
{{ event.start('M jS') }} until {{ event.until(' M jS ') }}

Note that unlike ::range(), the default formats in ::recurrence_range() do not depend on global WP settings, which is why the first line above passes args to start() and end().

recurrence_description()

The Event::recurrence_description() method returns a human-readable description of the recurrence. Greg will look in the recurrence_description key you pass to the greg/meta_keys hook. If the meta value is empty (i.e. the admin user put nothing in that field), Greg will generate its own description based on the recurrence data available.

frequency()

The Event::frequency() method returns the RRULE field for recurrence frequency. This corresponds to the frequency meta key you pass to the greg/meta_keys filter. It is on you to ensure that the value is one of:

  • secondly
  • minutely
  • hourly
  • daily
  • weekly
  • monthly
  • yearly

These values are case-insensitive. A future version of Greg may take on this responsibility, and manage these values for you, but we're not there yet!

Actions & Filters

Event query params

To make customizations across all Greg queries, you can hook into the greg/query/params filter. For example, say you just want a simple event series manager, and never need to list individual recurrences:

add_filter('greg/query/params', function(array $params) : array {
  $params['expand_recurrences'] = false;
  return $params;
});

The hook runs inside Greg\get_events() before the params are expanded into meta/taxonomy queries as described above, which is how it can honor special keys like expand_recurrences.

Query params: A more advanced example

Say you have a custom location post type associated with each event by the meta_key location_id, and on single location pages you want to query only for events at that location. (In this contrived scenario, there's no reason you couldn't simply run the query directly from your template. But humor me for a second and just imagine there's some reason you can't. Okay? Cool.) You can hook into Greg's query logic to accomplish this:

add_filter('greg/query/params', function(array $params) : array {
  if (is_single()) {
    $params['meta_query'] = [
      [
        'key'   => 'location_id',
        'value' => get_the_ID(),
      ],
    ];
  }

  return $params;
});

Since Greg simply uses Timber::get_posts() under the hood, the array returned from this hook can be any valid arguments to WP_Query::__construct().

Command Line Interface (CLI)

Greg comes with some custom WP-CLI tooling:

wp greg # describe sub-commands

(In the dev environment, prefix this with lando to run it inside Lando!)

Development

Clone this repo and start the dev environment using Lando:

lando start

NOTE: you may need to flush permalinks manually (Settings > Permalinks) for sub-pages to work.

Testing

lando unit # run unit tests
lando integration # run integration tests
lando test # run all tests

Roadmap

  • Performance optimizations (see Performance, above)
  • REST endpoints for listing/singular events
  • Default (but still optional) admin GUI using the configured meta keys
  • Shortcodes for listing Events, listing Event Categories, Event Details, etc.