Code Quality & Maintainability
Below are all 12 findings recorded in this dimension during the audit, sorted by severity then launch priority. Each card carries the full anatomy: severity / priority / effort, code location, evidence, technical issue, business impact, plain-language explanation, fix steps, related dimensions, and references.
Findings — Code Quality & Maintainability
src/lib/locale-region.test.ts:the only test file in the repofind src -name '*.test.*' returns exactly one file: src/lib/locale-region.test.ts (Vitest unit test for inferRegion fallback). vitest.config.ts is configured and Vitest 2.1.9 is in devDependencies, but no other test file exists. No tests for: coach-chat / voice-coach endpoints (1,245 + 498 LOC), AI tool-call parsers (meal-analysis.ts parseAiToolCall), auth flow, RLS-bypass paths, the 102-module src/server/ surface, the 38 hooks, or any component. 80,000+ LOC of TS/TSX has 1 test -> effective code coverage is well under 1%.A second engineer modifying coach-chat context-loading, meal-analysis JSON parsing, biometric calculations, blueprint generation, or any of the 100 server modules has no automated way to know whether their change broke existing behavior. With AI-generated outputs in critical paths and no contract tests on parseAiToolCall-style helpers, even silent regressions in the AI gateway response shape go undetected.
Every change ships untested. Two-engineer team velocity will collapse the first time a refactor breaks a downstream coach prompt path, because there is no safety net to catch it before users see broken state. In a health-adjacent product, an undetected regression in biometric calculation or coach safety filtering becomes an end-user incident, not a CI failure.
There is one automated test in the entire codebase. Everything else has been verified only by you clicking through the app. The first time someone else changes the code, there is no machine to tell them whether they broke something -- they will have to test by hand, every time, and they will miss things.
Add a test script in package.json (vitest run). Then prioritize tests for the highest-risk pure functions first: parseAiToolCall in meal-analysis.ts, the safety-filter helpers in _shared/safety-check.ts / medical-safety.ts / mental-health-risk.ts, the longevity-score composition in useDashboardData, and the region inference already covered. Target: 30 unit tests covering pure server-side helpers within the first sprint; defer integration / E2E to later.
L — 1–2 weeks
package.json + repo-wide:package.json:49 declares @tanstack/react-query ^5.83.0@tanstack/react-query ^5.83.0 in package.json:49. grep for useQuery|useMutation|useInfiniteQuery across src: 0 occurrences. grep for QueryClientProvider across src: 0 occurrences. grep for @tanstack/react-query import in src: 0 occurrences. Meanwhile: 510 useState calls, 304 useEffect calls, 130 direct supabase.from(...) calls across src/, 14 component/route files calling supabase.from(...) directly (CoachPage.tsx 9 supabase calls inline; JournalPage.tsx, settings.tsx, CoachChatSheet.tsx, BiometricEditDialog.tsx, MorningCheckInPrompt.tsx, WaterQuickAddSheet.tsx, etc.). All data fetching is hand-rolled with manual loading/error state.TanStack Query is a transitive devDep of TanStack Start but is treated by this codebase as if it didn't exist. Cache invalidation, request deduplication, automatic refetch on focus, optimistic updates, and stale-while-revalidate are all reimplemented (badly) via useEffect + ad-hoc invalidation buses (lib/data-events.ts, data-invalidation-bus.ts) -- or simply not implemented, which is why the dashboard reloads everything on every render path.
Manual data layer is a maintenance tax on every component. Each one redeclares loading + error + retry + invalidation state by hand. A second engineer cannot rely on a consistent data-fetching idiom and will introduce subtle bugs every time they add a new query.
A standard tool for fetching and caching data in React is already installed in your project, but the project does not actually use it. Instead, every screen re-implements the same fetch / loading / error pattern by hand. That is why simple data changes (like logging a meal) sometimes don't immediately appear elsewhere in the app -- there is no shared cache.
Either remove @tanstack/react-query from package.json to avoid shipping unused code, OR (better) adopt it: wrap the app in a single QueryClientProvider, migrate the top 5 data-fetching hooks (useProfile, useDashboardData, useLifestyleData, useBodyMeasurements, useWorkoutLogs) to useQuery, and let the rest follow. Choose one direction -- do not leave the library installed but unused.
M — 1–3 days
src/components/CoachPage.tsx + 4 other oversized files:CoachPage.tsx:1-2096 (47 hook calls, 9 supabase calls in component body)Top oversized source files (excluding generated routeTree.gen.ts and supabase/types.ts): src/components/CoachPage.tsx 2,096 LOC (47 hook calls, 9 supabase.from calls inline, 12 console.* calls), src/routes/api.coach-chat.ts 1,245 LOC (single server route with 16 supabase calls and the full coach prompt pipeline inline), src/components/CoachChatSheet.tsx 1,108 LOC, src/routes/training_.assessment.tsx 1,023 LOC, src/routes/training.tsx 966 LOC, src/components/JournalPage.tsx 942 LOC, src/components/profile/BodyScanSection.tsx 901 LOC, src/server/meal-analysis.ts 841 LOC. CoachPage.tsx alone contains UI rendering, supabase queries, intake completion side-effects, twin-reaction firing, blueprint reveal logic, dashboard tour state, ManualMealSheet integration, and a typewriter stream -- eight responsibilities in one file.Files this large in a codebase with no tests (cross-ref COD-001) become a no-go zone: a second engineer cannot safely change anything without reading 2,000 lines of context. Several of these (CoachPage, JournalPage, training routes) are page components that should be thin layouts composing extracted hooks and sub-components.
Onboarding cost for a second engineer is dominated by these five files. Bug-fix turnaround time on the coach surface -- the product's main feature -- will be 3-5x slower than on a properly factored codebase because any change requires re-reading the whole file.
Five files in your app are each 700-2,100 lines long and try to do everything in one place: drawing the screen, fetching data, running business logic, talking to Supabase. The largest one is your coach page. A new developer would need a full day just to safely add a button to this page.
- data-fetching into hooks in src/hooks/, (.
- sub-sections into child components, (.
- pure helpers into src/lib/. Target <=300 LOC per component file and <=100 LOC per function. Start with CoachPage.tsx -- extract useCoachIntakeFlow, useCoachChatState, and a CoachPersonaPanel sub-component.
L — 1–2 weeks
160 catch blocks in src/ (excluding routeTree.gen.ts). 12 .catch silently-absorb patterns (e.g. CoachKnockCard.tsx:55, CoachChatSheet.tsx:379, CoachPage.tsx:1214, api.coach-chat.ts:865-868). 105 catch blocks pair console.warn or console.error with a silent return. Samples: src/server/_shared/ai-tool-call.ts: console.warn callAITool network error then return null; src/server/north-star.functions.ts: console.error getTodaysNorthStar failed then return null; src/server/bio-twin-active.ts: console.warn then return null. With no Sentry / Honeybadger (cross-ref OPS-005), console.error in a Cloudflare Worker is lost.The dominant error-handling idiom is: try { ... } catch (e) { console.warn(e); return null }. Callers receive a null and have no way to distinguish no-data-found from AI-gateway-threw from Supabase-RLS-denied from network-blew-up. In a Cloudflare Worker without an error tracker, those console.warn lines never reach a human.
Production failures become invisible. The coach silently degrades, the bio-twin avatar silently skips, the north-star quietly returns null -- and the user sees a UI that just does not work right without anyone knowing why. The team will only learn about failures through user complaints, weeks later.
When something goes wrong in the app, the code mostly just writes a note to a log nobody reads and pretends everything is fine. Combined with the lack of an error-tracking service, this means failures in production are essentially invisible until a user complains.
- catch only what you can handle; let everything else bubble; (.
- every catch either rethrows, returns a typed Result type, or reports to an error tracker -- never just console.warn + return null; (.
- for fire-and-forget calls, name them: .catch(reportNonFatal). Pair this fix with installing Sentry (OPS-005).
M — 1–3 days
src/routes/api.coach-chat.ts + src/routes/api.voice-coach-chat.ts:api.coach-chat.ts:865-868 and api.voice-coach-chat.ts:313 onwardFive context-loader function calls appear in both routes: loadCoachMemoryThreads, loadFunctionalAgeBlock, loadRitualSignals, loadSnapshotArcBlock, loadTodaysNorthStar. api.coach-chat.ts is 1,245 LOC; api.voice-coach-chat.ts is 498 LOC; both build a long context block, both call the Lovable AI Gateway, both stream a response, both handle invalidation. The voice route is effectively a transcription-prefixed copy of the text route. Common tables queried by both: profiles, biometrics, lifestyle_logs, coach_messages, daily_intents (also queried by 10+ other files; 16 places query profiles, 14 query lifestyle_logs, 12 query biometrics).Voice coach and text coach are the same conversation pipeline with a different transport. They should share a single buildCoachContext / streamCoachReply core. As written, every time a context loader is added (e.g. new safety filter) the team must remember to wire it in both routes, and they will forget.
Bug fixes drift between the two surfaces. A safety filter added to text-coach will not protect voice-coach users until someone notices the omission. In a mental-health-adjacent product (cross-ref DOM-007), that drift is a domain-compliance risk in addition to a code-quality one.
Your text coach and voice coach are mostly the same code copied into two files. When you fix a bug in one, you have to remember to fix it in the other. Sooner or later someone will forget, and one of the two coaches will behave differently from the other.
- stays in the route file; everything downstream of messages lives in the shared module.
M — 1–3 days
src/server/ + src/routes/api*.ts:12 of 100 server modules use zod; 0 of the 2 SSE routes validate inputsServer module count: 100 files under src/server/ (top-level + _shared/ + onboarding/). grep for z.object|z.string|z.number|z.array|z.enum in src/server matches 12 files only -- bio-twin-active.functions.ts, bio-twin-avatar.ts, bio-twin-react.functions.ts, bio-twin-reactions.ts, body-measurements.functions.ts, body-progress-compare.ts, coach-event.functions.ts, coach-intake.functions.ts, measurement-prompts.functions.ts, onboarding/body-baseline-analyze.functions.ts, push-send.ts, yesterday-tomorrow-plan.functions.ts. The two SSE routes (api.coach-chat.ts 1,245 LOC, api.voice-coach-chat.ts 498 LOC) have NO zod schema. Meanwhile as-unknown-as casts appear 29 times across 14 files (rough type-cast as substitute for validation), and bare : any appears 17 times.Most server functions trust their input shape based on TypeScript types that the runtime cannot enforce. A malformed client request -- or a deliberately crafted one -- can pass right through into Supabase queries and AI prompts. Cross-ref SEC-010 frames this as a security finding; here it is also a maintainability finding because callers cannot read a schema to know what each function accepts.
Every change to a server function expected input shape is a runtime gamble: TypeScript will not catch a mismatch from the client, only the eventual database error will. In a codebase with no error tracker (OPS-005) and no tests (COD-001), that means the only signal of contract drift is a user-visible failure.
When the app backend receives data from the browser, it mostly trusts the data is shaped correctly without checking. If anything ever sends a slightly wrong shape -- a bug, a stale cached client, a malicious user -- the failure will happen deep inside the database or the AI call instead of being caught at the front door.
Zod is already in dependencies (zod ^3.24.2). Add a one-screen schema at the top of each server function and parse the input through it before any business logic. Start with the highest-risk endpoints (api.coach-chat.ts, api.voice-coach-chat.ts, body-measurements, meal-analysis). Use zod safeParse so validation failures return a clean 400, not a thrown 500.
M — 1–3 days
src/components/ui/:ls src/components/ui = 46 files; only 10 are imported by app codesrc/components/ui/ contains 46 .tsx files. A grep over src/components + src/routes + src/hooks for from-@/components/ui/<name> finds 10 used: sheet (11), button (10), input (6), dialog (5), label (3), slider (2), tooltip (1), toggle (1), textarea (1), sonner (1). The other 36 are unused: accordion, alert, alert-dialog, aspect-ratio, avatar, badge, breadcrumb, card, carousel, chart, checkbox, collapsible, command, context-menu, drawer, dropdown-menu, form, hover-card, input-otp, menubar, navigation-menu, pagination, progress, radio-group, resizable, scroll-area, select, separator, sidebar, skeleton, switch, table, tabs, toggle-group. sidebar.tsx alone is 744 LOC and is imported by nothing.The shadcn/ui registry was added wholesale (typical Lovable scaffolding) rather than per-component. Because the app declares sideEffects: false in package.json, tree-shaking probably eliminates most of these from the production bundle -- but the source files still bloat the repo, slow grep and IDE indexing, and tempt future developers to import unused primitives.
Repo size and search noise. Not a launch blocker -- tree-shaking probably handles the runtime cost -- but a steady tax on every grep and every code review. Cleaning it up signals discipline to a second engineer.
Three quarters of the UI building blocks shipped in your project are never used anywhere. They were added by the scaffolding tool, not by anyone who needed them. They make the codebase look bigger than it is, but they do not run.
Delete the 36 unused .tsx files from src/components/ui/. When a future feature needs one, re-add it via npx shadcn add. Keep the 10 that are imported. Verify bun run build still succeeds.
S — under ½ day
src/hooks/useDashboardData.ts:493-507src/hooks/useDashboardData.ts:493 comment reads: 7-day mock trend lines (TODO: replace with real history aggregation). Lines 495-507 return hard-coded arrays [54, 58, 61, 57, 63, 60, biometric.hrv-or-62] for hrv, [62, 70, 65, 58, 72, 68, biometric.recovery-or-74] for recovery, [10, 13, 11, 14, 9, 12, biometric.strain-or-12.4] for strain, and [6.8, 7.2, 6.5, 7.8, 7.4, 7.1, biometric.sleep_hours-or-7.6] for sleep -- the first six values of every signed-in user 7-day sparkline are the same constants, only the last value reflects real data.This is half-implemented. The sparklines render and look real to the user, but six of every seven points are fabricated. A user comparing their dashboard to a friend would see identical history for the first six days. In a longevity-claim context (cross-ref DOM-006), showing fabricated trend data is an evidence-quality issue, not just a code TODO.
Users see a polished-looking trend chart that is partly fictional. In a health-adjacent product where data integrity is a feature, that is a credibility risk. The TODO has been in place long enough that the team has stopped seeing it.
Your dashboard shows mini line charts for HRV, recovery, strain, and sleep over the last seven days. Right now, six of the seven days are made-up numbers and only the most recent day reflects real data. The TODO comment in the code shows this was a stopgap that did not get finished.
Either implement the real 7-day aggregation (a single Supabase query over biometrics joined to user_id with a 7-day window, ordered ascending), or render the sparkline only when 2+ real points exist and otherwise show a collecting-data state. Do not ship synthetic history as if it were real.
S — under ½ day
68 eslint-disable directives in src/ (excluding routeTree.gen.ts). Of those, 28 are specifically react-hooks/exhaustive-deps. Hot spots: CoachPage.tsx (6 disables at lines 493, 1106, 1112, 1125, 1161, 1245), CoachChatDialog.tsx:86, CoachKnockCard.tsx:56. Pattern: the developer added a side effect, the linter complained that the dep array was incomplete or wrong, and the linter was silenced rather than the dependency added.react-hooks/exhaustive-deps exists because missing deps cause stale-closure bugs: the effect captures an old value, never re-runs when it should, and produces hard-to-debug bugs. Disabling the rule one occurrence at a time is the dominant idiom in this codebase -- 28 disables in 86,000 LOC indicates a systematic give-up rather than isolated false positives.
Each disable is a latent stale-closure bug waiting for the right interaction sequence. A second engineer touching any of these effects will not know which disables are intentional (genuinely correct to omit deps) and which are I-gave-up. Combined with no tests (COD-001), reverting a wrong disable will silently break things.
React has a linter rule that catches a common bug pattern. The code disables this rule 28 times. Each disable is a place where the original developer knew about a potential bug but chose to silence the warning instead of fixing it. The next developer will not know which of those silences are safe.
Treat each disable as a small refactor: replace useEffect + disabled deps with useEffectEvent (React 19 has it) or extract the changing value into a ref. For event-handler-style effects, use useEffectEvent. Aim to halve the count in the first sprint and reach zero within two sprints.
M — 1–3 days
177 console.{log,error,warn,info,debug} calls across 81 files in src/ (excluding routeTree.gen.ts). Top hot spots: src/server/bio-twin-avatar.ts (12), src/components/CoachPage.tsx (12), src/routes/api.coach-chat.ts (9), src/server/onboarding/daily-block.functions.ts (8), src/server/meal-analysis.ts (7), src/components/body/BodyCheckInSheet.tsx (5), src/components/JournalPage.tsx (5). No logger module exists in src/lib/. ESLint has no-console disabled at the config level (eslint.config.js does not include no-console in rules).On Cloudflare Workers, console.* writes go to the Worker tail log, which is ephemeral and not searchable beyond a short retention window without wrangler tail running. There is no level filtering, no PII redaction, and no structured fields. Combined with COD-004 (catch-and-console as the error-handling idiom), this guarantees that production failures are visible only if someone is actively tailing logs at the moment of failure.
Logs are practically useless for diagnosing production issues. When a user reports the-coach-gave-me-a-weird-response-yesterday, there is no log to look up. The Lovable AI Gateway costs (cross-ref AI-003, AI-006) cannot be reconciled against in-app actions because there is no per-call log.
The app developers leaned heavily on console.log statements when debugging -- there are 177 of them. They print to Cloudflare short-lived log stream and then vanish. There is no central log that someone could read later to figure out why something went wrong yesterday.
- so logs survive longer than a Worker invocation.
M — 1–3 days
src/hooks/use-mobile.tsx:filename itselfls src/hooks/ shows 37 files matching the pattern useFoo.ts / useFoo.tsx (e.g. useAuth.tsx, useDashboardData.ts, useBodyMeasurements.ts), and exactly one file matching kebab-case: use-mobile.tsx. Routes folder follows TanStack convention so does not violate. Components folder is uniformly PascalCase.The shadcn scaffolding introduced use-mobile.tsx with a different naming convention than the rest of the hooks directory. A trivial inconsistency on its own, but symptomatic of generator output being merged without normalisation.
Negligible on its own -- a tiny papercut for IDE auto-complete and for grep-by-convention. Mentioned for completeness so the team can decide whether to enforce a hook-naming lint rule.
One hook in your project is named use-mobile with a dash. Every other hook is named useSomething without a dash. This is a small inconsistency left over from the scaffolding tool -- easy to fix, low priority.
Rename src/hooks/use-mobile.tsx to src/hooks/useMobile.tsx (and update its one importer). Optionally add an ESLint custom rule or eslint-plugin-filename-rules to enforce useCamelCase.tsx for hook files.
S — under ½ day
eslint.config.js:24eslint.config.js:24 sets @typescript-eslint/no-unused-vars to off. tsconfig.json also has noUnusedLocals: false and noUnusedParameters: false. Result: neither the type checker nor the linter flags unused imports, unused local variables, or unused parameters. Two commented-out imports were found by hand (AppProviders.tsx:14: // import { DailyRitualSheet } and integrations/supabase/client.ts:33: // import { supabase }), but the larger problem is unmeasurable -- there could be hundreds of unused symbols and nothing would flag them.Three guard rails for dead-code prevention are disabled at the configuration level. The codebase has no tool actively telling the team this-import-is-unused. Combined with the 36 unused shadcn primitives (COD-006), this signals that dead-code accumulation is unobserved.
Dead imports and dead variables drift in unnoticed. Each one is small; together they make grep noisier and increase the surface area a second engineer must read. Low-severity, but the fix is a one-line config change.
Your linter is told to ignore unused variables and imports. So if a developer leaves dead code in a file, no tool will warn about it. The cleanup is automatic -- turn the rule on, fix what it finds.
Change eslint.config.js:24 to @typescript-eslint/no-unused-vars at warn level with an argsIgnorePattern of ^_, and set tsconfig.json noUnusedLocals: true and noUnusedParameters: true. Run bun run lint and bun run build; fix what they flag.
S — under ½ day