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:
2026-02-07 14:20:26 +01:00
parent 0beb287365
commit 5edda2dba9
14 changed files with 821 additions and 109 deletions

View File

@@ -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<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',
},
}
const SPECIAL_METHODS = ['starter', 'gift', 'fossil', 'trade']
function EncounterMethodBadge({ method }: { method: string }) {
const config = specialMethodStyles[method]
if (!config) return null
return (
<span
className={`text-[9px] font-medium px-1.5 py-0.5 rounded-full ${config.color}`}
>
{config.label}
</span>
)
function groupByMethod(pokemon: RouteEncounterDetail[]): { method: string; pokemon: RouteEncounterDetail[] }[] {
const groups = new Map<string, RouteEncounterDetail[]>()
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"
/>
)}
<div className="grid grid-cols-3 gap-2 max-h-48 overflow-y-auto">
{filteredPokemon.map((rp) => (
<button
key={rp.id}
type="button"
onClick={() => setSelectedPokemon(rp)}
className={`flex flex-col items-center p-2 rounded-lg border text-center transition-colors ${
selectedPokemon?.id === rp.id
? '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 className="max-h-64 overflow-y-auto space-y-3">
{groupedPokemon.map(({ method, pokemon }, groupIdx) => (
<div key={method}>
{groupIdx > 0 && (
<div className="border-t border-gray-200 dark:border-gray-700 mb-3" />
)}
{hasMultipleGroups && (
<div className="text-xs font-medium text-gray-500 dark:text-gray-400 mb-1.5">
{getMethodLabel(method)}
</div>
)}
<span className="text-xs text-gray-700 dark:text-gray-300 mt-1 capitalize">
{rp.pokemon.name}
</span>
<EncounterMethodBadge method={rp.encounterMethod} />
<span className="text-[10px] text-gray-400">
Lv. {rp.minLevel}
{rp.maxLevel !== rp.minLevel && `${rp.maxLevel}`}
</span>
</button>
<div className="grid grid-cols-3 gap-2">
{pokemon.map((rp) => (
<button
key={rp.id}
type="button"
onClick={() => setSelectedPokemon(rp)}
className={`flex flex-col items-center p-2 rounded-lg border text-center transition-colors ${
selectedPokemon?.id === rp.id
? '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>
</>