Skip to content

confirmed bundle Type, reactivation endpoints, and big spreadsheet audit - #34

Open
AbeerDas wants to merge 8 commits into
mainfrom
labels-type-carrier
Open

confirmed bundle Type, reactivation endpoints, and big spreadsheet audit#34
AbeerDas wants to merge 8 commits into
mainfrom
labels-type-carrier

Conversation

@AbeerDas

Copy link
Copy Markdown
Contributor

Summary

Three related pieces off the back of reading the office's real RouteLabelsFileBMN.xlsx and Kristen's Slack answer on the Type column.

Ticket

None — follow-on from #30/#32 plus a design answer in Slack.

What changed

1. Bundle Type is confirmed, not guessed. Kristen (Slack, 2026-08-23): Carrier = a normal volunteer route, Commercial = a bulk drop at a business, Residential = a bulk drop at an apartment/condo. The spreadsheet independently backs it — LABELS column I holds exactly these category keywords.

  • LabelRoute carries a real type field
  • The page renders it instead of the placeholder
  • The filter pills actually filter now (they were deliberately inert)
  • Every row is carrier today, because route_deliveries only sources from volunteer_routes — so Commercial/Residential correctly match nothing until bulk drops get a per-issue delivery record
  • Locked in design_decisions.md

2. Reactivation endpoints. POST /api/{volunteers,captains}/{id}/reactivate clears retired_at, closing the gap where an accidental retirement needed a database edit to undo.

Deliberately does not re-attach routes or reclaim a territory — retirement released them and someone else may hold them now, so a reactivated person comes back unassigned and gets reassigned by hand. Integration tests cover both roles including the 409 when never retired.

3. docs/reference/route_labels_spreadsheet.md — a full audit of the workbook they actually run distribution from.

Why

The Type column and the RT number were both blocking [OPEN] items. The Slack answer settled one; reading the spreadsheet settled or advanced several more.

⚠️ The spreadsheet itself is deliberately NOT committed

It holds 322 names, 310 home addresses, 157 phone numbers, 384 email addresses and seven years of captain payment history — and this repo is public. The doc is structure, counts and patterns only, no records. It documents how to decrypt a copy (password BMN) so a future session can open one without it living in git.

What the audit found — flagged, not acted on

