Two days. That is how long the StatPro Games tab 404ed in production because a PR deleted mobile API routes it thought were unused.
Here is what happened. A refactor PR saw routes tagged "deprecated mobile" and removed them. The shipped Expo app still called those endpoints. Nobody noticed because the web app and the mobile app live in separate project trees and there was no check that connected them. Users on the StatPro mobile app just saw an empty Games tab for two days until someone reported it.
The fix: a contract guard that reads your client
The guard is a 200-line Node script with zero dependencies that runs in the docs CI job. It reads the mobile app API client (every apiRequest() call site), extracts the endpoint paths, and asserts each one resolves to a real Next.js route file under the web app /api/v1/mobile/ directory.
// The KNOWN_MISSING allowlist: routes the client calls
// that do not have a web route yet. When the route ships,
// this check FAILS until you remove the entry.
const KNOWN_MISSING = new Map([
["/games/[sport]/[slug]/matchup",
"plan 047 slice c - matchup has no canonical data source yet"],
]);
The KNOWN_MISSING map is the clever part. It is an allowlist for gaps you know about and have accepted. But it is fail-closed in the other direction: when a route ships, the check breaks until you remove the stale entry. The allowlist cannot quietly rot. And if someone refactors the client to use dynamic path construction instead of string literals, the guard fails on that too (fail-closed on non-literal arguments) rather than silently skipping unresolvable calls.
Why this pattern generalizes
Any time you have a consumer and a provider that live in different repos or different project trees and there is no compiler connecting them, you have this problem. A mobile app calling web routes. A CLI calling an API. A webhook receiver calling a job runner. The moment the provider can delete something the consumer still depends on, you have got a silent break waiting to happen.
The pattern: extract every call site from the consumer (grep, AST parse, whatever your language gives you), assert each resolves on the provider, and maintain a KNOWN_MISSING list that expires automatically. The whole thing runs in CI, no deps, no infrastructure. The extraction is the hard part; the assertion is ten lines.
Same day: the app became four modes
While the guard shipped, the rest of the night was a full information-architecture overhaul. The app used to be one big "betting" surface with stats sprinkled in. Now it is four explicit modes with their own tabs: Stats, Bet, Search, and Fantasy (the last one branded coming-soon).
On web, all eleven betting surfaces moved to /app/bet/* with permanent redirects. Both modes now share a single capped-width AppShell (1440px max, sidebar shifted to the shell edge, min-w-0 to prevent flex blowout). Mode-specific chrome (the picks FAB, the paywall gate) gets injected by the /bet layout only, so the frame is pixel-identical whether you are in Stats or Bet.
// One shell, two modes. The frame stays identical.
<SidebarProvider className="mx-auto max-w-[1440px]">
<AppSidebar className="md:left-[max(0px,calc((100vw-1440px)/2))]" />
<SidebarInset className="min-h-svh min-w-0">
{children}
</SidebarInset>
</SidebarProvider>
The min-w-0 on the inset is the kind of thing that costs you an hour if you forget it. Without it, the flex item cannot shrink below its content min-content width and the whole pane pushes past the viewport edge, clipping toolbar counts and right-rail tables at exactly 1440px.
Mobile got the same surgery
Bottom tabs are now modes (Stats, Bet, Search, Fantasy). Picks left the tab bar for a global floating action button plus a full-screen modal. Profile moved to a header avatar. The old HOME_ROUTE constant had been pointing at a dead route since plan 024 and finally got healed. About 200 lines of dead route builders got deleted.
The mobile Stats tab got the full reference port: a league picker hub, leader boards with year pickers, player profiles with season/career/last-10/splits tables, and team profiles with rosters and schedules. The stats table component got extracted so Search and Stats now share one implementation (search tests unchanged). Adapters stay defensive: ragged rows get padded, unrenderable tables get dropped with an honest note, and 404s offer "Go back" rather than a retry that will just fail again.
The lesson I am keeping
The two-day 404 was the expensive one. Everything else was planned IA work that went smoothly because it was planned. The contract guard is the thing I wish I had written before the refactor, not after. When two codebases depend on each other and no compiler connects them, a 200-line script in CI is cheaper than one user-facing outage.