# PHASES.md — Development Roadmap

> Detailed by design — this is a full mature-product plan, not an MVP slice. Mark items `[x]` only when actually done and verified, not when the code is written. Keep this in sync with reality; it's the single source of truth for "what's done / what's left." Add sub-items freely as work gets broken down further — this file is expected to grow.

## Sequencing logic

Website before mobile app — most users will take a test once rather than install an app, so prove the core web product first. Within the website: get the test → score → local comparison loop fully working and validated before layering community/competition features on top. Georgian content is deliberately last — see Phase 6 — the *architecture* for it is built in Phase 0 and used nowhere until then.

---

## Phase 0 — Foundation & Infrastructure

### Hosting & environment
- [ ] cPanel account provisioned for the app; PHP 8.3+ confirmed available via MultiPHP/ea-php
- [ ] Document root pointed at `public/`, deployment process defined (git pull + composer + artisan over SSH, or a simple deploy script) — **process is written up in `DEPLOY.md` and `deploy/systemd/*`, but not yet run against a real server; this coding session has no cPanel/WHM access to provision or verify it**
- [ ] Redis activated on the server, connection confirmed from Laravel — app is wired for Redis (cache/session/queue all default to `redis` in `.env.example`) and works against a local Redis instance in dev; not yet confirmed against the production server
- [ ] Cron entry added for `schedule:run` (every minute) — documented in `DEPLOY.md`, not yet added to a real crontab
- [ ] Supervisor (or systemd) configured to keep `queue:work` running and auto-restarting — unit file at `deploy/systemd/smarty-queue.service`, not yet installed/verified on a server
- [ ] Reverb running as a systemd service, LiteSpeed reverse-proxy rule exposing it through the main domain — unit file at `deploy/systemd/smarty-reverb.service`, proxy rule documented, neither deployed yet
- [ ] Staging vs. production environment strategy decided (subdomain, separate DB, etc.) — approach documented in `DEPLOY.md`, not yet stood up
- [ ] Backups: DB + uploaded files, automated, verified restorable

### Laravel app skeleton
- [x] Fresh Laravel 13 install, base folder structure agreed (where question generators, settings service, image generator, etc. live) — `app/Services` for cross-cutting services (settings), `app/Livewire/Admin` for the admin panel, `app/Models/Concerns` for shared model traits; question generators and the image generator land in Phase 1 alongside the schema they render
- [x] Settings service/facade implemented: cached, DB-backed, admin-editable (see ARCHITECTURE.md) — `App\Services\SettingsService` + `App\Facades\Settings` / `setting()` helper, cache-backed, editable at `/admin/settings`; covered by `tests/Feature/SettingsServiceTest.php`
- [x] `.env` scoped down to true bootstrap values only (DB, APP_KEY, mail, Redis, payment secrets)

### Auth & users
- [x] Registration/login, Sanctum token issuance — web session auth at `/register` and `/login`, Sanctum token issuance at `POST /api/login` for the Flutter app; covered by `tests/Feature/Auth/RegistrationTest.php` and `AuthenticationTest.php`
- [x] Socialite: Google login at minimum; Facebook/Apple as time allows — Google wired end-to-end including a "finish registration" step for the age gate (`tests/Feature/Auth/GoogleAuthenticationTest.php`); Facebook/Apple not started
- [ ] User model: username (required), real name + photo (optional, 18+ gated), age, gender, city, occupation — all fields exist on the `users` table (`username`, `name`, `photo_path`, `birthdate`→`age()`, `gender`, `city`, `occupation`) plus `show_real_name`/`show_photo`/`public_leaderboard_opt_in` flags and a `User::isAdult()` helper, but there's no profile-edit UI yet to actually *enforce* the 18+ gate on those flags — that lands with the Phase 1 profile page
- [x] Age gate enforced at registration: 16+ only for now, with a clear message rather than a silent block — `App\Rules\MinimumAge`, applied on both the direct-registration and Google-completion forms; covered by tests
- [x] Password reset / email verification flow — both flows implemented and covered (`tests/Feature/Auth/PasswordResetTest.php`, `EmailVerificationTest.php`)