Finding Impact
33% of "people" aren't first+last names — 80 organizations, 41 shared households, 35 single words Our required first_name/last_name can't hold a third of their roster
Phone filled on 19%, email on 43% — both required by createVolunteer Most real rows fail validation on import
Car allowance not modelled. Route91: 36 drops × $1.50 + $15 gas = $69 We compute the $54 only — affected captains are underpaid by our figure
SKIP HOUSES — per-territory do-not-deliver list No home in our schema; will make automated house counts (#23) overcount
PER has a 4th value, N/A Our pay_type enum has three
74% of route endpoints are intersections, and 311 of 1,181 segments are single buildings Entry UX assumes a street address
Substitutes used once in seven years Not a reason to remove it (client asked), but a reason not to invest more
Lucky Volunteer populated on 38 rows We dropped the field as post-MVP

Open items this settles

  • RT number is per territory, not per route — three independent sheets agree. Same missing field as "Territories have no name or number."
  • Bundle N of M is correct — 150+ real values match, on carrier rows only. Bulk-drop rows carry handling instructions instead ("Tie Loosely", "Open and leave in lobby") — that's the deferred VolunteerInstruction.
  • Territory drops do have a dateNew/Stop, an added/removed date, not a per-issue delivery date.
  • Label stock — they print on real peel-and-stick stock, two sheets at a time. This makes the cut guides added in Add bundle label printing with PDF export #30 likely wrong (they'd print rules across pre-die-cut labels). Flagged in the flow doc rather than reverted, pending the SKU.

Also removed five stale open_items.md entries that were already resolved in code (Google Maps go-live, cron scheduling, Places Autocomplete, Add Member, transfer atomicity).

Test plan

  • members.integration.test.ts16/16 pass, including two new reactivation tests (retire → reactivate round trip, and the 409 path) verified against the real database
  • Confirmed against live data that listLabels() returns type: "carrier" on real rows
  • 80 unit tests pass; typecheck, lint, format clean

Reviewer should check: the Labels page now shows Carrier and the pills filter. I could not verify in a browser — the app is behind the login gate.

Screenshots

None — UI change unverified visually, see above.

Checklist

  • Tests pass
  • Typecheck / lint clean
  • Coverage holds (net new tests)
  • No secrets or unscoped UI messaging added
  • No PII committed

Three related pieces of work off the back of reading the office's real
RouteLabelsFileBMN.xlsx and Kristen's answer on the Type column.

Label type is confirmed, not guessed. Kristen (Slack, 2026-08-23):
Carrier = a normal volunteer route, Commercial = a bulk drop at a
business, Residential = a bulk drop at an apartment or condo. The
spreadsheet backs it — LABELS column I holds exactly these category
keywords. LabelRoute now carries a real `type`, the page renders it
instead of a placeholder, and the filter pills actually filter. Every
row is "carrier" today because route_deliveries only ever sources from
volunteer_routes, so Commercial and Residential correctly match nothing
until bulk drops get their own per-issue delivery record.

Reactivation now exists. POST /api/{volunteers,captains}/{id}/reactivate
clears retired_at, closing the gap where an accidental retirement needed
a database edit to undo. It deliberately does not re-attach routes or
reclaim a territory: retirement released them and someone else may hold
them now, so a reactivated person comes back unassigned. Covered by
integration tests for both roles, including the 409 when they were never
retired.

docs/reference/route_labels_spreadsheet.md records what their workbook
actually contains. The file itself is NOT committed and must not be —
322 names, 310 home addresses, 157 phones, 384 emails and seven years of
payment history, against a public repo. The doc is structure and counts
only, plus how to decrypt a copy (password BMN) if a future session
needs one.

What it turned up, none of it acted on here:

- A third of their 468 "people" are not first+last names — 80
  organizations, 41 shared households, 35 single words. Our required
  first_name/last_name cannot hold them.
- Phone is filled on 19% of rows and email on 43%, but createVolunteer
  requires both. Most of their roster would fail validation on import.
- Captain pay includes a car allowance we do not model. Route91 spells
  it out: 36 drops x $1.50 + $15 gas = $69. We compute the $54 only.
- SKIP HOUSES is a real per-territory do-not-deliver list with nowhere
  to live, and it will make automated house counts (#23) overcount.
- Substitutes have been used once in seven years.

It also settles several open items: the RT number is per territory (three
sheets agree), Bundle N of M is correct, territory drops do have a date
(added/removed, not per-issue), and the label stock question — they print
on real peel-and-stick stock two sheets at a time, which makes the cut
guides added in #30 likely wrong. Flagged in the flow doc rather than
reverted, pending the SKU.
Second pass over the workbook, plus the two sections asked for.

New in the analysis:

- The Word merge reads only `LABELS!$A$1:$E$221`. Five columns reach the
  label, so the category keyword has never printed on one — it is a
  working column. And only rows 2-221 of 468 are in range, with the same
  mix of territories and categories above and below the line. Either the
  bottom half genuinely needs no labels or the range is stale and some
  are missed each issue; added as a question rather than an assumption.
- `Sheet1` is a captain's pickup worksheet with a literal
  Labeled/Unlabeled column per bundle. That is our `bundle_labels` model
  in spreadsheet form and independently confirms the per-bundle decision.
- `Payments2026` totals $915.80 per issue across 24 captains, so roughly
  $20k a year — a sanity check for the overview's cost figures.
- `Route24` shows per-paper pay in the open (1,200 x $0.18 = $216) and
  carries "DISCONTINUED 8.22.2023 AS PER SUSAN": they retire a route with
  a reason and a date, we soft-delete with neither.
- `QUITS` has drifted structurally — columns hold different kinds of
  value in different rows, only 117 of 431 have a parseable date, and one
  reason is "Deceased", which is not the same event as quitting.

Two new sections:

§5 lists the 17 things the office demonstrably does today that our
software has no way to represent, each pointing at the evidence. Some
were deliberate scope cuts; the list exists so those stay decisions
rather than oversights.

§6 replaces the old six-line priority list with the actual action items:
ten questions to put to the office and five decisions for the team, in
non-technical language, plus what is already answered and needs nothing.
@AbeerDas
AbeerDas marked this pull request as ready for review August 24, 2026 03:00
@AbeerDas AbeerDas changed the title feat(labels): confirmed bundle Type, reactivation endpoints, and the office spreadsheet audit confirmed bundle Type, reactivation endpoints, and big spreadsheet audit Aug 24, 2026
Add docs/reference/label_template_docx.md, covering MasterLabelsMELINDA.docx —
the Word mail-merge template our PDF exporter reimplements. Every constant in
lib/pdf/label-geometry.ts was re-derived from the file and verified; the
21-per-sheet grid is now exact rather than inferred (the document holds exactly
20 NEXT fields). The docx itself stays out of the repo: its properties carry
staff names and an internal network path.

Re-read the workbook against the tidied copy the office sent. Two structural
findings: LABELS is two passes through the territories, and the merge range
covering only the first is deliberate — Sheet1 shows every unlabeled bundle is
exactly 25 or 50 papers and every labeled one is a remainder quantity. That
answers the "half the rows never print" question. Also corrects the earlier
reading of substitutes: 'away' is written into the per-issue payment cells and
appears six times in 2026, not once in seven years.

Record the decisions taken against the gap list, including the two that go
against what the file shows (no car allowance, RT on the captain) so they stay
decisions rather than oversights. Resolve the territory-on-label open item — the
template's first merge field is the RT number all along.
…emplate

The office has a phone for 19% of their roster and an email for 42%, so
requiring both blocked four in five of their real volunteers from being entered
at all. Confirmed 2026-08-24: optional for everyone, not just on import.

Drop NOT NULL from email and phone on volunteers and captains. The request
schemas normalise undefined, null, "" and whitespace to null so "not on file" is
one state rather than three, while still rejecting a malformed address when one
is actually given. The side panel drops its three required-field guards, seeds
form state with ?? "", and reads back "Not on file".

Two things this surfaced. The member search filters interpolated the email into
a template string, so a null would have rendered as the literal "null" and
matched every contactless person on that query. And the edit form's dirty check
compared "" against null, marking the field changed on every save.

Also set the label's copy count to 24pt, the size the Word template uses; it had
been sharing the RT chip's 28pt. The two now take separate baselines from a
shared helper, since Word centres each cell independently rather than aligning
them. Cut guides are deliberately left in place.
…base

The hosted database's migration history has 20260728000000, applied from the
house-count branch, but the file never landed on main — so every `db push`
failed on a remote version with no local counterpart, for everyone.

Restore the file verbatim from 8e2bbdf so the repo reflects what the database
actually has. Nothing is applied by this: the remote already records the
version, so push skips it. Chosen over `migration repair --status reverted`,
which would have cleared the bookkeeping while leaving the table in place and
set the house-count branch up to fail later on a duplicate CREATE TABLE.

This also unblocked 20260805000000_payout_comment_settled, a column-comment
migration that had been sitting unapplied on the remote.
…+ last

A third of the office's roster is not one person: 122 organizations, 40 rows
naming several people who share a delivery, 9 with no surname at all. Our
required first_name + last_name mangled every one of them, and a household
sharing a route is one delivery rather than two volunteers.

display_name becomes authoritative — it is what prints on a label, what the
lists show, and what search matches. first_name and last_name stay, because
surname sort and structured search are worth keeping for the people who have
them; they just stop being mandatory. The backfill is exact, since both columns
were NOT NULL and validation required a non-empty trimmed string.

On create, a caller giving first + last gets the display name composed for them,
so the common case stays a two-field write; a caller naming a church sends
display_name alone. The create form offers Person or Organization/household and
renders two fields or one to match, which is the whole feature as far as the
office is concerned.

Ordering moves from last_name to display_name, since last_name is now nullable
and would have sorted every organization into a clump at the end.
Two things the office asked for on 2026-08-24.

The RT number is a designation carried by the captain, so it becomes
captains.rt_number and the label chip stops printing the RTXX stub. Text rather
than an integer: the office prints the leading zero of '01', and one captain's
designation is the span '31-71'. Unique where set, so a chip is never ambiguous,
and nullable, so a captain can exist before being given one — those still print
RTXX, which keeps the gap visible on paper instead of inventing a number.

Reprinting: listLabels takes an optional issue id and returns the recent issues
alongside the sheet, so the picker costs no second round trip. The screen still
defaults to the open issue. Picking a closed one puts it in reprint mode, where
Export with nothing selected takes every bundle rather than only the unlabelled
ones — a shipped run is normally fully labelled, so the usual default would have
found nothing and refused.

The number of issues offered is capped at twelve. Reprints are for "the printer
jammed on the last run", not for trawling years of history.
@vercel

vercel Bot commented Aug 24, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
beach-metro Ready Ready Preview Aug 24, 2026 11:55pm

Self-review of this branch turned up one miss and several rough edges.

The display_name sweep skipped lib/services/members.ts, which composes names
from the already-mapped camelCase fields rather than the snake_case row, so it
fell between the two passes. The members table — the main people screen — was
rendering the literal "null null" for every church, building and household,
which is precisely the record type the display_name work exists to support. The
volunteer and captain search filters had the same shape: they interpolated the
now-nullable halves, so an organization could not be found by name and a search
for "null" returned all of them.

Reprinting no longer rewrites history. Export marked every bundle labelled by
default, and reprint mode selects all of them, so reprinting a shipped issue
would silently mark bundles that had deliberately gone out unlabelled. The guard
is server-side rather than a markLabelled: false from the caller, so no client
can get it wrong.

Reprinting is also now reachable when nothing is open. The screen resolved the
open issue before rendering, so with none it failed outright and the picker
never appeared — impossible in exactly the situation that most calls for a
reprint, the stretch after the last run of the year is closed. It falls back to
the most recent issue and only refuses when there are no issues at all.

Two smaller ones. Renaming a person through first/last now carries display_name
along, so the label stops printing the old surname; the recompose is guarded to
never fire on a record that is not a person, since a stray first_name write must
not clobber "St. Aidan's Church". And a whitespace-only email now reads as "not
on file" like a whitespace phone already did, instead of failing as an invalid
address.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant