# Manabou Performance, Robustness & Maintainability Refactor

## Context

The Manabou app (Laravel 13 backend + Next.js 16 frontend) feels heavy. Three concrete user complaints:

1. **Navigation is slow** — even returning to a recently visited page takes too long.
2. **Some files are too large** — bloated pages with mixed concerns.
3. **Component redundancy** inflates the codebase.

Audit findings confirm all three. The root causes are structural, not algorithmic — they will not improve on their own. Key concrete issues:

- **Frontend**: All 45+ pages are `'use client'` (zero RSC, full hydration); `next.config.ts` has `images: { unoptimized: true }` AND `typescript: { ignoreBuildErrors: true }`; two parallel API clients (`frontend/api/client.ts` 575L + `frontend/lib/api.ts` 120L) with duplicate request queues; SWR exists but only 3 files use it; 52 files use `useRouter().push()` (no prefetch) vs 18 using `<Link>`; zero `loading.tsx`/`error.tsx`/`revalidate` exports; build still uses Webpack despite Turbopack being configured.
- **File size offenders (16 files >500 lines)**: [`frontend/app/review/page.tsx`](frontend/app/review/page.tsx) at **3,607 lines**, [`frontend/app/profile/page.tsx`](frontend/app/profile/page.tsx) at 1,005, [`frontend/components/navbar.tsx`](frontend/components/navbar.tsx) at 762 with 11 useState hooks.
- **Backend**: No `Resources/` or `Requests/` directories — 81 inline `validate()` calls across 44 controllers and inline `->toArray()` everywhere; fat controllers ([DashboardController](backend/app/Http/Controllers/DashboardController.php) 585L, [BattleController](backend/app/Http/Controllers/BattleController.php) 472L); confirmed N+1 in [ReviewController.php:41-43](backend/app/Http/Controllers/ReviewController.php#L41-L43); blocking `sleep(3)` in [RoundTimeoutJob.php:55](backend/app/Jobs/RoundTimeoutJob.php#L55); no rate limiting except battle/answer.
- **Duplication**: 5 modal components with duplicated overlay logic; 2 CSS files with overlapping utilities (`.container-mobile` defined twice); fragmented types across `domain/`/`components/`/`mappers/`; lucide-react imported in 84 files (navbar imports 25+ icons).

**Strategic intent**: Caching wins first → returning to a visited page should feel instant. Then incremental structural cleanup, each phase shippable on its own. New deps approved: **TanStack Query** (replaces dual API client + ad-hoc SWR), Laravel built-ins (Form Requests + Resources). Skip Zustand for now — localStorage/Context isn't the bottleneck.

---

## Phase 1 — Config & Navigation Quick Wins

**Effort: S (1 day) · Risk: Low**

The cheapest perceived-speed wins. Do this first to set up gains for everything later.

### Changes

- **`frontend/next.config.ts`**: remove `typescript.ignoreBuildErrors: true`; set `images: { unoptimized: false }` and configure `remotePatterns` for the Laravel asset host; add `experimental.optimizePackageImports: ['lucide-react', 'date-fns']`.
- **`frontend/package.json:13`**: change `"build": "next build --webpack"` → `"build": "next build"` (Turbopack default; root already configured at `next.config.ts:14-16`). Add `"build:analyze": "ANALYZE=true next build"` and wire `@next/bundle-analyzer`.
- **Codemod 52 `useRouter().push()` callsites → `<Link>`** where the destination is statically known. Keep `router.push()` only for post-async redirects. Touch: `frontend/components/navbar.tsx`, `frontend/components/bottom-nav.tsx`, `app/dashboard/**`, `app/decks/**`. `<Link>` triggers Next's automatic prefetch on viewport entry.
- **Add `loading.tsx`** at root + four hot routes (`app/review/`, `app/dashboard/`, `app/decks/`, `app/practice/`). Add `app/error.tsx` at root. Even simple skeletons kill the white flash.

### Prereqs

None. Before flipping `ignoreBuildErrors`, run `tsc --noEmit`, fix or `@ts-expect-error` real errors. `frontend/ts_errors.log` is a starting point.

### Verification

- `npm run build` succeeds without `--webpack`, image optimization works against the Laravel host.
- DevTools Network panel shows route bundle prefetch on link hover.
- Bundle analyzer report saved to `frontend/.next/analyze/`.

---

## Phase 2 — Centralize Data Layer (TanStack Query)

**Effort: M (2-3 days) · Risk: Medium**

Make "returning to a recently-visited page" structurally instant via stale-while-revalidate. Kill the dual API client.

### Changes

- Add `@tanstack/react-query` + `@tanstack/react-query-devtools`. Configure `QueryClient` in `frontend/app/layout.tsx`: `staleTime: 30_000`, `gcTime: 5*60_000`, `refetchOnWindowFocus: false`, `retry: 1`.
- **Delete `frontend/lib/api.ts`** (120L). Codemod its ~30 importers to `frontend/api/client.ts` (canonical: has the request queue, auth interceptor, CSRF). The duplicate queue is a real bug — two clients = two parallel auth refresh attempts under contention.
- For each domain in `frontend/api/services/`, add `frontend/hooks/queries/use-<domain>.ts` exposing `useQuery`/`useMutation` hooks. Centralize keys in `frontend/lib/query-keys.ts` with hierarchical factories (`['decks']`, `['decks', deckId]`, `['decks', deckId, 'cards']`).
- Migrate 3 SWR users (`app/dashboard/page.tsx`, `app/decks/page.tsx`, `frontend/hooks/use-api.ts`), then **delete `swr` from `package.json` and delete `frontend/hooks/use-api.ts`**.
- Migrate hot pages first: dashboard → decks → review → practice. Other pages can stay on direct `client.ts` calls (TanStack Query coexists with imperative calls).

### Reuse

- Keep `frontend/api/client.ts` interceptors and request queue — wrap in `QueryClient`'s `queryFn`/`mutationFn`.
- Keep existing service files in `frontend/api/services/` — TanStack hooks delegate to them.

### Prereqs

Phase 1's `<Link>` migration magnifies this win — Link prefetches the route bundle, TanStack Query serves cached data, page renders instantly.

### Verification

- Navigate to dashboard, then to deck list, then back to dashboard — back-nav should hydrate from cache instantly with revalidate-in-background.
- Mutate a card grade → dashboard stats invalidate and refetch.
- React Query Devtools shows the query tree.

---

## Phase 3 — Backend: Resources, Form Requests, N+1 Fixes

**Effort: M (3 days) · Risk: Low (parallel with Phase 2)**

Robustness without breaking the wire format. Can run on a separate branch in parallel with Phase 2.

### Changes

- Create `backend/app/Http/Requests/` and `backend/app/Http/Resources/` (currently absent). Generate one Request per write endpoint and one Resource per read endpoint. Start with highest-traffic: `ReviewController`, `DashboardController`, `PracticeController`, `DeckController`, `BattleController`.
- **Fix N+1 at [`ReviewController.php:33-78`](backend/app/Http/Controllers/ReviewController.php#L33-L78)**: the `->map()` calls `UserCard::where(...)->first()` per card. Replace with one eager `UserCard::whereIn('card_id', $cardIds)->where('user_id', $user->id)->get()->keyBy('card_id')` lookup before the map, OR push the entire join into [`ReviewQueueService`](backend/app/Services/ReviewQueueService.php) so the controller never sees a raw card list. Same controller re-projects `sorted_kanjis` twice — collapse via `CardReviewResource`.
- **Remove `sleep(3)` at [`RoundTimeoutJob.php:49`](backend/app/Jobs/RoundTimeoutJob.php#L49)**. Replace with `StartNextRound::dispatch($battle->id)->delay(now()->addSeconds(3))`. Blocking the queue worker for 3s per battle silently caps concurrency.
- **Modularize `backend/routes/api.php`** (273L) into per-feature files: `routes/v1/{auth,decks,review,practice,battle,dashboard,admin}.php`. Keep `api.php` as a 30-line aggregator. Mechanical, zero-risk.
- Add **per-route rate limiting** via named limiters in `RouteServiceProvider`: `throttle:ai` (5/min) on AI generation, `throttle:write` (60/min) on mutations, `throttle:read` (300/min) on reads. Currently only `battle/answer` is throttled.
- Move root-level controllers under `Api/V1/` to match the existing namespace (only 2 controllers there today). 5-7 per sub-PR via route-file imports — don't touch every controller's namespace in one go.

### Reuse

- [`ReviewQueueService`](backend/app/Services/ReviewQueueService.php) (248L) is already clean — push more orchestration there rather than the controller.
- [`SrsService`](backend/app/Services/SrsService.php) (128L) is well-isolated — leave alone.

### Verification

- Pest snapshot a sample response from `/api/v1/review/queue` before, refactor, snapshot after, diff with `assertJsonStructure` — wire shape unchanged.
- Telescope shows N+1 query count drops to 1 for `/api/v1/review/queue` request.
- `php artisan queue:work` processes battle round timeouts without 3s blocks (visible in Telescope job duration).

---

## Phase 4 — Split the Review Page (3,607 → ~6 files <500 lines)

**Effort: L (4-5 days) · Risk: Medium-High (write tests first)**

Decompose the worst offender on the hot path. Splitting alone won't speed up first navigation to `/review` (the user is already there); the wins are: (a) smaller initial bundle for first visit, (b) lazy modals, (c) maintainability.

### Target structure for `frontend/app/review/`

| File                                  | Responsibility                                                     | LOC target |
| ------------------------------------- | ------------------------------------------------------------------ | ---------- |
| `page.tsx`                            | Layout shell, route param parsing, mounts orchestrator             | <150       |
| `_components/review-orchestrator.tsx` | State machine: load queue, advance, finish                         | <500       |
| `_components/review-card.tsx`         | Renders one card, kanji breakdown, audio                           | <400       |
| `_components/review-input.tsx`        | Wanakana-bound input, grading reveal                               | <300       |
| `_components/review-modals.tsx`       | Edit + confirm + reflection wrappers (lazy via `next/dynamic`)     | <250       |
| `_hooks/use-review-queue.ts`          | TanStack `useQuery(['review','queue',deckId])` + grading mutations | <200       |
| `_hooks/use-review-haptics.ts`        | Vibration helpers currently inline                                 | <80        |
| `_lib/grading.ts`                     | Pure SRS-side helpers (unit-testable)                              | <150       |

### Changes

- Adopt the route-private `_components`/`_hooks` underscore-prefix convention — Next ignores these as routes.
- Replace `import { api } from '@/lib/api'` (line 9) with TanStack hooks from Phase 2.
- Move `wanakana` and `sonner` imports into the leaf components that use them, then wrap modals with `next/dynamic(() => import('./_components/review-modals'), { ssr: false })`.
- Consolidate the 25 lucide imports (line 6) into a single `_components/icons.ts` re-export — combined with Phase 1's `optimizePackageImports`, this is free tree-shaking.
- Use `useReducer` for the orchestrator instead of 11+ `useState` calls — easier to reason about transitions, easier to test.

### Reuse

- Existing [`useAudioPlayer`](frontend/hooks/use-audio-player.ts), [`useSpeechRecognition`](frontend/hooks/use-speech-recognition.ts) hooks.
- Existing [`KanjiBreakdown`](frontend/components/ui/kanji-breakdown.tsx), [`PitchAccent`](frontend/components/ui/pitch-accent.tsx), [`JlptBadge`](frontend/components/ui/jlpt-badge.tsx) primitives.

### Prereqs

**Phase 2 (TanStack Query)** — without it, the split inherits the dual-client mess and you do this work twice.

### Verification

- **Write 4-6 Playwright smoke tests against the existing page BEFORE the split** — golden path: load queue → grade card (Again/Hard/Good/Easy) → undo → finish session. Run after each subcomponent extraction to catch regressions.
- Bundle analyzer: `/review` initial JS chunk should drop ~30-45% (lucide + wanakana + sonner extraction).
- Cold-load `/review` on throttled 3G — Time-to-Interactive measurably better.

---

## Phase 5 — RSC Conversion + Code-Split Heavy Modals

**Effort: L (5-7 days) · Risk: Medium**

Now that data fetching is centralized and one hot page is decomposed, move easy wins to Server Components.

### Changes

- Audit each page in `frontend/app/`. A page can drop `'use client'` if it doesn't call `useState`/`useEffect`/`useRouter` or browser APIs at the top level. Likely safe: `app/about/`, `app/kanji/` (read-only listing), parts of `app/decks/[id]/page.tsx` (split into server shell + client interactive island), `app/grammar/` listings. **Goal: 10-15 pages converted, not all 45.**
- For each converted page, add a `revalidate` export (e.g., `export const revalidate = 60` for dictionary pages). Static dictionary pages can use `force-static`.
- **Wire up the existing-but-unused lazy files**: `frontend/components/lazy-modals.ts`, `lazy-practice.ts`, `lazy-sidebars.ts`. Replace eager imports of `card-edit-modal`, `deck-form-modal`, `pomodoro-setup-modal`, `weekly-reflection-modal`, `kanji-detail-modal`, `MnemonicImageCanvas` (canvas is heavy) with `next/dynamic` via these files.
- **`frontend/components/navbar.tsx`** (762L, 11 useState): split into server-rendered shell + small client island for open/close state. Move icon imports into `navbar-icons.ts` to dedupe — 25+ icons in one file is currently a per-page bundle tax.
- After this phase, run the bundle analyzer (added in Phase 1) and capture before/after numbers.

### Prereqs

- Phase 2 (TanStack Query supports server-side prefetch via `dehydrate`/`HydrationBoundary` — needed for any RSC page that needs data).
- Phase 4 (establishes the `_components`/`_hooks` convention to repeat here).

### Verification

- Audit `'use client'` usage drops from 45 to ~30.
- DevTools shows static HTML for converted pages (no hydration cost).
- Modals don't appear in initial route chunk (verify in bundle analyzer).
- Navbar icon import count: one `navbar-icons.ts` file.

---

## Phase 6 — Backend Controller & Service Slim-Down

**Effort: L (4-5 days) · Risk: Medium**

Break up the fat controllers/services now that Resources + Form Requests have absorbed validation and serialization weight.

### Changes

- **[`DashboardController.php`](backend/app/Http/Controllers/DashboardController.php) (585L)** — aggregates across 5+ domains. Extract to `app/Services/Dashboard/{StatsAggregator,StreakCalculator,DailyForecastBuilder}.php`. Controller becomes a thin orchestrator returning a `DashboardResource`. **Drop the chart aggregation entirely** — `DashboardData.chartData` is unused on the frontend (per audit).
- **[`BattleController.php`](backend/app/Http/Controllers/BattleController.php) (472L)** — extract `BattleMatchmaker`, `BattleQuestionGenerator`, `BattleScoring` into `app/Services/Battle/`. The job at [`RoundTimeoutJob.php:51`](backend/app/Jobs/RoundTimeoutJob.php#L51) currently resolves `app(BattleController::class)` — extract `startNextRound` into a `StartNextRoundAction` invoked by both the controller and the job.
- **[`DailyMixController.php`](backend/app/Http/Controllers/DailyMixController.php) (393L)** — push composition logic into `app/Services/DailyMix/MixComposer.php`.
- **Practice services dedup**: [`PracticeService`](backend/app/Services/PracticeService.php) (454L), [`ParticlePracticeService`](backend/app/Services/ParticlePracticeService.php) (449L), [`GrammarPracticeService`](backend/app/Services/GrammarPracticeService.php) (438L) duplicate ~60% of structure (build prompt → call AI → parse → persist). Extract `app/Services/Practice/AbstractPracticeGenerator.php` base + `PracticeResultParser`. Each concrete service drops to ~150 LOC.
- **[`AiGenerativeService`](backend/app/Services/AiGenerativeService.php) (370L)** — split per-feature methods (mnemonic, sentence, explanation) into `Ai{Mnemonic,Sentence,Explanation}Generator` classes. Centralize OpenAI client setup in an `AiClient` value-object.

### Prereqs

- Phase 3 (Resources + Form Requests). Without those, slimmed controllers still carry validation and serialization weight.

### Verification

- All slimmed controllers <200 lines.
- Battle real-time contracts unchanged (event names, payloads). Smoke test battle end-to-end.
- Pest feature tests cover each extracted service.

---

## Phase 7 — Cleanup: CSS, Types, Modals, Dead Code

**Effort: M (2-3 days) · Risk: Low**

Final pass on duplication. Doesn't move perf metrics much, but addresses the "redundant components" complaint and reduces ongoing maintenance cost.

### Changes

- **CSS consolidation**: merge [`frontend/app/globals.css`](frontend/app/globals.css) (99L) and [`frontend/app/components.css`](frontend/app/components.css) (325L). Duplicated `.container-mobile` is a real bug if they diverge. Pick `globals.css` as single source; move component styles to Tailwind `@apply` blocks or co-located CSS Modules.
- **Modal base**: extract [`frontend/components/ui/modal.tsx`](frontend/components/ui/modal.tsx) as the canonical primitive (overlay, backdrop click, escape, focus trap, scroll lock). Refactor `card-edit-modal.tsx`, `confirm-modal.tsx`, `deck-form-modal.tsx`, `pomodoro-setup-modal.tsx`, `weekly-reflection-modal.tsx` to compose it. Net drop ~400 LOC across 5 files. **Bonus: a11y for free.**
- **Type unification**: collapse `frontend/types/components/` into `frontend/types/domain/` where the component type is just `DomainType + UIState`. Use `Pick`/`Omit`/`Partial` at the call site instead of declaring parallel hierarchies. After Phase 3, most `frontend/types/mappers/` files become deletable (Resources match domain shapes).
- **Dead code sweep**:
  - Remove `frontend/templates/` (audit: unused).
  - Remove `DashboardData.chartData` field + paired backend aggregation (paired with Phase 6).
  - Remove backend `count` and `scratch/` directories if unused (verify first).
  - Remove `backend/build_output.txt`, `frontend/lint-results.txt`, `frontend/lint_error.log`, `frontend/ts_errors.log`, `frontend/ts_out.txt` from repo and add to `.gitignore`.
- **File-size budget**: add ESLint `max-lines: ['error', 500]`. Existing offenders get `// eslint-disable-next-line` with a TODO — visible debt.

### Prereqs

- Phase 3 (Resources finalize wire shape — types stop chasing a moving target).
- Phase 6 (dashboard slim-down enables removing chartData both ends).

### Verification

- `npm run lint` enforces the 500-line budget.
- All 5 modals share one base (verified via grep for backdrop/escape logic).
- Single CSS file; no duplicate utility classes.
- `.gitignore` keeps build artifacts out.

---

## Critical Files Summary

### Frontend

- [`frontend/next.config.ts`](frontend/next.config.ts) — Phase 1
- [`frontend/package.json`](frontend/package.json) — Phase 1, 2
- [`frontend/api/client.ts`](frontend/api/client.ts) — Phase 2 (canonical, keep)
- [`frontend/lib/api.ts`](frontend/lib/api.ts) — Phase 2 (delete)
- [`frontend/hooks/use-api.ts`](frontend/hooks/use-api.ts) — Phase 2 (delete after migration)
- [`frontend/app/layout.tsx`](frontend/app/layout.tsx) — Phase 2 (QueryClient setup)
- [`frontend/app/review/page.tsx`](frontend/app/review/page.tsx) — Phase 4 (split)
- [`frontend/components/navbar.tsx`](frontend/components/navbar.tsx) — Phase 5 (RSC split)
- [`frontend/components/lazy-modals.ts`](frontend/components/lazy-modals.ts), `lazy-practice.ts`, `lazy-sidebars.ts` — Phase 5 (wire up)
- [`frontend/app/globals.css`](frontend/app/globals.css), [`components.css`](frontend/app/components.css) — Phase 7

### Backend

- [`backend/app/Http/Controllers/ReviewController.php`](backend/app/Http/Controllers/ReviewController.php) — Phase 3 (N+1 fix)
- [`backend/app/Jobs/RoundTimeoutJob.php`](backend/app/Jobs/RoundTimeoutJob.php) — Phase 3 (sleep removal)
- [`backend/routes/api.php`](backend/routes/api.php) — Phase 3 (modularize)
- [`backend/app/Http/Controllers/DashboardController.php`](backend/app/Http/Controllers/DashboardController.php) — Phase 6
- [`backend/app/Http/Controllers/BattleController.php`](backend/app/Http/Controllers/BattleController.php) — Phase 6
- [`backend/app/Services/PracticeService.php`](backend/app/Services/PracticeService.php), [`ParticlePracticeService`](backend/app/Services/ParticlePracticeService.php), [`GrammarPracticeService`](backend/app/Services/GrammarPracticeService.php) — Phase 6 (dedup)

---

## Sequencing & Timeline

| #   | Phase                                      | Effort | When                         |
| --- | ------------------------------------------ | ------ | ---------------------------- |
| 1   | Config & navigation quick wins             | S      | Week 1                       |
| 2   | TanStack Query + kill dual client          | M      | Week 1-2                     |
| 3   | Backend Resources + Requests + N+1 + sleep | M      | Week 2 (**parallel with 2**) |
| 4   | Review page split                          | L      | Week 3                       |
| 5   | RSC + lazy modals                          | L      | Week 4                       |
| 6   | Backend controller/service slim-down       | L      | Week 5                       |
| 7   | Cleanup pass                               | M      | Week 6                       |

**~6 weeks for one engineer**, faster if Phases 2 and 3 run in parallel (different stacks, no conflict).

**Key insight on the review page**: it's the worst file in the codebase, but splitting it does _not_ fix the user's navigation complaint — they're already on the page. The reasons to split (Phase 4) are bundle size for first navigation to `/review`, lazy modals, and maintainability. Doing **Phase 2 caching first** means the rest of the app feels snappy immediately, while the big review surgery happens later with a working safety net.

---

## End-to-End Verification

After each phase, run:

1. **Build**: `cd frontend && npm run build` (no errors, no Webpack), `cd backend && composer test` (PHPUnit green).
2. **Bundle**: `cd frontend && npm run build:analyze` — capture `.next/analyze/client.html` size delta vs previous phase.
3. **Lighthouse**: run on `/dashboard`, `/review`, `/decks` — track LCP, TTI, CLS.
4. **Manual smoke**: log in → navigate dashboard → review queue → grade 5 cards → return to dashboard. Should feel instant on the back-nav from Phase 2 onward.
5. **Telescope/Pulse** (`backend/php artisan serve`, hit `/telescope`): query count per request should drop after Phase 3, job durations after Phase 3.
6. **Playwright suite** (added in Phase 4): `npx playwright test` — must stay green through Phase 4, 5, 6.
