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" /> )} -
- {filteredPokemon.map((rp) => ( - +
+ {pokemon.map((rp) => ( + + ))} +
+
))} diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index 460a044..3898999 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -1,6 +1,9 @@ +import { useState } from 'react' import { Link, Outlet } from 'react-router-dom' export function Layout() { + const [menuOpen, setMenuOpen] = useState(false) + return (
-
+ {/* Desktop nav */} +
+ {/* Mobile hamburger */} +
+ +
+ {/* Mobile dropdown */} + {menuOpen && ( +
+
+ setMenuOpen(false)} + className="block px-3 py-2 rounded-md text-base font-medium hover:bg-gray-100 dark:hover:bg-gray-700" + > + New Run + + setMenuOpen(false)} + className="block px-3 py-2 rounded-md text-base font-medium hover:bg-gray-100 dark:hover:bg-gray-700" + > + My Runs + + setMenuOpen(false)} + className="block px-3 py-2 rounded-md text-base font-medium hover:bg-gray-100 dark:hover:bg-gray-700" + > + Admin + +
+
+ )}
diff --git a/frontend/src/components/index.ts b/frontend/src/components/index.ts index 942e0e8..6db3d94 100644 --- a/frontend/src/components/index.ts +++ b/frontend/src/components/index.ts @@ -1,3 +1,4 @@ +export { EncounterMethodBadge } from './EncounterMethodBadge' export { EncounterModal } from './EncounterModal' export { EndRunModal } from './EndRunModal' export { GameCard } from './GameCard' diff --git a/frontend/src/pages/Home.tsx b/frontend/src/pages/Home.tsx index 8125ce2..f40df71 100644 --- a/frontend/src/pages/Home.tsx +++ b/frontend/src/pages/Home.tsx @@ -1,10 +1,125 @@ +import { Link } from 'react-router-dom' +import { useRuns } from '../hooks/useRuns' +import type { RunStatus } from '../types' + +const statusStyles: Record = { + active: + 'bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300', + completed: + 'bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300', + failed: 'bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-300', +} + export function Home() { + const { data: runs, isLoading } = useRuns() + + const activeRun = runs?.find((r) => r.status === 'active') + const recentRuns = runs?.slice(0, 5) + return ( -
-

Nuzlocke Tracker

-

- Track your Nuzlocke runs with ease -

+
+
+

+ Nuzlocke Tracker +

+

+ Track your Nuzlocke runs with ease +

+ + Start New Run + +
+ + {isLoading && ( +
+
+
+ )} + + {activeRun && ( +
+

+ Continue Playing +

+ +
+
+

+ {activeRun.name} +

+

+ Started{' '} + {new Date(activeRun.startedAt).toLocaleDateString(undefined, { + year: 'numeric', + month: 'short', + day: 'numeric', + })} +

+
+ + Resume → + +
+ +
+ )} + + {recentRuns && recentRuns.length > 0 && ( +
+
+

+ Recent Runs +

+ + View all + +
+
+ {recentRuns.map((run) => ( + +
+
+

+ {run.name} +

+

+ {new Date(run.startedAt).toLocaleDateString(undefined, { + year: 'numeric', + month: 'short', + day: 'numeric', + })} +

+
+ + {run.status} + +
+ + ))} +
+
+ )} + + {runs && runs.length === 0 && ( +

+ No runs yet. Start your first Nuzlocke challenge above! +

+ )}
) } diff --git a/frontend/src/pages/NewRun.tsx b/frontend/src/pages/NewRun.tsx index b6d4b6d..3b8a04e 100644 --- a/frontend/src/pages/NewRun.tsx +++ b/frontend/src/pages/NewRun.tsx @@ -17,6 +17,10 @@ export function NewRun() { const [runName, setRunName] = useState('') const handleGameSelect = (game: Game) => { + if (selectedGame?.id === game.id) { + setSelectedGame(null) + return + } setSelectedGame(game) if (!runName || runName === `${selectedGame?.name} Nuzlocke`) { setRunName(`${game.name} Nuzlocke`) diff --git a/frontend/src/pages/RunDashboard.tsx b/frontend/src/pages/RunDashboard.tsx index c8c0de6..88ecae9 100644 --- a/frontend/src/pages/RunDashboard.tsx +++ b/frontend/src/pages/RunDashboard.tsx @@ -176,7 +176,8 @@ export function RunDashboard() { {alive.length === 0 ? (

- No pokemon caught yet + No pokemon caught yet — head to encounters to start building your + team!

) : (
diff --git a/frontend/src/pages/RunEncounters.tsx b/frontend/src/pages/RunEncounters.tsx index fccadb9..ebdd2f1 100644 --- a/frontend/src/pages/RunEncounters.tsx +++ b/frontend/src/pages/RunEncounters.tsx @@ -1,16 +1,40 @@ -import { useState, useMemo } from 'react' +import { useState, useMemo, useEffect, useCallback } from 'react' import { useParams, Link } from 'react-router-dom' -import { useRun } from '../hooks/useRuns' +import { useRun, useUpdateRun } from '../hooks/useRuns' import { useGameRoutes } from '../hooks/useGames' import { useCreateEncounter, useUpdateEncounter } from '../hooks/useEncounters' -import { EncounterModal } from '../components' +import { + EncounterModal, + EncounterMethodBadge, + StatCard, + PokemonCard, + StatusChangeModal, + EndRunModal, + RuleBadges, +} from '../components' import type { Route, RouteWithChildren, + RunStatus, EncounterDetail, EncounterStatus, } from '../types' +const statusStyles: Record = { + active: 'bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300', + completed: + 'bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300', + failed: 'bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-300', +} + +function formatDuration(start: string, end: string) { + const ms = new Date(end).getTime() - new Date(start).getTime() + const days = Math.floor(ms / (1000 * 60 * 60 * 24)) + if (days === 0) return 'Less than a day' + if (days === 1) return '1 day' + return `${days} days` +} + type RouteStatus = 'caught' | 'fainted' | 'missed' | 'none' function getRouteStatus(encounter?: EncounterDetail): RouteStatus { @@ -189,6 +213,13 @@ function RouteGroup({
{child.name}
+ {!childEncounter && child.encounterMethods.length > 0 && ( +
+ {child.encounterMethods.map((m) => ( + + ))} +
+ )}
{childEncounter && ( @@ -218,12 +249,36 @@ export function RunEncounters() { ) const createEncounter = useCreateEncounter(runIdNum) const updateEncounter = useUpdateEncounter(runIdNum) + const updateRun = useUpdateRun(runIdNum) const [selectedRoute, setSelectedRoute] = useState(null) const [editingEncounter, setEditingEncounter] = useState(null) + const [selectedTeamEncounter, setSelectedTeamEncounter] = + useState(null) + const [showEndRun, setShowEndRun] = useState(false) + const [showTeam, setShowTeam] = useState(true) const [filter, setFilter] = useState<'all' | RouteStatus>('all') - const [expandedGroups, setExpandedGroups] = useState>(new Set()) + + const storageKey = `expandedGroups-${runId}` + const [expandedGroups, setExpandedGroups] = useState>(() => { + try { + const saved = localStorage.getItem(storageKey) + if (saved) return new Set(JSON.parse(saved) as number[]) + } catch { /* ignore */ } + return new Set() + }) + + const updateExpandedGroups = useCallback( + (updater: (prev: Set) => Set) => { + setExpandedGroups((prev) => { + const next = updater(prev) + localStorage.setItem(storageKey, JSON.stringify([...next])) + return next + }) + }, + [storageKey], + ) // Organize routes into hierarchical structure const organizedRoutes = useMemo(() => { @@ -231,6 +286,30 @@ export function RunEncounters() { return organizeRoutes(routes) }, [routes]) + // Map routeId → encounter for quick lookup + const encounterByRoute = useMemo(() => { + const map = new Map() + if (run) { + for (const enc of run.encounters) { + map.set(enc.routeId, enc) + } + } + return map + }, [run]) + + // Auto-expand the first unvisited group on initial load + useEffect(() => { + if (organizedRoutes.length === 0 || expandedGroups.size > 0) return + const firstUnvisited = organizedRoutes.find( + (r) => + r.children.length > 0 && + getGroupEncounter(r, encounterByRoute) === null, + ) + if (firstUnvisited) { + updateExpandedGroups(() => new Set([firstUnvisited.id])) + } + }, [organizedRoutes, encounterByRoute]) // eslint-disable-line react-hooks/exhaustive-deps + if (isLoading || loadingRoutes) { return (
@@ -255,12 +334,6 @@ export function RunEncounters() { ) } - // Map routeId → encounter for quick lookup - const encounterByRoute = new Map() - for (const enc of run.encounters) { - encounterByRoute.set(enc.routeId, enc) - } - // Count completed locations (groups count as 1, standalone routes count as 1) const completedCount = organizedRoutes.filter((r) => { if (r.children.length > 0) { @@ -273,8 +346,16 @@ export function RunEncounters() { const totalLocations = organizedRoutes.length + const isActive = run.status === 'active' + const alive = run.encounters.filter( + (e) => e.status === 'caught' && e.faintLevel === null, + ) + const dead = run.encounters.filter( + (e) => e.status === 'caught' && e.faintLevel !== null, + ) + const toggleGroup = (groupId: number) => { - setExpandedGroups((prev) => { + updateExpandedGroups((prev) => { const next = new Set(prev) if (next.has(groupId)) { next.delete(groupId) @@ -347,21 +428,187 @@ export function RunEncounters() { {/* Header */}
- ← {run.name} + ← All Runs -

- Encounters -

-

- {run.game.name} · {completedCount} / {totalLocations} locations -

+
+
+

+ {run.name} +

+

+ {run.game.name} · {run.game.region} · Started{' '} + {new Date(run.startedAt).toLocaleDateString(undefined, { + year: 'numeric', + month: 'short', + day: 'numeric', + })} +

+
+
+ {isActive && ( + + )} + + {run.status} + +
+
- {/* Progress bar */} + {/* Completion Banner */} + {!isActive && ( +
+
+ {run.status === 'completed' ? '\u{1f3c6}' : '\u{1faa6}'} +
+

+ {run.status === 'completed' ? 'Victory!' : 'Defeat'} +

+

+ {run.completedAt && ( + <> + Ended{' '} + {new Date(run.completedAt).toLocaleDateString(undefined, { + year: 'numeric', + month: 'short', + day: 'numeric', + })} + {' \u00b7 '} + Duration: {formatDuration(run.startedAt, run.completedAt)} + + )} +

+
+
+
+ )} + + {/* Stats */} +
+ + + + +
+ + {/* Rules */}
+

+ Active Rules +

+ +
+ + {/* Team Section */} + {(alive.length > 0 || dead.length > 0) && ( +
+ + {showTeam && ( + <> + {alive.length > 0 && ( +
+ {alive.map((enc) => ( + setSelectedTeamEncounter(enc) : undefined} + /> + ))} +
+ )} + {dead.length > 0 && ( + <> +

+ Graveyard +

+
+ {dead.map((enc) => ( + setSelectedTeamEncounter(enc) : undefined} + /> + ))} +
+ + )} + + )} +
+ )} + + {/* Progress bar */} +
+
+

+ Encounters +

+ + {completedCount} / {totalLocations} locations + +
{filteredRoutes.length === 0 && (

- No routes match this filter + {filter === 'all' + ? 'Click a route to log your first encounter' + : 'No routes match this filter — try a different one'}

)} {filteredRoutes.map((route) => { @@ -439,7 +688,7 @@ export function RunEncounters() {
{route.name}
- {encounter && ( + {encounter ? (
{encounter.pokemon.spriteUrl && (
+ ) : route.encounterMethods.length > 0 && ( +
+ {route.encounterMethods.map((m) => ( + + ))} +
)}
@@ -481,6 +736,34 @@ export function RunEncounters() { isPending={createEncounter.isPending || updateEncounter.isPending} /> )} + + {/* Status Change Modal (team pokemon) */} + {selectedTeamEncounter && ( + { + updateEncounter.mutate(data, { + onSuccess: () => setSelectedTeamEncounter(null), + }) + }} + onClose={() => setSelectedTeamEncounter(null)} + isPending={updateEncounter.isPending} + /> + )} + + {/* End Run Modal */} + {showEndRun && ( + { + updateRun.mutate( + { status }, + { onSuccess: () => setShowEndRun(false) }, + ) + }} + onClose={() => setShowEndRun(false)} + isPending={updateRun.isPending} + /> + )}
) } diff --git a/frontend/src/pages/index.ts b/frontend/src/pages/index.ts index 01a19f2..67b20d8 100644 --- a/frontend/src/pages/index.ts +++ b/frontend/src/pages/index.ts @@ -1,5 +1,4 @@ export { Home } from './Home' export { NewRun } from './NewRun' export { RunList } from './RunList' -export { RunDashboard } from './RunDashboard' export { RunEncounters } from './RunEncounters' diff --git a/frontend/src/types/game.ts b/frontend/src/types/game.ts index 81450ea..d60b9b4 100644 --- a/frontend/src/types/game.ts +++ b/frontend/src/types/game.ts @@ -15,6 +15,7 @@ export interface Route { gameId: number order: number parentRouteId: number | null + encounterMethods: string[] } export interface RouteWithChildren extends Route {