diff --git a/.beans/nuzlocke-tracker-k4u8--implement-pre-commit-hooks-for-linting.md b/.beans/nuzlocke-tracker-k4u8--implement-pre-commit-hooks-for-linting.md new file mode 100644 index 0000000..71ae04d --- /dev/null +++ b/.beans/nuzlocke-tracker-k4u8--implement-pre-commit-hooks-for-linting.md @@ -0,0 +1,11 @@ +--- +# nuzlocke-tracker-k4u8 +title: Implement pre-commit hooks for linting +status: completed +type: task +priority: normal +created_at: 2026-02-14T15:37:32Z +updated_at: 2026-02-14T15:40:44Z +--- + +Set up pre-commit framework with hooks for ruff (backend), ESLint/Prettier/tsc (frontend). Add pre-commit to dev deps, update CI with Prettier check, document in CLAUDE.md. \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5ce6a35..2f79340 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -45,6 +45,9 @@ jobs: - name: Lint run: npm run lint working-directory: frontend + - name: Check formatting + run: npx prettier --check "src/**/*.{ts,tsx,css,json}" + working-directory: frontend - name: Type check run: npx tsc -b working-directory: frontend diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..2640044 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,34 @@ +repos: + # Backend (Python) — ruff linting + formatting + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.15.0 + hooks: + - id: ruff + args: [--fix] + files: ^backend/ + - id: ruff-format + files: ^backend/ + + # Frontend (TypeScript/React) — local hooks using project node_modules + - repo: local + hooks: + - id: eslint + name: eslint + entry: npx eslint + language: system + files: ^frontend/src/.*\.(ts|tsx)$ + pass_filenames: true + + - id: prettier + name: prettier + entry: npx prettier --check + language: system + files: ^frontend/src/.*\.(ts|tsx|css|json)$ + pass_filenames: true + + - id: tsc + name: tsc + entry: bash -c 'cd frontend && npx tsc -b' + language: system + files: ^frontend/src/.*\.(ts|tsx)$ + pass_filenames: false diff --git a/CLAUDE.md b/CLAUDE.md index b981fb1..5b90c79 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,6 +8,18 @@ - **Merge commit** `develop` into `main` (marks deploy points). - Always `git pull` the target branch before merging into it. +# Pre-commit Hooks + +This project uses [pre-commit](https://pre-commit.com/) to run linting and formatting checks before each commit. + +**Setup:** `pip install pre-commit && pre-commit install` + +**Hooks configured:** +- **Backend:** `ruff check --fix` and `ruff format` on Python files under `backend/` +- **Frontend:** `eslint`, `prettier --check`, and `tsc -b` on files under `frontend/` + +Frontend hooks require `npm ci` in `frontend/` first (they use `npx` to run from local `node_modules`). + # Instructions - After completing a task, always ask the user if they'd like to commit the changes. diff --git a/backend/pyproject.toml b/backend/pyproject.toml index e83b69d..e4069a2 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -18,6 +18,7 @@ dependencies = [ [project.optional-dependencies] dev = [ "ruff>=0.9.0", + "pre-commit>=4.0.0", "pytest>=8.0.0", "pytest-asyncio>=0.25.0", "httpx>=0.28.0", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index e7bf0e5..ef8b805 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,7 +1,16 @@ import { Routes, Route, Navigate } from 'react-router-dom' import { Layout } from './components' import { AdminLayout } from './components/admin' -import { GenlockeDetail, GenlockeList, Home, NewGenlocke, NewRun, RunList, RunEncounters, Stats } from './pages' +import { + GenlockeDetail, + GenlockeList, + Home, + NewGenlocke, + NewRun, + RunList, + RunEncounters, + Stats, +} from './pages' import { AdminGames, AdminGameDetail, @@ -25,17 +34,26 @@ function App() { } /> } /> } /> - } /> + } + /> }> } /> } /> } /> - } /> + } + /> } /> } /> } /> } /> - } /> + } + /> diff --git a/frontend/src/api/admin.ts b/frontend/src/api/admin.ts index 89273fc..a3bb289 100644 --- a/frontend/src/api/admin.ts +++ b/frontend/src/api/admin.ts @@ -36,15 +36,17 @@ export const createGame = (data: CreateGameInput) => export const updateGame = (id: number, data: UpdateGameInput) => api.put(`/games/${id}`, data) -export const deleteGame = (id: number) => - api.del(`/games/${id}`) +export const deleteGame = (id: number) => api.del(`/games/${id}`) // Routes export const createRoute = (gameId: number, data: CreateRouteInput) => api.post(`/games/${gameId}/routes`, data) -export const updateRoute = (gameId: number, routeId: number, data: UpdateRouteInput) => - api.put(`/games/${gameId}/routes/${routeId}`, data) +export const updateRoute = ( + gameId: number, + routeId: number, + data: UpdateRouteInput +) => api.put(`/games/${gameId}/routes/${routeId}`, data) export const deleteRoute = (gameId: number, routeId: number) => api.del(`/games/${gameId}/routes/${routeId}`) @@ -53,7 +55,12 @@ export const reorderRoutes = (gameId: number, routes: RouteReorderItem[]) => api.put(`/games/${gameId}/routes/reorder`, { routes }) // Pokemon -export const listPokemon = (search?: string, limit = 50, offset = 0, type?: string) => { +export const listPokemon = ( + search?: string, + limit = 50, + offset = 0, + type?: string +) => { const params = new URLSearchParams() if (search) params.set('search', search) if (type) params.set('type', type) @@ -68,11 +75,17 @@ export const createPokemon = (data: CreatePokemonInput) => export const updatePokemon = (id: number, data: UpdatePokemonInput) => api.put(`/pokemon/${id}`, data) -export const deletePokemon = (id: number) => - api.del(`/pokemon/${id}`) +export const deletePokemon = (id: number) => api.del(`/pokemon/${id}`) -export const bulkImportPokemon = (items: Array<{ pokeapiId: number; nationalDex: number; name: string; types: string[]; spriteUrl?: string | null }>) => - api.post('/pokemon/bulk-import', items) +export const bulkImportPokemon = ( + items: Array<{ + pokeapiId: number + nationalDex: number + name: string + types: string[] + spriteUrl?: string | null + }> +) => api.post('/pokemon/bulk-import', items) export const bulkImportEvolutions = (items: unknown[]) => api.post('/evolutions/bulk-import', items) @@ -84,7 +97,12 @@ export const bulkImportBosses = (gameId: number, items: unknown[]) => api.post(`/games/${gameId}/bosses/bulk-import`, items) // Evolutions -export const listEvolutions = (search?: string, limit = 50, offset = 0, trigger?: string) => { +export const listEvolutions = ( + search?: string, + limit = 50, + offset = 0, + trigger?: string +) => { const params = new URLSearchParams() if (search) params.set('search', search) if (trigger) params.set('trigger', trigger) @@ -99,8 +117,7 @@ export const createEvolution = (data: CreateEvolutionInput) => export const updateEvolution = (id: number, data: UpdateEvolutionInput) => api.put(`/evolutions/${id}`, data) -export const deleteEvolution = (id: number) => - api.del(`/evolutions/${id}`) +export const deleteEvolution = (id: number) => api.del(`/evolutions/${id}`) // Export export const exportGames = () => @@ -119,11 +136,20 @@ export const exportEvolutions = () => api.get[]>('/export/evolutions') // Route Encounters -export const addRouteEncounter = (routeId: number, data: CreateRouteEncounterInput) => - api.post(`/routes/${routeId}/pokemon`, data) +export const addRouteEncounter = ( + routeId: number, + data: CreateRouteEncounterInput +) => api.post(`/routes/${routeId}/pokemon`, data) -export const updateRouteEncounter = (routeId: number, encounterId: number, data: UpdateRouteEncounterInput) => - api.put(`/routes/${routeId}/pokemon/${encounterId}`, data) +export const updateRouteEncounter = ( + routeId: number, + encounterId: number, + data: UpdateRouteEncounterInput +) => + api.put( + `/routes/${routeId}/pokemon/${encounterId}`, + data + ) export const removeRouteEncounter = (routeId: number, encounterId: number) => api.del(`/routes/${routeId}/pokemon/${encounterId}`) @@ -132,8 +158,11 @@ export const removeRouteEncounter = (routeId: number, encounterId: number) => export const createBossBattle = (gameId: number, data: CreateBossBattleInput) => api.post(`/games/${gameId}/bosses`, data) -export const updateBossBattle = (gameId: number, bossId: number, data: UpdateBossBattleInput) => - api.put(`/games/${gameId}/bosses/${bossId}`, data) +export const updateBossBattle = ( + gameId: number, + bossId: number, + data: UpdateBossBattleInput +) => api.put(`/games/${gameId}/bosses/${bossId}`, data) export const deleteBossBattle = (gameId: number, bossId: number) => api.del(`/games/${gameId}/bosses/${bossId}`) @@ -141,15 +170,17 @@ export const deleteBossBattle = (gameId: number, bossId: number) => export const reorderBosses = (gameId: number, bosses: BossReorderItem[]) => api.put(`/games/${gameId}/bosses/reorder`, { bosses }) -export const setBossTeam = (gameId: number, bossId: number, team: BossPokemonInput[]) => - api.put(`/games/${gameId}/bosses/${bossId}/pokemon`, team) +export const setBossTeam = ( + gameId: number, + bossId: number, + team: BossPokemonInput[] +) => api.put(`/games/${gameId}/bosses/${bossId}/pokemon`, team) // Genlockes export const updateGenlocke = (id: number, data: UpdateGenlockeInput) => api.patch(`/genlockes/${id}`, data) -export const deleteGenlocke = (id: number) => - api.del(`/genlockes/${id}`) +export const deleteGenlocke = (id: number) => api.del(`/genlockes/${id}`) export const addGenlockeLeg = (genlockeId: number, data: AddGenlockeLegInput) => api.post(`/genlockes/${genlockeId}/legs`, data) diff --git a/frontend/src/api/bosses.ts b/frontend/src/api/bosses.ts index c212ea9..0960fc3 100644 --- a/frontend/src/api/bosses.ts +++ b/frontend/src/api/bosses.ts @@ -1,7 +1,14 @@ import { api } from './client' -import type { BossBattle, BossResult, CreateBossResultInput } from '../types/game' +import type { + BossBattle, + BossResult, + CreateBossResultInput, +} from '../types/game' -export function getGameBosses(gameId: number, all?: boolean): Promise { +export function getGameBosses( + gameId: number, + all?: boolean +): Promise { const params = all ? '?all=true' : '' return api.get(`/games/${gameId}/bosses${params}`) } @@ -10,10 +17,16 @@ export function getBossResults(runId: number): Promise { return api.get(`/runs/${runId}/boss-results`) } -export function createBossResult(runId: number, data: CreateBossResultInput): Promise { +export function createBossResult( + runId: number, + data: CreateBossResultInput +): Promise { return api.post(`/runs/${runId}/boss-results`, data) } -export function deleteBossResult(runId: number, resultId: number): Promise { +export function deleteBossResult( + runId: number, + resultId: number +): Promise { return api.del(`/runs/${runId}/boss-results/${resultId}`) } diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 4547c0b..ec6424b 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -10,10 +10,7 @@ export class ApiError extends Error { } } -async function request( - path: string, - options?: RequestInit, -): Promise { +async function request(path: string, options?: RequestInit): Promise { const res = await fetch(`${API_BASE}/api/v1${path}`, { ...options, headers: { @@ -52,6 +49,5 @@ export const api = { body: JSON.stringify(body), }), - del: (path: string) => - request(path, { method: 'DELETE' }), + del: (path: string) => request(path, { method: 'DELETE' }), } diff --git a/frontend/src/api/encounters.ts b/frontend/src/api/encounters.ts index f3a34c6..3ffffda 100644 --- a/frontend/src/api/encounters.ts +++ b/frontend/src/api/encounters.ts @@ -9,14 +9,14 @@ import type { export function createEncounter( runId: number, - data: CreateEncounterInput, + data: CreateEncounterInput ): Promise { return api.post(`/runs/${runId}/encounters`, data) } export function updateEncounter( id: number, - data: UpdateEncounterInput, + data: UpdateEncounterInput ): Promise { return api.patch(`/encounters/${id}`, data) } @@ -25,7 +25,10 @@ export function deleteEncounter(id: number): Promise { return api.del(`/encounters/${id}`) } -export function fetchEvolutions(pokemonId: number, region?: string): Promise { +export function fetchEvolutions( + pokemonId: number, + region?: string +): Promise { const params = region ? `?region=${encodeURIComponent(region)}` : '' return api.get(`/pokemon/${pokemonId}/evolutions${params}`) } @@ -34,6 +37,8 @@ export function fetchForms(pokemonId: number): Promise { return api.get(`/pokemon/${pokemonId}/forms`) } -export function bulkRandomizeEncounters(runId: number): Promise<{ created: unknown[]; skippedRoutes: number }> { +export function bulkRandomizeEncounters( + runId: number +): Promise<{ created: unknown[]; skippedRoutes: number }> { return api.post(`/runs/${runId}/encounters/bulk-randomize`, {}) } diff --git a/frontend/src/api/games.ts b/frontend/src/api/games.ts index a092816..f1394ef 100644 --- a/frontend/src/api/games.ts +++ b/frontend/src/api/games.ts @@ -19,7 +19,10 @@ export function getGameRoutes(gameId: number): Promise { return api.get(`/games/${gameId}/routes?flat=true`) } -export function getRoutePokemon(routeId: number, gameId?: number): Promise { +export function getRoutePokemon( + routeId: number, + gameId?: number +): Promise { const params = gameId != null ? `?game_id=${gameId}` : '' return api.get(`/routes/${routeId}/pokemon${params}`) } diff --git a/frontend/src/api/genlockes.ts b/frontend/src/api/genlockes.ts index 618cc3f..be5c42b 100644 --- a/frontend/src/api/genlockes.ts +++ b/frontend/src/api/genlockes.ts @@ -1,5 +1,15 @@ import { api } from './client' -import type { Genlocke, GenlockeListItem, GenlockeDetail, GenlockeGraveyard, GenlockeLineage, CreateGenlockeInput, Region, SurvivorEncounter, AdvanceLegInput } from '../types/game' +import type { + Genlocke, + GenlockeListItem, + GenlockeDetail, + GenlockeGraveyard, + GenlockeLineage, + CreateGenlockeInput, + Region, + SurvivorEncounter, + AdvanceLegInput, +} from '../types/game' export function getGenlockes(): Promise { return api.get('/genlockes') @@ -25,10 +35,20 @@ export function getGenlockeLineages(id: number): Promise { return api.get(`/genlockes/${id}/lineages`) } -export function getLegSurvivors(genlockeId: number, legOrder: number): Promise { +export function getLegSurvivors( + genlockeId: number, + legOrder: number +): Promise { return api.get(`/genlockes/${genlockeId}/legs/${legOrder}/survivors`) } -export function advanceLeg(genlockeId: number, legOrder: number, data?: AdvanceLegInput): Promise { - return api.post(`/genlockes/${genlockeId}/legs/${legOrder}/advance`, data ?? {}) +export function advanceLeg( + genlockeId: number, + legOrder: number, + data?: AdvanceLegInput +): Promise { + return api.post( + `/genlockes/${genlockeId}/legs/${legOrder}/advance`, + data ?? {} + ) } diff --git a/frontend/src/api/pokemon.ts b/frontend/src/api/pokemon.ts index 07b35ea..17f85fb 100644 --- a/frontend/src/api/pokemon.ts +++ b/frontend/src/api/pokemon.ts @@ -10,10 +10,14 @@ export function fetchPokemonFamilies(): Promise<{ families: number[][] }> { return api.get('/pokemon/families') } -export function fetchPokemonEncounterLocations(pokemonId: number): Promise { +export function fetchPokemonEncounterLocations( + pokemonId: number +): Promise { return api.get(`/pokemon/${pokemonId}/encounter-locations`) } -export function fetchPokemonEvolutionChain(pokemonId: number): Promise { +export function fetchPokemonEvolutionChain( + pokemonId: number +): Promise { return api.get(`/pokemon/${pokemonId}/evolution-chain`) } diff --git a/frontend/src/api/runs.ts b/frontend/src/api/runs.ts index 936279e..1dbe336 100644 --- a/frontend/src/api/runs.ts +++ b/frontend/src/api/runs.ts @@ -20,7 +20,7 @@ export function createRun(data: CreateRunInput): Promise { export function updateRun( id: number, - data: UpdateRunInput, + data: UpdateRunInput ): Promise { return api.patch(`/runs/${id}`, data) } @@ -33,7 +33,11 @@ export function getNamingCategories(): Promise { return api.get('/runs/naming-categories') } -export function getNameSuggestions(runId: number, count = 10, pokemonId?: number): Promise { +export function getNameSuggestions( + runId: number, + count = 10, + pokemonId?: number +): Promise { let url = `/runs/${runId}/name-suggestions?count=${count}` if (pokemonId != null) { url += `&pokemon_id=${pokemonId}` diff --git a/frontend/src/components/BossDefeatModal.tsx b/frontend/src/components/BossDefeatModal.tsx index 66856b3..e53bf84 100644 --- a/frontend/src/components/BossDefeatModal.tsx +++ b/frontend/src/components/BossDefeatModal.tsx @@ -10,14 +10,24 @@ interface BossDefeatModalProps { starterName?: string | null } -function matchVariant(labels: string[], starterName?: string | null): string | null { +function matchVariant( + labels: string[], + starterName?: string | null +): string | null { if (!starterName || labels.length === 0) return null const lower = starterName.toLowerCase() const matches = labels.filter((l) => l.toLowerCase().includes(lower)) return matches.length === 1 ? matches[0] : null } -export function BossDefeatModal({ boss, onSubmit, onClose, isPending, hardcoreMode, starterName }: BossDefeatModalProps) { +export function BossDefeatModal({ + boss, + onSubmit, + onClose, + isPending, + hardcoreMode, + starterName, +}: BossDefeatModalProps) { const [result, setResult] = useState<'won' | 'lost'>('won') const [attempts, setAttempts] = useState('1') @@ -30,16 +40,20 @@ export function BossDefeatModal({ boss, onSubmit, onClose, isPending, hardcoreMo }, [boss.pokemon]) const hasVariants = variantLabels.length > 0 - const autoMatch = useMemo(() => matchVariant(variantLabels, starterName), [variantLabels, starterName]) + const autoMatch = useMemo( + () => matchVariant(variantLabels, starterName), + [variantLabels, starterName] + ) const showPills = hasVariants && autoMatch === null const [selectedVariant, setSelectedVariant] = useState( - autoMatch ?? (hasVariants ? variantLabels[0] : null), + autoMatch ?? (hasVariants ? variantLabels[0] : null) ) const displayedPokemon = useMemo(() => { if (!hasVariants) return boss.pokemon return boss.pokemon.filter( - (bp) => bp.conditionLabel === selectedVariant || bp.conditionLabel === null, + (bp) => + bp.conditionLabel === selectedVariant || bp.conditionLabel === null ) }, [boss.pokemon, hasVariants, selectedVariant]) @@ -58,7 +72,9 @@ export function BossDefeatModal({ boss, onSubmit, onClose, isPending, hardcoreMo

