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
36 KiB
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 withfetch, not<Form>.GET /api/seasons/:seasonId/draftalready returns a complete JSON draft board. draft-state-syncis a ready-made mobile hydration payload — picks, timers with absoluteexpiresAt, queue, watchlist, pause state, all in one event onjoin-draft.- The timer is deadline-based, not tick-based.
server/timer.tsemits an absoluteexpiresAtepoch 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.tsalready handles offline/online/visibilitychange with explicitly mobile-motivated comments.- The mobile information architecture already exists.
$leagueId.draft.$seasonId.tsxhas amd:hiddenbottom tab bar anduseDraftRoomState.tsdrives amobileTabstate machine (available | queue | board | teams | controls). The native draft room is reimplementing a known, designed layout — not inventing one. socket.io-clientruns unchanged in React Native.
Three blockers, all of which are also latent problems for the website:
- Socket.IO has no authentication whatsoever. There is no
io.use()middleware, noauth:option on the client, and no cookie check in the connection handler.join-draft(seasonId, teamId?)validates only that theteamIdbelongs to the season — never that the caller owns that team. Any unauthenticated client can joindraft-${seasonId}, receive the fulldraft-state-sync, and by passing an arbitrary validteamIdjointeam-${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. - 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 anintentdiscriminator. Theplans/completed/public-api-v1.mdplan was written for Clerk and never implemented — there is noserver/routes/apiV1.ts, nodocs/openapi.yaml, no/api/v1reference anywhere in the tree. - There is no validation or contract layer.
zod@4.3.6is a dependency used in exactly one file (app/utils/sports-data-sync.server.ts). Everything else isformData.get("x") as stringplus hand-rolled null checks. Socket payload types are declared three separate times (server/socket.ts,server/socket.d.ts, inline inuseDraftSocketEvents.ts) and are already drifting —pick-replaced,queue-eligibility-pruned,draft-rolled-back, anddraft-startedare emitted and handled but missing from theServerToClientEventsinterface.
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
classNameusages, 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:
- Move
app/,server/,database/,drizzle/, configs, and scripts intoapps/web/in one commit. Add the workspaces root. Nothing else changes. Gate:npm run typecheck && npm run test:allgreen. - Fix
Dockerfile,docker-compose.yml, and CI in a second commit. Gate: a successful deploy. - Extract
packages/corein a third commit, moving files with their__tests__/and leavingapp/lib/*re-export shims so no import site changes yet. Gate: tests green. - 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 thebearer()plugin tobetterAuth({...})inapp/lib/auth.server.ts. Register the mobile app's scheme intrustedOrigins. - Mobile:
@better-auth/expoclient plugin +expo-secure-storefor token storage.createAuthClient({ baseURL })— the web'sauthClienthas nobaseURLbecause 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 resolveduserIdto the socket. - Stop trusting the client's
teamId.join-draft(seasonId, teamId?)currently takesteamIdas a parameter and only checks it belongs to the season. Change the handler to derive the caller's team fromuserId + seasonIdserver-side. This closes the private-queue leak described in §2. - Reject
join-draftfor 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-draft → draft-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
mobileTabinuseDraftRoomState.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
controlstab on mobile (CommissionerDraftControlsishidden md:flexin 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
deviceTokenstable (user, token, platform, app version, timestamps) + a Drizzle migration vianpm run db:generate. - New
app/services/push.server.tsmirroring the shape of the Discord and email services, reusingenqueuePickNotification's per-league serialization so pushes arrive in pick order. - Respect the existing per-user notification preferences pattern (
draftEmailNotificationsEnabled,discordPingEnabled) — add a push equivalent toapp/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.
- Extract a headless draft-room core.
$leagueId.draft.$seasonId.tsxis 1,785 lines — loader, ~40useStateslices, ~15 mutation handlers, derived-stateuseMemos, 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-agnosticuseDraftRoom()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. - Socket authentication (§6.1) — security fix, ships independently.
- zod contracts for socket payloads — retires the triple declaration and the four drifted events.
- Correct the stale docs —
CLAUDE.mdandAGENTS.mdsay Clerk;docs/agents/architecture.mdlists a deadtimer-updateevent 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. | 4–7 |
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/coreinherits the existing co-located__tests__/suites — these become the shared guarantee that both platforms compute draft eligibility, snake order, and timers identically.packages/contractsgets 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.tsis 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:allgreen from the workspace root;npm run devserves the site identically;docker compose upbuilds; 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
teamIdtojoin-draftno longer receives that team'squeue-updated. - Phase 2:
cypress run --spec cypress/e2e/draft-room.cy.tspasses against the refactored room; a manual two-browser draft produces identical behavior tomain. - 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.tsis reachable in the app, natively or via DOM.
13. Open questions
Resolved by a spike during Phase 0/3, not by discussion:
- 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 readuseLoaderData(). Pick one representative admin route (suggestadmin.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'sapp.cssinside 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. - Metro resolution for the workspace. Symlink handling and
unstable_enablePackageExportsfor source-onlypackages/*. 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. - 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.
- Apple Developer and Google Play accounts, and EAS Build tier — needed before Phase 8, not before Phase 0.