Ominvo beta launches July 23, 2026 — only 10 spots remain Join the waitlist →
All posts
Field NotesDay 41

Day 41: Three rounds to ship one check-email page (and a polish bundle that took one)

June 11, 20265 min read

Day 41 had one planned task: add an intermediate page between the signup form and the verification email click. Show the user's email address. Offer a resend button. Give them a way back. The whole thing should have been one commit.

It took three.


Before writing a line of code, the context documentation was consulted. It said /onboarding was missing from the app. The actual directory listing showed it wasn't. The codebase is the source of truth. Documentation is a snapshot that ages. Read the code first, not the notes.


Round one: three failures in one test run

The /check-email page shipped in commit e8c8f65. It showed the destination email address, a Turnstile-gated resend button, and a "← Sign up again" back link. Clean build. TypeScript silent.

First production E2E test failed three ways at once.

The verification email linked to the homepage. emailRedirectTo was missing from the Supabase auth call — without it, the confirmation link routes to the site root, not /auth/callback. The page never updated when verification completed in another tab: no auth state listener meant the UI had no way to learn verification had happened. The resend button threw "captcha protection: request disallowed" — Supabase's project-level captcha gate applies to auth.resend(), not just the initial signup endpoint. Three separate assumptions. All three wrong.


Round two: fixed all three, found a fourth

Commit bdc8c5c added emailRedirectTo to both the signup and resend auth calls. Added a cross-tab auth state listener — when verification completes in another tab, the page flips to a green "Email verified" card and auto-redirects to /dashboard. Added Turnstile to the resend flow so the captcha challenge fires correctly.

Deployed. Tested. The page flashed for a second and redirected to /login before the email address was even readable.

New failure mode.


Round three: getSession() was lying

The redirect came from the auth guard on /check-email. The guard was calling getSession() to check whether the user was already verified. Supabase's getSession() reads from localStorage — no server round-trip, no validation. A stale token from a deleted test user was cached in the browser. It passed the session check. The guard treated the session as valid, triggered the redirect, and the page vanished.

Commit 1999f61 replaced getSession() with getUser() — a server round-trip that validates the token against Supabase's auth database. Added an email-match guard comparing the URL param against the server-returned address. Added an email_confirmed_at check to confirm actual verification happened, not just that a session token exists.

Three rounds. The last bug was not a code error. It was trusting localStorage as a source of verified state.

One more note: the round 2 flash may have been the browser serving a cached round 1 bundle — not an actual round 2 regression. The fix existed; the cache obscured it. Every debug session starts in a fresh incognito tab with a hard reload. The cost is three seconds.


The polish bundle: a different workflow

Commit 690fd88 closed four carryover bugs from Day 40 in one pass.

Homepage pricing: Chad annual was hardcoded "$240/year — save $120" with a "Save 33% with annual billing" banner. The real Stripe price is $288/year — save $72, 20% off. All three strings were separate hardcoded literals. All three were wrong.

Admin Plan Breakdown row math: the revenue API returned per-tier counts only. AdminClient multiplied count × hardcoded $30 to produce each row's MRR label. An annual Chad subscriber contributes $24/mo — not $30. The fix extended the API to accumulate chadMrr and gigachadMrr in the same Stripe loop that already calculates total MRR. AdminClient reads the real sums directly. The row label now reflects what was actually charged.

Settings Card 5: a GigaChad feature promo block was gated to tier !== 'gigachad', showing to both noob and chad users. Chad users already had an "Upgrade to GigaChad" button in Card 4. Re-gated to tier === 'noob'. One CTA per audience.

The approach mattered. One read-only investigation prompt mapped every wrong value, line number, and surrounding context. A second surgical edit prompt provided exact old/new string pairs for each fix. No back-and-forth. No iteration. The contrast with the three-round main task is the point — separating investigation from editing eliminates the category of bug where fixing one thing silently breaks another.


MCP ops

Two test-mode Chad monthly subscriptions canceled via Stripe MCP — accumulation from three rounds of E2E testing. Three orphaned records for the test email address removed from Supabase via the Supabase MCP. Both operations ran without opening a browser. Production housekeeping costs a line of intent, not a dashboard session.


60 days out

Hard No #70 from Day 40: run an E2E test in production before calling anything done. It found three bugs on a feature that looked finished after round one. The same rule paid out again today.

Four commits. Four fixes that required a real deployment to surface. getSession() reading localStorage. emailRedirectTo missing from auth calls. Project-level captcha on auth.resend(). Pricing strings wrong in three places. None of them visible to tsc --noEmit. All of them visible the moment someone actually tried to sign up.

~60 days to launch. August 10.

Day 41 of building in public. Full changelog at /changelog.

Written by

The founder of Ominvo

Building review management for single-location small businesses. Join the waitlist →