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 [showEndRun, setShowEndRun] = useState(false)
const [teamSort, setTeamSort] = useState<TeamSortKey>('route') 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) { if (isLoading) {
return ( return (
<div className="flex items-center justify-center py-16"> <div className="flex items-center justify-center py-16">
@@ -81,14 +91,6 @@ export function RunDashboard() {
} }
const isActive = run.status === 'active' 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 visitedRoutes = new Set(run.encounters.map((e) => e.routeId)).size
const totalRoutes = routes?.length const totalRoutes = routes?.length

View File

@@ -33,6 +33,28 @@ import type {
BossPokemon, BossPokemon,
} from '../types' } 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> = { const statusStyles: Record<RunStatus, string> = {
active: 'bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300', active: 'bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300',
completed: completed:
@@ -421,6 +443,7 @@ export function RunEncounters() {
const [showEggModal, setShowEggModal] = useState(false) const [showEggModal, setShowEggModal] = useState(false)
const [expandedBosses, setExpandedBosses] = useState<Set<number>>(new Set()) const [expandedBosses, setExpandedBosses] = useState<Set<number>>(new Set())
const [showTeam, setShowTeam] = useState(true) const [showTeam, setShowTeam] = useState(true)
const [teamSort, setTeamSort] = useState<TeamSortKey>('route')
const [filter, setFilter] = useState<'all' | RouteStatus>('all') const [filter, setFilter] = useState<'all' | RouteStatus>('all')
const storageKey = `expandedGroups-${runId}` const storageKey = `expandedGroups-${runId}`
@@ -621,10 +644,21 @@ export function RunEncounters() {
}, [organizedRoutes, encounterByRoute]) // eslint-disable-line react-hooks/exhaustive-deps }, [organizedRoutes, encounterByRoute]) // eslint-disable-line react-hooks/exhaustive-deps
const alive = useMemo( const alive = useMemo(
() => [...normalEncounters, ...transferEncounters, ...shinyEncounters].filter( () => sortEncounters(
(e) => e.status === 'caught' && e.faintLevel === null, [...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 // Resolve HoF team encounters from IDs
@@ -686,9 +720,6 @@ export function RunEncounters() {
} }
const isActive = run.status === 'active' const isActive = run.status === 'active'
const dead = normalEncounters.filter(
(e) => e.status === 'caught' && e.faintLevel !== null,
)
const toggleGroup = (groupId: number) => { const toggleGroup = (groupId: number) => {
updateExpandedGroups((prev) => { updateExpandedGroups((prev) => {
@@ -1036,31 +1067,45 @@ export function RunEncounters() {
{/* Team Section */} {/* Team Section */}
{(alive.length > 0 || dead.length > 0) && ( {(alive.length > 0 || dead.length > 0) && (
<div className="mb-6"> <div className="mb-6">
<button <div className="flex items-center justify-between mb-3">
type="button" <button
onClick={() => setShowTeam(!showTeam)} type="button"
className="flex items-center gap-2 mb-3 group" onClick={() => setShowTeam(!showTeam)}
> className="flex items-center gap-2 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 <h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
strokeLinecap="round" {isActive ? 'Team' : 'Final Team'}
strokeLinejoin="round" </h2>
strokeWidth={2} <span className="text-xs text-gray-400 dark:text-gray-500">
d="M19 9l-7 7-7-7" {alive.length} alive{dead.length > 0 ? `, ${dead.length} dead` : ''}
/> </span>
</svg> <svg
</button> 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 && ( {showTeam && (
<> <>
{alive.length > 0 && ( {alive.length > 0 && (