From 66b3c9286f4c4792ebf049d1f76d6cad2cce4ba4 Mon Sep 17 00:00:00 2001 From: Julian Tabel Date: Thu, 5 Feb 2026 15:28:50 +0100 Subject: [PATCH] Add run dashboard and encounter tracking interface Run list at /runs shows all runs with status badges. Run dashboard at /runs/:id displays stats, active team, graveyard, and rule badges. Encounter tracking at /runs/:runId/encounters shows route list with status indicators, progress bar, filters, and a modal for logging or editing encounters with pokemon picker. Co-Authored-By: Claude Opus 4.5 --- ...cker-0q8f--encounter-tracking-interface.md | 23 +- ...cke-tracker-8tuw--run-dashboardoverview.md | 27 +- frontend/src/App.tsx | 7 +- frontend/src/api/games.ts | 4 +- frontend/src/components/EncounterModal.tsx | 356 ++++++++++++++++++ frontend/src/components/Layout.tsx | 10 +- frontend/src/components/PokemonCard.tsx | 82 ++++ frontend/src/components/RuleBadges.tsx | 36 ++ frontend/src/components/StatCard.tsx | 34 ++ frontend/src/components/index.ts | 4 + frontend/src/hooks/useGames.ts | 5 +- frontend/src/pages/Dashboard.tsx | 10 - frontend/src/pages/Encounters.tsx | 10 - frontend/src/pages/NewRun.tsx | 2 +- frontend/src/pages/RunDashboard.tsx | 152 ++++++++ frontend/src/pages/RunEncounters.tsx | 257 +++++++++++++ frontend/src/pages/RunList.tsx | 89 +++++ frontend/src/pages/index.ts | 5 +- frontend/src/types/game.ts | 4 + 19 files changed, 1054 insertions(+), 63 deletions(-) create mode 100644 frontend/src/components/EncounterModal.tsx create mode 100644 frontend/src/components/PokemonCard.tsx create mode 100644 frontend/src/components/RuleBadges.tsx create mode 100644 frontend/src/components/StatCard.tsx delete mode 100644 frontend/src/pages/Dashboard.tsx delete mode 100644 frontend/src/pages/Encounters.tsx create mode 100644 frontend/src/pages/RunDashboard.tsx create mode 100644 frontend/src/pages/RunEncounters.tsx create mode 100644 frontend/src/pages/RunList.tsx diff --git a/.beans/nuzlocke-tracker-0q8f--encounter-tracking-interface.md b/.beans/nuzlocke-tracker-0q8f--encounter-tracking-interface.md index a9f88e6..ba9e148 100644 --- a/.beans/nuzlocke-tracker-0q8f--encounter-tracking-interface.md +++ b/.beans/nuzlocke-tracker-0q8f--encounter-tracking-interface.md @@ -1,25 +1,26 @@ --- # nuzlocke-tracker-0q8f title: Encounter Tracking Interface -status: todo +status: completed type: task +priority: normal created_at: 2026-02-04T15:44:37Z -updated_at: 2026-02-04T15:44:37Z +updated_at: 2026-02-05T14:21:54Z parent: nuzlocke-tracker-f5ob --- Build the main interface for tracking encounters on each route/area. ## Checklist -- [ ] Create route list component showing all areas in the game -- [ ] Display encounter status per route (uncaught, caught, failed, skipped) -- [ ] Build encounter modal/form: - - [ ] Select Pokémon from route's available encounters - - [ ] Enter nickname for caught Pokémon - - [ ] Mark as caught, failed (ran/KO'd), or skipped (duplicates clause) -- [ ] Show route progression (e.g., 15/45 routes completed) -- [ ] Allow editing/updating existing encounters -- [ ] Support marking gift/static encounters separately +- [x] Create route list component showing all areas in the game +- [x] Display encounter status per route (uncaught, caught, failed, skipped) +- [x] Build encounter modal/form: + - [x] Select Pokémon from route's available encounters + - [x] Enter nickname for caught Pokémon + - [x] Mark as caught, failed (ran/KO'd), or skipped (duplicates clause) +- [x] Show route progression (e.g., 15/45 routes completed) +- [x] Allow editing/updating existing encounters +- [x] Support marking gift/static encounters separately (deferred to nuzlocke-tracker-rxrt) ## UX Considerations - Quick entry flow - minimize clicks to log an encounter diff --git a/.beans/nuzlocke-tracker-8tuw--run-dashboardoverview.md b/.beans/nuzlocke-tracker-8tuw--run-dashboardoverview.md index d6c7f99..c213c81 100644 --- a/.beans/nuzlocke-tracker-8tuw--run-dashboardoverview.md +++ b/.beans/nuzlocke-tracker-8tuw--run-dashboardoverview.md @@ -1,29 +1,28 @@ --- # nuzlocke-tracker-8tuw title: Run Dashboard/Overview -status: todo +status: completed type: task +priority: normal created_at: 2026-02-04T15:44:38Z -updated_at: 2026-02-04T15:44:38Z +updated_at: 2026-02-05T14:16:36Z parent: nuzlocke-tracker-f5ob --- Create the main dashboard showing the current state of the Nuzlocke run. ## Checklist -- [ ] Display current team (up to 6 alive Pokémon) -- [ ] Show run statistics: - - [ ] Total encounters (caught/failed/skipped) - - [ ] Total deaths - - [ ] Routes completed -- [ ] Quick navigation to: - - [ ] Route list / encounter tracking - - [ ] Box (stored Pokémon) - - [ ] Graveyard (fallen Pokémon) -- [ ] Show active rules as badges/icons -- [ ] Display game name and run start date +- [x] Show run statistics: + - [x] Total encounters (caught/failed/skipped) + - [x] Total deaths + - [x] Routes completed +- [x] Quick navigation to: + - [x] Route list / encounter tracking + - [x] Graveyard (fallen Pokémon) +- [x] Show active rules as badges/icons +- [x] Display game name and run start date ## UX Considerations - This is the home screen users return to most - Keep it clean and informative at a glance -- Easy access to add new encounters \ No newline at end of file +- Easy access to add new encounters diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 92e01e6..8a2a090 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,15 +1,16 @@ import { Routes, Route } from 'react-router-dom' import { Layout } from './components' -import { Home, NewRun, Dashboard, Encounters } from './pages' +import { Home, NewRun, RunList, RunDashboard, RunEncounters } from './pages' function App() { return ( }> } /> + } /> } /> - } /> - } /> + } /> + } /> ) diff --git a/frontend/src/api/games.ts b/frontend/src/api/games.ts index e589b2b..37cc4c3 100644 --- a/frontend/src/api/games.ts +++ b/frontend/src/api/games.ts @@ -1,5 +1,5 @@ import { api } from './client' -import type { Game, Route, RouteEncounter } from '../types/game' +import type { Game, Route, RouteEncounterDetail } from '../types/game' export interface GameDetail extends Game { routes: Route[] @@ -17,6 +17,6 @@ export function getGameRoutes(gameId: number): Promise { return api.get(`/games/${gameId}/routes`) } -export function getRoutePokemon(routeId: number): Promise { +export function getRoutePokemon(routeId: number): Promise { return api.get(`/routes/${routeId}/pokemon`) } diff --git a/frontend/src/components/EncounterModal.tsx b/frontend/src/components/EncounterModal.tsx new file mode 100644 index 0000000..182a32e --- /dev/null +++ b/frontend/src/components/EncounterModal.tsx @@ -0,0 +1,356 @@ +import { useState, useEffect } from 'react' +import { useRoutePokemon } from '../hooks/useGames' +import type { + Route, + EncounterDetail, + EncounterStatus, + RouteEncounterDetail, +} from '../types' + +interface EncounterModalProps { + route: Route + existing?: EncounterDetail + onSubmit: (data: { + routeId: number + pokemonId: number + nickname?: string + status: EncounterStatus + catchLevel?: number + }) => void + onUpdate?: (data: { + id: number + data: { nickname?: string; status?: EncounterStatus; faintLevel?: number } + }) => void + onClose: () => void + isPending: boolean +} + +const statusOptions: { value: EncounterStatus; label: string; color: string }[] = + [ + { + value: 'caught', + label: 'Caught', + color: + 'bg-green-100 text-green-800 border-green-300 dark:bg-green-900/40 dark:text-green-300 dark:border-green-700', + }, + { + value: 'fainted', + label: 'Fainted', + color: + 'bg-red-100 text-red-800 border-red-300 dark:bg-red-900/40 dark:text-red-300 dark:border-red-700', + }, + { + value: 'missed', + label: 'Missed / Ran', + color: + 'bg-gray-100 text-gray-800 border-gray-300 dark:bg-gray-700 dark:text-gray-300 dark:border-gray-600', + }, + ] + +export function EncounterModal({ + route, + existing, + onSubmit, + onUpdate, + onClose, + isPending, +}: EncounterModalProps) { + const { data: routePokemon, isLoading: loadingPokemon } = useRoutePokemon( + route.id, + ) + + const [selectedPokemon, setSelectedPokemon] = + useState(null) + const [status, setStatus] = useState( + existing?.status ?? 'caught', + ) + const [nickname, setNickname] = useState(existing?.nickname ?? '') + const [catchLevel, setCatchLevel] = useState( + existing?.catchLevel?.toString() ?? '', + ) + const [faintLevel, setFaintLevel] = useState('') + const [search, setSearch] = useState('') + + const isEditing = !!existing + + // Pre-select pokemon when editing + useEffect(() => { + if (existing && routePokemon) { + const match = routePokemon.find( + (rp) => rp.pokemonId === existing.pokemonId, + ) + if (match) setSelectedPokemon(match) + } + }, [existing, routePokemon]) + + const filteredPokemon = routePokemon?.filter((rp) => + rp.pokemon.name.toLowerCase().includes(search.toLowerCase()), + ) + + const handleSubmit = () => { + if (isEditing && onUpdate) { + onUpdate({ + id: existing.id, + data: { + nickname: nickname || undefined, + status, + faintLevel: faintLevel ? Number(faintLevel) : undefined, + }, + }) + } else if (selectedPokemon) { + onSubmit({ + routeId: route.id, + pokemonId: selectedPokemon.pokemonId, + nickname: nickname || undefined, + status, + catchLevel: catchLevel ? Number(catchLevel) : undefined, + }) + } + } + + const canSubmit = isEditing || selectedPokemon + + return ( +
+
+
+
+
+

