Fix team sort: add to RunEncounters and fix hook ordering

Add sort dropdown to RunEncounters (the encounters page with the
expandable team section) and move all useMemo hooks before early
returns in both RunDashboard and RunEncounters to fix React hook
ordering violations.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Julian Tabel
2026-02-09 12:21:07 +01:00
parent bc9bcf4c4b
commit 6d955439eb
2 changed files with 85 additions and 38 deletions

View File

@@ -56,6 +56,16 @@ export function RunDashboard() {
const [showEndRun, setShowEndRun] = useState(false)
const [teamSort, setTeamSort] = useState<TeamSortKey>('route')
const encounters = run?.encounters ?? []
const alive = useMemo(
() => sortEncounters(encounters.filter((e) => e.status === 'caught' && e.faintLevel === null), teamSort),
[encounters, teamSort],
)
const dead = useMemo(
() => sortEncounters(encounters.filter((e) => e.status === 'caught' && e.faintLevel !== null), teamSort),
[encounters, teamSort],
)
if (isLoading) {
return (
<div className="flex items-center justify-center py-16">
@@ -81,14 +91,6 @@ export function RunDashboard() {
}
const isActive = run.status === 'active'
const alive = useMemo(
() => sortEncounters(run.encounters.filter((e) => e.status === 'caught' && e.faintLevel === null), teamSort),
[run.encounters, teamSort],
)
const dead = useMemo(
() => sortEncounters(run.encounters.filter((e) => e.status === 'caught' && e.faintLevel !== null), teamSort),
[run.encounters, teamSort],
)
const visitedRoutes = new Set(run.encounters.map((e) => e.routeId)).size
const totalRoutes = routes?.length

View File

@@ -33,6 +33,28 @@ import type {
BossPokemon,
} from '../types'
type TeamSortKey = 'route' | 'level' | 'species' | 'dex'
function sortEncounters(encounters: EncounterDetail[], key: TeamSortKey): EncounterDetail[] {
return [...encounters].sort((a, b) => {
switch (key) {
case 'route':
return a.route.order - b.route.order
case 'level':
return (a.catchLevel ?? 0) - (b.catchLevel ?? 0)
case 'species': {
const nameA = (a.currentPokemon ?? a.pokemon).name
const nameB = (b.currentPokemon ?? b.pokemon).name
return nameA.localeCompare(nameB)
}
case 'dex':
return (a.currentPokemon ?? a.pokemon).nationalDex - (b.currentPokemon ?? b.pokemon).nationalDex
default:
return 0
}
})
}
const statusStyles: Record<RunStatus, string> = {
active: 'bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300',
completed:
@@ -421,6 +443,7 @@ export function RunEncounters() {
const [showEggModal, setShowEggModal] = useState(false)
const [expandedBosses, setExpandedBosses] = useState<Set<number>>(new Set())
const [showTeam, setShowTeam] = useState(true)
const [teamSort, setTeamSort] = useState<TeamSortKey>('route')
const [filter, setFilter] = useState<'all' | RouteStatus>('all')
const storageKey = `expandedGroups-${runId}`
@@ -621,10 +644,21 @@ export function RunEncounters() {
}, [organizedRoutes, encounterByRoute]) // eslint-disable-line react-hooks/exhaustive-deps
const alive = useMemo(
() => [...normalEncounters, ...transferEncounters, ...shinyEncounters].filter(
(e) => e.status === 'caught' && e.faintLevel === null,
() => sortEncounters(
[...normalEncounters, ...transferEncounters, ...shinyEncounters].filter(
(e) => e.status === 'caught' && e.faintLevel === null,
),
teamSort,
),
[normalEncounters, transferEncounters, shinyEncounters],
[normalEncounters, transferEncounters, shinyEncounters, teamSort],
)
const dead = useMemo(
() => sortEncounters(
normalEncounters.filter((e) => e.status === 'caught' && e.faintLevel !== null),
teamSort,
),
[normalEncounters, teamSort],
)
// Resolve HoF team encounters from IDs
@@ -686,9 +720,6 @@ export function RunEncounters() {
}
const isActive = run.status === 'active'
const dead = normalEncounters.filter(
(e) => e.status === 'caught' && e.faintLevel !== null,
)
const toggleGroup = (groupId: number) => {
updateExpandedGroups((prev) => {
@@ -1036,31 +1067,45 @@ export function RunEncounters() {
{/* 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"
<div className="flex items-center justify-between mb-3">
<button
type="button"
onClick={() => setShowTeam(!showTeam)}
className="flex items-center gap-2 group"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 9l-7 7-7-7"
/>
</svg>
</button>
<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 > 1 && (
<select
value={teamSort}
onChange={(e) => setTeamSort(e.target.value as TeamSortKey)}
className="text-sm border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-1.5 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100"
>
<option value="route">Route Order</option>
<option value="level">Catch Level</option>
<option value="species">Species Name</option>
<option value="dex">National Dex</option>
</select>
)}
</div>
{showTeam && (
<>
{alive.length > 0 && (