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.

★ 0stars

Install

Download source zip No release zip yet

This is the repository archive. It installs, but the folder name will carry the branch suffix and updates will not flow.

Install straight from GitHub with WP-CLI:

wp plugin install https://github.com/groundwork-common/groundwork-common-volunteer-tracker/archive/refs/heads/main.zip
No built-in updater. New versions won't appear under Dashboard → Updates. Install new releases yourself, or let Git Updater track the repository.

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 — somebody four hours short with six unverified is a ten-second problem, and only visible if the two numbers stay apart.
  • A deadline is reported, never predicted. Overdue means the date has passed and the hours are not there. There is no "on track" indicator, because that needs a rate this plugin has no business inventing, and being told you are behind by software that guessed is worse than not being told.
  • Anonymizing removes the requirement along with the name. The hours survive because they are the organization's own service record and identify nobody. "120 hours required by 15 November for Franklin County Municipal Court" says a real person was under a real order, names it and dates it — and it does not stop being a disclosure because the name above it has gone.
  • Changing a whole repeat is a screen, not a checkbox. The data model always allowed it — GWC_VT_SHIFT_SERIES links every occurrence to the first, so it is a loop over sibling IDs — but the obvious spelling is an "apply to the series" checkbox on the shift editor, and that is refused twice over. gwc_vt_handle_save_shift() is what every other screen posts to, and a mode that multiplies its blast radius by twenty makes it a handler nobody can reason about; and a silent correction on save is a bug even when the correction is right, which has no larger version than twenty rosters changed by one click that did not say how many. So it is its own page with its own nonce and handler, and it states what it will touch first. Future-only by default; called-off occurrences excluded by default, because a cancellation is an answer the organization already gave somebody and rewriting its time un-answers it while the notice stays sent; the notify decision made once for the batch with the number of people it would reach on the screen; and the date is not a field it can change, because moving every occurrence by a day is a different repeat. gwc_vt_repeat_targets() produces the numbers the screen states and the rows the handler writes, so the two cannot disagree.
  • A chip that is on looks on, in one colour, and says so instead of a notice saying it. Four things about the filter row, each of which came from looking at the screen. The on state was a tint — #fcf0f1 beside #fff — white against white at a glance, so the only thing announcing a filtered list was a small × at the end of one chip. And the Events chip had no palette at all, because the class went in without a rule behind it, so it was the one chip where nothing but the × changed. Both are filled now. In the same colour, which is the second decision: they were briefly filled with each state's own colour — red for short, green for full — on the argument that short is the same red everywhere here. That held while only one chip could be on, and Events made two possible at once, at which point two chips that are both simply on in two different colours read as two different kinds of on. Colour on a control says whether it is chosen; colour on a row says what the row is. The state's colour still means what it always meant everywhere it describes data — the rule down a row, the badges, the calendar chips, the legend. And the notice above the table went, because the chip had made it a second copy of itself: filled, counting what it left, clearing itself with its own ×, beside a sentence saying the list was narrowed and offering a link back to the same place. What stayed is the test for whether a message belongs — slots mode and a shortened window arrive from a dashboard link and have no control on the screen at all, so they keep their sentence. gwc_vt_triage_queue_notice() has always done the same thing from the other end: it suppresses itself on the screen that already is the queue it would nudge you toward. Month, from a narrowed list, opens on the month that list starts in — four events in a year with the nearest in December, and a calendar opening on today answers with an empty grid. Only while narrowed: an unnarrowed "Coming up" starts today, which is the month somebody pressing Month means. And the way back names its month: "Back to this month" sat a few pixels from the name of the month being looked at, so "this" had two candidates and the wrong one was nearer.
  • "Events" is a filter, not a view, because the bar it sat on is about time. The schedule's view links read Coming up · Already happened · Events, which looks like three peers and is two axes: the first two are when, the third was what kind. Choosing it silently answered a question the other two had not asked — and it could not express "events that already happened", which is a thing somebody wants. It is a chip now, beside the states, carrying its own count and composing with both halves of the bar and with the calendar. The ?view=events screen it replaced is deleted outright: a second renderer with its own table, columns, empty state and a fixed −120/+400 day window, shadowing what the ordinary list had already done since gwc_vt_only=events became a real narrowing. Nothing is lost — the flat list narrowed to events shows the same rows with the same badge, roster link and fill figure. Old addresses keep working by translation rather than redirect: gwc_vt_schedule_only() reads view=events as the narrowing, so a bookmark, a link in somebody's email and the two handlers that redirect after deleting an event all still land on what they meant.
  • Every sub-view of the schedule behaves the same way, and two of them used to lose messages. The schedule is one registered page with eight views behind it — the list, the month, the events list, the shift editor, the event editor, a roster, and the two screens that stop to ask before calling a time off or dropping a role — and they grew one at a time until they disagreed about the things a screen does rather than shows. The costly half was silent: gwc_vt_event_redirect() and gwc_vt_event_roster_redirect() land on the event editor and the roster with the result of every save, cancel, copy, roster addition and refusal in the URL, and neither screen printed it — so saving an event said nothing at all, and "they are not on this shift" looked like a button that does nothing. Both print it now, and the rule is that the two notice functions travel together: a result in the URL that nothing draws is a message nobody gets. The rest is chrome that is not only chrome — one way back in one shape (gwc_vt_render_schedule_back(), a link because it navigates and decides nothing, where the event editor and roster had none at all and two screens used a button), one views bar function for all three lists (the events view offered "Coming up" and itself, so arriving there took the past and the calendar off the screen — that view has since gone entirely, see below), and <hr class="wp-header-end"> on every one of them, since that is the element core moves notices to and a screen without one puts its messages somewhere else than the screen beside it. And a URL naming a shift, an event or a time that is gone now says so — it used to fall through to the schedule in silence, which is indistinguishable from a screen that has lost something. tests/integration/event-screens.php asserts each of those across all eight views. The last of the five was the events list having no chips and no find box, where the two lists beside it both did — a site with forty events a year and no way to ask which of them is short of people. It filters through the same gwc_vt_schedule_rows() and gwc_vt_filter_schedule_rows() as the others, which have always handled events: searching a person's name finds the event they are on a time of, because an event has no roster of its own. Two things are specific to it, and both are the kind of thing that reads as working — every chip and the form itself carry view=events, or a chip on this screen answers with the shifts; and the box says Find an event, since "Find a shift" over a list of events is the sort of wrong nobody reports and everybody notices. Its empty state distinguishes "no events yet" from "no event matches that", because saying the first to somebody with a full calendar is telling them something untrue. The navigation row is one function for all three, because that is what stopped them drifting the last two times: on the left gwc_vt_render_schedule_status_links() says which things are listed, on the right gwc_vt_render_schedule_view_tabs() says whether they are drawn as a list or a calendar, and the row ends with core's own clear. The list/calendar toggle is far right on every view — it redraws the whole screen rather than narrowing it, so beside the chips it would read as a third filter, and it floats to the same edge core floats its own screen-level controls to. It used to be missing from the events list entirely, which left the calendar unreachable from there without going back first; on that view it marks neither half, because the events list is neither and marking "List" would leave the flat schedule with no link from here. Both title-action buttons are on all three headers too: a header that gains and loses a button as you move between three views of one screen makes somebody hunt for the button they used last time. And the toggle changes how, never what: pressing Month on the events list used to give a calendar of everything, silently widening the subject while appearing only to change the drawing. gwc_vt_only=events is the narrowing that fixes it, and it is a parameter rather than a property of one screen — the calendar reads it, the flat list reads it (which is where a day clicked out of that calendar lands), the heading and the find box follow it, and every chip, month step and search on both carries it, because a chip that widens the screen it is narrowing is the same bug with an extra click in front of it. The flat list says it has been narrowed and offers the way back, through the same gwc_vt_render_schedule_narrowing() that already covered slots mode and a shortened window — a screen that has quietly stopped showing everything is how somebody concludes there is nothing there. Its empty state stops saying "add a shift" while narrowed to events, which is advice about the thing it is not showing.
  • One way onto the schedule, because the two words are not self-explanatory. The header carried Add a shift and Add an event, which asks somebody to choose between two nouns before anything has told them what either means — and the intuition those nouns invite is the wrong one. It is not one-thing-versus-many: a shift can happen twenty times, through a repeat, and an event happens once. What actually differs is whether the occasion has several roles people pick between. So there is one Add button and a screen that asks in those terms, with the same two buttons on it — the words are kept so a how-to that says select Add a shift still finds one. ?shift=new and ?gwc_vt_event=new still open the editors directly, because the dashboard's own actions link straight at them and somebody who knows which they want should not be asked. tests/integration/event-screens.php asserts one title action per view, that the chooser offers both routes and says what each is for, and that the direct address still skips it.
  • An event can run on a pattern, and every run is a whole event. The repeat lived on the shift, so a monthly meal service was twelve events typed in twelve times — inc/event-cpt.php had always said a container and a series "compose", which was a claim about the schema and not about anything a coordinator could do. Now Run it again on takes a rhythm and a date to stop, and materialises real events: each with its own roles, roster, dates and cancellation, so calling off October leaves November alone. Drafts, and empty of people, for the reason a single copy already was — an event that went live because somebody pressed a button is a public page nobody has read. The cap is this feature's own (GWC_VT_EVENT_REPEAT_MAX, 24) rather than the recurrence module's 200, because each run is a whole grid and a twenty-time festival repeated weekly for a year is four thousand posts from one button; hitting it is reported, with what to do next. It also fixed a silent one: copying an event carried the times and dropped every credential — they are one meta row each, so a loop over gwc_vt_shift_meta_keys() walks past them, and a copied festival quietly stopped asking for the food handler card. Repeating would have done that twelve times at once. tests/integration/event-editing.php drives the real handler and asserts both kinds of credential survive, the event's own and each role's.
  • A scheduled shift and a logged hour are different nouns, and never the same record. A shift is a plan — Saturday nine to twelve, food sorting, we need six. An hour entry is a claim about the past that a staff member has attested to. If a signup could become an hour by sitting on the calendar until the date passed, somebody who never turned up would accrue hours toward a document a probation officer reads, and nobody would find out until they read it. So they are separate post types, and turning a roster into hours is an explicit act by a person who was there.
  • A shift cannot be logged until it has ended. gwc_vt_save_entry() silently clamps a future date to today, so logging Saturday on Friday would date Saturday's hours to Friday — on the document a court reads, with nothing on screen to say it happened. Refused rather than warned about, in the screen and in the handler, because the screen is not the only thing that can post to it.
  • A volunteer can prove who they are, and still has no account. The public forms are anonymous on purpose, and that is what stops them answering "is this named person working off a court order". The cost is that they do not know who they are talking to, so nothing about a particular volunteer can ever be enforced or shown on them. Signing in lifts that, in one place, by asking the person to prove they hold the mailbox — after which telling them something about themselves tells nobody else anything. It creates no WordPress user: no wp_insert_user, no password, no role, no appearance in Users. What it adds is a session that says "the browser holding this cookie controls the address on volunteer 41", lasting twelve hours. Three things carry the design. Asking for a link answers with one sentence whatever the address turns out to be — on file, unknown, or on two records at once — and the mail is queued for shutdown rather than sent inline, because a real SMTP call takes a second and would make the matched address measurably slower than the unmatched one, undoing an identical sentence with a stopwatch. Following the link does not spend it: mail scanners fetch every URL in a message on delivery, so the link renders a button and the POST behind it redeems, which is the plugin's existing "nothing mutates on GET" rule doing exactly the job it was written for. And the session resolves through a server-side lookup, so a record that has been anonymized, erased or swept takes its live sessions with it. This is the first cookie the plugin has ever set.
  • A public form cannot create a volunteer record. inc/self-log.php states the rule and applying to volunteer keeps it: what a stranger sends becomes a gwc_vt_application, held as claims, and a signed-in person turns it into a gwc_vt_volunteer or discards it. Three things follow, and all three are the point — spam reaches a queue somebody empties rather than the volunteer list; there is no enumeration oracle, because the handler never asks whether the address already belongs to a volunteer and so cannot be made to answer whether a named person is working off a court order; and nothing is authoritative until a person makes it so. It is its own post type rather than a pending volunteer, because every volunteer query in the plugin asks for publish or any, and a pending identity record would be invisible to most of them and visible to a few. The form shares the hours form's honeypot, timing stamp and rate limiter rather than restating them, so neither can drift into being the weaker one. Asking whether somebody has court-ordered or school-required hours is a second switch, off by default and gated on the write and not merely the field: a public page asking a stranger that question, on a site that stores the answer, is a decision an organization makes deliberately or not at all.
  • An event's credentials add to its roles', they do not replace them. This is the one place the credential feature departs from inc/admin-event.php's rule that the most specific non-empty value wins and nothing appends — which is right for a location and a supervisor, and wrong here. The real case is a liability waiver for the whole day plus a food handler card on the kitchen role, and under replacement the kitchen would silently stop asking for the waiver: the role most likely to need it, losing it because it asked for something else. The union happens at read time in gwc_vt_required_credential_ids() rather than by copying the event's onto each slot, which is what makes adding a waiver after the grid exists reach every role, and removing it later remove it from all of them.
  • A shift's credentials are one meta row each, and cannot ride the save handler's $fields array. One row per credential so meta_query can ask "which shifts need this one" against an indexed column instead of LIKE-ing through serialized data. The consequence is that they must be written by gwc_vt_set_shift_credentials() and nothing else: everything in $fields goes through gwc_vt_shift_meta_value(), which falls through to (string) $value and turns a list into the literal word Array — in the database, under a screen reporting success. tests/integration/credential-shifts.php drives the real handler and asserts the stored value, and sabotaging it back into $fields produces exactly that string.
  • Changing what a shift asks for is not a move, and mails nobody. gwc_vt_shift_movement_keys() deliberately does not list credentials, for the reason it already omits the activity and the supervisor: a mass mail must never be a side effect of a coordinator tidying up. The integration script asserts silence and carries a control that moves the same shift's time on the same roster and watches mail arrive — without which "no mail was sent" is also what a broken harness looks like.
  • A missing credential is flagged where a double-booking already is, and no more freely. gwc_vt_event_clashes() carries the constraint in its own comment: reporting a thing too freely trains coordinators to ignore the flag that matters. So three cases are deliberately not flagged — a shift that has already happened, because nothing can be done about Saturday on Monday; a signup nobody has matched to a volunteer record, because we have not looked the address up and hard rule 4 says we may not, so the roster says it once above the table instead of on every row; and report-mode credentials are drawn exactly as loudly as blocking ones, because the difference between them is what happens at signup, not how alarming the fact is once somebody is on the roster.
  • The block is not inside gwc_vt_add_signup(), and that is deliberate. It looks like the choke point and is the wrong place. It is not the only way somebody reaches a roster — gwc_vt_settle_signups() and gwc_vt_handle_signup_promote() both write post_status directly — so a check there would announce a guarantee it does not give. It is called by things that are not a person joining a shift: the reconciler, the seed, test fixtures. And it cannot ask for an override, which is a decision a named person makes with a reason, on a screen. So the four handlers where a human puts a person on a shift call gwc_vt_signup_credential_refusal() first, and gwc_vt_add_signup() stays what it is. tests/integration/credential-block.php §2 asserts the low-level writer still writes uncredentialed, so moving the check inward fails the suite.
  • Settling and promoting are never blocked. Both act on somebody the organization already accepted; being promoted off a waiting list is not a new decision by that person, and the thing that triggers it is usually somebody else withdrawing at nine in the evening. A gate there would fail silently on a cron pass, leaving a place empty and a person on a list, with the only record in a log nobody reads. The screen that can say something is the roster, which flags them; the person who can act is the coordinator, who is looking at it. Nothing about a volunteer's standing has ever silently removed them from a shift, and this does not start.
  • A blocking credential can be overridden, and the override is the point. Refusal is the default, but a coordinator standing in front of somebody holding a certificate that has not been entered yet will get them onto that shift somehow — by recording a credential they have not verified, by adding them under another name, or by abandoning the plugin for that Saturday. All three are invisible afterwards. An override that names who decided and why is the only outcome that leaves a record. The reason is required and gwc_vt_record_override() refuses to write one half of the pair, so an override with no explanation cannot exist even if the markup changed; the reader keys off the reason for the same reason. The roster then shows who and why instead of the missing-credential flag.
  • A gated shift asks the public to sign in, and that is not an oracle. It looks like one and is not. "This shift needs a waiver, so sign in first" is a fact about a Saturday — every visitor gets the identical answer for the identical shift, whether or not they have ever volunteered here — and the public list already says it on the row. Nothing about it varies with the person, so it cannot be made to answer whether a named person is on file, which is what hard rules 3 and 4 protect. Once somebody has signed in, naming what they are missing tells them about themselves, which is what building sign-in first bought. credential-block.php §5 posts the same submission twice, once with an address on file and once without, and compares everything the site contributed byte for byte — and does the same on an ungated shift, so the check cannot pass merely because both answers are refusals.
  • Two roster handlers used to report success for an add that did not happen. Both discarded gwc_vt_add_signup()'s return value and redirected with rostered regardless, so a refused add — a shift called off, somebody already on it — rendered "Added to the shift." over an unchanged roster. Fixed in passing here, because the credential refusal made the return value load-bearing for the first time.
  • The dashboard widget is a window onto the tracker's dashboard, not a second copy of it. It reads gwc_vt_dashboard_items() and gwc_vt_shifts_between() and decides nothing for itself, because two definitions of "what needs doing" is how a screen and the number above it start disagreeing. Three things follow from where it loads, and all three go the opposite way to the tracker's own screen. It is gated on gwc_vt_cap( 'verify' ) rather than edit_posts, because a contributor cannot act on most of those lines and the counts concern people on a court-ordered service program, on the page everybody lands on after logging in. Its counts are cached for two minutes, because gwc_vt_dashboard_counts() runs seven counts and two of them walk posts — a cost the tracker's own screen is asked for and this one is not. And it brings a two-kilobyte stylesheet of its own instead of the 64KB admin sheet, because wp-admin's dashboard belongs to every plugin at once.
  • The widget names shifts and never people. Naming the shift is the whole reason it is not the worklist in a smaller box: "2 shifts need people" makes a coordinator click to find out which, where "Sat 29 Aug — 3 of 8" is the answer. Naming a person is the opposite — a name beside "past their deadline" is a court-order disclosure on a screen shared with every other plugin, so tests/integration/dashboard-widget.php asserts no volunteer and no court appears anywhere in the markup.
  • The widget makes no prediction, and its absence is deliberate. There is no "at risk" line. gwc_vt_requirement_progress() states the refusal in its own comment — overdue is a fact, on-track is a guess, and being told you are behind by software that guessed is worse than not being told. A dashboard widget is exactly where that temptation lands, so the test asserts the words a prediction would need are absent.
  • Every screen that shows somebody else's record is gated on edit_others_posts, not edit_posts. The post types use capability_type => 'post', and below the plural that does the right thing — edit_post, delete_post and the ownership rules map as they do for a post. It has never decided who reaches a screen, and every custom screen here queries for itself: the schedule drawer, the applications queue, the verify queue, the REST routes behind them. All were gated on edit_posts, which a contributor has — a role WordPress designed for "may draft a post, may not see anybody else's". That let a contributor read volunteer names off a roster and read a stranger's name, email, phone, photograph and the court that ordered their service off the applications queue. gwc_vt_records_cap() is the one filterable capability name the menu and every screen behind it read, and gwc_vt_can_see_records() derives from it rather than filtering separately — two mechanisms would be two places for a site's override to apply unevenly. Author is excluded deliberately: publishing your own posts is not seeing other people's. `te

This README is longer than the copy stored here. Read the rest on GitHub →