### Localization scaffolding
- [x] Translation layer chosen and wired up (Claude Code's call — package or dedicated tables), extensible beyond 2 languages — UI strings via standard Laravel `lang/{locale}` files; content translations via a generic `translations` table + `App\Models\Concerns\HasTranslations` trait (unused until Phase 1+ content models exist); `config('app.supported_locales')` is the single source of truth other code reads instead of assuming two languages
- [x] Convention established for marking untranslated strings (e.g., a lint-able TODO marker) so the future Georgian pass has a clean checklist — `php artisan translations:audit` reports every UI key and content field missing a translation for each configured locale (a missing row *is* the TODO marker); covered by `tests/Unit/TranslationsAuditCommandTest.php`
- [x] All content authored in English during this phase and beyond, until Phase 6 — only `en` is configured in `APP_SUPPORTED_LOCALES`

### Theming
- [x] Light/dark theme toggle, light default — class-based Tailwind dark mode, toggle in the nav on every layout
- [x] Preference persisted to user record for registered users, synced on login — `users.theme`, `POST /theme`, server value wins over local storage on render; covered by `tests/Feature/ThemeControllerTest.php`
- [x] Guest fallback: local/device-level only, defaults to light — guests never hit the server, `localStorage` only, defaults to `light`

### Admin panel shell
- [x] Custom admin skeleton: layout, auth-gated to staff roles, English-only (no translation layer needed here) — `components.layouts.admin`, gated by the `staff` route middleware; admin views use plain English strings, deliberately outside `lang/`
- [x] Role/permission model, even if just "admin" and "staff" to start — `users.role` (`user`/`staff`/`admin`) plus `access-admin`/`manage-settings`/`manage-users` gates
- [x] Settings management screen (wired to the settings service above) — `/admin/settings`, admin-only
- [x] Basic user management (search, view, suspend) — `/admin/users` (search + suspend, staff can view/staff+admin can suspend) and `/admin/users/{user}` (detail + role change, admin-only); covered by `tests/Feature/Admin/AccessControlTest.php`

---

## Phase 1 — Core Test-Taking Loop (Web)

### Question data model
- [x] Core `questions` schema supporting multiple types: multiple-choice text, and the rule-based visual type (see ARCHITECTURE.md) — `categories`, `questions`/`question_options` (MCQ, translatable via `HasTranslations`), `visual_templates` (registry rows for coded generators, not per-instance content)
- [x] Question bank organized by subject/category and difficulty — `category_id` + `difficulty` (1-5) on both `questions` and `visual_templates`
- [x] Randomization strategy for assembling a test instance from the bank — `App\Services\TestAttemptService::assemble()`: random MCQ pick + weighted-random visual template pick, shuffled together; covered by `tests/Feature/Tests/TestAttemptFlowTest.php`

### Visual question generator (first pass)
- [x] Rule-description data structure for the grid/attribute family (grid size, attributes, relations) — `App\Services\VisualQuestions\Grid\CellAttributes` (shape/color/size/rotation/count) + per-template puzzle/options JSON
- [x] Rule-description data structure for the paper-folding family (shape, fold lines, hole position) — `App\Services\VisualQuestions\Fold\HolePunchGenerator`, coordinates on a 0-4 square grid
- [x] Initial templates built: rotation matching, pattern-matrix completion, odd-one-out, single/double-fold hole-punch — all 4 implemented and unit-tested (`tests/Unit/VisualQuestions/`), 200k-iteration stress-tested for the fold generator's distractor uniqueness
- [x] Client-side rendering on web (SVG) from the rule description, both families — only the rule JSON is stored/transmitted; SVG is produced at render time by Blade components (`resources/views/components/visual/*.blade.php`) from that JSON, never a stored image
- [x] Distractor generation: single-rule-break method, both families — `CellAttributes::breakOneRule()`/`distractors()` for the grid family; wrong-crease-position / undercount / overcount for the fold family
- [x] Admin: template on/off toggles, difficulty/frequency tuning (not a visual builder yet) — `/admin/visual-templates`, staff-gated; covered by `tests/Feature/Admin/ContentManagementTest.php`

### Test-taking flow (web)
- [x] IQ test assembled from the bank (mix of text + visual questions) — `iq` category seeded (`CategorySeeder`, `VisualTemplateSeeder`), roughly half MCQ / half visual per attempt
- [x] Test-taking UI: timer, progress indicator, no going back to change answers already submitted (integrity) — `resources/views/tests/play.blade.php`; server rejects answering anything but the current unanswered item (`TestAttemptService::answer()`)
- [x] Server-side answer submission per question or per test — timing and correctness validated server-side, never trust client timer/score — one item served/answered at a time (`current()`/`answer()`), `presented_at`/`answered_at`/`time_spent_ms` are all server timestamps, never client input
- [x] Rule defined and enforced for which attempt counts toward public rank (e.g., first attempt only, or best-of-N within a cooldown window) — first *completed* attempt per user+category counts (`TestAttemptService::finalize()`); covered by test

### Results & sharing
- [x] Result screen: score + comparison against city/country averages — `/r/{token}`, averages computed from `counts_toward_rank` attempts, scoped by the viewer's city and overall
- [x] Certificate/OG-image generator (Intervention Image or equivalent): one template, two outputs — downloadable certificate and Open Graph share image, disclaimer text baked into the image — `App\Services\CertificateGenerator`, one 1200×630 PNG template serves both `/r/{token}/certificate.png` (OG image) and `/r/{token}/certificate/download` (attachment); renders with a bundled DejaVu Sans TTF (GD's built-in font is ASCII-only and ignores font size, so a real font file is required)
- [x] Shareable result link (public, pretty URL) with correct OG tags — `/r/{token}` (ULID), OG/Twitter tags wired via `partials/head.blade.php`
- [x] Basic public profile page (username, score badges, join date) — `/u/{username}`, real name/photo gated to `show_real_name` + 18+ per existing privacy rules

### SEO basics
- [x] Meta tags (title/description) per public page — home, test intro, result, profile pages all pass `description`/`canonical`/`og-image` through the layout
- [x] Sitemap.xml generation — `/sitemap.xml` (home + active categories + users with a public score), cached 1h; `robots.txt` points at it
- [x] Clean, human-readable URLs for tests, results, profiles — `/tests/{key}`, `/r/{token}`, `/u/{username}`

**Verified:** `vendor/bin/phpunit` (63 tests, 3679 assertions) green; manually exercised the full flow end-to-end in a browser (Playwright) as a real user — login, start test, answer a full mix of MCQ + all 4 visual templates, land on the result page, download a correctly-rendered certificate — and as admin (toggle a visual template, create a question). One real bug caught only by that manual pass (not by the HTTP feature tests, which read answer values from the DB instead of the rendered page) and fixed: the MCQ answer buttons were submitting their loop position instead of the option id; the regression test now scrapes option values from the rendered HTML. Also fixed a latent Phase 0 bug: `SettingsService::all()` cached an `Illuminate\Support\Collection`, which silently fails to unserialize under Laravel 13's default `cache.serializable_classes = false` on any non-`array` cache driver (i.e. everywhere outside the test env) — now caches a plain array.

---

## Phase 2 — Retention & Comparison Depth (Web)

### More tests
- [x] EQ test — `eq` category seeded (`CategorySeeder`) with real sample content (`QuestionSeeder`); assembly falls back to 100% text items for categories with no visual templates (fixed a gap where a category without visual templates would silently fill only half its item count)
- [x] Subject quizzes: general knowledge, biology, geography, physics (each its own question pool) — each seeded as its own `Category` + question pool
- [x] Remaining visual-question templates (sequence-what's-next, plus any others worth adding) — `App\Services\VisualQuestions\Grid\SequenceWhatsNextGenerator` (rotation or count progression, break-one-rule distractors), unit-tested and manually verified rendering in-browser

### Leaderboards
- [x] Redis sorted sets per scope: national, city, age-bracket, occupation — `App\Services\Leaderboard\LeaderboardService`, one ZSET per (category, scope, scope value, period); best score wins within a period (`ZADD ... GT`) — a deliberately looser rule than the "first attempt only" rule that governs the simpler city/country *average* stat from Phase 1
- [x] All-time and weekly-reset variants — `alltime` key plus a `weekly:{ISO year-week}` key (bounded 21-day TTL) written on every completed attempt
- [x] Leaderboard pages built as real SEO landing pages (per-city, per-occupation), not just in-app screens — `/tests/{key}/leaderboard`, `.../leaderboard/city/{city}`, `.../leaderboard/occupation/{occupation}`, `.../leaderboard/age/{bracket}`; top cities/occupations included in `sitemap.xml` (bounded to 25 each per category)
- [x] Pagination/lazy-loading for large leaderboards; efficient "what's my rank" lookup — `LengthAwarePaginator` over `ZREVRANGE` pages, `ZREVRANK` for O(log n) rank lookup; a public-listing row is only shown for users with `public_leaderboard_opt_in` (PROJECT.md: "opt-in, not default"), though rank *numbers* reflect the full competitive pool and a user always sees their own rank regardless of opt-in

### Spaced repetition
- [x] Failed-question tracking per user — `failed_questions` table (MCQ bank questions only — generated visual items are never tracked, since they're procedurally fresh every time, not a stable question to resurface); `App\Services\SpacedRepetitionService`
- [x] Scheduled job resurfaces specific failed questions after N weeks — `spaced-repetition:notify`, scheduled daily, emails a user once per newly-due batch (`notified_at` guards against re-notifying daily for the same batch)
- [x] UI for "questions to review" separate from taking a fresh test — `/reviews` (due/pending counts) → `/reviews/next` (one question at a time, no timer, no attempt record, no leaderboard/score impact)

### Re-engagement (pre-mobile)
- [x] Email notification infrastructure (queued, templated) — `App\Mail\SpacedRepetitionReviewReadyMail` / `WeeklyQuizAvailableMail`, both `ShouldQueue`, Markdown mail templates, signed one-click unsubscribe link (`reminder_emails_opt_in`)
- [x] First re-engagement triggers: spaced-repetition ready, weekly quiz available — the daily spaced-repetition command above, plus `quizzes:weekly-digest` scheduled every Monday to opted-in verified users

### Progress tracking
- [x] Per-subject skill breakdown stored and displayed over time (not just a single score) — every completed attempt was already stored (Phase 1 schema); profile now surfaces the full per-category history instead of only the first-ever ("counts_toward_rank") attempt
- [x] Profile page updated to show trend, not just latest result — per-category latest score, best score, attempt count, an inline SVG sparkline over the last 10 attempts, and an up/down trend indicator vs. the previous attempt

**Verified:** `vendor/bin/phpunit` (95 tests, 5453 assertions) green against a real local Redis instance (leaderboards are never faked/mocked — see ARCHITECTURE.md's "no `ORDER BY` against the primary DB" requirement). Manually exercised end-to-end in a browser: took the EQ and Biology text-only tests and the IQ test (hitting all 5 visual templates including the new sequence-what's-next), checked the spaced-repetition queue after deliberately answering wrong, toggled the all-time/weekly leaderboard views, and viewed a profile with two attempts in the same category. Two real bugs were only caught by that manual pass and fixed: `resources/views/profiles/show.blade.php` used Blade's `@{{ }}` literal-escape sequence where a real interpolation was intended (rendered `{{ $profileUser->username }}` as literal text instead of the username), and the attempt-count string used `__()` with a `|` pluralization pattern that only `trans_choice()` actually honors (rendered `"1 attempt|1 attempts"` verbatim regardless of count). Also fixed, from code review rather than a test failure: `LeaderboardService::rankFor()`/`scoreFor()` checked `=== null` for a missing Redis member, but phpredis returns `false` (not `null`) for `zRevRank`/`zScore` on a miss — the old code would have silently reported a wrong user as rank 1.

---

## Phase 3 — Community: Clubs (Web)

### Club core
- [x] Club data model: interest-based (public, joinable) vs. custom (private/invite-only by default) — `clubs` table (`type` interest/custom, `visibility` public/private, `status` active/suspended), `App\Models\Club`; a custom club's creator can still choose to make it public
- [x] Club creation flow, invite-link mechanism for custom clubs — `/clubs/create` → `App\Services\ClubService::create()`; private clubs get a random `invite_code`, shared as `/clubs/{slug}/invite/{code}` (auto-joins on visit, safe to reuse)
- [x] Membership management: join/leave, creator/moderator roles — `club_members` pivot (`creator`/`moderator`/`member`), `ClubService::join()/leave()/promote()/demote()/transferOwnership()`; a creator must transfer ownership before they can leave

### Moderation
- [x] Creator tools: remove member, delete post/content, report — `ClubService::removeMember()` (creator/moderator only, a moderator can't remove another moderator or the creator), `deletePost()` (author or moderator), `report()` (any member, club-level or post-level)
- [x] Basic admin-side oversight for reported clubs/content — `/admin/clubs` (`App\Livewire\Admin\Clubs\Index`, `manage-content` gate): review pending reports, action (delete the post / suspend the club) or dismiss, plus a direct suspend/reinstate toggle per club

### Club stats
- [x] Per-club aggregate stats (average score, member count, top performers) — `App\Services\ClubStatsService`, shown per category on the club page; member count is a live count, average score/top performers reuse the Redis-backed data below rather than a separate aggregation path
- [x] Simple club leaderboard (ranking clubs against each other) — not live battles yet, that's Phase 4 — `App\Services\Leaderboard\ClubRankingService` (Redis sorted set per category, club's score = average of members' best scaled score), recomputed by the `clubs:refresh-rankings` scheduled command (hourly); viewed at `/tests/{category}/clubs/leaderboard`
- [x] Individual rank visibility as a setting separate from club membership (participate without exposing personal rank) — `club_members.show_rank_in_club`; `LeaderboardService::SCOPE_CLUB` (one Redis sorted set per club, per category) only records a member there when the flag is on — national/city/age/occupation ranking is unaffected either way. Full within-club leaderboard at `/clubs/{slug}/leaderboard/{category}`

**Verified:** `vendor/bin/phpunit` (144 tests, 5555 assertions) green, including club creation/join/leave/role-permission/post/report/moderation/leaderboard coverage against a real local Redis instance. Manually exercised end-to-end in a browser (Playwright): created a public interest club and a private custom club as one user, confirmed the invite-link UI and copy, posted to the club wall and saw it render, joined the public club as a second user via the join button, viewed the admin oversight screen (empty reports queue, both clubs listed with working suspend toggle), and loaded the empty-state within-club leaderboard and club-vs-club rankings pages. No console errors on any page.

---

## Phase 4 — Competitive Layer & Economy (Web)

### Stars/points
- [x] Ledger table implemented: append-only, source/amount/timestamp/user, balance derived — `star_ledger_entries` (credit/debit rows, no `updated_at`, `App\Models\StarLedgerEntry`), `App\Services\Economy\StarLedgerService::credit()/debit()/balanceFor()` sums the table rather than touching a stored balance; `debit()` rejects going negative unless explicitly told `allowNegative: true` (used only by the admin manual-adjustment tool)
- [x] Earning sources: weekly challenge completion, sponsor content module, referral (non-incentivized-sharing-safe design), purchase — weekly challenge and sponsor-content rewards below; `App\Services\Economy\ReferralService::rewardForFirstCompletedTest()` pays the *referrer* only once the referred user completes their first real test (never for the share/registration itself — PROJECT.md's "non-incentivized-sharing-safe" requirement); star purchases via `SubscriptionService::initiateStarPurchase()` + gateway webhook, rate is the admin-editable `economy.stars_per_gel` setting
- [x] Spending: premium unlock, extra custom club slots beyond free limit — `SubscriptionService::unlockWithStars()`; `App\Services\Economy\ClubSlotService::purchaseSlot()` + `ClubService::create()` enforces `economy.free_custom_club_limit` for custom clubs
- [x] Admin: manual ledger adjustment tool (for support/dispute cases), fully audited — `/admin/economy` (`manage-economy` gate, admin-only), records `created_by_id` + a required reason on every adjustment; covered by `tests/Feature/Admin/Phase4AdminTest.php`

### Weekly challenges & sponsor content
- [x] Weekly challenge scheduling (via the settings service) — `weekly_challenges` (admin-created events with a window + reward), `challenges:schedule-weekly` (scheduled Mondays) auto-schedules the next category in rotation using the `economy.weekly_challenge_default_reward_stars` setting; completion detected in `TestAttemptService::finalize()` via `WeeklyChallengeService::recordAttempt()`, one reward per user per challenge (unique constraint)
- [x] Sponsor content module: "learn about sponsor, answer questions" flow, clearly labeled as sponsored — `/sponsors`, "Sponsored by :name" label on every card/page; reward only on a perfect quiz score (`SponsorContentService::submit()`)
- [x] Admin: sponsor content management, scheduling, basic performance reporting (views/completions) — `/admin/sponsors` (sponsor + sponsor-content CRUD, questions/options authored inline like the existing question-bank form), views/completions/completion-rate per content item

### Live club battles
- [x] Reverb wired to push live score updates during a scheduled club-vs-club battle window — `App\Events\ClubBattleScoreUpdated` (`ShouldBroadcastNow`) on a private `club-battle.{id}` channel, authorized to members of either club (`routes/channels.php`); dispatched from `ClubBattleService::refresh()`, triggered from `TestAttemptService::finalize()` for any live battle the finishing user's club is in
- [x] Battle scheduling, matchmaking (or fixed rival assignment), results/history — fixed rival assignment: `/admin/club-battles` lets staff pick both clubs, category, and window; `club-battles:transition` (every 5 min) moves scheduled → live → completed and sets a winner; `/clubs/{club}/battles` is the results/history list, per-battle detail at `/clubs/{club}/battles/{battle}`
- [x] Fallback behavior if a client disconnects mid-battle (reconcile on reconnect, don't lose progress) — the battle show page always re-fetches the current `club_score`/`opponent_score`/`status` from the DB on load (never trusts only what it last heard over the socket) and only layers live push updates on top via Alpine + Echo; score itself is a recomputed aggregate from `test_attempts`, not an incremental counter, so there's nothing to "lose" on a missed push

### Premium tier
- [x] Subscription/payment flow via Keepz / BOG installment — `App\Services\Payments\{KeepzGateway,BogInstallmentGateway}` (hosted-redirect `initiate()`, HMAC webhook signature verification), `SubscriptionService::initiateCheckout()`/`handleWebhook()`, `/premium` + `POST /webhooks/payments/{provider}` (CSRF-exempt, gateway-signature-authenticated instead). **Structurally complete but not verified against a live Keepz/BOG sandbox account — this coding session has no gateway credentials, the same caveat Phase 0 already carries for cPanel/WHM.** Confirm the exact request/response/webhook payload shape against each provider's merchant docs before going live; `initiateStarPurchase()`/webhook credit path *is* covered by a test that fakes the HTTP call and signs a real webhook payload (`tests/Feature/Premium/SubscriptionTest.php`), so the plumbing around the gateway is exercised even though the gateway itself isn't
- [x] Premium gates: full subject breakdowns, progress history, leaderboard filters, ad-free, verified mode, extra clubs — `Gate::define('premium', ...)` / `User::hasActivePremium()`; profile page (`ProfileController`) shows only the latest score for non-premium, full sparkline/trend/best-score history for premium — the headline score itself always stays free (PROJECT.md); national leaderboard page gets a premium-only "jump to any city/occupation" quick filter (the existing public per-city/occupation SEO pages from Phase 2 stay fully public and unchanged — gating those would contradict the SEO-landing-page requirement); extra club slots above. **Ad-free and verified mode are not gated because neither feature exists yet** — ads are explicitly Phase 7 scope and verified/proctored mode is explicitly Phase 7 scope (see PHASES.md below); the `premium` gate is ready for both once those features land
- [x] Admin: subscription management, manual grant/revoke for support cases — `/admin/subscriptions` (`manage-subscriptions` gate, admin-only), grant-by-username-and-days, revoke; every grant records which admin made it

**Verified:** `vendor/bin/phpunit` (180 tests, 5635 assertions) green against real local Redis (club-battle scoring, weekly-challenge/referral/sponsor reward hooks, and channel-authorization tests all run against actual services, not mocks — including one test that switches the broadcaster to a real Pusher-protocol driver specifically to prove the private channel actually rejects a non-member, since the 'null' driver used everywhere else in tests skips auth entirely). `composer` + `npm run build` both succeed; manually smoke-tested every new public page (`/challenges`, `/sponsors`, `/clubs`, home, login, register) over `php artisan serve` against a real (sqlite) database — all 200, no view-compilation errors. Laravel Pint clean. Payment-gateway HTTP calls themselves are unverified (see Premium tier note above) — everything else in this phase was exercised end-to-end through the test suite, including the star-ledger → weekly-challenge/referral/sponsor/club-battle/premium integration points inside `TestAttemptService::finalize()`.

---

## Phase 5 — Mobile App (Flutter)

- [x] Flutter project scaffolded, talking to the existing API (no new backend endpoints designed mobile-first — reuse Phase 1–4 API surface, extend only where a genuine mobile gap exists)
- [x] Core loop: test-taking, results, certificate share (native share sheet), profile — built, statically verified; not yet interactively run on a device/emulator (see Verified note)
- [x] Leaderboards, clubs, club battles (Reverb-protocol client-side) — built, statically verified; live-socket path not yet run against a real Reverb server
- [x] Push notifications via FCM: spaced-repetition reminders, weekly quiz availability, club battle events — backend send path + mobile registration/receive path both wired and config-gated; no real Firebase project exists yet, so an actual push has never been delivered
- [x] Theme sync (registered users) and local fallback (guests) matching the web behavior
- [ ] App store listings, review, submission (Android first, then iOS, or simultaneous depending on capacity) — needs real developer accounts, signing keys, store assets, and human review; out of reach of a coding session

**What was added on the backend for this phase** (`smarty` repo): the JSON API barely existed before this (`routes/api.php` was 11 lines — login/logout/user only), because Phases 1–4 were built web/Livewire-only. Added, all reusing the existing service layer (`TestAttemptService`, `LeaderboardService`, `ClubService`, `ClubBattleService`, etc.) rather than duplicating business logic: mobile registration (`Api\RegisteredUserController`), theme + locale sync, device-token registration (new `device_tokens` table) for FCM, the full test-taking loop (categories/start/current/answer/result — the "current item" response deliberately never includes `correct_option_id`/`correct_key`, covered by a dedicated test), leaderboards, clubs (CRUD/join/leave/posts/reports/rankings), club battles + a Sanctum-authenticated `/api/broadcasting/auth` route (the framework's default only accepts the `web` session guard), reviews (spaced repetition), wallet/premium reads. Added `FcmSender` (Firebase HTTP v1, hand-rolled service-account JWT — no SDK dependency) and wired it into the three triggers PHASES.md calls for: `spaced-repetition:notify`, `quizzes:weekly-digest`, and `ClubBattleService`'s live/completed transitions. `FcmSender` and the API's locale endpoint both no-op safely when unconfigured (no Firebase project / `ka` not yet in `supported_locales` until Phase 6), same pattern as the existing payment gateways.

**Verified:** `vendor/bin/phpunit` — 206 tests, 5733 assertions, all green, including a new `tests/Feature/Api/*` suite (auth, device tokens, test-taking incl. the no-leaked-answer assertion, leaderboards, clubs) run against real local Redis. Laravel Pint clean. On the Flutter side (`smarty-app`): `flutter analyze` clean (zero errors/warnings, a handful of stylistic `info` left as deliberate calls), a widget test boots the real app (full Riverpod/go_router/l10n wiring, faked secure-storage only) and lands on the login screen for a guest, `flutter pub get` resolves cleanly. **What could not be verified in this environment:** no Android SDK, Xcode, or Chrome were available, so nothing was ever actually run on an emulator/device/browser — no screen has been tapped through interactively, the Reverb live-score path has never connected to a running Reverb server, and push has never been delivered to a real device (no Firebase project exists yet — `firebase_core`/`firebase_messaging` are wired but `Firebase.initializeApp()` will throw without a provisioned project's config files, which `PushService` catches and no-ops on, mirroring `FcmSender`'s backend-side gate). Before shipping, this needs: a real Firebase project (Android `google-services.json` + iOS `GoogleService-Info.plist`), a provisioned Reverb server reachable from a device, and a pass on an actual emulator/device to catch anything static analysis can't (layout overflow, gesture issues, etc.).

---

## Phase 6 — Georgian Localization Rollout

- [ ] Full audit of translation-TODO-marked strings from Phase 0–5
- [ ] UI strings translated to Georgian
- [ ] Quiz/question content translated to Georgian (or written natively in Georgian where translation would lose meaning — e.g., wordplay-dependent items)
- [ ] Georgian set as the default locale for new users (with language switcher retained)
- [ ] QA pass specifically for Georgian text rendering (line length, font support, RTL/LTR non-issue but check UI overflow)
- [ ] SEO content (meta tags, leaderboard page copy) localized, not just app UI

---

## Phase 7 — Monetization Polish & Launch Readiness

- [ ] Ads integration (only once there's real traffic to justify it)
- [ ] Verified/proctored mode: time-lock + tab-lock implementation; webcam-assisted evaluated as a stretch goal, not a requirement
- [ ] Security/performance hardening pass: rate limiting, abuse/multi-account detection heuristics, query performance review under realistic load
- [ ] Legal review: privacy policy, terms of service, Georgia data-protection compliance sign-off (see PROJECT.md → Compliance notes) — done by an actual lawyer, not inferred from this document
- [ ] Analytics/funnel tracking in place (signup → first test → return visit → club join) to inform what comes after this roadmap

---

## Explicitly deferred beyond this roadmap
- Under-16 registration (needs a parental-consent flow first)
- Multi-currency or international payment rails
- Full psychometric validity work (entertainment content, not a clinical instrument)
- Cube-net-folding and full 3D object-rotation-matching visual question types (multi-fold or non-axis-aligned paper-folding also deferred — revisit if the simple hole-punch version proves popular)
- Additional languages beyond Georgian/English
- Generic visual-template builder UI in the admin (beyond template on/off + tuning)
