b22df13- Each post-mortem's shareable link and its incident record now go to smadpicklebot.com/postmortems#pm-<id>
- The Release Dashboard's outage rows link there too
Picklebot was built to manage my now 60-strong group of picklers playing at the Caltech Athenaeum — I wrote about it on Substack. This Release Dashboard contains a lot of CI/CD and DORA metrics, and has the Commit Log at the end. This page republishes automatically on every push to main.
Co-authored with Claude Code: Sonnet 4.5 (18) → Opus 4.5 (171) → Opus 4.6 (266) → Sonnet 4.6 (88) → Fable 5 (36) → Opus 5 (301) → Claude (44) → Opus 5 (1M context) (15) → Fable 5.1 (149) → Opus 5.5 (1M context) (167) → Opus 5.5 (40) — 1295 of 1384 commits (94%).
Every outage since logging began, with time to restore (TTR). Recorded in ops/incidents.json in the same commit that fixes the break, so the log cannot drift from the repair. Upstream outages are listed but excluded from change failure rate. The newest 3 are shown; the button below the table lists the rest.
| Date | Service | Impact | TTR | Cause | |
|---|---|---|---|---|---|
| 092926 | sync-members (Sync Group Members workflow, smad-whatsapp.py sync-members) | New group members got no Player-sheet row and no welcome DM: ND from 9/22, John Stowell from 9/27, cunningham dan from 9/28; their votes went untracked and they got no reminders post-mortem | 10072 min | self-inflicted | |
| 092726 | GREEN-API token (whatsapp-message-sender, smad-picklebot, smad-whatsapp-webhook) | Every WhatsApp send and GREEN-API call would have failed for 4 minutes; none was attempted in the window, so no player saw it post-mortem | 4 min | operational | |
| 092026 | Daily Reminder Runner (GitHub Actions) | The 8 AM run never started: no Sunday games poll for the week, no Last Call scheduling, no Games This Week report, no vote/payment/survey reminders, no Venmo sync, no court cache refresh and not the every-other-day Gmail watch renewal (9/20 is an even day; the watch renewed on 9/18 ran to 9/25, so nothing was at risk that day) post-mortem | 595 min | self-inflicted | |
| 091126 | smad-picklebot (Cloud Run) | /pb match answered 'No player found matching' for every name on two commands sent after a 26-minute lull; both matches were lost until backfilled by hand (Win Loss Log 20260911-7pm-9 and -10) | 46 min | self-inflicted | |
| 090426 | Daily Reminder Runner (GitHub Actions) | The 8 AM run never started: no game day reminder, no vote/payment/survey reminders, no Venmo sync and no court cache refresh that morning (a Thursday; not a poll day) | 118 min | self-inflicted | |
| 090126 | greenapi instance (all WhatsApp in and out) | Every /pb command and every outgoing WhatsApp message stopped. Gene found it when he tried to record a match at tonight's game. | 364 min | upstream | |
| 083026 | smad-picklebot | /pb shyam from a DM returned HTTP 500 and replied nothing. The command was unusable for its only two intended DM callers, Shyam and the admins | not measured | self-inflicted | |
| 083026 | venmo-sync-trigger | One Venmo/Zelle sync run failed outright — no payment was pending, so nothing was lost | 9 min | upstream | |
| 082726 | court-booking | Every court WhatsApp notification with no explicit reply_chat_id was dropped without trace - nightly booking results, cancellation results, reservation listings. Email notifications were unaffected, which is why nobody noticed | 3994 min | self-inflicted | |
| 082726 | whatsapp-message-sender | WhatsApp message delivery stopped — the function crash-looped at import | 11 min | self-inflicted | |
| 082526 | GREEN-API instance | Bot unreachable — WhatsApp session dropped to notAuthorized | 10 min | operational | |
| 082426 | whatsapp-message-sender | WhatsApp message delivery stopped — every Firestore call failed | 15 min | self-inflicted | |
| 082126 | cancel-game workflow (smad-whatsapp.py cancel-game) | Every player who had voted yes for a game that was then cancelled got no DM and no email: 8/22, 8/28, 8/30 and 9/8 cancellations. The summary sent to the requester said 'nobody voted yes' | 26012 min | self-inflicted |
10 older outages folded
092926 · sync-members (Sync Group Members workflow, smad-whatsapp.py sync-members)
Post-mortem read the full post-mortem
When 09/22/26 12:20:40 → 09/29/26 12:13:06 PT
How it was timed measured from real timestamps
Cause deploy
Detected by Gene, on 9/29: new players told him they had no welcome DM. Every failing run exited 0 (success), so the workflow watchdog never alerted: a monitoring gap, closed by a9873cf (a refused member now fails the run)
Introduced by Join Date, Referrer and Join Source on the Player sheet, backfilled from history and stamped by sync-members
9428645 (9/9 21:24 PT) widened sync-members' new-member row to every static column (Join Date, Referrer, Join Source added after L) but left its write range ending at W/L; Sheets refuses a row wider than its range ('tried writing to column [W]'), so every insert failed. The defect was live from 9/9; nobody joined until 9/22, so `started` is the first refused member, from the run log. 14 runs refused 20 attempts, all green; each inserted a row before its write was refused; Snow White found 18 nameless rows, each with the formulas of the row above (not 20, unexplained), and deleted them with Gene's approval (66f2c31). The insert had no test. Fix: row and range from one column map (ColumnMapper.static_row/static_range), a refused member fails the run, a refused write deletes its row, tests/test-sync-members.py with a fake that enforces the range rule. Restored by Snow White's manual sync-members --execute on the fixed code at Gene's request: the three welcome DMs went out 12:13:04-06 PT (GREEN-API outgoing log). Financial impact none: only Andy Tien voted (can't play), nobody played. Follow-ups by Snow White: names from Google Contacts (8f7dfae), a shared phone number fails the run (66f2c31), a post-run integrity check (8b92ced).
092726 · GREEN-API token (whatsapp-message-sender, smad-picklebot, smad-whatsapp-webhook)
Post-mortem read the full post-mortem
When 09/27/26 10:40:04 → 10:43:41 PT
How it was timed measured from real timestamps
Cause operational
Detected by Snow White's own getStateInstance check (401 on three methods) after the third rotation attempt, a few minutes before the first logged failure; its time was not recorded. The webhook's instance-state poll logged the 401 at 10:40:04 PT as a WARNING (debounced by design: one failed read is not an outage)
A planned token rotation (the old token's first half had leaked into a test fixture and the whole token into a session transcript). GREEN-API switches to a console-regenerated token minutes after showing it; scripts/rotate-greenapi-token.ps1 checked the new token at once, took the 401 as a bad copy and stopped with nothing written, and the old token then died. The session misread the 401 as 'some other key' and shipped an updateApiToken path (4a1e8ea) that also got 401. Restored by the -Paste run (df583ba) at 10:41:41 PT; first confirmed send 10:43:41 PT. `started` is the first LOGGED failure; the session saw 401s earlier, unrecorded. Action items in the post-mortem: the check now waits out a 401 for 3 minutes, and the console-paste flow is the default.
092026 · Daily Reminder Runner (GitHub Actions)
Post-mortem read the full post-mortem
When 09/20/26 08:00:04 → 17:55:10 PT
How it was timed measured from real timestamps
Cause config
Detected by The Workflow Watchdog's hourly sweep (cron :41, which GitHub ran 47 minutes late at 9:28 AM PT) filed issue #69 at 16:28Z from the run's startup_failure conclusion; detection 88 minutes after the failure. The Alert Investigator triaged it at 10:14 AM PT with the one-line fix and the dispatch needed. Nothing woke a session that could act: the desktop was off (Sunday morning), the cloud session cannot dispatch and workflows are the desktop's by rule, so repair waited 8 hours for a human handover
Introduced by Actions minutes: retire GREEN-API Watch, gate close-on-green on an OPEN_ALERTS variable
Fixed by The 8am runner starts again: a caller must grant what the workflow it calls asks for
A called workflow is bounded by the job that calls it, and GitHub refuses to start the run rather than degrading it. 720b9df (2026-09-19 22:03 PT) added actions:write to fifteen workflow files for the OPEN_ALERTS gate, including release-notes.yml, but not to daily-reminder-runner.yml's release-digest job -- the repo's only `uses: ./.github/workflows/` edge. THE SAME FILE FAILED THE SAME WAY ON 2026-09-04 (issues:write that time), and the comment documenting that outage sits six lines above the block that was wrong again; that earlier outage has no entry in this file, so the MTTR and change-failure tiles do not count it. restored is null on purpose: the fix is pushed but the runner is dispatched by hand or at the next 8 AM, and restoration is the first successful run, not the push. It is filled in from that run rather than estimated. RESTORED by the hand dispatch of run 35549088614 at 5:53 PM PT (Gene chose reminder_type=all): poll created 5:54 PM PT, every step green, release-digest started. Ten hours late. Why the refactor shipped without its caller is written up in session-notes.md, 2026-09-20 PT (Snow White).
091126 · smad-picklebot (Cloud Run)
When 09/11/26 21:28:10 → 22:14:27 PT
How it was timed measured from real timestamps
Cause deploy
Detected by Gene, reading 'No player found matching gene' about himself. No monitor: the ERROR line reached Cloud Logging as plain stdout text with no severity, so neither a severity filter nor the hourly Error Reporting sweep could see it
Introduced by Build the Sheets service once per thread: one /pb match built it ten times and OOM-killed picklebot at 992 MiB (#50)
596ba70 (2026-09-09 PT, the #50 memory fix) caches the built Sheets service per thread. The Resource carries httplib2's keep-alive sockets in Http.connections; the far end closes an idle one after twenty-odd minutes, and httplib2 0.32.0 re-raises the resulting socket.error from the request phase without closing the connection, so the dead socket is tried again on every later call. Both 9:28 PM commands failed in under 3 ms with 'EOF occurred in violation of protocol'. get_full_player_data() swallowed it and returned [], and an empty roster fed to the match parser reads back as every name unknown -- a confident wrong diagnosis, sent to the scorekeeper. Gaps of 14-22 minutes earlier in the evening survived; 26 did not. Fix: a hand-out after 60 s idle closes and clears the pool (one TLS handshake per burst, no rebuild), and handle_record_match() refuses an empty roster with a message that says the sheet could not be read and nothing was written.
090426 · Daily Reminder Runner (GitHub Actions)
When 09/04/26 08:00:04 → 09:58:28 PT
How it was timed measured from real timestamps
Cause config
Detected by Noticed by hand: the 8 AM reminders did not arrive and the run was found as startup_failure in the Actions list, then fixed and re-dispatched within two hours. No monitor existed for it then (a startup_failure has zero jobs, so the watchdog had nothing to read, and no failure issue was filed)
Introduced by Send the whole day's list, to three chats, at 8am from Cloud Scheduler
Fixed by Let the 8am runner start again: grant the digest job the scopes it calls for
daily-reminder-runner.yml's release-digest job called release-notes.yml with no permissions block of its own, while the callee's job asked for issues: write; a called workflow is bounded by its caller's job, so GitHub refused to start the run (run 33886955002, startup_failure at 15:00:04Z). Introduced by bf14474 (2026-09-03, 'Send the whole day's list, to three chats, at 8am from Cloud Scheduler'), which added the release-digest call with no permissions block; fixed by 85a51ec (2026-09-04 9:56 AM PT, 'Let the 8am runner start again: grant the digest job the scopes it calls for'); restored by run 33898063769, a hand dispatch on the fixed caller at 16:58:28Z. Recorded 2026-09-20 PT, sixteen days late, when the same file failed the same way; timestamps are the runs API's. The comment written into the workflow that day described the failure exactly and did not prevent its repeat -- the class is now caught by scripts/check-workflow-reporting.py check 3 (c32a3e6).
090126 · greenapi instance (all WhatsApp in and out)
When 09/01/26 18:01:10 → 09/02/26 00:05:20 PT
How it was timed measured from real timestamps
Cause external
Detected by a human noticed - Gene, 1h41m in, when a /pb command did not answer. Nothing alerted; see the detection note below.
WHATSAPP applied the restriction, not GREEN-API and not a device problem. Confirmed from the vendor's own docs rather than inferred from the word: the suspended state 'indicates that WhatsApp has applied temporary restrictions to your account', is 'the initial stage of a WhatsApp account suspension', and replaced the deprecated yellowCard status. getWaSettings returned suspendedUntil = 1788332470 = 2026-09-02 00:01:10 PDT, exactly six hours after the start. restored is NOT that value: the instance was still suspended at 00:03:22 and authorized at 00:05:20, which is the observed figure recorded here. The vendor's own expiry ran four minutes optimistic, so a restored time taken from it would have been a guess that happened to look precise -- the exact failure this file's rules forbid. A watcher polling every 20s supplied the real one. Gene re-linked at 23:18:13 PDT and it authorized, then suspended again 47 seconds later at 23:19:00. Re-linking inside the window does not hold, and knowing that is the difference between waiting 34 minutes and repeatedly rescanning a QR. LIKELY TRIGGER, ours: release-notes.yml sends one WhatsApp message per push to main, calling GREEN-API directly from the runner via shared.greenapi.send_message, which bypasses whatsapp-message-sender and therefore its dedup, its queue and its logs -- the sender logged 6 messages in three days while the real figure was far higher. Successful release-note runs: 30 on 08-31, 57 on 09-01, 13 on 09-02 (UTC), 10 in the two hours before the suspension, overwhelmingly one per session-note reply from a single session. WhatsApp does not state a reason, so this is the leading candidate and not a confirmed cause. cause is external because a vendor restriction is what broke it; self_inflicted stays false because change failure rate is measured against deploys and no deploy caused this -- but the sending rate was ours, and commit rule 7's push-batching advice was written about republishing the dashboard, not about this larger cost. DETECTION: the webhook logged [INSTANCE STATE] notAuthorized as an ERROR immediately and nothing consumed it. error-reporting.yml swept two minutes later and correctly said nothing -- it reads GCP Error Reporting GROUPS, built from exceptions, and a logger.error() line never becomes one. Closed by .github/workflows/greenapi-watch.yml, which polls getWaSettings every 15 minutes, reports stateInstance and suspendedUntil, and opens an issue on anything that is not authorized. Verified against this live outage: it opened issue #39.
083026 · smad-picklebot
When 08/30/26 15:38:00 PT, restore time not recorded
How it was timed measured from real timestamps
Cause deploy
Detected by Shyam ran the command during a live test with Gene and got silence
Introduced by feat: derive session fees from SESSION_HOURS; let admins DM /pb shyam
_is_admin_dinkers_member() read GREENAPI_INSTANCE_ID and GREENAPI_API_TOKEN as module globals. picklebot/main.py imports ADMIN_GROUP_ID at module level but those two only inside other functions, so the reference raised NameError, process_command() 500'd and the webhook logged "Picklebot returned 500" without replying. NOTHING WAS CHARGED: the crash is in the permission check, which runs before handle_shyam_court(), so no credit was posted and no cancellation attempted - the North court was still booked afterwards, as Gene confirmed. WHY TESTS MISSED IT: the test set main.GREENAPI_INSTANCE_ID, which CREATED a module attribute production does not have, so it manufactured the exact thing that was missing. Patching a name onto the module under test instead of where the code really reads it turns a NameError into a pass. The test now patches shared.config and asserts the function references no undefined module global. WHY THE SMOKE TEST MISSED IT: it proves the container starts and serves; this raised on a lazily-reached code path inside one handler, which no startup check can reach.
083026 · venmo-sync-trigger
When 08/29/26 21:15:37 → 21:24:40 PT
How it was timed measured from real timestamps
Cause external
Detected by Gene forwarded the GCP alert email ~10h later. Every automated layer missed it — see notes.
Google Sheets API returned HTTP 503 'The service is currently unavailable' at shared/venmo_sync.py:189 during a cold start (instance began 21:15:34, failed 21:15:37 PDT). Vendor-side and genuinely external: no deploy, no code of ours misbehaved. Restored is the alert's own auto-recovery at 21:24:40; the condition is 'ERROR count > 0', so it cleared by itself once no further errors logged. Nobody resolved it. IMPACT WAS NIL, BY LUCK: the last Venmo payment was 08/29 00:33 PDT and was acknowledged at 00:33; nothing arrived at 21:15, so the sync had nothing to record. Had a payment been pending it would have been lost silently, because venmo-trigger swallows exceptions on purpose (0fda64c, to stop a Pub/Sub retry storm) while its comment promised 'the daily scheduled sync will catch anything missed' — a sync that did not exist. THE REAL FAILURE WAS DETECTION. Three layers should have surfaced this and none did: (1) error-reporting.yml judges freshness against a fixed 70-minute window commented 'slightly wider than the hourly cadence' — the cadence is not hourly, GitHub ran that cron 7 times in 28 hours with gaps to 6.5h, and the 05:29Z sweep saw this error at ~70 minutes and classified it stale, going green having filed nothing; (2) diagnose-logs.yml queried three of the four deployed functions, omitting venmo-sync-trigger, so investigating it would have returned an empty report reading as 'no errors'; (3) no agent session has any route to a GCP alert email. FIXED IN THIS SESSION: freshness window now measured from the previous run that actually happened (7f99641); diagnostic queries all four functions and reports the window it used (980bb2d, 2e0b4a0); execute(num_retries=3) on all 11 Sheets calls in venmo_sync and zelle_sync so a transient 5xx is absorbed in-process; payment-sweep.yml re-runs both syncs daily before the reminder job and on demand, making the promised safety net real.
082726 · court-booking
When 08/27/26 23:58:00 → 08/30/26 18:31:47 PT
How it was timed measured from real timestamps
Cause deploy
Detected by Shyam reported a missing cancellation confirmation while testing /pb shyam. Not by any monitor - a green workflow and a swallowed branch look identical from outside
Introduced by Finish the timezone consolidation properly
Fixed by fix: load .env before any import that reaches shared config
a32da25 ('Finish the timezone consolidation properly') added `from webhook.shared.config import PST` to email_service.py. court-booking.py had imported email_service three lines BEFORE load_dotenv() since January, which was harmless until email_service began reaching config. config.py resolves constants at module level, so ADMIN_GROUP_ID froze to '' before .env was read, and Python does not re-execute a module on re-import - the later `from webhook.shared.config import ADMIN_GROUP_ID` returned the cached ''. `chat_id = reply_chat_id or ADMIN_GROUP_ID` was then falsy and `if chat_id:` skipped the send WITHOUT LOGGING ANYTHING. SAME COMMIT SERIES AS THE 8/27 pytz OUTAGE: consolidating PST into shared config added a shared-config import to two places that were not ready for one. That is the pattern, not the individual bug. STARTED is the first court-booking run after a32da25 (the nightly booking at 11:58 PM PT on 08/27; a32da25 landed 12:51 PM PT the same day), not the commit time. The GitHub API reports that run as 06:58Z, which is 11:58 PM PT -- it was first recorded here as '06:58 PT', a 7-hour error across a day boundary - the script runs in CI, so the bug took effect on the next run. RESTORED is verified at every hop, not assumed. PUBLISH: the read-only probe at 6:31 PM PT on 08/30 logged 'Reservations sent to WhatsApp via Pub/Sub' where the identical probe 10 minutes earlier logged nothing. CONSUME: whatsapp-message-sender logged 'Processing message ... ' and 'Message sent to' for that window. DELIVER: Snow White queried GREEN-API directly and confirmed the message is in Admin Dinkers. That last hop matters because a green publish into a crash-looping consumer is exactly the 2026-08-27 pytz incident -- a publish is not a delivery, and until someone looked at the group this was still an inference. BLAST RADIUS measured, not estimated: 25 hours of whatsapp-message-sender logs show 5 messages, all from picklebot, none from any court-* source - including across the 11:58 PM PT nightly run on 08/29. WHY NOTHING CAUGHT IT: the deploy gate checks packages, not config; the smoke test proves containers start, and this is a GitHub Actions script with no container; and the failing branch wrote no log line at all, so even a log-based alert had nothing to fire on.
082726 · whatsapp-message-sender
When 08/27/26 10:27:49 → 10:38:48 PT
How it was timed measured from real timestamps
Cause deploy
Detected by GCP Error Reporting email, relayed by Gene
Introduced by Date the reminder email subjects, and collapse the duplicates behind them
Fixed by Fix: whatsapp-message-sender missing pytz, which broke message delivery
Consolidating the PST constant put `import pytz` into shared/config.py. All four Cloud Functions import that module, but whatsapp-sender's requirements had no pytz — its own main.py never referenced it. ModuleNotFoundError at import, so the Pub/Sub consumer for every WhatsApp message never started. Verifying the import locally, where pytz exists, proved nothing about the deploy targets. A CI check that each function's requirements cover the imports of webhook/shared/ would have caught it before deploy; that guard does not exist yet.
082526 · GREEN-API instance
When 08/25/26 22:41:10 → 22:51:24 PT
How it was timed measured from real timestamps
Cause operational
Detected by Self-induced and watched live — the logout was ours
Originally logged as an undated 6-minute EXTERNAL GREEN-API drop, 'noticed by chance'. All three of those were wrong. GREEN-API's authorization log (INVESTIGATION-2026-08-25-group-events.md) shows the session was continuously authorized from 06/04 12:12:57 PM to 08/25 10:41:10 PM, so no vendor-side drop happened at all: Gene logged the Firefox linked device out at 22:41:10 while reverting the mystery group-admin actions, and that device WAS the GREEN-API session. Re-linked at 22:51:24, so 10m14s of real unavailability with exact timestamps. A second, deliberate 5m23s logout on 08/26 10:30:40–10:36:03 AM proved the attribution; that one was a controlled test, not an outage, and is not logged as one. Kept self_inflicted=false because change failure rate measures deploys and no deploy was involved — but the cause is 'operational', not 'external', so the dashboard no longer blames a vendor for our own logout. handle_state_change() in webhook/main.py now alerts by EMAIL (not WhatsApp, which is the thing that would be broken) on stateInstanceChanged, so a repeat is timestamped rather than reconstructed months later.
082426 · whatsapp-message-sender
When 08/24/26 19:55:00 → 20:10:06 PT
How it was timed measured from real timestamps
Cause dependency
Detected by Slack alert relayed by Gene
Introduced by Stop gating payment thank-you emails on the SMS opt-in
Fixed by Pin google-api-core below 2.35.0 to restore Firestore
google-api-core 2.35.0 URL-encoded the Firestore database id (projects/P/databases/%28default%29), so every call returned 400 Invalid database id. The package appeared in no requirements file — it arrived as a transitive dep resolved at build time, which made every deploy a bet on whatever shipped that day. Fixed by pinning google-api-core<2.35.0; the follow-up (0d62f52, f0ee6c7) moved all four functions to pip-compile lockfiles so transitive deps are pinned. Counted self-inflicted: the dependency was upstream, but shipping unpinned transitive deps was our choice.
082126 · cancel-game workflow (smad-whatsapp.py cancel-game)
When 08/21/26 22:09:22 → 09/08/26 23:41:00 PT
How it was timed measured from real timestamps
Cause deploy
Detected by Gene, reading '⏭️ Players notified: nobody voted yes' on a game three people had voted for. No monitor: the run was green, the 403s were logged on a GitHub runner that Cloud Logging never sees, and the swallowed False rendered as a skip
Introduced by feat: /pb cancel game next + notify everyone who voted for a canceled game
Fixed by the commit adding this entry: cancel-game.yml credentials, three-state cancel summary, non-zero exit
fc45723 added the notify step to cancel-game but not the GREEN-API credentials to cancel-game.yml; _send_dm() sends straight to GREEN-API, so every send was an unauthenticated 403. _send_dm() returned False without raising, _notify_canceled_game_players() returned only the players it reached, and the caller's one explanation for an empty list was 'nobody voted yes'. Email rides a successful DM, so nobody was emailed either. The 9/4 fix (0a267b6) added GMAIL_USERNAME to this workflow and did not notice the WhatsApp side had never worked. Restored by hand: the two other 9/9 voters were DMed and emailed from the desktop at 23:41 PT. Fix: the credentials, a third result state ('failed', ❌) that names the unreached voters, and a non-zero exit so the workflow goes red.
A January–February build-out sprint, a spring taper into maintenance, then steady feature evolution — the shape of an app reaching stability while still shipping weekly.
| Week of Jan 12 | 25 |
| Week of Jan 19 | 56 |
| Week of Jan 26 | 111 |
| Week of Feb 2 | 61 |
| Week of Feb 9 | 73 |
| Week of Feb 16 | 69 |
| Week of Feb 23 | 32 |
| Week of Mar 2 | 30 |
| Week of Mar 9 | 19 |
| Week of Mar 16 | 19 |
| Week of Mar 23 | 10 |
| Week of Mar 30 | 4 |
| Week of Apr 6 | 2 |
| Week of Apr 13 | 7 |
| Week of Apr 20 | 9 |
| Week of Apr 27 | 0 |
| Week of May 4 | 0 |
| Week of May 11 | 1 |
| Week of May 18 | 5 |
| Week of May 25 | 1 |
| Week of Jun 1 | 15 |
| Week of Jun 8 | 9 |
| Week of Jun 15 | 3 |
| Week of Jun 22 | 0 |
| Week of Jun 29 | 5 |
| Week of Jul 6 | 12 |
| Week of Jul 13 | 17 |
| Week of Jul 20 | 6 |
| Week of Jul 27 | 5 |
| Week of Aug 3 | 0 |
| Week of Aug 10 | 3 |
| Week of Aug 17 | 64 |
| Week of Aug 24 | 222 |
| Week of Aug 31 | 107 |
| Week of Sep 7 | 105 |
| Week of Sep 14 | 46 |
| Week of Sep 21 | 73 |
| Week of Sep 28 | 158 |
Every calendar day since the first commit. 145 of 263 days have at least one commit; the busiest single day is 83.
The same commits collapsed onto a single axis — the shape of a day. Peak at 10pm, quietest at 6am.
Every commit placed by local hour and weekday. Busiest hour is 10pm, and 35% of all commits land between 9pm and 5am — the retirement-project signature.
Net lines after every commit. The codebase has grown steadily — but that hides the churn underneath: 131,044 lines of Python were written and 28,448 deleted along the way, so 22% of the Python ever written has since been refactored away.
Every day with a deploy since the first one, Pacific days. The solid fill is the day's mean; the bar continues to p90. Days with no bar had no deploys and sit out the average rather than counting as zero. A push whose deploy was skipped (it changed nothing the function runs, since 2026-10-01) is not a deploy and is left out. No rolling average: each day stands alone, so a change shows the next day. Latest: Oct 3, mean 99s over 4 deploys. From ci-metrics.json, refreshed daily.
The suites alone: the Tests workflow's 'Run every suite under coverage' step on GitHub's runner, every day since the workflow started (2026-09-12), Pacific days. Same reading as Deploy Time. The dashed line, on the right-hand axis, is how many checks the suites ran that day (the day's last run in test-results.json), so a slower day can be read against a bigger suite. Latest: Oct 3, mean 34s over 4 runs. From ci-metrics.json, refreshed daily.
The whole Tests workflow end to end: the runner's setup, checkout, the package install, the suites above and the upload of their results. The gap between this chart and the one above is the overhead. Latest: Oct 3, mean 50s over 4 runs. From ci-metrics.json, refreshed daily.
Statement coverage from coverage.py over every tracked Python file except the suites themselves, so a module no suite imports counts as zero rather than dropping out of the denominator. Measured by tests.yml on each push that touches Python and written to ops/test-results.json; the suites are tests/test-*.py, run by scripts/run-tests.py. Last run: 2026-10-03 21:38 PT, commit 04dda59.
140,870 lines across 424 tracked files — counted from git ls-files each time this page is built, so it never drifts from the repo. Tests are the tests/ suites, split out from Python so the application and the code that checks it are counted apart.
Every outage in ops/incidents.json since the first, at the day it started: the dot's height is how long it took to restore, on a log scale (they range from minutes to weeks), filled for self-inflicted, hollow for a vendor's. The line is the trailing 30-day mean time to restore, the tile's figure, day by day. 1 outage with no measured restore time not plotted. Latest: Sep 27, 4m.
Every /pb command players typed, from the bot's own command log (dry runs left out): the time in the bot from the command arriving to the reply leaving, the fill the day's mean and the bar its p90. A first command after a quiet spell pays a cold start; a post-game report pays for its Claude-written story. The dashed line, on the right-hand axis, is commands that failed that day: a crash, or a reply that refused the command (an unknown player, a bad score), counted since 2026-10-02. Latest: Oct 3, mean 4.2s over 8 commands. From ci-metrics.json, refreshed daily.
Every message the sender delivered (its "Message sent to" log line), the red foot of a bar the ones it gave up on (its ERROR lines). Two outages were messages that silently stopped; a day that should have reminders and shows none is the tell. From ci-metrics.json, refreshed daily.
ERROR lines from the four functions: each one emails an alert, so this is how often the system paged. The dashed line, on the right-hand axis, is alert issues opened that day (a workflow that failed). From ci-metrics.json, refreshed daily.
Billed minutes per day for this repo's workflow runs: each job rounded up to the minute, as GitHub bills it. The dashed line, on the right-hand axis, is the month's running total, against 2,000 included. Other repos on the account draw from the same allowance; the tile shows the account total when the billing token can read it. From ci-metrics.json, refreshed daily.
What changed for players, a day at a time: each day covers the 24 hours ending 8:00 AM PT, written by Claude from that day's commits and sent to the SMAD Pickleball group at 8 AM. 136 days so far.
🎮 Game Plan works mid-game/pb game plan now works after a game has started. With no game named, it plans today's game until four hours after its start, then the next upcoming one. Posted mid-game, it shows the Game Day block and the next suggested matches, numbered to continue from the matches already played. This fixes the "no upcoming game" reply when a player joined after the start.
📈 Weight and SMAD DUPR chart/pb weight record and /pb stats my now send a chart of your weight and SMAD DUPR over time. Weight in lbs is on the left and your SMAD DUPR after every game is on the right. The DUPR from before your first fit is dashed. You need at least two weigh-ins, and the reply tells you the chart is on its way.
📊 Is the model any good? New dashboard charts
The SMAD DUPR dashboard now has three charts on how well the ratings are doing. They show how balanced the matches are, how often the favorite wins compared with a coin flip, and how close the real margin lands to the predicted spread. Each chart marks 9/22, when the Game Plan started picking matches.
🕸️ Cleaner match graph
The match graph now fills the whole frame, and no dot covers another player's name. Names also have a halo in the page's color, so lines running under them no longer cut through the text.
🏆 Top 5 list names
The most-partners list is now titled "Most promiscuous". The other lists include Dynamic Duo, En Fuego!, and Mr. Clutch, and every title is in Title Case.
🔢 Easier-to-read match lines
The spread and win chance now appear with a slash, like +3/64%. Recorded matches, suggested matches, and post-game report lines all use it. Rebuilt dashboard reports pick it up on the next export. Stories already kept are not rewritten.
🇺🇸 American English everywhere
Everything you read now uses American spellings, such as favorites, favored, color, and canceled. This covers the post-game story and spread check, the dashboard, and the court shortfall and canceled job messages.
🛠️ Behind the scenes
Commands the bot refuses, like an unknown player or a bad score, now show as failed in the command log instead of as successes. We also added tests for the Game Plan and the match graph, and five more model trends are on the backlog for the dashboard and post-game report.
📊 SMAD DUPR 2.0 ratings
Ratings now start from each player's sheet DUPR, and recent matches count more than old ones. Win chances allow for a 6-point miss, so they are less overconfident: in a backtest, favourites won about 69% of the time, against 55% for the old model. Match and suggestion lines show the win chance after the spread, like +3 69%. The dashboard is titled SMAD DUPR 2.0 and has a summary of the model, a brighter match graph with dots sized by matches played, a colour key, and top-5 lists (winningest partnerships, biggest rivalries, win streaks, giant killers, clutch players, hot right now, and more). Players under 10 matches are left out of the lists. The 1.0 page is kept for comparison.
➕ Add a game mid-week/pb add game 10/1/26 7:30pm south @Rich Vic adds a game outside the weekly poll. It gets Last Call, the Game Plan, and the lights job just like a poll game, and registers the players you name. Names separated by spaces now read as whole names ("Gene Thanh John Wang 2" is three players), and @-mentions no longer swallow the next name. The reply ends with the command to join the game: /pb register 10/1/26 7:30pm.
⚡ Much faster commands/pb match now answers in about 0.7 to 1.2 seconds in the bot, down from 4 to 8. /pb game plan, /pb post game, /pb games and /pb balances also make far fewer sheet reads, and /pb games went from 1.46 s to 0.32 s. Match replies end on a ⏱️ line showing the time. Keepalive pings should also stop the slow first match after a quiet spell (it took 18.7 s once).
🎾 Match and Game Plan fixes
Suggestion numbers are now stable all night: a number is never reused for a different match, and undo restores the list exactly. Match lines end on the ratings' spread (+3 favoured, -2 an upset), and the Game Plan shows "+1, 66% to win". Impossible scores like /pb match 61 15-3 are refused. /pb undo -1 is refused, and undoing the only match still lists what to play next. Courts are saved once per game, so replies stay quick.
📅 Dates without a time/pb cancel game 10/3/26 used to say "Invalid date or time". Now /pb cancel game, /pb register, /pb left and /pb unregister take a bare date and use the first game that day. The reply says which time it assumed.
📝 Post-game story
Catchphrases now appear in full, woven into the story's own sentences rather than as quotes or tacked on after a dash. There are new Chick Hearn lines and a "Hodl to your dreams. To the moon!" for rising stocks and underdog wins. Post-game reports end on the winners' spread, and the analysis gains a 🎯 Spread check on how the predicted spreads did against the night's scores.
💸 Payments and court fixes
Paying twice in one Venmo sync now thanks you with the right balance each time. Zelle emails ending in a period ("$8.00.") are booked, and Venmo and Zelle times are no longer 7 hours off. /pb record payment John 20 is refused when two Johns exist. /pb court cancel without a court no longer cancels both courts. Names like Mary-Nell are no longer mistaken for dry-run flags. /pb stats my now shows the SMAD DUPR as of each row's date.
🛠️ Behind the scenes
Test coverage rose to 99.2%, which turned up many small bugs, all fixed. Deploys are faster and skip unchanged functions. We moved to Python 3.14, and the Release Dashboard got new charts. Release notes are now written daily by Claude Sonnet 5.5.
🎾 The Game Plan never suggests the same matchup twice
A four-player plan was listing round 1's match again as match 3, because the balancing step threw out the one unplayed pairing for being an 81% court. Now an unplayed pairing always beats a repeat, and balance only chooses among the unplayed ones. A matchup already listed, or still on court, is left out, so four players see their three pairings and the list stops. Once nothing new is left, a repeat can follow.
⚡ /pb match answers faster, and the numbers stay in order
A reply that took 11.2 s now skips the Game Day weather call (5.3 s of it) that a match reply throws away — that header is built only for the Game Plan report. The planner reuses the game the match was recorded against and the roster already read, instead of re-reading the poll and roster, and admin lookups are cached for 30 minutes instead of 5 (another 1.35 s). Match numbers now continue from the highest recorded or listed, so a typed match no longer jumps from 18 to 28.
📝 The post-game story reads straight through
Every line about a match now names its players and gives its score, so no line leans on the one before it — "That 12-10 needed extra time, and the booth needed oxygen" won't happen again. The stock-down 📉 line must follow the stock-up 📈 line as the template has them, "extra time" is now "overtime", and stories that invent time spans like "for years" or "all season" are rejected. Any story that breaks these rules is dropped in favour of the template.
🔁 /pb post game force rewrites the night's analysis
For a night whose matches were corrected after the story was written, admins can now re-run the night and have the analysis rewritten over the kept story. The report is saved and the dashboard redrawn so it picks up the new story, and the reply tells you whether the new story stood. If the rewrite fails its checks, the kept story stays — never the template.
📊 /pb survey results counts only current players
20 of 67 survey responses came from archived players, so the counts, best times and heatmap now use the 47 who still play. Anyone the roster can't match is still counted.
🛠️ Behind the scenes
The error sweep now measures its window from the newest previous run rather than whichever GitHub lists first (a 15-day-old run had stretched the window to 366.7 h and re-filed two stale 9/25 errors), only asks for runs created in the last 26 hours, and retries when it gets an empty or stale answer.
🏓 Game Plan reads cleaner and the numbers hold still
Suggested matches are now just a number and the two sides — no 🏓, no "balanced:" / "learn:" / "playing:" tags — with the match still on court listed first, and the sums line (6.33v5.62) sits at the margin so WhatsApp stops wrapping it. A suggestion keeps its number until it's recorded, so on a night like 9/29 court 2 can record match 2 first and match 1 is still match 1; an undone match comes back under its own number. The Game Plan lists whole rounds — 6 suggestions on two courts, 4 on one — and /pb game plan 2 (or 1) forces the court count. Round 3 is now titled "ratings learn most from".
📝 Match lines fit a phone
Every /pb match, undo and ✅ reply plus the /pb post game list now use one short form: "1 751 Thanh/Vic 11-6 Gene/Rich" — number, recorded time, first names joined by "/", score where "beat" used to be. Post-game match lines lead with the time they were recorded (07:51pm). /pb match 14 5 now tells you which match 14 is and how to write its score, and the "* under 10 matches / Move" footnote is gone from the report (the dashboard legend still explains it).
🙋 Guests are rated, not counted
The 9/29 clinic coach subbed for a late Roger and was recorded as Roger in matches 2 and 4; both rows now read Guest (3.80), and Roger's night is 3-3. A guest is held at his known rating so his partners and opponents are rated fairly, but he's left out of tonight's records, the standings, the match graph and the recap — the match lines still name him.
🕢 A 7:30 game is a 7:30 game
Games logged at 7:30 kept being labelled "7pm"; they now keep the minutes (match ids like 20260929-730pm-3) and the 17 rows from 9/29 were relabelled. Kept post-game stories were silently saved under a blank key and never shown again — they're found now, and the 11 existing ones are labelled. Everywhere a player reads it, "game night" is just "game", since games can be at noon or Saturday morning.
👋 New members get their row and welcome DM again
A broken insert meant three members joining between 9/22 and 9/29 got no row and no welcome DM; that's fixed and the sync now checks the sheet against the group after every run. New members are named from Google Contacts first, so "cunningham dan" is welcomed as Dan Cunningham. A member who leaves now gets a note that their stats are archived and how to come back.
🎙️ More booth calls, and Gene can add his own
The post-game story can now call "Boom shakalaka!", "To beat the champ, you've got to knock 'em out!" and "The mustard is off the hot dog!". All 33 catchphrases moved to a Catch Phrases tab — a new line Gene adds is live within ten minutes.
📊 SMAD DUPR dashboard hides archived players
Archived players are off the Ratings table, the History chart and the suggested-match advice, on the page and in /pb smad dupr graph. They stay in the match graph, drawn faded, since their matches still rate everyone they played.
🛠️ Behind the scenes Watchdog alerts now go out by issue, email and WhatsApp independently so one outage can't silence the rest, member sync retries a connection blip before failing, and the Release Dashboard gained lead-time and detection tiles plus a shared post-mortem page.
🏓 Logging matches before the game starts
Once the Game Plan is posted, /pb match and /pb match undo <n> belong to that upcoming game for the hour before it starts — no more scores landing on last week's session. So /pb match undo 3 before the start removes the match you logged off suggestion 3. If no plan has been posted and no game has started, the reply now tells you that posting a Game Plan opens the game.
📵 A 4-minute gap in messages on 9/27
A scheduled security change to our WhatsApp connection went wrong and Picklebot sent nothing from 10:40 to 10:43 PT. Sends were restored and we wrote up what happened so it doesn't repeat.
🛠️ Behind the scenes
The token rotation script now handles the real-world delay before a new token goes live (retrying for up to 3 minutes), keeps the token safely saved as it goes, and can resume a failed run; an incident record and post-mortem were filed with a shareable page linked from the Release Dashboard. Our secret-scanning sweep now reports the actual file and commit for each alert and says whether that file still exists in the current code.
🎾 SMAD DUPR starts everyone from the middle
Your rating no longer begins at your declared DUPR — every player now starts from 3.25, the group's mean, and only match results move you from there. Ratings pull in toward the middle: on today's log the top came in from 4.72 to about 4.36 and the bottom moved up. Your sheet DUPR is still shown beside your rating and still sets the scale, so a 1.0 gap means what it means in DUPR. The model also re-measures itself at every refit instead of being pinned, and the dashboard's older nights are re-fitted the same way so the trend line has no step at the switch.
📝 The post-game story is now written for the night
The analysis in your post-game report is written from that night's facts — the matches, every rating, move and record — instead of fixed template lines. Every number in it is checked against the facts, and anything that doesn't match falls back to the old template. It quotes catchphrases on their own, with no announcer name and no quotation marks, and once a night's story is written it's kept for good: refits, remodels and dashboard refreshes never rewrite it.
📈 Dashboard: every game night's report, and a history chart you can hover
The dashboard now rebuilds a post-game report for every game night from the Win Loss Log with the current model, so old reports match how ratings work today. "Over time" is now SMAD DUPR History and starts at 8/25, the first night with enough matches, with one point per game night. Hover or tap a line to see the player's name and current SMAD DUPR, with the rest faded — handy when twenty lines overlap. The explainer text is much shorter: the formula, the 3.25 start, and what ± means.
🔁 /pb post game is safe to re-run
Move is now measured against the previous game night from the match log alone, so running /pb post game twice posts the same report instead of a different one, and it won't save a duplicate report row. The reply's footnote now reads "Move = since the previous game night".
🧪 /pb post game remodel
New command for changing the model without generating a new game report: it saves the refit and redraws the dashboard, but leaves the dashboard's latest post-game report as the last game night's.
🚫 Cancelling a game cleans up properly
When a game is cancelled, the Game Plan post, the eve Last Call and (for night games) the lights reminder are all deleted together. Previously a cancelled game's Game Plan still fired — the cancelled Sat 9/26 9am game posted at 8:00 AM PT.
🔔 Less noise in Admin Dinkers
The per-payment "Payment Received" posts and the survey, payment, vote, DUPR and game reminder summaries no longer go to the admin group. Your thank-you DM, email and Payment Log row are unchanged. The one alert left is "Game Reminder SKIPPED", for a reminder that couldn't go out.
🛠️ Behind the scenes — Tokens and keys are now scrubbed from every log, email and issue, the message sender retries connection timeouts and pages once for a genuinely lost message, token-rotation scripts were added, and GitGuardian alerts now file issues automatically. 65 test suites, 2241 checks, 0 failed.
🏆 /pb post game is now the whole night in one report
Run /pb post game and it refits the SMAD DUPR ratings and replies with "Post-Game Report for 9/25/26": the numbered matches, the night's SDPR / Move / W-L table, and the post-game analysis. /pb compute smad dupr still works as another way in. Only an admin's run saves the results and redraws the dashboard — anyone else gets the same report, just not saved. On the dashboard it appears under "Latest Post Game Report", and the masthead now reads the refit date, like "SMAD Pickleball · 09/25/2026 · refit 5", instead of "Win Loss Log".
🎾 Suggested matches follow the night you actually played
The Game Plan now builds from the matches really recorded, so a late start or an off-plan match no longer throws the numbers off. Matchups already played tonight aren't offered again unless every alternative repeats one — replayed over the 9/25 night, the new list had zero repeats against four in the old one. The next three suggestions print under the Match Recorded, undo and register replies, numbered on from the last recorded match. /pb match <n> <score> now also understands "11 to 7" and "11 7", and refuses a number already recorded by naming the next one.
↩️ /pb match undo 2 removes a specific match
You can now undo any recorded match of tonight's game by its number — /pb match undo 2 or /pb match undo #2 — no matter who recorded it, and the number matches the one in the Match Recorded reply. Plain /pb match undo still removes your own last match. The reply, headed "Undo Match 2", shows the night's last three matches and the next suggested matches, and a suggestion freed up by the removal is listed again under its number. Out-of-range numbers and empty games are refused with nothing deleted.
📊 Top 20 board: ten or more matches
The Top 20 board on Game Day Group Stats and the daily Hall of Shame and Fame now only lists players with at least ten logged matches, and the header says so: "🏆 Top 20: SMAD DUPR > W-L > Hrs (10+ matches)". Exactly ten qualifies; nine doesn't, even at 9-0 with the best rating. If nobody qualifies, the board is left out rather than shown empty.
💡 Lights reminder times itself around the game
The reminder email to the desk is now scheduled one hour after the game starts (8pm game → 9pm) instead of a fixed 8pm, and it asks for the lights to stay on for 3 hours after the start — an 8pm game runs to 11:00pm. Tonight's 8pm game keeps its original 2.5 hours, so its email still says 10:30pm.
🎙️ Post-game analysis calls it like LA's booth
The analysis now drops famous commentary catchphrases on every line: "It's time for Dodger baseball!" and "Pull up a chair!", "This game's in the refrigerator!" and "Slam dunk!", "I don't believe what I just saw!", "Showtime!", plus "Booyah!" and "Show me the money!" on stock up. Stock down stays upbeat — "Tomorrow's another ballgame.", "Every slump ends. Think Blue!", "No harm, no foul." — never a jab. A new Thriller line calls out the night's longest extra-time game, where the winning score went past 11.
🛠️ Behind the scenes
The board, the undo numbering and the reply block are each written once now instead of in duplicate copies, and the test suite finished at 61 suites and 2115 checks with 0 failures.
🎾 Game Plan now shows five suggested matches/pb game plan prints the first five suggestions instead of the first two rounds (four matches on our two courts), so you can see a bit further ahead. The numbering is unchanged — matches 1 to 5 in the report, with the match replies picking up from 6 — so /pb match <n> <score> and the "suggested matches left" block still point at the same games. The same list shows up in the reminder that goes out 1h before.
📋 Match Recorded keeps the last three matches
After you record a score, the reply now lists just the last three matches of the night, including the one you just entered, each under its own number. The count line still counts the whole night, and /pb post game and the undo reply still show the full list.
🛠️ Behind the scenes
The developer's release dashboard trends now cover the last 30 days instead of 90, with incidents counted inside that same window and the test-time tile labelled accordingly. A test that assumed a fixed date now pins its clock so it passes on any day; 60 suites, 2057 checks, 0 failed.
🎯 Record a match by its number
You can now log a result with just the suggestion number and the score: /pb match 14 9-11. The score is read in the order the two sides are listed, so 9-11 means the right-hand side won. Numbers are fixed when the Game Plan is made and never shift, so recording out of order just leaves gaps — no confusion about which match is which.
🔢 Suggested matches are numbered, continuing from what's been played
The Game Plan now numbers the courts of rounds 1 and 2 as 1. to 4., and suggested matches pick up from there: with 13 matches recorded, the next suggestion is 14. One running list all night, so the number you type is always the number you see.
🙋 /pb unregister for no-shows and early leavers
If someone doesn't turn up, /pb unregister @player takes them off the night and off the night's charge (and tells you if the cell held game-day hours). If someone plays and then heads home early, /pb unregister @player left drops them from the Game Plan but keeps their charge. Either way the plan rebuilds on the spot, and the next two rounds appear right under the reply. Every /pb match reply after that follows the new roster.
✅ Cleaner Match Recorded replies
Names now read the same short way they do in the players list — "Gene C", "John W2" — everywhere in the reply, with no rating tacked on after each name. Wins read as "beat" instead of "def.", and the suggested matches that follow are numbered.
🛠️ Behind the scenes
60 test suites and 2049 checks pass, with the README and command help updated for the new /pb unregister and numbered /pb match.
🎾 The Game Plan arrives — An hour before each game, Picklebot now posts "Picklebot Suggested Matches for Balance and Algo Training": the night's date, weather, courts, who's in with their SMAD DUPR, and suggested teams and matches built from the ratings and who has played whom. It replaces the second Last Call, and you can ask for it anytime with /pb game plan. It ends with Fun Picklebot Graph Stats — things like "Gene C and Ryan H have faced each other 14 times, the most of anyone here" — plus a link to the SMAD DUPR dashboard.
📋 The plan follows you down the night — The posted report now shows only the first 2 rounds, and every /pb match confirmation lists the next 2 unplayed rounds ("28 of 32 left; next 2 rounds"), so you don't scroll back up to find the plan. A court clears once that same pairing is logged, either side winning, in any name order. The plan also keeps going as long as the night does instead of running out after round 5, it's frozen at game time so later rounds no longer replay round 1's courts, and a dry run to Admin Dinkers can no longer swallow the real post to the group.
🗣️ /pb match understands more of how you type — Of 20 attempts on 9/22, 7 were refused; all seven now work. Two first names with no "and" ("ryan dom beat richard mark 8") are read as a team, "Daum" finds Dominic, and dictation slips in the score position are caught (won 1, to/too 2, for/fore 4, ate 8). When it still can't tell who you mean, the reply now lists tonight's first names so you can pick.
📊 A refit reply about tonight, not the whole ladder — /pb compute smad dupr now answers with "SMAD DUPR Refit for 9/22/26", one summary line ("13 matches played between 8 players."), and a table of tonight's players with SDPR, Move and W-L. Under it is a post-game analysis drawn from the night: stock up, stock down, the rookie card, the upset. Each reply is kept and shown on the SMAD DUPR dashboard under "Latest recalc report", with older ones behind "See more", and the dashboard redraws itself after a refit.
✍️ /pb register a late arrival — If the pinned poll is hard to find, or someone turns up last minute, any member can add a player to today's roster exactly as a poll vote would, so their matches can be recorded. It takes an @-mention, a typed name or nothing at all for yourself, finds today's game (or the next one, telling you how to name it), and reports rather than overwrites an existing mark.
🛠️ Behind the scenes — The SMAD DUPR column now sits right beside DUPR on the main sheet, a new Court Finance dashboard turns the weekly consolidated report into a page, and a rerun lever lets fixes ship without the desktop being on — on 09/20 a fix sat from 10:16 AM to 5:43 PM waiting on it.
🏓 /pb players shows a self-rated DUPR when we haven't fitted one yet
If you haven't got a fitted SMAD DUPR, the game-night roster now shows the self-rated DUPR from the sheet instead of an empty cell, so newcomers aren't a mystery. A fitted rating always wins when both exist. The ranked lists — /pb players, the Top N board and the shame report — stick to fitted ratings only, so players who haven't played in months don't float above the people showing up every week; with no fitted rating the cell stays blank and the player ranks last.
🛠️ Behind the scenes
Test results, CI metrics and the SMAD DUPR export now live in a reports bucket instead of the repo, with the old copies kept as a fallback until both dashboards were confirmed reading from the bucket. The release digest and dashboard also stopped counting bot-written records as releases (119 such commits now excluded instead of 59), and the alert sweep now closes an alert once every workflow has a green run after its newest failure. 55 suites, 1787 checks, 0 failed.
🗳️ The Sunday games poll is back
The 8am Sunday runner failed to start, so for ten hours there was no games poll, no Last Call, no Games This Week and no vote or payment reminders. The poll was restored by hand at 5:54 PM PT and everything downstream ran green. Nothing you voted on was lost — the week just started late.
🎾 Shyam's court handover now works closer to game time
The handover used to need your vote 48+ hours before the game; it now needs 24. The late Sunday poll put Shyam's vote 47.1 hours before Tuesday's 7 PM game, so the transfer was skipped without anyone being told. Now if a vote lands inside the window, the bot DMs you that the court transfer was not started, says why, and gives you the override: /pb shyam MM/DD/YY.
📊 The SMAD DUPR dashboard has a match graph
A new section sits between Over time and How it is calculated, with the graph drawing, a legend and a tile row showing players, matches and pairs met out of possible. If there's no graph to show the section simply doesn't appear, and names with characters like < now display correctly instead of breaking the page. The export carries only names, ratings and pair edges — never a phone number or an email.
📋 Every outage you feel now gets a written post-mortem
Any outage that affects players or money gets a public write-up: what broke, end-user impact, who did what and the full timeline. The 2026-09-20 poll outage has one, and the Release Dashboard links it right on the outage row, naming the commits by subject instead of a bare code.
🛠️ Behind the scenes
The exact mistake that stopped the runner was caught once before on 2026-09-04, so there's now an automatic check that a workflow grants everything it calls, plus 15 new tests covering both shapes of the failure. Alert auto-closing moved onto the watchdog's hourly sweep after the old gate silently stopped working, and the suite stands at 53 suites, 1740 checks, 0 failed.
🎾 /pb games signs off once
The weekly games list was coming through with two Picklebot signatures and two jokes stacked at the bottom. Now /pb games shows a single footer, the way the other game listings already did.
🛠️ Behind the scenes
Retired a leftover monitoring workflow that duplicated a check already running every ten minutes, and made the alert-closing job skip itself unless an alert is actually open — both were quietly eating build minutes. Test suite still fully green.
📊 Your Stats now shows hours this month with a rank
Your stats DM leads with this month's hours and where you stand, like "12 hrs this month (#1 of 20)", ranked among players who've played this month. The year milestone line stays. Your rating line now reads SDUPR (the SMAD DUPR); if the fit hasn't rated you yet, you'll see the sheet DUPR, clearly labelled.
🏓 SMAD DUPR: your W-L record, and steadier ratings/pb compute smad dupr now shows your wins and losses instead of a bare match count, and the table still fits a phone. The k setting is now sticky — it's carried from the last refit and only re-measured every 50 matches or when you ask with /pb compute smad dupr rescale — so a night you sat out no longer nudges your number. The reply tells you whether k was carried or re-measured and why. A live dry run with k carried at 3.25 since 98 matches moved every rating +0.00.
🕸️ See the match graph behind the ratings
New /pb smad dupr graph shows what the log actually supports: which players are connected, which matches hold the picture together, pairs we can't yet compare, and a suggestion for the next match that would tell us the most. Anyone can ask — it only reads.
📈 Dashboard explains the sticky k
The SMAD DUPR dashboard (now version 9) says where k was set and when it re-measures, with a note on why a night you sat out used to move you and no longer does — Victorio's +0.08 on 9/17 was all k. The methodology section spells out that k was measured at 98 matches and re-measures at 148.
📋 Last Call is shorter
The Last Call message now sticks to attendance and balances and drops the Top 20 board. Game Day, /pb game next and the shame report still carry the board.
🙂 No joke on reminders
The game day reminder and Last Call no longer carry a joke — neither is a reply to something you typed. The signature and help hint still close every reminder, and the admin copy reads the same way.
🛠️ Behind the scenes
A failed connection check now warns first and only escalates after 15 minutes, so brief network timeouts stop paging. 50 suites, 1634 checks, 0 failed.
🛠️ Behind the scenes The hourly error sweep now runs on a schedule we control, firing at :23 past every hour instead of the old setup whose gaps stretched from 2h to 6.7h (about six runs a day) — so problems get noticed sooner, and a follow-up fix stopped the sweep from filing duplicate reports and made it tidy up stale ones on every run. Shared release-notes links now always show the latest version.
📊 SMAD DUPR dashboard, redrawn every time/pb compute smad dupr now rebuilds the dashboard page after a real refit, and the reply's last line tells you whether the redraw was requested. The page shows the Move column (how much your rating changed since your last refit, "new" the first time), a time series of every player's SMAD DUPR across refits, and a closing "Caveats, and what would make it better" section. The reply now ends on the SMAD DUPR Dashboard link with no signature footer. The first export covers 20 players, 1 refit and 88 matches.
🏓 /pb match understands "Jon" and full scores
"Gene and Shyam beat Jon and Vic five" was refused because "jon" matched Jonathan, who wasn't playing — now a name match among the players on the court wins over a club-wide name who isn't in the session, and if two people who played both fit, you get an error naming both. /pb match also accepts a full score on the end: "11 - 8", "11 to 8", "8-11" or "eleven to eight". The winner's score is still set by the league rules, so a typed 11-10 records as 12-10, and "John Wang 2 8" still reads the 2 as part of his name.
📋 Match confirmation lists every match again
The numbered list of matches logged so far is back in the /pb match reply, after a game got recorded twice 18 minutes apart and the duplicate was invisible. You get the count line, a blank, then the full list; the W-L table stays with /pb post game.
📈 Post-game tables now show SMAD DUPR
In /pb post game, the W-L Record column is headed SDPR and shows your roster SMAD DUPR to two decimals, blank until you have one. The numbered match lines show SMAD DUPR too ("Gabe 3.11 & Vijay 2.89 def. ..."), in the match confirmation and undo as well. The signature now sits directly under the W-L table instead of a line below it.
💡 Lights reminder asks for 9:30pm
After the 8:00 PM email asking for lights until 10pm went out and the lights still went off at 8:45 PM, the reminder now reads "Reminder to turn all court lights on until 9:30pm - Thanks Gene". Same recipient, same 8pm send on night games.
🎾 No more empty cancellation threat
Inside the 12-hour window the court can't be cancelled and we pay for it either way, so Game Day and Last Call no longer say it will be. The line now reads that we need more players to fill the court and it's within 12 hours. One-court and two-court shortfalls both changed; past games, full rosters and resting pairs read the same as before.
✨ SMAD DUPR table is easier to read
There's a blank line above the table and a rule under the header, measured by what's actually visible so it lines up.
🛠️ Behind the scenes
New and rewritten test suites cover the shortfall wording, name resolution, full scores, the dashboard and the table rule — 49 suites, 1524 checks, 0 failed. Documentation and the investigator's wake routine were also repaired after it stopped firing.
🏆 The daily report is now the Hall of Shame and Fame
The daily message leads with the signature and Daily Hall of Shame and Fame for MM/DD/YY, and ends on the SMAD DUPR Dashboard link one line under the board — no more redundant signature footer or trailing blank lines. Money only shows up when someone actually owes; a creditors-only table no longer nags the group. The Top 20 board closes the message, the Not Voted list is unchanged, and /pb shame now also answers to "hall of fame" and "hall of shame" aliases.
📊 Player tables: SDPR, new ranking, shorter names
The rating column is the fitted SMAD DUPR shown as SDPR to two decimals (blank until it's fitted), and /pb players, the Game Day board and the Hall of Shame and Fame all rank the same way: SMAD DUPR, then W-L, then Hrs. You can still sort with dupr, dpr or rating — old commands keep working. First names cap at 6 letters with proper nicknames (Vic, Jon, Rich, Stan, Spen, Dom, Will), roster digits stay glued to the initial (John W2 beside John W), and Pct prints without its dot, so tables fit 32 columns again on the live roster.
💸 Game Day and Last Call only mention money when somebody owes
The Bal/Cred block in the Game Day Reminder and the Last Call summary now appears only when a player carries a balance — the same rule the daily report got earlier that day. If only creditors are listed, the block and its congrats line stay out and the board still renders.
🥒 /pickleball works as a command prefix
Typing /pickleball match undo used to get an unrecognised-slash reply. All three prefixes — /pb, /picklebot and /pickleball — now work in any case, and the unrecognised reply names all three.
📬 Picklebot Upgrades digest left the SMAD Pickleball group
The daily upgrades digest was too noisy for the main group, so it now goes to Admin Dinkers and Gene's DM only. /pb commits digest still answers whoever asks for it.
🛠️ Behind the scenes
Venmo sync now reads Sheets through the shared cached accessor instead of building its own connection — the duplicate shape that OOM-killed Picklebot on 09/08. Plus a generated command registry, a no-duplicate-code ratchet test, and quieter alerting so routine restarts stop paging Gene.
🗳️ Your vote always lands, and under your real name
Two votes on Sunday morning hit a connection error before reaching the poll sheet. No votes were lost — every one went through on a fresh connection — and the webhook has been fixed so it can't get stuck on a stale connection again.
📇 Poll entries now use your full name
When someone votes who isn't in the roster yet, Picklebot now looks up the phone book contact name first and only falls back to your WhatsApp display name as a last resort. That's why one Sunday vote logged as "Dru" instead of Dru Huang — that row was corrected by hand, and new ones will come through right the first time.
🛠️ Behind the scenes
Failure alerts now name the actual error in their title and close themselves on the next clean run, so stale alerts don't pile up. The alert investigator pipeline is documented end to end, and the resident instructions file was slimmed from 1,271 lines to 169 with nothing deleted — just moved.
🤐 No jokes on top of admin replies
Picklebot was still tacking a joke onto a couple of admin answers, including /pb booking list and /pb shyam, because those two commands sat outside the no-joke rule. They're inside it now, and a check scans every admin command so a new one can't slip out again.
💰 Court finances, properly kept
The club's running costs are now split into what's genuinely variable month to month — Anthropic, GitHub, GREEN-API, Twilio, plus an estimate for GCP — instead of being lumped in as fixed, and Anthropic is charged 100% to pickleball. Member credits are now booked against the month of the game they reverse, so Shyam's 9/1 credit written on 8/30 counts in September, and dues accrue for the open month: August no longer reads as a big loss next to a flattering September. The Caltech Associates donation sits deliberately outside the P&L, so it doesn't tangle with the tax deduction.
📊 A readable Court Finance sheet and email
There's now a "2026 Court Finance" tab — one per year, formatted like an actual finance sheet: bold grey header, frozen Month column, dollars with negatives in red parentheses, and a bold year-to-date line under a double rule. The notes explain each column family in plain English, including Operating = Gross − Overhead and what a negative month means, and the same notes go out with the weekly consolidated email. That email's totals row also lines up with its columns again after a formatting bug lost its alignment.
🛠️ Behind the scenes
The 38 regression suites moved to tests/ and now run automatically on every change: 38 suites, 1,306 checks, 0 failed, 23.1% statement coverage, with tests and coverage tiles added to the Release Dashboard, which now defaults to dark.
🏓 SMAD DUPR: your own rating, fitted to our actual matches
Every player now has a SMAD DUPR in its own column next to DUPR, calculated from your win/loss record plus how strong your partners and opponents were. The first run wrote 20 ratings from 76 matches. Anyone with under 10 matches is marked provisional.
🔄 Refit the ladder when game night is done
Type /pb compute smad dupr (admin) once all the night's matches are entered and the ratings recalculate on the spot — no waiting for a daily job. The reply is a phone-width ladder showing each player's rating, how much they moved since the last refit, and a star for provisional players.
📈 Every refit is saved, so progress is visible
A new "SMAD DUPR Log" tab stores one row per player per refit — rating, previous, change, matches and who ran it — so your rating now has a history instead of just a current number. A refit that changed nothing isn't logged, and if the log write fails the ratings still land on the sheet and the reply tells you why.
✅ Match Recorded is now three tight lines
Recording a match replies with the header, the result, and the session count directly underneath — no blank lines, no "N matches logged" sentence, no signature. Batches still list every result first, then the session line.
🔌 /pb match no longer loses matches after a quiet stretch
When Picklebot sat idle between matches, a stale connection to the sheet made it read an empty roster and claim it couldn't find your names. Now a connection idle more than 60 seconds is refreshed before use, and if the roster genuinely can't be read you get "Nothing recorded: the roster read from the sheet failed", the actual error text, and a nudge to send the same line again — never a silent miss. The two matches lost that night were written back in at the times they were first sent.
🗣️ /baby and /bb now work
Courtside dictation keeps turning /pb into "/Baby" and "/BB", and both were being refused. They're now accepted aliases, so "/Baby Gabe and Vijay beat..." records like normal.
👥 Vacationing players are back on the Top N board
Only archived players are filtered out now, so the Top N board and /pb players list exactly the same people in the same order again, and the "#N of M" rank in your DM counts the full group. The survey availability grid is unchanged — it still marks vacationers rather than hiding them.
🛠️ Behind the scenes
Picklebot's logs are now structured, so an error actually triggers the alert chain instead of vanishing — that night's failure had raised nothing. Plus new tests across the ratings, the match reply shape, the idle-connection fix and the aliases.
🚫 /pb cancel game won't cancel a game that already happened
If you point /pb cancel game at a date in the past, it now stops you right away instead of running the cancellation. Before, a past game would quietly go through the whole process and spit out six "not found" lines, with nothing useful reaching the Court Log or the players.
🛠️ Behind the scenes
A new test guards this, failing the build if a past game ever gets dispatched again.
🩹 /pb match stopped running the bot out of memory
Recording a match was building the connection to the scoresheet ten times over and could kill Picklebot mid-request at 992 MiB. It now builds it once and reuses it, so the same work peaks at 68 MiB and /pb match stays up.
🧾 Cleaner match confirmations
When you record a score, the reply is now just the line you wrote plus the running "N matches so far" count — no more W-L table. The full W-L Record for Today belongs to /pb post game, and undoing a match still shows the remaining list.
📊 Tidier W-L and standings tables
Every record in a table now pads to the same width, so a night of 3-1 and 0-1 lines up just like a Top N with a 12-4 in it. Column headers shift so the dash in the header sits right over the dash in the records, under any sort and any name length.
💸 Payments find the right player
Venmo and Zelle now match payers through the same roster matcher the survey uses, so a profile with the names swapped, a nickname, or a unique last name lands on the right person instead of being missed on one side and found on the other. Venmo also accepts a whole number of session fees — James Ji's $20 for two and a $50 monthly pre-pay now count — and a one-word Zelle name no longer gets handed to whoever happens to share that first name. Payment runs can also reach Google properly now, so thank-you DMs, the Admin Dinkers notice and the Zelle sheet read all work (and the daily joke comes from the real jokes sheet again, not the fallback).
⚡ /pb payment sync on demand
Admins can run the Venmo + Zelle sweep right away with /pb payment sync (also venmo sync, zelle sync, payment sweep), and the counts come back in the chat whatever the outcome.
🙋 /pb members and /pb players no longer collide/pb members sync used to be read as a players list sorted by "sync" and refused with a sort error. Member services and stats are now separate: players sync and sync players belong to syncing, and bare /pb members gets the normal unknown-command reply.
🗓️ Join Date, Referrer and Join Source on the Player sheet
Every member row now carries when they joined, who referred them and how they found us, backfilled from welcome messages, first votes and the group's own join events — 142 cells filled, 59 join dates corrected from the chat archive and 3 referrers recovered. Where someone's first game predates their join date, the earlier date wins, so the three founding members read 7/10/2025.
🛠️ Behind the scenes
The group's first year of chat was archived from a phone export (5,346 records) into a private bucket, and court finance gained a full reconciliation and P&L with weekly and monthly reports to Gene — including excluding golf, happy hours and non-SMAD courts from the court numbers. Court booking also stopped republishing the dashboard on nights with nothing to book.
🎾 /pb match is harder to fool and quicker to read The bot now refuses the same match logged twice in a row — two scorekeepers logged one 11-1 43 seconds apart — and naming the same person on both sides of a line ("James and James") is rejected too. If a first name is ambiguous and nobody by that name voted, you'll hear "David Lin and David Sohn are not playing tonight — did someone forget to vote?" The confirmation now shows the line you recorded, the match count and the session W-L table instead of relisting every match, so the twentieth match of the night isn't a screen-long reply.
📊 Player tables: Pct, Top 20, and smarter names Every player table gains a Pct column right after W-L (1.000 for unbeaten, blank with no matches), the Top N board is now Top 20, and /pb players sorts by W-L, then hours, then DUPR by default like the leaderboard does. Names are only shortened to "First L." when they truly don't fit — on the live roster 13 of 45 names were shortened and every 11-character name stayed whole. W-L columns are zero-padded to a fixed width so the columns line up.
💬 Commands that used to vanish now answer A /pb command sent as a reply to another message is now read — one /pb payment transfer reply was dropped entirely. /phoebe and /pv, what iOS dictation makes of "/pb", are accepted, and any other slash command gets a reply instead of silence. /pb post game now matches any spelling with report or summary (plus game summary and match summary) and is listed in /pb help.
🏖️ Admins can set vacation for someone else /pb set vacation @player <date> and /pb set vacation <name> <date> both work now, which silences that player's vote and survey. Admin DMs also work correctly from the bot's phone, so the command no longer gets refused in a member's DM.
🗓️ Cancelling a game, and actually telling people /pb cancel game 9/9/26 now works with just a date when there's only one game on it; two games ask for the time and name both. Separately, the cancellation DMs to voters had been failing silently since 08/21 — that's fixed, and the summary now names anyone who couldn't be reached instead of saying "nobody voted yes."
🏟️ /pb courts list and your DUPR on every match /pb courts list answers instantly with the courts we hold from today on, one line per slot, noting when a court is on someone else's account. Match rows now carry each player's DUPR, with 46 older rows filled in from history, and the post-game report shows the rating of the night.
🛠️ Behind the scenes Court billing with the Athenaeum is now reconciled month by month against our own Court Log, weigh-ins and all logs are stamped in Pacific time, admin-only replies drop the joke, and admins can pull the upgrades digest with /pb commits digest.
🙋 Tag any player, any command
Typing @Toby Hsieh used to work in some places and fail in others — /pb archive player @Toby Hsieh came back with 'Player "@19175282626" not found'. There is now one way names are read across the bot, so /pb archive player, remind vote, remind payment, set DUPR and record payment all understand the same @mention, @me and plain names.
🎾 Cancelling a court tells you the truth
When a handover cancellation went through but the bot couldn't confirm it, it reported "failed" — so you'd think you still had a court when you didn't. The bot now reloads the reservations page after cancelling and sends a separate "couldn't verify" message, which is different from an actual failure.
📝 The survey knows you by the name you use
If you took the survey as "Alex Laussu" but the roster says Alexandre, the bot now matches you by last name and adopts the first name you typed. It also stops flagging a DUPR as changed when "3" and "3.0" are the same number.
📣 Court reports say who asked
Cancellation notices and results used to show a raw chat id. They now show the player's name instead.
📖 "Court Booking" in the help menu
The /pb help section called "Court Handover" is now "Court Booking" — only /pb shyam was a handover, the other three commands are the booking list. The README matches.
🔕 No more Last Call Summary
The Last Call Summary sent to Admin Dinkers is gone, both after DMs and in the "everyone has voted" case.
🛠️ Behind the scenes
Lifted the google-api-core version cap now that 2.36.0 handles the Firestore database id correctly, added a standing rule that every session reads times in Pacific Time from the message itself, and documented the shared player resolver in the README.
📬 The daily "What Shipped" digest
Picklebot now posts a plain-language list of every change made the day before, titled like "What Shipped on Wed Sept 2 2026", sent at 8am by Cloud Scheduler. The first version cut off at 15 items and said "and 13 more" — now the whole day's list goes out. It briefly went to the SMAD Pickleball group; that's been dropped, so the digest stays in Admin Dinkers and Gene's DM and doesn't fill up the club chat.
📶 Fewer silent outages, and no more false alarms
Picklebot's WhatsApp connection is now checked every 10 minutes from the keepalive, instead of waiting for a notification that may never arrive — so if the bot goes offline, it gets noticed and fixed sooner. The old alarm treated every state except "authorized" as broken, including the normal starting and sleepMode states that clear on their own within 5 minutes. Those no longer page anyone, while a genuinely unlinked instance still does, and an unknown state now says plainly that it doesn't know.
🛠️ Behind the scenes
Deploy and metrics jobs no longer report a failure as a success, the "what changed" hook stopped attributing our own work to someone else, and scheduled jobs are moving off GitHub cron (which is UTC-only and shifts an hour twice a year) onto Cloud Scheduler for about $0.10/month by folding them into jobs that already run often enough. Also wrote up an evaluation of the official WhatsApp Business API: it's blocked for now because official groups cap at 8 participants with no way to add members, so the group chat you use would not work there.
🏓 Court booking knows when Shyam can't book
Shyam can only book a free court on his own account 48 or more hours ahead, so the bot no longer hands a booking off to him automatically when that window has already closed. It checks this before the cancel cutoff, since 48 hours is the binding limit against 12. Typing /pb shyam <date> by hand still works exactly as before, for the times you know something the bot doesn't.
🛠️ Behind the scenes
A pile of plumbing on the development side: the daily change digest now sends once a day instead of once per push (with a link and commit subjects capped at 15), the health watcher stopped alarming while everything was healthy, push access and permission prompts were untangled after an outage, and the developer's Commits per Week chart stopped drawing one bar fatter than the rest. New tests cover the court rules with 16 cases.
💸 New: /pb payment transfer, when one member pays for another
When Roger covers Alexandre at the court, one command now records it: a debit for the payer and a credit for the player, sharing one Transaction ID. Names work the same way as in /pb match, the reply is in plain members' language, and both people get a DM — the payer because they're out of pocket, the other because their balance moved. A payer's debit no longer moves their Last Paid date, since paying for someone else isn't a payment of their own dues.
📈 DUPR ratings now keep a history
A new DUPR Log tab records every rating change: when, who, old, new and status — including ratings that were set by command, ratings ratcheted up by the survey sync, and survey values that were declined. Ratings always show one decimal, so 4.0 no longer prints as "4". Players without a rating read "no score".
🏆 /pb players sorts the way you ask, and so does the Top 15/pb players [dupr] [w-l] [hrs] takes up to 3 criteria in priority order, and the stat columns reorder to match what you sorted on, so the first column is always the one being ranked. An unknown or repeated criterion is refused with the reason instead of being quietly ignored. The Top 15 board is now ranked by W-L, then hours, then DUPR — so unrated players are no longer pushed down, and anyone at 0-0 sits below every real record.
📵 Stats rows stop turning into phone numbers
WhatsApp was linkifying runs of digits in the stats tables and making rows look like phone numbers. An invisible separator between the stat columns breaks the run, with no change to column widths or spacing.
🗂️ Archived players off the roster listing/pb players now leaves archived members out, matching the Top N board, which has always excluded them. Players away on vacation still appear — they're members who are away. An all-archived list says so rather than showing an empty table. Archived players still get their payment reminders.
📴 WhatsApp outage on 09/01, and a watcher so it can't go unnoticed
Every /pb command and every outgoing message stopped at 18:01:10 PT when WhatsApp suspended the account; service came back at 00:05:20, an outage of 364 minutes. Nothing announced it — Gene found it by typing a command. A new check now asks the vendor every 15 minutes and raises an alert on anything other than a healthy state, including the suspension expiry time, so the wait is known instead of guessed.
📰 Release notes paused on WhatsApp, dashboard unchanged
Release notes are the leading suspect for the message volume behind the suspension: one WhatsApp message per push, with 30 runs on 08-31 and 57 on 09-01, of which 34 were notes-only commits. Sending to the group is paused behind a setting while that's sorted. The dashboard still gets every release note.
🛠️ Behind the scenes
Court-booking and CI failures now actually reach someone with the relevant log tail attached instead of "check the logs", release notes go through the normal sender so messages are counted, and payment and sorting changes are covered by new mutation-tested suites.
🎾 Courts are counted, not guessed
When Shyam hands his North booking back to his own club account, the bot used to only see one court — so a 6-player game got told "We have 6 players for 1 court, excellent!" and stopped recruiting while two spots sat open. Courts are now actually counted (including "North & South"), and every "we need N more players" line comes from that same count, so the header and the shortfall can't disagree. The 8:00 PM Last Call on 08/31 that read "Court: South" for a two-court game is the exact case this fixes.
📒 A ledger of every court we hold
There's a new Court Log that records each court-hour, written the moment we book it (with the hours we actually booked) and marked cancelled the moment /pb court cancel or /pb cancel game gives one back. /pb shyam now writes its handover row before anything else, so a hiccup mid-command can never hand the same court back twice. Game reports read courts from this ledger before trusting the poll text, so a court added or dropped after the poll went up is reflected.
🏷️ You can see whose court it is
A handed-over court is now labelled, so "Courts: North + South" doesn't hide that one of them sits on Shyam's booking — one we can't cancel or move ourselves.
🔄 Court info is actually live
The court fetch was quietly dying every scheduled run because chromium was never installed, so reminders went out showing courts as of poll creation and looked perfectly healthy. That's fixed, failures now say so loudly, and court figures carry how old they are instead of a three-day-old answer passing as fresh.
🚫 No more requests the club will refuse
The Athenaeum's booking horizon and cancellation cutoff are now known to the bot: /pb shyam won't be offered past the cutoff and /pb court cancel refuses inside the window and tells you what to do instead, rather than firing off something that gets rejected. /pb cancel game still goes through, since it also DMs players.
✅ Booking reports tell the truth
Both courts for Tue 09/08 7pm were booked and confirmed, and the run still reported a loss — the north and south jobs each try both courts, so one always "loses" the other. Confirmed bookings are no longer reported as lost, and a real loss still shows.
🗓️ /pb booking list
Show, add to and remove from the weekly booking schedule. Times are normalised (7pm, 7:00 PM and 19:00 all work), duplicates and no-op removals are refused, and the list always reads Monday-first with earlier times first. Also: the Game Day report no longer has a doubled blank line after the balance table, which tidies /pb show games too.
🛠️ Behind the scenes
Documentation corrections, safety guards on pushes and deploys, and a lot of work on the developer session tooling and its test suite.
🎾 Court handover with Shyam, now hands-free
When Shyam votes in the weekly poll, Picklebot now runs /pb shyam MM/DD/YY for him on every game he's voted for, and the confirmation lands in his DM. The credit is worked out from the hourly rate times the 2-hour session length instead of a fixed amount, so it stays right if the rate changes. Three separate guards make sure a vote, a re-vote or a repeat weeks later can never double up a payment.
🔔 Court bookings and cancellations are being announced again
For 4 days, court notifications were silently dropped and nobody was told — that's fixed and verified. Cancellations and bookings now go to both the person who asked and Admin Dinkers, with each send independent so one failure can't swallow the other. Cancellation replies also go back to whoever ran the command, instead of nowhere. The joke in the game cancellation reply is gone.
🙋 /pb shyam from a DM
Admins can now run /pb shyam by DM, not just Shyam himself, and a crash that made the command fail from a DM is fixed — nothing was ever charged by that bug. The command is now listed in the admin help and documented in the READMEs.
👋 A welcome message that tells new members the truth
The welcome text now comes from the WhatsApp Group Info description that all 45 members actually read, so there's one master copy instead of three that had drifted apart. The game times are gone — two of the three were wrong, and the weekly poll already carries that week's real times. The poll day is now named in one place, so it can't go stale again.
📈 DUPR ratings only go up when they should
Rating sync now moves in one direction only, so a stale read can't walk your SMAD rating backwards. Score pushes are now treated as a publish rather than a save, so a batch of results actually goes out.
🛠️ Behind the scenes
Every deploy is now smoke-tested instead of trusted, failed deploys actually fail, and production logs can be pulled from CI for diagnosis. Plus a long run of timezone fixes (everything is stated in Pacific now), developer safety hooks, and test suites that catch the bugs the old ones structurally couldn't.
🔔 No more vote nagging once the week is done
The daily vote reminder now checks whether there are any games left this week. Once the last game has begun, the reminder stops and non-voters are left off the shame list — no reason to chase a vote for a game you can no longer play.
📋 The Daily Nags Shame Report
The report now reads "Daily Nags Shame Report" with the date, so it's clear what it is when it lands.
😐 /pb match gets straight to the point
Asking for a match no longer tacks a joke onto the answer. Everything else, including /pb match undo and the post-game report, still comes with a joke.
💸 Payments find their way in on their own
There's a new /pb sync-zelle command, plus a payment sweep that now runs daily before the reminder job, so Zelle and Venmo payments get picked up without anyone asking. The bot also retries when Google Sheets hiccups mid-request instead of giving up. One such hiccup happened on 08/30 — nothing was lost, and the sweep and retries are there so the next one is handled quietly.
🛠️ Behind the scenes
The developer dashboard got a lot of attention: it no longer slides sideways on a phone, the commit log collapsed from 321 phone screens to 26 with a month index, and deploy timing is now measured (p90 of 108s across 324 runs) and charted instead of hardcoded. Also cleaned stray files out of the project root and tightened how commits are categorised and logged.
🎾 Scores are easier to record
You can now dictate a match and Picklebot will understand it — spelled-out scores like "eleven nine" work, and scores on plain typed lines are read the same way as lines with @-mentions. "best", "b" and "w" now count as separators alongside "beat" and "def". And when a line can't be read, you get the real reason instead of a misleading one.
📋 "Record for this session" now shows DUPR
The session table gained a DPR column pulled from the roster, so you can see ratings next to the results. It still fits at 24 columns on a phone, and if the roster can't be read you just lose the column, not the table.
↩️ /pb match undo tells you where you stand
Undo now has a proper header and lists the matches still recorded for the session, so the scorekeeper can confirm the right one was removed. An empty session says "no matches recorded for" rather than showing nothing, and a dry run is clearly marked "(DRY RUN)".
⭐ Admins can set someone else's rating
New /pb set dupr @player <rating> for admins, while setting your own rating works exactly as before. Ratings must be between 2.0 and 6.0 in 0.5 steps, and set dupr @someone with no number now tells you the rating is missing. It's listed in the admin help only.
🗳️ Clearer poll nudge
The vote reminder header now reads "Not Voted in This Week's Poll".
📱 Pocketed phones are starting group video calls
On 8/28 a pocketed phone started a WhatsApp group video call during a game, with three members joining — the same pattern as the 8/25 incident. If WhatsApp is open when you pocket your phone, close it or lock the screen first.
🛠️ Behind the scenes
Timestamps you see now say "PT" instead of a hardcoded PST label that was wrong half the year, release notes no longer tack on a joke, and set-dupr is available from the command line with the same rating rules.
💵 Balances that line up
The dollar figures in the balance block are now aligned, so you can scan who owes what down a single column instead of hunting across ragged lines.
😳 A tidier shame report
Money and voting are now separate sections of the shame report. Missing a payment and missing the poll are different sins, and now they read that way.
📬 Reminder emails you can actually file
Reminder email subjects now carry the date, and duplicates are collapsed behind them. Your inbox gets one clearly dated thread instead of a pile of identical-looking nudges.
📶 Messages going out again
A missing piece in the message sender had been breaking delivery, so some Picklebot messages never reached the group. That's fixed, and deploys are now blocked if a missing dependency like that ever sneaks in again.
🛠️ Behind the scenes
Every corner of the bot now reads the clock from one shared Pacific timezone setting instead of 13 files doing their own thing, and GCP errors are swept hourly so problems get caught even when nobody's watching. The developer dashboard also picked up outage tracking with mean-time-to-restore and change-failure-rate tiles, a 5-per-row layout, and a build stamp.
🔔 The bot now tells us the moment it goes offline
If Picklebot's WhatsApp connection drops, an email alert now fires right away instead of the group finding out the hard way. On 8/26 the bot was offline for six minutes and we only noticed by chance — that was the last time. The alert goes by email on purpose: when the WhatsApp session is the thing that's broken, a WhatsApp message would never arrive.
🔒 Tighter security on the bot's WhatsApp account
Two-step verification is now switched on for the number Picklebot runs on, with a recovery email registered, so nobody can re-register it. Everything kept working through the change — the bot's session stayed connected and a test message went out fine.
🛠️ Behind the scenes
We closed out the investigation into the 8/25 group changes, ruling out one possibility at a time until only one explanation remained, and logged a support request with our WhatsApp provider for their session logs. We also wrote up how to audit and restore the bot's linked devices, so the next hiccup takes minutes instead of days.
🎾 Court bookings are now verified, not assumed
Picklebot used to report a booking as done the moment it submitted the request. Now it reloads the reservations grid afterward and checks the court is actually there, and the confirmation email says SUCCESS only for a confirmed booking — otherwise SUBMITTED. Dry runs are flagged as such, and the verification step is time-bounded so it can never swallow the report.
📖 New /pb post game report
You can now pull up the post-game story on demand with /pb post game. The same renderer powers the match confirmation, so the two always tell the same tale.
✅ /pb match now shows the session's matches so far
When you record a score, the confirmation lists the running match list for that session, so you can see at a glance what's been logged and catch a mistyped score right away.
🗓️ Game day info in one place/pb game next now mirrors the Game Day report, so you get the same details whichever you ask for. The list of who hasn't voted yet moved into the Game Day report, and /pb game remind is new for nudging them.
📋 Tidier rosters and standings
The game roster now uses the same player table as everywhere else, so columns line up consistently. The trailing period after the rank is gone, which keeps /pb players from wrapping on narrow phone screens.
🛠️ Behind the scenes
Deploy and Terraform failures now open GitHub issues with the actual exception line, error reporting can be listed and resolved from CI, and the diagnose tool gained free-text and multi-term log search with configurable sweeps (25h, 48h, 7-day). Also added a roster-width check that measures the Top N leaderboard and no longer passes when it reads zero players, plus some file cleanup and docs.
🗳️ Poll and reminder nudges
If the poll goes out today, you won't also get a vote reminder for it — no more double nudges. Shame-list ties are now broken by last name, so the order is stable instead of random. Weekly poll creation now runs as part of the one daily job, so it fires on a single reliable schedule.
🤖 /pb is back to answering properly
The AI model behind /pb intent reading had been retired, and a bad library version was breaking its Firestore lookups — both are fixed, so asking /pb things works again. Every /pb command now gets written to an audit trail after your reply is sent, so logging never slows your answer down.
💸 Payment thank-yous for everyone
Thank-you emails for Venmo and Zelle payments were only going out to players who had opted into SMS. That gate is gone, so you get your confirmation either way.
📋 Cleaner Last Call report
Tightened the spacing to a single blank line before Group Stats, so the Last Call report reads cleanly in WhatsApp.
🔔 Game day reminders no longer vanish quietly
If a game day reminder gets skipped — nobody marked y/2 for the date, or a marked name doesn't match the roster — it now gets reported instead of silently doing nothing, so a missing reminder gets noticed and fixed.
🛠️ Behind the scenes
Plenty of plumbing: logging that was being silently thrown away in all four functions now works, keepalive checks fail loudly instead of pretending the bot is warm, WhatsApp send failures record the full response, and usage and log diagnostics plus a scheduler audit were added. We also locked dependencies across all the Cloud Functions and CLI, cleaned out dead Twilio secrets, inventoried the rest, and fixed Terraform timeouts and plan failures hiding themselves.
📱 The Sunday poll post now reads clean on your phone
The upcoming-games report that goes out with the weekly poll is titled "Week of MM/DD/YYYY Upcoming Games" so you can see at a glance which week it covers. The per-game rosters and the "we need N more players" warning are gone from that post — right after the poll goes up the only voter is whoever created it, so those lines said nothing. "This wk" now counts the poll's week instead of the week that already finished, so the attendance number matches the games listed above it.
📊 Leaderboard and stats titles fit one line
The player leaderboard no longer wraps awkwardly on mobile, and the group stats headers were shortened to "Top 15: Hours Played Since 6/25" and "Player Bal/Cred and Nags" so each sits on a single line. The vote shame title lost its trailing colon too.
📌 A nudge to pin the poll
When the weekly poll posts, Gene now gets a DM with the poll question and its options as a reminder to pin it to the top of the group. Vote reminders, last call and the shame report all tell you the poll is pinned, so a missed pin made three messages wrong. WhatsApp offers no way for the bot to pin on its own, so the reminder arrives at the one moment it can be acted on — and if the DM fails, the poll still goes out.
🗂️ Archiving a player actually removes them from the group
Archive removals had been silently failing: the bot was asking WhatsApp to remove the player without the credentials to do it. Those are now in place, and the bot confirms removal by checking the live participant list rather than trusting a "success" reply, re-checking once after 2 seconds to allow for WhatsApp lag.
🛠️ Behind the scenes
The weekly attendance count had been copy-pasted into three files and is now one shared piece of code, and missing credentials log a clear error instead of an unhelpful failure.
🏓 Match scores are here — /pb match now takes an optional score, and you only type the losing score: the winner's number comes from league rules (to 11, win by 2 from 10-10, hard cap 15). A losing score above 14 gets rejected, since 15 is the cap. You can also use @me for yourself (WhatsApp won't let you @-mention yourself), and typed names work too, including multi-word ones like @Jerry Shen, so mentions, @me and typed names all mix on one line.
📅 Matches are logged under the game's own date — The log now stores the date and time the game was actually played, not the moment the score was typed. Match IDs are anchored to the game as well, so 20260821-8pm-1 is the first match of the 8/21 8pm game. Scores like 11-9 and times like 8pm no longer get mangled into dates and clock values.
📊 Win-loss on the player list and leaderboard — /pb players and the Group Stats leaderboard now share one layout and both show a W-L column next to hours and DUPR, with a blank record showing as 0. The balance column is gone from both, which narrows /pb players from 43 to 37 characters so it stops wrapping on phones — balances are still in /pb balances and the group stats balance block. Sorting stays on hours played, as there isn't enough match data yet to rank on win-loss.
😳 Daily shame report — New /pb shame shows who owes money and who hasn't voted in this week's poll, and it also posts to the group from the 8am daily run, right after the Venmo sync and payment reminders so balances and nag counts are same-day fresh. Nobody gets shamed on the day the poll is created, so you always have a chance to vote first — you can still pull it up on demand any day. The balance table now has Name, Bal/Cred and Pay Nags headers, and non-voters are listed by how many vote nags they've collected.
🎉 A nicer note when everyone's square — If there are credits but nobody owes, the report says so under the Player Balance/Credit header instead of reading like a shame list, and the existing all-square message still covers the case where no one has any balance at all. The balance and vote lists now sit together as one block, the header carries the report date as Daily Shame Report for MM/DD/YY in PST, and the "want to show up" typo is fixed.
🛠️ Behind the scenes — Shared renderers for the player table and balance block so the same numbers show up the same way everywhere, plus README and docs updates.
🏆 Match results are now tracked
You can log doubles matches straight in chat by @-mentioning the four players, one match per line, so a whole session goes in with a single message. Only doubles count — exactly two players a side — and everyone in a match must have voted to play that session, which keeps the log in step with payments. Your own record shows in /pb stats my and everyone else's in /pb standings; /pb record is gone, and record/records now take you to the standings.
❌ Canceling a game tells the people who were playing/pb cancel game next cancels the earliest upcoming game from the current poll, and if there's nothing upcoming it says so plainly instead of choking. Every cancel now DMs and emails each player who voted yes, with the game time and a note that the courts were released, sent before the vote column disappears so nobody gets missed.
🔔 Reminders now tell you how many you've had
Payment reminder DMs count your nudges and suggest pre-paying $50/mo, and vote reminder DMs say how many vote reminders you've received. Your Stats block shows both Vote Reminders and Payment Reminders after your DUPR, with the pre-pay hint when you owe. The vacation example now reads /pb set vacation MM/DD/YY with your return date.
🥇 Leaderboard back to top 15
Group stats list 15 players instead of 20, with the header updated to match.
😂 Better pickleball jokes
Social links shared in the group chat are now collected daily into the jokes sheet, with duplicates of the same reel merged and @-mentions turned into first names so phone numbers are never stored. A deeper scan back through June added 14 more jokes, bringing the sheet to 150, and non-jokes like photo albums and group invites are filtered out.
⚖️ Health log reads cleanly
Entries now show as 08/21/2026: 195.4 lbs | 226h | 3.5, and a credentials fix means the log no longer comes back empty when it actually has entries.
🛠️ Behind the scenes
The Release Dashboard got codebase and velocity stats, commits-per-day and time-of-day charts, and emails and phone numbers are stripped from it automatically. Deploys got faster — about 73 seconds, down from 90.
🏆 Top 20 leaderboard, now with money and attendance — /pb games grew from a top-10 list into a full report: Top 20 — Hours Played Since June 2025 with DUPR ratings, then weekly attendance, then a balances and credits section with neatly aligned columns and reminder nag counts. The report now also gets posted to the SMAD group after a poll is created and with the daily reminders, so everyone sees the same numbers.
📊 Your stats in DMs, the group's stats in the group — DMs now show your own personal stats block, including your playing streak read from your full game history, while group messages show the group picture. The pointless "Played this week!" line for a streak of 1 is gone, and every message ends with a standard signature, a hint on how to get help, and a joke.
🎾 Court numbers on your games — /pb games, your next game and every game display now show which court you're on. Court info is fetched live from Athenaeum for all courts and refreshed on demand, so you're not looking at a stale booking from an old poll.
🌤️ Humidity in the forecast — Game reports and /pb weather now include humidity %, on its own line so the forecast stays easy to read.
💸 Venmo payments match more reliably — For standard payments, we now match on a unique last name as a fallback, so fewer payments get stranded as unmatched. Thank-you messages also show the correct PST/PDT label instead of a hardcoded one.
🧹 Tidier Last Call — The "Already voted" list was dropped from the Last Call group summary, keeping it to what you actually need to see before the deadline.
🛠️ Behind the scenes — Fixed the daily welcome-back DM for returning players (missing credentials, plus it now only sends after a vacation is actually cleared), replaced hardcoded "2h before" references with the real game-time constant, and worked through some Venmo timestamp and email auth logging, docs and code cleanup.
🎾 New: /pb shyam
One command now cancels the North court and credits $10 back — no more sorting it out by hand in the chat.
🏖️ Vacation bookings land on the right day
Fixed a bug where booking a court while a player was on vacation could end up with the wrong date. Your booking now holds the day you actually picked.
🛠️ Behind the scenes
Added a backup scheduled trigger so the member sync keeps running even if the usual runner isn't available.
🎾 Court info straight from the source
Game-day and last-call messages now pull the court listing live from the Athenaeum, so what you see is what's actually booked.
📋 Weekly poll is simpler
The "Want to play but not these times" option is gone from the poll — it wasn't being used. One less thing to scroll past when you vote.
🏖️ Vacation mode handles your balance
When you confirm vacation, if you still owe money, Picklebot now shows your balance and a payment link right there so you can settle up before you go. The exemption message no longer mentions payment separately, since it's now handled in the confirmation.
🔔 Reminders in a smarter order
Player reminders now lead with payment reminders, so what you owe is the first thing you see instead of buried further down.
🎾 "Need more players" now says how many
When we're short for the week, the nudge is personalized to how many courts we have booked, so you know exactly how many more players we need instead of a generic call for bodies.
⏰ Last call moved to 1 hour before game time
The pre-game last call used to go out 2 hours ahead; it now lands 1 hour before we play. That keeps the final heads-up closer to the actual decision moment.
🌴 Vacation mode for the group
While Gene is away, Picklebot will sit quiet: no weekly poll, no reminders, and no court booking. Nothing to do on your end — normal service picks back up after the break.
🔔 Last call only pings people who haven't voted
If you already responded to this week's poll, last call will leave you alone — a blank game cell for a poll responder now counts as an implicit "no". Only players who haven't voted since the poll went up (or have never voted) get the nudge. Vote reminders and last call now read your voting history the same way, so the two can't disagree.
🆕 A correct free first week for new players
New members now start with their join date on record instead of next Sunday, so your first week is free no matter which day the poll was created.
🎾 Courts shown where you need them
Last call and the game-day report now show which courts are booked. Poll options and the sheet's column headers carry the court name too, so you can see where you're signing up to play — now without the clunky angle brackets around the court name.
🛠️ Behind the scenes — Switched the Claude Code plan from Max ($100/mo) to Pro ($20/mo) to keep running costs down. Nothing changes for you in the group.
🏝️ Vacation can now run as long as you need
Setting a vacation no longer caps your return date at one month out. Tell Picklebot any future date and it's used exactly as you gave it — the old "max length" warning is gone from the help and status text too.
🛠️ Behind the scenes
New players added to the roster now start with their Last Voted date set to the coming Sunday.
👤 Your name, spelled out properly
When WhatsApp only hands us your initials, Picklebot now checks Google Contacts to find your full name instead. So you should see real names on the poll, the Game Plan and the score sheets rather than mystery letters.
🛠️ Behind the scenes
The member sync job now carries the SMAD hourly court rate in its own settings, so cost-related numbers stay consistent.
🔔 Vote reminders now go to the right people
A wiring problem was sending reminders based on the wrong poll date, so some of you got nudged when you'd already voted. Poll creation dates are now read correctly, including from the Poll Log Archive for older polls, and "last voted" is compared by date only so a vote earlier the same day counts. Odd entries in the Poll Log no longer throw the reminder off either.
🛠️ Behind the scenes
We added error alerts for the things that quietly break a player's day — a failed WhatsApp send, a Venmo or Zelle payment not recorded, a missing payment thank-you, or a failed poll date lookup — so we hear about them instead of you. The webhook that receives your messages also got more memory, from 256MB to 512MB.
🎾 Court names are back to normal
We briefly had the North and South courts mixed up — that's been put back the right way, so North is court 3 and South is court 4 again. Bookings and reminders will now point you to the court you actually expect.
🛠️ Behind the scenes — Picklebot's underlying libraries (Playwright, python-dotenv, pytz, the google-* packages, venmo-api, matplotlib and the webhook requirements except cloudevents) are now unpinned so they always pull the latest versions. Nothing changes in how you use the bot.
🎾 Court bookings land on the right court
North and South were swapped behind the scenes, so a booking could show up on the wrong court. The IDs are now correct — North is court 4, South is court 3 — so what you see in the booking is the court you'll play on.
🛠️ Behind the scenes
Picklebot's WhatsApp messaging now runs through one shared internal piece instead of an outside library, and some stray debug files were cleaned out of the project.
💸 Zelle payments from business accounts now match up
Picklebot reads the Zelle column names a little more flexibly, so payments sent from business accounts get matched to your name instead of sitting unrecognized. If you've paid from a business account before, your dues should now land where they belong.
🌍 International numbers now work
If your WhatsApp number isn't a US one, Picklebot now handles it properly — no more mix-ups when it DMs you.
📵 No more text messages
SMS is gone. From now on you'll hear from Picklebot by WhatsApp DM and email only, so everything lands in one place instead of being split across your texts.
🎾 Court booking got smarter
Picklebot can now book the West and East Tennis Courts, so there are more slots in play each week. The backup booking job now skips single-court entries, which stops us from accidentally double-booking the same court. And when a slot is already taken or the date doesn't match, the bot closes the pop-up and carries on instead of giving up on the rest of the bookings.
🛠️ Behind the scenes
Updated python-dotenv (security fix) and the browser automation tool Playwright that drives the court bookings.
💳 Venmo payments keep getting picked up
The mailbox connection that spots your Venmo payments now refreshes its access on both sides at once, so the payment checker on Cloud Run stops going quiet after a token renewal. Fewer moments where you paid and the bot hadn't noticed yet.
🎾 No pointless backup booking runs
The court booking backup job now skips itself entirely when there's no |Both entry in the booking list, so it only wakes up when there's actually a second court to chase.
🛠️ Behind the scenes
Documented how the Gmail token stays in sync across both secret stores and gave CI the write access it needs to do it.
🎾 Court names Picklebot understands
You can now say "south court" or "north court" and Picklebot will know exactly which court you mean, instead of not recognizing the name.
⏰ Game day reminders reach everyone
If you voted on game day itself, the reminder could skip you. Now everyone who's in gets the reminder.
🙋 Clearer opt-out in vote reminders
The vote reminder now shows you the exact command to use — /pb set vacation — so you can mute the nudges without guessing the wording.
🔁 Fewer missed polls and bookings
The daily runner, the Sunday poll and court booking now retry automatically if something hiccups, so the weekly poll and your court are less likely to be held up.
🛠️ Behind the scenes
A new watchdog keeps an eye on all 5 automated workflows and alerts Gene by DM and email if one fails, and the monitoring and sync-members schedule are now documented.
- On Sundays, last-call was reading only the current week, so it never saw the Monday games that had just been created, and nobody got a reminder.
📣 Sunday last call fixes
The last-call reminder was only looking at the current week, so on Sundays it missed the Monday games that had just been created and nobody got a nudge. It now checks both weeks, so you'll get your reminder to sign up for the upcoming games.
👥 The roster keeps itself up to date
Picklebot now checks the WhatsApp group every morning at 8am PT and updates the player list automatically. If someone leaves the group, they're archived; if they come back, they're restored. Names are also looked up through Contacts, so new members show up properly instead of as a bare phone number.
📋 Survey answers come along for the ride
The daily member sync now also pulls in survey responses, so your preferences stay current without anyone having to re-enter them.
💰 Last Paid dates stick around again
Your Last Paid date now gets stamped straight onto the main sheet when you pay, so it no longer disappears when old payments are archived. New players get a proper date from the start, and there's a backfill that scans both the Payment Log and the Archive to repair any dates that went missing.
🫀 Early warning before the Dead Man Switch
If Gene hasn't checked in by day 9 or 10, Picklebot now sends an alive check before the Dead Man Switch fires. Both the alive check and the Dead Man Switch notification go to the Admin Dinkers group as well as Gene by DM and email, so nothing slips through quietly.
📋 Archived players no longer clutter reminders
Vote and survey reminder previews now list archived players in their own "Archived" section instead of mixing them in with players on vacation. Archived players are also left out of survey slot rankings entirely, so the Game Plan only considers people actually playing.
🔐 Sheet access handled automatically
Picklebot now grants viewer access to the sheet when a new member is synced in with an email, and revokes it when a player is archived. A one-time cleanup removed access for players who were already archived.
📇 Better contact sync/pb members sync now pulls names and emails more reliably from Google Contacts, so emails land in the sheet on sync instead of staying blank — which is also what makes the automatic sheet access work.
🛠️ Behind the scenes
The README now covers the Drive access lifecycle, the player archive system and the Dead Man Switch early warning, plus details like the MM/DD format for /pb set vacation and the Contacts fallback during member sync.
💵 Court fee is now $5/hr
The court rate used for session costs has been updated to $5/hr, and the welcome message now says $10/session. Sessions before the rate change date still use the old rate, so past balances stay correct.
🌴 Leaving the group and coming back is smoother
Players who leave are now parked with a vacation date of 12/31/2099 instead of being moved to a separate archive sheet. If you rejoin, your row is found by phone number and your vacation date is cleared, so your history comes back with you. Archived players still get payment reminders if they owe money.
🗳️ The weekly poll now lands Sunday morning — Picklebot creates the poll at 8am on Sunday instead of Saturday at 1pm, so you'll see it when the week ahead is actually on your mind.
👋 New players get a proper welcome
When WhatsApp doesn't share your name, Picklebot now checks Google Contacts to find it. If it still can't, your welcome DM just says "Hi there!" instead of the old "Hi Unknown (1926)!".
📧 Email notifications reach everyone
Heads-up emails now go to any player with an email address on file, instead of only those set up for SMS reminders. Archive notices do the same, so if you're being archived you'll actually hear about it.
✍️ Clearer wording when you're archived
The reasons now say "last month" instead of "last 3 months", and "not voted in the weekly games poll" instead of just "not voted", so it's obvious what happened and how to come back.
🛠️ Behind the scenes
Added a global off switch for emails, enabled the People API, and updated the Gmail setup docs for the new contacts permission.
🏖️ Vacation dates are easier to type
You can now set your time away with just the month and day — no year needed. Type /pb set vacation with a date like MM/DD and Picklebot will understand it.
🗳️ New poll option for "none of these times work"
The weekly availability poll now ends with an extra choice: "Want to play but not these times. DM Gene your openings." Pick it if you want in but none of the listed slots work, then message Gene your openings. Choosing it marks you as unavailable for all the listed times, so the poll count stays accurate.
📋 Game report player list is easier to read
The player list in the game report is back to a clean column-aligned layout, with name, DUPR and hours lining up down the page. We tried a few formats to get it rendering right in WhatsApp and settled on the one that's easiest to scan, and the spacing around the list is tidied up so there's no stray gap after it.
🛠️ Behind the scenes
Added a separate email blast script that reads the 2026 Senior Parents Email List and groups 220 students into 207 unique families so each household gets one personalized note.
📦 Roster cleanup now moves faster, and vacations are safe
Players who haven't played in 1 month (down from 3 months) are now flagged for archiving, so the roster stays current. If you're on vacation, your return date counts as your latest activity, so you won't be archived while you're away. Setting a vacation updates your Last Voted date to your return date, and the archive preview message now says "1 month" to match.
💸 Payment thank-yous now show when you paid
When your Venmo or Zelle payment is picked up, the thank-you message now includes the exact time of the transaction, like "on Apr 16, 2026 at 5:53 PM PST", so you can match it to your own records at a glance.
🕒 Payment Log timestamps in PST
Every payment entry is now stamped in Pacific time, and the old "Date" column is now "Transaction Date". Venmo payments keep their full transaction time instead of being trimmed to just the day, so the log reflects exactly when money moved.
🔁 No more duplicate payment entries
Payment sync now checks both the Payment Log and the Archive before recording anything, and skips transactions older than the last aggregation. That means a payment you already made won't show up twice after dues are totaled.
💸 Refunds and credits now work in /pb record payment
You can record a negative amount, so /pb record payment Derek Chang -4 venmo zeroing balance finally goes through instead of failing to parse. Handy for refunds, credits, or zeroing out a balance.
🧾 New /pb aggregate payments to tidy up the Payment Log
This rolls each invoiced player's payments into a single row and moves the individual rows to a new Payment Log Archive sheet. Your Paid and Balance numbers stay exactly the same, and running it again just updates the existing rows rather than double-counting. A dry run shows the aggregate amount per player before anything changes.
📦 Archiving inactive players got simpler
Archiving now goes by 3 criteria instead of 4: no games in 3 months, no votes in 1 month, and not on vacation. Filling out the survey no longer keeps an inactive player on the active roster. The preview, email, and DM notifications all list the new criteria.
🗳️ No more empty games summary right after the poll
The next-week games summary is no longer posted the moment the poll goes up, since it had zero votes and nothing useful to show. The poll itself still posts as usual, and last-call reminders are unchanged.
🛠️ Behind the scenes
Documented /pb aggregate payments in the README and tidied up the Payment Log columns so everything lines up.
🧹 Removing someone from the group now tells the truth
When a player is removed from the WhatsApp group, Picklebot now checks whether it actually worked instead of assuming it did. Failed removals used to be reported as successes, so someone could stay in the group while the bot said otherwise. Failures are now logged with the full response, which makes the occasional hiccup much faster to track down.
📋 Survey sync now shows who's missing
When the survey syncs, the summary now lists respondents whose email or name didn't match anyone in the main sheet, plus the players who haven't taken the survey yet (skipping anyone on vacation). The Survey Reminders summary also lists the reminded players as bullets, the same way Vote Reminders does, and adds an On Vacation section sorted by return date. Less guesswork about who still needs to fill it out.
💸 Payment thank-yous show the right balance
When your Venmo or Zelle payment is recorded, the thank-you message now shows your balance after the payment is subtracted, so the number you see matches what you actually owe.
🛠️ Behind the scenes
Deploys now copy the whole shared code folder, so updates can't go out half-stale.
💰 Credits now show up correctly
If you had a credit on your account, the bot was sometimes reading it as zero when Venmo or Zelle payments came in. Balances are now read the same way everywhere, so your credit is counted properly and payment notes show the right amount.
✉️ Clearer vote reminder DM
The reminder DM has been reworded and reordered: the note about opting out now comes right after the group link, and the copy mentions the Saturday 1pm poll and that you can set your own time off with /pb set vacation.
💵 Credits now read like credits
When you're ahead on payments, Picklebot says "$3.00 credit" instead of the confusing "$-3.00". Balances now look the same everywhere you see them — the game day DM, /pb balance mine, and payment reminders.
📣 Last call now finds next week's games
When you send a last call for a specific game date and time, Picklebot now checks this week's poll first and then next week's. Games sitting on next week's poll can finally be targeted instead of coming back empty. If the date still doesn't match anything, the error message lists the games available across both weeks so you can pick the right one.
🏖️ Set your own vacation — no more asking an admin
Use /pb vacation to see your current status, /pb set vacation <MM/DD/YY> to go on vacation (up to 31 days — longer dates get capped with a warning), and /pb clear vacation when you're back. Dates work as MM/DD/YY or MM/DD/YYYY, and the commands work in any chat, not just DMs. Admins get a heads-up automatically whenever you set or clear vacation, so the poll and Game Plan stay accurate.
🎾 Check and update your own DUPR/pb dupr shows your current SMAD DUPR rating, and /pb set dupr <rating> updates it (2.0 to 6.0, in 0.5 steps). Works in DMs, the SMAD group, and Admin Dinkers. Changes are sent to the admins so ratings stay trustworthy.
📦 Archiving inactive players (admins)/pb archive players finds players who have gone quiet on all four counts — no games in 3 months, no votes in 1 month, no survey, and not on vacation — moves them to an archive sheet, removes them from the WhatsApp group, and sends them a DM and email. /pb archive player <name> archives someone directly. If an archived player comes back, the members sync now restores their original row instead of starting them from scratch, and the archive sheet keeps its game columns in step when a poll is created or a game is cancelled. The notification DM now includes a working group link, and if one player's archive fails the rest of the batch still goes through.
😄 Joke is for everyone/pb joke now sits in the self-service part of the help text instead of under admin Actions, because anyone can use it.
🛠️ Behind the scenes
Deploy config now ships the pieces the archive command needs to remove players from the group, and the README env var table was brought up to date.
💵 Payments accept more methods and dollar signs
Recording a payment now works with any method you name — court-credit and credit included, not just Venmo, Zelle, cash or check. You can also type the amount with a dollar sign (like $8) and it will be read correctly instead of being rejected.
📧 Game day reminders reach everyone again
Your game day DM now goes out with email notifications working properly — some players were missing them before. The message also looks cleaner, with the extra blank line between the player list and your balance removed.
🗳️ The weekly poll only shows up when it should
The poll is now created only by the Saturday 1pm job, so it can no longer appear by accident during the daily 8am run. Vote reminders are also more reliable: the poll's creation date is saved the moment the poll goes out, so reminders work even before anyone has voted.
⚖️ Weight logging now shows your full stats
When you record your weight, you get the confirmation plus your complete stats report and history right away, instead of just a one-line reply.
🛠️ Behind the scenes
Reminders now pull player info from a single source and one fewer spreadsheet read, and a shared Firestore setup was added across the bot's services.
🗓️ The weekly poll now drops Saturday at 1pm
Instead of waiting for Sunday morning, the poll for the week's game goes out Saturday at 1pm. You get an extra day to check your plans and lock in your spot, and we get a clearer headcount earlier for the court booking.
📋 Poll reminders now come every day
If you haven't answered the weekly poll yet, Picklebot will nudge you daily instead of only on even-numbered days. Fewer missed replies means the lineup locks in sooner.
💸 Payments record themselves again
Venmo and Zelle payments were failing to sync, so some payments weren't being recorded — that's fixed. Zelle now only looks at the last 24 hours of email, so old payments no longer get re-recorded as new ones. Zelle thank-you messages send properly again too.
📧 Emails are back on
Email had been silently switched off in production, so notifications that should have reached your inbox never did. Email is now on by default, with a single switch to turn it off if ever needed.
💰 Clearer payment reminders
Payment reminder DMs now show the date of your last game, a Venmo link, and consistent 💰💸 emojis whether they reach you on WhatsApp, SMS or email. The thank-you message after a payment got its missing 💰 back.
✍️ Picklebot signs off the same way everywhere
Venmo and Zelle messages were signing as "SMAD Pickleball" without the emojis while everything else used the proper signature. Now every message Picklebot sends uses one identical signature.
📝 Tidier messages
Vote reminders and game reports lost their stray blank lines — before "Games This Week", before "We need X more", and between the signature and the joke. Game reports now always show the detailed DUPR and hours format instead of sometimes falling back to a plain list of names.
📋 Shorter survey reminder
The survey reminder is now a simple one-time explanation of the DUPR, SMS and email opt-ins. The outdated session schedule and bar hours text is gone.
🛠️ Behind the scenes
Shared formatters and config were consolidated so reminders, signatures and deploys all pull from one place, and the docs were updated to match.
💸 Zelle payments now record themselves
Pay with Zelle and Picklebot picks it up straight from the payment email — no more asking someone to mark you paid. It matches your name even if you go by a nickname (Gabriel → Gabe), and every payment is tagged with a transaction ID and a recorded-at time so nothing gets counted twice. Venmo keeps working exactly as before.
📧 Reminders now arrive by email too
Vote reminders, balance reminders and last call can now reach you by email as well as WhatsApp and SMS, all sent together so you never get one without the other. Email subjects carry an emoji so you can spot them at a glance (Vote Reminder 🗳️, Balance Reminder 💰, Last Call 🥃).
🙏 A thank-you when you pay
Record a payment and the player gets a thank-you DM on WhatsApp, SMS and email, sometimes with a joke. Messages also got a light polish: no more "Thanks," opener and the signature is now bold.
💡 Lights for night games
For games starting at 6pm or later, Picklebot emails the courts desk at 8pm to ask for the lights, so you're not playing in the dark. If the game gets cancelled, that reminder is cleaned up automatically along with the other scheduled jobs.
🛠️ Behind the scenes
Added the Gmail credentials to our secret setup and refreshed the docs, made the Venmo sync optional when no token is set, and fixed a scheduling bug that could pass the wrong setting when creating reminder jobs.
🗓️ Availability heatmap always shows the latest answers
The heatmap now updates when someone edits their existing poll answer, not just when a new person replies. Previously an edit could leave the old picture in place, since only the number of responses was being checked. Each heatmap link is also freshly stamped so WhatsApp can't show you an old image.
🗓️ New games show up right away/pb games used to say "No games scheduled" until someone voted — now every game appears as soon as the poll goes up. Last call also works properly right after poll creation, and the night-before last-call nudge now goes out at 8pm instead of 7pm.
❌ Cancel a game by day name
You can now type /pb game cancel mon (or fri, etc.) instead of the full date, and Picklebot finds this week's game for you. If there's no game that day — or more than one — it'll tell you instead of guessing. Sunday cancels now look ahead to the upcoming week's poll, so a Mon 3/16 game is found correctly.
💸 No more double payment pings
Venmo syncs and their WhatsApp messages could fire twice when a message got delivered more than once. That's now locked down, so you get one payment confirmation per payment — not two.
📋 Game info looks the same everywhere
Game Day Reminder, Last Call, Vote Reminder and Next Game now use one shared layout, with the same date style — DoW MM/DD/YYYY @ time — in all of them. The full player list always shows, so no more cut-off at 8 players. Weather for the Vote Reminder is fetched in one go, so it arrives faster.
🗓️ /pb games shows the whole week
It used to list only games still ahead of you. Now you see every game this week, with past ones marked with a checkmark and no weather.
🛠️ Behind the scenes
README and architecture notes were updated for the payment dedup work and the court argument removal from game reminders.
🔔 Vote reminders every day
Vote reminders now go out daily instead of only on game days, so you get a nudge whether or not it's a game night. The reminder DM is shorter and names the poll you haven't voted in, with a link straight to the group.
📣 Last Call knows which poll
The Last Call DM now names the poll and includes the group link, so you can see what you're being called for and jump in with one tap.
😄 Jokes can carry links
The jokes sheet now supports a URL column, so a joke can come with a link attached. The meme command has been retired.
📲 Texts go out through a registered messaging service
SMS now sends over an A2P 10DLC messaging service instead of a single number, which keeps texts from the bot landing reliably.
🛠️ Behind the scenes
We removed the Sentry error-tracking setup, consolidated duplicate GitHub token secrets, and tidied the README with updated secret counts and a codebase size table.
😄 A joke with every message
Picklebot now signs off at the end of its messages instead of the top, and tacks a pickleball joke onto every WhatsApp send — including these release notes. Same info, slightly more groan.
⏰ 7pm Last Call
There's now a Last Call reminder in the evening at 7pm, so you get one more nudge before the weekly poll closes. Scheduling also correctly rolls over to next week when it should, so reminders land for the right game.
🛠️ Behind the scenes
Fixed credentials handling for the jokes sheet so it works from our command line and automated runs, and smoothed out infrastructure setup by reusing the existing database instead of failing on it.
😂 A joke with every reply
Picklebot now tacks a random joke onto the end of its responses, including court bookings — the only exceptions are /pb joke and /pb meme, which are already in the joke business. Small thing, but it should make checking the poll a little more fun.
💸 Cleaner Venmo handles
If you include an @ at the front of your Venmo username, Picklebot now strips it before saving, so your handle is stored the way payments expect it.
🎾 Court booking is back to grabbing courts cleanly
The booking run no longer does its warm-up pass, which was sometimes leaving a pop-up overlay on screen and blocking the click that locks in your court. Bookings should now go through without getting stuck.
🛠️ Behind the scenes
The warm-up change is written up in our profiling notes so we can keep an eye on booking speed.
🗑️ Cancel a booked game
You can now call off a court with /pb game cancel — no more asking someone to do it by hand. It's listed in the help text, and the Game section now shows a paddle icon so it's easier to spot.
⚖️ Track your weight with Picklebot
New /pb weight record lets you log your weight, and it works in every group, not just one. Each entry comes back with the date so you can see exactly when you logged it, and your weight is now stored on the main sheet alongside your DUPR.
📊 See your own stats/pb stats my shows your current stats plus your full Health Log history, newest first, with weight, hours and DUPR on each entry. Health commands now sit in the read-only section of the help list so it's clear they only show you information.
🌐 A home page for SMAD
Picklebot now has a public landing page, complete with a header photo.
🛠️ Behind the scenes
Fixed a missing module in the deploy so the health commands work reliably, corrected the Health Log column mapping, tidied the court-booking script's behavior when a cancel fails, and updated the READMEs and systems diagram.
💌 Payment confirmations keep flowing
Picklebot now renews its Gmail connection every other day instead of every 6 days. That means less chance of it going quiet and missing a Venmo or Zelle confirmation, so your payment gets marked as received on time.
🛠️ Behind the scenes
The repo is now private with a security audit document added, and some internal notes were tidied up so they render properly.
🗳️ Vote reminder DMs look like the rest of the bot
When Picklebot nudges you to vote, the games in that DM now show up in the same format you see everywhere else. One consistent look for upcoming games, no more second version drifting out of sync.
🎾 Court names in your booking list look right again
When you pull up the booking list, each court now shows its proper name instead of an unresolved alias. No more guessing which court you're actually booked on.
🏝️ Vacation-aware booking suggestions
Booking suggestions and best times now know who's away, so the slots Picklebot recommends line up with who can actually play.
🔁 Repeat a command as often as you like
Sending the same /pb command twice in a row no longer gets silently swallowed — every message gets an answer now.
💪 Picklebot stops crashing mid-conversation
Picklebot was running out of memory (544 MiB against a 512MB limit) and dropping out; it now has 1024MB of room. Last call also no longer comes up empty — if this week is done, it rolls forward to next week.
🗳️ /pb survey sync
A new command that matches survey responses in two passes, so poll and survey answers line up with the right players.
🛠️ Behind the scenes
Shared game, poll and formatting code now lives in one place for both the bot and the command line, so the same answer comes back wherever you ask.
🗳️ Your votes stick now
Voting on game day no longer gets quietly overwritten — a yes on the day of play is recorded as 2 hours, and an old "cannot play" rule that stripped valid future game-date votes is gone. Votes for games already in the past are now blocked, so the poll only reflects games you can actually play.
💸 No payment nudge right before you play
If you have a game today, Picklebot holds off on payment reminders until after game time. One less Venmo/Zelle ping while you're grabbing your paddle.
📅 Court bookings fetched automatically
Picklebot now pulls reservations itself instead of leaning on a manual booking list, and it merges back-to-back hours into one block. The court info you see is current and reads as one clean session instead of separate hours.
📵 No more double messages
The sender now remembers what it has already sent, so you shouldn't get the same message twice.
🔄 Sync players either way around
Both /pb players sync and /pb sync players now work, so you don't have to remember the word order.
🛠️ Behind the scenes
Vote handling was consolidated into one shared helper, 120 lines of dead code were deleted, action result messages were unified, and the internal docs were updated.
🤖 The bot won't act on something you didn't ask for
Picklebot no longer lets a misread message trigger a real action like booking a court or cancelling a job — those have to come from a command you actually typed. Phrases like "court reservations" now reliably pull up your reservation list, and cancelling a job works whether you say it as job cancel or the other way around. Anything the bot doesn't recognize about courts or jobs gets handled gracefully instead of guessed at.
📬 No more double messages
The part of Picklebot that sends WhatsApp messages now runs as a single sender, so you shouldn't see the same message land in the group twice.
🛠️ Behind the scenes
Added a global SMS on/off switch, currently off.
🔔 No more phantom vote nudges
If you had already voted in the weekly poll, a bad poll date entry could still ping you to vote. That's fixed — the bot now reads the poll date one single way, so vote reminders only go to players who actually haven't voted yet.
📋 See who's been reminded with /pb players reminders
New view showing each player's balance, pay reminders and vote reminders, sorted so you can see at a glance where things stand. Handy for sorting out who still owes and who still needs to vote.
📣 Last Call reaches everyone
Last Call now also goes to players who haven't answered the survey, so nobody misses their shot at a spot. The summary is always posted to the SMAD group too, so the final count is right there in the chat.
⏱️ Tidier game day list
The game day player list got a small cleanup: one space between DUPR and Hr, and "Hrs" is now just "Hr". Easier to scan on a phone.
🛠️ Behind the scenes
Fixed the 2026 Hours formula drifting when new poll date columns are added, so hour totals stay correct, and SMS dry-run logs now show player names instead of phone numbers.
📣 Last Call now arrives on its own, 2 hours before each game
Picklebot schedules the Last Call DMs automatically 2 hours before every game, so nobody has to kick it off by hand. The summary now includes the weather, the player list with DUPR and hours, and whether we still need people. Each DM ends with its own random joke, slightly warmer copy ("Next game is coming up soon!"), and the availability note now sits right before the vote prompt.
🎾 Court booking retries, and later slots
If the date entry hiccups during booking, the bot now retries and reloads the page instead of giving up — that matters most at midnight when courts open. Suggested booking times now run as late as 9pm instead of stopping at 7pm.
🧭 Commands are easier to find
The /pb command list has been reorganized noun-first and grouped by category, with emojis on the Read-only and Actions section headers so you can spot what you want faster. New: /pb players dupr sorts the roster by DUPR rating highest first, then by hours.
💸 Quieter payment watching
Picklebot now watches only the Venmo label in Gmail instead of the whole inbox, so payment detection stays focused on actual Venmo notifications.
🛠️ Behind the scenes
Suppressed noisy SSL retry logs, added an infrastructure profiling audit and midnight booking performance notes, and updated the docs for last-call scheduling and /pb players [dupr].
📋 Cleaner player list
The DUPR and Hrs labels in the game player list lost their colons, so the lineup reads a little tidier at a glance.
🔔 Reminders that tell you more
The survey reminder now mentions your DUPR rating and the SMS opt-in, so you know what you're signing up for when you reply. Vote reminders now share one format and fall back to next week when there's no game this week, so you always get a sensible nudge. Vacation skip is still in place, so you won't be pinged while you're away.
🛠️ Behind the scenes
We unified duplicated code (one date parser, shared helpers, column mapping instead of hardcoded indices) and switched the whole project from print() to proper logging, which makes issues easier to spot and fix.
📱 Text messages, if you want them
Picklebot can now forward reminders to you as a text message through Twilio, for players who opt in. There's a new "SMS Reminder" column on the sheet (right after Mobile), and your DUPR and SMS preference stay in sync with the survey.
🥒 The weekly poll now matches the real court bookings
Poll options are built from the actual Athenaeum reservations instead of a fixed list, so you'll never vote for a slot we don't have. Options now carry emoji too: 🥒 for games and 😭 for can't play.
🔔 Reminders go to the right people only/pb reminders payment no longer sends vote reminders by mistake, and it skips players who have a game today that hasn't happened yet. You now get a numbered list of who was reminded and how much they owe. Admin Dinkers also stopped getting the game day reminder in production.
🎾 More from /pb games
New /pb games last week shows the games just played. Game player lists now show each player's DUPR score with zero-padded hours, and when there are no games left this week Picklebot shows next week's instead. Court booking suggestions now appear right in the games view.
📋 Cleaner player and availability lists
DUPR ratings now display as X.X and line up in a neat column. The availability grid and Best Times use two-letter days (Mo/Tu/We/Th/Fr/Sa/Su), Best Times only lists slots with 4+ available players, and the Last Played column is gone from /pb list-players.
🕚 Court booking fires at 11:58 PM PST
The booking scheduler moved from 12:58 AM to 11:58 PM PST, so bookings go in just before the window opens.
🛠️ Behind the scenes
Fixed double-counting of early 2026 game hours (1/3 and 1/6), merged the CLI and Picklebot games handlers into one shared path, and updated the cost table with GCP Secret Manager, GREEN-API and Claude Code.
🥃 New: Last Call
If you said you're available in the availability survey but haven't voted in the poll for an upcoming game, you'll now get a nudge DM. The DM includes the game date, the weather, and who else is in with their hours, so you can decide fast. Only people who left the poll blank get pinged — if you voted no, you're left alone.
📊 DUPR ratings now in /pb players
The players list shows your DUPR rating right between your name and your hours, with 9 ratings pulled in from the availability survey. To keep the list readable, the Last Voted, VR and PR columns are gone. There's also a new reminder for players who still need to share a DUPR rating.
👋 Smoother welcome for new members
New members added through member sync now get a welcome DM with the group info. Members are also inserted in the right alphabetical spot on the sheet, and the hours formula now points at the actual first game column. James Ji's row was repaired and moved into place.
✅ Member sync tells you when it's done
When member sync is triggered from the group, you now get a message back in the same chat when it finishes, with a summary. The "triggered" message says a completion message is on its way, so there's no guessing.
🧑🤝🧑 Full names in survey availability
The best times list now shows full names instead of partial ones, matched to the main sheet by email so everyone appears the same way they do everywhere else.
🔔 Clearer reminders
The poll reminder is now called the "games poll" reminder instead of the "availability poll" reminder, so it's obvious which poll needs your vote. Survey reminders work the same way as vote and payment reminders now, including the dry-run preview, and the admin summary separates vacation skips from no-phone skips.
🎾 Court booking runs at 12:58 AM PST
The nightly court booking attempt moved from 11:55 PM to 12:58 AM PST, so it fires in the right booking window.
🛠️ Behind the scenes
Added time limits to all automated jobs (5 minutes for infrastructure jobs) so nothing hangs silently, plus some code cleanup around reminders and column lookups.
📊 Availability heatmap image
Survey results now come with a picture. Picklebot sends a heatmap of everyone's availability right after the results text, so you can see at a glance when the group is free. If nothing has changed since the last one, the saved image is sent instantly instead of being redrawn.
🔄 Easier-to-read grid
The availability grid flipped: days run across the top and times down the side. It reads the way a calendar does.
🙋 Names in Best Times
The Best Times list now shows who is actually available at each slot, not just a count.
✏️ Edit your answers
The availability results header now includes a link straight to the survey, so you can change your times without hunting for it.
⏰ Fresher numbers before reminders
The heatmap is refreshed automatically each day before the survey reminders go out, so what you see matches the latest responses.
🛠️ Behind the scenes
Heatmap generation moved to its own workflow with proper storage and auth set up, fixing the case where the image never arrived, plus the survey link now comes from config instead of being hardcoded.
📋 Availability survey, right in the chat
Type /pb survey to see who has filled out the availability survey and who still hasn't, and /pb survey results to see everyone's availability. The survey link now lives with the bot, so you can grab it any time.
⏰ Survey reminders that know when to stop
Picklebot now sends survey reminders automatically as part of the daily reminders, on even days of the month so your phone doesn't buzz every day. Players on vacation are skipped, and the message comes with a freezing emoji plus a note about Rathskeller Bar in the same paragraph.
🛎️ Admins can nudge on demand
Group admins can now run /pb survey reminder to send the survey reminder right away, without waiting for the even-day schedule. A dry run shows the current survey status first, so you can see who's missing before anything goes out. A bug that also fired game, vote and payment reminders alongside the survey has been fixed.
🔑 Admin commands work in the group
SMAD group admins can now run action commands directly in the SMAD group chat instead of having to message the bot separately.
🗓️ See next week's games
There's now a command to look up the games coming up next week, so you can plan ahead without scrolling back through the chat.
🎾 Booking and cancelling courts works properly again
Asking Picklebot to book a court or cancel a reservation now reads your details correctly, so the right court and time go through the first time. A crash that could hit certain messages before Picklebot understood them is also fixed.
🛠️ Behind the scenes
Duplicate game, phone and payment code was consolidated into shared modules, and deploys were split into separate per-function workflows that run one at a time with retries to avoid GCP throttling errors.
🗓️ See next week's games
You can now ask Picklebot what's coming up — try next week games in the chat and it'll list next week's matches. The bot understands a wider range of phrasings for it now, so you don't have to get the wording exactly right.
📣 Next week's games posted automatically
After the Sunday poll goes out, Picklebot now posts next week's games straight to the group. One less thing to ask for.
🗳️ Smoother voting
Removed some leftover cleanup work that ran every time someone voted on the poll and was throwing errors behind the scenes. Voting should just work.
🛠️ Behind the scenes
Added Sentry error tracking across the bot so problems get spotted and fixed faster, tidied up how configuration and secrets are managed, and consolidated duplicate game logic into shared modules.
🗳️ Weekly poll shows the right week and the right time
The poll title now points at the next upcoming Monday, so "week of" matches the week you're actually signing up for. Times with minutes also show properly — a 10:30am start reads as 10:30am instead of being rounded down to 10am.
📊 Game totals count correctly
Marking a game as played now updates your Totals with a straight sum, so your played count stays accurate.
🛠️ Behind the scenes
All the automatic jobs — poll posting, reminders, and the nightly court booking — moved into managed infrastructure and were consolidated from 7 jobs down to 3, still running at $0.00/month.
💰 Payment thank-yous got a little shinier
When Picklebot confirms your payment, the thank-you message now comes with a money bag emoji. Small touch, but it makes your paid-up status easier to spot in the chat.
💰 Check your own balance, anywhere/pb my balance is here, so you can see what you owe without asking. You can also now DM Picklebot directly with /pb commands instead of posting in the group.
🏓 Game posts now show weather and player hours/pb next game and the games list now include the weather forecast for upcoming games, plus a tidy lineup showing each player's hours. Past games show a checkmark instead of a forecast.
🎾 Court reservations from the chat
You can now list, book and cancel reservations through Picklebot (admin only). Reservation lists include the day of the week, booking confirmations spell out date, time, duration and court, and results are sent back to whichever chat asked. Court names accept shorthand like south, north or both, and if a booking fails you now get the real reason instead of a generic message.
⚡ Quicker answers to everyday commands
Commands the keyword parser already understands skip the extra AI step, so they come back faster.
📋 A cleaner help menu/pb help has reorganised sections, emojis at the start of each line, and a tip on using natural language. /pb next is now /pb next game.
🛠️ Behind the scenes
Shared modules for GREEN-API, configuration, reminders and game formatting removed a pile of duplicated code; totals formulas now update automatically when new players are added. Also fixed a Venmo sync retry storm, trimmed cloud storage and costs, and set up automatic release notes.
💬 Fewer duplicate reminder DMs
If you already got your game day reminder today, Picklebot now skips sending you a separate payment reminder DM. One nudge a day is plenty.
🛠️ Behind the scenes
Cleaned a stale schedule out of the daily reminder workflow so reminders fire on the right timetable.
🌤️ Weather in your game day reminder
Game day reminders now include the forecast for Pasadena at game time — emoji, temperature, chance of rain and wind. You can also check it yourself any time with /pb weather, which now looks ahead 7 days instead of 3.
🏓 Game Day Reminder got friendlier
The reminder is now titled "Game Day Reminder!" and shows each player's 2026 hours played in a neat aligned list. Group messages and DMs use the same layout, so everyone sees the same info.
⏰ One daily reminder run at 8am PST
Game day reminders and vote reminders now go out together from a single daily run, so timing is consistent. Payment and vote reminders can also be sent independently instead of always together.
🗳️ Vote reminder DMs show what's coming up
Vote reminder DMs now list the upcoming scheduled games with who's already in, plus a "need X more!" nudge so you know exactly how close we are to a full game.
📋 Clearer List Players
The player list is now titled "List Players" with tidier columns: Hrs 2026, Hrs 2025, Last Played and Last Voted. Dates show as MM/DD/YY with no day-of-week or time clutter, and the header spacing lines up properly.
💰 Credits now show up correctly
Balances written in accounting format were being misread, so credits didn't appear. Credits now display in game reminder DMs, the payment admin summary and in Picklebot, and payment reminder DMs got money emojis.
✨ Emojis on commands
Every /pb help entry and every Picklebot command response header now has a matching emoji, making it easier to spot what you're looking at.
🛠️ Behind the scenes
Duplicate code across the bot was consolidated into shared modules (-1,060 net lines), weather, player data and display formatting now live in one place, and deploy notifications render as a clean monospace block on WhatsApp.
✅ No more confirmation links
Picklebot actions now just happen — the extra "click here to confirm" step has been removed everywhere. Fewer taps, less clutter in the chat.
🔔 Reminders go to the right people
Vote and payment reminders are previewed in detail before they're sent, and the vote check now compares your last vote against the date the poll was created. If that date can't be worked out, the reminder stops instead of pinging the wrong players.
💬 Replies always come through
A bug that silently swallowed Picklebot's reply messages during test runs is fixed, so you get an answer when you expect one.
🛠️ Behind the scenes
New admin commands to list and cancel court reservations directly, making booking cleanup quicker.
💸 Venmo payments match even if we don't have your username
If your Venmo username isn't in our spreadsheet yet, Picklebot now matches your payment by your name instead, so it gets credited instead of sitting unmatched. When that works, your Venmo username is saved automatically, so every payment after that matches instantly.
🗳️ One poll, one place
The weekly poll now only posts to the SMAD group — the duplicate post to Admin Dinkers is gone.
🛠️ Behind the scenes
Deploy notifications now include full commit details, and a payments sync call was fixed to match the current function signature.
💸 Pay in one tap
Payment reminders now include a direct Venmo link, so you can settle up without hunting for the right handle. Admins also have a new /pb record payment <player> <amount> <method> [notes] command, and the Payment Log gained a Mobile column so payments match to the right player automatically.
⚡ Faster court booking
Picklebot now warms up before the booking window opens: it logs in early and does a practice run on both courts while it's waiting. That shaves about 0.7s off login and makes the second court book about 0.5s faster, with all the extra work happening during idle time instead of the moment that counts. The date field was also made more reliable so a stale page can't trip up a booking.
🔢 Cleaner lists/pb games and /pb next now show players as a numbered list, so it's easier to count who's in at a glance.
👥 Keeping the roster in sync
New /pb sync members command lines the WhatsApp group up with the spreadsheet, so new joiners don't get missed.
🛠️ Behind the scenes
Added token authentication for the WhatsApp webhook and documented it, fixed a deploy that was missing shared modules, and cleaned up the admin deploy notifications to show just the commit subjects.
🏟️ Court booking is faster and books the right date
Booking now jumps straight to the court slot instead of hunting through the page, cutting slot search from about 1.1s to 0.04s, with another ~0.35s saved per booking by calling the court directly ('3' = North PB, '4' = South PB). Before anything is submitted, the bot double-checks that the date and time on screen match what we asked for, so a parallel job can no longer grab the wrong day. A matching bug where "2:00 PM" could match "12:00 PM" is fixed, so you get the slot we actually wanted.
💬 Messages get through more reliably
WhatsApp notifications — booking confirmations, Venmo thank-yous, error alerts — now go out through a new message pipeline with a backup queue, so a hiccup in one part of the system doesn't swallow your message.
🗳️ Vote counts you can trust
The weekly count now reads the main 2026 Pickleball sheet as the single source of truth, counts both a "y" and hours played for past games, and filters the week properly from Monday 00:00 to Sunday 23:59. There's also a new next-game command to pull up the upcoming game.
💸 Payment reminders and email
Added a workflow to nudge a single player about an outstanding payment. Email sending moved to a Gmail login that no longer depends on an app password, so reminders and receipts keep flowing.
🧯 Dead Man Switch
If Gene goes unresponsive, the bot now halts operations instead of quietly continuing. A bug that made it read only part of the sheet is fixed, so it looks at the full picture before deciding.
🔒 Your info stays private
We stopped logging full webhook payloads and sensitive error details that could contain personal info, locked down who can call the service, and switched game screenshots from public links to private links that expire after 7 days.
🛠️ Behind the scenes
Column lookups now read sheet headers instead of fixed positions, booking code dropped ~240 lines of unused fallback logic, a --dry-run flag makes test bookings safer, and several deployment and import failures were fixed.
💸 Payment reminders on demand — You can now send a payment nudge to everyone with /pb reminders payment, alongside /pb reminders vote for the weekly poll. The help text now lists both so you don't have to guess the wording.
🏓 Game day reminders look and read better — The reminder now goes out titled "Game Day!" with a paddle emoji, and each message gets its own random joke instead of repeating the same one. Reminders also only count as sent once they actually go through, so nobody gets skipped.
⏰ Pending actions expire at the right time — Fixed a timezone bug that made pending actions time out at the wrong moment, so confirmations stay open as long as they should.
✅ Played games get marked properly — Marking a game as played now records it correctly, so match history and scores line up.
🔤 No more garbled characters — Pages served by the bot now use UTF-8, so names, emojis and accents show up as intended instead of as strange symbols.
⏰ Game reminders, 2 hours before you play
Picklebot now messages the group two hours before each game with the player list and a joke, and DMs each player their balance plus a payment reminder. Your attendance also gets marked in the sheet automatically when the reminder goes out. These reminders are scheduled for every game as soon as the poll is created, and they clean themselves up after they run.
😂 A joke with every nudge
Vote reminders, payment reminders and payment thank-you notes now come with a random joke pulled from the Pickleball Jokes sheet. If that sheet can't be reached, there are 3 backup jokes so you still get a laugh.
🗳️ show-votes knows who actually voted
The non-voter list now reads the Last Voted column instead of the poll log, since WhatsApp's webhook sometimes misses votes. Players on vacation are also left off the "haven't voted" list, so the nudges only go to people who still need to answer.
🎾 More reliable court booking
Booking now waits a touch longer for the court page to finish loading before hunting for slots, which stops those "nothing found" failures. A grid refresh after the date is entered fixes evening slots (like 7pm) that previously never appeared, and if a slot button slips away mid-click Picklebot retries a different way instead of giving up. When something does go wrong, it now records the available courts and slots so the issue can be chased down.
🛠️ Behind the scenes
Gmail token refreshes now update their stored credential automatically without being able to break a run, and all the booking timing tests plus two already-shipped speed optimizations are now documented.
🏓 Court booking is faster and lands on the right slot
Booking now finishes both courts in 14.16 seconds total — 2.66s for North and 1.80s for South, which skips re-typing the date. We also fixed a bug where the booker could click the wrong time slot, and it now recognizes both "07:00 PM" and "7:00 PM" so 7pm courts get grabbed correctly.
💸 Payment reminders are simpler and less frequent
Zelle is no longer offered in payment reminders — Venmo only. Vote and payment reminders now go out every other day instead of daily, so the group chat stays quieter.
🛠️ Behind the scenes
Spreadsheet settings moved into secure storage, the court booking jobs were renamed to north/south/sweeper, and a new profiling mode measures each booking step so we can keep trimming seconds. Temporary debug screenshots and logging were added during testing and removed before production.
⚡ Court booking just got a lot faster
Booking now fires at 12:01:00 instead of 00:01:01, and we trimmed the slow parts of the process: the first court skips a redundant page reload (about 1.7s saved) and the calendar wait dropped from 2s to 1s (about another 1s). We also stopped scanning all 139 cells and go straight to the right time row. More speed means a better shot at the courts we actually want.
⏱️ You can see how fast it was
The WhatsApp booking confirmation now shows the elapsed seconds for each court, and the email adds a "Booking Time" field. If a booking felt slow, you can see it instead of guessing.
🛡️ A second backup attempt
Added a new backup booking job at 12:00:30 AM PST, on top of the existing backup that handles the South court. If the main run stumbles, there's another chance to land the reservation.
🛠️ Behind the scenes
A lot of this window was tuning timings by trial and error — Playwright slow_mo between 100ms and 10ms, calendar waits between 0.6s and 1s, interactive delays between 0.5s and 0.75s — with several reverts when a setting was too aggressive for the schedule to render. Login and navigation were also sped up, and the date field went back to typing because the calendar control needed it.
🔔 Nudge one person, not everyone
You can now send a reminder to a single player with /pb remind payment <name> or /pb remind vote <name>. It understands @mentions, plain names, and phone numbers, so tagging someone in the group just works. Payment reminders now end with a joke after the signature — so there's a little sugar with the ask.
😂 Jokes and memes, hand-picked
Jokes now come from a curated list of 50 instead of being written by AI, so no more groan-worthy robot humor. Memes come from a sheet of 49 pickleball memes collected from Reddit, with a random one each time, sent as a proper image.
👥 /pb players shows the whole roster
New /pb players command lists every player with their stats: 2026 hours, 2025 hours and balance, sorted by 2026 hours. Hours and last-played dates from the old 2025 sheet are now included, so the bot and the CLI finally agree on your totals. Last Voted dates are formatted like every other date (M/DD/YY).
🎾 Backup court booking can go South first
The backup booking job can now reverse the court order and grab South before North, giving us another way to land a court when the usual order doesn't work out.
🛠️ Behind the scenes
Group IDs, phone IDs and spreadsheet IDs moved out of the code and into secrets, and there's now a private test chat for trying commands without spamming the group.
🤖 Meet SMAD Picklebot — ask the bot things right in WhatsApp
Type /pb help to see what it can do. /pb status, /pb balance and /pb deadbeats answer questions on the spot, and the bot understands plain English, not just exact commands. It works in both the SMAD group and Admin Dinkers; action commands like booking and polls stay admin-only, so the SMAD group sees a shorter help menu.
📅 See the week's games with /pb games and /pb next/pb games lists every game scheduled this week straight from the poll votes, and /pb next shows the next one with a countdown and who's playing. Options like "Cannot play this week" are filtered out, and if you changed your vote only your latest one counts, so nobody shows up twice.
🎾 Book courts through the bot
Admins can ask Picklebot to book a court, and anything more than 7 days out is automatically scheduled to run at 12:01 AM, 7 days before the date — exactly when reservations open. /pb jobs lists what's queued and /pb jobs cancel <job_id> drops one. Booking, polls and reminders now ask for confirmation first via a one-click link that expires after 24 hours, so nothing fires by accident.
😄 Jokes and memes/pb joke serves up a fresh pickleball joke and /pb meme posts a captioned pickleball meme. Meme images now come from a reliable source and are checked before sending, so you get a picture instead of a broken link.
🏟️ More reliable court booking
Booking a second court no longer trips over a stale page — the booker reloads before each attempt. The summary now includes booking timings so we can see where things slow down.
🚨 We find out instantly when a booking fails
A new monitor watches the booking runs and sends a WhatsApp alert with the court, date and time whenever a slot isn't secured — even if the run otherwise looked fine. It says why: courts not released yet (with the exact time remaining), booked by someone else, or already reserved by you. Alerts include a screenshot link so the problem is obvious at a glance.
💵 Cleaner balance reports
The "Totals" row from the spreadsheet is no longer counted as a player, so /pb balance and /pb deadbeats stop double-counting.
🛠️ Behind the scenes
Added an automated error monitor for our scheduled jobs, gave Picklebot more memory, and updated the project documentation and architecture diagrams.
🏖️ Vacation dates that just work
Vacation dates now understand the MM/DD/YY format, so you can type dates the way you normally would and they'll be read correctly. Players out on vacation also show up in their own section of the vote reminder summary, so nobody gets chased for a vote they can't give.
⏰ Daily reminders at 8:00 AM
Daily reminders now go out at 8:00 AM PST.
🗳️ A clean poll every week
When a new availability poll goes up, the previous week's poll log is moved into an archive sheet and the log starts fresh. That means this week's responses are never mixed in with last week's, and the old entries are still kept if we need to look back.
🛠️ Behind the scenes
Some housekeeping on internal files that players won't notice.
⏰ Reminders and the poll now run on time
The weekly poll and the daily vote/payment reminders moved to Google Cloud Scheduler instead of GitHub's timer, which wasn't firing reliably. The poll goes out Sunday at 10:00 AM PST and reminders go out daily at 10:00 AM PST.
🎾 Court booking starts warming up early
Booking now kicks off at 11:55 PM PST so everything is loaded and ready, then waits and books right at 12:01 AM. This stops us from missing the booking window because the machine was still starting up.
🏝️ Vacation mode takes a return date
Instead of just marking vacation on or off, you now give a return date (MM/DD/YYYY) and you'll be skipped from vote reminders until that date passes. Payment reminders still come through while you're away, and vacation status now shows in the player list.
🗳️ Votes can be recorded by hand
New update-vote command lets a vote be entered for a player, with --list-options to see the available dates and --dry-run to preview first. It marks your chosen dates, clears your previous answer, and updates your Last Voted date. Also fixed a bug where a second vote on the same poll could get attached to the wrong poll date.
💸 Clearer payment follow-ups
Payment reminder summaries now include each player's Last Played date, so it's obvious who owes for what. When a payment comes in, a notification is sent to the Admin Dinkers group.
🙂 Emojis show up properly
Poll output no longer mangles emoji characters on Windows.
🛠️ Behind the scenes
GCP resources moved to Terraform, workflows were split and renamed (court booking, poll creation, vote/payment reminders), the booking script was renamed to court-booking.py, and the docs were reorganized around the whole project rather than just booking.
🗓️ The weekly poll now gets the dates right
The poll question now names the correct week for the dates it offers, and poll dates are calculated in PST so options line up with real Los Angeles days. Vote confirmations and other timing in the bot also run on PST now, so nothing drifts an hour off.
📣 Admin Dinkers group gets its own updates
The weekly poll is now also posted to the Admin Dinkers group (votes there are not tracked, so vote in SMAD as usual). After payment and vote reminders go out, and after court bookings run, a summary lands in the admin group too — so the organizers can see at a glance what the bot did.
✅ Only SMAD votes count
Votes are now matched to the SMAD group, so taps in any other group are ignored and the weekly poll tally stays clean.
✍️ Bot now signs off as SMAD Picklebot
Messages from the bot carry the SMAD Picklebot signature across all of its replies, so it's clear who's talking.
🛠️ Behind the scenes
Nudged the daily booking and weekly poll schedules so they fire reliably, moved the admin group setting to a plain config variable, added the timezone library to the webhook, and fixed a Windows console crash in the poll dry run.
🎾 Court booking hangs on a little longer
Booking sometimes gave up before the court system came back with an answer, so a reservation could fail for no good reason. Picklebot now waits up to 15 minutes instead of 10 before calling it quits, so more bookings go through on the first try.
💸 Venmo payments confirm themselves again
Automatic Venmo sync is back on, so your payment gets picked up without anyone chasing it. Picklebot now sends you a WhatsApp thank you the moment it records your payment, and the balance in that message is the freshly recalculated one, not a stale number. Duplicate notifications can no longer double-count a single payment.
🗳️ Vote reminders tell you how to stop them
The reminder DM about the weekly poll now includes instructions for opting out, so you can turn it off yourself if you'd rather not get the nudge.
🎾 Court booking no longer misses the midnight drop
Booking was failing when new courts opened at midnight because the page still showed the old availability. Picklebot now reloads for a fresh look, so it grabs the court when it appears.
🛠️ Behind the scenes
The payment sync got a dry-run mode for safe testing, a shared module behind the CLI, and a deploy workflow entry, plus an updated architecture diagram.
💸 Venmo payments say thanks back
When your Venmo payment lands, Picklebot now sends you a WhatsApp thank you with the amount and a "Your balance is now: $X.XX" line, so you know where you stand without asking. Payments are picked up automatically from the Venmo email, synced straight into the sheet.
🗳️ Weekly poll and reminders run on their own
The weekly poll is now created automatically on Sunday mornings, and daily reminders for payments and voting go out each morning. Reminder wording now points you to "this week's poll pinned to the top of the group" instead of referring to a pin that never worked.
📅 Poll question shows the week you're voting for
The poll question now includes the Monday date of the current week, so there's no guessing which week you're signing up for. Votes can still be changed for 7 days, after which a poll is treated as expired.
🧹 Tidier poll history
Poll log entries older than 7 days are cleaned up every Sunday, and weekly totals now tally correctly as new dates are added.
🛠️ Behind the scenes
The poll log moved from Firestore to Google Sheets and all the old Firestore plumbing was removed, plus a systems architecture diagram was added to the README and the automation workflows got fixes for dry-run mode and poll-date overrides.
💰 Balances and "last paid"
We reworked how your balance and your last payment date are worked out in the sheet, so what you see in /pb balance lines up with what you've actually sent over Venmo or Zelle.
🎾 Court booking right at the stroke of midnight
Booking now opens 7 days ahead of the actual target booking date, not whenever the bot happens to run — so no more missed courts around the midnight border. The booking attempt now fires at 12:01:01 AM PST, and the bot reliably picks the right 60- or 120-minute slot from the dropdown.
🗳️ A cleaner weekly poll/pb poll now pulls its game date options straight from the booking list for the upcoming week, so the dates you vote on are the ones we're actually trying to book. The nudge to vote has been reworded to be a little friendlier and clearer.
🛠️ Behind the scenes
All Google Sheets work moved into one central place, which makes scores, payments and ratings easier to keep consistent going forward.
🎾 Court booking now fires at 12:01 AM
Picklebot now aims for 12:01 AM PST when courts open, instead of 12:00:15 AM PST. Same goal: grab our court the moment it's available.
📋 Pickle Registry in SMAD Sheets
Player info now lives in a Pickle Registry in SMAD Sheets, so the bot has one tidy place to look you up.
💬 WhatsApp connection
Picklebot is wired into WhatsApp, so everything happens right in the group chat.
🎾 Court booking lands on the right day, every time
The booking robot now always targets the exact day of the week at least 7 days after it runs, so a run that fires near midnight no longer books a day off. We also fixed a leftover case where the 7-day window was measured from the moment the job started instead of the day we're actually booking for.
⏰ Booking runs shifted later
The two scheduled booking jobs now kick off a bit later, and the bot waits for the booking page to finish loading before it starts clicking. Fewer missed slots from a page that wasn't ready yet.
🛠️ Behind the scenes
Cleaned up logging so booking runs are easier to trace, plus a small text-encoding fix.
🎾 Court booking at midnight is now rock-solid
Fixed a midnight wraparound bug that could make the booking script wait 23 hours instead of seconds, so courts get grabbed the moment the window opens. The booking date is now calculated from the actual booking time (12:00:15 AM) rather than when the job kicks off (11:54 PM), so we always reserve the right day. The safety wait is capped at 10 minutes, matching the grace period.
🛠️ Behind the scenes
Reorganized the bot along 12-factor app principles to keep configuration and deploys clean.
🗓️ Courts now book themselves, every week
Picklebot now holds a weekly booking list using plain day names like Monday or Saturday, and grabs your court 7 days ahead automatically. No more setting an alarm for midnight — it runs on its own at 11:50 PM PST, with a backup attempt at 12:01 AM so a slow start never costs us a court. Booking times are now configurable, and everything runs on proper PST/PDT time so dates never drift when the clocks change.
🏟️ Both courts at once
If we need the space, Picklebot can now book North and South together in one go instead of one at a time. Handy for the weeks when the group runs big.
⏱️ A 10-minute grace period
Midnight booking can get congested, so Picklebot now keeps trying for 10 minutes (up from 5) instead of giving up. Fewer nights where we end up courtless for no good reason.
📧 Email summary after each booking run
You now get a booking summary report by email with a clearer subject line, so you can see at a glance what was booked and when. Booking date and time are now shown as one combined date-time, and passing a single date-time by hand overrides the weekly list for one-off bookings.
🛠️ Behind the scenes
Scheduling moved to automated daily runs with a corrected UTC schedule, manual runs for debugging were improved, and timestamps plus the ignore list were tidied up.
🗓️ Court booking on autopilot
The daily midnight booking run and BOOKING_LIST are now working, so courts get grabbed for us automatically without anyone staying up for it.
🎾 Automatic court booking
Picklebot can now book our court on Ath by itself — the first version is live. Navigation through the booking pages was tightened up, so it moves through the flow faster and with fewer places to get stuck. Less chasing courts by hand before each week's game.
🛠️ Behind the scenes
The booking bot now runs on a scheduled GitHub Actions job instead of waiting on someone to kick it off.
Every commit, newest first. 644 of 1384 touched deployed code. Click a subject to read its message. 76 commits carrying nothing but session-to-session notes are excluded here and from every count on this page: that channel moved to its own repository on 2026-09-02 PT because each push here announced itself as a release. 173 commits that are bots writing their own records back (CI timing, payment sweeps, court-booking anomalies) or a session triggering a workflow (a diagnose, a log pull, a rerun) are excluded the same way: a commit, but not an upgrade.
b22df1304dda592c17aa6e60d35318c98c0346ba59f6c8fc9f39eb0a04846c6b7fc38eb4ed4afa6f1e677b0868d35fd84b8d6594059c5cb465edc8176614529745c28848ece54a66454e6fb95f351a1a3436753648937ba0ac93c48a485e37fa354f64c0acc624bbd217531a288280d1604e464b2333acbaff9eb53b2ffe21524805e1cb68447bf5f795be4398f5f723690d2d193670b254b2e3cae3712b07fed16b97c2bb2583ec02cbab132c03bcd16be5970011e4c81aba931f92d97df9b0c41bd6c003635a97e3b3522b97a6789fb5bbb939bf71d97493267b2d7110dfb83dea48b0c7c13203ecccaf26892b124786ec211cc928e3070ace0bac31fef1676f40c0ba9a9782a580914ec33ba3589a464ffd27f13ed17874ff0d71e2dcc26eb52573982c01d60fb1d14e801ef98d8c8fa8027c8df73f1eeba75753af03b76a1bd003cc08a88998143efcc0e610f76c0d33a05f0a86588ad455e66063efe2ae52a7677b3d88c6e71b38e38ceaf04b4d1fb5143929b119d918dfe47daad4f177a13e6b9c7bbd3b56598ecbb36896362444e763da9da731b1cac403cd004e6cbfb5e795a200b7b2e3c0a92302831170681bd192be03402528b9117d6c0a024eb54bf40800957e268b8317cd35641d719840f60d455f26ded7d2028b8d6669330e3368e555090ac492e440524fe72c1d576cc9326d7ee52e2af7fdb1a19a00b37206a63802937c836976b8b92ced66f2c318f7dfaebf41be708cf29b43241760a68198a9873cfc514f24d0ed40c870ef09999b445899f332df583ba4a1e8ea1e9d31546820696a33831ce6a0975dcee970fd700908bab1d6a841265ebdca13914f62eb098203a8e42ed6cc8d4fe5d4f2b1eaad596dc218dfac88c8c8d03d79611b41ec10241ff60bf6cb7461645a68f7817f13ec6a38c3fb06d67b89fca89a33c406925152691c14d178b80375ed09b239dde7db797c1e64193209a798d966c76fce57f5564d900c5172697dc9abc1153f621f295f1dc45fb7438d305bcbebd8557763b450affa917fe9a98b0067b53e13543728769049679ae8025112231f21f800ff25ef0a96ba31b28546126530785a7066d103216355eb8f59b900aacb4edd76f241e2c11f57302bca734aa2a9696d56a9b16429976c9fffe404cc70e3ddf98a3f86d0cd90ca40f748c1fc32a3e647ae0f4985a7dc720b9df3fa545381666ad06be7a68a05893b24c6017ccdffbcb8853473955a9e02e9a05b6318afdf46e0aefafe0ccf043699603e026cc93e256aa5cb49369b3fbf1f8381a1dbbf74545bdd4dae1825b1db3e9ae91c0b9416618a11bcfe9898fc6fd9e6b0642a505c3941d0ae4e99c11ebb4449fa5ef1401c8935c1a78711cc9608da3a398a76ba287a05fe59518be85e50f1339ef1d82108e8ab3fc3e25d44ccfa17d4afe2558d411ec69339fc0b1fa33acfe345c3ac52ef2bc6e523a9832630c20182206f1d4e0563e1616ee58a89e74a3a51d927774937b851f513833a5fbe489ccfaf039ee839d1f869ec947324351bc28b7514320a177f39c817067f4b95899cf91beef7e824dfb3607492c704984729efb24cd658e27cfe2da4a4501b2797f46e36c0cfb86fb6f1256a501119456552b8f664481b4eca813dfc64a64596ba7010bd79b94286456750cf73023e53cf31f30f56b1c180b81a6acf62ba9f630d7f2c3b0cbd2858a1521b1a6d3f0db8d7390b02a0e8238c2bae45e83ad92715a22831ab46caa3fda9b269b8d2efd270450192be7985f4c9914e768b72226b1eda25f0e2b51098172cab2a792e912fbb31f2cd4dd8ee8abaf0795290d20d13bf2e93ec3b8f74bb284c810790c732a1b11d30e2768836b428ad70eef4d89f4ff72352151c0eb64c09fae8c3a82abdd0f6964fb755b02e200e5408c01faf1473d7a544e9bcb59fc9c7db6b94b36e06e630541ee2b94952d5768425745c7b887ccbb126ac7a5c1d7b8cf485a51ec696cc6683970a2f47d1440246f780a267b623d643b28731babf144740b529394b0c8e3565d764c001aa28ffdb5f04954bed9b9a62f2567e6079ede36165b725a3aec2aa0ac5325b60df0ca2c6bc4695eff13a281Two harness failures found while testing this, both "the instrument lied":
c6be65ad058b80a6e4958972b40f81b7149ec2f0fc2d4a6e352a392671ce5f6c37ab11a0744b13fd9bcdf94ba1350cd10371be92f7ba59aded9a7cd9f358bb5bdc27d978d7cd7f1ca4a4f25a53c35f9c261b50a0a4eaec1aab4209014cb2d78e3ecbcde553cf3b35c267a284c617f231686a347175b1c04583c7f27a94a2a0eaf4f2f8c5b924166c7298ddcdb2c2250d82eff4d8a92afe31d1ce7f043ef7ba0fa3b696ce20a9fb71268cb2de459c3adb57ead91dabba401920cb00f5472ead367f80ec582135b4a904b17c073aa71cc1fb4582ef7f74f2a794c9f1079d1175c3d782f540d98c4fe3eaa693a9da1e81d23f312add106f7665d4c4985d33b5996c873067b3820225206aaaad9beebd3d70e9d55173ff6f62fe90e4b7ab32af7638014b5f2ac77d507d2706b603829db64fb65aad3b6f62b3da60d3ba54fc64719f17630643423267320737c365171c5b8d7f417ddc672e1215da41e1ce8b29c067cb47f8505f909d7b976cede66734811fa5a6de3ebbd4fb5c68132d296ab0bc7dc86708438e812df4Undocumented since it shipped: absent from README, the picklebot README and the function registry. Records the two things that are not obvious from the name - the North court and the $10 are hardcoded, so a change to the arrangement is a code change, and the non-admin path matches the sender's roster name against "shyam", so nobody else can run it from a DM.
a8e6f37It shipped in 32a480b two weeks ago and appeared in no help output, so the only way to find it was to read main.py. Admin help only, matching the command's own permission check.
1efdc3fCancelling a game deletes the sheet column, releases the court bookings and DMs everyone who voted yes. A punchline under that reads as flippant about news nobody wanted.
5302e663a18c11The daily survey sync wrote any differing survey answer to the main sheet, including a lower one. John Wang 2 was set to 4.0 by hand and the sync put him back to his old self-reported 3.5 two days later. The survey answer is a point-in-time self-report and stays in the survey sheet as history; the main sheet is the current rating. A survey value is now written only when it raises the rating or the sheet holds nothing usable, and an unparseable answer never overwrites a real one. Held values come back as dupr_held and are printed in the sync report - a sync that quietly declines to act reads the same as one that found nothing.
c3d5e93Batching pushes only helps if the routine stops firing per push. It is configured at claude.ai/code/routines, not in this repo, so this records the options: path-filter to the files the page actually renders, or drop the webhook and keep the daily cron.
1de72ddEvery push to main republishes the Release Dashboard artifact into a rolling window of 20 versions labelled by position, not identity. A 21st appeared at 08:47 on 2026-08-30 and was gone by 09:21. This session pushed 25 times in 99 minutes against 1-2 on a normal day, which recycles the whole window in under two hours - so a link shared that morning pointed at different content by lunchtime. The pin was not drifting; the label was never bound to a version. Commit fine-grained as before, but accumulate and push once per coherent chunk.
e02d60fThe regex matched only zelle's "0 recorded, 0 skipped" wording, so venmo reported null counts while its own log said "Recorded: 0, Skipped: 44". Claiming unmeasured for something measured is the same lie as claiming 0 for something unmeasured, inverted. A crashed run still yields null.
8d15eccIt imported yaml, passed locally, and failed on the runner where PyYAML is not installed - the same missing-dependency bug this checker exists to stop shipping, in the checker itself. CLAUDE.md requires depending only on what is guaranteed to exist. Matches the `uses:` directive by regex instead, which also avoids the comment false positive. Both rules unit-tested on synthetic workflows.
6308f49Both patterns shipped and cost real time, so this is enforced rather than documented: a report-failure step with no captured output, and a `... && break` retry loop with no check after it. Parses the YAML rather than grepping - a substring match on "report-failure" flagged this checker's own comments.
209bc0e6b1138aThe retry loop ends on `sleep`, so a deploy that failed both attempts exited 0: green step, no failure issue, nothing deployed. All four workflows. Verified under bash -eo pipefail, which is what runners use - double failure now exits 1, success still exits 0. Also tees the deploy output so the failure issue carries the gcloud error rather than a link.
0825f7fA report reading only "Failed on <sha> - [run log]" is useless to the reader who most needs it: GitHub serves run logs from a host an agent sandbox is refused, so five round-trips went into building ways to read a failure rather than reading it. The cause was one missing pip dependency. The action now reads $RUNNER_TEMP/step.log when no explicit details are given, so a workflow needs one tee on its risky step - no step id, no outputs, no interpolation to get wrong, which took three attempts on pull-logs. When nothing was captured it says so and names the fix.
c7a2a55google.auth.transport.requests imports the requests package directly, and `pip install google-auth` does not pull it in. The install step went green and the script failed at import; it worked locally only because the repo already has requests. The error said "google-auth not installed", which was false and sent the investigation at an interpreter mismatch that turned out to be a red herring - roster-widths uses the same pip/python3 pairing and runs fine. It now reports the ImportError itself.
fc5107fTwo attempts to carry the failure detail via steps.outputs into the issue produced an empty detail both times. Committing a file is the one channel verified working end to end here, and it is the convention the rest of the repo already uses. Commits on failure too - a failed pull's output is the part worth reading.
7ac37a7The capture only wrapped the python call, so the early "secret is not set" exit reported nothing and the issue could not explain itself. A trap with process substitution was tried first and raced - tee had not flushed when the trap read the file. Running the whole step through a pipeline is deterministic; both the failure and success paths verified.
f4f72e0"Pull Logs failed" carried only a run-log link, and GitHub serves those from a host the agent sandbox is refused - so the failure report was unreadable by the one reader it exists for. Two runs failed and the reason was invisible.
f0c9dc1On Windows, Get-Content piped to gh secret set can prepend a byte-order mark. The secret then looks correctly set and json.loads rejects it at use time - a failure that surfaces only when someone needs logs.
5cde916Setting an environment variable on a cloud environment is a web-UI operation - the CLI has --cloud and --environment but no command for env config. GitHub Secrets can be set from a terminal, so this route needs no browser: gh secret set GCP_LOG_VIEWER_KEY < claude-log-viewer.json Costs a push round-trip instead of running in-process, and buys independence from anyone being at a keyboard - the case that failed on 08/30, when the alert arrived at 21:19 and the only person who could pull logs was asleep. Runs the same scripts/pull-logs.py a local run uses, so output and redaction cannot diverge between the two routes. diagnose-logs.yml reimplemented its own queries and drifted to three of the four functions.
40ab4eagcloud writes keys to the current directory, which is the repo root, and claude-log-viewer.json did not match the existing *-credentials.json rule - a git add . would have committed a live private key.
43b2b96google.auth.default() wants a file path, and a remote sandbox has no file to point at - the key arrives as an environment variable. Without this, setting GCP_LOG_VIEWER_KEY would have looked configured and changed nothing. Local ADC still works unchanged, so one script serves both machines. Both credential paths fail loudly and write nothing.
96722107602fb9A remote session can reach logging.googleapis.com but holds no credentials, and GitHub serves run logs from a blob host the sandbox is refused. The local VS Code session has both the credentials and the repo, and git is already the channel between them: run this there, commit the result, read it here. Committed output is scrubbed harder than a transient log view - emails and phone numbers redacted, entries truncated. One file, overwritten; git history is the archive rather than a pile nobody prunes. Exits non-zero and writes nothing on bad credentials, an API error or an unknown service, so a file that exists came from a query that ran. An empty result is reported as production being quiet, never as silence.
85fb066Measured from a remote session: the GCP logging, error-reporting and monitoring APIs are reachable and only lack credentials, while GitHub run logs and artifacts are blocked outright - the proxy answers 403 to CONNECT for both blob hosts. Two different problems, and only the first is worth solving directly. Records the exact gcloud commands for a scoped read-only service account, with the trade-offs stated: read-only and revocable, but broader than the current truncated issue route, and a long-lived key where federation would be better. Also queues a Datadog evaluation. The present stack is assembled from parts and each part has already failed in a way that looked like success.
f4ddc8eGitHub serves run logs and step summaries from a blob host the agent sandbox cannot reach - the proxy answers 403 to CONNECT for both results-receiver.actions.githubusercontent.com and the artifact blob host. So a result that lands only there is invisible to the session that needs it: after the 08/30 sweep this workflow went green and nobody could confirm whether it had recorded anything. ops/last-payment-sweep.json carries the return codes, parsed recorded/skipped/ unmatched counts and the log tail, committed only when it changes. A missing log yields null counts, never 0.
18d7ebbExternal: Google Sheets returned 503 during a cold start. 9 minutes, nil impact The entry records the detection gap as the real failure. Three layers should have surfaced it and none did: error-reporting judged freshness against a fixed 70-minute window while its cron ran 7 times in 28 hours; diagnose-logs queried three of four functions, omitting this one; and no agent session can receive a GCP alert email. Gene forwarded it ~10h later, which is how it was found at all.
f5273b7Three comments asserted the 04:15Z Sheets 503 cost a payment its sync and its thank-you. It did not. The last Venmo payment was 08/29 00:33 PDT and was acknowledged at 00:33; nothing arrived at 21:15, so the sync had nothing to record. The claim was inferred from timing and written as fact. The failure mode is still real - a pending payment would have been lost silently comments now say so.
adf0e09It promised "the daily scheduled sync will catch anything missed". No such sync existed, so a swallowed failure was simply lost - which is what happened on 08/30. payment-sweep.yml now provides it.
75e70b8A single Sheets 503 during a cold start on 2026-08-30 04:15Z lost a payment's sync and its thank-you. There were no retries at any level: the client library was not asked for any, and the outer handler deliberately does not re-raise. execute(num_retries=3) makes the client back off and retry 5xx/429 itself, so a transient failure is absorbed before it becomes either a lost sync or a Pub/Sub retry storm - the storm being the cost incident 0fda64c fixed by swallowing. Eleven call sites across venmo_sync and zelle_sync.
7c6fde6A payment email's sync gets one shot: venmo-trigger fires on Pub/Sub and swallows exceptions, so a transient failure loses the sync and the payer gets no thank-you. sync_venmo_to_sheet re-fetches the last 50 transactions and dedupes against the aggregate watermark, so re-running is idempotent and recovers it. Runs 14:40 UTC, ahead of the 16:00 UTC reminder job, so overnight payers are acknowledged first thing. Separate from daily-reminder-runner on purpose: that workflow also creates the weekly poll and books courts, and a payment sweep must not be able to delay them. Push-triggered on ops/payment-sweep.txt as well, because GitHub schedules are best-effort - an hourly cron in this repo managed 7 runs in 28 hours - and the GitHub App cannot use workflow_dispatch. Zelle runs too. It is rare here, under five payments in 2026, but it has the same swallow-and-ack bug and a sweep covering one method is a gap.
3e9bf4esync_zelle_emails() was reachable only from inside venmo-trigger, so nothing outside that Cloud Function could re-run a Zelle sync. Mirrors sync-venmo.
1aca73eThe issue body printed inputs.minutes, which is empty on a push trigger, so a run driven by "minutes=900" in ops/diagnose.txt announced itself as 120 minutes. Now reports the resolved window and the services queried.
f422c54The service loop covered three of the four deployed functions; venmo-sync-trigger was absent. A diagnose request for it would have queried services it does not run in, found nothing, and reported an empty result - which reads as "no errors" rather than "wrong question". Adds a services= override in ops/diagnose.txt for targeting one function.
7f99641The window was a fixed 70 minutes, commented "slightly wider than the hourly cadence, so no gap". The cadence is not hourly: over one 28h stretch this workflow ran 7 times instead of 29, with gaps to 6.5h, because GitHub schedules are best-effort. Errors firing inside a skipped hour were seen by the next sweep, judged stale, and never reported - the run going green either way. That is exactly how a venmo-sync-trigger error at 2026-08-30 04:19Z was lost: the next sweep ran 05:29Z and missed the window by about a minute. The window is now the real gap since the previous successful run, plus 10 minutes' grace, falling back to 24h when the run API cannot be read - noisy beats silent. A gap over 95 minutes is called out in the report, since a late sweep is itself a monitoring failure. Adds actions:read to query the runs.
911d9b6Prompts are this project's requirements and they do not survive the session, so nothing durable records acceptance criteria - commit messages describe what was built and verified, never what was asked. That is the blocker for a test suite rather than a planning nicety: tests written from existing code lock in current behaviour, and four confident wrong behaviours found in one session would have been enshrined as correct. Records the Linear evaluation (free tier, first-party MCP so tickets are readable by every future session), the proposed working model, and the open questions on sequencing against the repo restructure and peeps.
ad5bd34Naming files in git add does not scope a commit; it adds to what is already staged. Three wrong commits here from that: git add -A swept 251 unrelated deletions into a pytz hotfix, git commit -a left ops/incidents.json untracked so the incident record missed its own fix, and a git rm of nine files landed in a commit describing a one-line change. The rule existed for all three; nothing enforced it. Also records how to split a pushed tip safely - prove the tree is unchanged, push with --force-with-lease pinned to the old sha, never below the tip.
c34411cKept revoke-archived-access.py despite having no references - it is recurring maintenance, not a completed migration.
c99f857It runs daily in sync-members.yml, scraping the group into the Jokes sheet jokes.py reads, so its commits are product work however the folder is named. Moving it would break a live workflow with no test behind it - that move belongs in the repo restructure.
e28e006The classifier read subject lines, so 159 of 589 feat commits (27%) were internal work - dashboards, workflows, ops data - filed as features no player benefits from. The repo already separates these directories; nothing was using them. Path decides only when there is no explicit prefix. Location says WHERE the work was and a prefix says WHAT KIND, and they are orthogonal: an optimisation can live in CI. Deciding by path first relabelled `perf: skip redundant Cloud SDK install` as infra because it edits .github/. feat 589 to 429 (72% to 53%), infra/CI 9 to 116, new tooling chip at 46.
2381d95fe2152f9e9f84aadc3b90An unrecognised prefix was classified by the first word after the colon, and "skip" was in the fix list - so `perf: skip redundant Cloud SDK install`, a 14% deploy speedup, was filed as a bug fix while the only other perf: commit landed in feat. That remainder rule now trusts only unambiguous repair verbs, moving three commits out of fix. Optimisation is neither feat nor refactor: no new capability, but behaviour does change. Detected by prefix; the February booking optimisations predate the convention and are listed by SHA, since the words do not separate the cases. Excludes two debatable ones - 0fda64c pairs a cost saving with a retry-storm fix, 383b45a disables an optimisation to stop modal blocking.
015d89aNow 83s (271 runs, p90 102s) instead of a 92s lifetime average. 30 days rather than 7: 7 of the last 28 calendar weeks had no deploys at all, so a 7-day tile would read "not measured" a quarter of the time, and when it did resolve it could rest on one cold run - the week of 05/11 had a single deploy at 143s. When volume is healthy the two agree within 1s.
63083b3The all-time mean is dominated by history and lags a pipeline change indefinitely: it read 92s for over a week after e51f142 took the real figure to 82s. deploy stays for the record; deploy_30d is what the dashboard shows.
e9586e1Empty weeks sit out rather than counting as zero - a week with no deploys has no deploy time, and interpolating would invent data. Weighted by run count, not a plain average of weekly means: volume ranges 1 to 147 per week and correlates -0.65 with duration, so an unweighted line reads up to 14s high (101s vs the true 92s). Lines wear an ink token rather than a third hue, so they read as a smoothing of the two series rather than a third one.
e5e968bMean headlines the tile; p90 stays in the label because the mean is pulled by outliers and p90 is what shows a slow tail becoming normal. The chart nests the mean inside the p90 bar rather than stacking them. They are one measure at two percentiles, so a true stack would draw a 206s total that does not exist; read this way the upper band is p90 minus mean, which is real. One hue at two steps for the same reason, plus the legend two series require.
a079d1dfetch-depth: 0, because the default shallow clone has one commit and the first-commit date sets the scan start. window_days input now narrows a scan rather than defining one.
0373833Two bugs, both mine. The window was an arbitrary 90 days, and the weekly series was recomputed each run - so every week ageing past the window was dropped for good. GitHub's 90-day retention covers logs and artifacts, not run metadata, which is what timings come from: January runs are still queryable, so the cap bought nothing. Default is now the whole project, derived from the first commit rather than a constant, and the weekly series accumulates - weeks outside a scan are carried forward, never dropped, so this survives GitHub eventually purging them. 21 weeks now, 2026-02-16 to 2026-08-24, from 863 deploy runs over 226 days. Adds mean_s alongside median and p90.
53d1477a45061fThe file reported window_days: 90 while holding 46 days. GitHub caps the runs list at 1000 items however you paginate - page 11 returns nothing against 3082 runs on main - so paging deeper cannot work. The scan is now partitioned by week and each partition paginated on its own. 367 deploy runs over a real 90 days, up from 322 over 46. p90 is 110s, not the 106s the short window suggested. Adds covers_from, covers_days and complete, so nothing can render a window the data does not support, and warns loudly if any week hits the cap.
01772bfChicago style, so prepositions stay lowercase: "Commits by Time of Day", "Lines of Code over Time".
b14ab66Weeks with no deploys render as empty columns, not zero-height bars: a gap means nobody shipped - the vacation is visible in it - and a 0 would read as an instant deploy. Tile relabelled to "deploy time p90".
9d86c0eA single p90 says whether deploys are slow now; the series says whether they are getting slower, which is what actually creeps up and blocks shipping. Bucketed by ISO week from runs already fetched, so five weeks of history exist immediately rather than accruing from today.
85e35f0The mix was cut to most_common(3), which hid 31 commits and made infra/CI invisible on the whole page - 9 spread over 8 months never places in a single month's top three. Totals were always right; the month headers were not showing what they summed to. Chip and count now render through one cat_chip() used by both legends, wrapped so a line break cannot land between them - "docs" and its 12 were splitting across lines, leaving a chip that looked unlabelled next to an orphaned number.
448fe29"Not just the ones that shipped ... the rest are docs, ops and tooling" framed non-deploying commits as normal, which now argues against the one-commit-one- deploy policy. The figures were already computed; only the editorialising was stale. States the split and leaves the reader to judge it.
76662e3A number in prose is computed at build time or not written. The dashboard carried "about 73 seconds" for months against a real p90 of 108s. If it cannot be computed, leave it out - no approximations, no "about N".
9a99148Says what the page is and where the Commit Log is. Drops the commit count and deploy-time sentence: figures belong in tiles, where they are computed and scoped, not in prose nobody re-reads. Removes deploy_prose, now unused.
0979e3eRetention is 3 days, not the 7 that looks natural: these arrive at ~4/day and burst to 9, so a 7-day window closes nothing for a week and settles at ~26 open a burst cannot leave an agent with nothing to read. Only closes, never deletes, and only touches the machine label. Dropping the label exempts an issue permanently.
99aff74Diagnostic dumps and roster reports are CI output parked where an agent can read it, not work items. 15 of 20 open issues were these, burying the 5 real ones. The roster-widths FAILED issue is deliberately left unlabelled - that one is a work item.
6c2892eNot a metric worth dashboard highlighting. The figure survives in the models-line prose, so nothing is lost, and the grid returns to 5 columns for a clean 5/5 at 10 tiles.
5fa88ffThe workflow I added yesterday was the only one of 18 without a failure reporter. It runs unattended on a schedule, so a broken collector would leave the dashboard serving stale numbers as current - the silent no-op the metric exists to catch.
9a78349A mechanism that reports success must first prove it ran. Five instances in this repo of code that looked correct and touched nothing real, including a monitor that watched six pushes with a binary that was not installed. Rules: probe before watching, fail loudly and write nothing, never report 0 for unmeasured, depend only on git/curl/python3, and treat an API success flag as a claim rather than proof.
593ece2633ab77Runs at 06:37 PT, just ahead of the 07:00 PT dashboard rebuild, so the page reads fresh numbers. Commits ops/ci-metrics.json only when it changes. Pushes with the default GITHUB_TOKEN, which GitHub does not use to start new workflow runs - that is what stops release-notes.yml, which has no paths filter and messages the Admin group on every push to main, from announcing a metrics commit every morning. Switching to a PAT would remove that protection.
f91b456Deploy duration and test duration are what gate deploy frequency; neither was measured. Over 90d: 324 deploy runs, median 83s, p90 108s, max 148s - the dashboard's hardcoded "~73s" understates p90 by nearly half. Stdlib only, no gh: the production-alert monitor was built on gh and silently did nothing for six pushes because the binary is absent from that environment. Fails loudly and writes nothing on no-token, bad repo or unreachable API, so a collector that produces nothing cannot look like a quiet week. Tests are null until a suite exists, rendered as "not measured" rather than 0.
bddefd6Fine-grained commits and high deploy frequency going forward. The pattern to stop is mixed commits: 265 of the 422 that never deployed swept docs, tooling and workflows together. Rule 7 still holds - split the commits, push them together, so docs never reach main without the code.
4d98ae1c393c171a71b19da81b7eb06e4f2ac46435b617b60d3a003c7df9eb41d62b4373105a1bce0e3aFixed a rendering bug found while testing: dropping the voting section left three blank lines where it had been, because the separator was unconditional. Verified all three states render correctly -- games left, no games left, and no poll at all -- plus the fail-open path.
8d8eb9b39d025cVerified no hardcoded zone label remains in any formatted output, and the release message renders correctly as PDT.
4e39bcc01b0cd209a022b68ed3f9Verified the error is now single and accurate for both ambiguous and unknown names, and that scored, unscored and numeral-name lines still parse unchanged.
1e6499eGene dictated "/PB match Gene Chuang and John Wang 2 beat Gabe and Ryan six" and got "No player found matching 'ryan six'". Two causes, both now fixed. Verified against 11 cases including both collisions: "beat Ryan and John Wang 2" keeps the player and takes no score, while "beat Ryan and John Wang 2 nine" takes the score AND keeps the player. Also covers digit and word scores on @-lines, zero, the 14 cap, and 15 correctly rejected.
f19287e1277a09Verified: the subcommand registers and its help renders; validation rejects 7.5, 3.7 and "abc" before any sheet access; validate_dupr accepts 2.0/3.5/6.0 and rejects out-of-range, wrong-increment, non-numeric and empty.
9d1d668Routing checked against 12 inputs: all four @-mention spellings, the four self-service spellings, out-of-range, non-numeric, and the no-rating case. Resolution checked against the real shared resolvers with a stub roster including two Dereks and a name ending in a digit.
9f51e1b557cb081b751b024489b83cca0b8b8d81845a9e9f2fc5b660101fd99a32da25I called this done twice and it was not. The first pass removed 7 module-level `PST =` definitions; the second fixed one straggler in webhook/main.py. Neither touched the aliases or the inline constructions, so the zone was still being rebuilt in 20+ places under six different names: PST, _PACIFIC, PACIFIC_TZ, pst, pacific, pst_tz. Verified: config.py is the only place the zone is constructed; all seven shared modules resolve PST to the same object; `.localize()` still returns PDT, so the 12 callers that need a pytz object rather than ZoneInfo are safe; the dependency gate passes on all four functions; both CLIs and the dashboard build still run.
21e6a1cBoth self-inflicted outages in ops/incidents.json were Claude-pushed deploys, so the guard worth having is the one that runs before the deploy, not a sweep that notices afterwards.
2d95dc6ac4d2ec35a83d2731f541Caught by the local VS Code session, which left it deliberately rather than edit a file this session was mid-refactor on.
577d3b74b749abb0c2683 moved the PST constant into shared/config.py and imported pytz there. config.py is imported by every function, but whatsapp-sender's requirements did not include pytz -- its own main.py never referenced it. The function failed at import: File "/workspace/shared/config.py", line 12, in <module> import pytz ModuleNotFoundError: No module named 'pytz' whatsapp-message-sender is the Pub/Sub consumer that delivers every WhatsApp message, so nothing was being sent. venmo-trigger already had pytz; webhook and picklebot already had it. Only this one was short. Added to requirements.in and pinned in the compiled requirements.txt at the same 2026.3.post1 the other three functions use. pytz has no dependencies of its own, so the hand-added pin matches what pip-compile would emit. The general lesson is recorded in requirements.in: shared/config.py is imported by all four functions, so ANY dependency added there becomes a dependency of all four.
b0c2683Vote and balance reminder emails now carry today's PST date: SMAD Pickleball - Vote Reminder 🗳️ - 08/27/26 An identical subject every day made Gmail thread the copies, so the newest nag hid under older ones, and a repeated subject to the same recipient is exactly what spam filters score on. One-off mails (Last Call, Payment Received, Game Day) are unchanged -- they do not repeat. Implemented as append_date= on the shared maybe_send_email() / maybe_send_extra_notifications(), with subject_date() as the single date formatter, rather than pasting strftime at each call site. Two duplications found while doing it, both fixed rather than noted: PST was defined in 7 modules. The copies had drifted: firestore_utils used a fixed timezone(timedelta(hours=-8)), an hour off for the ~8 months a year California is on PDT, skewing every human-readable timestamp it wrote. Now one definition in config.py -- the module that imports nothing else, so anything can take it without a cycle -- and every other module imports it. Four now-unused `import pytz` lines dropped (AST-verified unreferenced, not grepped). email_service.send_payment_reminder() built its own HTML body, its own subject, and sent through its own send_email(): three copies of what webhook/shared already owns, and already drifted -- its wording differed and it had no nag count and no pre-pay hint. It now delegates to format_payment_reminder_dm() + maybe_send_email(), so the legacy `smad-sheets.py send-reminders` path (documented in SMAD_SETUP.md, which is why it was not simply deleted) sends the same message as picklebot and send-balance-dm. 48 lines removed. Verified: all ten touched files compile; the five shared modules resolve to the same PST object; subject rendering checked for dated and undated paths and across five consecutive days; the reminder body rendered from the exact dict email_service now builds, including a one-word name.
80441ecThe pre-pay hint that closes the balance block runs several lines and butted straight into the "Not Voted for This Week's Poll" header, so the two read as one paragraph on a phone. A blank line now separates the two sections. Lines within each section stay single-spaced -- a table and the blurb explaining it belong together, which is what the previous all-single-newline rule was protecting. Checked all four branches: poll with non-voters, no poll this week, nobody carrying any balance (no table at all), and everyone in credit (no pre-pay hint, so the break falls right after the code fence).
25ee905"$10 bal" and "$10 cred" were built as one string and right-aligned as a unit. Because "bal" is a character shorter than "cred", every balance row sat one column right of every credit row, so the dollar figures never lined up with each other -- which is what the group sees each morning in the shame report. Figure and suffix are now separate columns: figure right-aligned so 10 and 140 agree on the units digit, suffix left-aligned so it cannot move the number. Also visible in the same report: the table rendered at 35 columns and wrapped "Nags" onto its own line on a phone. "Pay Nags" is 8 characters over a 1-digit column; the block title already says these are payment nags, and the vote table keeps its own "Vote Nags" label. Header is now "Nags" and the table is 31 columns, inside the ~32 budget that format_player_table() already works to. Checked against the reported data plus all-owing, all-credit, single-row and 4-digit cases; the widest lands on exactly 32.
54130dd4721344a7e400d505175b9ddb64e8a51da410eb8e2The project cannot be rebuilt from `terraform apply` alone, and nothing tracked that. Enough of the live infrastructure was created by hand that a new project -- or disaster recovery -- is a manual runbook nobody had written down. Records the four gaps with file:line evidence (secret values, the two IAM roles granted by hand in July, errorreporting.user, the processed_messages TTL), the current rebuild order, and a plan for a bootstrap module plus a secret manifest. Two things worth keeping visible. Because apply is all-or-nothing, an IAM resource Terraform cannot grant blocks every unrelated infra change until it is commented out -- so these gaps cannot even be represented in the config. And the chicken-and-egg behind them is arguably correct: letting github-deploy self-grant would let CI award itself any role, so the fix is a human-run bootstrap step, not wider CI permissions.
5761dbbd0fcc1dThe doc treated "no /pb archive command near the removal" as evidence. That signal searched the group chat export, but archive is normally run by DM to the bot, which a group export cannot see. Kevin Cheng and Nardo Manaloto are the counter-example: both were archived by DM'd commands invisible to the export, then removed by hand when the archive failed to remove them. Marked invalid rather than deleted, so the next reader does not re-derive it. Those two removals are now explained, which leaves Albern as the only unexplained removal in the group's 89-event history. Adds the cheapest decisive check, which had been missed: Albern's Vacation Return cell. 12/31/2099 means code archived him and the auto-remove path took him out; his real date means no archive touched him. The auto-remove path has been live since 8/20, before the incident. Also records why he could be archive-eligible at all -- is_on_vacation() goes False once the return date passes, so a lapsed vacation makes a player a candidate again.
e4dea47Written so the work can continue in a local session that has gcloud, the .env and the project checkout -- none of which exist in the remote sandbox this was investigated from. Records the exact timestamps recovered from the iOS chat export, what each candidate was eliminated by, the two open questions that could still overturn it, and the gcloud/Firestore queries to run next. Two traps are called out explicitly because both produced a wrong conclusion during the investigation: the WhatsApp Desktop UI renders late-synced system events roughly two hours out of order, so only the phone export can be trusted for timing; and a GREEN-API webhook arrives identically whether that session performed an action or merely observed it, so silence in the logs is not exoneration.
e526a8eVerification already sat outside both booking loops, so it never delayed the next item on the booking list. The timing risk was elsewhere: the email and WhatsApp reports are sent AFTER it, get_reservations() paginates with `while True` and waits on networkidle, and the GHA job allows 10 minutes. An unbounded check on a slow grid could have burned the job timeout and sent no report at all -- worse than the wrong report it was added to prevent. try/except does not cover a hang. Capped at VERIFY_TIMEOUT_SECONDS (default 90, env-overridable) via asyncio.wait_for. On timeout the claimed bookings are marked unconfirmed and the run continues to the report.
9434752A booking report came back "[OK] SUCCESS" for North 09/01 7:00 PM and no reservation existed. The success was never evidence of anything. book_court() ended its try-block with an unconditional `return True`, and three failure paths logged an error and then fell into it: the "Make Reservation" button not being found, the click returning false, and the click throwing. True became status 'success' became [OK] SUCCESS in the email. Even the happy path checked nothing -- its idea of confirmation was a log line pointing at a screenshot whose capture is commented out. Not safety mode: the Cloud Scheduler job posts only {ref: "main"}, so the workflow passes SAFETY_MODE=False and this was a real submit that did not take. The likely mechanism is the parallel north/south jobs, which both book both courts: two dialogs open on the same slot, both clear the "already taken" pre-check, both submit, the server accepts one, and the loser reported success. If the grid cannot be read, or comes back empty, nothing is downgraded -- it is marked unverified. An unreadable list is not evidence a booking failed, and inventing that verdict would be this same bug facing the other way. RESERVATIONS_URL hoisted to a constant; it was pasted in two places and verification needs a third. Verified the reconciliation against site-holds-nothing, holds-it, holds-one-of-two, wrong-court, read-throws, empty-list and nothing-claimed, including 7:00 PM vs 07:00 PM normalization. Not exercised end to end against the live site from this session.
5ae7ce3The note still described the rank cell as "73." with a trailing period, which the previous commit removed. Left as-is it would send the next reader looking for a column that no longer exists.
2bf87c4At 73 players the rank cell was "73." -- three characters where "73" is two -- which put the full player table at 33 monospace columns and wrapped it on a phone. The names were never the problem; the period was. Right-aligned digits already read as a rank column without it. Measured against the live sheet (73 players, longest name "Greggory Millward" at 17 chars): /pb players (all 73) 33 -> 32 was wrapping, now fits Top 15 leaderboard 32 -> 31 8-player game roster 32 -> 31 It also closes a latent trap: the Top 15 sizes its name column to whoever is in it, and a 17-char name entering it took that table to 33. Now that case lands on 32.
aedc920The report covered game rosters and /pb players but skipped the Top 15 leaderboard, which is the table most people actually see (Group Stats, Game Day report) and is not implied by either. It ranks by hours, so the names it contains -- and its width -- have nothing to do with who is on a given game roster. It also carries a 3-char rank cell ("15."), one wider than an 8-player roster's "8.", so it can wrap on a name that a roster renders fine.
93728ddThe first run went green and reported "0 players ... fits", which is the worst possible answer: a credential failure dressed up as a clean bill of health. Two causes, both fixed: stderr is captured to a file and posted as an issue on failure, because this session cannot reach GitHub's run-log host -- otherwise a failing run is just as silent as the wrong answer it replaces.
6b11febThe roster and leaderboard size their name column to the widest name present, so whether a report wraps on a phone is decided by who is on the sheet -- a fact that exists only in production. There was no way to check it without reading the sheet by hand. Reports the longest names, the worst-case 8-player roster, which single names tip a roster over on their own, the real upcoming game rosters, and the full /pb players table. It measures the output of the real format_player_table() rather than re-deriving the column math, so the answer cannot drift from the renderer. Push-triggered on ops/roster.txt and reported as an issue, the same pattern as diagnose-logs and error-reporting, because the App has Actions read but not write and run-log bodies are unreachable.
81000dbThe roster printed its own layout with a label on every row ("Danny Mata DUPR 2.0 Hr 06"), which spent the width on repeated labels and left no room for win-loss. It also zero-padded hours, so 6 hours read as "06". Delegate to format_player_table() -- the renderer the Top N leaderboard already uses -- so both share one header row and one set of column widths. That frees the space for a W-L column and lands the roster at exactly 32 monospace columns, one line per player on a phone. Roster order is preserved via a new sort_by="none": the list is alphabetical so a player can find their own name, and the Game Day report it sits in carries the hours leaderboard a few lines further down. Re-sorting the roster by hours would print the same ranking twice. A voter with no roster row still gets a line rather than being dropped, so the roster cannot disagree with the "Players (N)" count above it.
db59b9fLogging a match confirmed only the lines just sent, so a scorekeeper logging a session one match at a time had no way to see what was already recorded without asking for a separate report. Reuse the post-game report module instead of a second renderer: The block is best-effort and deliberately skipped when it would mislead: dry runs (nothing was written), all-failed batches, and an empty session. A match-log read failure logs a warning and still returns the confirmation -- knowing the match was written matters more than the list. The old "Logged against the <game>" line becomes fallback_note, rendered only when there is no block, since the block header already names the game.
b8881f3Lists every match logged for the last game played, then each player's record for that session. Session records are deliberately not lifetime ones: someone who went 3-1 tonight reads that as tonight's result, and their overall record is already in /pb standings and /pb stats my. get_session_matches() keys on Game Date AND Game Time, so a 7pm and a 9pm game on the same date never merge into one report, while a session logged without a time label still matches. It also strips the leading apostrophe those cells are written with, so a hand-typed or re-exported row cannot silently fail to match. The intent sits above the record-match branch on purpose. That branch matches the prefix "match ", so "match report" would otherwise be parsed as logging a match against a player named "report" -- verified, along with "/pb match @a @b beat @c @d 9" still routing to record_match. Read-only, so it is available in the SMAD group as well as Admin, and added to both the CLI and picklebot per the shared-code policy.
73f08c72c9652d27208a2The four function deploys could fail with no trace anywhere readable: run log bodies are served from a host the Claude Code session cannot reach, and GitHub only emails on failure, never on the subsequent fix -- so a red email is not evidence anything is still broken, and there is no green one to say otherwise. Twice today a failure was only noticed because it was relayed by hand. Written as a composite action rather than the same block pasted into four workflows, which is the duplication this repo already has a policy against. It also dedupes: a repeat failure comments on the existing open issue instead of opening another, so a flapping deploy cannot bury the tracker. Each workflow was validated after editing -- the reporter is the last step, guarded by failure(), and the job carries issues: write -- and reverted rather than written if any check failed.
994ca28Every Python traceback starts with 'Traceback (most recent call last):', so all three open groups rendered identically and the report could not be used to decide anything. Uses the last non-empty line, which carries the exception type and message, and adds the affected service.
6f41729github-deploy cannot modify the project IAM policy, so Terraform applying that binding fails with 'Policy update access denied'. Apply is all-or-nothing, so leaving it in blocks every unrelated infra change -- which it already did. Same situation as monitoring.admin and logging.admin, which the file already records as hand-granted. The gcloud command is in the comment.
7b20067The plan job reports its own failures, which left apply as the one place a Terraform error could still vanish -- run log bodies are served from a host the session cannot reach, so a failed apply was just a red X. Adds pipefail (same reasoning as the plan step, where tee was masking the exit status) and an issue containing the error and permission lines.
4a4f7e9The listing workflow returned SERVICE_DISABLED: Error Reporting shows up in the console because ingestion is automatic, but the REST service was never enabled, so nothing can list or resolve groups programmatically. Grants github-deploy roles/errorreporting.user, which covers groups.list/get/update. errorreporting.admin would additionally allow deleting all project error data, which nothing here needs.
d761a3bThe session has no route to GCP Error Reporting notifications, so an outage is only noticed when someone relays a Slack alert. This reports open groups as a GitHub issue the session can read, and can mark them RESOLVED once a fix is out. Resolve only ever touches groups whose lastSeenTime is older than quiet_minutes. Marking a still-firing error resolved would hide a live outage, so those are reported and skipped rather than closed.
1e18b20Push-triggered runs now read 'minutes=N' from ops/diagnose.txt, so the window can be widened without a workflow_dispatch this session cannot make.
d4511cbgame-reminder.yml had been broken since 2026-02-10. It passed three positional arguments -- date, time, court -- to a parser that accepts two, so every run died on "unrecognized arguments: Both". It worked in February, so court was removed from send_game_reminder() at some point and the workflow was never updated. send_game_reminder(sheets, game_date, game_time, dry_run) has no court parameter at all, so the input was dead as well as fatal; it is gone. Date and time are now optional: leave both blank and the workflow runs --send-on-game-day, which auto-detects today's games and reports why it skipped if there are none. Both invocation shapes were checked against argparse rather than assumed. /pb game remind dispatches that workflow instead of reimplementing the reminder. send_game_reminder marks attendance in the sheet and fans out DMs; a second copy in picklebot would be two things to keep in step. Admin-only, and "dry run" previews to Admin Dinkers without messaging the group or touching the sheet. Verified: all four command phrasings parse, "game cancel next" still routes to cancel_game rather than being shadowed, blank date/time omits the inputs so the workflow auto-detects, and a missing token or non-204 response surfaces as an error rather than a false success.
06e2d1d/pb game next stopped after the game block, so previewing the game day format meant dispatching the game-reminder workflow from the GitHub UI. It now carries the same modules as the reminder -- Top N leaderboard, weekly attendance, balances and the non-voter list -- via the same format_group_stats_block(), so the two cannot drift. Composed in render_next_game rather than format_next_game: the latter is a pure formatter over already-fetched data, while the extra modules need their own lookups. Stats are appended only when there is a game, so the "no poll data" and "no games" replies are untouched. Every added module is best-effort and independently wrapped. The next game is what was asked for; weekly attendance, the poll date, the non-voter list and the stats block can each fail without costing the caller their answer. Verified for all five failure paths plus the no-games case.
e333411smad-whatsapp.py and webhook/shared/command_formatters.py both began with EF BB BF. Python strips a leading BOM itself (PEP 263) so nothing was broken at runtime, but tools that read the bytes do not: ast.parse() rejects U+FEFF outright, which is how this surfaced. Both were introduced on 2026-08-21 -- fc45723 and eec2b05 -- which is the signature of Windows PowerShell 5.1, where Set-Content -Encoding UTF8 and > redirects write UTF-8 with a BOM by default. Only the first three bytes of each file changed; the diff is one line per file and the remaining content is byte-identical. Verified after: both compile, both ast.parse from raw text, command_formatters renders, and smad-whatsapp.py imports. Adds a .gitattributes note so the cause is recorded where someone editing from a shell will see it. Use -Encoding utf8NoBOM (PowerShell 7+).
476189aThe daily shame report and the game day reminder both fired at 8am and both carried a balance block, so on a game day the group got two near-identical nag lists minutes apart. Now the shame report skips game day entirely, and the game day report's group stats gain the non-voter list, rendered after the balance/credit block by the same format_vote_shame_block() the shame report uses -- so the two cannot drift. non_voters=None (the default) omits the section, which keeps existing callers such as /pb games unchanged; an empty list still renders the "everyone voted" line. The distinction matters: "no data to show" and "nobody to shame" are different states. Computing the list is wrapped -- if the poll date or sheet lookup fails, the section is dropped and the reminder still goes out. The reminder is the point; the list is a bonus. Verified: suppressed on game day, still sent on an ordinary day, and the pre-existing poll-creation-day rule still applies (including when both fall on the same day).
d6a73faLocks the root requirements (53 packages) the same way as the four Cloud Functions. This one matters more than its "CLI only" label suggests: it is installed by 10+ workflows including court-booking, which races other club members for courts at midnight. An unpinned build there resolves "latest" at run time, so a bad release lands mid-race with no warning. Verified in a clean venv -- playwright, venmo-api, matplotlib, googleapiclient, pubsub, scheduler, storage and firestore all import, and api-core resolves to 2.34.0 with the correct (default) path. Also adds a weekly check for a google-api-core release that is safe to unpin. It does not compare version numbers: it installs each candidate and reads Client()._database_string, which is where the bug appears, and opens an issue only when a release is genuinely fixed. Silence means nothing to do. That check exists because a stale cap is the failure mode this repo keeps producing -- a gitignore rule that matched nothing for six months, secrets orphaned by a half-finished migration, a scheduler job removed from code but not from the project. A cap nobody revisits is the same shape. Probe logic validated locally: 2.34.0 reads (default), 2.35.0 reads %28default%29.
f0ee6c7Completes the rollout started with whatsapp-message-sender: webhook (47), picklebot (53) and venmo-trigger (53) now pin every transitive dependency. requirements.in is the edited file; requirements.txt is generated, and Cloud Functions installs from it exactly as before. Each lock was installed into a clean venv and checked: google-api-core resolves to 2.34.0, Firestore builds projects/p/databases/(default) rather than the URL-encoded form, and picklebot's module-scope imports all load -- pubsub, storage, scheduler, firestore, googleapiclient, service_account, cloudevents. Note that google-cloud-firestore resolves to 2.29.0 in every lock. It shipped the same day as the api-core regression and looked like the culprit, but it is fine; capping api-core is what matters.
8f48401The recipient lines already end in "\n", so prefixing group stats with "\n\n" produced three newlines and rendered as two blank lines. Verified across all four shapes -- survey-available list only, no-survey list only, both lists, and nobody to DM -- plus the no-group-stats case, which must not leave a dangling newline.
0d62f52Adds requirements.in as the edited file and generates requirements.txt with all 38 transitive dependencies pinned. Cloud Functions still installs from requirements.txt, so the deploy is unchanged. Unpinned builds resolve "latest" at build time, which made every deploy a bet on whatever shipped that day. google-api-core 2.35.0 -- a transitive dep named in no requirements file -- URL-encoded the Firestore database id and took WhatsApp delivery down within a minute of the deploy that resolved it. A cap on the direct dep would not have helped, because the direct dep was not the problem. Verified by installing the lock into a clean venv: api-core resolves to 2.34.0, Firestore builds projects/p/databases/(default) rather than the encoded form, and the runtime imports load. Rolling this out one function at a time so a bad lock cannot take all four down at once.
e9b9e74The audit write sat between process_command and send_whatsapp_message, so every command paid for a Firestore round trip before picklebot attempted to answer. Exceptions were swallowed, but latency was not. The google-api-core 2.35.0 regression made that concrete: with every Firestore call failing, each command burned a failing write before even trying to reply. Auditing should never be able to delay or degrade the thing it is auditing. Duration is still measured where the command finishes, so the metric still describes command time rather than reply time. The failure path audits immediately because it re-raises and never reaches the reply. Verified: reply precedes audit on success, a failure is still recorded with ok=False, and an audit that raises leaves the reply already sent and the request returning 200.
7d9b01bgoogle-api-core 2.35.0 URL-encodes the Firestore database id into the resource path -- projects/P/databases/%28default%29 instead of (default) -- so every Firestore call fails with "400 Invalid database id". That took WhatsApp message delivery down: whatsapp-message-sender could not claim messages for dedup, so replies stopped being delivered and /pb returned nothing. Errors began 02:54:29Z, within a minute of the first deploy that rebuilt with the new library, and were still recurring at 03:01:57Z. Nothing in this repo changed to cause it. Every requirements file left google-cloud-firestore and its transitive deps unpinned, so any deploy would pick up whatever pip resolved that day. The trigger was a routine push touching webhook/shared/, which rebuilds all four functions. Isolated by bisecting rather than guessing. google-cloud-firestore 2.29.0 also shipped 2026-08-24 and looked like the obvious suspect, but it is innocent: with api-core 2.34.0 even 2.29.0 builds the path correctly, and with 2.35.0 firestore 2.28.1 is broken. google-cloud-core is irrelevant in both directions. Verify any future bump with Client(project='p')._database_string.
643a1c0picklebot hardcoded claude-3-haiku-20240307, which has been retired upstream. Every natural-language command was returning 404 not_found_error, which is what tripped the Cloud Run ERROR alert. Fast-path commands were unaffected -- parse_intent_fallback runs before the API call, so /pb help and friends never touched it. Only commands the fallback parser cannot handle were broken, which is why this stayed invisible. Moves the model to ANTHROPIC_MODEL in shared config, defaulting to claude-haiku-4-5-20251001. Env-overridable so the next retirement is a variable change rather than a code deploy. Also widens the log diagnostic to whatsapp-message-sender: picklebot returned 200 for every command, so a missing reply is downstream of it.
0189141General-purpose 'what is production saying right now' reader: severity >=WARNING plus container lifecycle lines and request-status counts for both HTTP functions, reported as an issue because run log bodies are served from an unreachable host. Truncates every line rather than dumping payloads, which carry PII.
39616d4send_game_reminder bailed on an empty roster with a bare logger.warning and return (0, 0). Nobody was told, so a game day report could fail to go out with no trace anywhere -- which is precisely why "why didn't the game day report send?" was unanswerable earlier today, and it was made worse by INFO logging being discarded in production at the time. Now notifies the Admin group, and distinguishes the two causes, because they call for different responses: The notification is wrapped: a failure to reach the Admin group must not turn a skipped reminder into a crash.
db84b44Both payment sync paths wrapped maybe_send_extra_notifications in "if sms_reminder:". That made sense when SMS was a real channel, but Twilio is gone and the function now sends email only -- so anyone who had not opted into SMS received no payment notification at all. Not a downgraded one: nothing. zelle_sync's own comment above the gate still read "# SMS + email", which is where the assumption came from. The guard was redundant as well as wrong: maybe_send_email already no-ops for a player with no email on file, which is the check that actually matters. Verified that a player who declined SMS but has an email now gets one, and that a player with no email is still skipped.
85a7b17call_api logged only the status code, which is what made the archive removal bug expensive to find: picklebot was deployed without GREENAPI_INSTANCE_ID/API_TOKEN and removeGroupParticipant returned a bare 403, indistinguishable from GREEN-API refusing an authenticated request. Fixed in 9781931 by mounting the credentials; this makes the next such failure legible instead of opaque. Adds the response body (capped at 300 chars) and, on 401/403 with empty credentials, says so explicitly. The URL is still never logged -- the API token is a path segment in it.
9eebb0cProvisioning a Firestore TTL field runs a server-side field operation that took over three minutes. That exceeded the Terraform job's old timeout-minutes: 3, killed the apply mid-run, and left a stale state lock in GCS which then blocked every subsequent plan. Recorded next to the resource so the next person does not rediscover it the same way.
548e664Run log bodies are served from a host that is unreachable from the Claude Code session, so a failing plan is currently a dead end without opening the browser. On failure the plan job now opens an issue containing the error and lock lines only -- not the full plan, which can echo resource attributes and whose state reads a secret version -- plus the force-unlock command, since a killed apply leaving a stale lock is the likeliest cause.
1021becThe plan piped into tee, so the step's exit status was tee's. A failed plan reported success, wrote no tfplan, and the failure only appeared two jobs later as "Download Plan Artifact: failure" -- pointing at artifacts rather than at the plan error that actually occurred. That is exactly how the last run presented, and it cost a diagnostic round trip. Adds set -o pipefail, preserves the real exit code, echoes the tail of a failed plan into the job summary, and sets if-no-files-found: error on the upload so a missing tfplan is reported where it happens.
d791127The apply for the pb_commands TTL ran 3m15s against timeout-minutes: 3 and was killed mid-apply, so the run reported "cancelled" and the TTL may not have converged. Firestore TTL and index field operations provision a field index server-side and routinely exceed three minutes; the previous apply, which only touched a log exclusion, finished in 17 seconds. The old limit was tuned for fast resources. Plan 3 -> 6, apply 3 -> 15. State is in GCS with locking, so a killed apply can also leave a lock behind -- the next run will surface that as a state lock error rather than silently doing nothing.
95a4affCloud Logging cannot answer who uses /pb and how often. The webhook deliberately keeps payloads out of the logs because they carry PII, so no log line records a sender -- and the INFO lines that recorded the command itself were being discarded entirely until the previous commit. Counting usage from logs is not fixable; it needs a durable record. One document per invocation in pb_commands: raw text, parsed intent, sender name and phone, chat type, success/error, duration and dry-run flag. Raw text is kept -- it can contain player names, but Firestore is reachable only by the service account and those names already live in the sheet. Duration also exposes cold starts. Wrapped at the single process_command call site rather than inside it, so duration and failures are captured uniformly including exceptions, and log_command swallows everything: auditing must never break the command a user asked for. /pb usage reads it back, admin-gated, since it shows who ran what. The TTL (400 days) is declared in Terraform rather than set with gcloud like the processed_messages one, which is invisible to Terraform -- the same drift that left dead Twilio secrets and orphaned SMAD_SPREADSHEET_ID in Secret Manager.
46bedbaThe keepalive job GETs the webhook every 10 minutes and the webhook GETs picklebot in turn, about 288 request log lines a day. Over a 30 day sample that was 4,549 of picklebot's 4,663 lines -- 97% noise, burying the entries anyone actually needs. For legibility, not cost: ingestion is a few MB a month against a 50 GiB free tier and Cloud Logging does not appear on the bill. Kept narrow because an exclusion drops entries at ingestion and they cannot be recovered. Only status=200 is dropped, so a failing keepalive still lands in the logs -- the GET handler now returns 503 when picklebot is unreachable, which is the case worth keeping. Only GET, so inbound POST webhooks are untouched. ?action=confirm is preserved, being a person clicking a confirmation link rather than a health check.
d47c912logging.basicConfig() is a no-op when the root logger already has a handler, and functions-framework installs one before these modules are imported. The level therefore stayed at WARNING and every logger.info() call was dropped -- roughly 128 call sites, 54 in the function files plus 74 in shared/ which CI copies into each. Confirmed against production rather than inferred: in Cloud Logging every INFO string returns zero hits ("Processing command", "Parsed intent", "Message queued to", "Fast-path parsed") while WARNING and ERROR strings from the same functions are present ("Failed to remove ... from WhatsApp", "Claude API error"). Reproduced locally, and force=True restores INFO. This is why /pb usage cannot be counted -- the lines that would record it were generated and thrown away -- and why recent debugging had so little to work with.
fde6cd3The selector is confirmed working -- 4663 log lines for smad-picklebot in the window -- and retention reaches back to 2026-01-29, so July is queryable after all and the earlier 30-day assumption was wrong. But "Processing command" still matches nothing, so the phrase or the payload field is wrong rather than the usage being zero. Samples message shapes (text up to the first colon, digits collapsed) so the log vocabulary is visible without putting player names into a public issue, and probes other known picklebot log strings to separate a wrong phrase from a wrong field.
4ada34cThe first run returned 0 rows for every window, including the unbounded "oldest retained entry" query. That is the signature of a filter that matches nothing, not of a service nobody uses -- a wrong resource label returns 0 rows indistinguishably from genuine non-use, and reporting "you never use /pb" off that would have been wrong in a way that quietly decides the min-instances question. Now probes each candidate selector (service_name, function_name, configuration_name, logName) and each plausible payload field (textPayload for stderr, jsonPayload.message for structured logging), reports which matched, and prints total log volume for the service so a zero count can be told apart from a broken query.
8484fc4There is no durable /pb command log -- invocations exist only as "Processing command:" lines in Cloud Logging -- so counting them has to happen where GCP credentials live. Push-triggered rather than dispatch-only because the GitHub App has Actions read but not write, and results come back as an issue because run log bodies are served from a host the session cannot reach. Both are workarounds for the same sandbox limits, written down so the shape of this workflow is not mysterious later. Reports command verbs only. Full command text can embed player names ("/pb payment remind Jane Doe"), and the webhook already keeps payloads out of logs for that reason. Also reports the oldest retained entry, since the _Default bucket holds 30 days and July is likely already purged.
ed82349The GET keepalive returned 200 unconditionally, including when its picklebot leg failed. Cloud Scheduler only checks HTTP status, so a broken ping -- wrong PICKLEBOT_URL, timeout, picklebot down -- left the job green while picklebot quietly went cold. That matters because picklebot is the expensive half: 9 dependencies and 6 secrets read at instance startup, against 7 and 3 for the webhook. It is the cold start the ping exists to prevent, and the failure mode hid exactly that. Now returns 503 when picklebot answers >=400, is unreachable, or when PICKLEBOT_URL is unset -- the last being a misconfiguration that would otherwise warm only the webhook with nothing saying so. Scheduler records a failed run and retries.
2e99bb1Removes a 204KB GitHub Actions log export that has been tracked since February. Its filename is a mangled Windows path -- d:\code\SMADPickleBot \temp_logs.txt with the backslashes eaten and the colon replaced by U+F03A, a private-use lookalike -- so a redirect created a file in the repo root instead of writing to a path. That invisible character is why the existing `dcode*temp_logs.txt` rule never matched it: nothing matches `dcode` when a codepoint sits between `d` and `code`. The rule was written, looked right, and did nothing for six months. .gitignore had accumulated four such entries, each naming one dump after it caused trouble, including temp_logs.json which I added the same way earlier today. Replaced with rules for the class. git check-ignore confirms both dumps are now caught; memes.json and unicode_report.json stay tracked. Neither file contained a credential: the Athenaeum username and password in the CI log are *, along with 678 other values GitHub redacted, and the high-entropy strings that triggered the scanner are Cloud Run mount aliases, runner image hashes and resource paths. History is left intact -- rewriting it would invalidate every SHA for no security gain.
f2a55d5iam.tf grants the compute service account roles/secretmanager.secretAccessor at the project level, so every Cloud Function can already read every secret. The per-secret bindings in secrets.tf grant a role the member already holds and change nothing. The list reads as least privilege and is not: all four functions run as the same default compute service account, so per-secret bindings cannot restrict one function relative to another -- there is only one identity to bind. That would require a service account per function and dropping the project-level grant. This is worth writing down because the list is missing GREENAPI_WEBHOOK_TOKEN, which looks like an access gap and is not; the README claimed exactly that in the previous commit. Corrected there too.
89e5593Listed the live secrets rather than inferring them from the repo, which turned up three that nothing consumes: Records the remaining 8 in a README inventory table with what mounts each and why it is a secret, so the next audit does not have to reconstruct this. Also notes that GREENAPI_WEBHOOK_TOKEN is mounted by the webhook but has no Terraform IAM binding, and that Secret Manager bills for reads as well as storage -- cold starts, not secret count, drive that half. Drops the one-shot Twilio cleanup workflow; the deletions were done directly with gcloud.
d513413Terraform never owned TWILIO_ACCOUNT_SID / TWILIO_AUTH_TOKEN / TWILIO_FROM_NUMBER, so they survive in Secret Manager after the config cleanup and can only be removed with gcloud -- which has to run where GCP_SA_KEY exists. The secret names are a hardcoded allowlist rather than an input, so this cannot be repurposed into an arbitrary secret-deleter, and it requires typing DELETE to do anything. Idempotent: an already-absent secret is reported rather than failing. The exit status carries the outcome so the result is readable from the API without the log body. Safe to delete once it has been run.
cce35f3The Twilio account has been closed — the number was never approved for A2P 10DLC — and nothing in the codebase referenced TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN or TWILIO_FROM_NUMBER: no Python, no workflows, no requirements entry. Only three `removed{}` blocks in secrets.tf still named them. Those blocks were already applied, so Terraform holds none of these in state and dropping them changes no infrastructure. The secrets still exist in Secret Manager and have to be deleted with `gcloud secrets delete`; a note in secrets.tf records that, and that a future SMS channel should provision its own rather than reuse them. Also drops temp_logs.json, a 33KB Cloud Run config export that was committed by accident, and gitignores it. Checked before removal: it contains secret *names* in service annotations, not values.
95b4abbActual GCP billing for Aug 1-24 2026 shows Cloud Scheduler at $0.39 and Secret Manager at $0.49, against docs that claimed Cloud Scheduler was "$0.00/month, fully covered by free tier" and Secret Manager ~$0.30. The free tier is 3 jobs per billing account, and the 3 permanent jobs do fit inside it. But schedule-last-call.py creates per-game jobs every week -- 2 last-call jobs per game plus 1 lights reminder per night game -- so the live count runs well past 3 and has done for a long time. Three docs asserted $0.00 while reasoning only about the permanent jobs. Also records the GCP subtotal from real billing rather than estimates: $0.88 month-to-date, $1.06 forecast. Cloud Scheduler and Secret Manager are the only non-zero lines.
8cadfedCloud Scheduler jobs in this project were created by hand and imported into Terraform afterwards, per the import commands at the top of scheduler.tf. So removing a resource block is not proof the live job is gone: if it was never imported, terraform apply reports success and changes nothing, and terraform plan cannot tell the difference because Terraform does not know about resources outside its state. This runs gcloud where the credentials actually live. The exit status carries the answer so the outcome is readable from the API without the log body, which GitHub serves from a host the sandbox cannot reach. Deleting requires explicitly selecting action=delete.
45d6b60Poll creation had its own Cloud Scheduler job, 8am-sunday-poll-creation, which POSTed to the same workflow file as 8am-daily-runner and differed only by passing reminder_type=poll_only. That was never two runners: it was one workflow with two triggers whose steps were gated into disjoint sets. Both fired at "0 8" on Sunday, so the reminder run read poll state while the poll run was still creating it. A "Resolve run mode" step now decides from the PST weekday, and the poll steps gate on that instead of on reminder_type. They already sat above the reminder steps, so poll creation completes before any reminder runs and the race is gone structurally rather than worked around. Dispatching with reminder_type=poll_only still forces poll creation on any day. The poll steps get continue-on-error so a flaky Athenaeum scrape cannot abort the job and silently drop the payment, survey and shame reminders, which an isolated run could not do before. Timeout goes 5 -> 20 minutes since one run now does both halves including the Playwright install. POLL_CREATION_WEEKDAY becomes a single source: the GitHub Variable gates the workflow and is written into .env for the vote-reminder guard, so the weekday is no longer duplicated against a cron expression. This also drops the scheduler back to 3 jobs. Cloud Scheduler's free tier is 3 per billing account, and adding the Sunday job had quietly made it 4 while gcp-scheduler/README.md and CLOUD_COST_OPTIMIZATION.md both still claimed "exactly 3 jobs, fully within free tier". Removing the resource from Terraform does not delete the live job; that needs a terraform apply, which should show exactly one destroy.
155b7a1Vote reminders no longer go out on the day the weekly poll is created. A reminder that morning is about the poll being replaced, or about one that does not exist yet. The guard leads with a weekday test rather than asking whether a poll was created today. The poll-creation job ("0 8 * * 0") and the daily reminder job ("0 8 * * *") fire on the same minute every Sunday, and the reminder job normally wins that race: it reaches vote reminders in a minute or two while the poll job is still installing Playwright and scraping reservations. At that point the newest poll on record is still last week's, so a created-today check alone would not fire. That check is kept as a secondary signal for polls created ad hoc on other days. POLL_CREATION_WEEKDAY (default Sunday) must stay in sync with the sunday_poll cron in infra/terraform/scheduler.tf. Setting it blank falls back to the created-today check only. Picklebot's "/pb poll reminders" is deliberately untouched, so an admin can still send one by hand. Also sorts the not-voted-this-week list by last name when players are tied on vote nag count, instead of by first name.
9781931bb8a0b4a429f4d45c216bSize the table columns from actual content instead of fixed widths, and abbreviate the DUPR header to DPR. Hrs and W-L reserved 4 and 5 columns for values never wider than 3, and the rank field reserved 4 for "15.". At 36 columns the table overflowed the ~32 monospace columns a phone renders, wrapping W-L onto its own line. It now measures each column from its widest real value and comes to 32, with every header sitting squarely over the values it describes. All five columns are retained. Widths adapt: the rank narrows to 2 with single-digit ranks, and W-L grows to 4 once someone reaches ten wins.
1c762f4d77ef2fbab05ed68d99194ed157ce2f5dba1ca94f80f476dd45bc74390843a40e86a32a9a0eb70dbf15feec2b05fc457230e5903c0c63a57338baf6b299b0ed6f360c6cac3e680f6e75b56cb085383edbcf7ea5d7b6eccc1ef8bcee51f142df11a40c11bc11ef34661fffd0dde6510edc59c7658ad1dbbb5b50457d98b3748cfa77e191aaf40c472f47f13f45c3f41d0851fb1b59d8fc021c126f349792The previous "fix" incorrectly assumed venmo-api bakes timezone offset into epoch values. It actually returns standard UTC epochs, so plain fromtimestamp(ts, tz=PACIFIC_TZ) is correct. The offset correction was subtracting/adding 7 hours, producing wrong transaction dates. Also fixed Danny Mata and Ryan Hsu Payment Log timestamps manually. Added warning logs for Gmail/People API token refresh failures instead of silent except-pass swallowing.
5ca8584Deduplicate weather string formatting into _format_weather_line(). Humidity now displays as separate line with 💧 icon after wind.
04c67d7Reflect venmo last-name fallback, timestamp fix, humidity in weather, Top 20 leaderboard rename, bal/cred/nags formatting, dynamic PST/PDT.
9c6c0945778850b60184073e489bDocstrings and comments still said "2h before" after the constant was changed to 1. Updated docs to reference HOURS_BEFORE_GAME instead of a hardcoded value, and made the log message use the variable at runtime.
3b2d38eset_player_vacation() and set_player_dupr() were not passing credentials_file to get_sheets_service(), causing silent write failures in CI workflows where env var credentials aren't set but smad-credentials.json exists on disk. This caused sync-members to fail clearing Issa's 12/31/2099 archive date every day, yet still send a "Welcome back" DM each run.
507d8b3426e86b5c0d8dd9fa1364924e4b30ce12ab486c37eAdd unified personal stats block to Game Day, Vote Reminder, Payment Reminder, and Last Call DMs: this week's games, hours + rank, streak, milestone (50hr), favorite day, DUPR, and balance. Add group stats block to Game Day and Last Call group messages: top 10 leaderboard by hours and weekly attendance (this/last/2wks ago). Add standard DM footer with Picklebot self-help commands (/pb players, /pb balance, /pb set vacation, /pb help) via format_dm_signature(). New shared functions:
32a480bCancels North Pickleball Court reservation for the given date and records a $10 credit for Shyam in Payment Log. Looks up game time from poll data automatically. Only executable by Shyam or from admin group.
184324dAdds a second schedule trigger 30 min after the primary to handle transient GitHub Actions runner-not-acquired failures. Sync is idempotent so the backup run is a no-op if the primary succeeded.
578fa38The vacation exception code used `booking_date` as a local variable, which overwrote the main() parameter (None for scheduled runs). This caused prepare_bookings() to enter MANUAL mode instead of BOOKING LIST mode, using a hardcoded default date and failing with a strptime type error. Rename to `game_date` to avoid shadowing.
cf3c379Courts are now fetched in real-time from the Athenaeum website instead of relying on Firestore data stored at poll creation time. This handles court cancellations between poll creation and game day. Falls back to Firestore if the live fetch fails. Also pluralizes "Courts:" label when both courts are booked.
144f9a909d3456Players with an outstanding balance see their amount and Venmo link when setting vacation, with a reminder they'll still get payment reminders until paid.
50e15e8Payment reminders still go out during vacation if player has a balance.
55368d1Nobody uses it — they just vote "Can't play this week" instead.
dc6f97bAlso rescheduled existing Cloud Scheduler jobs for today's 6pm game (4pm→5pm) and tomorrow's 8am game (6am→7am).
de932a8Tailors the player need message in game reports based on how many courts are booked: warns about cancellation when under capacity, encourages rotation pairs when close, and celebrates when courts are full.
b01816dVacation now triggers the dead man switch (is_gene_dead returns True), suppressing poll creation, vote reminders, game reminders, and court booking. Vacation-triggered suppression logs [VACATION MODE] without sending admin notifications. Court booking has an exception: if the game date (invoke_time + 7 days) is after vacation return, booking proceeds to reserve courts for post-vacation games.
7da74896117a2fCourt bookings now appear in poll option text (e.g. "Mon 7/21/26 7pm <North>") and sheet column headers, so players see which court is booked at a glance. get_courts_for_game() also parses court from the option text as a fallback.
8043597Court mapping (game option → court name) is captured from Athenaeum reservations during poll creation, stored in Firestore alongside the poll document, and displayed in build_game_report() as 🏟️ Court: North.
c24d363e8a4feb6e3f9a8New members joining the WhatsApp group can't see chat history including the current poll, so vote reminders before the next poll creation (Sundays) are pointless. Set Last Voted to next Sunday instead of today.
fe4eb14af7c46cMissing env var caused HOURLY_RATE to default to 0, producing broken Invoiced formulas (=O*4+P*0 instead of =O*4+P*5) for new members.
3004153If getContactInfo returns a short all-caps name like "RY", treat it as initials and try Google Contacts for the full name before accepting it.
151c0b7Newer unpinned google-cloud-firestore + google-api-python-client versions have heavier gRPC dependencies, pushing usage to 266 MiB against the 244 MiB (256 MB) limit. 512 MB gives headroom; still well within free tier.
59960b225cf29eRequired for Terraform to create: Roles were also granted manually via gcloud before this commit to unblock the pending Terraform apply.
9c0f7e7Code changes — logger.warning → logger.error for user-facing failures: Terraform — monitoring.tf: GitHub Variable ALERT_EMAIL set to [email redacted].
a44545eCredentials.from_service_account_file() without explicit scopes creates unscoped credentials that fail all Firestore requests with 403. The Sheets service correctly passes scopes= but Firestore never did. This means store_poll_creation() has silently failed every time the CLI created a poll, leaving the Firestore polls/ collection permanently empty. Going forward, CLI poll creation will successfully write to Firestore. Also granted roles/datastore.user to [email redacted] on smad-pickleball (done manually via gcloud — the SA previously only had cloudscheduler.admin).
e64bf53poll_created is already date-only (time=00:00:00) via date_only=True. last_voted is currently parsed with a date-only format so also has no time, but make it explicit to guard against future format changes that might include a time component.
53e5773When a player votes on a poll that has already been rotated to the Archive (e.g. accidentally tapping last week's pinned poll), get_poll_created_date() now also checks Pickle Poll Log Archive before falling back to the most-voted current poll. This gives correct attribution and prevents writing the current week's poll date for an old poll_id entry. Also moved the `if not poll_earliest: return None` guard inside the else branch so an empty current-log result doesn't short-circuit the Archive lookup. Data fix: manually corrected Sushil Anand's row 35 Poll Created Date in Pickle Poll Log from 07/13/26 (bogus vote_timestamp) to 07/05/26 09:14:41 (actual creation date of poll 3EB0E63CEC085595E54242, confirmed from Archive).
746077fTwo defensive fixes for when Firestore is unavailable: 1. player_data.py get_poll_created_date() fallback: pick the poll with the most entries rather than the one with the latest earliest date. The real poll has 20+ voter rows; a stray single-entry (e.g. vote-removal with an unknown stanzaId) won't win. 2. main.py vote recording: when Firestore lookup fails for a specific poll_id, fall back to the most-voted poll in the Poll Log before writing vote_timestamp as poll_date. Prevents bogus "new poll" rows that were the root cause of the 7/13 date contamination.
de8873dgithub-deploy already has roles/datastore.owner (superset of datastore.user) so the IAM change was redundant. Also Terraform SA lacks projectIamAdmin rights so it would fail every run. Root cause fix (firestore_utils.py project=smad-pickleball) is sufficient.
d83d124Root cause: github-deploy SA connected to Firestore using creds.project_id ('stockaccounts') instead of the GCP project where Firestore lives ('smad-pickleball'). This caused a 403, falling back to the Poll Log sheet which returned 7/13 instead of the correct 7/12 poll creation date. Result: players who voted on 7/12 (same day as poll) were reminded again. Fixes:
133ba73The 2026-07-11 change (dec8a7e) swapped to North=4, South=3 based on faulty reasoning. Live booking of Monday 2:00PM|South confirmed it booked North court instead, proving court_id 3=North, 4=South. Original mapping was correct all along.
dd2d7e1Unpinned functions-framework, requests, flask, pytz, venmo-api, and all google-* packages across all 4 webhook requirements.txt files. cloudevents kept at ==1.* in venmo-trigger and whatsapp-sender: 2.x moved `from cloudevents.http import CloudEvent` to a different import path — would break both Cloud Functions without a code migration.
963a587Both are already on latest (venmo-api 0.3.1, matplotlib 3.11.0).
9f6feebVerified all APIs used (Credentials, build(), PublisherClient, CloudSchedulerClient, firestore.Client, storage.Client/blob/upload) are stable across version ranges including storage 3.x. Kept venmo-api pinned (unofficial library) and matplotlib as floor (>=3.7).
87239bed3c4650fb7793fdcb4ba1Removes browser-save artifacts and debug output accidentally committed in e5afc42 (Twilio removal): .gitignore additions:
a0022baCLI now calls webhook/shared/greenapi.py directly (synchronous) instead of the third-party whatsapp-api-client-python package. This ensures CLI test runs exercise the same code path as Cloud Functions, making local regression testing meaningful.
dec8a7ecourt_id 3 maps to South and 4 maps to North on the Athenaeum site. The previous swap caused single-court |South bookings to silently book North instead. Both-court bookings were unaffected (both IDs get used). Backlog: add post-booking confirmation verification to detect silent failures when Make Reservation does not actually complete.
a00cf82Adds _find_player_by_zelle_alias() so players who pay via a business account (e.g. "Franks Landing Llc" → Bill Chui) can be matched by putting the exact email subject sender name in their Zelle sheet column. Matching order: exact name → Zelle alias column → first+last word → unique last name.
e5afc42Twilio A2P 10DLC approval was not obtained. SMS forwarding is removed entirely. Notifications now use WhatsApp DMs (GREEN-API) and email (Gmail API) only. Removed: maybe_send_extra_notifications() now sends email only (no behavior change for players without SMS opt-in, which was everyone since Twilio was never active).
745cdfenormalize_us_phone() only accepted 10/11-digit US numbers, causing format_phone_for_whatsapp() to return None for international numbers (e.g. HK +852 numbers). This silently dropped DM, group removal, and email notifications for any player with a non-US mobile. Fix: format_phone_for_whatsapp() now falls back to raw digits for international numbers (7-15 digits per E.164). format_phone_for_sms() remains US-only (Twilio is US-configured).
bbca6b0When slot verification detected 'already taken' or a date mismatch, the code returned False immediately without closing the BookMgrModalOverlay dialog. This left the modal open, causing it to intercept pointer events to #txtDate, which timed out all subsequent bookings in the same session (30s each). Triggered tonight when the book-court-south parallel job raced to book South@6pm first; north job opened the dialog, detected 'slot already taken', and abandoned it — blocking 8pm|Both + West Tennis 6pm + 8pm. Fix: close the booking dialog (click 'close' or Escape) before returning False on both the 'already taken' and 'date mismatch' paths.
12a0338The book-court-south parallel job runs whenever BOOKING_LIST contains |Both. Previously it would also process single-court entries (|West, |East, |North, |South) and attempt duplicate bookings — the reverse_court_order logic only skipped reversal but still booked the court. Now single-court entries are skipped entirely in the backup job since the primary job handles them. Fixes: tennis court double-booking with Friday 6:00 PM|West in BOOKING_LIST.
69f8d72Adds West Tennis Court (id=1) and East Tennis Court (id=2) to COURT_IDS. Adds 'west'/'east' aliases to COURT_ALIASES for BOOKING_LIST shorthand. Both alias remains pickleball-only. Tennis courts are always single-court bookings.
fca9c8dTested locally with --get-reservations (login + reservation fetch succeeded).
012e545Symlink-following vulnerability in set_key()/unset_key() — not exploitable here (project never calls set_key), but trivial to fix.
ec0562fbook-court-south is a backup job that books in reverse order (South→North) to race against book-court-north (North→South) when booking both courts. When BOOKING_LIST has only single-court entries like |South or |North, both jobs targeted the same court and caused a duplicate booking. Fix: book-court-south now only runs when BOOKING_LIST contains 'Both'. Single-court entries use book-court-north only.
c397423README: add GMAIL_OAUTH_TOKEN_JSON to the cross-platform secret duplication table (was missing — it exists in both GCP SM and GHA), explain the renewal workflow syncs both places, and add a runbook for when Zelle sync shows "Gmail service unavailable". secrets.tf: grant github-deploy SA secretVersionAdder on GMAIL_OAUTH_TOKEN_JSON so gmail-watch-renewal.yml can push refreshed token versions to Secret Manager. Without this IAM binding the gcloud secrets versions add step in the workflow would fail with 403.
c44e89dPreviously only updated the GitHub secret. Cloud Run reads GMAIL_OAUTH_TOKEN_JSON from GCP Secret Manager, so the token was going stale after the first expiry cycle (the GHA-only update never reached the deployed function). Now updates both: Also adds GCP auth + Cloud SDK steps and bumps timeout to 5m.
3824b74'South Court' (two words) wasn't in COURT_ALIASES, causing it to fall through to the unknown-court fallback which defaults to both courts.
f541e70Booking script is idempotent — if the court is already booked, a retry fails gracefully. Safe to retry on transient GitHub API errors.
0a0bc7818c76d3da15f4fUses Gmail OAuth via Python stdlib (no checkout/pip install needed): refreshes token via oauth2.googleapis.com, then sends via Gmail API v1. Reuses GMAIL_OAUTH_TOKEN_JSON + GMAIL_OAUTH_CLIENT_JSON secrets already available from other workflows.
46ec7d4063d72dProblem: when GitHub's runner pool is exhausted, the queued job is cancelled before any steps run, so internal 'Notify on failure' steps never execute. Today's 8am daily run sat queued for 15 minutes with runner_name="" then was cancelled silently. Changes:
8ebe16dPlayers who vote via WhatsApp poll on game day get stored as 2 (not 'y') by get_vote_column_value. mark_game_played only collected 'y' rows, so game-day voters were excluded from the group message and individual DMs. Fix: collect both 'y' (update to 2) and already-'2' rows when building the playing roster. Sheet update still only applies to 'y' rows; '2' rows are already correct.
dce455cPreviously used fallback_next_week=True which only looks at next week when the current week has zero future games. On Sunday morning after poll creation, the current week's last game (e.g. Sun 10:30am) is still future, so fallback never triggered and next week's newly-created poll games were missed. Now fetches week_offset=0 and week_offset=1 and merges, so all upcoming games across both weeks are scheduled regardless of what day it is.
40b5820Runs automatically after members sync every day at 8am PST, keeping DUPR ratings and SMS numbers up to date from survey responses.
f11acf662d407108ce67594c4359081b1c2c8ac68c4c46141Checks all current Drive permissions on the spreadsheet against archived players (vacation=12/31/2099 with email on file) and revokes any access found. Dry-run confirmed 0 archived players had access, so no revocations needed.
fb69135survey_sync(): grant viewer access to every matched player's email via Drive API (idempotent; no notification email sent) archive_player_rows(): revoke sheet viewer access for each archived player's email after setting vacation=12/31/2099 set_player_vacation(): grant access back when un-archiving (was 12/31/2099 → new real date); revoke when archiving via vacation command (new date = 12/31/2099) google_sheets_utils.py: add get_drive_service(), grant_sheet_viewer_access(), revoke_sheet_viewer_access() (lists permissions to find ID, then deletes) infra/terraform/apis.tf: add drive.googleapis.com (already enabled via gcloud) format_survey_sync(): show "Granted viewer access to N player(s)."
0608b2073dd14a9031448Sends Gene a WhatsApp DM + email on the 9th and 10th day without voting (while not on vacation) to warn him the dead man switch will activate in 2 or 1 day(s) respectively.
4bf9ddaThe old MAXIFS formula matched on Venmo username, but aggregate rows use 'aggregate-LastName' as the venmo key — so MAXIFS returned empty after aggregation ran.
dfd2910Eliminates the '2026 Pickleball Archive' sheet. Archived players now stay in the main sheet with Vacation Return = 12/31/2099, keeping them out of reminders and polls while preserving payment history in-sheet.
bf953f2112161aTerraform will destroy 1pm-saturday-poll-creation and create 8am-sunday-poll-creation on next apply.
10418cd16aaf11Google People API was manually enabled for Google Contacts lookup in player sync. Codify in Terraform so it's tracked as infrastructure.
864f991068950710e49e39ae2eeaFalls back to MM/DD format, using current year and bumping to next year if the date has already passed. Fixes error when user omits year.
55ee9b4640a571302f7af42b0b9c8286fc0a8347d63ebdb6a7349acc26e1a28c60f40d3992fd2d93879d013fa5e6c8578d0091ffe6e0626adf63b5c2d021cc06c8c19abf9bd38fa0a6b719b22fbf309b0ddd73e7263c54993a990482415319e36c352e32fbb8c786463ef7e4ae1c1f84dbd6f951363efa0cf82a3b1f98f4e58f6f5855143bad22a5935ebdb0df433f8d6f719ac69c21c6d2fb3b124aea161cc61a684b7d144085ce23188c13052278c199fb85e5cb7b11373c4260c7a0a019be947687e14d8aca283fb2f3765a8f7Pub/Sub push subscriptions deliver ~36% of Gmail Watch notifications twice, causing concurrent venmo-sync runs that race past the sheet-level dedup check (TOCTOU) and send duplicate WhatsApp messages. Layer 1 - Firestore distributed lock in venmo-sync-trigger prevents the second instance from running at all (transactional acquire, 90s TTL, graceful fallback). Layer 2 - Transaction-based deterministic correlation_ids (venmo-dm-{txn_id}, venmo-group-{txn_id}) flow through to whatsapp-sender's Firestore dedup as a safety net. Also removes hardcoded court line from game day reminder (was always wrong when a court gets canceled).
fec202889dad23Vote reminder DM now shows actual poll name ("Can you play the week of M/D/YY?") derived from poll creation date, with group URL on same line. Jokes now combine text + URL column from sheet.
cec3c30Consolidated Pickleball Memes into Pickleball Jokes sheet. Jokes now combine text + URL column when present.
c467173eb835dfThe sender uses ADC for auth but needs the spreadsheet ID env var so jokes.py can fetch from the Pickleball Jokes sheet instead of falling back to hardcoded jokes.
546e29f5ce9b2ca77e734Remove deleted github-actions-token from GCP secrets list (11 secrets), update GitHub Secrets count to 17, fix cost estimate.
4a84f3aReplace direct number sending (from_=) with messaging_service_sid for A2P 10DLC compliance. Campaign pending approval; SMS_ENABLED remains false until approved.
d217fe7GITHUB_TOKEN and github-actions-token held the same PAT. Switch Terraform to use GITHUB_TOKEN for scheduler headers and CI access, eliminating the duplicate. The github-actions-token secret can now be deleted from GCP.
d18c6e6Sentry was not useful for error logging — Cloud Logging is sufficient.
d11d3dccacd411google.auth.default() needs GOOGLE_APPLICATION_CREDENTIALS to find the credentials file. Cloud Functions have ADC automatically, but CLI and GitHub Actions workflows need the fallback to smad-credentials.json.
80f18d2The (default) Firestore database was created manually but never imported into Terraform state, causing 409 errors on every apply since 3/1.
a0694deSchedule two Last Call jobs per game: 7pm the evening before (new) and 2 hours before game time (existing). Also fix bug where Sunday scheduling found no future games because it didn't fall back to next week's poll.
9ce2f88Centralize joke logic into webhook/shared/jokes.py (reads from Jokes Google Sheet with 1hr cache, falls back to hardcoded). All Picklebot WhatsApp messages now end with *signature* followed by a random joke. Removes duplicate static-only joke code from picklebot main.py and whatsapp-sender. Updates deploy workflows to copy jokes.py, and adds it to CLAUDE.md and README shared module registries.
f58b74d59c058c383b45aModal overlay from failed warm-up attempts was not being dismissed, blocking all subsequent booking clicks and causing 30s timeouts.
d6e4d2772968b2f05753eName not Player Name, Hours Played not Total Hours.
46729393a07370346ebf74dd0cf7cebabc16e9c9fe3a99a95Records player's weight to Health Log sheet with date, name, weight, total hours played, and DUPR rating. Uses sender phone identification to auto-detect the invoking player.
5614098cancel_reservation_main now returns success bool and exits 1 when reservation not found, so cancel-game correctly reports "not found" instead of false "canceled" status.
01e143832713f195e0bb5Automates 3 manual steps into one operation: 1. Delete game date column from sheet (auto-adjusts formulas) 2. Cancel both court reservations via court-booking.py 3. Cancel Cloud Scheduler last-call job CLI: python smad-whatsapp.py cancel-game "03/05/2026 7:00 PM" [--dry-run] Picklebot: /pb game cancel 3/5 7pm
7cd0412d48962f8b98f32Simple landing page describing SMAD Pickleball as a community group with opt-in SMS notifications for game reminders, vote reminders, and payment reminders. Added .gitignore exception for docs/*.html.
4a65d6bAdd proper headings, code fences around profiler output blocks, and consistent formatting to prevent setext heading misinterpretation.
7d44348365f692Every-6-days schedule caused a gap when short months or paused daily runner meant the watch expired before the next renewal. Every other day (even days) provides ample buffer against the 7-day expiration.
0d216efVote reminder DMs now use the same formatter as /pb games and last call, showing weather, DUPR, and hours. Past games are filtered out via future_only=True. Removed format_upcoming_games_text since it's no longer used anywhere.
529aaf8BOOKING_LIST entries like "Monday 6:00PM|South" passed the raw court name "South" to book_court(), which only recognizes full names like "South Pickleball Court". Apply COURT_ALIASES to resolve shorthand names, and default to booking both courts on unrecognized input.
59cf9b3Extract shared _build_slot_rankings() and _format_slot_line() helpers used by both format_booking_suggestions() and format_survey_availability(). Shows "X available (Y total)" when vacation players exist, appends * to vacation player names. Uses full names instead of first names. Booking suggestions still exclude days with existing games.
7f7996bContent-based dedup (SHA-256 of message content) was blocking users from receiving the same bot response within 24h — e.g., /pb help was permanently blocked after first send. Switched to correlation_id-based dedup so each intentional command gets through while Pub/Sub retries are still blocked. Also added unclaim_message() to clean up dedup on send failure so retries work.
a309b8cf373d82Consolidates duplicate _get_games_message() and next-week fallback logic from both CLI and picklebot into shared functions. Fixes /pb next game not falling back to next week when all current games have passed.
4ea19e1Extract survey_sync() to shared/player_data.py (2-phase: email then name match with email backfill) and format_survey_sync() to shared/command_formatters.py. Both CLI and picklebot use the same shared functions with zero duplicate code.
f379c6fExtract build_action_success_message() and build_action_failure_message() in picklebot to eliminate duplicate success/failure message building between the direct command handler and confirmation-link handler. Also includes the BOOKING_LIST removal and consecutive reservation consolidation from the previous commit's smad-whatsapp.py changes.
f416cb6create-poll now always uses actual Athenaeum court reservations instead of falling back to the static BOOKING_LIST env var. When --from-reservations is not provided, it auto-fetches from Athenaeum via subprocess. Consecutive hourly slots on the same day are consolidated into a single poll option (e.g., Mon 5pm + Mon 6pm → Mon 5pm).
2649062Prevents duplicate WhatsApp messages by hashing message content + recipient before sending. Uses atomic Firestore create() — if the same content was already sent to the same recipient within 24 hours, it's skipped. Documents auto-expire via TTL policy on the expires_at field.
9d671193fd39c6ee1fb9bGame-day votes now write 2 (hours) since the column processor already ran. Shared via get_vote_column_value() in player_data.py — both webhook and CLI use the same logic. Also removed the cannot-play override that was stripping valid future game votes when "Can't play this week" was also selected.
7753d67The override assumed players who selected both "Can't play this week" and a game date had forgotten to uncheck dates. In practice, players use it to mean "can't play today" while still voting for future games. update_poll_date_columns() already handles this naturally — "can't play" has no matching date column so it's skipped. Game date selections now pass through to the sheet correctly.
22ac077They already get a game-day reminder that includes their balance. Uses get_todays_games() from poll votes instead of broken last_game_date field. Shows skipped players in both CLI and picklebot previews.
b1d61de592c531Pub/Sub at-least-once delivery was causing duplicate payment notifications when multiple instances processed the same message concurrently.
1262320Twilio 888 number not approved yet — disable all SMS sends until SMS_ENABLED=true is set in GitHub Variables. Wired into all workflows and picklebot Cloud Run deploy.
5582c8027aa7dcGoogle Sheets auto-shifts absolute column references when columns are inserted, causing the SUM range to exclude newly added game date columns. Now re-reads headers after insertion and resets every player's Hours formula to start from the dynamically determined first date column.
5e1b52fLast Call now DMs players who haven't taken the availability survey AND haven't voted (in addition to the existing survey-available non-voters). The summary is always posted to the SMAD Pickleball group and admin group, even when there are zero DM recipients (previously skipped due to early return).
02f081bd597dbaget_poll_created_date() was picking the MAX poll date across all rows, which included a corrupted entry (Jeff Herring's row had vote timestamp as poll date: 02/22 instead of 02/21). This made 11 players who voted on 2/21 appear as non-voters and receive false reminders. Fix: use earliest date per poll_id instead of max across all dates. Also consolidated duplicate get_poll_creation_date() from webhook/main.py into the shared function with optional poll_id and date_only params.
1058721394deeb1481747679e728Rename verb-first commands to noun-first pattern (e.g. book → court book, remind payment → payment remind, sync members → members sync). Group help text by category (Balance, Games, Info, Survey, Court, Jobs, Members, Payment, Poll, Fun) with emoji headers and per-command emojis. Old command names still work via Claude Haiku NLP + regex fallback aliases.
281f34783dcb4a0e0cf63Was filtering out 8pm and 9pm slots. Wed 8pm had 8 available players but wasn't showing in suggestions.
f3b0828Silence urllib3/sentry_sdk transport loggers that spam ~636 SSL retry lines per day across all Cloud Run services. Sentry still retries and delivers events — only the noisy WARNING logs are suppressed. Also adds section 26 to profiling-results.md documenting the full infrastructure audit (Cloud Run services + GHA workflows).
3f65019Was watching INBOX, causing every email to trigger a Venmo sync (48 spurious invocations today). Now watches only the Venmo label (Label_8840251291188778873), which is applied by an existing Gmail filter on from:[email redacted].
1a7bd47Retry mechanism now does a full page reload (fresh ViewState) between attempts instead of just retrying in-place. Also documents all February midnight booking run timings, showing cross-month postback is the root cause of the Feb 22 failure.
41d688bCross-month date entries (e.g., Feb→Mar) can fail silently when the ASP.NET postback doesn't fire. Retries up to 3 times with 2s timeout per attempt instead of failing after a single 3s wait.
d29ca95122f96a2b270d7CLI tools (smad-sheets, smad-whatsapp, payments-management, setup-gmail-watch, send-release-notes) use format="%(message)s" for identical output. email_service fallback log functions use getattr(logger, level). court-booking browser console handler now uses structured log(). Only remaining print() is the structured JSON log() function in court-booking.py (correct pattern for Cloud Run).
e8df5ca0d283cfc63ad70f8e204dMigrated all 8 remaining functions (update_vote_in_sheet, mark_game_played, find_player_row, find_date_column, show_balances, send_reminders, register_player, add_date_column) to use ColumnMapper for header-based column lookup. Deleted all 16 COL_* constant definitions.
b694e21Vacation players should still receive survey reminders since the survey is about general availability, not weekly game participation.
b92a73ebe19d61Extract duplicated vote reminder DM into shared format_vote_reminder_dm() in command_formatters.py. Both CLI and picklebot now include upcoming games and joke in vote reminders. Add next-week fallback for upcoming games lookup (same pattern as games command).
19d8b7d6a4b8dcee5d732Old 2025 sheet "Hours Played" column already includes these games, so don't add them again to hours_2026.
3b18bfe36893b5The games command now checks next week if all this week's games are in the past (e.g., on Sundays after a new poll is created).
64fe0b1Shows top 10 survey-based time slot suggestions after the games list, excluding days that already have games, filtered to 4+ players and 7pm max.
fcbf2d2Emoji is stripped before writing sheet column headers and when matching poll votes to columns, so vote tracking is unaffected.
a138bf6Poll creation now fetches real court reservations from the Athenaeum website via Playwright, ensuring the poll reflects what's actually booked (including ad-hoc bookings and excluding cancelled courts). BOOKING_LIST remains as fallback when --from-reservations is not provided.
4d33f9212:58 AM was 57 minutes past the 00:01 AM booking target, causing the script to think it missed tonight's window and wait until tomorrow — then get cancelled by the 3-minute job timeout.
5659b8dcf2530ePlayers with sms_reminder=yes in Google Sheet receive SMS copies of WhatsApp DM reminders. Adds shared sms.py module with maybe_send_sms() called from all 8 DM send sites (6 CLI, 2 picklebot). Deduplicates phone formatting into phone_utils.normalize_us_phone(). Cleans up Terraform secrets: removes resource blocks, uses removed{} blocks + string-based IAM bindings (Terraform reads, never owns).
f136650c3892991ddfe8bformat_player_list_with_hours now displays "DUPR: X.X Hrs: 02" instead of "2 hrs played". Affects all game summaries: Game Day Reminder, Show Games, Next Game, and Last Call.
a45e863df4fc52d7a0b05execute_send_reminders was dispatching the workflow with no inputs, causing it to always run both. Now passes reminder_type input correctly (vote -> vote_only, payment -> payment_only).
9805db2Admin Dinkers already receives a separate Game Reminder Summary. Sending the full group message there too was redundant. Dry-run preview to Admin Dinkers is kept.
62bd7122 min warmup is enough for GHA + Python. Booking still hits the 1 AM window. Updated GCP scheduler, Terraform, and workflow comment to match.
71ec4773 min for all jobs except court-booking (10 min, needs Playwright/Chromium). Prevents runaway jobs from burning free tier minutes silently.
328b9beTerraform was running 400+ min on bad days burning most of the free tier. 5 min is more than enough for plan+apply; if it hangs something is wrong. Also corrects stale comment claiming two nightly court booking triggers.
12578dbAvoids confusion between the weekly games poll and the availability survey.
5599af3SURVEY_COLUMNS['rating'] was col 17 (session rating), but DUPR is col 18. Added 'dupr' key at col 18 and 'games_per_week' at col 19. get_survey_data() now returns 'dupr' field per respondent. New CLI command send-dupr-reminders DMs survey respondents missing DUPR score.
6c9fef3Survey respondents' self-reported names are unreliable (e.g. "James" vs "James Lee"). Now builds email→canonical_name map from main sheet and joins on survey respondent email. Falls back to survey name if no email match.
aea3623Fixes disambiguation for players with same first name (James, John, etc.)
a9b7a32When sync-members adds a new WhatsApp member to the spreadsheet, send them a DM with the SMAD group description (stored as GitHub Variable SMAD_WELCOME_MESSAGE) so they get onboarding info automatically.
e0c576e07ac7b6df1849c4ed937b6ba819a2714ff10d9028886cef3b3dc77649f6c92c54ca4e6a6cb5b64e562015d141912831ba0f296fa08495133ed9652699a106651d73824b10e746c15dd4217205122a3b543c5bbfdb6b4a99Previously action commands (book, cancel, reminders, etc.) were only allowed from Admin Dinkers group, test chat, or admin DMs. Now SMAD group admins are detected via GREEN-API and granted admin access.
134becbThe record_payment branch had a local 'import re' at line 388 which caused Python to treat 're' as a local variable for the entire function, breaking re.sub/re.match calls earlier in the function.
fb41563The exact-match parser returned {"raw": "2/20/26 4pm north"} but the handler expected separate date, time, court keys. Added _parse_raw_booking_params() to split the raw string into structured params, fixing both /pb cancel reservation and /pb book commands.
af94cdd5a4b0dcedc303f88f5fff7f47ca8Eliminate ~450 lines of duplicate code across 10 phases:
39dd128Add --send flag to next-week-games CLI command to post formatted game schedule to the SMAD group. Add step to daily runner workflow that runs after poll creation on Sundays, ensuring new game columns exist before reading them.
7bbc304"show next week games" was misrouted to show_games because next_week_games was missing from the LLM intent list and the phrase wasn't in exact matches.
6e87b62Delete 4 duplicate functions from smad-whatsapp.py (~300 lines) that duplicated shared module implementations: parse_game_date_from_header, get_poll_votes_from_sheets, get_games_from_poll, and get_upcoming_games_text. Migrate all callers to use get_votes_from_main_sheet() and get_games_from_votes() from player_data.py. Add future_only param to get_games_from_votes() and week_offset param to get_votes_from_main_sheet() for next-week support. Add next-week-games command to both CLI and picklebot. Move upcoming games formatting to shared format_upcoming_games_text() in command_formatters.py. Add shared function registry to CLAUDE.md to prevent future duplicate code.
7a62d492963bedThe SENTRY_DSN secret was created via gcloud before Terraform could manage it, causing a 409 conflict on terraform apply. Using a declarative import block (Terraform 1.5+) to adopt the existing resource into state. This is a no-op after the first successful apply.
4dd2e77Integrate Sentry SDK into all 4 Cloud Functions for proper error tracking, replacing the custom gha-error-monitor that produced non-actionable alerts. Sentry provides real stacktraces, error grouping, and a dashboard on the free tier (5K events/month). Sentry integration: gha-error-monitor decommission:
e3ad116Adds comprehensive Configuration Management section to README with canonical source tables for all 15 GitHub Secrets, 9 GitHub Variables, and 9 GCP Secret Manager secrets. Documents the 4 cross-platform duplicated secrets with rotation procedure. Updates mermaid diagram to show GCP SM, GitHub Secrets/Variables, and .env as separate config sources. Moves GMAIL_USERNAME and NOTIFICATION_EMAIL from Secrets to Variables table in GITHUB_ACTION_SETUP.md.
396a9ec12ac8c69fb2230When converting 'y' to 2 on game day, also update the Totals row formula for that column from COUNTIF("y") to SUM so it correctly totals the numeric hours.
196929269383626671772Replace "Booking may have failed" with specific reasons: "Slot already taken" when the booking dialog returns empty date/time (slot reserved), or date/time mismatch details when verification fails. Adds last_failure_reason tracking to AthenaeumBooking class.
ee8800a10f88f80fda64cf437216Tries parse_intent_fallback() first (instant string matching) and only calls Claude Haiku when the fallback returns 'unknown'. Eliminates ~0.5-2s API latency for all standard /pb commands.
3c0a432d430e1350edc316f5c4fb6a4efbdPicklebot sends "south"/"north" but court-booking.py expected full names like "South Pickleball Court". Added alias mapping.
ea1ce47Booking and cancellation now send actual success/failure results back to the requesting WhatsApp chat via Pub/Sub, instead of just "triggered!".
0a1f8a03d249702213bb57effb4498febdce444d744425621e5fce9638caedd850d1943a56f66get_player_balances() only returns name and balance, missing the mobile field needed for phone number matching. Switch to get_full_player_data() which includes mobile, first_name, and last_game_date.
3f79ca7The shared/__init__.py unconditionally imported whatsapp_message and whatsapp_publisher, but whatsapp-sender only has greenapi.py in its shared/ folder. This caused ModuleNotFoundError on every message send.
82929c39c85776eab6b9a731aab24bf272e03c86faSends WhatsApp release notes to Admin Dinkers on every push, not just deploys. Removes duplicate notify-deployment job from deploy workflow.
d605877bb92866eca9cddGame day reminder DMs already include the player's balance, so sending a separate payment reminder is redundant. Now checks if last_game_date is today and skips those players. Admin summary shows who was skipped.
6294b5cCloud Scheduler handles the 8am trigger via workflow_dispatch. The leftover cron caused duplicate runs (8:00 AM + 8:20 AM).
887fc9710a1a3bExtract data fetching and display formatting from picklebot into webhook/shared/player_data.py and webhook/shared/command_formatters.py. Both CLI and picklebot now use the same code for all 7 display commands. Adds 5 new CLI commands (list-players, deadbeats, balances, games, status) and fixes next-game to use main sheet instead of unreliable poll log.
a5993efaa224264e7101123c58be4a1cc1213297df69ce5b2Games later in the week (e.g. Saturday) were missing weather data because Open-Meteo was only queried for 3 forecast days.
9913db51fce0c84f384f861fa3bb4b0dc224645ec8436cecfNew command: `smad-whatsapp.py weather [date] [time]` Defaults to current time in PST if no args provided.
00cf6e0Fetches wind speed and gusts from Open-Meteo API. Gusts only shown when they exceed sustained wind by more than 5 mph.
59b514bCloud Scheduler jobs weren't created this week due to missing GCP auth in poll-creation. As a workaround, add GHA cron schedule triggers for the remaining two games. Also refactors the workflow to resolve game parameters (date/time/court) in a single step that handles both cron and workflow_dispatch triggers.
e4327b3Admin Dinkers summary now includes weather, hours, and monospace player list instead of the old plain-text format.
5cb8056a83fa91fccd6d4Removes webhook/picklebot/shared/ and webhook/gha-error-monitor/shared/ from git tracking. These are now created by CI/CD during deployment by copying from the canonical webhook/shared/ source. This eliminates source-level duplication and drift risk. Changes:
13196dfUpdates gha-error-monitor whatsapp_message.py and whatsapp_publisher.py to match the canonical webhook/shared/ versions. These copies had drifted and were missing newer functions (polls, images, updated message types).
c733468Adds --get-reservations to view all booked court reservations and --cancel-reservation to cancel a specific booking by date/time/court. Parameterizes login() to accept different landing URLs.
d42c7bb1796715Show who would receive reminders and who is on vacation instead of just saying "would trigger workflow".
8eca60eAll /pb actions now execute immediately without requiring a confirmation link click. Dry run mode still previews without executing.
0aaae8aThe dry_run flag was being passed to send_whatsapp_message for the reply, which suppressed the preview/response. Now dry_run only prevents the action itself, not the reply back to the chat.
5d755ebShow commit body (bullet points) in Admin Dinkers notification, not just the subject line. Filter out Co-Authored-By lines.
ca7242ad7a4b00Use jq split to extract first line of each commit message instead of processing all lines which caused duplicate bullets and empty lines.
6ca231e83e946aThe __init__.py imports whatsapp_message and whatsapp_publisher which were not being copied to the venmo-trigger/shared folder.
780d4a41bdf658Without delay=0, press_sequentially has ~3s per character delay, making date entry take 30+ seconds instead of <1 second.
5b7a461fill() sets value directly without triggering keyboard events, breaking the date postback. press_sequentially() simulates typing to properly trigger form events.
6556370c587333Increased wait from 0.35s to 1.0s after warm-up grid refresh to prevent "Element not attached to DOM" error when switching from warm-up date to real booking date.
311c9f8During idle wait before 12:01am, perform dry-run bookings on tomorrow's date to cache browser assets (iframe JS/CSS/fonts). Changes: Benefits:
18dc72dNavigate directly to protected booking page URL instead of /member-login. Server redirects to login, then back to booking page after auth - saving one navigation step.
c66e71aebeca4ae6e9a61af72c65Validates Authorization header (Bearer token) on incoming webhook requests. Token configured via GREENAPI_WEBHOOK_TOKEN secret and webhookUrlToken setting in GREEN-API dashboard.
fd4a829Tested skipping the date postback wait with direct invoke - server requires calendar to be in correct date state before booking dialog opens. The date parameter in LaunchLockedReserver() is not sufficient.
92e02e2The hybrid search was a fallback from before direct invoke was tested. Direct invoke has been proven reliable and is faster.
b721981TODO #1 optimization: The page reload after navigation was redundant. Testing confirmed date entry works correctly without it. Savings: ~0.8s per booking run
8e5b4d1Document 3 potential optimizations to investigate: 1. Eliminate page reload for first court (~1.0s savings) 2. Skip date postback with direct invoke (~3.0s savings) 3. Test param 7 for duration control (reliability improvement)
c75c563Performance: Direct invoke 0.01s vs Search+Click 0.09s per court
e8441c5The previous approach of checking rowText failed because rowText contains all text in the table row, which could match incorrect times. Now we: 1. Extract the time directly from the onclick function call (LaunchLockedReserver's 6th argument) 2. Compare the extracted slotTime with the target time 3. Log the onclick time for verification This fixes the bug where searching for 2:00 PM would click 12:00 PM.
af8be11e5df6f2The substring check `rowText.includes("2:00 PM")` matched "12:00 PM" because "2:00 PM" is contained within "12:00 PM". Changed to regex with word boundary to ensure exact time matching.
6dddd076cf937b842eadf429005c2e18febe0316cec25cf6858a7cccRemove unconditional venmo_sync import from shared/__init__.py since it requires venmo_api which is not available in all Cloud Functions. Code that needs venmo_sync should import directly from shared.venmo_sync.
79e79fe50711eec1772abPhase 2 of WhatsApp Pub/Sub refactoring - decouple services from GREEN-API: court-booking.py: picklebot: Documentation: Cleanup:
d454862Implements event-driven architecture where clients publish messages to Pub/Sub without knowing about GREEN-API. The whatsapp-message-sender Cloud Function consumes messages and delivers via GREEN-API. New components: Migrated services (no longer need GREEN-API secrets): Features:
a078c5eAutomatically stops court booking, vote reminders, game reminders, and poll creation if Gene hasn't voted in 10+ days and isn't on vacation. Payment reminders continue to collect debts. Sends WhatsApp notification to Admin Dinkers when triggered with instructions to reactivate.
00c02e95dbfb91bed6f09abaedebCLAUDE.md: profiling-results.md:
f22a9e2Optimization #10: Hybrid JS slot search Optimization #16: Slot verification in booking dialog Also:
f719f2ea16d41f1a155995c57b42After entering a date and waiting for postback, verify the schedule header (.currdate a) shows the expected date before proceeding with grid refresh. If the header shows the wrong date, abort immediately to prevent booking on the wrong date. Bug discovered: Two parallel midnight jobs both reported success booking 02/11/2026, but one actually booked 02/04/2026 because its date postback didn't complete in time. The 0.9s wait wasn't enough under server load. Fix: Check schedule header after 0.9s wait. If wrong date, return False. Cost: ~0.02s (query_selector + inner_text).
0b503d94a067acd97064cStore timestamps as UTC instead of PST to avoid timezone comparison issues that caused immediate expiration.
acc1ad9494a7e7a2372adPreviously fetched one joke and reused for group + all DMs. Now each DM and the group message get their own random joke.
3fb0baaString '2' broke SUMIF formulas in 2026 Hours Played column.
4f27a8ba9267c8The auto-update requires GH_PAT_TOKEN to have 'repo' scope. If it fails, the renewal still succeeds - just needs manual secret update if token gets revoked in the future.
20c80cbThe workflow now saves the refreshed OAuth token back to GMAIL_OAUTH_TOKEN_JSON secret after each successful renewal. This prevents token expiration from requiring manual re-auth.
f5034bbfc672c58e3c56bca1d00300867b9Reliability fixes for intermittent "Element not attached to DOM" errors: 1. Increase calendar wait from 0.75s to 1.0s 2. Add JS fallback for slot click 3. Add debug logging for slot search failures These fixes address the midnight booking failures from 02/01/2026.
e36619da1a0248Also documents failed optimization attempts: Total booking time: 23.5s → ~14s (~40% faster from baseline)
ec41debUse autoRefreshresBooking() AJAX call instead of full page reload when booking the second court for the same date. Reduces refresh time from ~1.3-2.8s to ~0.35s. Total booking time improved from 23.5s to ~13.8s (~41% faster). Tested AJAX wait times: 0.5s (works), 0.2s (fails), 0.35s (works).
a41661aJS-based clicking doesn't trigger the modal properly. Only native Playwright element.click() works reliably. Keep optimizations #1-3 which reduced total time from 23.5s to 15s.
c2a0ebfRemoves 50ms delay between browser actions. Per-court booking time improved: North 3.1s->2.7s, South 3.1s->2.2s
120f1e7Reduces navigation time from ~2.0s to ~1.2s (-0.8s)
e0d0a7ea5e1c24Removes temporary debugging code that was slowing down booking:
f7042f2aa3db0c74e20a237558b35cce2aff42800b17fdedc5575da7Third backup job that starts after schedule transitions to bookable. With ~41s GHA warm-up, script starts ~12:01:11 AM and gets fresh bookable schedule naturally through login flow (no reload needed).
a900c94At 12:01 AM, schedule should transition from red to green automatically. The reload may be unnecessary overhead.
7d12da90.6s causes elements to not fully render (onclick not available).
52bfd70Testing with slow_mo=20ms - explicit waits may compensate.
968ed4cTesting more aggressive timing - explicit waits should provide stability.
9bb7d64JS evaluate() doesn't trigger the postback needed by Telerik RadComboBox to refresh the calendar. Reverting to click/select/type with delay=0.
62b4ff550fdbe07af9a70ee1d36365d2bf77638390977c70609408dd5213bc589d3c336ca2fbf9cb7bc48c3f827Moved the following from GitHub vars to secrets: These contain WhatsApp group IDs and spreadsheet IDs that should not be publicly visible in the repository settings.
c40a6b9This ensures primary job books North→South while backup books South→North, maximizing chances of getting both courts if server is slow.
077e45f09daa1541e99c69bc43c16f4292709ccf72WhatsApp doesn't auto-preview i.redd.it URLs. Switch to sending actual images via GREEN-API sendFileByUrl endpoint.
37ca789Reddit blocks Cloud Function IPs. Instead:
13448dcReddit blocks cloud provider IPs. Try:
660046844af5c3Reddit requires User-Agent in format: python:app:version (by /u/username) Generic user agents get blocked with 403 Forbidden.
a1eaa9a2a91c6e25b2d51e01c216Players who change their vote have multiple rows in the poll log. Now we keep only the most recent vote per player based on timestamp.
7dbc3c3Confirmation flow: 1. User sends /pb book, /pb poll create, or /pb reminders 2. Bot stores action in GCS, returns message with confirm link 3. User clicks link -> action executed -> result sent to WhatsApp
06e401b1ee6e787d5b295README.md: webhook/picklebot/README.md:
3d86d848fbe036e203e78Cloud Scheduler library requires more memory than 256MB limit.
a29b085d03f05c1e6eaf6700ba40GREEN-API sends some messages as extendedTextMessage instead of textMessage. Now handles both types when detecting /pb commands.
ef4b70f2e26f6eReplace complex regex patterns with simple text search: find "until reservations open" and extract text before it.
359e811Track elapsed time for each book_court() call and display in:
88d266aEnhanced regex patterns to extract countdown text (e.g., "5 hours 44 minutes") even when format varies across different page layouts.
6e3c701Normalized time format to handle "08:00 AM" vs "8:00 AM" mismatch when detecting BOOKED_BY_OTHERS failure reason.
ef26f4ea8ec958All booking failure reasons are now extracted from logs: This eliminates Vision API costs and reduces latency. Claude Haiku text API remains as fallback for unknown failures.
1cfa2d78962e9aa7874b3978b1c5When a booking fails, the monitor now: 1. Fetches the booking calendar screenshot from workflow artifacts 2. Sends it to Claude Vision for visual analysis of the calendar state 3. Sends the screenshot image via WhatsApp alongside the text alert
d711e95Prefer specific step log files (e.g., "Run booking script") over combined logs (0_*.txt) which are too large. The failure messages appear late in combined logs, getting truncated at 15KB.
c1a0f30Logs were being truncated before reaching booking script output. Now prioritize files with 'booking' in the name to ensure failure patterns are captured within the 15KB limit.
fe6deb74b0f5354d9b64eMove page reload inside the courts-to-book loop to ensure fresh DOM elements for every court booking, not just the 2nd+ courts.
542bf05Reload page before booking 2nd+ court to get fresh DOM elements. Fixes "Element is not attached to the DOM" error that occurred when trying to enter date for South Pickleball Court after booking North.
4140634Updated vote-payment-reminders Cloud Scheduler job from 7:45 AM to 8:00 AM.
3a2c372Updated vote-payment-reminders Cloud Scheduler job from 10:00 AM to 7:45 AM.
b672a459f3dd75Shows players on vacation with their return dates in the admin summary message sent after vote reminders.
d69ab97Previously only parsed MM/DD/YYYY (4-digit year), now also handles MM/DD/YY (2-digit year) format for vacation return dates.
6fed9bff4c946cd49bb261f17e685e51be2When a Venmo payment is recorded, notify Admin Dinkers in addition to sending the thank you DM to the payer.
63f494bBREAKING: Remove input defaults that were overriding GHA variables. Before: workflow_dispatch input defaults (00:00:15 for target time, 'True' for safety mode) would always be used, even when Cloud Scheduler triggered the workflow - making vars.BOOKING_TARGET_TIME and vars.SAFETY_MODE completely ignored. After: Inputs have no defaults, so the || chain correctly falls through to GHA variables, then to hardcoded fallbacks. Precedence is now: explicit input → GHA variable → hardcoded fallback Also updated all documentation from 00:00:15 to 00:01:01 (courts open at 12:01 AM, not 12:00:15 AM).
7db131dShows when each player last played in the Admin Dinkers summary report, helping to identify inactive players with outstanding balances.
b4ddb6bUse safe_print() for poll questions containing emojis to prevent UnicodeEncodeError on Windows console (charmap codec).
8d17ecc1551134243c46bWhen GREEN-API fails to deliver pollUpdateMessage webhooks (like for Karan's phone), this command allows manually recording votes: Features:
7e3c656483ef9458bab5cb67764aCloud Scheduler jobs will trigger GitHub Actions via workflow_dispatch API:
8f382cdd446bb2824cbbb145a866ee34d2540250906bcf2a73cf758c70ca092fd9fe3707a59371858bf5082643e5b3c878Adding timestamp comments to force GitHub to re-register schedules. GitHub Actions scheduler can stop firing without workflow file changes.
298a5b285f9ae346e53c3b69a89dGitHub Actions runs in UTC, so datetime.now() would return UTC time. After 4pm PST (midnight UTC), the GHA would see Monday as "today" and generate poll dates for the following week instead of current week.
60e9e16The poll options show dates starting from tomorrow, so the question should reference the Monday of that week, not the current week.
cece8faba69c792211b91b7f4200Group IDs aren't sensitive - use vars instead of secrets for consistency with SMAD_WHATSAPP_GROUP_ID.
9cebe14272d43cb8d5db8The 10-minute cap was truncating the normal ~10.6 minute wait (invoke at 23:50, target at 00:01:02), causing bookings to start 37 seconds early before courts were released.
9eec48b5c563b2Adds Gmail Watch + Pub/Sub + venmo-sync-trigger pipeline, smad-whatsapp.py CLI, shared venmo_sync module, all 5 GitHub Actions workflows, and corrects Playwright labeling.
1c5c458Remove ~250 lines of duplicate sync logic and WhatsApp functions from payments-management.py. The sync-venmo command now calls the same sync_venmo_to_sheet() used by the Cloud Function.
4c9f6e0record_payment() now does a fresh read of existing transaction IDs right before each write, preventing duplicates from sequential Gmail notifications. Also adds deduplicate_payment_log() as a safety net to clean up any that slip through truly concurrent writes.
809ca3fAfter target time is reached, reload the page so newly-released court slots are visible. Also increase calendar wait from 1s to 2s for the schedule iframe to fully load after date entry.
bdb1b48Tells players they can vote on Sunday or request vacation mode to stop receiving daily vote reminders.
330e9c3Both Cloud Functions (WhatsApp poll/vote webhook and Venmo sync trigger) now auto-deploy on push to webhook/ files.
e13a1f4ea91d02Sends automatic thank you DM with payment amount and balance when the Cloud Function records a new Venmo payment.
9130e54Re-adds push notification pipeline: Gmail watch → Pub/Sub → Cloud Function → venmo-sync. Includes GitHub Actions workflow for auto-renewal every 6 days.
fcf80deChanges to WhatsApp thank you DM:
db281f6Match the signature format used in other WhatsApp DM messages: "Picklebot🥒🏓🤖"
067e64dWhen sync-venmo records new payments, automatically send WhatsApp DM to each payer: "Hi {first_name}! Thank you for your payment of ${amount}! Your payment has been recorded." Features:
99cafe3Reverting to manual Venmo sync approach (via CLI or cron). Deleted Cloud Function, Pub/Sub topic, Gmail watch setup, and GitHub Actions workflow.
17e8478Gmail API watches expire after 7 days. This workflow automatically renews the watch every 6 days to maintain real-time Venmo payment notifications. New files: Changes: Workflow schedule: Requires GitHub Secrets: Setup instructions in GMAIL_WATCH_SETUP.md
ab3a89aChanged Cloud Function from HTTP trigger to Pub/Sub (Gmail API watch). Provides instant notifications when Venmo emails arrive (no polling). Changes: Architecture: Venmo email → Gmail → Gmail API watch → Cloud Pub/Sub → Cloud Function → venmo-sync Setup required: 1. Create OAuth credentials in Google Cloud Console 2. Run setup-gmail-watch.py to authorize Gmail access 3. Watch expires after 7 days, must be renewed Deployed:
002b221Copied shared/ module into venmo-trigger/ directory for deployment. Cloud Functions needs all dependencies in the source directory. Changes: Deployment successful:
f56a027Implements real-time Venmo payment sync via Gmail forwarding to Cloud Function. Emails trigger venmo-sync (no parsing) for robust username matching, handling duplicate names and format changes. Architecture: New files: Benefits: Setup: Deploy Cloud Function + Gmail Apps Script (every 5 min) Cost: $0/month
cb75289Since polls will be manually pinned (GREEN-API doesn't support automatic pinning), updated reminder messages to reflect that the poll is pinned to the top of the group. Changes:
c3dcc3eGREEN-API does not support the pinMessage() method. Removed the non-functional pinning code that was causing warnings and updated messages to reflect that polls are not automatically pinned. Changes:
1875e732d2d0b155bf5f1f78c20bFixes argument parsing error: "unrecognized arguments: --dry-run"
f994bb2Both workflows follow the same pattern as daily-booking.yml with:
bcde832Replace PNG image reference with inline Mermaid code for native GitHub rendering. Diagram shows complete system architecture including WhatsApp integration, Cloud Functions, Google Sheets, Venmo API, court booking automation, and CI/CD pipeline.
ecf3f15Changes: The webhook now exclusively uses Google Sheets for poll vote tracking. Firestore was fully replaced in the previous migration.
73134e3Main Changes: Technical Details:
d0a04ecd6a885d67fa0362480ecd59021df03b10d06dd40749668a4dChanges made: webhook/main.py - Simplified to only handle Firestore updates: Removed Google Sheets imports and configuration Removed get_sheets_service(), col_to_letter(), update_sheet_vote() functions Removed the call to update_sheet_vote() in handle_poll_update() Added note in docstring that Google Sheets updates are handled by smad-sheets.py webhook/requirements.txt - Reduced dependencies: Removed google-api-python-client and google-auth (no longer needed) Only needs functions-framework and google-cloud-firestore smad-sheets.py - Now the central location for all Google Sheets functions: Already had CANNOT_PLAY_PHRASES, is_cannot_play_option(), update_vote_in_sheet(), col_index_to_letter() Added new sync-votes command that: Connects to Firestore Gets the most recent poll Reads all votes from Firestore Updates Google Sheets with Last Voted dates and y/n values New architecture: Webhook (Cloud Function) → Writes votes to Firestore only (real-time) smad-sheets.py sync-votes → Reads Firestore, updates Google Sheets (on-demand) This separation is cleaner because Cloud Functions can't import local Python modules, and it gives you manual control over when to sync to sheets.
2619fd02fae4f206c70fc5765e3f9fafad558ada287a2b0b91db29c322ea05d82492f244e38a82cc05eee2f403533bf5dcb4db28b01fc40310f5869169a13b5122bc423337774a56de7booking_list (line 32-34): Override the repository's BOOKING_LIST variable Example: "Tuesday 7:00 PM,Friday 4:00 PM" When set, this overrides single booking parameters and activates Booking List Mode booking_target_time (line 35-38): Control when the script starts booking Default: '00:00:15' safety_mode (line 39-42): Control whether to complete the booking Default: 'True' (stops before booking for safety)
65af1cde95b133fe112e9a257514Previously, GitHub Actions generated invoke_time in UTC and the script converted it to PST. This made debugging confusing with mixed timezones in logs.
14502b62477d7b38387bd2a3c91a1d2374962317cf9eac36a78f18b0