diff --git a/.beans/nuzlocke-tracker-qeim--ux-improvements-pass.md b/.beans/nuzlocke-tracker-qeim--ux-improvements-pass.md
index cd67111..19dfb31 100644
--- a/.beans/nuzlocke-tracker-qeim--ux-improvements-pass.md
+++ b/.beans/nuzlocke-tracker-qeim--ux-improvements-pass.md
@@ -1,16 +1,20 @@
---
# nuzlocke-tracker-qeim
title: UX improvements pass
-status: draft
+status: completed
type: task
+priority: normal
created_at: 2026-02-05T14:27:17Z
-updated_at: 2026-02-05T14:27:17Z
+updated_at: 2026-02-07T13:05:14Z
---
The current encounter tracking and run dashboard UX is clunky. Do a holistic UX review and propose improvements.
Areas to evaluate:
- Encounter logging flow (too many clicks? modal vs inline?)
+ - Encounter logging should be the default view for runs
+ - Team overview is not really needed
+ - Logging encounters should show the type of encounter (grass, surfing, fishing, ...)
- Route list readability and navigation (long lists)
- Route grouping UX polish (auto-expand unvisited groups, remember expand state, visual hierarchy for parent vs child)
- Run dashboard information density
@@ -20,4 +24,124 @@ Areas to evaluate:
- Visual feedback for actions (success/error toasts, optimistic updates)
- It is unintuitive to not be able to deselect a game on the new run page
-Produce a concrete plan with specific UI/UX changes to implement.
\ No newline at end of file
+Produce a concrete plan with specific UI/UX changes to implement.
+
+---
+
+## Concrete UX Improvement Plan
+
+### 1. Make encounters the default run view — merge dashboard into encounters
+
+**Problem:** The run dashboard (`/runs/:runId`) and encounter log (`/runs/:runId/encounters`) are separate pages. Users must click "Log Encounter" to get to the actual tracker. The dashboard shows team/graveyard but that's secondary to the encounter flow.
+
+**Change:** Merge the dashboard into the encounters page as a unified view:
+- `/runs/:runId` shows the encounter list directly (current RunEncounters content)
+- Move stats + team/graveyard into a collapsible sidebar or header summary on the same page
+- Specifically: put the 4 stat cards in a compact row at the top, above the route list
+- Team and graveyard move to a slide-out panel or collapsible section below the stats
+- Remove the separate RunDashboard page; redirect `/runs/:runId` to the merged view
+- Keep "End Run" as a button in the header area
+
+**Files:** `RunDashboard.tsx`, `RunEncounters.tsx`, `App.tsx` (routing)
+
+### 2. Show encounter method in the route list (not just the modal)
+
+**Problem:** Users can't see what encounter methods are available on a route until they click it. For Nuzlocke tracking, knowing whether a route has grass, surf, fishing, etc. is important for planning.
+
+**Change:** Show small method badges on each route row in the encounters list:
+- Fetch route encounter data summary (distinct methods) and show as tiny icons or text badges
+- e.g. "Route 1" shows 🌿 walk | "Pallet Town" shows 🏄 surf 🎣 fishing ⭐ starter
+- Use the same EncounterMethodBadge component from the modal, but also add badges for wild methods (walk, surf, old-rod, good-rod, super-rod, rock-smash, headbutt)
+- Show badges in a row below the route name
+
+**Files:** `RunEncounters.tsx`, `EncounterModal.tsx` (extract shared badge component)
+
+### 2b. Group Pokemon by encounter method in the EncounterModal
+
+**Problem:** The Pokemon selection grid in the EncounterModal shows all Pokemon in a flat list. On routes with many encounter methods (walk, surf, old-rod, good-rod, super-rod, etc.) it's a jumbled mess — the user can't tell which Pokemon come from grass vs. fishing.
+
+**Change:** Group the Pokemon grid by encounter method with labeled sections and spacers:
+- Group `routePokemon` by `encounterMethod` before rendering
+- Render each group with a header label (e.g. "Grass", "Surfing", "Old Rod", "Good Rod", "Super Rod") and the Pokemon grid underneath
+- Add a visual spacer/divider between groups (e.g. a thin horizontal line or margin gap)
+- Method display order: starter, gift, fossil, trade, walk, headbutt, surf, rock-smash, old-rod, good-rod, super-rod
+- Use friendly labels: walk → "Grass", surf → "Surfing", old-rod → "Old Rod", etc.
+- If a route only has one method, skip the grouping header (no visual noise)
+- Time-of-day differences: these are already aggregated in our encounter data (same "walk" method, rates summed). If time-based filtering is added later, use tabs above the grid to switch day/morning/night rather than creating separate groups.
+
+**Files:** `EncounterModal.tsx`
+
+### 3. Route grouping polish
+
+**Problem:** All route groups start collapsed. Users have to manually expand each one. No visual distinction between visited and unvisited groups.
+
+**Changes:**
+- Auto-expand the first unvisited group on page load (guides the player to the next location)
+- Persist expand/collapse state in localStorage keyed by runId
+- Add a subtle left border color to groups based on status (green = caught, red = fainted, etc.)
+- Show route count badges: "3/5 areas" for groups
+
+**Files:** `RunEncounters.tsx`
+
+### 4. Allow game deselection in NewRun wizard
+
+**Problem:** Once you select a game, you can't deselect it — clicking the same game again does nothing. This is unintuitive.
+
+**Change:** Toggle selection — clicking an already-selected game deselects it:
+```tsx
+const handleGameSelect = (game: Game) => {
+ if (selectedGame?.id === game.id) {
+ setSelectedGame(null)
+ return
+ }
+ // ... existing logic
+}
+```
+
+**Files:** `NewRun.tsx`
+
+### 5. Improve Home page with recent runs + clear CTA
+
+**Problem:** Home page is just a title and tagline. No way to quickly resume a run or start one.
+
+**Change:**
+- Show "Continue Run" card for the most recent active run (if any)
+- Show "Start New Run" prominent CTA button
+- Show recent runs list (last 3-5) with quick links
+- If no runs exist, show onboarding message
+
+**Files:** `Home.tsx`, `useRuns.ts`
+
+### 6. Mobile nav improvements
+
+**Problem:** Nav links crowd on small screens. No hamburger menu.
+
+**Change:**
+- Add hamburger menu on small screens (< sm breakpoint)
+- Collapse nav links into dropdown
+- Ensure all touch targets are minimum 44px
+
+**Files:** `Layout.tsx`
+
+### 7. Better empty states
+
+**Problem:** Several pages have minimal empty states ("No pokemon caught yet", etc.) without guiding the user.
+
+**Changes:**
+- RunEncounters with no encounters: "Click a route to log your first encounter"
+- RunDashboard/merged view with no alive pokemon: "Catch your first Pokemon to build your team!"
+- RunList with no runs: Already has CTA, but add illustration or more welcoming text
+
+**Files:** Various page components
+
+## Checklist
+
+- [x] Merge dashboard into encounters page (unified run view)
+- [x] Show encounter method badges on route rows
+- [x] Group Pokemon by encounter method in EncounterModal with headers and spacers
+- [x] Auto-expand first unvisited route group
+- [x] Persist route group expand/collapse state in localStorage
+- [x] Allow game deselection in NewRun wizard
+- [x] Improve Home page with recent runs + CTA
+- [x] Add mobile hamburger nav menu
+- [x] Improve empty states with helpful guidance text
\ No newline at end of file
diff --git a/backend/src/app/api/games.py b/backend/src/app/api/games.py
index 3ea29a1..8013d0e 100644
--- a/backend/src/app/api/games.py
+++ b/backend/src/app/api/games.py
@@ -6,6 +6,7 @@ from sqlalchemy.orm import selectinload
from app.core.database import get_session
from app.models.game import Game
from app.models.route import Route
+from app.models.route_encounter import RouteEncounter
from app.schemas.game import (
GameCreate,
GameDetailResponse,
@@ -66,35 +67,43 @@ async def list_game_routes(
result = await session.execute(
select(Route)
.where(Route.game_id == game_id)
+ .options(selectinload(Route.route_encounters))
.order_by(Route.order)
)
all_routes = result.scalars().all()
+ def route_to_dict(route: Route) -> dict:
+ methods = sorted({re.encounter_method for re in route.route_encounters})
+ return {
+ "id": route.id,
+ "name": route.name,
+ "game_id": route.game_id,
+ "order": route.order,
+ "parent_route_id": route.parent_route_id,
+ "encounter_methods": methods,
+ }
+
if flat:
- return all_routes
+ return [route_to_dict(r) for r in all_routes]
# Build hierarchical structure
# Group children by parent_route_id
- children_by_parent: dict[int, list[Route]] = {}
+ children_by_parent: dict[int, list[dict]] = {}
top_level_routes: list[Route] = []
for route in all_routes:
if route.parent_route_id is None:
top_level_routes.append(route)
else:
- children_by_parent.setdefault(route.parent_route_id, []).append(route)
+ children_by_parent.setdefault(route.parent_route_id, []).append(
+ route_to_dict(route)
+ )
# Build response with nested children
response = []
for route in top_level_routes:
- route_dict = {
- "id": route.id,
- "name": route.name,
- "game_id": route.game_id,
- "order": route.order,
- "parent_route_id": route.parent_route_id,
- "children": children_by_parent.get(route.id, []),
- }
+ route_dict = route_to_dict(route)
+ route_dict["children"] = children_by_parent.get(route.id, [])
response.append(route_dict)
return response
diff --git a/backend/src/app/schemas/game.py b/backend/src/app/schemas/game.py
index 6236a99..d3ef9cb 100644
--- a/backend/src/app/schemas/game.py
+++ b/backend/src/app/schemas/game.py
@@ -7,6 +7,7 @@ class RouteResponse(CamelModel):
game_id: int
order: int
parent_route_id: int | None = None
+ encounter_methods: list[str] = []
class GameResponse(CamelModel):
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index bf9a71a..affa059 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -1,7 +1,7 @@
import { Routes, Route, Navigate } from 'react-router-dom'
import { Layout } from './components'
import { AdminLayout } from './components/admin'
-import { Home, NewRun, RunList, RunDashboard, RunEncounters } from './pages'
+import { Home, NewRun, RunList, RunEncounters } from './pages'
import {
AdminGames,
AdminGameDetail,
@@ -17,8 +17,8 @@ function App() {
} />
} />
} />
- } />
- } />
+ } />
+ } />
}>
} />
} />
diff --git a/frontend/src/components/EncounterMethodBadge.tsx b/frontend/src/components/EncounterMethodBadge.tsx
new file mode 100644
index 0000000..28267ae
--- /dev/null
+++ b/frontend/src/components/EncounterMethodBadge.tsx
@@ -0,0 +1,99 @@
+const METHOD_CONFIG: Record = {
+ starter: {
+ label: 'Starter',
+ color:
+ 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/40 dark:text-yellow-300',
+ },
+ gift: {
+ label: 'Gift',
+ color: 'bg-pink-100 text-pink-800 dark:bg-pink-900/40 dark:text-pink-300',
+ },
+ fossil: {
+ label: 'Fossil',
+ color:
+ 'bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300',
+ },
+ trade: {
+ label: 'Trade',
+ color:
+ 'bg-emerald-100 text-emerald-800 dark:bg-emerald-900/40 dark:text-emerald-300',
+ },
+ walk: {
+ label: 'Grass',
+ color:
+ 'bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300',
+ },
+ headbutt: {
+ label: 'Headbutt',
+ color: 'bg-lime-100 text-lime-800 dark:bg-lime-900/40 dark:text-lime-300',
+ },
+ surf: {
+ label: 'Surfing',
+ color: 'bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300',
+ },
+ 'rock-smash': {
+ label: 'Rock Smash',
+ color:
+ 'bg-orange-100 text-orange-800 dark:bg-orange-900/40 dark:text-orange-300',
+ },
+ 'old-rod': {
+ label: 'Old Rod',
+ color: 'bg-cyan-100 text-cyan-800 dark:bg-cyan-900/40 dark:text-cyan-300',
+ },
+ 'good-rod': {
+ label: 'Good Rod',
+ color: 'bg-sky-100 text-sky-800 dark:bg-sky-900/40 dark:text-sky-300',
+ },
+ 'super-rod': {
+ label: 'Super Rod',
+ color:
+ 'bg-indigo-100 text-indigo-800 dark:bg-indigo-900/40 dark:text-indigo-300',
+ },
+}
+
+/** Display order for encounter method groups */
+export const METHOD_ORDER = [
+ 'starter',
+ 'gift',
+ 'fossil',
+ 'trade',
+ 'walk',
+ 'headbutt',
+ 'surf',
+ 'rock-smash',
+ 'old-rod',
+ 'good-rod',
+ 'super-rod',
+]
+
+export function getMethodLabel(method: string): string {
+ return (
+ METHOD_CONFIG[method]?.label ??
+ method
+ .replace(/-/g, ' ')
+ .replace(/\b\w/g, (c) => c.toUpperCase())
+ )
+}
+
+export function getMethodColor(method: string): string {
+ return METHOD_CONFIG[method]?.color ?? 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300'
+}
+
+export function EncounterMethodBadge({
+ method,
+ size = 'sm',
+}: {
+ method: string
+ size?: 'sm' | 'xs'
+}) {
+ const config = METHOD_CONFIG[method]
+ if (!config) return null
+ const sizeClass = size === 'xs' ? 'text-[8px] px-1 py-0' : 'text-[9px] px-1.5 py-0.5'
+ return (
+
+ {config.label}
+
+ )
+}
diff --git a/frontend/src/components/EncounterModal.tsx b/frontend/src/components/EncounterModal.tsx
index c341a73..bd6ee14 100644
--- a/frontend/src/components/EncounterModal.tsx
+++ b/frontend/src/components/EncounterModal.tsx
@@ -1,5 +1,10 @@
-import { useState, useEffect } from 'react'
+import { useState, useEffect, useMemo } from 'react'
import { useRoutePokemon } from '../hooks/useGames'
+import {
+ EncounterMethodBadge,
+ getMethodLabel,
+ METHOD_ORDER,
+} from './EncounterMethodBadge'
import type {
Route,
EncounterDetail,
@@ -52,38 +57,22 @@ const statusOptions: { value: EncounterStatus; label: string; color: string }[]
},
]
-const specialMethodStyles: Record = {
- starter: {
- label: 'Starter',
- color:
- 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/40 dark:text-yellow-300',
- },
- gift: {
- label: 'Gift',
- color: 'bg-pink-100 text-pink-800 dark:bg-pink-900/40 dark:text-pink-300',
- },
- fossil: {
- label: 'Fossil',
- color:
- 'bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300',
- },
- trade: {
- label: 'Trade',
- color:
- 'bg-emerald-100 text-emerald-800 dark:bg-emerald-900/40 dark:text-emerald-300',
- },
-}
+const SPECIAL_METHODS = ['starter', 'gift', 'fossil', 'trade']
-function EncounterMethodBadge({ method }: { method: string }) {
- const config = specialMethodStyles[method]
- if (!config) return null
- return (
-
- {config.label}
-
- )
+function groupByMethod(pokemon: RouteEncounterDetail[]): { method: string; pokemon: RouteEncounterDetail[] }[] {
+ const groups = new Map()
+ for (const rp of pokemon) {
+ const list = groups.get(rp.encounterMethod) ?? []
+ list.push(rp)
+ groups.set(rp.encounterMethod, list)
+ }
+ return [...groups.entries()]
+ .sort(([a], [b]) => {
+ const ai = METHOD_ORDER.indexOf(a)
+ const bi = METHOD_ORDER.indexOf(b)
+ return (ai === -1 ? 999 : ai) - (bi === -1 ? 999 : bi)
+ })
+ .map(([method, pokemon]) => ({ method, pokemon }))
}
export function EncounterModal({
@@ -127,6 +116,12 @@ export function EncounterModal({
rp.pokemon.name.toLowerCase().includes(search.toLowerCase()),
)
+ const groupedPokemon = useMemo(
+ () => (filteredPokemon ? groupByMethod(filteredPokemon) : []),
+ [filteredPokemon],
+ )
+ const hasMultipleGroups = groupedPokemon.length > 1
+
const handleSubmit = () => {
if (isEditing && onUpdate) {
onUpdate({
@@ -206,38 +201,54 @@ export function EncounterModal({
className="w-full px-3 py-1.5 mb-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
)}
-