Battle: {boss.name}

-

{boss.location}

+

+ {boss.location} +

{/* Boss team preview */} @@ -88,7 +104,11 @@ export function BossDefeatModal({ boss, onSubmit, onClose, isPending, hardcoreMo .map((bp) => (
{bp.pokemon.spriteUrl ? ( - {bp.pokemon.name} + {bp.pokemon.name} ) : (
)} @@ -138,7 +158,9 @@ export function BossDefeatModal({ boss, onSubmit, onClose, isPending, hardcoreMo {!hardcoreMode && (
- + r.parentRouteId !== null).map(r => r.parentRouteId)) - const leafRoutes = routes.filter(r => !parentIds.has(r.id)) + const parentIds = new Set( + routes.filter((r) => r.parentRouteId !== null).map((r) => r.parentRouteId) + ) + const leafRoutes = routes.filter((r) => !parentIds.has(r.id)) // Debounced pokemon search useEffect(() => { @@ -44,7 +46,9 @@ export function EggEncounterModal({ const timer = setTimeout(async () => { setIsSearching(true) try { - const data = await api.get<{ items: Pokemon[] }>(`/pokemon?search=${encodeURIComponent(search)}&limit=20`) + const data = await api.get<{ items: Pokemon[] }>( + `/pokemon?search=${encodeURIComponent(search)}&limit=20` + ) setSearchResults(data.items) } catch { setSearchResults([]) @@ -196,11 +200,13 @@ export function EggEncounterModal({ ))}
)} - {search.length >= 2 && !isSearching && searchResults.length === 0 && ( -

- No pokemon found -

- )} + {search.length >= 2 && + !isSearching && + searchResults.length === 0 && ( +

+ No pokemon found +

+ )} )}
diff --git a/frontend/src/components/EncounterMethodBadge.tsx b/frontend/src/components/EncounterMethodBadge.tsx index 46c9340..f436e33 100644 --- a/frontend/src/components/EncounterMethodBadge.tsx +++ b/frontend/src/components/EncounterMethodBadge.tsx @@ -69,14 +69,15 @@ export const METHOD_ORDER = [ export function getMethodLabel(method: string): string { return ( METHOD_CONFIG[method]?.label ?? - method - .replace(/-/g, ' ') - .replace(/\b\w/g, (c) => c.toUpperCase()) + 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' + return ( + METHOD_CONFIG[method]?.color ?? + 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300' + ) } export function EncounterMethodBadge({ @@ -88,7 +89,8 @@ export function EncounterMethodBadge({ }) { 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' + const sizeClass = + size === 'xs' ? 'text-[8px] px-1 py-0' : 'text-[9px] px-1.5 py-0.5' return ( () for (const rp of pokemon) { const list = groups.get(rp.encounterMethod) ?? [] @@ -84,7 +89,7 @@ function groupByMethod(pokemon: RouteEncounterDetail[]): { method: string; pokem function pickRandomPokemon( pokemon: RouteEncounterDetail[], - dupedIds?: Set, + dupedIds?: Set ): RouteEncounterDetail | null { const eligible = dupedIds ? pokemon.filter((rp) => !dupedIds.has(rp.pokemonId)) @@ -109,17 +114,17 @@ export function EncounterModal({ }: EncounterModalProps) { const { data: routePokemon, isLoading: loadingPokemon } = useRoutePokemon( route.id, - gameId, + gameId ) const [selectedPokemon, setSelectedPokemon] = useState(null) const [status, setStatus] = useState( - existing?.status ?? 'caught', + existing?.status ?? 'caught' ) const [nickname, setNickname] = useState(existing?.nickname ?? '') const [catchLevel, setCatchLevel] = useState( - existing?.catchLevel?.toString() ?? '', + existing?.catchLevel?.toString() ?? '' ) const [faintLevel, setFaintLevel] = useState('') const [deathCause, setDeathCause] = useState('') @@ -128,27 +133,31 @@ export function EncounterModal({ const isEditing = !!existing const showSuggestions = !!namingScheme && status === 'caught' && !isEditing - const lineagePokemonId = isGenlocke && selectedPokemon ? selectedPokemon.pokemonId : null - const { data: suggestions, refetch: regenerate, isFetching: loadingSuggestions } = - useNameSuggestions(showSuggestions ? runId : null, lineagePokemonId) + const lineagePokemonId = + isGenlocke && selectedPokemon ? selectedPokemon.pokemonId : null + const { + data: suggestions, + refetch: regenerate, + isFetching: loadingSuggestions, + } = useNameSuggestions(showSuggestions ? runId : null, lineagePokemonId) // Pre-select pokemon when editing useEffect(() => { if (existing && routePokemon) { const match = routePokemon.find( - (rp) => rp.pokemonId === existing.pokemonId, + (rp) => rp.pokemonId === existing.pokemonId ) if (match) setSelectedPokemon(match) } }, [existing, routePokemon]) const filteredPokemon = routePokemon?.filter((rp) => - rp.pokemon.name.toLowerCase().includes(search.toLowerCase()), + rp.pokemon.name.toLowerCase().includes(search.toLowerCase()) ) const groupedPokemon = useMemo( () => (filteredPokemon ? groupByMethod(filteredPokemon) : []), - [filteredPokemon], + [filteredPokemon] ) const hasMultipleGroups = groupedPokemon.length > 1 @@ -224,13 +233,15 @@ export function EncounterModal({ loadingPokemon || !routePokemon || (dupedPokemonIds - ? routePokemon.every((rp) => dupedPokemonIds.has(rp.pokemonId)) + ? routePokemon.every((rp) => + dupedPokemonIds.has(rp.pokemonId) + ) : false) } onClick={() => { if (routePokemon) { setSelectedPokemon( - pickRandomPokemon(routePokemon, dupedPokemonIds), + pickRandomPokemon(routePokemon, dupedPokemonIds) ) } }} @@ -268,12 +279,15 @@ export function EncounterModal({ )}
{pokemon.map((rp) => { - const isDuped = dupedPokemonIds?.has(rp.pokemonId) ?? false + const isDuped = + dupedPokemonIds?.has(rp.pokemonId) ?? false return ( @@ -518,11 +540,7 @@ export function EncounterModal({ onClick={handleSubmit} className="px-4 py-2 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors" > - {isPending - ? 'Saving...' - : isEditing - ? 'Update' - : 'Log Encounter'} + {isPending ? 'Saving...' : isEditing ? 'Update' : 'Log Encounter'}
diff --git a/frontend/src/components/EndRunModal.tsx b/frontend/src/components/EndRunModal.tsx index 253799c..4dc79eb 100644 --- a/frontend/src/components/EndRunModal.tsx +++ b/frontend/src/components/EndRunModal.tsx @@ -7,7 +7,12 @@ interface EndRunModalProps { genlockeContext?: RunGenlockeContext | null } -export function EndRunModal({ onConfirm, onClose, isPending, genlockeContext }: EndRunModalProps) { +export function EndRunModal({ + onConfirm, + onClose, + isPending, + genlockeContext, +}: EndRunModalProps) { const victoryDescription = genlockeContext ? genlockeContext.isFinalLeg ? 'Complete the final leg of your genlocke!' diff --git a/frontend/src/components/GameGrid.tsx b/frontend/src/components/GameGrid.tsx index 01f3339..75f81f5 100644 --- a/frontend/src/components/GameGrid.tsx +++ b/frontend/src/components/GameGrid.tsx @@ -29,32 +29,46 @@ export function GameGrid({ games, selectedId, onSelect, runs }: GameGridProps) { const generations = useMemo( () => [...new Set(games.map((g) => g.generation))].sort(), - [games], + [games] ) const regions = useMemo( () => [...new Set(games.map((g) => g.region))].sort(), - [games], + [games] ) const activeRunGameIds = useMemo(() => { if (!runs) return new Set() - return new Set(runs.filter((r) => r.status === 'active').map((r) => r.gameId)) + return new Set( + runs.filter((r) => r.status === 'active').map((r) => r.gameId) + ) }, [runs]) const completedRunGameIds = useMemo(() => { if (!runs) return new Set() - return new Set(runs.filter((r) => r.status === 'completed').map((r) => r.gameId)) + return new Set( + runs.filter((r) => r.status === 'completed').map((r) => r.gameId) + ) }, [runs]) const filtered = useMemo(() => { let result = games if (filter) result = result.filter((g) => g.generation === filter) if (regionFilter) result = result.filter((g) => g.region === regionFilter) - if (hideWithActiveRun) result = result.filter((g) => !activeRunGameIds.has(g.id)) - if (hideCompleted) result = result.filter((g) => !completedRunGameIds.has(g.id)) + if (hideWithActiveRun) + result = result.filter((g) => !activeRunGameIds.has(g.id)) + if (hideCompleted) + result = result.filter((g) => !completedRunGameIds.has(g.id)) return result - }, [games, filter, regionFilter, hideWithActiveRun, hideCompleted, activeRunGameIds, completedRunGameIds]) + }, [ + games, + filter, + regionFilter, + hideWithActiveRun, + hideCompleted, + activeRunGameIds, + completedRunGameIds, + ]) const grouped = useMemo(() => { const groups: Record = {} @@ -77,7 +91,9 @@ export function GameGrid({ games, selectedId, onSelect, runs }: GameGridProps) {
- Gen: + + Gen: +
- Region: + + Region: +
@@ -143,7 +144,9 @@ export function GenlockeGraveyard({ genlockeId }: GenlockeGraveyardProps) {
setGameId(e.target.value)} @@ -208,7 +232,9 @@ export function BossBattleFormModal({
- + (a[0] ?? '').localeCompare(b[0] ?? '')) + const remaining = [...map.entries()].sort((a, b) => + (a[0] ?? '').localeCompare(b[0] ?? '') + ) for (const [label, pokemon] of remaining) { variants.push({ label, pokemon }) } return variants } -export function BossTeamEditor({ boss, onSave, onClose, isSaving }: BossTeamEditorProps) { - const [variants, setVariants] = useState(() => groupByVariant(boss)) +export function BossTeamEditor({ + boss, + onSave, + onClose, + isSaving, +}: BossTeamEditorProps) { + const [variants, setVariants] = useState(() => + groupByVariant(boss) + ) const [activeTab, setActiveTab] = useState(0) const [newVariantName, setNewVariantName] = useState('') const [showAddVariant, setShowAddVariant] = useState(false) const activeVariant = variants[activeTab] ?? variants[0] - const updateVariant = (tabIndex: number, updater: (v: Variant) => Variant) => { + const updateVariant = ( + tabIndex: number, + updater: (v: Variant) => Variant + ) => { setVariants((prev) => prev.map((v, i) => (i === tabIndex ? updater(v) : v))) } const addSlot = () => { updateVariant(activeTab, (v) => ({ ...v, - pokemon: [...v.pokemon, { pokemonId: null, pokemonName: '', level: '', order: v.pokemon.length + 1 }], + pokemon: [ + ...v.pokemon, + { + pokemonId: null, + pokemonName: '', + level: '', + order: v.pokemon.length + 1, + }, + ], })) } const removeSlot = (index: number) => { updateVariant(activeTab, (v) => ({ ...v, - pokemon: v.pokemon.filter((_, i) => i !== index).map((item, i) => ({ ...item, order: i + 1 })), + pokemon: v.pokemon + .filter((_, i) => i !== index) + .map((item, i) => ({ ...item, order: i + 1 })), })) } - const updateSlot = (index: number, field: string, value: number | string | null) => { + const updateSlot = ( + index: number, + field: string, + value: number | string | null + ) => { updateVariant(activeTab, (v) => ({ ...v, - pokemon: v.pokemon.map((item, i) => (i === index ? { ...item, [field]: value } : item)), + pokemon: v.pokemon.map((item, i) => + i === index ? { ...item, [field]: value } : item + ), })) } @@ -92,7 +125,13 @@ export function BossTeamEditor({ boss, onSave, onClose, isSaving }: BossTeamEdit const name = newVariantName.trim() if (!name) return if (variants.some((v) => v.label === name)) return - setVariants((prev) => [...prev, { label: name, pokemon: [{ pokemonId: null, pokemonName: '', level: '', order: 1 }] }]) + setVariants((prev) => [ + ...prev, + { + label: name, + pokemon: [{ pokemonId: null, pokemonName: '', level: '', order: 1 }], + }, + ]) setActiveTab(variants.length) setNewVariantName('') setShowAddVariant(false) @@ -109,8 +148,11 @@ export function BossTeamEditor({ boss, onSave, onClose, isSaving }: BossTeamEdit e.preventDefault() const allPokemon: BossPokemonInput[] = [] for (const variant of variants) { - const conditionLabel = variants.length === 1 && variant.label === null ? null : variant.label - const validPokemon = variant.pokemon.filter((t) => t.pokemonId != null && t.level) + const conditionLabel = + variants.length === 1 && variant.label === null ? null : variant.label + const validPokemon = variant.pokemon.filter( + (t) => t.pokemonId != null && t.level + ) for (let i = 0; i < validPokemon.length; i++) { allPokemon.push({ pokemonId: validPokemon[i].pokemonId!, @@ -147,7 +189,10 @@ export function BossTeamEditor({ boss, onSave, onClose, isSaving }: BossTeamEdit {v.label ?? 'Default'} {v.label !== null && ( { e.stopPropagation(); removeVariant(i) }} + onClick={(e) => { + e.stopPropagation() + removeVariant(i) + }} className="ml-1.5 text-gray-400 hover:text-red-500 cursor-pointer" title="Remove variant" > @@ -171,13 +216,31 @@ export function BossTeamEditor({ boss, onSave, onClose, isSaving }: BossTeamEdit type="text" value={newVariantName} onChange={(e) => setNewVariantName(e.target.value)} - onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); addVariant() } if (e.key === 'Escape') setShowAddVariant(false) }} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault() + addVariant() + } + if (e.key === 'Escape') setShowAddVariant(false) + }} placeholder="Variant name..." className="px-2 py-1 text-sm border rounded dark:bg-gray-700 dark:border-gray-600 w-40" autoFocus /> - - + +
)}
@@ -185,7 +248,10 @@ export function BossTeamEditor({ boss, onSave, onClose, isSaving }: BossTeamEdit
{activeVariant.pokemon.map((slot, index) => ( -
+
- + (null) const [result, setResult] = useState(null) @@ -73,7 +80,10 @@ export function BulkImportModal({ title, example, onSubmit, onClose, createdLabe {result && (
-

{createdLabel}: {result.created}, {updatedLabel}: {result.updated}

+

+ {createdLabel}: {result.created}, {updatedLabel}:{' '} + {result.updated} +

{result.errors.length > 0 && (
    {result.errors.map((err, i) => ( diff --git a/frontend/src/components/admin/EvolutionFormModal.tsx b/frontend/src/components/admin/EvolutionFormModal.tsx index 64a6f54..bd42841 100644 --- a/frontend/src/components/admin/EvolutionFormModal.tsx +++ b/frontend/src/components/admin/EvolutionFormModal.tsx @@ -1,7 +1,11 @@ import { type FormEvent, useState } from 'react' import { FormModal } from './FormModal' import { PokemonSelector } from './PokemonSelector' -import type { EvolutionAdmin, CreateEvolutionInput, UpdateEvolutionInput } from '../../types' +import type { + EvolutionAdmin, + CreateEvolutionInput, + UpdateEvolutionInput, +} from '../../types' interface EvolutionFormModalProps { evolution?: EvolutionAdmin @@ -23,10 +27,10 @@ export function EvolutionFormModal({ isDeleting, }: EvolutionFormModalProps) { const [fromPokemonId, setFromPokemonId] = useState( - evolution?.fromPokemonId ?? null, + evolution?.fromPokemonId ?? null ) const [toPokemonId, setToPokemonId] = useState( - evolution?.toPokemonId ?? null, + evolution?.toPokemonId ?? null ) const [trigger, setTrigger] = useState(evolution?.trigger ?? 'level-up') const [minLevel, setMinLevel] = useState(String(evolution?.minLevel ?? '')) diff --git a/frontend/src/components/admin/FormModal.tsx b/frontend/src/components/admin/FormModal.tsx index 137f477..0af37e1 100644 --- a/frontend/src/components/admin/FormModal.tsx +++ b/frontend/src/components/admin/FormModal.tsx @@ -55,7 +55,11 @@ export function FormModal({ onBlur={() => setConfirmingDelete(false)} className="px-4 py-2 text-sm font-medium rounded-md text-red-600 dark:text-red-400 border border-red-300 dark:border-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 disabled:opacity-50" > - {isDeleting ? 'Deleting...' : confirmingDelete ? 'Confirm?' : 'Delete'} + {isDeleting + ? 'Deleting...' + : confirmingDelete + ? 'Confirm?' + : 'Delete'} )}
    diff --git a/frontend/src/components/admin/GameFormModal.tsx b/frontend/src/components/admin/GameFormModal.tsx index fb5f512..1f0ab8f 100644 --- a/frontend/src/components/admin/GameFormModal.tsx +++ b/frontend/src/components/admin/GameFormModal.tsx @@ -20,13 +20,23 @@ function slugify(name: string) { .replace(/^-|-$/g, '') } -export function GameFormModal({ game, onSubmit, onClose, isSubmitting, onDelete, isDeleting, detailUrl }: GameFormModalProps) { +export function GameFormModal({ + game, + onSubmit, + onClose, + isSubmitting, + onDelete, + isDeleting, + detailUrl, +}: GameFormModalProps) { const [name, setName] = useState(game?.name ?? '') const [slug, setSlug] = useState(game?.slug ?? '') const [generation, setGeneration] = useState(String(game?.generation ?? '')) const [region, setRegion] = useState(game?.region ?? '') const [boxArtUrl, setBoxArtUrl] = useState(game?.boxArtUrl ?? '') - const [releaseYear, setReleaseYear] = useState(game?.releaseYear ? String(game.releaseYear) : '') + const [releaseYear, setReleaseYear] = useState( + game?.releaseYear ? String(game.releaseYear) : '' + ) const [autoSlug, setAutoSlug] = useState(!game) useEffect(() => { @@ -53,14 +63,16 @@ export function GameFormModal({ game, onSubmit, onClose, isSubmitting, onDelete, isSubmitting={isSubmitting} onDelete={onDelete} isDeleting={isDeleting} - headerExtra={detailUrl ? ( - - View Routes & Bosses - - ) : undefined} + headerExtra={ + detailUrl ? ( + + View Routes & Bosses + + ) : undefined + } >
    diff --git a/frontend/src/components/admin/PokemonFormModal.tsx b/frontend/src/components/admin/PokemonFormModal.tsx index e165957..7135fac 100644 --- a/frontend/src/components/admin/PokemonFormModal.tsx +++ b/frontend/src/components/admin/PokemonFormModal.tsx @@ -2,8 +2,17 @@ import { type FormEvent, useState, useEffect } from 'react' import { Link } from 'react-router-dom' import { useQueryClient } from '@tanstack/react-query' import { EvolutionFormModal } from './EvolutionFormModal' -import type { Pokemon, CreatePokemonInput, UpdatePokemonInput, EvolutionAdmin, UpdateEvolutionInput } from '../../types' -import { usePokemonEncounterLocations, usePokemonEvolutionChain } from '../../hooks/usePokemon' +import type { + Pokemon, + CreatePokemonInput, + UpdatePokemonInput, + EvolutionAdmin, + UpdateEvolutionInput, +} from '../../types' +import { + usePokemonEncounterLocations, + usePokemonEvolutionChain, +} from '../../hooks/usePokemon' import { useUpdateEvolution, useDeleteEvolution } from '../../hooks/useAdmin' import { formatEvolutionMethod } from '../../utils/formatEvolution' @@ -18,20 +27,32 @@ interface PokemonFormModalProps { type Tab = 'details' | 'evolutions' | 'encounters' -export function PokemonFormModal({ pokemon, onSubmit, onClose, isSubmitting, onDelete, isDeleting }: PokemonFormModalProps) { +export function PokemonFormModal({ + pokemon, + onSubmit, + onClose, + isSubmitting, + onDelete, + isDeleting, +}: PokemonFormModalProps) { const [pokeapiId, setPokeapiId] = useState(String(pokemon?.pokeapiId ?? '')) - const [nationalDex, setNationalDex] = useState(String(pokemon?.nationalDex ?? '')) + const [nationalDex, setNationalDex] = useState( + String(pokemon?.nationalDex ?? '') + ) const [name, setName] = useState(pokemon?.name ?? '') const [types, setTypes] = useState(pokemon?.types.join(', ') ?? '') const [spriteUrl, setSpriteUrl] = useState(pokemon?.spriteUrl ?? '') const [activeTab, setActiveTab] = useState('details') - const [editingEvolution, setEditingEvolution] = useState(null) + const [editingEvolution, setEditingEvolution] = + useState(null) const [confirmingDelete, setConfirmingDelete] = useState(false) const isEdit = !!pokemon const pokemonId = pokemon?.id ?? null - const { data: encounterLocations, isLoading: encountersLoading } = usePokemonEncounterLocations(pokemonId) - const { data: evolutionChain, isLoading: evolutionsLoading } = usePokemonEvolutionChain(pokemonId) + const { data: encounterLocations, isLoading: encountersLoading } = + usePokemonEncounterLocations(pokemonId) + const { data: evolutionChain, isLoading: evolutionsLoading } = + usePokemonEvolutionChain(pokemonId) const queryClient = useQueryClient() const updateEvolution = useUpdateEvolution() @@ -42,7 +63,9 @@ export function PokemonFormModal({ pokemon, onSubmit, onClose, isSubmitting, onD }, [onDelete]) const invalidateChain = () => { - queryClient.invalidateQueries({ queryKey: ['pokemon', pokemonId, 'evolution-chain'] }) + queryClient.invalidateQueries({ + queryKey: ['pokemon', pokemonId, 'evolution-chain'], + }) } const handleSubmit = (e: FormEvent) => { @@ -80,7 +103,9 @@ export function PokemonFormModal({ pokemon, onSubmit, onClose, isSubmitting, onD
    {/* Header */}
    -

    {pokemon ? 'Edit Pokemon' : 'Add Pokemon'}

    +

    + {pokemon ? 'Edit Pokemon' : 'Add Pokemon'} +

    {isEdit && (
    {tabs.map((tab) => ( @@ -99,10 +124,15 @@ export function PokemonFormModal({ pokemon, onSubmit, onClose, isSubmitting, onD {/* Details tab (form) */} {activeTab === 'details' && ( - +
    - +
    - +
    - +
    - + setConfirmingDelete(false)} className="px-4 py-2 text-sm font-medium rounded-md text-red-600 dark:text-red-400 border border-red-300 dark:border-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 disabled:opacity-50" > - {isDeleting ? 'Deleting...' : confirmingDelete ? 'Confirm?' : 'Delete'} + {isDeleting + ? 'Deleting...' + : confirmingDelete + ? 'Confirm?' + : 'Delete'} )}
    @@ -197,28 +237,35 @@ export function PokemonFormModal({ pokemon, onSubmit, onClose, isSubmitting, onD
    {evolutionsLoading && ( -

    Loading...

    - )} - {!evolutionsLoading && (!evolutionChain || evolutionChain.length === 0) && ( -

    No evolutions

    - )} - {!evolutionsLoading && evolutionChain && evolutionChain.length > 0 && ( -
    - {evolutionChain.map((evo) => ( - - ))} -
    +

    + Loading... +

    )} + {!evolutionsLoading && + (!evolutionChain || evolutionChain.length === 0) && ( +

    + No evolutions +

    + )} + {!evolutionsLoading && + evolutionChain && + evolutionChain.length > 0 && ( +
    + {evolutionChain.map((evo) => ( + + ))} +
    + )}
    @@ -318,7 +337,8 @@ export function NewGenlocke() { Retire Hall of Fame
    - Pokemon that beat the Elite Four are retired and cannot be used in the next leg + Pokemon that beat the Elite Four are retired and cannot + be used in the next leg
    @@ -334,7 +354,8 @@ export function NewGenlocke() { Naming Scheme

    - Get nickname suggestions from a themed word list when catching Pokemon. Applied to all legs. + Get nickname suggestions from a themed word list when catching + Pokemon. Applied to all legs.

    @@ -384,7 +405,9 @@ export function NewGenlocke() {

    Name

    -

    {name}

    +

    + {name} +

    @@ -403,7 +426,8 @@ export function NewGenlocke() { {leg.game.name} - {leg.region.charAt(0).toUpperCase() + leg.region.slice(1)} + {leg.region.charAt(0).toUpperCase() + + leg.region.slice(1)}
    @@ -417,22 +441,29 @@ export function NewGenlocke() {
    -
    Nuzlocke Rules
    +
    + Nuzlocke Rules +
    {enabledRuleCount} of {totalRuleCount} enabled
    -
    Hall of Fame
    +
    + Hall of Fame +
    {genlockeRules.retireHoF ? 'Retire' : 'Keep'}
    -
    Naming Scheme
    +
    + Naming Scheme +
    {namingScheme - ? namingScheme.charAt(0).toUpperCase() + namingScheme.slice(1) + ? namingScheme.charAt(0).toUpperCase() + + namingScheme.slice(1) : 'None'}
    @@ -530,8 +561,18 @@ function LegRow({ className="p-1 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 disabled:opacity-30 disabled:cursor-not-allowed" title="Move up" > - - + +
    @@ -576,8 +637,18 @@ function AddLegDropdown({ onClick={() => setOpen(true)} className="flex items-center gap-2 text-sm text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 font-medium" > - - + + Add Region diff --git a/frontend/src/pages/NewRun.tsx b/frontend/src/pages/NewRun.tsx index 2ddeac3..140b493 100644 --- a/frontend/src/pages/NewRun.tsx +++ b/frontend/src/pages/NewRun.tsx @@ -47,13 +47,13 @@ export function NewRun() { if (!selectedGame) return createRun.mutate( { gameId: selectedGame.id, name: runName, rules, namingScheme }, - { onSuccess: (data) => navigate(`/runs/${data.id}`) }, + { onSuccess: (data) => navigate(`/runs/${data.id}`) } ) } - const visibleRuleKeys = RULE_DEFINITIONS - .filter((r) => !hiddenRules?.has(r.key)) - .map((r) => r.key) + const visibleRuleKeys = RULE_DEFINITIONS.filter( + (r) => !hiddenRules?.has(r.key) + ).map((r) => r.key) const enabledRuleCount = visibleRuleKeys.filter((k) => rules[k]).length const totalRuleCount = visibleRuleKeys.length @@ -84,7 +84,8 @@ export function NewRun() { {selectedGame.name}

    - {selectedGame.region.charAt(0).toUpperCase() + selectedGame.region.slice(1)} + {selectedGame.region.charAt(0).toUpperCase() + + selectedGame.region.slice(1)}

    @@ -137,7 +138,11 @@ export function NewRun() { {step === 2 && (
    - +
    )} @@ -223,7 +229,9 @@ export function NewRun() {
    Region
    - {selectedGame && (selectedGame.region.charAt(0).toUpperCase() + selectedGame.region.slice(1))} + {selectedGame && + selectedGame.region.charAt(0).toUpperCase() + + selectedGame.region.slice(1)}
    @@ -233,10 +241,13 @@ export function NewRun() {
    -
    Naming Scheme
    +
    + Naming Scheme +
    {namingScheme - ? namingScheme.charAt(0).toUpperCase() + namingScheme.slice(1) + ? namingScheme.charAt(0).toUpperCase() + + namingScheme.slice(1) : 'None'}
    diff --git a/frontend/src/pages/RunDashboard.tsx b/frontend/src/pages/RunDashboard.tsx index b328840..59fed98 100644 --- a/frontend/src/pages/RunDashboard.tsx +++ b/frontend/src/pages/RunDashboard.tsx @@ -3,12 +3,21 @@ import { useParams, Link } from 'react-router-dom' import { useRun, useUpdateRun, useNamingCategories } from '../hooks/useRuns' import { useGameRoutes } from '../hooks/useGames' import { useCreateEncounter, useUpdateEncounter } from '../hooks/useEncounters' -import { StatCard, PokemonCard, RuleBadges, StatusChangeModal, EndRunModal } from '../components' +import { + StatCard, + PokemonCard, + RuleBadges, + StatusChangeModal, + EndRunModal, +} from '../components' import type { RunStatus, EncounterDetail } from '../types' type TeamSortKey = 'route' | 'level' | 'species' | 'dex' -function sortEncounters(encounters: EncounterDetail[], key: TeamSortKey): EncounterDetail[] { +function sortEncounters( + encounters: EncounterDetail[], + key: TeamSortKey +): EncounterDetail[] { return [...encounters].sort((a, b) => { switch (key) { case 'route': @@ -21,7 +30,10 @@ function sortEncounters(encounters: EncounterDetail[], key: TeamSortKey): Encoun return nameA.localeCompare(nameB) } case 'dex': - return (a.currentPokemon ?? a.pokemon).nationalDex - (b.currentPokemon ?? b.pokemon).nationalDex + return ( + (a.currentPokemon ?? a.pokemon).nationalDex - + (b.currentPokemon ?? b.pokemon).nationalDex + ) default: return 0 } @@ -29,9 +41,9 @@ function sortEncounters(encounters: EncounterDetail[], key: TeamSortKey): Encoun } 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', + 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', } @@ -59,12 +71,24 @@ export function RunDashboard() { const encounters = run?.encounters ?? [] const alive = useMemo( - () => sortEncounters(encounters.filter((e) => e.status === 'caught' && e.faintLevel === null), teamSort), - [encounters, teamSort], + () => + 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], + () => + sortEncounters( + encounters.filter( + (e) => e.status === 'caught' && e.faintLevel !== null + ), + teamSort + ), + [encounters, teamSort] ) if (isLoading) { @@ -111,7 +135,10 @@ export function RunDashboard() { {run.name}

    - {run.game.name} · {run.game.region.charAt(0).toUpperCase() + run.game.region.slice(1)} · Started{' '} + {run.game.name} ·{' '} + {run.game.region.charAt(0).toUpperCase() + + run.game.region.slice(1)}{' '} + · Started{' '} {new Date(run.startedAt).toLocaleDateString(undefined, { year: 'numeric', month: 'short', @@ -137,7 +164,9 @@ export function RunDashboard() { }`} >

    - {run.status === 'completed' ? '\u{1f3c6}' : '\u{1faa6}'} + + {run.status === 'completed' ? '\u{1f3c6}' : '\u{1faa6}'} +

    {run.namingScheme - ? run.namingScheme.charAt(0).toUpperCase() + run.namingScheme.slice(1) + ? run.namingScheme.charAt(0).toUpperCase() + + run.namingScheme.slice(1) : 'None'} )} @@ -329,7 +359,7 @@ export function RunDashboard() { onConfirm={(status) => { updateRun.mutate( { status }, - { onSuccess: () => setShowEndRun(false) }, + { onSuccess: () => setShowEndRun(false) } ) }} onClose={() => setShowEndRun(false)} diff --git a/frontend/src/pages/RunEncounters.tsx b/frontend/src/pages/RunEncounters.tsx index 2693a95..6a592cf 100644 --- a/frontend/src/pages/RunEncounters.tsx +++ b/frontend/src/pages/RunEncounters.tsx @@ -3,9 +3,17 @@ import { useParams, Link, useNavigate } from 'react-router-dom' import { useRun, useUpdateRun } from '../hooks/useRuns' import { useAdvanceLeg } from '../hooks/useGenlockes' import { useGameRoutes } from '../hooks/useGames' -import { useCreateEncounter, useUpdateEncounter, useBulkRandomize } from '../hooks/useEncounters' +import { + useCreateEncounter, + useUpdateEncounter, + useBulkRandomize, +} from '../hooks/useEncounters' import { usePokemonFamilies } from '../hooks/usePokemon' -import { useGameBosses, useBossResults, useCreateBossResult } from '../hooks/useBosses' +import { + useGameBosses, + useBossResults, + useCreateBossResult, +} from '../hooks/useBosses' import { EggEncounterModal, EncounterModal, @@ -35,7 +43,10 @@ import type { type TeamSortKey = 'route' | 'level' | 'species' | 'dex' -function sortEncounters(encounters: EncounterDetail[], key: TeamSortKey): EncounterDetail[] { +function sortEncounters( + encounters: EncounterDetail[], + key: TeamSortKey +): EncounterDetail[] { return [...encounters].sort((a, b) => { switch (key) { case 'route': @@ -48,7 +59,10 @@ function sortEncounters(encounters: EncounterDetail[], key: TeamSortKey): Encoun return nameA.localeCompare(nameB) } case 'dex': - return (a.currentPokemon ?? a.pokemon).nationalDex - (b.currentPokemon ?? b.pokemon).nationalDex + return ( + (a.currentPokemon ?? a.pokemon).nationalDex - + (b.currentPokemon ?? b.pokemon).nationalDex + ) default: return 0 } @@ -56,9 +70,9 @@ function sortEncounters(encounters: EncounterDetail[], key: TeamSortKey): Encoun } 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', + 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', } @@ -129,7 +143,7 @@ function organizeRoutes(routes: Route[]): RouteWithChildren[] { */ function getGroupEncounter( group: RouteWithChildren, - encounterByRoute: Map, + encounterByRoute: Map ): EncounterDetail | null { for (const child of group.children) { const enc = encounterByRoute.get(child.id) @@ -154,7 +168,7 @@ function effectiveZone(route: Route): number { */ function getZoneEncounters( group: RouteWithChildren, - encounterByRoute: Map, + encounterByRoute: Map ): Map { const zoneMap = new Map() for (const child of group.children) { @@ -172,14 +186,23 @@ function countDistinctZones(group: RouteWithChildren): number { return zones.size } -function matchVariant(labels: string[], starterName?: string | null): string | null { +function matchVariant( + labels: string[], + starterName?: string | null +): string | null { if (!starterName || labels.length === 0) return null const lower = starterName.toLowerCase() const matches = labels.filter((l) => l.toLowerCase().includes(lower)) return matches.length === 1 ? matches[0] : null } -function BossTeamPreview({ pokemon, starterName }: { pokemon: BossPokemon[]; starterName?: string | null }) { +function BossTeamPreview({ + pokemon, + starterName, +}: { + pokemon: BossPokemon[] + starterName?: string | null +}) { const variantLabels = useMemo(() => { const labels = new Set() for (const bp of pokemon) { @@ -189,16 +212,20 @@ function BossTeamPreview({ pokemon, starterName }: { pokemon: BossPokemon[]; sta }, [pokemon]) const hasVariants = variantLabels.length > 0 - const autoMatch = useMemo(() => matchVariant(variantLabels, starterName), [variantLabels, starterName]) + const autoMatch = useMemo( + () => matchVariant(variantLabels, starterName), + [variantLabels, starterName] + ) const showPills = hasVariants && autoMatch === null const [selectedVariant, setSelectedVariant] = useState( - autoMatch ?? (hasVariants ? variantLabels[0] : null), + autoMatch ?? (hasVariants ? variantLabels[0] : null) ) const displayed = useMemo(() => { if (!hasVariants) return pokemon return pokemon.filter( - (bp) => bp.conditionLabel === selectedVariant || bp.conditionLabel === null, + (bp) => + bp.conditionLabel === selectedVariant || bp.conditionLabel === null ) }, [pokemon, hasVariants, selectedVariant]) @@ -228,7 +255,11 @@ function BossTeamPreview({ pokemon, starterName }: { pokemon: BossPokemon[]; sta .map((bp) => (

    {bp.pokemon.spriteUrl ? ( - {bp.pokemon.name} + {bp.pokemon.name} ) : (
    )} @@ -420,7 +451,7 @@ export function RunEncounters() { const advanceLeg = useAdvanceLeg() const [showTransferModal, setShowTransferModal] = useState(false) const { data: routes, isLoading: loadingRoutes } = useGameRoutes( - run?.gameId ?? null, + run?.gameId ?? null ) const createEncounter = useCreateEncounter(runIdNum) const updateEncounter = useUpdateEncounter(runIdNum) @@ -451,7 +482,9 @@ export function RunEncounters() { try { const saved = localStorage.getItem(storageKey) if (saved) return new Set(JSON.parse(saved) as number[]) - } catch { /* ignore */ } + } catch { + /* ignore */ + } return new Set() }) @@ -463,7 +496,7 @@ export function RunEncounters() { return next }) }, - [storageKey], + [storageKey] ) // Organize routes into hierarchical structure @@ -475,25 +508,35 @@ export function RunEncounters() { // Split encounters into normal (non-shiny) and shiny const transferIdSet = useMemo( () => new Set(run?.transferEncounterIds ?? []), - [run?.transferEncounterIds], + [run?.transferEncounterIds] ) - const { normalEncounters, shinyEncounters, transferEncounters } = useMemo(() => { - if (!run) return { normalEncounters: [], shinyEncounters: [], transferEncounters: [] } - const normal: EncounterDetail[] = [] - const shiny: EncounterDetail[] = [] - const transfer: EncounterDetail[] = [] - for (const enc of run.encounters) { - if (transferIdSet.has(enc.id)) { - transfer.push(enc) - } else if (enc.isShiny) { - shiny.push(enc) - } else { - normal.push(enc) + const { normalEncounters, shinyEncounters, transferEncounters } = + useMemo(() => { + if (!run) + return { + normalEncounters: [], + shinyEncounters: [], + transferEncounters: [], + } + const normal: EncounterDetail[] = [] + const shiny: EncounterDetail[] = [] + const transfer: EncounterDetail[] = [] + for (const enc of run.encounters) { + if (transferIdSet.has(enc.id)) { + transfer.push(enc) + } else if (enc.isShiny) { + shiny.push(enc) + } else { + normal.push(enc) + } } - } - return { normalEncounters: normal, shinyEncounters: shiny, transferEncounters: transfer } - }, [run, transferIdSet]) + return { + normalEncounters: normal, + shinyEncounters: shiny, + transferEncounters: transfer, + } + }, [run, transferIdSet]) // Map routeId → encounter for quick lookup (normal encounters only) const encounterByRoute = useMemo(() => { @@ -635,8 +678,7 @@ export function RunEncounters() { if (organizedRoutes.length === 0 || expandedGroups.size > 0) return const firstUnvisited = organizedRoutes.find( (r) => - r.children.length > 0 && - getGroupEncounter(r, encounterByRoute) === null, + r.children.length > 0 && getGroupEncounter(r, encounterByRoute) === null ) if (firstUnvisited) { updateExpandedGroups(() => new Set([firstUnvisited.id])) @@ -644,21 +686,25 @@ export function RunEncounters() { }, [organizedRoutes, encounterByRoute]) // eslint-disable-line react-hooks/exhaustive-deps const alive = useMemo( - () => sortEncounters( - [...normalEncounters, ...transferEncounters, ...shinyEncounters].filter( - (e) => e.status === 'caught' && e.faintLevel === null, + () => + sortEncounters( + [...normalEncounters, ...transferEncounters, ...shinyEncounters].filter( + (e) => e.status === 'caught' && e.faintLevel === null + ), + teamSort ), - teamSort, - ), - [normalEncounters, transferEncounters, shinyEncounters, teamSort], + [normalEncounters, transferEncounters, shinyEncounters, teamSort] ) const dead = useMemo( - () => sortEncounters( - normalEncounters.filter((e) => e.status === 'caught' && e.faintLevel !== null), - teamSort, - ), - [normalEncounters, teamSort], + () => + sortEncounters( + normalEncounters.filter( + (e) => e.status === 'caught' && e.faintLevel !== null + ), + teamSort + ), + [normalEncounters, teamSort] ) // Resolve HoF team encounters from IDs @@ -810,7 +856,10 @@ export function RunEncounters() { {run.name}

    - {run.game.name} · {run.game.region.charAt(0).toUpperCase() + run.game.region.slice(1)} · Started{' '} + {run.game.name} ·{' '} + {run.game.region.charAt(0).toUpperCase() + + run.game.region.slice(1)}{' '} + · Started{' '} {new Date(run.startedAt).toLocaleDateString(undefined, { year: 'numeric', month: 'short', @@ -819,7 +868,8 @@ export function RunEncounters() {

    {run.genlocke && (

    - Leg {run.genlocke.legOrder} of {run.genlocke.totalLegs} — {run.genlocke.genlockeName} + Leg {run.genlocke.legOrder} of {run.genlocke.totalLegs} —{' '} + {run.genlocke.genlockeName}

    )}
    @@ -868,7 +918,9 @@ export function RunEncounters() { >
    - {run.status === 'completed' ? '\u{1f3c6}' : '\u{1faa6}'} + + {run.status === 'completed' ? '\u{1f3c6}' : '\u{1faa6}'} +

    - {run.status === 'completed' && run.genlocke && !run.genlocke.isFinalLeg && ( - - )} + { + onSuccess: (genlocke) => { + const nextLeg = genlocke.legs.find( + (l) => l.legOrder === run.genlocke!.legOrder + 1 + ) + if (nextLeg?.runId) { + navigate(`/runs/${nextLeg.runId}`) + } + }, + } + ) + } + }} + disabled={advanceLeg.isPending} + className="px-4 py-2 text-sm font-medium rounded-lg bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50 transition-colors" + > + {advanceLeg.isPending + ? 'Advancing...' + : 'Advance to Next Leg'} + + )}
    {/* HoF Team Display */} {run.status === 'completed' && ( @@ -957,7 +1016,11 @@ export function RunEncounters() { return (
    {dp.spriteUrl ? ( - {dp.name} + {dp.name} ) : (
    {dp.name[0].toUpperCase()} @@ -1040,11 +1103,13 @@ export function RunEncounters() { className="w-6 h-6" /> ) : ( -
    +
    {boss.order}
    )} @@ -1077,7 +1142,8 @@ export function RunEncounters() { {isActive ? 'Team' : 'Final Team'} - {alive.length} alive{dead.length > 0 ? `, ${dead.length} dead` : ''} + {alive.length} alive + {dead.length > 0 ? `, ${dead.length} dead` : ''} setSelectedTeamEncounter(enc) : undefined} + onClick={ + isActive + ? () => setSelectedTeamEncounter(enc) + : undefined + } /> ))}
    @@ -1130,7 +1200,11 @@ export function RunEncounters() { key={enc.id} encounter={enc} showFaintLevel - onClick={isActive ? () => setSelectedTeamEncounter(enc) : undefined} + onClick={ + isActive + ? () => setSelectedTeamEncounter(enc) + : undefined + } /> ))}
    @@ -1146,7 +1220,9 @@ export function RunEncounters() {
    setSelectedTeamEncounter(enc) : undefined} + onEncounterClick={ + isActive ? (enc) => setSelectedTeamEncounter(enc) : undefined + } />
    )} @@ -1162,7 +1238,9 @@ export function RunEncounters() { setSelectedTeamEncounter(enc) : undefined} + onClick={ + isActive ? () => setSelectedTeamEncounter(enc) : undefined + } /> ))}
    @@ -1182,7 +1260,11 @@ export function RunEncounters() { disabled={bulkRandomize.isPending} onClick={() => { const remaining = totalLocations - completedCount - if (window.confirm(`Randomize encounters for all ${remaining} remaining locations?`)) { + if ( + window.confirm( + `Randomize encounters for all ${remaining} remaining locations?` + ) + ) { bulkRandomize.mutate() } }} @@ -1242,9 +1324,10 @@ export function RunEncounters() { )} {filteredRoutes.map((route) => { // Collect all route IDs to check for boss cards after - const routeIds: number[] = route.children.length > 0 - ? [route.id, ...route.children.map((c) => c.id)] - : [route.id] + const routeIds: number[] = + route.children.length > 0 + ? [route.id, ...route.children.map((c) => c.id)] + : [route.id] // Find boss battles positioned after this route (or any of its children) const bossesHere: BossBattle[] = [] @@ -1253,68 +1336,77 @@ export function RunEncounters() { if (b) bossesHere.push(...b) } - const routeElement = route.children.length > 0 ? ( - toggleGroup(route.id)} - onRouteClick={handleRouteClick} - filter={filter} - pinwheelClause={pinwheelClause} - /> - ) : (() => { - const encounter = encounterByRoute.get(route.id) - const rs = getRouteStatus(encounter) - const si = statusIndicator[rs] - - return ( -
    - - {si.label} - - + + {si.label} + + + ) + })() ) - })() return (
    @@ -1358,67 +1450,83 @@ export function RunEncounters() {
    -
    -
    - - - - {boss.spriteUrl && ( - {boss.name} - )} -
    -
    - - {boss.name} - - - {bossTypeLabel[boss.bossType] ?? boss.bossType} - - {boss.specialtyType && ( - - )} +
    +
    + + + + {boss.spriteUrl && ( + {boss.name} + )} +
    +
    + + {boss.name} + + + {bossTypeLabel[boss.bossType] ?? boss.bossType} + + {boss.specialtyType && ( + + )} +
    +

    + {boss.location} · Level Cap:{' '} + {boss.levelCap} +

    -

    - {boss.location} · Level Cap: {boss.levelCap} -

    +
    +
    e.stopPropagation()}> + {isDefeated ? ( + + Defeated ✓ + + ) : isActive ? ( + + ) : null}
    -
    e.stopPropagation()}> - {isDefeated ? ( - - Defeated ✓ - - ) : isActive ? ( - - ) : null} -
    -
    - {/* Boss pokemon team */} - {isBossExpanded && boss.pokemon.length > 0 && ( - - )} + {/* Boss pokemon team */} + {isBossExpanded && boss.pokemon.length > 0 && ( + + )}
    {sectionAfter && (
    - {sectionAfter} + + {sectionAfter} +
    )} @@ -1519,7 +1627,7 @@ export function RunEncounters() { setShowHofModal(true) } }, - }, + } ) }} onClose={() => setShowEndRun(false)} @@ -1535,7 +1643,7 @@ export function RunEncounters() { onSubmit={(encounterIds) => { updateRun.mutate( { hofEncounterIds: encounterIds }, - { onSuccess: () => setShowHofModal(false) }, + { onSuccess: () => setShowHofModal(false) } ) }} onSkip={() => setShowHofModal(false)} @@ -1558,13 +1666,13 @@ export function RunEncounters() { onSuccess: (genlocke) => { setShowTransferModal(false) const nextLeg = genlocke.legs.find( - (l) => l.legOrder === run!.genlocke!.legOrder + 1, + (l) => l.legOrder === run!.genlocke!.legOrder + 1 ) if (nextLeg?.runId) { navigate(`/runs/${nextLeg.runId}`) } }, - }, + } ) }} onSkip={() => { @@ -1577,13 +1685,13 @@ export function RunEncounters() { onSuccess: (genlocke) => { setShowTransferModal(false) const nextLeg = genlocke.legs.find( - (l) => l.legOrder === run!.genlocke!.legOrder + 1, + (l) => l.legOrder === run!.genlocke!.legOrder + 1 ) if (nextLeg?.runId) { navigate(`/runs/${nextLeg.runId}`) } }, - }, + } ) }} isPending={advanceLeg.isPending} diff --git a/frontend/src/pages/RunList.tsx b/frontend/src/pages/RunList.tsx index 492033f..cc2cae0 100644 --- a/frontend/src/pages/RunList.tsx +++ b/frontend/src/pages/RunList.tsx @@ -3,9 +3,9 @@ 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', + 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', } diff --git a/frontend/src/pages/Stats.tsx b/frontend/src/pages/Stats.tsx index e78c736..5ba0061 100644 --- a/frontend/src/pages/Stats.tsx +++ b/frontend/src/pages/Stats.tsx @@ -178,15 +178,25 @@ function StatsContent({ stats }: { stats: StatsResponse }) {
    - +
    - Win Rate: {pct(stats.winRate)} + Win Rate:{' '} + + {pct(stats.winRate)} + - Avg Duration: {fmt(stats.avgDurationDays, ' days')} + Avg Duration:{' '} + + {fmt(stats.avgDurationDays, ' days')} +
    @@ -233,10 +243,16 @@ function StatsContent({ stats }: { stats: StatsResponse }) {
    - Catch Rate: {pct(stats.catchRate)} + Catch Rate:{' '} + + {pct(stats.catchRate)} + - Avg per Run: {fmt(stats.avgEncountersPerRun)} + Avg per Run:{' '} + + {fmt(stats.avgEncountersPerRun)} +
    @@ -244,10 +260,7 @@ function StatsContent({ stats }: { stats: StatsResponse }) { {/* Pokemon Rankings */}
    - +
    - +
    {pct(stats.mortalityRate)}
    -
    Mortality Rate
    +
    + Mortality Rate +
    {fmt(stats.avgCatchLevel)}
    -
    Avg Catch Lv.
    +
    + Avg Catch Lv. +
    {fmt(stats.avgFaintLevel)}
    -
    Avg Faint Lv.
    +
    + Avg Faint Lv. +
    @@ -347,7 +370,9 @@ export function Stats() { {stats && stats.totalRuns === 0 && (

    No data yet

    -

    Start a Nuzlocke run to see your stats here.

    +

    + Start a Nuzlocke run to see your stats here. +

    )} diff --git a/frontend/src/pages/admin/AdminEvolutions.tsx b/frontend/src/pages/admin/AdminEvolutions.tsx index 4ca8252..65fbb3b 100644 --- a/frontend/src/pages/admin/AdminEvolutions.tsx +++ b/frontend/src/pages/admin/AdminEvolutions.tsx @@ -11,7 +11,11 @@ import { } from '../../hooks/useAdmin' import { exportEvolutions } from '../../api/admin' import { downloadJson } from '../../utils/download' -import type { EvolutionAdmin, CreateEvolutionInput, UpdateEvolutionInput } from '../../types' +import type { + EvolutionAdmin, + CreateEvolutionInput, + UpdateEvolutionInput, +} from '../../types' const PAGE_SIZE = 50 @@ -28,7 +32,12 @@ export function AdminEvolutions() { const [triggerFilter, setTriggerFilter] = useState('') const [page, setPage] = useState(0) const offset = page * PAGE_SIZE - const { data, isLoading } = useEvolutionList(search || undefined, PAGE_SIZE, offset, triggerFilter || undefined) + const { data, isLoading } = useEvolutionList( + search || undefined, + PAGE_SIZE, + offset, + triggerFilter || undefined + ) const evolutions = data?.items ?? [] const total = data?.total ?? 0 const totalPages = Math.ceil(total / PAGE_SIZE) @@ -120,12 +129,18 @@ export function AdminEvolutions() { > {EVOLUTION_TRIGGERS.map((t) => ( - + ))} {(search || triggerFilter) && ( - {group.order} + + {group.order} + {group.name} {group.pinwheelZone != null ? group.pinwheelZone : '\u2014'} @@ -138,7 +155,9 @@ function SortableRouteGroup({ {child.order} - {'\u2514'} + + {'\u2514'} + {child.name} @@ -172,8 +191,14 @@ function SortableBossRow({ onPositionChange: (bossId: number, afterRouteId: number | null) => void onClick: (b: BossBattle) => void }) { - const { attributes, listeners, setNodeRef, transform, transition, isDragging } = - useSortable({ id: boss.id }) + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id: boss.id }) const style = { transform: CSS.Transform.toString(transform), @@ -208,22 +233,29 @@ function SortableBossRow({ {boss.order} {boss.name} - {boss.gameId != null && (() => { - const g = games.find((g) => g.id === boss.gameId) - return g ? ( - - {g.name} - - ) : null - })()} + {boss.gameId != null && + (() => { + const g = games.find((g) => g.id === boss.gameId) + return g ? ( + + {g.name} + + ) : null + })()} {boss.bossType.replace('_', ' ')} - {boss.specialtyType ? : '\u2014'} + {boss.specialtyType ? ( + + ) : ( + '\u2014' + )} + + + {boss.section ?? '\u2014'} - {boss.section ?? '\u2014'} {boss.location} {(regionFilter || genFilter) && (