Improve UX with merged run view, method badges, grouped encounters, and mobile nav
Merges the run dashboard into the encounters page as a unified view at /runs/:runId, adds encounter method grouping in the modal and badges on route rows, improves the home page with recent runs, adds mobile hamburger nav, and polishes empty states. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,16 +1,20 @@
|
|||||||
---
|
---
|
||||||
# nuzlocke-tracker-qeim
|
# nuzlocke-tracker-qeim
|
||||||
title: UX improvements pass
|
title: UX improvements pass
|
||||||
status: draft
|
status: completed
|
||||||
type: task
|
type: task
|
||||||
|
priority: normal
|
||||||
created_at: 2026-02-05T14:27:17Z
|
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.
|
The current encounter tracking and run dashboard UX is clunky. Do a holistic UX review and propose improvements.
|
||||||
|
|
||||||
Areas to evaluate:
|
Areas to evaluate:
|
||||||
- Encounter logging flow (too many clicks? modal vs inline?)
|
- 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 list readability and navigation (long lists)
|
||||||
- Route grouping UX polish (auto-expand unvisited groups, remember expand state, visual hierarchy for parent vs child)
|
- Route grouping UX polish (auto-expand unvisited groups, remember expand state, visual hierarchy for parent vs child)
|
||||||
- Run dashboard information density
|
- Run dashboard information density
|
||||||
@@ -21,3 +25,123 @@ Areas to evaluate:
|
|||||||
- It is unintuitive to not be able to deselect a game on the new run page
|
- 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.
|
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
|
||||||
@@ -6,6 +6,7 @@ from sqlalchemy.orm import selectinload
|
|||||||
from app.core.database import get_session
|
from app.core.database import get_session
|
||||||
from app.models.game import Game
|
from app.models.game import Game
|
||||||
from app.models.route import Route
|
from app.models.route import Route
|
||||||
|
from app.models.route_encounter import RouteEncounter
|
||||||
from app.schemas.game import (
|
from app.schemas.game import (
|
||||||
GameCreate,
|
GameCreate,
|
||||||
GameDetailResponse,
|
GameDetailResponse,
|
||||||
@@ -66,35 +67,43 @@ async def list_game_routes(
|
|||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
select(Route)
|
select(Route)
|
||||||
.where(Route.game_id == game_id)
|
.where(Route.game_id == game_id)
|
||||||
|
.options(selectinload(Route.route_encounters))
|
||||||
.order_by(Route.order)
|
.order_by(Route.order)
|
||||||
)
|
)
|
||||||
all_routes = result.scalars().all()
|
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:
|
if flat:
|
||||||
return all_routes
|
return [route_to_dict(r) for r in all_routes]
|
||||||
|
|
||||||
# Build hierarchical structure
|
# Build hierarchical structure
|
||||||
# Group children by parent_route_id
|
# 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] = []
|
top_level_routes: list[Route] = []
|
||||||
|
|
||||||
for route in all_routes:
|
for route in all_routes:
|
||||||
if route.parent_route_id is None:
|
if route.parent_route_id is None:
|
||||||
top_level_routes.append(route)
|
top_level_routes.append(route)
|
||||||
else:
|
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
|
# Build response with nested children
|
||||||
response = []
|
response = []
|
||||||
for route in top_level_routes:
|
for route in top_level_routes:
|
||||||
route_dict = {
|
route_dict = route_to_dict(route)
|
||||||
"id": route.id,
|
route_dict["children"] = children_by_parent.get(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, []),
|
|
||||||
}
|
|
||||||
response.append(route_dict)
|
response.append(route_dict)
|
||||||
|
|
||||||
return response
|
return response
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ class RouteResponse(CamelModel):
|
|||||||
game_id: int
|
game_id: int
|
||||||
order: int
|
order: int
|
||||||
parent_route_id: int | None = None
|
parent_route_id: int | None = None
|
||||||
|
encounter_methods: list[str] = []
|
||||||
|
|
||||||
|
|
||||||
class GameResponse(CamelModel):
|
class GameResponse(CamelModel):
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Routes, Route, Navigate } from 'react-router-dom'
|
import { Routes, Route, Navigate } from 'react-router-dom'
|
||||||
import { Layout } from './components'
|
import { Layout } from './components'
|
||||||
import { AdminLayout } from './components/admin'
|
import { AdminLayout } from './components/admin'
|
||||||
import { Home, NewRun, RunList, RunDashboard, RunEncounters } from './pages'
|
import { Home, NewRun, RunList, RunEncounters } from './pages'
|
||||||
import {
|
import {
|
||||||
AdminGames,
|
AdminGames,
|
||||||
AdminGameDetail,
|
AdminGameDetail,
|
||||||
@@ -17,8 +17,8 @@ function App() {
|
|||||||
<Route index element={<Home />} />
|
<Route index element={<Home />} />
|
||||||
<Route path="runs" element={<RunList />} />
|
<Route path="runs" element={<RunList />} />
|
||||||
<Route path="runs/new" element={<NewRun />} />
|
<Route path="runs/new" element={<NewRun />} />
|
||||||
<Route path="runs/:runId" element={<RunDashboard />} />
|
<Route path="runs/:runId" element={<RunEncounters />} />
|
||||||
<Route path="runs/:runId/encounters" element={<RunEncounters />} />
|
<Route path="runs/:runId/encounters" element={<Navigate to=".." relative="path" replace />} />
|
||||||
<Route path="admin" element={<AdminLayout />}>
|
<Route path="admin" element={<AdminLayout />}>
|
||||||
<Route index element={<Navigate to="/admin/games" replace />} />
|
<Route index element={<Navigate to="/admin/games" replace />} />
|
||||||
<Route path="games" element={<AdminGames />} />
|
<Route path="games" element={<AdminGames />} />
|
||||||
|
|||||||
99
frontend/src/components/EncounterMethodBadge.tsx
Normal file
99
frontend/src/components/EncounterMethodBadge.tsx
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
const METHOD_CONFIG: Record<string, { label: string; color: string }> = {
|
||||||
|
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 (
|
||||||
|
<span
|
||||||
|
className={`${sizeClass} font-medium rounded-full whitespace-nowrap ${config.color}`}
|
||||||
|
>
|
||||||
|
{config.label}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,5 +1,10 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect, useMemo } from 'react'
|
||||||
import { useRoutePokemon } from '../hooks/useGames'
|
import { useRoutePokemon } from '../hooks/useGames'
|
||||||
|
import {
|
||||||
|
EncounterMethodBadge,
|
||||||
|
getMethodLabel,
|
||||||
|
METHOD_ORDER,
|
||||||
|
} from './EncounterMethodBadge'
|
||||||
import type {
|
import type {
|
||||||
Route,
|
Route,
|
||||||
EncounterDetail,
|
EncounterDetail,
|
||||||
@@ -52,38 +57,22 @@ const statusOptions: { value: EncounterStatus; label: string; color: string }[]
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
const specialMethodStyles: Record<string, { label: string; color: string }> = {
|
const SPECIAL_METHODS = ['starter', 'gift', 'fossil', 'trade']
|
||||||
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',
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
function EncounterMethodBadge({ method }: { method: string }) {
|
function groupByMethod(pokemon: RouteEncounterDetail[]): { method: string; pokemon: RouteEncounterDetail[] }[] {
|
||||||
const config = specialMethodStyles[method]
|
const groups = new Map<string, RouteEncounterDetail[]>()
|
||||||
if (!config) return null
|
for (const rp of pokemon) {
|
||||||
return (
|
const list = groups.get(rp.encounterMethod) ?? []
|
||||||
<span
|
list.push(rp)
|
||||||
className={`text-[9px] font-medium px-1.5 py-0.5 rounded-full ${config.color}`}
|
groups.set(rp.encounterMethod, list)
|
||||||
>
|
}
|
||||||
{config.label}
|
return [...groups.entries()]
|
||||||
</span>
|
.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({
|
export function EncounterModal({
|
||||||
@@ -127,6 +116,12 @@ export function EncounterModal({
|
|||||||
rp.pokemon.name.toLowerCase().includes(search.toLowerCase()),
|
rp.pokemon.name.toLowerCase().includes(search.toLowerCase()),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const groupedPokemon = useMemo(
|
||||||
|
() => (filteredPokemon ? groupByMethod(filteredPokemon) : []),
|
||||||
|
[filteredPokemon],
|
||||||
|
)
|
||||||
|
const hasMultipleGroups = groupedPokemon.length > 1
|
||||||
|
|
||||||
const handleSubmit = () => {
|
const handleSubmit = () => {
|
||||||
if (isEditing && onUpdate) {
|
if (isEditing && onUpdate) {
|
||||||
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"
|
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"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<div className="grid grid-cols-3 gap-2 max-h-48 overflow-y-auto">
|
<div className="max-h-64 overflow-y-auto space-y-3">
|
||||||
{filteredPokemon.map((rp) => (
|
{groupedPokemon.map(({ method, pokemon }, groupIdx) => (
|
||||||
<button
|
<div key={method}>
|
||||||
key={rp.id}
|
{groupIdx > 0 && (
|
||||||
type="button"
|
<div className="border-t border-gray-200 dark:border-gray-700 mb-3" />
|
||||||
onClick={() => setSelectedPokemon(rp)}
|
)}
|
||||||
className={`flex flex-col items-center p-2 rounded-lg border text-center transition-colors ${
|
{hasMultipleGroups && (
|
||||||
selectedPokemon?.id === rp.id
|
<div className="text-xs font-medium text-gray-500 dark:text-gray-400 mb-1.5">
|
||||||
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/30'
|
{getMethodLabel(method)}
|
||||||
: 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{rp.pokemon.spriteUrl ? (
|
|
||||||
<img
|
|
||||||
src={rp.pokemon.spriteUrl}
|
|
||||||
alt={rp.pokemon.name}
|
|
||||||
className="w-10 h-10"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="w-10 h-10 rounded-full bg-gray-200 dark:bg-gray-600 flex items-center justify-center text-xs font-bold">
|
|
||||||
{rp.pokemon.name[0].toUpperCase()}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<span className="text-xs text-gray-700 dark:text-gray-300 mt-1 capitalize">
|
<div className="grid grid-cols-3 gap-2">
|
||||||
{rp.pokemon.name}
|
{pokemon.map((rp) => (
|
||||||
</span>
|
<button
|
||||||
<EncounterMethodBadge method={rp.encounterMethod} />
|
key={rp.id}
|
||||||
<span className="text-[10px] text-gray-400">
|
type="button"
|
||||||
Lv. {rp.minLevel}
|
onClick={() => setSelectedPokemon(rp)}
|
||||||
{rp.maxLevel !== rp.minLevel && `–${rp.maxLevel}`}
|
className={`flex flex-col items-center p-2 rounded-lg border text-center transition-colors ${
|
||||||
</span>
|
selectedPokemon?.id === rp.id
|
||||||
</button>
|
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/30'
|
||||||
|
: 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{rp.pokemon.spriteUrl ? (
|
||||||
|
<img
|
||||||
|
src={rp.pokemon.spriteUrl}
|
||||||
|
alt={rp.pokemon.name}
|
||||||
|
className="w-10 h-10"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-10 h-10 rounded-full bg-gray-200 dark:bg-gray-600 flex items-center justify-center text-xs font-bold">
|
||||||
|
{rp.pokemon.name[0].toUpperCase()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<span className="text-xs text-gray-700 dark:text-gray-300 mt-1 capitalize">
|
||||||
|
{rp.pokemon.name}
|
||||||
|
</span>
|
||||||
|
{SPECIAL_METHODS.includes(rp.encounterMethod) && (
|
||||||
|
<EncounterMethodBadge method={rp.encounterMethod} />
|
||||||
|
)}
|
||||||
|
<span className="text-[10px] text-gray-400">
|
||||||
|
Lv. {rp.minLevel}
|
||||||
|
{rp.maxLevel !== rp.minLevel && `–${rp.maxLevel}`}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
import { Link, Outlet } from 'react-router-dom'
|
import { Link, Outlet } from 'react-router-dom'
|
||||||
|
|
||||||
export function Layout() {
|
export function Layout() {
|
||||||
|
const [menuOpen, setMenuOpen] = useState(false)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-gray-100">
|
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-gray-100">
|
||||||
<nav className="bg-white dark:bg-gray-800 shadow-sm">
|
<nav className="bg-white dark:bg-gray-800 shadow-sm">
|
||||||
@@ -11,7 +14,8 @@ export function Layout() {
|
|||||||
Nuzlocke Tracker
|
Nuzlocke Tracker
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center space-x-4">
|
{/* Desktop nav */}
|
||||||
|
<div className="hidden sm:flex items-center space-x-4">
|
||||||
<Link
|
<Link
|
||||||
to="/runs/new"
|
to="/runs/new"
|
||||||
className="px-3 py-2 rounded-md text-sm font-medium hover:bg-gray-100 dark:hover:bg-gray-700"
|
className="px-3 py-2 rounded-md text-sm font-medium hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||||
@@ -31,8 +35,68 @@ export function Layout() {
|
|||||||
Admin
|
Admin
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
{/* Mobile hamburger */}
|
||||||
|
<div className="flex items-center sm:hidden">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setMenuOpen(!menuOpen)}
|
||||||
|
className="p-2 rounded-md hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||||
|
aria-label="Toggle menu"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="w-6 h-6"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
{menuOpen ? (
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M6 18L18 6M6 6l12 12"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M4 6h16M4 12h16M4 18h16"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{/* Mobile dropdown */}
|
||||||
|
{menuOpen && (
|
||||||
|
<div className="sm:hidden border-t border-gray-200 dark:border-gray-700">
|
||||||
|
<div className="px-2 pt-2 pb-3 space-y-1">
|
||||||
|
<Link
|
||||||
|
to="/runs/new"
|
||||||
|
onClick={() => 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
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
to="/runs"
|
||||||
|
onClick={() => 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
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
to="/admin"
|
||||||
|
onClick={() => setMenuOpen(false)}
|
||||||
|
className="block px-3 py-2 rounded-md text-base font-medium hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||||
|
>
|
||||||
|
Admin
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</nav>
|
</nav>
|
||||||
<main>
|
<main>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
export { EncounterMethodBadge } from './EncounterMethodBadge'
|
||||||
export { EncounterModal } from './EncounterModal'
|
export { EncounterModal } from './EncounterModal'
|
||||||
export { EndRunModal } from './EndRunModal'
|
export { EndRunModal } from './EndRunModal'
|
||||||
export { GameCard } from './GameCard'
|
export { GameCard } from './GameCard'
|
||||||
|
|||||||
@@ -1,10 +1,125 @@
|
|||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import { useRuns } from '../hooks/useRuns'
|
||||||
|
import type { RunStatus } from '../types'
|
||||||
|
|
||||||
|
const statusStyles: Record<RunStatus, string> = {
|
||||||
|
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() {
|
export function Home() {
|
||||||
|
const { data: runs, isLoading } = useRuns()
|
||||||
|
|
||||||
|
const activeRun = runs?.find((r) => r.status === 'active')
|
||||||
|
const recentRuns = runs?.slice(0, 5)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center min-h-screen p-8">
|
<div className="max-w-4xl mx-auto p-8">
|
||||||
<h1 className="text-4xl font-bold mb-4">Nuzlocke Tracker</h1>
|
<div className="text-center py-12">
|
||||||
<p className="text-lg text-gray-600 dark:text-gray-400">
|
<h1 className="text-4xl font-bold text-gray-900 dark:text-gray-100 mb-2">
|
||||||
Track your Nuzlocke runs with ease
|
Nuzlocke Tracker
|
||||||
</p>
|
</h1>
|
||||||
|
<p className="text-lg text-gray-600 dark:text-gray-400 mb-8">
|
||||||
|
Track your Nuzlocke runs with ease
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
to="/runs/new"
|
||||||
|
className="inline-block px-6 py-3 bg-blue-600 text-white rounded-lg font-medium text-lg hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition-colors"
|
||||||
|
>
|
||||||
|
Start New Run
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading && (
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<div className="w-6 h-6 border-2 border-blue-600 border-t-transparent rounded-full animate-spin" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeRun && (
|
||||||
|
<div className="mb-8">
|
||||||
|
<h2 className="text-sm font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-3">
|
||||||
|
Continue Playing
|
||||||
|
</h2>
|
||||||
|
<Link
|
||||||
|
to={`/runs/${activeRun.id}`}
|
||||||
|
className="block bg-white dark:bg-gray-800 rounded-lg shadow hover:shadow-md transition-shadow p-5 border-l-4 border-green-500"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||||
|
{activeRun.name}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Started{' '}
|
||||||
|
{new Date(activeRun.startedAt).toLocaleDateString(undefined, {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span className="text-blue-600 dark:text-blue-400 font-medium text-sm">
|
||||||
|
Resume →
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{recentRuns && recentRuns.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<h2 className="text-sm font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide">
|
||||||
|
Recent Runs
|
||||||
|
</h2>
|
||||||
|
<Link
|
||||||
|
to="/runs"
|
||||||
|
className="text-sm text-blue-600 dark:text-blue-400 hover:underline"
|
||||||
|
>
|
||||||
|
View all
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{recentRuns.map((run) => (
|
||||||
|
<Link
|
||||||
|
key={run.id}
|
||||||
|
to={`/runs/${run.id}`}
|
||||||
|
className="block bg-white dark:bg-gray-800 rounded-lg shadow hover:shadow-md transition-shadow p-4"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-medium text-gray-900 dark:text-gray-100">
|
||||||
|
{run.name}
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{new Date(run.startedAt).toLocaleDateString(undefined, {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={`px-2.5 py-0.5 rounded-full text-xs font-medium capitalize ${statusStyles[run.status]}`}
|
||||||
|
>
|
||||||
|
{run.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{runs && runs.length === 0 && (
|
||||||
|
<p className="text-center text-gray-500 dark:text-gray-400">
|
||||||
|
No runs yet. Start your first Nuzlocke challenge above!
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,10 @@ export function NewRun() {
|
|||||||
const [runName, setRunName] = useState('')
|
const [runName, setRunName] = useState('')
|
||||||
|
|
||||||
const handleGameSelect = (game: Game) => {
|
const handleGameSelect = (game: Game) => {
|
||||||
|
if (selectedGame?.id === game.id) {
|
||||||
|
setSelectedGame(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
setSelectedGame(game)
|
setSelectedGame(game)
|
||||||
if (!runName || runName === `${selectedGame?.name} Nuzlocke`) {
|
if (!runName || runName === `${selectedGame?.name} Nuzlocke`) {
|
||||||
setRunName(`${game.name} Nuzlocke`)
|
setRunName(`${game.name} Nuzlocke`)
|
||||||
|
|||||||
@@ -176,7 +176,8 @@ export function RunDashboard() {
|
|||||||
</h2>
|
</h2>
|
||||||
{alive.length === 0 ? (
|
{alive.length === 0 ? (
|
||||||
<p className="text-gray-500 dark:text-gray-400 text-sm">
|
<p className="text-gray-500 dark:text-gray-400 text-sm">
|
||||||
No pokemon caught yet
|
No pokemon caught yet — head to encounters to start building your
|
||||||
|
team!
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-3">
|
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-3">
|
||||||
|
|||||||
@@ -1,16 +1,40 @@
|
|||||||
import { useState, useMemo } from 'react'
|
import { useState, useMemo, useEffect, useCallback } from 'react'
|
||||||
import { useParams, Link } from 'react-router-dom'
|
import { useParams, Link } from 'react-router-dom'
|
||||||
import { useRun } from '../hooks/useRuns'
|
import { useRun, useUpdateRun } from '../hooks/useRuns'
|
||||||
import { useGameRoutes } from '../hooks/useGames'
|
import { useGameRoutes } from '../hooks/useGames'
|
||||||
import { useCreateEncounter, useUpdateEncounter } from '../hooks/useEncounters'
|
import { useCreateEncounter, useUpdateEncounter } from '../hooks/useEncounters'
|
||||||
import { EncounterModal } from '../components'
|
import {
|
||||||
|
EncounterModal,
|
||||||
|
EncounterMethodBadge,
|
||||||
|
StatCard,
|
||||||
|
PokemonCard,
|
||||||
|
StatusChangeModal,
|
||||||
|
EndRunModal,
|
||||||
|
RuleBadges,
|
||||||
|
} from '../components'
|
||||||
import type {
|
import type {
|
||||||
Route,
|
Route,
|
||||||
RouteWithChildren,
|
RouteWithChildren,
|
||||||
|
RunStatus,
|
||||||
EncounterDetail,
|
EncounterDetail,
|
||||||
EncounterStatus,
|
EncounterStatus,
|
||||||
} from '../types'
|
} from '../types'
|
||||||
|
|
||||||
|
const statusStyles: Record<RunStatus, string> = {
|
||||||
|
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'
|
type RouteStatus = 'caught' | 'fainted' | 'missed' | 'none'
|
||||||
|
|
||||||
function getRouteStatus(encounter?: EncounterDetail): RouteStatus {
|
function getRouteStatus(encounter?: EncounterDetail): RouteStatus {
|
||||||
@@ -189,6 +213,13 @@ function RouteGroup({
|
|||||||
<div className="text-sm text-gray-700 dark:text-gray-300">
|
<div className="text-sm text-gray-700 dark:text-gray-300">
|
||||||
{child.name}
|
{child.name}
|
||||||
</div>
|
</div>
|
||||||
|
{!childEncounter && child.encounterMethods.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1 mt-0.5">
|
||||||
|
{child.encounterMethods.map((m) => (
|
||||||
|
<EncounterMethodBadge key={m} method={m} size="xs" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{childEncounter && (
|
{childEncounter && (
|
||||||
<span className="text-xs text-gray-400 dark:text-gray-500">
|
<span className="text-xs text-gray-400 dark:text-gray-500">
|
||||||
@@ -218,12 +249,36 @@ export function RunEncounters() {
|
|||||||
)
|
)
|
||||||
const createEncounter = useCreateEncounter(runIdNum)
|
const createEncounter = useCreateEncounter(runIdNum)
|
||||||
const updateEncounter = useUpdateEncounter(runIdNum)
|
const updateEncounter = useUpdateEncounter(runIdNum)
|
||||||
|
const updateRun = useUpdateRun(runIdNum)
|
||||||
|
|
||||||
const [selectedRoute, setSelectedRoute] = useState<Route | null>(null)
|
const [selectedRoute, setSelectedRoute] = useState<Route | null>(null)
|
||||||
const [editingEncounter, setEditingEncounter] =
|
const [editingEncounter, setEditingEncounter] =
|
||||||
useState<EncounterDetail | null>(null)
|
useState<EncounterDetail | null>(null)
|
||||||
|
const [selectedTeamEncounter, setSelectedTeamEncounter] =
|
||||||
|
useState<EncounterDetail | null>(null)
|
||||||
|
const [showEndRun, setShowEndRun] = useState(false)
|
||||||
|
const [showTeam, setShowTeam] = useState(true)
|
||||||
const [filter, setFilter] = useState<'all' | RouteStatus>('all')
|
const [filter, setFilter] = useState<'all' | RouteStatus>('all')
|
||||||
const [expandedGroups, setExpandedGroups] = useState<Set<number>>(new Set())
|
|
||||||
|
const storageKey = `expandedGroups-${runId}`
|
||||||
|
const [expandedGroups, setExpandedGroups] = useState<Set<number>>(() => {
|
||||||
|
try {
|
||||||
|
const saved = localStorage.getItem(storageKey)
|
||||||
|
if (saved) return new Set(JSON.parse(saved) as number[])
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
return new Set<number>()
|
||||||
|
})
|
||||||
|
|
||||||
|
const updateExpandedGroups = useCallback(
|
||||||
|
(updater: (prev: Set<number>) => Set<number>) => {
|
||||||
|
setExpandedGroups((prev) => {
|
||||||
|
const next = updater(prev)
|
||||||
|
localStorage.setItem(storageKey, JSON.stringify([...next]))
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
},
|
||||||
|
[storageKey],
|
||||||
|
)
|
||||||
|
|
||||||
// Organize routes into hierarchical structure
|
// Organize routes into hierarchical structure
|
||||||
const organizedRoutes = useMemo(() => {
|
const organizedRoutes = useMemo(() => {
|
||||||
@@ -231,6 +286,30 @@ export function RunEncounters() {
|
|||||||
return organizeRoutes(routes)
|
return organizeRoutes(routes)
|
||||||
}, [routes])
|
}, [routes])
|
||||||
|
|
||||||
|
// Map routeId → encounter for quick lookup
|
||||||
|
const encounterByRoute = useMemo(() => {
|
||||||
|
const map = new Map<number, EncounterDetail>()
|
||||||
|
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) {
|
if (isLoading || loadingRoutes) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center py-16">
|
<div className="flex items-center justify-center py-16">
|
||||||
@@ -255,12 +334,6 @@ export function RunEncounters() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Map routeId → encounter for quick lookup
|
|
||||||
const encounterByRoute = new Map<number, EncounterDetail>()
|
|
||||||
for (const enc of run.encounters) {
|
|
||||||
encounterByRoute.set(enc.routeId, enc)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Count completed locations (groups count as 1, standalone routes count as 1)
|
// Count completed locations (groups count as 1, standalone routes count as 1)
|
||||||
const completedCount = organizedRoutes.filter((r) => {
|
const completedCount = organizedRoutes.filter((r) => {
|
||||||
if (r.children.length > 0) {
|
if (r.children.length > 0) {
|
||||||
@@ -273,8 +346,16 @@ export function RunEncounters() {
|
|||||||
|
|
||||||
const totalLocations = organizedRoutes.length
|
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) => {
|
const toggleGroup = (groupId: number) => {
|
||||||
setExpandedGroups((prev) => {
|
updateExpandedGroups((prev) => {
|
||||||
const next = new Set(prev)
|
const next = new Set(prev)
|
||||||
if (next.has(groupId)) {
|
if (next.has(groupId)) {
|
||||||
next.delete(groupId)
|
next.delete(groupId)
|
||||||
@@ -347,21 +428,187 @@ export function RunEncounters() {
|
|||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<Link
|
<Link
|
||||||
to={`/runs/${runId}`}
|
to="/runs"
|
||||||
className="text-sm text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 mb-2 inline-block"
|
className="text-sm text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 mb-2 inline-block"
|
||||||
>
|
>
|
||||||
← {run.name}
|
← All Runs
|
||||||
</Link>
|
</Link>
|
||||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-gray-100">
|
<div className="flex items-start justify-between">
|
||||||
Encounters
|
<div>
|
||||||
</h1>
|
<h1 className="text-3xl font-bold text-gray-900 dark:text-gray-100">
|
||||||
<p className="text-gray-600 dark:text-gray-400 mt-1">
|
{run.name}
|
||||||
{run.game.name} · {completedCount} / {totalLocations} locations
|
</h1>
|
||||||
</p>
|
<p className="text-gray-600 dark:text-gray-400 mt-1">
|
||||||
|
{run.game.name} · {run.game.region} · Started{' '}
|
||||||
|
{new Date(run.startedAt).toLocaleDateString(undefined, {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{isActive && (
|
||||||
|
<button
|
||||||
|
onClick={() => setShowEndRun(true)}
|
||||||
|
className="px-3 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded-full font-medium hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
|
||||||
|
>
|
||||||
|
End Run
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<span
|
||||||
|
className={`px-3 py-1 rounded-full text-sm font-medium capitalize ${statusStyles[run.status]}`}
|
||||||
|
>
|
||||||
|
{run.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Progress bar */}
|
{/* Completion Banner */}
|
||||||
|
{!isActive && (
|
||||||
|
<div
|
||||||
|
className={`rounded-lg p-4 mb-6 ${
|
||||||
|
run.status === 'completed'
|
||||||
|
? 'bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800'
|
||||||
|
: 'bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-2xl">{run.status === 'completed' ? '\u{1f3c6}' : '\u{1faa6}'}</span>
|
||||||
|
<div>
|
||||||
|
<p
|
||||||
|
className={`font-semibold ${
|
||||||
|
run.status === 'completed'
|
||||||
|
? 'text-blue-800 dark:text-blue-200'
|
||||||
|
: 'text-red-800 dark:text-red-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{run.status === 'completed' ? 'Victory!' : 'Defeat'}
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
className={`text-sm ${
|
||||||
|
run.status === 'completed'
|
||||||
|
? 'text-blue-600 dark:text-blue-400'
|
||||||
|
: 'text-red-600 dark:text-red-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{run.completedAt && (
|
||||||
|
<>
|
||||||
|
Ended{' '}
|
||||||
|
{new Date(run.completedAt).toLocaleDateString(undefined, {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
})}
|
||||||
|
{' \u00b7 '}
|
||||||
|
Duration: {formatDuration(run.startedAt, run.completedAt)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Stats */}
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 mb-6">
|
||||||
|
<StatCard
|
||||||
|
label="Encounters"
|
||||||
|
value={run.encounters.length}
|
||||||
|
color="blue"
|
||||||
|
/>
|
||||||
|
<StatCard label="Alive" value={alive.length} color="green" />
|
||||||
|
<StatCard label="Deaths" value={dead.length} color="red" />
|
||||||
|
<StatCard
|
||||||
|
label="Routes"
|
||||||
|
value={completedCount}
|
||||||
|
total={totalLocations}
|
||||||
|
color="purple"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Rules */}
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
|
<h2 className="text-sm font-medium text-gray-500 dark:text-gray-400 mb-2">
|
||||||
|
Active Rules
|
||||||
|
</h2>
|
||||||
|
<RuleBadges rules={run.rules} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Team Section */}
|
||||||
|
{(alive.length > 0 || dead.length > 0) && (
|
||||||
|
<div className="mb-6">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowTeam(!showTeam)}
|
||||||
|
className="flex items-center gap-2 mb-3 group"
|
||||||
|
>
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||||
|
{isActive ? 'Team' : 'Final Team'}
|
||||||
|
</h2>
|
||||||
|
<span className="text-xs text-gray-400 dark:text-gray-500">
|
||||||
|
{alive.length} alive{dead.length > 0 ? `, ${dead.length} dead` : ''}
|
||||||
|
</span>
|
||||||
|
<svg
|
||||||
|
className={`w-4 h-4 text-gray-400 transition-transform ${showTeam ? 'rotate-180' : ''}`}
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M19 9l-7 7-7-7"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{showTeam && (
|
||||||
|
<>
|
||||||
|
{alive.length > 0 && (
|
||||||
|
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-6 gap-2 mb-3">
|
||||||
|
{alive.map((enc) => (
|
||||||
|
<PokemonCard
|
||||||
|
key={enc.id}
|
||||||
|
encounter={enc}
|
||||||
|
onClick={isActive ? () => setSelectedTeamEncounter(enc) : undefined}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{dead.length > 0 && (
|
||||||
|
<>
|
||||||
|
<h3 className="text-sm font-medium text-gray-500 dark:text-gray-400 mb-2">
|
||||||
|
Graveyard
|
||||||
|
</h3>
|
||||||
|
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-6 gap-2">
|
||||||
|
{dead.map((enc) => (
|
||||||
|
<PokemonCard
|
||||||
|
key={enc.id}
|
||||||
|
encounter={enc}
|
||||||
|
showFaintLevel
|
||||||
|
onClick={isActive ? () => setSelectedTeamEncounter(enc) : undefined}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Progress bar */}
|
||||||
|
<div className="mb-4">
|
||||||
|
<div className="flex items-center justify-between mb-1">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||||
|
Encounters
|
||||||
|
</h2>
|
||||||
|
<span className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{completedCount} / {totalLocations} locations
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
<div className="h-2 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
|
<div className="h-2 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
|
||||||
<div
|
<div
|
||||||
className="h-full bg-blue-500 rounded-full transition-all"
|
className="h-full bg-blue-500 rounded-full transition-all"
|
||||||
@@ -401,7 +648,9 @@ export function RunEncounters() {
|
|||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{filteredRoutes.length === 0 && (
|
{filteredRoutes.length === 0 && (
|
||||||
<p className="text-gray-500 dark:text-gray-400 text-sm py-4 text-center">
|
<p className="text-gray-500 dark:text-gray-400 text-sm py-4 text-center">
|
||||||
No routes match this filter
|
{filter === 'all'
|
||||||
|
? 'Click a route to log your first encounter'
|
||||||
|
: 'No routes match this filter — try a different one'}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{filteredRoutes.map((route) => {
|
{filteredRoutes.map((route) => {
|
||||||
@@ -439,7 +688,7 @@ export function RunEncounters() {
|
|||||||
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||||
{route.name}
|
{route.name}
|
||||||
</div>
|
</div>
|
||||||
{encounter && (
|
{encounter ? (
|
||||||
<div className="flex items-center gap-2 mt-0.5">
|
<div className="flex items-center gap-2 mt-0.5">
|
||||||
{encounter.pokemon.spriteUrl && (
|
{encounter.pokemon.spriteUrl && (
|
||||||
<img
|
<img
|
||||||
@@ -457,6 +706,12 @@ export function RunEncounters() {
|
|||||||
: ' (dead)')}
|
: ' (dead)')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
) : route.encounterMethods.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1 mt-0.5">
|
||||||
|
{route.encounterMethods.map((m) => (
|
||||||
|
<EncounterMethodBadge key={m} method={m} size="xs" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<span className="text-xs text-gray-400 dark:text-gray-500 shrink-0">
|
<span className="text-xs text-gray-400 dark:text-gray-500 shrink-0">
|
||||||
@@ -481,6 +736,34 @@ export function RunEncounters() {
|
|||||||
isPending={createEncounter.isPending || updateEncounter.isPending}
|
isPending={createEncounter.isPending || updateEncounter.isPending}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Status Change Modal (team pokemon) */}
|
||||||
|
{selectedTeamEncounter && (
|
||||||
|
<StatusChangeModal
|
||||||
|
encounter={selectedTeamEncounter}
|
||||||
|
onUpdate={(data) => {
|
||||||
|
updateEncounter.mutate(data, {
|
||||||
|
onSuccess: () => setSelectedTeamEncounter(null),
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
onClose={() => setSelectedTeamEncounter(null)}
|
||||||
|
isPending={updateEncounter.isPending}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* End Run Modal */}
|
||||||
|
{showEndRun && (
|
||||||
|
<EndRunModal
|
||||||
|
onConfirm={(status) => {
|
||||||
|
updateRun.mutate(
|
||||||
|
{ status },
|
||||||
|
{ onSuccess: () => setShowEndRun(false) },
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
onClose={() => setShowEndRun(false)}
|
||||||
|
isPending={updateRun.isPending}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
export { Home } from './Home'
|
export { Home } from './Home'
|
||||||
export { NewRun } from './NewRun'
|
export { NewRun } from './NewRun'
|
||||||
export { RunList } from './RunList'
|
export { RunList } from './RunList'
|
||||||
export { RunDashboard } from './RunDashboard'
|
|
||||||
export { RunEncounters } from './RunEncounters'
|
export { RunEncounters } from './RunEncounters'
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export interface Route {
|
|||||||
gameId: number
|
gameId: number
|
||||||
order: number
|
order: number
|
||||||
parentRouteId: number | null
|
parentRouteId: number | null
|
||||||
|
encounterMethods: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RouteWithChildren extends Route {
|
export interface RouteWithChildren extends Route {
|
||||||
|
|||||||
Reference in New Issue
Block a user