WP Manifestindependent plugin directory
manifest / users / groundwork-common-volunteer-tracker

Groundwork Common Volunteer Tracker

Log volunteer hours, have staff attest to them, and produce a verification letter a court or a school will accept. A free WordPress plugin for nonprofits hosting court-ordered or school-required community service.

by Groundwork Common LLC · github.com/groundwork-common/groundwork-common-volunteer-tracker · website

0stars
0forks

Install

No release zip yet. The repository archive installs, but the folder name will carry the branch suffix and updates will not flow:

wp plugin install https://github.com/groundwork-common/groundwork-common-volunteer-tracker/archive/refs/heads/main.zip

Readme

Groundwork Common Volunteer Tracker

Plan volunteer shifts, log the hours worked, have staff attest to them, and produce a verification letter a court or a school will accept.

Why it works that way

Plenty of nonprofits — food banks, thrift stores, shelters — host people working off court-ordered or school-required community service alongside their regular volunteers. At the end of it that person needs a letter saying how many hours they worked and when, on the organization's letterhead.

Today that is a paper form in a drawer, a Word template somebody edits by hand, or a staff member reconstructing six weeks of Saturdays from memory two days before a court date. The volunteer-management platforms that do this properly are priced for organizations with a volunteer director. The best free WordPress option covers signup and scheduling and stops exactly where this plugin used to start.

For four releases the answer to that was to stay on our own side of the line: hours in, verification letter out, and scheduling is somebody else's product. What changed the argument is that the line runs through the middle of one job. A coordinator running both types Saturday twice — once into a signup tool and again into a sign-in sheet — and the evidence was already in this codebase, because "Log a day" is a roster screen with no roster to start from.

So the job is the whole loop, and the part still nobody else has built is the second half of it: sign up, show up, hours recorded, hours verified, letter out. Scheduling here exists to feed the hours; it is not a scheduling product with hours bolted on, and every decision below follows from that ordering.

The constraint that shapes everything else is what the plugin is allowed to claim. Its output is a document somebody hands to a probation officer. A seal, the word "certified", a rendered signature, language borrowed from an affidavit — each is a few minutes of work, each makes the letter look more official, and each is the plugin asserting an authority it does not have. Nobody at Groundwork Common watched anybody sweep a warehouse floor.

What the letter does instead is report what the organization recorded, say plainly that the organization is the authoritative record-keeper, itemize the hours with the staff member who attested to each one and when, timestamp it, and give it a reference code that can be checked. Those are facts a reader can verify. A seal is a picture of one.

Requirements

WordPress 6.3, PHP 7.4. No build step and no npm: every file that runs on a user's site is a file in this repository, byte for byte. Composer is present, but only as a linter — it installs no runtime dependency and everything it touches is in .distignore. See Coding standards.

The PHP 7.4 floor is tested, not assumed. PHPUnit 11 needs PHP 8.2, so the unit suite cannot run on 7.4 — which means without a deliberate job the compatibility claim in the plugin header would be verified by nobody. The Tests workflow runs the integration scripts against a real 7.4 site, and parses every shipping file with 7.4's own parser. To check it yourself:

bin/wpenv --floor start

