An app for managing sports teams and groups. The coach owns structured state
WhatsApp is bad at — rosters, RSVPs, event capacity/waitlists, payment tracking —
and broadcasts to the group's existing WhatsApp chat with one tap. See
CLAUDE.md for the product vision and design decisions, and
BRAINSTORM.md for how we got here.
- Next.js (App Router) + TypeScript, as a PWA
- Postgres via Prisma
- Auth.js (NextAuth v5) — magic-link email (logged to console in dev)
- Tailwind CSS v4 for styling, lucide-react for icons
- Vitest for unit tests
- Hosting: Railway (app + Postgres) — live at whosin.team
Layered so logic stays testable and the native-app door stays open (a future native client could call the same API). Keep a clean boundary — don't bury business logic in server-rendered pages.
src/
app/ routes: pages (UI) + route handlers + thin server actions
components/ reusable UI (PlayerForm, EventForm, WhatsAppComposer, BottomNav, ...)
server/ backend: coach/group resolution + domain services
services/ the real logic (roster.ts, events.ts)
lib/ shared, pure: db client, messages.ts, status.ts, squad.ts, ui.ts
The rule: route handlers / server actions stay thin and delegate to
server/services; pure formatting/validation lives in lib.
npm install
cp .env.example .env # fill in DATABASE_URL, AUTH_SECRET, SEED_COACH_EMAIL
npm run db:migrate # apply migrations to your local Postgres
npm run db:seed # demo coach + 12-player roster
npm run dev # http://localhost:3000Log in: enter the address you set as SEED_COACH_EMAIL — the demo roster
belongs to that coach — then copy the magic link printed in the terminal
(dev doesn't send real email yet).
| Script | What it does |
|---|---|
npm run dev |
Start the dev server |
npm run build |
Production build |
npm run db:migrate |
Create + apply a migration from schema changes (local dev) |
npm run db:seed |
Reset to a known demo roster (destructive — wipes event statuses) |
npm run db:studio |
Prisma Studio |
npm run lint |
ESLint — code quality |
npm run format |
Prettier — auto-format all files |
npm run format:check |
Prettier — check formatting without writing |
npm test |
Run the unit test suite once |
npm run test:watch |
Run unit tests in watch mode |
npm run test:coverage |
Run unit tests + print a coverage report (HTML in coverage/) |
npm run test:e2e |
Run Playwright end-to-end tests (auto-starts the dev server) |
ESLint (code quality) and Prettier (formatting) are complementary, not
overlapping: ESLint catches bugs and bad patterns (unused vars, <a> for
internal links, hook deps), Prettier owns layout (indentation, quotes, width).
eslint-config-prettier disables ESLint's formatting rules so the two never
fight. Run npm run format before committing; npm run lint enforces quality.
Our layered design concentrates the valuable logic in pure, easily-tested places. We invest where bugs actually live, in order of value-per-effort:
| Layer | Test type | Status |
|---|---|---|
lib/ pure functions (message formatting, status labels, validation) |
Unit (Vitest) — no DB, no browser | ✅ in place |
server/services (createEvent seeding, ensureEntries, status/squad writes) |
Integration (Vitest + a test Postgres) | ⏳ planned |
Client components (WhatsAppComposer, DateTimeInput) |
Component (Vitest + React Testing Library) | ⏳ later |
| Full flows (create event → mark In → share) | E2E (Playwright) | ✅ in place |
| Auth (redirect unauthenticated, login, session) | E2E (Playwright) | ✅ in place |
| Roster page (add player modal, delete confirmation) | E2E (Playwright) | ✅ in place |
| Events page (tabs, pagination, create modal, FAB) | E2E (Playwright) | ✅ in place |
| Navigation (logo links, pencil icon hrefs) | E2E (Playwright) | ✅ in place |
- Pure logic first.
lib/messages.tsbuilds what gets broadcast to real people; it's critical and trivial to test (no mocking). Most real bugs so far have been in formatting/state, not infrastructure. - Deterministic dates.
vitest.setup.tspinsTZ=UTCso date formatting in tests is stable across machines/CI. - E2E for the spine, not everything. One green end-to-end happy path is worth more than many shallow tests — it proves the app actually works.
- Defer service-integration tests until the schema settles, to avoid rewriting them as the data model evolves (recurring events, etc.).
We rely on automated coverage rather than a hand-maintained tally (which
drifts). npm run test:coverage prints a per-file table and writes an HTML
report to coverage/ (gitignored). Today src/lib is well covered and the
other layers read as gaps — accurate, since their tests are still planned.
Infra glue (lib/db.ts, layouts, route handlers) is excluded so the numbers
reflect real logic. Per-directory thresholds can be added in
vitest.config.ts once the suite stabilizes.
E2E specs live in e2e/. npm run test:e2e auto-starts the dev server and runs
against it, so a local Postgres with a synced schema is required. A global setup
(e2e/global-setup.ts) provisions an isolated E2E coach + group + roster
(separate from dev/seed data, reset each run) and injects a DB-backed session via
storageState, so tests start authenticated without driving the magic-link UI
(that flow is better tested in its own targeted spec later). Reports land in
playwright-report/ (gitignored).
Never hard-code dates in an E2E spec. Use the helpers in e2e/dates.ts
(daysFromNow, dateValue, dateTimeValue, nextYear) so every date is
relative to the run. A hard-coded date is a time bomb: it passes until the
calendar catches up, then fails permanently, and the failure looks like a
broken feature rather than a stale fixture. This already happened once —
recurring-events.spec.ts pinned Sep 2026 and started reporting "expected 4,
received 2" once two of its occurrences aged onto the Past tab. Note also that
specs share one database within a run (one worker, serial), so an event a
spec creates is visible to later specs — which is why the created event must
stay in the future, or events-page.spec.ts's empty-Past-tab assertion fails.
Co-locate unit tests as *.test.ts next to the code (src/lib/messages.test.ts);
put E2E specs in e2e/*.spec.ts. Run:
npm test # unit, once
npm run test:watch # unit, watch mode
npm run test:coverage # unit + coverage report
npm run test:e2e # end-to-end (starts the dev server)Everything below is what stands between the current local-only app and real users at whosin.team. Ordered roughly by priority. 🔴 = blocker (no one can use it without this), 🟡 = needed for a credible launch, ⚪ = nice-to-have.
- Add whosin as its own project in the existing Railway account (a new account isn't needed — Railway is one account → many projects). For a clean ownership/billing boundary from the partner's project, put it in its own Workspace. Free/Hobby tier is fine to start; upgrade when usage warrants.
- One Railway project, two services: the Next.js app + a Postgres database. Railway can host both — no need for a separate DB host. (Hosting the app on Railway too keeps everything in one place and matches existing experience; Vercel + a separate DB is the main alternative but adds moving parts.)
- Connect the GitHub repo to the app service so Railway builds on push
(
npm run build→npm start). Set the start command if needed. - Confirm the app runs as a long-lived Node server (it does) — Prisma connection pooling is fine, no serverless adapter needed.
Two projects in one Railway account (whosin + the partner's project). Using one account is simpler than juggling two logins — the only risk is deploying to the wrong project, which is low-stakes and obvious (the project name is right there). GitHub and Claude don't enter into it.
- Separate Workspaces keep ownership/billing clean between whosin and the partner's project while sharing one login. Switch projects from the dropdown.
- GitHub doesn't pick the project — the Railway dashboard you're in does. Create the whosin service from the whosin project/workspace.
- Scope the Railway GitHub App to "Only select repositories" → just the whosin repo when authorizing, so a service can't accidentally pull the wrong repo.
- Prefer the dashboard over the global
railwayCLI. If you use the CLI or CI, use a project-scopedRAILWAY_TOKEN(it can't touch any other project) and runrailway status/railway whoamibefore acting.- Claude has no Railway connection — it only touches Railway if you ask it to run a
railwayCLI command, so dashboard-only work keeps it out entirely.
-
Provision Railway Postgres; copy its
DATABASE_URLinto the app service's env (Railway can inject it automatically). Wired via Railway's variable reference (${{Postgres.DATABASE_URL}}), the internal URL. -
Decide on migrations: took the documented shortcut — ran
prisma db pushdirectly against the prod DB for the first deploy. Now resolved: generated a baseline migration (prisma/migrations/20260619214557_baseline) from a diff against an empty schema, then marked it applied (not re-run, since the schema already matched) on local, staging, and production viaprisma migrate resolve --applied 20260619214557_baseline.npm startnow runsprisma migrate deploybeforenext start, so future schema changes ship as committed migrations applied automatically on deploy. Local dev now usesnpm run db:migrate(prisma migrate dev) instead ofdb:push. -
Do NOT run the demo seed against production. It's destructive and creates fake players. The real coach signs in and builds their own group.
prisma/seed.mjsnow refuses to run against any non-localDATABASE_URL(checks the hostname, not a hardcoded URL) unlessALLOW_REMOTE_SEED=trueis explicitly set — keeps the door open to seed staging on purpose, while blocking accidental runs against prod. -
Enable WAL archiving on the Railway Postgres service (Postgres service → Backups tab → enable continuous archiving). Scheduled backups and point-in-time restore require the Pro plan (not available on Hobby), but archiving itself is free and runs regardless — enabled now so a restore is possible later if you ever upgrade.
-
Rotating the Postgres password (done 2026-09-16, after the live credential leaked into a chat transcript). Railway has no "change password" control — the Postgres service's Danger zone only offers deleting the whole service — and the rotation is two halves, both required: run
ALTER USER postgres WITH PASSWORD '…';overrailway connect Postgres(orpsqlviaDATABASE_PUBLIC_URL), then updatePOSTGRES_PASSWORDon the Postgres service, because Railway composesDATABASE_URL/DATABASE_PUBLIC_URLfrom it and changing the database alone leaves both stale. Keep those two as${{POSTGRES_PASSWORD}}templates rather than literals so one edit propagates; the app'sDATABASE_URLstays${{Postgres.DATABASE_URL}}and needs only a redeploy. Generate the password alphanumeric only — it is embedded in a URL, and@ : / # %break parsing in a way Prisma reports asP1000: Authentication failed, indistinguishable from a wrong password. Two traps that each cost time here:psqlprintsALTER ROLEon success, and a missing semicolon leaves it on arailway-#continuation prompt where the statement silently never runs; and\password postgresis the better command anyway, since it prompts instead of leaving the new secret in shell history. -
Staging environment, reachable from a phone. Set up via Railway's Duplicate Environment on the
productionenvironment, which cloned the app + Postgres services into a newstagingenvironment with theDATABASE_URLreference automatically rewired to the staging Postgres (confirmed it resolves to the staging DB, not prod). Then: generated a freshAUTH_SECRET, generated a public domain (whosin-staging.up.railway.app) and setAUTH_URLto it, kept the sameRESEND_API_KEY/EMAIL_FROM(same verified sending domain), and pushed the schema withprisma db pushusing the staging Postgres's public connection string (DATABASE_URL_PUBLIC-style URL — the internalpostgres.railway.internalhost only resolves inside Railway's network, not from a laptop). Verified end-to-end on a phone: page loads, magic-link login works. Local Postgres remains fine for everyday schema iteration; this environment covers the cases local can't (phone testing, rehearsing against a deployed build). -
Staging and production deploy from different branches. Both environments originally watched
main(an artifact of Duplicate Environment), so a single push deployed straight to production with no rehearsal step. Now: staging watchesmain, production watches a dedicatedproductionbranch, with Wait for CI enabled on the production service so a red build can't ship. The environments already had separateDATABASE_URL,AUTH_SECRET, andAUTH_URLvalues from the duplication, so only the deploy trigger changed.Shipping is therefore a git operation, which keeps the release history in the repo rather than in Railway's dashboard: ```bash # always start here: local refs go stale fast, and a stale main is # why a promote silently reports "Everything up-to-date" git checkout main && git pull # what is on main but not yet in production? git log --oneline origin/production..main # ship main to production (fast-forward) git push origin main:production # roll production back to a known-good commit git push -f origin <sha>:production ``` There is deliberately **no local `production` branch** — it only exists on the remote, so it can't drift or be committed to by accident. Always refer to it as `origin/production`; plain `production` will fail with "unknown revision". ⚠️ **Rolling back reverts code, not data.** `npm start` runs `prisma migrate deploy`, and Prisma has no automatic down-migration. If a bad deploy applied a *destructive* migration (dropped a column, narrowed a type), force-pushing the old commit leaves production running old code against the new schema — frequently worse than the bug you're backing out. Rollback is only safe while migrations are additive. See the open item below.
Magic-link login now sends real email in production and has been confirmed working end-to-end (email arrives, link signs in).
- Sign up for an email sender — used Resend.
- Verify a sending domain with Resend — used the subdomain
contact.whosin.team(not the root domain) and added its SPF/DKIM/DMARC records at the registrar. - Replace the console
sendVerificationRequestinsrc/auth.ts— ended up calling Resend's HTTP API directly (fetch), not SMTP: Railway blocks outbound SMTP ports, which surfaced as connection timeouts until the switch. Configured viaRESEND_API_KEY+EMAIL_FROM. Console fallback still used for local dev (whenRESEND_API_KEYis unset). - Generate a fresh production
AUTH_SECRET(do not reuse the dev one). - Set
AUTH_URL=https://whosin.teamin production env. - After deploy, confirm the production session cookie works over HTTPS —
confirmed via a real magic-link login on
whosin.team.
- Add whosin.team as a custom domain on the Railway app service; set the
CNAME/TXT records it gave at the registrar (Namecheap). Watch for a
conflicting default "URL Redirect Record" on
@— it blocks the CNAME from resolving and needs to be deleted. - Railway auto-provisions an HTTPS certificate (Let's Encrypt) for the
custom domain — verified active (
whosin.teamloads over HTTPS). - Add the email DNS records from step 3 (SPF/DKIM/DMARC) at the same
registrar (under the
contact.subdomain). - Apex ALIAS +
[email protected]forwarding — the apex was a CNAME to Railway (ak04l2iq.up.railway.app), and a CNAME must be the only record at its hostname (RFC 1034), so no MX could exist atwhosin.teamand Namecheap's email forwarding was unavailable (its UI reports this as the misleading "Your domain is using other email service"). Asking the zone for MX literally returned the CNAME. Fixed by switching the@record from CNAME to ALIAS, same target — ALIAS answers as an A record while still tracking Railway's hostname, which frees the apex to hold MX. Then Mail Settings → Email Forwarding, with[email protected]→ personal Gmail, to back the invite-request link on/login(src/lib/access.ts). Verified end to end: apex answers A,eforward1-5MX live, SPF added, site still loads, Railway domain still validated, test email delivered. Notefphelp.appsolves the same problem with a hardcoded A record to a Railway IP — it works, but pins infrastructure we don't control; ALIAS is the safer form of the same trick. - Side effect — Namecheap allows one mail mode per zone. Switching
Mail Settings to Email Forwarding deleted every custom MX row,
including the two on subdomains that Resend asked for
(
contact.whosin.teaminbound,send.contact.whosin.teambounce feedback). SPF and DKIM survived, so sending is unaffected for both apps and Resend still reports the domain verified. Nothing receives oncontact.whosin.team, so this was accepted rather than reverted: switching back to Custom MX would dropeforward1-5and kill the new alias. See the Cloudflare item in step 8 for the way out. This fired on 2026-09-15: Resend re-checked, found nosend.contactMX, flippedcontact.whosin.teamto unverified, and magic-link sign-in started failing in production with a 403 — weeks after the DNS change that caused it.
Set on the Railway app service:
-
DATABASE_URL(prod Postgres, via variable reference) -
AUTH_SECRET(fresh, strong) -
AUTH_URL=https://whosin.team -
EMAIL_FROM+RESEND_API_KEY(HTTP API key, not SMTP creds —.env.examplereflects this) -
ALLOWED_EMAILS— comma-separated sign-in allowlist. Required: with it unset in production every sign-in is refused, by design. Sign-up is invite-only in v1, which also keeps/loginfrom being an open email relay that strangers can use to burn the Resend quota and the sending domain's reputation. Set on production (3 addresses), confirmed 2026-09-16. If it ever looks missing, check the service before assuming it was lost: Railway variables are scoped per service, per environment, so it lives on thewhosinapp service, not on Postgres.
- Rename the app from "my-team" to the brand (whosIn / whosin.team):
updated
metadata.title, the home page heading, andpublic/manifest.webmanifest(name,short_name).theme_colorstill uses the placeholder slate — revisit once a brand color is picked alongside the logo. - Create a logo + app icons. The manifest references
public/icon-192.pngandpublic/icon-512.pngwhich don't exist yet; also add afaviconand anapple-touch-icon. (Tools for the logo: Recraft or Midjourney for the mark — Recraft can export clean SVG; Looka/Canva for a quick wordmark; or just a typographic wordmark in a nice font. Generate the icon sizes with a favicon generator like realfavicongenerator.net.) - Verify "Add to Home Screen" shows the logo + name correctly on a phone.
The primary user is a phone-first coach — test on a real phone, not just the laptop:
- Layout/responsiveness on a small screen (the new Tailwind UI).
- The "Share to WhatsApp" flow actually opens WhatsApp and pre-fills the
message on a phone (the key feature) — check the emoji survive the handoff,
not just the text. See the endpoint note in
src/lib/messages.ts. - The native date/time picker behaves on mobile.
- Magic-link login on the phone (tap link in email → lands authenticated).
- Tip: test against the deployed URL, or expose local over your network with
a tunnel (e.g.
ngrok) — public-wifi note: a tunnel avoids same-network issues.
- Privacy policy / basic terms 🟡 — added
/privacyand/termspages, linked from the sign-in page. Covers what's collected (coach email for magic-link auth; player name/phone/email/notes entered by the coach), that it's not sold/shared beyond the hosting (Railway) and email (Resend) providers, and that deletion requests go to[email protected]and are handled manually for now. Terms include a no-affiliation-with-WhatsApp disclaimer per the coexistence model inCLAUDE.md. Not formal legal review — revisit with a lawyer before any paid tier. - Error & not-found pages ⚪ — added
src/app/error.tsx(client error boundary with a "Try again" button) andsrc/app/not-found.tsx, matching the existing minimal page style. - Rate-limit the magic-link request ⚪ —
src/auth.tsnow enforces a 60-second per-email cooldown insendVerificationRequest(backed byisRateLimitedinsrc/lib/rateLimit.ts, unit-tested) before sending another magic-link email. In-memory, so it resets on redeploy and only holds for a single instance — revisit with a shared store (e.g. Redis) if the app ever scales beyond one Railway instance. - CI on PRs ⚪ — added
.github/workflows/ci.yml: runs format check, lint, typecheck, and unit tests on every PR intomain. Deliberately skips E2E for now (needs a Postgres service + more setup) — fine while it's a single-developer repo; add it if/when others start contributing or E2E coverage grows. - Error monitoring ⚪ — wired
@sentry/nextjs:src/instrumentation.ts(server/edge) +src/instrumentation-client.ts(browser), both readingNEXT_PUBLIC_SENTRY_DSN(not secret, so one var works for both sides).src/app/error.tsxand the newsrc/app/global-error.tsx(catches errors in the root layout itself) report viaSentry.captureException.next.config.tswraps the config withwithSentryConfigso production builds upload source maps (needsSENTRY_AUTH_TOKEN, scopeproject:releases, set on Railway) and strip them from the shipped client bundle. SetNEXT_PUBLIC_SENTRY_DSNandSENTRY_AUTH_TOKENon the Railway staging + production app services to activate; both unset locally is fine (errors just won't report and the build skips source map upload in dev). - Migration safety / rollback guardrails 🔴 — do this next, or very
soon. Now that production deploys deliberately from the
productionbranch, a bad release can be rolled back withgit push -f origin <sha>:production— but that reverts code only.npm startrunsprisma migrate deployand Prisma has no automatic down-migration, so a destructive migration (dropped column, narrowed type, renamed field) survives the rollback and leaves old code running against a new schema. The deploy split we just made is what raises the stakes here: production can now lag staging by several commits, so a single promote may apply several migrations at once. Worth deciding: (a) an expand/contract convention so migrations are always additive and reversible within a release, (b) a review step that flags destructive SQL inprisma/migrationsbefore promoting, and (c) whether the Hobby plan's lack of point-in-time restore is acceptable once real coaches have data in there — WAL archiving is on, but restore needs Pro. Get to this before the first column drop or rename, not after. - Move DNS to Cloudflare 🟡 — done 2026-09-16. Not the
calm-week migration this item originally planned for: risk (a) fired
first (see step 4), Resend flipped
contact.whosin.teamto unverified, and/loginstarted returning 500 withResend API error 403: domain is not verified. Sending had run on SPF+DKIM alone for weeks after the MX rows were deleted, which is why nothing looked wrong until it broke. Namecheap allows exactly one mail mode per zone (Email Forwarding or Custom MX), so restoring Resend's MX there would have killed[email protected]— Cloudflare has no zone-wide mail mode, MX are ordinary per-hostname records, and CNAME flattening keeps the apex on Railway, so forwarding and Resend coexist. Registrar stays Namecheap; only the nameservers change.theapps.appis a separate zone and was unaffected. The zone, recreated in Cloudflare: apexwhosin.teamCNAME →<id>.up.railway.app(DNS only);_dmarcTXTv=DMARC1; p=none;;send.contactMX10 feedback-smtp.us-east-1.amazonses.com;send.contactTXTv=spf1 include:amazonses.com ~all; andresend._domainkey.contactTXT holding thep=MIGf…key. Four traps, all avoidable next time: Cloudflare's importer cannot enumerate a zone — it guesses common hostnames, so it found only the apex CNAME and_dmarcand silently missed all three Resend records, which must be added by hand before activation; the apex must stay grey-cloud / DNS only, because Railway issues its own certificate and a proxied origin breaks renewal (Cloudflare's "not fully protected" warning is an upsell, ignore it); Resend's "Enable Receiving" MX is not needed — nothing receives oncontact.whosin.team, and its record belongs on that subdomain, never the apex, where it would collide with Email Routing; and switching Namecheap to Custom DNS wipes every record in the Advanced DNS tab, so inventory the zone first (Resolve-DnsName -Server dns1.registrar-servers.com) — there is no rollback afterwards. Copy DKIM from Resend's dashboard, never retype it. Outcome: the zone now does what Namecheap structurally could not — apex forwarding and Resend's subdomain records coexisting. Cloudflare Email Routing servesaccess@and[email protected]— apex MXroute1/2/3.mx.cloudflare.net, plus Cloudflare's own SPF include at the apex — both delivering; Resend reports Sending verified and magic-link sign-in works on production. Note Email Routing can only be configured after the zone goes active, so mail to both addresses bounces from the nameserver switch until it is — keep that window short. Resend's inbound half showed as pending and was disabled: nothing receives oncontact.whosin.team, and sending never depended on it. Verified from outside afterwards: apex answers Railway with nocf-rayheader (confirming grey-cloud), and the DKIM key is byte-identical to the Namecheap original. - Security headers 🟡 —
next.config.tsnow sets them on every route:Strict-Transport-Security(1 year, subdomains, deliberately nopreload— that submits the domain to a browser-baked list that is slow to reverse),X-Frame-Options: DENY(the delete-player and cancel-event actions are one-click form posts, so clickjacking was the one plausible attack),X-Content-Type-Options,Referrer-Policy, andPermissions-Policy. CSP ships asContent-Security-Policy-Report-Onlyon purpose: a CSP mistake breaks the app silently for real users, and report-only surfaces violations without blocking. Verified against a production build (next build+next start) driving/login,/,/events,/roster,/privacy,/terms, the create-event modal and an event detail page: zero violations. Two notes before promoting it to the enforcing header: (a)'unsafe-inline'inscript-srcis required, since Next injects inline bootstrap/hydration scripts and the nonce alternative needs middleware this app deliberately doesn't have; (b)connect-srcwildcards*.sentry.iobecause the DSN host is org- and region-specific andNEXT_PUBLIC_SENTRY_DSNis unset locally, so Sentry's real egress was never exercised in testing — check staging's console for a report-only violation on the ingest host before enforcing, or error reporting dies quietly. When promoting, rename the header key and add backupgrade-insecure-requests(browsers ignore it in report-only and warn on every page load, so it was removed).poweredByHeader: falsealso drops Next'sx-powered-byresponse header, which advertised the framework and version to anyone curious. - Public-link prep 🟡 — done ahead of the THeApps showcase site linking
here.
src/app/opengraph-image.tsxrenders a 1200×630 preview card frompublic/logo.pngat build time, withopenGraph/twittermetadata and ametadataBaseinsrc/app/layout.tsxso shared links resolve it absolutely.src/app/robots.tsallows only the pages a signed-out visitor can actually see (/,/login,/privacy,/terms) and disallows the rest, which would only redirect a crawler to/login;/check-emailis additionallynoindex./loginnow carries a standing invite-only line under the button, so a visitor arriving from the showcase site learns why they can't sign up instead of hitting a dead end. Deleted the unused 2.1 MBpublic/logo_original.png, which was being served publicly. - Uptime check ⚪ — still open: a simple external ping (e.g.
UptimeRobot's free tier) against
https://whosin.teamso you hear about an outage instead of a coach telling you. - Pre-launch smoke test on production 🔴 — sign in with a real email, create a group, add the roster, create an event, and share to WhatsApp from a phone. This is the real go/no-go.