brackt/plans/mobile-app-architecture.md
Claude ddacf8b307
Add mobile app framework decision and architecture design
Research and design document evaluating how to build iOS and Android
clients for Brackt while maximizing code sharing with the website.

Recommends Expo (React Native SDK 56) in an npm workspaces monorepo,
with a contract-first zod layer and a hybrid UI strategy: native screens
for the 24 surfaces that justify them, and Expo DOM components reusing
existing web components for the 44-route long tail (admin, marketing,
brackets). Rejects a universal react-native-web UI, documenting why.

Covers the shared packages/core extraction, better-auth Expo integration,
socket-first realtime, push notifications, the Phase 0 monorepo mechanics,
an eight-phase delivery sequence, testing, risks, and verification.

Also records three findings from the audit that stand on their own:
Socket.IO has no handshake authentication and join-draft trusts a
client-supplied teamId, exposing other managers' private queues; the
public-api-v1 plan is filed under completed but was never implemented;
and CLAUDE.md and AGENTS.md still describe Clerk instead of better-auth.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SnwjrXK8UpMZC4pmLTQ3hE
2026-08-20 21:41:12 +00:00

36 KiB
Raw Blame History

Brackt Mobile — Framework Decision & Architecture Design

Status: Design document. Not an implementation plan yet — this is the input to one. Date: 2026-08-20


1. Context

Brackt.com is a multi-sport fantasy drafting platform. Its centerpiece is a live, real-time snake draft with a running clock, a per-team draft queue, autodraft, and commissioner controls. That experience is the strongest possible argument for a native app: drafts are time-boxed, turn-based, and happen while people are away from a desk. The website already tries to compensate — app/hooks/useDraftNotifications.ts fires Web Notifications only when document.hidden, and server/socket.ts emits a full draft-state-sync snapshot on reconnect with an inline comment explaining it exists because mobile backgrounding breaks the HTTP revalidate path. The codebase has been reaching for an app for a while.

The goal: ship iOS and Android from one codebase, with as much code shared with the website as possible, so the two never drift apart on the rules that matter.

The constraint that shapes everything: divergence risk is not evenly distributed. Two <Button> components drifting apart is cosmetic. Two implementations of calculateDraftEligibility drifting apart silently changes who is allowed to draft whom. This design deliberately spends its sharing budget on the logic and contract layers, and accepts a rewritten presentation layer.

Decisions already made (see §4 for rationale): Expo/React Native; hybrid native + Expo DOM components; contract-first with zod; npm workspaces monorepo; admin surfaced via DOM components.


2. Where the codebase actually stands

An audit of the repo produced these numbers. They drive every decision below.

Layer LOC (non-test) Files Portable to mobile?
app/routes/ ~30,500 136 Split — loaders/actions stay server, JSX rewritten
app/components/ ~19,600 197 No — Tailwind v4 + 12 Radix packages, DOM-only
app/services/ ~19,400 117 Server-only (32 simulators, EV engine, sync) — stays put
app/models/ ~15,200 104 Server-only (Drizzle + AsyncLocalStorage) — types reusable
app/lib/ ~4,300 56 Mostly yes — this is the extraction target
app/hooks/ ~1,240 14 Logic yes, DOM listeners no
server/ ~1,070 11 Server-only, reused as-is

Good news, and it is genuinely good:

  • The draft room is already mobile-shaped. 21 endpoints under app/routes/api/ are a real JSON API (Response.json, proper status codes, session-checked). The draft room already calls them with fetch, not <Form>. GET /api/seasons/:seasonId/draft already returns a complete JSON draft board.
  • draft-state-sync is a ready-made mobile hydration payload — picks, timers with absolute expiresAt, queue, watchlist, pause state, all in one event on join-draft.
  • The timer is deadline-based, not tick-based. server/timer.ts emits an absolute expiresAt epoch and clients count down locally (app/lib/draft-timer.ts). No per-second socket traffic, and state is fully reconstructible from a timestamp — exactly right for a client that backgrounds.
  • useDraftSocket.ts already handles offline/online/visibilitychange with explicitly mobile-motivated comments.
  • The mobile information architecture already exists. $leagueId.draft.$seasonId.tsx has a md:hidden bottom tab bar and useDraftRoomState.ts drives a mobileTab state machine (available | queue | board | teams | controls). The native draft room is reimplementing a known, designed layout — not inventing one.
  • socket.io-client runs unchanged in React Native.