Things that are deliberate

  • Everything on the dashboard is a queue. That is the test a line has to pass to appear: dealing with it makes it go away. It is what separates a worklist from a status board, and it is why there is no panel listing who is working off a requirement — no amount of work makes a list of who is under a court order shorter. What appears instead is the one line that is a queue: somebody has passed their deadline, go and look.
  • The dashboard names nobody. Every line is a count and a link; the names live on the screen the link goes to, which is somewhere a person has gone deliberately. The overdue line is the one it would be tempting to expand, and it is the one that must not be. DashboardTest asserts the exact field set of a worklist line, so adding anywhere to put a name fails the build.
  • The worklist is ordered by what is lost if it waits, not by which number is biggest. Eight shifts to verify is a bigger number than one unlogged shift and a smaller problem: verification keeps, and hours nobody typed up get further from anybody's memory every week. The order is fixed rather than derived, and asserted.
  • A queue at zero does not appear. The same rule as the daily summary. A screen reporting "none waiting" five times over is one people stop reading, and then the line that says Saturday is short gets skimmed with it.
  • Quick actions offer only what the person can reach. Somebody without gwc_vt_issue_letters gets no reference checker rather than a box that will refuse them, and somebody without export_others_personal_data gets no erase link — a link that fails when clicked teaches somebody the screen is broken; an absent one teaches them nothing. This rule arrived with the "Where to next" map, which listed every screen in the plugin. The map went when the menu became six nouns and started carrying the same information; what survived is the half the menu deliberately does not list, which is the verbs.
  • The dashboard is information on the left and action on the right. The fortnight and the year are what is true; the rail beside them — the verbs, what is waiting, and the reference checker — is what somebody might do about it. Reading across answers "how are we", reading down the rail answers "what now".
  • A worklist line lands on a screen showing exactly what it counted. Every one of the five now does. The two that could not were unreconciled and understaffed, and the reason was real rather than an oversight: both count an event's times individually, because the daily digest is built on them and an event's Saturday morning being short of people is what that email exists to mention — while the schedule list collapses an event to one row and reaches 120 days back and 400 forward against the counters' 180 and 7. So the number described slots over a screen drawing events, across a window that did not match. The collapsing became an opt-out (gwc_vt_slots=1) rather than a fact, a gwc_vt_within parameter narrows the window in whichever direction the view faces, and the screen says it has been narrowed and offers a way back. The default view is unchanged, and tests/integration/worklist-links.php asserts count-equals-rows for all five by reading the URL each line builds and rendering the screen it names.
  • Every worklist line names a job, verb first. "Write up a shift that has already happened", not "a shift has happened and its hours are not logged". A description makes the reader translate a state into a task before they can decide anything; the state belongs on the second line, which is there to answer "and if I leave it?".
  • Coming up runs two weeks, split at the end of this week. The two halves answer different questions — this week is still fixable by ringing round, next week is only worth knowing about — and fourteen undifferentiated rows answer neither. Where the week breaks is the site's business, so it comes from start_of_week; a great many places outside Europe and North America answer Saturday or Sunday.
  • The reporting year is a filter, not a setting. Most organizations report on the calendar year. The ones that do not know exactly when theirs begins and have somebody who can add a line to a theme — where a setting would put a question on the Settings screen that almost nobody needs, and answering it wrongly would quietly misstate a figure that goes to a funder.
  • There is no screen for producing a letter, and that is the point. There was one: you went to it, searched for the person you had just been looking at, chose dates, and produced a letter in one sitting. Its first question was who, and that question was the whole problem — the volunteer picker existed to re-answer something that had just been answered, and answering it wrongly (typing a name without selecting it) produced a screen that looked broken. It was kept off the menu for the same reason, because a menu entry would be "an invitation to start from a blank form and go looking for somebody". The box on the volunteer's record cannot ask the question at all. Everything the screen did that was worth keeping moved with it: the letterhead warning to the box, the readiness note to the draft row, its help tabs to the volunteer editor. One thing improved by going — InactiveTest used to assert that exactly one picker asked for inactive volunteers, since an inactive person is precisely who a court asks about months later; it now asserts that none has to, because no picker stands between anybody and a letter.
  • One way to open a thing, and the pattern is the deliverable. A volunteer's record grew three secondary actions and each arrived with its own idea of what pressing it does: Log hours navigated to another screen, Draft a letter unfolded a panel in place, and Record a credential showed fields that were saved by the volunteer's own Update button further down the page — a form that was not a form, whose own comment had to say so, where somebody who filled it in and did not scroll down recorded nothing. All three open a sheet now, and a sheet commits: what you pressed and what happens are the same act, in one place. inc/admin-sheet.php draws the frame, the close control and the trigger; assets/js/admin-sheet.js opens and closes anything matching, knowing nothing about what is inside. Six sheets use it, including the three the letter flow already had as hand-written copies of each other. Two rules a sheet can break silently, and tests/integration/sheets.php checks both across every sheet rather than a favourite one, because a pattern is only a pattern while nothing is exempt: every field must name its form by ID, since a sheet is printed outside wp-admin's <form id="post"> and one that forgets is submitted with the volunteer instead; and no sheet may render with the hidden attribute, because it is hidden by CSS under body.js and that is what lets each sheet be the only copy of its form — with scripting off it is a block at the foot of the record, working, and rendering an inline fallback as well would duplicate every id in it.
  • A letter is started on the person it is about, and what gets saved is the question, not the answer. Producing a letter used to be its own screen: you went to it, searched for the person you had just been looking at, chose dates, and did the whole job in one sitting or lost it. That is the wrong shape for how the job arrives — a court asks in March, the hours are short until April, and the person who sends it is not the person who was asked. So the intention is a record, a gwc_vt_letter_draft child post on the volunteer, and it holds the period and nothing else. Not the hours, not the shift list, not the reference: those are answers, and every screen that shows a draft recomputes them as it draws. A draft that stored 18h30m would be a letter that silently disagreed with the record the day somebody verified another shift.
  • Where a letter is sent is a question, not an assumption. The address on the volunteer's record is the usual answer and not the only one: a probation officer or a school routinely asks to be sent the letter directly. Without somewhere to say so, the coordinator's only route is to print it, save a PDF and attach it to their own mail — which sends the same document with nothing in the log to say where it went. So emailing asks, offers the address on file, and takes a typed one. A typed address that does not parse is refused rather than replaced with the one on file: sanitize_email() strips dana@ down to nothing, which is indistinguishable from "they typed none", and quietly posting a court letter somewhere other than where it was addressed is the mailbox version of the silent-correction rule below.
  • No copy of an issued letter is kept, so "open it again" says what it is. The band across the top of a reopened letter reads not the copy that went out, names the reference that stays on the one the volunteer is holding, and the print button and document title stop saying "draft" — because that rendering is neither a draft nor the letter, and the two wrong answers are opposites.
  • A draft dies with the volunteer; the issued-letter log outlives them. The two records look alike and are opposites. A draft is an intention about a named person — "we are about to tell a court about this one" — so anonymizing or deleting a volunteer takes it, the same as a credential record, and gwc_vt_orphan_letter_draft_ids() sweeps up after the one route that fires none of this plugin's hooks. The log holds figures, a reference and no name, and is the organization's own receipt of its own conduct: nothing removes it, including discarding the draft it came from, because by then somebody else is holding the letter.
  • What somebody was required to do never reaches the letter. Everything else this plugin records is something the organization observed. How many hours a court ordered is a fact about the court's own document — one the organization may have seen as a photograph and may be reading wrong. Printing "120 ordered, 94 completed" would be the organization certifying the terms of an order back to the court that issued it, and if those terms were modified on appeal while the filing-cabinet copy was not, the letter is confidently wrong about the one line its reader checks. RequiredTest asserts no letter file reads the meta key; tests/integration/required.php asserts that raising the requirement from 40 hours to 500 produces a byte-identical letter and the same reference code.
  • Only verified hours count toward a requirement. Those are the only ones the organization stands behind, and the only ones a letter states a total for. Unverified hours are reported beside it rather than folded in — s

Read the full README on GitHub →