Tag: Gutenberg

  • 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.

  • Building the pet companion plugin: server-rendered blocks, no build step


    I’ve been doing development work for the Venango County Humane Society since early 2025. Most of what I’ve built lives behind the scenes — a Petstablished sync plugin, some custom post types, a faceted pet browser. But earlier this month I shipped something different: a small companion plugin that lets any WordPress site embed adoptable pets from the shelter using Gutenberg blocks.

    If you’re a partner site looking to install it, I wrote a separate post for that. This one is for WordPress developers — what’s in the plugin, what the interesting decisions were, and what’s still open. The repo is at github.com/jwincek/vcpahumane-pet-companion.

    The shape

    Two blocks, no build step, no JavaScript bundler, no node_modules. PHP renders everything; JavaScript exists only in the editor (edit.js) for the sidebar controls. Both blocks talk to the source site’s standard /wp-json/wp/v2/pet endpoint with a transient cache layer in front.

    The plugin is intentionally small — a few hundred lines of PHP, a few hundred of plain JavaScript, no dependencies. I want partner sites with limited technical resources to be able to read the entire plugin in an afternoon and understand it.

    Decision 1: two blocks instead of three

    The first version had three blocks: Pet Listing Grid, Pet Card, and Featured Pets. The grid and the featured-pets blocks did almost the same thing — both rendered a card grid, differing mainly in whether the order was randomized and whether the count was big or small. After looking at them side by side for a day, I deleted the listing grid.

    The reasoning: if the partner site wants a “browse all” experience, the right answer is to link to the shelter’s own adoption catalog at
    vcpahumane.org/adopt/pets, not to rebuild that catalog locally. The shelter already has filters, pagination, and a real adoption flow over there. The companion plugin’s job is to be a friendly handoff to the shelter, not to duplicate the destination.

    That left two blocks with genuinely different jobs:

    • Featured Pets — a small rotating selection. Sidebar surface. Browse-y.
    • Pet Card — a single specific pet, embedded inside a blog post or fundraiser page. Connection-y.

    Once I framed them as “browse” and “feature,” the design choices for each became obvious in different directions.

    Decision 2: Pet Card is a profile, not a card

    The original Pet Card block looked like the cards in the Featured Pets grid — just bigger. That felt wrong as soon as I had both on a page together. A visitor encountering Featured Pets is browsing; they want a thumbnail to click. A visitor encountering Pet Card is reading a blog post about a specific animal; they’re already interested. Optimizing both for click-through made the second one feel cheap.

    So I rewrote Pet Card as a magazine-style profile: big image on the left (or top, on mobile and via a sidebar toggle), the shelter’s full writeup — not a 22-word excerpt — and a real pill-shaped CTA button that says “Start the adoption process” instead of a subtle “Meet me →” link. Same data, different surface, different intent.

    The two blocks now share almost no markup. Featured Pets uses a shared vcpa_pet_companion_render_card() helper for visual consistency across its cards; Pet Card has its own render function with its own CSS class prefix (.vcpa-pet-profile). They diverged cleanly.

    Decision 3: the adopted-state celebration

    This is the part I’m most fond of. The source plugin removes pets from the shelter’s site when they’re adopted (because they’re no longer adoptable). For Featured Pets that’s fine — the grid just shows whoever’s currently available. But for Pet Card, where a partner site has specifically embedded this one pet in a blog post, it’s a problem: the post breaks. The plugin fetches an empty result and the block disappears or shows an error.

    The fix is small but feels much bigger. When an editor picks a pet in the sidebar, the picker’s “save” handler doesn’t just store the pet’s ID — it also stores the pet’s name, image URL, and alt text as block attributes:

    function pickPet( pet ) {
        setAttrs( {
        petId: pet.id,
        petSlug: pet.slug,
        petName: pet.name,
        petImage: pet.image,
        petAlt: pet.alt
        } );
    }

    Block attributes are persisted in the post content, not in the database separately. They survive forever. So even after the source site forgets about Bagel, the block itself still knows Bagel’s name and what they looked like.

    When the live REST query comes back empty AND there’s a cached name in the attributes, the render falls into a celebration branch instead of the empty branch:

    if ( ! empty( $result['pets'] ) ) {
        // Normal profile render.
        return;
    }
    
    if ( $cached_name !== '' ) {
        // The pet existed at pick time but is gone now.
        // They were almost certainly adopted. Celebrate.
        printf(
            '<h2 class="vcpa-pet-profile__name">%s</h2>',
            esc_html( sprintf( __( '%s found a home!' ), $cached_name ) )
        );
        return;
    }
    

    The block transforms from “Bagel needs a home” into “Bagel found a home!” without anyone touching the post. A blog post written a month ago about a pet who needed adoption becomes, automatically, a blog post about a pet who found one.

    There’s no webhook, no deletion event, no inverse-sync. Just block attributes doing what they’re already designed to do: remembering things.

    Decision 4: no build step

    I made this call partly out of laziness and partly out of conviction.

    Laziness first: a build step means package.json, node_modules, @wordpress/scripts, a webpack config, watching for changes during development, remembering to commit the built artifacts (or not, and adding a CI step), making sure the build environment matches between dev and prod. For two blocks with five UI controls each, that’s a lot of yak-shaving.

    Conviction second: I wanted partner sites — small nonprofits, vet clinics, businesses — to be able to read the entire plugin without needing to understand a build pipeline. If someone wants to fix a typo in a label or adjust a spacing value, they should be able to open the file in any editor and have the change take effect on save.

    So edit.js is hand-written using wp.* globals:

    ( function ( wp ) {
        var el = wp.element.createElement;
        var registerBlockType = wp.blocks.registerBlockType;
        var InspectorControls = wp.blockEditor.InspectorControls;
        // ...
        registerBlockType( 'vcpa-pet-companion/featured-pets', {
            edit: function ( props ) {
                return el( 'div', useBlockProps(),
                    el( InspectorControls, {}, /* ... */ ),
                    el( ServerSideRender, {
                        block: 'vcpa-pet-companion/featured-pets',
                        attributes: props.attributes
                    } )
                );
            },
            save: function () { return null; }
        } );
    } )( window.wp );
    

    No JSX. No imports. Verbose wp.element.createElement calls instead of JSX angle brackets. It is genuinely uglier than the JSX version. But it runs straight from a .js file with no transformation, and that property is worth more to me than the prettier syntax.

    The gotcha that took an hour to find

    There is one nasty thing about the no-build-step approach that I want to warn other developers about, because it isn’t well-documented anywhere.

    When you point editorScript at file:./edit.js in block.json, WordPress looks for a sibling file called edit.asset.php to find out what JavaScript dependencies the script needs. That asset file is normally generated by @wordpress/scripts at build time and contains something like:

    <?php return [
        'dependencies' => [ 'wp-blocks', 'wp-element', /* ... */ ],
        'version'      => '0.2.0',
    ];
    

    If you don’t have a build step, you don’t have an asset file. WordPress reacts to this by enqueuing your script with zero dependencies. Your script will run, but window.wp is not guaranteed to be defined when it does — the load order is essentially undefined. On some sites it works because another plugin has already loaded wp-blocks; on other sites it throws Cannot read properties of undefined (reading 'element') and your blocks never register.

    The fix is to hand-write edit.asset.php next to your edit.js:

    <?php return [
        'dependencies' => [
            'wp-blocks',
            'wp-element',
            'wp-block-editor',
            'wp-components',
            'wp-server-side-render',
            'wp-i18n',
        ],
        'version' => '0.2.2',
    ];
    

    That’s it. Two-line PHP file per block, declaring the wp-* packages your script touches. WordPress reads it, enqueues those scripts as dependencies, and load order works correctly.

    I shipped 0.2.0 without these files because it worked locally. The first production install reported “blocks aren’t showing up” within an hour of the push. 0.2.1 added the asset files. If you’re going down the no-build-step road, don’t repeat my mistake.

    Decision 5: two-layer caching with split negative TTLs

    The source plugin syncs with Petstablished — the shelter’s adoption software — once a day. So the upstream data only changes once every 24 hours, no matter how often the companion plugin asks. That means the companion’s cache TTL can be generous: I set it to 6 hours by default, which gives a same-day window for adopted-pet celebrations to appear without making partner sites pay for unnecessary refetches.

    The more interesting part is the negative cache. When a fetch fails, we store a sentinel [ '__error' => $message ] value in the same transient slot, so the next request fails fast instead of paying another timeout. Without negative caching, if vcpahumane.org goes down, every visitor on every partner site pays a full 8-second timeout until the source recovers. With it, only one visitor per minute does.

    But the first version of the negative cache had a bug. It treated all failures the same way and cached them for 60 seconds. That’s appropriate for real failures (HTTP 500, malformed JSON) — those mean the source is genuinely broken and we should back off. But it’s wrong for network-layer failures, especially in the editor.

    Here’s the symptom: change a Featured Pets block’s count slider in the editor. The new value triggers a new REST request. That request hits a transient loopback timeout (Local by Flywheel’s PHP-FPM worker pool sometimes wedges on self-referential REST calls). The negative cache stores the error for 60 seconds. You change the slider again — now everything fails for a full minute, even though the source is fine, because the editor is hitting the poisoned cache slot.

    The fix is to split the negative TTL by error category:

    // Soft failure: cURL timeout, DNS hiccup, connection refused.
    // These usually resolve in seconds. Cache briefly.
    private const NEGATIVE_TTL_SOFT = 5;
    
    // Hard failure: HTTP 4xx/5xx, malformed JSON.
    // Source is genuinely broken. Cache for a minute.
    private const NEGATIVE_TTL_HARD = 60;
    

    Five seconds is long enough to absorb a hot-loop retry storm, short enough that the next user interaction succeeds. Sixty seconds for real failures still protects against an outage.

    Block style variants vs. core block supports

    WordPress 6.x gives you two complementary mechanisms for letting editors customize blocks: block supports (color, spacing, border, typography) and block styles (named visual variants registered via register_block_style()). I use both, but for different things.

    Featured Pets gets three named styles — Card, Minimal, Overlay — because those are structural differences. Card has a border and a white background; Minimal removes both; Overlay positions the title over the image with a gradient. These aren’t sliders; they’re discrete looks that an editor picks from a thumbnail strip.

    Both blocks also use core supports for color, spacing, and border so that partner sites can theme the output to match their brand. A vet clinic with a teal theme can give the cards teal borders and a teal CTA without writing any CSS.

    Pet Card doesn’t get block style variants. The reason is that “Minimal” or “Overlay” don’t really make sense for a profile — what would they be? Instead it gets a layout toggle (image left vs. image top) and a “show full story” toggle. Both are real choices an editor might want to make; neither needs to live in a styles picker.

    The general principle: block styles for genuinely discrete visual variants, sidebar controls for everything else. If you find yourself making block styles that differ only in one CSS property, those should probably be a sidebar toggle instead.

    What’s still open

    Things I deliberately didn’t build, in case anyone wants to send a PR:

    1. Taxonomy filter is OR, not AND. The wp/v2 REST API accepts comma-separated term IDs as OR filters. To do AND (“good with kids AND good with cats”) would require either a custom REST endpoint with tax_query.relation = AND or client-side filtering after a wider fetch. Not impossible; not done.
    2. No pagination on Featured Pets. The block is meant to be small (1–12 pets), so pagination feels overkill. But if someone wanted to use Featured Pets as a “browse all” surface despite my earlier reasoning, they’d need it.
    3. No gallery support. Petstablished returns multiple images per pet, but the source plugin currently only stores the first as a featured image. To surface a gallery in Pet Card, the source plugin would need to also cache the additional URLs. That’s a cross-plugin change I haven’t tackled.
    4. No editor preview JS-side fetching. Both blocks render their editor preview via ServerSideRender, which means every attribute change triggers a server roundtrip that itself makes a remote HTTP call. On slow hosts this feels sluggish. The fix is to fetch from the editor directly (using apiFetch against the source’s REST API with CORS) and render locally — but that’s significantly more code, and the current approach is good enough for the data sizes involved.
    5. No tests. The plugin is small enough that the cost of a test harness exceeds the cost of breaking changes I’d catch. If it grows, that calculus flips.

    If any of these sound interesting, the repo welcomes issues and pull requests at any size — even typo fixes and CSS tweaks. It’s a small project for a small shelter, and I’d rather have a plugin that improves in small steps than one that’s untouched because the bar to contribute feels high.

    Why this exists at all

    The companion plugin is the second smaller plugin I’ve built around the same core piece of work — a Petstablished sync plugin that lives on vcpahumane.org and handles the actual data ingestion, custom post type registration, faceted browsing, and adoption flow integration. That one is the more architecturally interesting of the two, and I’ll write about it in a future post.

    But the companion plugin is the part that spreads. The sync plugin serves one site; the companion plugin can serve any number of partner sites, each one becoming another place where adoptable pets show up when someone is looking. That’s the part that scales. And the part that scales is the part most worth doing carefully.

  • Help Venango County’s adoptable pets find homes from your own website


    The Venango County Humane Society is a small no-kill shelter in northwestern Pennsylvania with room for fewer than sixty animals at a time. Most of the dogs and cats who come through find homes with families nearby, but every so often someone drives a long way to meet a pet they first saw online — which is the whole reason this post exists.

    Every adoption starts the same way: someone, somewhere, sees a pet and stops scrolling. The more places those pets show up, the more chances they have to be seen.

    So I built a small WordPress plugin that lets any WordPress site display adoptable pets from VCHS. If you run a website — a local business, another nonprofit, a vet clinic, a personal blog — you can drop a block onto a page and visitors will see real pets currently looking for homes. Click any of them and they’ll be sent straight to the shelter’s adoption page to start the process.

    Here’s what it looks like in action. These pets are live right now:

    That’s not a screenshot. Those are actual animals at the shelter, fetched from vcpahumane.org as you loaded this page. When one of them finds a home, they’ll quietly disappear from the grid and someone new will take their place.

    What this is

    The plugin is called VCPA Humane Pet Companion and it ships two Gutenberg blocks that you can drop into any post or page. The shelter handles all the data — keeping pet listings up to date, syncing with their adoption software, writing the descriptions — and your site just displays whatever’s currently available. There’s nothing for you to maintain.

    Both blocks pull live data, so you always show animals who are still looking. No stale listings, no manual updating, no risk of featuring a pet who’s already been adopted.

    Block 1: Featured Pets

    This is the block above. It’s meant for sidebars, homepages, footer sections — anywhere you want a small rotating selection of adoptable pets to share space with the rest of your site. You can choose how many pets to show, how many columns, and whether to randomize the order. You can also filter by animal type (dogs, cats) or by traits like “good with kids” or “special needs,” so a
    cat-focused business can show only cats and a senior-pet advocate can highlight the older animals who are easiest to overlook.

    It’s the browse surface. Visitors who like what they see click through to vcpahumane.org and continue from there.

    Block 2: Pet Card

    The second block is for when you want to feature one specific pet — maybe you’re writing a blog post about a fundraiser, or a particular animal has captured your attention, or you want to give a long-stay resident a moment of extra visibility. The Pet Card block embeds a single pet as a profile, with the shelter’s full writeup and a clear button to start the adoption process.

    Joker

    Joker

    Meet Joker! This sweet boy is looking for a forever home to call his own. Joker has FeLV, or Feline Leukemia, and would need to be the only cat in the home or in a home with other FeLV+ cats. This is not contagious to humans or other species of animals. This does, however, mean his health would need to be monitored closely in cooperation with your veterinarian in order to keep on top of anything that may arise. He is very friendly, playful, and would be a wonderful companion for a family looking to open their home to a new member. If you think Joker is the right fit for your family, apply today! DOB: 8/9/2019

    Start the adoption process

    Here’s the part I’m most fond of: when a featured pet finds a home and is removed from the shelter’s listings, the block doesn’t break. It quietly transforms into a celebration card — “[Name] found a home!” — using the pet’s name and photo, which the block remembers from when you first picked them. You don’t have to update anything. A blog post you wrote a month ago about a pet who needed a home becomes, automatically, a blog post about a
    pet who found one.

    That feels like the right way to handle adoption: not as data getting deleted, but as good news.

    How to install

    Three steps, no code:

    1. Download the plugin from github.com/jwincek/vcpahumane-pet-companion (use Code → Download ZIP).
    2. Upload it to your WordPress site through Plugins → Add New → Upload Plugin.
    3. Activate it. That’s it. The blocks will appear in the editor under
      “VCPA Featured Pets” and “VCPA Pet Card.”

    There’s nothing to configure. The plugin is preset to talk to vcpahumane.org right out of the box.

    What it costs

    Nothing. No fees, no API keys, no signup, no tracking. The plugin is GPL licensed and the source is public. The shelter is a small nonprofit and this is a small contribution to making their pets more findable.

    If you have a website and even a corner of it that could host a featured pets block, please consider it. Every additional place a pet shows up is another chance for someone to stop scrolling.


    For developers: the plugin is intentionally small and built without a
    build step. If you’d like to read about how it’s put together — including the design decisions, the gotchas, and the things still left to do — I wrote a longer post about it: Building the pet companion plugin →.

    If you’d like to help, the code is on GitHub and contributions of any
    size are welcome.


    Photos and pet data courtesy of the Venango County Humane Society.