Tag: The Events Calendar

  • Managing shelter programs with WordPress and The Events Calendar


    The Venango County Humane Society runs weekly programs. Bingo every Tuesday and Saturday night. A feline spay and
    neuter clinic every Tuesday morning. Adoption events, fundraisers, volunteer orientations, education sessions — most of them recurring, most of them indefinite, all of them needing to appear on a public calendar so donors, volunteers, and community members know when and where to show up.

    The shelter already used The Events Calendar for its public calendar view — it’s the most popular event plugin in WordPress, well-maintained, and the free version handles everything you’d expect from an event display. Except for one thing: recurring events. That feature is paywalled behind The Events Calendar Pro,
    which starts at around $100 a year. For a shelter with a strict
    operating budget, that’s a real decision — and for a pattern as simple as “bingo every Tuesday and Saturday, forever,” it’s a lot of money to spend on a calendar.

    The natural workaround is The Events Calendar’s CSV import. You can build a spreadsheet with one row per event instance, upload it, and TEC will create the events. This works. It also means that every quarter, the front desk manager has to open a spreadsheet, duplicate last week’s bingo row thirteen more times, fix the dates, re-upload, and hope nothing went wrong. A recurring service generates 100+ events a year, and the import file has to be maintained as a rolling document. It’s not hard, exactly. It’s just constant, and it’s the kind of maintenance work that has no upside — it just has to get done.

    This post is about the plugin I built to make that go away. It’s at
    wp-content/plugins/vcpahumane-shelter-events-wrapper/ in the site’s tree, available as a GPL project at
    github.com/jwincek/vcpahumane-shelter-events-wrapper,
    and it went live on vcpahumane.org about two weeks ago. Currently two people edit events with it: me and the shelter’s front desk manager.

    The plugin is a thin authoring layer on top of The Events Calendar.
    You define a program once (“Bingo, every Tuesday and Saturday, 5 PM to 9 PM, at the shelter”), and the plugin generates individual TEC events for each occurrence on a rolling schedule. TEC continues to handle the public calendar display — this plugin doesn’t touch the front-end — but the authoring experience is now “describe the pattern” instead of “maintain a spreadsheet.” That’s the entire pitch.

    Is it a wrapper or an extension?

    The plugin is called “shelter-events-wrapper,” but reading the code I’d actually describe it as an extension alongside TEC, not a
    literal wrapper. The admin doesn’t stop using TEC’s interface — they still see TEC’s events list, still click into individual event posts
    to edit them when needed, still use TEC’s categories and calendar
    views on the front end. The plugin adds a second authoring surface (the Programs CPT) that generates TEC events but doesn’t replace TEC’s own editing.

    This matters because the plugin inherits TEC’s strengths for free.
    Every TEC extension in the ecosystem — integrations with booking
    systems, email marketing, social media sharing — keeps working. The calendar views, archive pages, and single-event templates remain TEC’s, which means they get TEC’s updates and accessibility improvements without anyone having to backport anything.

    It also means the plugin has a narrower responsibility: authoring
    recurring programs. It doesn’t try to be a better calendar. It just
    tries to be a better admin experience for the specific case of
    defining recurring services that run indefinitely.

    Decision 1: programs as a custom post type, events as the output

    The centerpiece is a custom post type called shelter_program. Each program has the fields you’d expect — title, description, recurrence pattern, start and end times, timezone, venue, organizer, cost — plus a few nonprofit-specific additions: a contact email, an “appointments required” flag, age restrictions, a “variable pricing” checkbox for services like the spay/neuter clinic where cost depends on the animal, and a blackout dates list.

    A program is not an event. It’s a description of a recurring service.
    When the cron runs, or when an admin clicks “Generate Events” on a dedicated page, the plugin walks forward through the calendar and creates individual TEC events for each occurrence that matches the program’s pattern. Those events are stored in TEC’s native tribe_events post type using TEC’s PHP ORM:

    ```php
    $event = tribe_events()
        ->set_args( [
            'title'          => 'BINGO Night — Tuesday, April 15, 2025',
            'description'    => $program['description'],
            'start_date'     => $start,
            'end_date'       => $end,
            'timezone'       => $program['timezone'],
            'venue'          => $venue_id,
            'organizer'      => $organizer_id,
            'cost'           => $program['cost'],
            'featured'       => $program['featured'],
        ] )
        ->create();

    No direct database writes. No template overrides. No wrestling with TEC’s internals. The ORM exists precisely for this use case and it works well. The plugin’s contribution is the authoring UX and the automation; TEC’s contribution is everything else.

    The rolling window is eight weeks by default, configurable in config/events.json. Each daily cron run extends the window forward, creating new events as needed. Nothing is generated indefinitely into the future — just enough for visitors browsing the next two months to see what’s upcoming.

    Decision 2: deterministic hashes for duplicate prevention

    Cron runs, and it runs again, and sometimes it runs a third time because a stale job didn’t get cleaned up properly, and suddenly you have three copies of next Tuesday’s bingo on your calendar. This is the classic failure mode of “generate events from a schedule,” and the standard fix is to make event creation idempotent: running the generator twice should produce the same result as running it once.

    The plugin does this with a SHA-256 hash of the program slug plus the event date, stored as post meta on each generated event:

    private static function make_hash( string $slug, string $date ): string {
        return hash( 'sha256', $slug . '|' . $date );
    }
    
    private static function event_exists( string $meta_key, string $hash ): bool {
        global $wpdb;
        $exists = $wpdb->get_var(
            $wpdb->prepare(
                "SELECT COUNT(*) FROM {$wpdb->postmeta}
                 WHERE meta_key = %s AND meta_value = %s",
                $meta_key,
                $hash
            )
        );
        return (int) $exists > 0;
    }
    

    Before creating an event for a given program on a given date, the generator hashes the pair and checks whether an event with that hash already exists. If yes, skip. If no, create and stamp the hash onto the new post.

    This is simple enough that it sounds almost beneath mention, but the alternatives are worse: a unique compound meta key requires custom table creation; a title-based check breaks the moment you change a program’s name; a “last generated” timestamp on the program doesn’t handle partial runs. The hash approach is idempotent, storage-efficient, and fails safely — if somehow an event does get duplicated, the worst that happens is you delete one manually and the check prevents the next cron run from recreating it.

    Decision 3: the Replace workflow

    This is the decision I’m most pleased with, because it came directly from a problem I didn’t anticipate.

    Here’s the problem: programs run forever, but reality intervenes. “Bingo is cancelled this week because of the holiday.” “This month’s Feline Clinic is being replaced with a special shots-only session.” “Tuesday’s event is happening in a different venue because we’re renovating the main hall.” The recurring definition is still correct — bingo will resume next week — but one specific occurrence needs to change or be replaced.

    A naive solution is to let the admin edit the individual generated event in TEC’s interface. That works until the next cron run overwrites the edit, because the syncer sees a program-linked event that’s out of sync with its program definition and “corrects” it.

    The plugin solves this with an explicit Replace workflow. From the Generate Events admin page, each upcoming event gets a “Replace” action that does three things:

    1. Cancels the original event. It’s not deleted — the post stays in place with [CANCELLED] prepended to the title and a _shelter_cancelled meta flag set. This preserves a historical record and keeps the cross-references intact.
    2. Creates a brand-new draft event. The new event is pre-populated with the original’s date, time, venue, and organizer, but it’s otherwise a blank slate — the admin has full creative freedom to change the title, description, featured image, category, and anything else.
    3. Stores cross-references. The original gets a _shelter_replaced_by meta pointing to the new event’s ID; the new event gets a _shelter_replaces_event meta pointing back to the original. The Event Syncer respects this linkage — it will never re-sync a replaced event from the program definition, so the creative edits are safe from future updates.

    After clicking Replace, the admin is kicked into TEC’s native block editor for the new event. They can use TEC’s categories, meta, and content tools normally — nothing in the replacement workflow is proprietary to this plugin. When they publish, the new event appears on the calendar in place of the cancelled one. Two weeks later, the regular Tuesday bingo resumes without any further intervention.

    This is the feature that turns “a better way to create recurring events” into “a real tool for managing a recurring program.” Anyone building event tooling for a nonprofit will eventually run into “this week is different,” and having a thoughtful answer to that question is the difference between a demo and a production tool.

    Decision 4: weekly recurrence only (and why that’s probably wrong)

    Let me flag a real limitation honestly. The plugin only supports weekly day-of-week recurrence — “every Tuesday and Saturday” — and nothing more complex. It doesn’t handle “second Saturday of the month,” “last Friday of every month,” “every other Tuesday,” “alternating Mondays and Wednesdays,” or any other pattern that can’t be expressed as a fixed set of weekdays repeating every week.

    For VCHS’s current programs this is fine. Bingo is every Tuesday and Saturday; the clinic is every Tuesday. None of the shelter’s programs need anything more sophisticated right now.

    But I already know this won’t last. The moment the shelter adds a monthly fundraiser or a “second Saturday of the month” volunteer orientation, the plugin will fall over and I’ll have to extend it. The cleanest way to handle this is probably to adopt RFC 5545 RRULE syntax — the same standard iCalendar uses — which supports all of those patterns cleanly and has battle-tested PHP libraries. But that’s a nontrivial refactor of the metabox, the generator, and the UI, so I’ve punted on it for now.

    Worth knowing if you clone the plugin and your use case is more complex than “specific weekdays every week.” You’ll hit this wall faster than I did.

    Decision 5: the small UX details that matter for non-technical users

    One of the two editors using this plugin is the shelter’s front desk manager, who is not a developer and didn’t ask to be one. This shaped several small UX decisions that are worth noting because they’re the kind of thing that doesn’t make it into most technical posts.

    Day-of-week chips instead of a text field. The recurrence pattern is set by clicking toggle buttons for each weekday. Not a comma-separated list, not a dropdown, not an RRULE string — visual pill buttons that say “Mon Tue Wed Thu Fri Sat Sun” and light up when clicked. Anyone can understand this without explanation.

    “Dry run” preview on the generate page. Before actually creating events, admins can click “Generate (dry run)” to see a preview of what would be created. This makes the generator feel safe to experiment with. Running it accidentally doesn’t break anything; it just shows you a list.

    “Also update past events” checkbox, off by default. When you change a program — say, you correct a typo in the description, or change the venue — by default only future events are updated. Past events are frozen as historical record. The opt-in checkbox lets you override that when you actually want to retroactively fix past events, but it’s not the default because the default should preserve history.

    Cancelled events stay visible, with a badge. When an event is cancelled (either individually or via Replace), it isn’t deleted — it stays on the calendar with a “Cancelled” badge. This way anyone looking at the calendar retrospectively can see “yes, bingo was supposed to happen on March 5th, and it was cancelled, not missed.” For a recurring community program, that matters.

    The Staff Guide, rendered from README.md inside WordPress admin. There’s a dashboard page under Events → Staff Guide & Help that renders the plugin’s README.md as HTML. The parser is hand-rolled (it handles the specific Markdown subset the README uses — headings, lists, tables, code blocks, emphasis, links — nothing fancier) and has no external dependencies. The front desk manager can find help without leaving WordPress, without visiting GitHub, without knowing that GitHub exists. For a plugin with two users, one of whom is not technical, this matters more than any code-level architectural decision.

    None of these are flashy. None of them are “WordPress 6.9 Abilities API” or “Block Bindings” or any of the novel-sounding things. But they’re the decisions that determine whether the front desk manager actually uses the plugin or reverts to the spreadsheet. Technical sophistication without UX thoughtfulness is just a more elaborate spreadsheet.

    A warning about get_posts() and TEC venues

    This is a “small gift to the next developer” section, same spirit as the edit.asset.php gotcha from the companion plugin post.

    When creating TEC events from a program, the plugin needs to reuse existing venues and organizers if they already exist with the same name — otherwise every sync creates duplicate venues, and you end up with forty-seven “Venango County Humane Society” entries in the venues list.

    The natural way to look up a venue by name is:

    $existing = get_posts( [
        'post_type' => 'tribe_venue',
        'post_title' => $venue_name,
        'post_status' => [ 'publish', 'draft' ],
        'posts_per_page' => 1,
    ] );
    

    This does not work. get_posts() silently ignores the post_title parameter — it’s not a supported WP_Query argument, despite looking like it should be. get_posts() will return the first venue it finds regardless of title, and if there are no venues at all it’ll return an empty array. You won’t get an error. You won’t get a warning. You’ll just get the wrong result, and your plugin will quietly create duplicate venues every sync.

    The fix is a direct $wpdb query:

    $existing_id = $wpdb->get_var(
        $wpdb->prepare(
            "SELECT ID FROM {$wpdb->posts}
             WHERE post_type = 'tribe_venue'
               AND post_title = %s
               AND post_status IN ('publish', 'draft')
             LIMIT 1",
            $venue_name
        )
    );
    

    This is one of those “WordPress quirk” things that’s well-known if you’ve dealt with it before and completely invisible if you haven’t. I lost real time to this during development. If you’re writing any plugin that looks up posts by title, always go through $wpdb directly, not get_posts() or WP_Query — they quietly don’t support title filtering even though the documentation makes it look like they do.

    (As a bonus, the plugin’s title-match query also promotes any matching draft venues to publish status when they’re reused. This avoids a subtle bug where a venue created manually but left as a draft would stay invisible on the calendar even though events were being assigned to it.)

    What’s still open

    Features I haven’t built yet, in rough order of how likely I am to actually need them:

    1. Monthly and more complex recurrence patterns. The weekly-only limitation will bite eventually. RFC 5545 RRULE is the right answer; doing the refactor well is the hard part. If you have experience with RRULE parsing libraries for PHP, I’d love help with this.
    2. A proper date picker for blackout dates. Currently blackout dates are entered as a textarea with one date per line in YYYY-MM-DD format. It works. A calendar widget would be kinder to the non-technical user.
    3. Bulk actions on programs. If you want to pause five programs at once (say, during a facility renovation), you currently have to edit each one individually. A bulk action on the programs list table would be a small, useful addition.
    4. Time-of-day differences within a recurrence pattern. Currently a program has one start time and one end time, so “Bingo is 5 PM on Tuesdays but 7 PM on Saturdays” requires two separate programs. That’s fine for now but it’s a papercut.
    5. An iCal export endpoint. TEC already provides this for its own events, so it’s probably already covered, but worth verifying. If TEC’s iCal doesn’t include shelter-program-generated events correctly, a custom export would be useful.
    6. An optional “import from TEC CSV” migration path for shelters already using CSV imports. It should be possible to detect repeating patterns in an existing CSV file and offer to convert them into program definitions. Not built yet.

    None of these are blocking anyone. The plugin is doing its job for VCHS’s current programs, and the absent features are nice-to-haves rather than dealbreakers. But if you’re a developer who wants to contribute and any of these sound interesting, the repo is open and I’d be happy to pair on design.

    How other shelters could use this

    This is a case study more than a pitch, but I want to say something honest about portability.

    The plugin is written for a specific shelter with specific programs, and the seed data in config/events.json reflects that — it creates bingo and the spay/neuter clinic as starter programs on first activation. For another shelter to use the plugin, the first thing they’d need to do is delete those seed programs and create their own.

    The category taxonomy (Fundraiser, Clinic, Adoption, Education, Volunteer) was chosen for VCHS’s programming but is generic enough that most shelters would find it applicable. Categories are editable from the WordPress admin like any normal taxonomy, so a shelter with different needs can add their own without touching code.

    The plugin’s text domain is shelter-events (not vcpahumane-*) because I wanted it to feel generic enough to apply elsewhere. The UI strings, the field labels, the help text — none of them reference VCHS specifically. A shelter in Idaho could install this plugin and use it without seeing “Venango County” anywhere.

    That said, I haven’t actually tested it at another shelter. If you run a small nonprofit that manages recurring programs and you’re currently wrestling with TEC’s CSV import, I’d love to hear whether this plugin would work for you, and what would need to change to make it usable. Open an issue, send an email, whatever works.


    The repo: github.com/jwincek/vcpahumane-shelter-events-wrapper

    License: GPL

    Requires: WordPress 6.4+, The Events Calendar (free), PHP 8.1+

    Status: Live on vcpahumane.org as of two weeks ago. Two active users. Currently managing two recurring programs generating several events a week; tested at smaller scale than it would ideally support, but no known issues.

    If you’re building similar tools for other nonprofits, I’d love to compare notes. And if you’re a nonprofit and you’d like to try this plugin, I’d love even more to hear whether it actually helps.