Three blockers, all of which are also latent problems for the website:

  1. Socket.IO has no authentication whatsoever. There is no io.use() middleware, no auth: option on the client, and no cookie check in the connection handler. join-draft(seasonId, teamId?) validates only that the teamId belongs to the season — never that the caller owns that team. Any unauthenticated client can join draft-${seasonId}, receive the full draft-state-sync, and by passing an arbitrary valid teamId join team-${teamId} to read another manager's private queue and watchlist. Mutations are safe (every /api/* action re-checks the session), so this is a read-side leak, not a write path — but it is a real leak that exists today.
  2. Most of the app is not consumable by a non-React-Router client. ~70 page routes return turbo-stream-serialized loader data (not JSON), and ~57 route actions take FormData + cookies with an intent discriminator. The plans/completed/public-api-v1.md plan was written for Clerk and never implemented — there is no server/routes/apiV1.ts, no docs/openapi.yaml, no /api/v1 reference anywhere in the tree.
  3. There is no validation or contract layer. zod@4.3.6 is a dependency used in exactly one file (app/utils/sports-data-sync.server.ts). Everything else is formData.get("x") as string plus hand-rolled null checks. Socket payload types are declared three separate times (server/socket.ts, server/socket.d.ts, inline in useDraftSocketEvents.ts) and are already drifting — pick-replaced, queue-eligibility-pruned, draft-rolled-back, and draft-started are emitted and handled but missing from the ServerToClientEvents interface.

Two pieces of documentation are stale and should be corrected as part of this work: CLAUDE.md and AGENTS.md both say "Clerk auth" — the app migrated to better-auth (BETTERAUTH_MIGRATION.md, and docs/agents/auth.md is accurate). docs/agents/architecture.md documents a timer-update socket event that no longer exists and omits ~8 events that do.


3. Framework decision

Chosen: Expo (React Native) — SDK 56 / React Native 0.85

React Native with Expo is the only option that satisfies "iOS + Android from one codebase" and "share code with the website," because the website is React + TypeScript. Flutter, native Swift/Kotlin, and .NET MAUI all score zero on sharing and were not seriously considered.

Within the React/TypeScript family:

Option Verdict
Expo + React Native Chosen. Same language, same React model, same socket.io-client, mature push story, managed cloud builds (EAS) so no Xcode/Android Studio required day-to-day. New Architecture is mandatory and stable as of SDK 55+.
Capacitor / WebView wrapper Rejected as the primary approach. Near-zero divergence, but the draft room — the single screen most worth having native — is the one most damaged by webview latency and scroll/gesture behavior.
PWA only Rejected. public/site.webmanifest already declares display: standalone, but there is no service worker, so there is no Web Push today and adding it is not free. On iOS, web push requires the user to add to home screen and reliability is poor — unacceptable for a time-boxed draft clock. No store presence either.
React Native Web universal UI Rejected — see below.
One / Tamagui-based universal frameworks Rejected. Too young to bet a solo-maintained production app on.

Rejected: universal UI (react-native-web + NativeWind)

This was the closest call and deserves an explicit record, because it is the option that sounds most aligned with "share as much as possible."