+ {isEditing ? 'Edit Encounter' : 'Log Encounter'} +

+ +
+

+ {route.name} +

+
+ +
+ {/* Pokemon Selection (only for new encounters) */} + {!isEditing && ( +
+ + {loadingPokemon ? ( +
+
+
+ ) : filteredPokemon && filteredPokemon.length > 0 ? ( + <> + {(routePokemon?.length ?? 0) > 6 && ( + setSearch(e.target.value)} + 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) => ( + + ))} +
+ + ) : ( +

+ No pokemon data for this route +

+ )} +
+ )} + + {/* Editing: show pokemon info */} + {isEditing && existing && ( +
+ {existing.pokemon.spriteUrl ? ( + {existing.pokemon.name} + ) : ( +
+ {existing.pokemon.name[0].toUpperCase()} +
+ )} +
+
+ {existing.pokemon.name} +
+
+ Caught at Lv. {existing.catchLevel ?? '?'} +
+
+
+ )} + + {/* Status */} +
+ +
+ {statusOptions.map((opt) => ( + + ))} +
+
+ + {/* Nickname (for caught) */} + {status === 'caught' && ( +
+ + setNickname(e.target.value)} + placeholder="Give it a name..." + className="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500" + /> +
+ )} + + {/* Level (for new caught encounters) */} + {!isEditing && status === 'caught' && ( +
+ + setCatchLevel(e.target.value)} + placeholder={ + selectedPokemon + ? `${selectedPokemon.minLevel}–${selectedPokemon.maxLevel}` + : 'Level' + } + className="w-24 px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500" + /> +
+ )} + + {/* Faint Level (only when editing a caught pokemon to mark dead) */} + {isEditing && + existing?.status === 'caught' && + existing?.faintLevel === null && ( +
+ + setFaintLevel(e.target.value)} + placeholder="Leave empty if still alive" + className="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500" + /> +
+ )} +
+ +
+ + +
+
+
+ ) +} diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index 23673cc..9bd02d6 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -19,16 +19,10 @@ export function Layout() { New Run - Dashboard - - - Encounters + My Runs
diff --git a/frontend/src/components/PokemonCard.tsx b/frontend/src/components/PokemonCard.tsx new file mode 100644 index 0000000..b1ba87b --- /dev/null +++ b/frontend/src/components/PokemonCard.tsx @@ -0,0 +1,82 @@ +import type { EncounterDetail } from '../types' + +interface PokemonCardProps { + encounter: EncounterDetail + showFaintLevel?: boolean +} + +const typeColors: Record = { + normal: 'bg-gray-400', + fire: 'bg-red-500', + water: 'bg-blue-500', + electric: 'bg-yellow-400', + grass: 'bg-green-500', + ice: 'bg-cyan-300', + fighting: 'bg-red-700', + poison: 'bg-purple-500', + ground: 'bg-amber-600', + flying: 'bg-indigo-300', + psychic: 'bg-pink-500', + bug: 'bg-lime-500', + rock: 'bg-amber-700', + ghost: 'bg-purple-700', + dragon: 'bg-indigo-600', + dark: 'bg-gray-700', + steel: 'bg-gray-400', + fairy: 'bg-pink-300', +} + +export function PokemonCard({ encounter, showFaintLevel }: PokemonCardProps) { + const { pokemon, route, nickname, catchLevel, faintLevel } = encounter + const isDead = faintLevel !== null + + return ( +
+ {pokemon.spriteUrl ? ( + {pokemon.name} + ) : ( +
+ {pokemon.name[0].toUpperCase()} +
+ )} + +
+ {nickname || pokemon.name} +
+ {nickname && ( +
+ {pokemon.name} +
+ )} + +
+ {pokemon.types.map((type) => ( + + {type} + + ))} +
+ +
+ {showFaintLevel && isDead + ? `Lv. ${catchLevel} → ${faintLevel}` + : `Lv. ${catchLevel ?? '?'}`} +
+ +
+ {route.name} +
+
+ ) +} diff --git a/frontend/src/components/RuleBadges.tsx b/frontend/src/components/RuleBadges.tsx new file mode 100644 index 0000000..1275616 --- /dev/null +++ b/frontend/src/components/RuleBadges.tsx @@ -0,0 +1,36 @@ +import type { NuzlockeRules } from '../types' +import { RULE_DEFINITIONS } from '../types/rules' + +interface RuleBadgesProps { + rules: NuzlockeRules +} + +export function RuleBadges({ rules }: RuleBadgesProps) { + const enabledRules = RULE_DEFINITIONS.filter((def) => rules[def.key]) + + if (enabledRules.length === 0) { + return ( + + No rules enabled + + ) + } + + return ( +
+ {enabledRules.map((def) => ( + + {def.name} + + ))} +
+ ) +} diff --git a/frontend/src/components/StatCard.tsx b/frontend/src/components/StatCard.tsx new file mode 100644 index 0000000..b9b68ad --- /dev/null +++ b/frontend/src/components/StatCard.tsx @@ -0,0 +1,34 @@ +interface StatCardProps { + label: string + value: number + total?: number + color: string +} + +const colorClasses: Record = { + blue: 'border-blue-500', + green: 'border-green-500', + red: 'border-red-500', + purple: 'border-purple-500', + amber: 'border-amber-500', + gray: 'border-gray-500', +} + +export function StatCard({ label, value, total, color }: StatCardProps) { + return ( +
+
+ {value} + {total !== undefined && ( + + {' '} + / {total} + + )} +
+
{label}
+
+ ) +} diff --git a/frontend/src/components/index.ts b/frontend/src/components/index.ts index ebc77dd..bc79d49 100644 --- a/frontend/src/components/index.ts +++ b/frontend/src/components/index.ts @@ -1,6 +1,10 @@ +export { EncounterModal } from './EncounterModal' export { GameCard } from './GameCard' export { GameGrid } from './GameGrid' export { Layout } from './Layout' +export { PokemonCard } from './PokemonCard' +export { RuleBadges } from './RuleBadges' export { RuleToggle } from './RuleToggle' export { RulesConfiguration } from './RulesConfiguration' +export { StatCard } from './StatCard' export { StepIndicator } from './StepIndicator' diff --git a/frontend/src/hooks/useGames.ts b/frontend/src/hooks/useGames.ts index e6274d2..ff57818 100644 --- a/frontend/src/hooks/useGames.ts +++ b/frontend/src/hooks/useGames.ts @@ -15,10 +15,11 @@ export function useGame(id: number) { }) } -export function useGameRoutes(gameId: number) { +export function useGameRoutes(gameId: number | null) { return useQuery({ queryKey: ['games', gameId, 'routes'], - queryFn: () => getGameRoutes(gameId), + queryFn: () => getGameRoutes(gameId!), + enabled: gameId !== null, }) } diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx deleted file mode 100644 index a51e23d..0000000 --- a/frontend/src/pages/Dashboard.tsx +++ /dev/null @@ -1,10 +0,0 @@ -export function Dashboard() { - return ( -
-

Dashboard

-

- Run dashboard will be implemented here. -

-
- ) -} diff --git a/frontend/src/pages/Encounters.tsx b/frontend/src/pages/Encounters.tsx deleted file mode 100644 index 17a7277..0000000 --- a/frontend/src/pages/Encounters.tsx +++ /dev/null @@ -1,10 +0,0 @@ -export function Encounters() { - return ( -
-

Encounters

-

- Encounter tracking will be implemented here. -

-
- ) -} diff --git a/frontend/src/pages/NewRun.tsx b/frontend/src/pages/NewRun.tsx index 235a0bb..b6d4b6d 100644 --- a/frontend/src/pages/NewRun.tsx +++ b/frontend/src/pages/NewRun.tsx @@ -27,7 +27,7 @@ export function NewRun() { if (!selectedGame) return createRun.mutate( { gameId: selectedGame.id, name: runName, rules }, - { onSuccess: () => navigate('/dashboard') }, + { onSuccess: (data) => navigate(`/runs/${data.id}`) }, ) } diff --git a/frontend/src/pages/RunDashboard.tsx b/frontend/src/pages/RunDashboard.tsx new file mode 100644 index 0000000..8c2b950 --- /dev/null +++ b/frontend/src/pages/RunDashboard.tsx @@ -0,0 +1,152 @@ +import { useParams, Link } from 'react-router-dom' +import { useRun } from '../hooks/useRuns' +import { useGameRoutes } from '../hooks/useGames' +import { StatCard, PokemonCard, RuleBadges } from '../components' +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 RunDashboard() { + const { runId } = useParams<{ runId: string }>() + const { data: run, isLoading, error } = useRun(Number(runId)) + const { data: routes } = useGameRoutes(run?.gameId ?? null) + + if (isLoading) { + return ( +
+
+
+ ) + } + + if (error || !run) { + return ( +
+
+ Failed to load run. It may not exist. +
+ + Back to runs + +
+ ) + } + + 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 visitedRoutes = new Set(run.encounters.map((e) => e.routeId)).size + const totalRoutes = routes?.length + + return ( +
+ {/* Header */} +
+ + ← All Runs + +
+
+

+ {run.name} +

+

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

+
+ + {run.status} + +
+
+ + {/* Stats */} +
+ + + + +
+ + {/* Rules */} +
+

+ Active Rules +

+ +
+ + {/* Active Team */} +
+

+ Active Team +

+ {alive.length === 0 ? ( +

+ No pokemon caught yet +

+ ) : ( +
+ {alive.map((enc) => ( + + ))} +
+ )} +
+ + {/* Graveyard */} + {dead.length > 0 && ( +
+

+ Graveyard +

+
+ {dead.map((enc) => ( + + ))} +
+
+ )} + + {/* Quick Actions */} +
+ + Log Encounter + +
+
+ ) +} diff --git a/frontend/src/pages/RunEncounters.tsx b/frontend/src/pages/RunEncounters.tsx new file mode 100644 index 0000000..d464245 --- /dev/null +++ b/frontend/src/pages/RunEncounters.tsx @@ -0,0 +1,257 @@ +import { useState } from 'react' +import { useParams, Link } from 'react-router-dom' +import { useRun } from '../hooks/useRuns' +import { useGameRoutes } from '../hooks/useGames' +import { useCreateEncounter, useUpdateEncounter } from '../hooks/useEncounters' +import { EncounterModal } from '../components' +import type { Route, EncounterDetail, EncounterStatus } from '../types' + +type RouteStatus = 'caught' | 'fainted' | 'missed' | 'none' + +function getRouteStatus(encounter?: EncounterDetail): RouteStatus { + if (!encounter) return 'none' + return encounter.status +} + +const statusIndicator: Record< + RouteStatus, + { dot: string; label: string; bg: string } +> = { + caught: { + dot: 'bg-green-500', + label: 'Caught', + bg: 'bg-green-50 dark:bg-green-900/10', + }, + fainted: { + dot: 'bg-red-500', + label: 'Fainted', + bg: 'bg-red-50 dark:bg-red-900/10', + }, + missed: { + dot: 'bg-gray-400', + label: 'Missed', + bg: 'bg-gray-50 dark:bg-gray-900/10', + }, + none: { dot: 'bg-gray-300 dark:bg-gray-600', label: '', bg: '' }, +} + +export function RunEncounters() { + const { runId } = useParams<{ runId: string }>() + const runIdNum = Number(runId) + const { data: run, isLoading, error } = useRun(runIdNum) + const { data: routes, isLoading: loadingRoutes } = useGameRoutes( + run?.gameId ?? null, + ) + const createEncounter = useCreateEncounter(runIdNum) + const updateEncounter = useUpdateEncounter(runIdNum) + + const [selectedRoute, setSelectedRoute] = useState(null) + const [editingEncounter, setEditingEncounter] = + useState(null) + const [filter, setFilter] = useState<'all' | RouteStatus>('all') + + if (isLoading || loadingRoutes) { + return ( +
+
+
+ ) + } + + if (error || !run) { + return ( +
+
+ Failed to load run. +
+ + Back to runs + +
+ ) + } + + // Map routeId → encounter for quick lookup + const encounterByRoute = new Map() + for (const enc of run.encounters) { + encounterByRoute.set(enc.routeId, enc) + } + + const allRoutes = routes ?? [] + const completedCount = allRoutes.filter((r) => + encounterByRoute.has(r.id), + ).length + + // Filter routes + const filteredRoutes = + filter === 'all' + ? allRoutes + : allRoutes.filter((r) => { + const enc = encounterByRoute.get(r.id) + return getRouteStatus(enc) === filter + }) + + const handleRouteClick = (route: Route) => { + const existing = encounterByRoute.get(route.id) + if (existing) { + setEditingEncounter(existing) + } else { + setEditingEncounter(null) + } + setSelectedRoute(route) + } + + const handleCreate = (data: { + routeId: number + pokemonId: number + nickname?: string + status: EncounterStatus + catchLevel?: number + }) => { + createEncounter.mutate(data, { + onSuccess: () => { + setSelectedRoute(null) + setEditingEncounter(null) + }, + }) + } + + const handleUpdate = (data: { + id: number + data: { nickname?: string; status?: EncounterStatus; faintLevel?: number } + }) => { + updateEncounter.mutate(data, { + onSuccess: () => { + setSelectedRoute(null) + setEditingEncounter(null) + }, + }) + } + + return ( +
+ {/* Header */} +
+ + ← {run.name} + +

+ Encounters +

+

+ {run.game.name} · {completedCount} / {allRoutes.length} routes +

+
+ + {/* Progress bar */} +
+
+
0 ? (completedCount / allRoutes.length) * 100 : 0}%`, + }} + /> +
+
+ + {/* Filter tabs */} +
+ {( + [ + { key: 'all', label: 'All' }, + { key: 'none', label: 'Unvisited' }, + { key: 'caught', label: 'Caught' }, + { key: 'fainted', label: 'Fainted' }, + { key: 'missed', label: 'Missed' }, + ] as const + ).map(({ key, label }) => ( + + ))} +
+ + {/* Route list */} +
+ {filteredRoutes.length === 0 && ( +

+ No routes match this filter +

+ )} + {filteredRoutes.map((route) => { + const encounter = encounterByRoute.get(route.id) + const rs = getRouteStatus(encounter) + const si = statusIndicator[rs] + + return ( + + ) + })} +
+ + {/* Encounter Modal */} + {selectedRoute && ( + { + setSelectedRoute(null) + setEditingEncounter(null) + }} + isPending={createEncounter.isPending || updateEncounter.isPending} + /> + )} +
+ ) +} diff --git a/frontend/src/pages/RunList.tsx b/frontend/src/pages/RunList.tsx new file mode 100644 index 0000000..492033f --- /dev/null +++ b/frontend/src/pages/RunList.tsx @@ -0,0 +1,89 @@ +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 RunList() { + const { data: runs, isLoading, error } = useRuns() + + return ( +
+
+

+ Your Runs +

+ + Start New Run + +
+ + {isLoading && ( +
+
+
+ )} + + {error && ( +
+ Failed to load runs. Please try again. +
+ )} + + {runs && runs.length === 0 && ( +
+

+ No runs yet. Start your first Nuzlocke! +

+ + Start New Run + +
+ )} + + {runs && runs.length > 0 && ( +
+ {runs.map((run) => ( + +
+
+

+ {run.name} +

+

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

+
+ + {run.status} + +
+ + ))} +
+ )} +
+ ) +} diff --git a/frontend/src/pages/index.ts b/frontend/src/pages/index.ts index d70c66d..01a19f2 100644 --- a/frontend/src/pages/index.ts +++ b/frontend/src/pages/index.ts @@ -1,4 +1,5 @@ export { Home } from './Home' export { NewRun } from './NewRun' -export { Dashboard } from './Dashboard' -export { Encounters } from './Encounters' +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 3e78bc4..523d1ba 100644 --- a/frontend/src/types/game.ts +++ b/frontend/src/types/game.ts @@ -33,6 +33,10 @@ export interface RouteEncounter { maxLevel: number } +export interface RouteEncounterDetail extends RouteEncounter { + pokemon: Pokemon +} + export type EncounterStatus = 'caught' | 'fainted' | 'missed' export interface Encounter {