Align repo config with global development standards
Some checks failed
CI / backend-lint (push) Failing after 1m4s
CI / actions-lint (push) Failing after 6s
CI / frontend-lint (push) Successful in 59s

- Add missing tsconfig strictness flags (noUncheckedIndexedAccess,
  exactOptionalPropertyTypes, noImplicitOverride,
  noPropertyAccessFromIndexSignature) and fix all resulting type errors
- Replace ESLint/Prettier with oxlint 1.48.0 and oxfmt 0.33.0
- Pin all frontend and backend dependencies to exact versions
- Pin GitHub Actions to SHA hashes with persist-credentials: false
- Fix CI Python version mismatch (3.12 -> 3.14) and ruff target-version
- Add vitest 4.0.18 with jsdom environment for frontend testing
- Add ty 0.0.17 for Python type checking (non-blocking in CI)
- Add actionlint and zizmor CI job for workflow linting and security audit
- Add Dependabot config for npm, pip, and github-actions
- Update CLAUDE.md and pre-commit hooks to reflect new tooling
- Ignore Claude Code sandbox artifacts in gitignore

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-16 20:39:41 +01:00
parent e4814250db
commit 3a64661760
91 changed files with 2073 additions and 3215 deletions

View File

@@ -3,17 +3,9 @@ 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,
@@ -43,10 +35,7 @@ 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':
@@ -60,8 +49,7 @@ function sortEncounters(
}
case 'dex':
return (
(a.currentPokemon ?? a.pokemon).nationalDex -
(b.currentPokemon ?? b.pokemon).nationalDex
(a.currentPokemon ?? a.pokemon).nationalDex - (b.currentPokemon ?? b.pokemon).nationalDex
)
default:
return 0
@@ -70,8 +58,7 @@ function sortEncounters(
}
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: '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',
}
@@ -91,10 +78,7 @@ function getRouteStatus(encounter?: EncounterDetail): RouteStatus {
return encounter.status
}
const statusIndicator: Record<
RouteStatus,
{ dot: string; label: string; bg: string }
> = {
const statusIndicator: Record<RouteStatus, { dot: string; label: string; bg: string }> = {
caught: {
dot: 'bg-green-500',
label: 'Caught',
@@ -186,14 +170,11 @@ 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
return matches.length === 1 ? (matches[0] ?? null) : null
}
function BossTeamPreview({
@@ -218,14 +199,13 @@ function BossTeamPreview({
)
const showPills = hasVariants && autoMatch === null
const [selectedVariant, setSelectedVariant] = useState<string | null>(
autoMatch ?? (hasVariants ? variantLabels[0] : null)
autoMatch ?? (hasVariants ? (variantLabels[0] ?? null) : 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])
@@ -255,17 +235,11 @@ function BossTeamPreview({
.map((bp) => (
<div key={bp.id} className="flex items-center gap-1">
{bp.pokemon.spriteUrl ? (
<img
src={bp.pokemon.spriteUrl}
alt={bp.pokemon.name}
className="w-20 h-20"
/>
<img src={bp.pokemon.spriteUrl} alt={bp.pokemon.name} className="w-20 h-20" />
) : (
<div className="w-20 h-20 bg-gray-200 dark:bg-gray-700 rounded-full" />
)}
<span className="text-xs text-gray-500 dark:text-gray-400">
Lvl {bp.level}
</span>
<span className="text-xs text-gray-500 dark:text-gray-400">Lvl {bp.level}</span>
</div>
))}
</div>
@@ -294,9 +268,7 @@ function RouteGroup({
}: RouteGroupProps) {
const groupEncounter = getGroupEncounter(group, encounterByRoute)
const usePinwheel = pinwheelClause && groupHasZones(group)
const zoneEncounters = usePinwheel
? getZoneEncounters(group, encounterByRoute)
: null
const zoneEncounters = usePinwheel ? getZoneEncounters(group, encounterByRoute) : null
// For pinwheel groups, determine status from all zone statuses
let groupStatus: RouteStatus
@@ -354,28 +326,19 @@ function RouteGroup({
{groupEncounter.nickname ?? groupEncounter.pokemon.name}
{groupEncounter.status === 'caught' &&
groupEncounter.faintLevel !== null &&
(groupEncounter.deathCause
? `${groupEncounter.deathCause}`
: ' (dead)')}
(groupEncounter.deathCause ? `${groupEncounter.deathCause}` : ' (dead)')}
</span>
</div>
)}
</div>
<span className="text-xs text-gray-400 dark:text-gray-500 shrink-0">
{si.label}
</span>
<span className="text-xs text-gray-400 dark:text-gray-500 shrink-0">{si.label}</span>
<svg
className={`w-4 h-4 text-gray-400 transition-transform ${isExpanded ? '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"
/>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
@@ -409,13 +372,9 @@ function RouteGroup({
: 'hover:bg-gray-100 dark:hover:bg-gray-700/50'
} ${childSi.bg}`}
>
<span
className={`w-2 h-2 rounded-full shrink-0 ${childSi.dot}`}
/>
<span className={`w-2 h-2 rounded-full shrink-0 ${childSi.dot}`} />
<div className="flex-1 min-w-0">
<div className="text-sm text-gray-700 dark:text-gray-300">
{child.name}
</div>
<div className="text-sm text-gray-700 dark:text-gray-300">{child.name}</div>
{!childEncounter && child.encounterMethods.length > 0 && (
<div className="flex flex-wrap gap-1 mt-0.5">
{child.encounterMethods.map((m) => (
@@ -425,14 +384,10 @@ function RouteGroup({
)}
</div>
{childEncounter && (
<span className="text-xs text-gray-400 dark:text-gray-500">
{childSi.label}
</span>
<span className="text-xs text-gray-400 dark:text-gray-500">{childSi.label}</span>
)}
{isDisabled && (
<span className="text-xs text-gray-400 dark:text-gray-500 italic">
(locked)
</span>
<span className="text-xs text-gray-400 dark:text-gray-500 italic">(locked)</span>
)}
</button>
)
@@ -450,9 +405,7 @@ export function RunEncounters() {
const { data: run, isLoading, error } = useRun(runIdNum)
const advanceLeg = useAdvanceLeg()
const [showTransferModal, setShowTransferModal] = useState(false)
const { data: routes, isLoading: loadingRoutes } = useGameRoutes(
run?.gameId ?? null
)
const { data: routes, isLoading: loadingRoutes } = useGameRoutes(run?.gameId ?? null)
const createEncounter = useCreateEncounter(runIdNum)
const updateEncounter = useUpdateEncounter(runIdNum)
const bulkRandomize = useBulkRandomize(runIdNum)
@@ -464,10 +417,8 @@ export function RunEncounters() {
const [selectedRoute, setSelectedRoute] = useState<Route | null>(null)
const [selectedBoss, setSelectedBoss] = useState<BossBattle | null>(null)
const [editingEncounter, setEditingEncounter] =
useState<EncounterDetail | null>(null)
const [selectedTeamEncounter, setSelectedTeamEncounter] =
useState<EncounterDetail | null>(null)
const [editingEncounter, setEditingEncounter] = useState<EncounterDetail | null>(null)
const [selectedTeamEncounter, setSelectedTeamEncounter] = useState<EncounterDetail | null>(null)
const [showEndRun, setShowEndRun] = useState(false)
const [showHofModal, setShowHofModal] = useState(false)
const [showShinyModal, setShowShinyModal] = useState(false)
@@ -511,32 +462,31 @@ export function RunEncounters() {
[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: normal,
shinyEncounters: shiny,
transferEncounters: transfer,
normalEncounters: [],
shinyEncounters: [],
transferEncounters: [],
}
}, [run, transferIdSet])
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])
// Map routeId → encounter for quick lookup (normal encounters only)
const encounterByRoute = useMemo(() => {
@@ -638,9 +588,7 @@ export function RunEncounters() {
const currentLevelCap = useMemo(() => {
if (!nextBoss) {
// All defeated — no cap (or use last boss's level)
return sortedBosses.length > 0
? sortedBosses[sortedBosses.length - 1].levelCap
: null
return sortedBosses.length > 0 ? sortedBosses[sortedBosses.length - 1]!.levelCap : null
}
return nextBoss.levelCap
}, [nextBoss, sortedBosses])
@@ -650,8 +598,8 @@ export function RunEncounters() {
const sectionDividerAfterBoss = useMemo(() => {
const map = new Map<number, string>()
for (let i = 0; i < sortedBosses.length - 1; i++) {
const current = sortedBosses[i]
const next = sortedBosses[i + 1]
const current = sortedBosses[i]!
const next = sortedBosses[i + 1]!
if (next.section != null && current.section !== next.section) {
map.set(current.id, next.section)
}
@@ -677,8 +625,7 @@ export function RunEncounters() {
useEffect(() => {
if (organizedRoutes.length === 0 || expandedGroups.size > 0) return
const firstUnvisited = organizedRoutes.find(
(r) =>
r.children.length > 0 && getGroupEncounter(r, encounterByRoute) === null
(r) => r.children.length > 0 && getGroupEncounter(r, encounterByRoute) === null
)
if (firstUnvisited) {
updateExpandedGroups(() => new Set([firstUnvisited.id]))
@@ -699,9 +646,7 @@ export function RunEncounters() {
const dead = useMemo(
() =>
sortEncounters(
normalEncounters.filter(
(e) => e.status === 'caught' && e.faintLevel !== null
),
normalEncounters.filter((e) => e.status === 'caught' && e.faintLevel !== null),
teamSort
),
[normalEncounters, teamSort]
@@ -728,10 +673,7 @@ export function RunEncounters() {
<div className="rounded-lg bg-red-50 dark:bg-red-900/20 p-4 text-red-700 dark:text-red-400">
Failed to load run.
</div>
<Link
to="/runs"
className="inline-block mt-4 text-blue-600 hover:underline"
>
<Link to="/runs" className="inline-block mt-4 text-blue-600 hover:underline">
Back to runs
</Link>
</div>
@@ -803,10 +745,10 @@ export function RunEncounters() {
const handleUpdate = (data: {
id: number
data: {
nickname?: string
status?: EncounterStatus
faintLevel?: number
deathCause?: string
nickname?: string | undefined
status?: EncounterStatus | undefined
faintLevel?: number | undefined
deathCause?: string | undefined
}
}) => {
updateEncounter.mutate(data, {
@@ -852,14 +794,10 @@ export function RunEncounters() {
</Link>
<div className="flex items-start justify-between">
<div>
<h1 className="text-3xl font-bold text-gray-900 dark:text-gray-100">
{run.name}
</h1>
<h1 className="text-3xl font-bold text-gray-900 dark:text-gray-100">{run.name}</h1>
<p className="text-gray-600 dark:text-gray-400 mt-1">
{run.game.name} &middot;{' '}
{run.game.region.charAt(0).toUpperCase() +
run.game.region.slice(1)}{' '}
&middot; Started{' '}
{run.game.region.charAt(0).toUpperCase() + run.game.region.slice(1)} &middot; Started{' '}
{new Date(run.startedAt).toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
@@ -959,40 +897,36 @@ export function RunEncounters() {
</p>
</div>
</div>
{run.status === 'completed' &&
run.genlocke &&
!run.genlocke.isFinalLeg && (
<button
onClick={() => {
if (hofTeam && hofTeam.length > 0) {
setShowTransferModal(true)
} else {
advanceLeg.mutate(
{
genlockeId: run.genlocke!.genlockeId,
legOrder: run.genlocke!.legOrder,
{run.status === 'completed' && run.genlocke && !run.genlocke.isFinalLeg && (
<button
onClick={() => {
if (hofTeam && hofTeam.length > 0) {
setShowTransferModal(true)
} else {
advanceLeg.mutate(
{
genlockeId: run.genlocke!.genlockeId,
legOrder: run.genlocke!.legOrder,
},
{
onSuccess: (genlocke) => {
const nextLeg = genlocke.legs.find(
(l) => l.legOrder === run.genlocke!.legOrder + 1
)
if (nextLeg?.runId) {
navigate(`/runs/${nextLeg.runId}`)
}
},
{
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'}
</button>
)}
}
)
}
}}
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'}
</button>
)}
</div>
{/* HoF Team Display */}
{run.status === 'completed' && (
@@ -1016,14 +950,10 @@ export function RunEncounters() {
return (
<div key={enc.id} className="flex flex-col items-center">
{dp.spriteUrl ? (
<img
src={dp.spriteUrl}
alt={dp.name}
className="w-12 h-12"
/>
<img src={dp.spriteUrl} alt={dp.name} className="w-12 h-12" />
) : (
<div className="w-12 h-12 rounded-full bg-gray-200 dark:bg-gray-600 flex items-center justify-center text-sm font-bold">
{dp.name[0].toUpperCase()}
{dp.name[0]?.toUpperCase()}
</div>
)}
<span className="text-[10px] text-blue-600 dark:text-blue-400 capitalize mt-0.5">
@@ -1045,19 +975,10 @@ export function RunEncounters() {
{/* Stats */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 mb-6">
<StatCard
label="Encounters"
value={normalEncounters.length}
color="blue"
/>
<StatCard label="Encounters" value={normalEncounters.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"
/>
<StatCard label="Routes" value={completedCount} total={totalLocations} color="purple" />
</div>
{/* Level Cap Bar */}
@@ -1123,9 +1044,7 @@ export function RunEncounters() {
{/* Rules */}
<div className="mb-6">
<h2 className="text-sm font-medium text-gray-500 dark:text-gray-400 mb-2">
Active Rules
</h2>
<h2 className="text-sm font-medium text-gray-500 dark:text-gray-400 mb-2">Active Rules</h2>
<RuleBadges rules={run.rules} />
</div>
@@ -1180,11 +1099,7 @@ export function RunEncounters() {
<PokemonCard
key={enc.id}
encounter={enc}
onClick={
isActive
? () => setSelectedTeamEncounter(enc)
: undefined
}
onClick={isActive ? () => setSelectedTeamEncounter(enc) : undefined}
/>
))}
</div>
@@ -1200,11 +1115,7 @@ export function RunEncounters() {
key={enc.id}
encounter={enc}
showFaintLevel
onClick={
isActive
? () => setSelectedTeamEncounter(enc)
: undefined
}
onClick={isActive ? () => setSelectedTeamEncounter(enc) : undefined}
/>
))}
</div>
@@ -1220,9 +1131,7 @@ export function RunEncounters() {
<div className="mb-6">
<ShinyBox
encounters={shinyEncounters}
onEncounterClick={
isActive ? (enc) => setSelectedTeamEncounter(enc) : undefined
}
onEncounterClick={isActive ? (enc) => setSelectedTeamEncounter(enc) : undefined}
/>
</div>
)}
@@ -1238,9 +1147,7 @@ export function RunEncounters() {
<PokemonCard
key={enc.id}
encounter={enc}
onClick={
isActive ? () => setSelectedTeamEncounter(enc) : undefined
}
onClick={isActive ? () => setSelectedTeamEncounter(enc) : undefined}
/>
))}
</div>
@@ -1251,9 +1158,7 @@ export function RunEncounters() {
<div className="mb-4">
<div className="flex items-center justify-between mb-1">
<div className="flex items-center gap-3">
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
Encounters
</h2>
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">Encounters</h2>
{isActive && completedCount < totalLocations && (
<button
type="button"
@@ -1261,9 +1166,7 @@ export function RunEncounters() {
onClick={() => {
const remaining = totalLocations - completedCount
if (
window.confirm(
`Randomize encounters for all ${remaining} remaining locations?`
)
window.confirm(`Randomize encounters for all ${remaining} remaining locations?`)
) {
bulkRandomize.mutate()
}
@@ -1325,9 +1228,7 @@ 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]
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[] = []
@@ -1361,9 +1262,7 @@ export function RunEncounters() {
onClick={() => handleRouteClick(route)}
className={`w-full flex items-center gap-3 px-4 py-3 rounded-lg text-left transition-colors hover:bg-gray-100 dark:hover:bg-gray-700/50 ${si.bg}`}
>
<span
className={`w-2.5 h-2.5 rounded-full shrink-0 ${si.dot}`}
/>
<span className={`w-2.5 h-2.5 rounded-full shrink-0 ${si.dot}`} />
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
{route.name}
@@ -1381,20 +1280,14 @@ export function RunEncounters() {
{encounter.nickname ?? encounter.pokemon.name}
{encounter.status === 'caught' &&
encounter.faintLevel !== null &&
(encounter.deathCause
? `${encounter.deathCause}`
: ' (dead)')}
(encounter.deathCause ? `${encounter.deathCause}` : ' (dead)')}
</span>
</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"
/>
<EncounterMethodBadge key={m} method={m} size="xs" />
))}
</div>
)
@@ -1449,7 +1342,7 @@ export function RunEncounters() {
return (
<div key={`boss-${boss.id}`}>
<div
className={`my-2 rounded-lg border-2 ${bossTypeColors[boss.bossType] ?? bossTypeColors.other} ${
className={`my-2 rounded-lg border-2 ${bossTypeColors[boss.bossType] ?? bossTypeColors['other']} ${
isDefeated
? 'bg-green-50/50 dark:bg-green-900/10'
: 'bg-white dark:bg-gray-800'
@@ -1467,18 +1360,10 @@ export function RunEncounters() {
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M9 5l7 7-7 7"
/>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
{boss.spriteUrl && (
<img
src={boss.spriteUrl}
alt={boss.name}
className="h-10 w-auto"
/>
<img src={boss.spriteUrl} alt={boss.name} className="h-10 w-auto" />
)}
<div>
<div className="flex items-center gap-2">
@@ -1488,13 +1373,10 @@ export function RunEncounters() {
<span className="px-2 py-0.5 text-xs font-medium rounded-full bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300">
{bossTypeLabel[boss.bossType] ?? boss.bossType}
</span>
{boss.specialtyType && (
<TypeBadge type={boss.specialtyType} />
)}
{boss.specialtyType && <TypeBadge type={boss.specialtyType} />}
</div>
<p className="text-xs text-gray-500 dark:text-gray-400">
{boss.location} &middot; Level Cap:{' '}
{boss.levelCap}
{boss.location} &middot; Level Cap: {boss.levelCap}
</p>
</div>
</div>
@@ -1515,10 +1397,7 @@ export function RunEncounters() {
</div>
{/* Boss pokemon team */}
{isBossExpanded && boss.pokemon.length > 0 && (
<BossTeamPreview
pokemon={boss.pokemon}
starterName={starterName}
/>
<BossTeamPreview pokemon={boss.pokemon} starterName={starterName} />
)}
</div>
{sectionAfter && (