Against it:

  • Scope. ~19,600 LOC across 197 components, ~4,350 className usages, and 12 @radix-ui/* packages that are DOM-only. Universalizing means rewriting all of it.
  • It regresses the website. You would lose Radix's accessibility primitives, semantic HTML and SSR/SEO for the marketing pages and generated sitemap, recharts (PointProgressionChart, RecentScoresCard), @dnd-kit (draft-order sorting), @tanstack/react-virtual (participant list), react-image-crop (avatar editor), and Turnstile on auth forms. Each has an RN substitute; each substitute is a downgrade.
  • Much of the UI is desktop-dense by nature — 36 admin routes, standings tables, bracket trees (BracketTreeView, Cs2TournamentBracket, NbaBracketLayout). A primitive set optimized for phone and dense desktop tables is good at neither.
  • The payoff is the wrong layer. It buys pixel-level sharing while the actual divergence risk sits in draft eligibility, snake-draft math, timer semantics, and API shapes — all of which this design shares anyway, at a fraction of the cost.

For a solo developer working in TypeScript/React only, it is a months-long migration that makes the website worse in order to share the layer that matters least.

Chosen UI strategy: hybrid — native screens + Expo DOM components

Native React Native screens for the surfaces that justify native quality:

  • Draft room, draft queue, draft board
  • League home, standings, team detail
  • Auth, onboarding, invite-accept
  • User settings, team settings, notification preferences

Expo DOM components ('use dom') for the long tail — these render the existing web React components, unchanged, inside a native-managed WebView with a serializable-props bridge:

  • All 36 admin routes
  • Marketing/static: rules, how-to-play, privacy, support
  • Tournament brackets and bracket tree views
  • Avatar editor / image crop (canvas-based, genuinely web tech)

This is what makes "full parity" tractable for one person. Those screens are literally the same files the website renders, so they cannot diverge, and they are exactly the screens where a WebView's cost is irrelevant — nobody is drag-scrolling a Swiss-stage admin table with 60fps expectations. Any DOM screen can be promoted to a native screen later, one at a time, forever, without a rewrite of the shell.


4. Target architecture

4.1 Repository layout

brackt/
├── apps/
│   ├── web/                    # the existing React Router 7 app, moved wholesale
│   │   ├── app/                # routes, components, models, services, lib
│   │   ├── server/             # Express + Socket.IO + timer
│   │   ├── database/           # Drizzle schema + context
│   │   └── server.ts
│   └── mobile/                 # new Expo app
│       ├── app/                # Expo Router file-based routes
│       ├── components/         # native components (NativeWind-styled)
│       ├── dom/                # 'use dom' wrappers around web components
│       └── lib/                # native socket client, auth, push registration
├── packages/
│   ├── contracts/              # zod schemas — THE shared contract (new)
│   ├── core/                   # pure domain logic, extracted from app/lib (moved)
│   └── api-client/             # typed fetch client generated from contracts (new)
├── drizzle/                    # migrations stay at root (drizzle.config.ts points into apps/web)
└── package.json                # npm workspaces root

4.2 Layering, and what each layer means for divergence

┌───────────────────────────────────────────────────────────┐
│  PRESENTATION            web JSX  │  RN screens  │ DOM cmp │  ← diverges by design
├───────────────────────────────────────────────────────────┤
│  packages/api-client     typed fetch + socket client       │  ← shared
│  packages/contracts      zod schemas, z.infer'd types      │  ← shared, the contract
│  packages/core           draft rules, snake math, timers   │  ← shared, the rules
├───────────────────────────────────────────────────────────┤
│  SERVER  app/routes/api  app/models  app/services  server/ │  ← single implementation
└───────────────────────────────────────────────────────────┘

The rule this enforces: anything that could produce a different answer on two clients lives in packages/core or packages/contracts, and there is exactly one copy of it.

4.3 packages/core — the extraction

These files were verified as pure (no DB, no React, no Node built-ins) and move out of app/lib/ with their co-located tests:

File LOC Why it must be shared
draft-eligibility.ts 233 The draft rules engine. Decides which sports a team may draft from.
draft-order.ts 82 Snake-draft math: buildDraftOrderTeams, getTeamForPick, getProjectedPicks
draft-timer.ts 145 formatClockTime, calculateTimeAfterPick, clientExpiresAt, chess-clock presets
overnight-pause.ts 96 isInOvernightWindow, getOvernightResumeUTC
bracket-templates.ts 1,301 Bracket shape definitions
fifa-2026-bracket.ts + third-place 668 World Cup bracket logic
flag-generator.ts, flag-types.ts, avatar-data.ts, avatar-colors.ts, color-hash.ts ~166 Deterministic avatar config from a seed — renderer-agnostic
date-utils.ts, normalize-team-name.ts, fuzzy-match.ts, standings-display.ts, tournament-identity.ts, sport-icon-url.ts, cloudinary-url.ts, scoring-types.ts ~450 Misc pure helpers
calculatePickInfo (from app/models/draft-utils.ts) Snake round/pick math, already used on both server and client paths

One carve-out: getTimerColorClass in draft-timer.ts returns Tailwind class strings. It stays web-side; packages/core exports a getTimerSeverity(): 'normal' | 'warning' | 'critical' and each platform maps severity to its own styling.

Every one of these files already has tests in __tests__/, which move with them and become packages/core's test suite.

4.4 packages/contracts — hand-written zod, not derived from Drizzle

Domain types today come from typeof schema.X.$inferSelect, and composite types use Awaited<ReturnType<typeof someModelFn>>. That pattern is a hard blocker for mobile: it drags drizzle-orm/pg-core and the postgres driver into the type graph, and the inferred types carry Date objects and numeric-as-string values that do not survive a JSON boundary.

Decision: packages/contracts hand-writes zod schemas for the wire format and derives types with z.infer. It does not import from database/schema.ts at all.

This costs some duplication and buys three things: mobile never touches Drizzle; the wire format is explicit rather than accidental (dates are ISO strings, numerics are numbers, both stated in the schema); and there is finally runtime validation where today there is none. Server handlers validate their inputs against the same schemas, so a drift between the DB shape and the wire shape becomes a test failure rather than a silent undefined on one platform.

Contents:

  • Socket events — one schema per event, replacing the three drifting declarations. Includes the four currently-missing events.
  • API request/response bodies — for each of the 21 existing draft/queue endpoints, then league, team, settings, and admin domains as they convert.
  • Shared enums — season status, autodraft mode, timer mode, audit action, derived from the same source of truth as the pgEnums.

4.5 Screen inventory — every route, assigned

app/routes.ts has 95 route entries. This is the full parity map.

Native RN screens (24) — the surfaces where native quality is worth the rewrite:

Group Routes
Draft (3) /leagues/:id/draft/:seasonId, /draft-queue/:seasonId, /draft-board/:seasonId
League (8) /leagues/new (the 1,314-line wizard), /leagues/creating, /leagues/:id, /settings, /audit-log, /upcoming-events, /sports-seasons/:ssId, /sports-seasons/:ssId/events/:eventId
Standings (2) /leagues/:id/standings/:seasonId, .../teams/:teamId
Auth (6) /login, /register, /check-email, /forgot-password, /reset-password, /onboarding
User & entry (5) /settings/:section?, /teams/:teamId/settings, /i/:inviteCode, /user-profile, plus a native home/dashboard replacing /

Expo DOM components (44) — existing web components, reused verbatim:

Group Count Routes
Admin 36 routes/admin.tsx layout + all 35 children (sports, sports-seasons and its 16 sub-pages, participants, tournaments, templates, data-sync, standings-snapshots, users, leagues, simulators, draft-schedule)
Static / marketing 5 /how-to-play, /rules, /support, /privacy-policy, /sports
Data-dense read-only 3 /upcoming-events, /sports-seasons/:id/tournament (bracket trees), avatar editor within settings

No screen (27) — server-only, unchanged: the 23 api/* resource routes, 3 cron job endpoints, /healthz. /test-socket is a dev scratch page and is not ported.

The ratio is the argument for the hybrid: 24 screens to build, 44 to inherit.

4.6 Phase 0 mechanics — what actually breaks in the move

I read every build config. Most of them survive the move untouched, because they resolve paths relative to their own __dirname or ./ and therefore move with the app. Named concretely so Phase 0 is a checklist, not an exploration:

Move to apps/web/ unchanged: vite.config.ts, react-router.config.ts, vitest.config.ts, components.json, instrument.server.mjs, server.ts, scripts/, public/, .storybook/, cypress/, .oxlintrc.json.

Needs real edits:

File What breaks Fix
Dockerfile Enumerates directories explicitly (COPY app/ /app/app/, COPY server/, COPY database/, COPY tsconfig*.json vite.config.ts …) and does a root-level npm ci in three stages Rewrite to copy the workspace root manifests + apps/web/ + packages/, and run npm ci --workspaces. This is the largest single mechanical change.
drizzle.config.ts out: "./drizzle", schema: "./database/schema.ts" Move the config and drizzle/ (227 migration files) into apps/web/. The DB belongs to the server app; keeping migrations at the repo root while the schema moves is the setup most likely to silently generate a migration into the wrong place.
tsconfig.json / tsconfig.vite.json / tsconfig.node.json / tsconfig.server.json Path maps ~/*./app/* etc. still work post-move, but the new packages are invisible Add @brackt/core/* and @brackt/contracts/* to paths in all four. Keep ~/* exactly as-is so no import statement in the existing 92k LOC has to change.
package.json Single-package scripts Root becomes a workspaces manifest; dev/build/test:run etc. delegate via -w apps/web. lint script (oxlint app/ server/ database/) becomes workspace-aware.
.forgejo/workflows/deploy.yml npm ci + npm run test:run + npm run lint at root Still works if root scripts delegate; verify the Cypress and Postgres service steps still resolve. The two cron workflows (daily-snapshots.yml, sync-and-simulate.yml) are just curl calls to production and need no change.
docker-compose.yml Build context Point at the new context.

One config deserves special attention. vite.config.ts has a custom database-context-alias plugin that resolves ~/database/context to database/context.browser-stub.ts for client builds and the real context.ts for SSR. That plugin exists because someone already got burned by Drizzle leaking into a client bundle. Metro has no equivalent, and writing one is avoidable: it is the concrete reason packages/contracts must never import database/schema.ts (§4.4). If mobile's type graph can't reach Drizzle, no resolver stub is needed.

Order of operations, so the repo is never broken for long:

  1. Move app/, server/, database/, drizzle/, configs, and scripts into apps/web/ in one commit. Add the workspaces root. Nothing else changes. Gate: npm run typecheck && npm run test:all green.
  2. Fix Dockerfile, docker-compose.yml, and CI in a second commit. Gate: a successful deploy.
  3. Extract packages/core in a third commit, moving files with their __tests__/ and leaving app/lib/* re-export shims so no import site changes yet. Gate: tests green.
  4. Delete the shims and rewrite import sites mechanically. Gate: tests green.

No feature work in any of these commits.

4.7 Library substitution map (native screens only)

DOM-component screens keep their existing libraries untouched. These substitutions apply only to the 24 native screens:

Web Native Notes
Tailwind v4 + app.css @theme tokens NativeWind with the same CSS-variable token names The design tokens (--color-electric, --amber-accent, --coral-accent, radius scale) port; the app is already hard-coded dark, which removes theme-switching work
12 × @radix-ui/* + shadcn ui/ react-native-reusables (shadcn-shaped, NativeWind-based) Closest available analogue to the existing API surface
lucide-react lucide-react-native Near drop-in
recharts (PointProgressionChart) victory-native / react-native-svg Or keep as a DOM component — charts are a reasonable DOM candidate
@dnd-kit/* (queue + draft order) react-native-draggable-flatlist (Reanimated) Upgrade, not a downgrade
@tanstack/react-virtual FlatList Simpler natively
sonner toasts RN toast library
react-image-crop + AvatarEditor Stays a DOM component Canvas-based; genuinely web tech
@marsidev/react-turnstile WebView Registration flow only
nprogress (NavigationProgress) Dropped Meaningless in RN
Inline SVG (FlagSvg, BracktGradients, BracketDecor) react-native-svg Mechanical but non-trivial; the config generation (flag-generator.ts) is already shared via packages/core

5. Auth on mobile

better-auth is already in place, has an official Expo integration, and this is largely configuration rather than invention.

  • Server: add the expo() plugin and the bearer() plugin to betterAuth({...}) in app/lib/auth.server.ts. Register the mobile app's scheme in trustedOrigins.
  • Mobile: @better-auth/expo client plugin + expo-secure-store for token storage. createAuthClient({ baseURL }) — the web's authClient has no baseURL because it relies on same-origin; mobile must set it explicitly.
  • OAuth (Google, Discord) uses the system browser plus a deep-link callback; the better-auth Expo plugin handles the cookie-to-URL-parameter conversion.
  • Web is unaffected. It keeps cookie sessions. Server handlers already read sessions via auth.api.getSession({ headers }), which works for both a cookie and a bearer header — so the ~57 existing call sites need no change.
  • Turnstile on registration needs a WebView on mobile, or the mobile registration flow defers to the system browser.

Biometric unlock was not selected as a v1 must-have; long-lived secure-store sessions cover the "never logged out mid-draft" need on their own.


6. Realtime on mobile

6.1 Fix socket authentication first (security work, not mobile work)

Mobile has no ambient cookie jar for the handshake, so it forces the fix — but the fix is owed to the website regardless.

  • Client: io(url, { auth: { token } }).
  • Server: add io.use() middleware that verifies the better-auth session (bearer token from mobile, cookie from web) and attaches the resolved userId to the socket.
  • Stop trusting the client's teamId. join-draft(seasonId, teamId?) currently takes teamId as a parameter and only checks it belongs to the season. Change the handler to derive the caller's team from userId + seasonId server-side. This closes the private-queue leak described in §2.
  • Reject join-draft for private draft boards when the caller is not a member, matching the 401/403 logic already in the draft room's loader.

6.2 The mobile client is socket-first

The website maintains two sources of truth — the SSR loader and the socket — and pays for it with reconciliation machinery (isRevalidatingRef, pendingPicksDuringRevalidationRef in useDraftAuthRecovery.ts) that buffers socket picks landing mid-revalidation and merges them by pick id.

The mobile client sidesteps this entirely: join-draftdraft-state-sync is the only hydration path, and every subsequent change arrives as an event. One source of truth, no reconciliation buffer. This is a genuine simplification, and once it is proven on mobile it is worth evaluating whether the web draft room should adopt the same model.

6.3 The native draft room — already designed

The web draft room's mobile breakpoint is not a fallback; it is a deliberate phone layout that the native screen should port structurally rather than reinvent:

  • Header — logo, "Draft Room", exit.
  • On-the-clock bar (md:hidden, aria-live="assertive") — whose turn, the countdown, and a distinct treatment for your turn versus overnight pause. This becomes a persistent native header component.
  • Bottom tab bar with five tabs, driven by mobileTab in useDraftRoomState.ts:66: available | queue | board | teams | controls. This maps one-to-one onto an Expo Router bottom-tab navigator inside the draft route.
  • Commissioner controls already live in their own controls tab on mobile (CommissionerDraftControls is hidden md:flex in the header), so the native screen inherits that placement.

Two things become better natively: AvailableParticipantsSection (784 LOC) currently uses @tanstack/react-virtual and becomes a plain FlatList; the queue's @dnd-kit sortable becomes a native draggable list with real gesture handling.

6.4 Backgrounding

The deadline-based timer makes this tractable: the client stores an absolute expiresAt and derives the countdown from wall-clock time, so a backgrounded app resumes with a correct clock rather than a stale tick count. On foreground, re-emit join-draft and let draft-state-sync replace local state wholesale.


7. Push notifications

The decision logic already exists server-side — sendOnTheClockEmail (app/services/draft-email.server.ts) and notifyPickMadeOnDiscord (app/services/draft-discord.server.ts) are both already called from app/routes/api/draft.make-pick.ts and server/timer.ts. Push is a fourth fan-out alongside email and Discord, not a redesign.

  • New deviceTokens table (user, token, platform, app version, timestamps) + a Drizzle migration via npm run db:generate.
  • New app/services/push.server.ts mirroring the shape of the Discord and email services, reusing enqueuePickNotification's per-league serialization so pushes arrive in pick order.
  • Respect the existing per-user notification preferences pattern (draftEmailNotificationsEnabled, discordPingEnabled) — add a push equivalent to app/components/user/settings/NotificationsSection.tsx.
  • Because the server owns an absolute pick_deadline_at, "your pick expires in 60 seconds" can be scheduled accurately, not just fired reactively.

Events to send: you're on the clock; your pick is about to expire; the draft is starting soon; the draft started; your autodraft made a pick for you; draft complete.

Background draft queue (the second must-have): queue mutations already run through api/queue/{add,remove,clear,reorder} with optimistic updates and rollback on the web. Mobile adds a write queue that persists pending mutations across a background/kill and replays them on reconnect, reconciling against the authoritative draft-state-sync.


8. Prerequisite refactors

These land in apps/web/ before or alongside mobile work, and each stands on its own merits.

  1. Extract a headless draft-room core. $leagueId.draft.$seasonId.tsx is 1,785 lines — loader, ~40 useState slices, ~15 mutation handlers, derived-state useMemos, and the full responsive JSX tree in one file. The hook decomposition (useDraftRoomState, useDraftSocketEvents, useDraftAuthRecovery) is already ~80% of the way there; what remains is pulling the mutation handlers and derived state out into a platform-agnostic useDraftRoom() that both the web JSX and the RN screen consume. This is the single highest-leverage refactor and should happen before any RN screen is written.
  2. Socket authentication (§6.1) — security fix, ships independently.
  3. zod contracts for socket payloads — retires the triple declaration and the four drifted events.
  4. Correct the stale docsCLAUDE.md and AGENTS.md say Clerk; docs/agents/architecture.md lists a dead timer-update event and omits ~8 live ones.

9. Phased delivery

Each phase ends with something that works and is worth having on its own.

Phase Deliverable Depends on
0 — Foundations npm workspaces monorepo; web moved to apps/web/; packages/core extracted with its tests passing; CI, Dockerfile, and drizzle config updated. Web app behaves identically.
1 — Contracts & security packages/contracts with socket + draft/queue API schemas; server-side validation wired in; socket handshake auth; teamId derived server-side. Website gets a real security fix. 0
2 — Headless draft core useDraftRoom() extracted; web draft room refactored onto it and verified against the existing Cypress draft-room.cy.ts. 1
3 — App shell Expo scaffold, Expo Router, NativeWind, better-auth Expo login/register/OAuth, deep links, session persistence. Runs on device. 0, 1
4 — Native draft room The core deliverable. Socket-first draft room, queue, board, autodraft, commissioner controls, offline write queue. 2, 3
5 — Push deviceTokens table, push.server.ts, on-the-clock and expiry-warning sends, preference UI on both platforms. 4
6 — Native league surfaces League home, standings, team detail, settings, invite-accept — requires converting those route actions to contract-validated endpoints. 1, 3
7 — DOM long tail Admin, marketing/rules, tournament brackets, avatar editor via 'use dom'. Parity reached. 3
8 — Release App Store and Play Store submission, EAS Build/Submit pipeline, OTA update channel, crash reporting via the existing Sentry account. 47

Phases 3 and 7 unblock "full parity" much earlier than a pure-native path would, which is the whole point of the hybrid.


10. Testing

The repo mandates tests for every feature (docs/agents/testing.md) and has 179 test files. The mobile work extends that rather than inventing a parallel regime.

  • packages/core inherits the existing co-located __tests__/ suites — these become the shared guarantee that both platforms compute draft eligibility, snake order, and timers identically.
  • packages/contracts gets round-trip tests: server response → zod parse → expected type, so a wire-format change fails loudly on both platforms at once.
  • Web keeps vitest + Cypress. cypress/e2e/draft-room.cy.ts is the regression gate for the Phase 2 headless refactor.
  • Mobile uses vitest + React Native Testing Library for logic and components; Maestro or Detox for the one E2E flow worth automating (login → join draft → make a pick).
  • Contract conformance — one test per endpoint asserting the real handler's response parses against its contract schema.

11. Risks

Risk Mitigation
Socket.IO is single-instance and stateful. connectedTeams presence and draftRoomClosureTimers are in-memory Maps; deployment relies on sticky sessions. More clients per draft (web + phone for the same user) increases the blast radius of a restart. Existing docs/infrastructure-roadmap.md Phase C covers Traefik sticky sessions; the Redis Socket.IO adapter is the known next step. Not a blocker for v1, but track it.
The Phase 0 monorepo move. Smaller than it looks — most configs are __dirname-relative and move intact — but the Dockerfile enumerates directories explicitly and drizzle's paths are hard-coded. The four-commit sequence in §4.6, each with its own green gate. Keep ~/* path aliases identical so no existing import changes.
The 1,785-line draft room refactor could regress the live draft. Cypress draft-room.cy.ts as the gate; refactor is behavior-preserving by construction; ship Phase 2 separately from any mobile code.
Expo DOM components have real constraints — only serializable props and async callbacks cross the bridge, and they don't get React Router's loader data for free. Verify the pattern on one admin route in a spike before committing Phase 7. See §12 open question.
Solo maintenance of three surfaces (web, native, DOM). The DOM tier is deliberately the unmaintained tier — it tracks the website automatically. Native surface is capped at ~15 screens.
App Store review for a fantasy sports app; some reviewers scrutinize anything resembling contests. Brackt has no wagering or entry fees; positioning is straightforward, but budget a review cycle.
Metro vs Vite resolution differences for workspace packages and path aliases. Resolved in Phase 0 by making packages/* source-only TypeScript with matching alias config on both bundlers.

12. Verification

  • Phase 0: npm run typecheck && npm run test:all green from the workspace root; npm run dev serves the site identically; docker compose up builds; CI workflows pass unchanged in behavior.
  • Phase 1: new contract tests pass; a socket client without a valid session is rejected at handshake; a client passing another team's teamId to join-draft no longer receives that team's queue-updated.
  • Phase 2: cypress run --spec cypress/e2e/draft-room.cy.ts passes against the refactored room; a manual two-browser draft produces identical behavior to main.
  • Phase 4: run a real draft with a phone and a browser in the same season — picks, timer, queue, and autodraft stay in sync; background the app for five minutes mid-draft and confirm the clock is correct on resume.
  • Phase 5: on-the-clock push arrives on a locked device within seconds of the previous pick landing.
  • Phase 7: every route in app/routes.ts is reachable in the app, natively or via DOM.

13. Open questions

Resolved by a spike during Phase 0/3, not by discussion:

  1. The DOM-component spike — do this before committing to Phase 7. 'use dom' only passes serializable props and async function callbacks across the bridge, and the admin page components currently read useLoaderData(). Pick one representative admin route (suggest admin.users.tsx — table-shaped, low risk) and answer: what is the minimum wrapper shape that feeds it loader data as props? Does Expo bundle Tailwind's app.css inside the DOM component? What happens to a react-router <Link> rendered inside one — does it need intercepting and forwarding to Expo Router? If the answers are ugly, the fallback is an authenticated in-app browser for admin, which is worse but still parity.
  2. Metro resolution for the workspace. Symlink handling and unstable_enablePackageExports for source-only packages/*. Resolve during the Phase 3 scaffold; the mitigation is well-trodden (Expo documents monorepo setups) but it should be proven with a real cross-package import before Phase 4 depends on it.
  3. Whether the web draft room should adopt the mobile client's socket-first model (§6.2) and shed its reconciliation buffers, once mobile has proven the pattern.
  4. Apple Developer and Google Play accounts, and EAS Build tier — needed before Phase 8, not before Phase 0.