Add naming scheme selection to run configuration

Add a nullable naming_scheme column to NuzlockeRun so users can pick a
themed word category for nickname suggestions. Includes Alembic migration,
updated Pydantic schemas, a GET /runs/naming-categories endpoint backed by
a cached dictionary loader, and frontend dropdowns in both the NewRun
creation flow and the RunDashboard for mid-run changes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-11 21:36:50 +01:00
parent e61fce5f72
commit e324559476
15 changed files with 215 additions and 31 deletions

View File

@@ -28,3 +28,7 @@ export function updateRun(
export function deleteRun(id: number): Promise<void> {
return api.del(`/runs/${id}`)
}
export function getNamingCategories(): Promise<string[]> {
return api.get('/runs/naming-categories')
}

View File

@@ -1,6 +1,6 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { getRuns, getRun, createRun, updateRun, deleteRun } from '../api/runs'
import { getRuns, getRun, createRun, updateRun, deleteRun, getNamingCategories } from '../api/runs'
import type { CreateRunInput, UpdateRunInput } from '../types/game'
export function useRuns() {
@@ -51,3 +51,11 @@ export function useDeleteRun() {
},
})
}
export function useNamingCategories() {
return useQuery({
queryKey: ['naming-categories'],
queryFn: getNamingCategories,
staleTime: Infinity,
})
}

View File

@@ -2,7 +2,7 @@ import { useMemo, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { GameGrid, RulesConfiguration, StepIndicator } from '../components'
import { useGames, useGameRoutes } from '../hooks/useGames'
import { useCreateRun, useRuns } from '../hooks/useRuns'
import { useCreateRun, useRuns, useNamingCategories } from '../hooks/useRuns'
import type { Game, NuzlockeRules } from '../types'
import { DEFAULT_RULES } from '../types'
import { RULE_DEFINITIONS } from '../types/rules'
@@ -14,11 +14,13 @@ export function NewRun() {
const { data: games, isLoading, error } = useGames()
const { data: runs } = useRuns()
const createRun = useCreateRun()
const { data: namingCategories } = useNamingCategories()
const [step, setStep] = useState(1)
const [selectedGame, setSelectedGame] = useState<Game | null>(null)
const [rules, setRules] = useState<NuzlockeRules>(DEFAULT_RULES)
const [runName, setRunName] = useState('')
const [namingScheme, setNamingScheme] = useState<string | null>(null)
const { data: routes } = useGameRoutes(selectedGame?.id ?? null)
const hiddenRules = useMemo(() => {
@@ -44,7 +46,7 @@ export function NewRun() {
const handleCreate = () => {
if (!selectedGame) return
createRun.mutate(
{ gameId: selectedGame.id, name: runName, rules },
{ gameId: selectedGame.id, name: runName, rules, namingScheme },
{ onSuccess: (data) => navigate(`/runs/${data.id}`) },
)
}
@@ -180,6 +182,33 @@ export function NewRun() {
/>
</div>
{namingCategories && namingCategories.length > 0 && (
<div>
<label
htmlFor="naming-scheme"
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
>
Naming Scheme
</label>
<select
id="naming-scheme"
value={namingScheme ?? ''}
onChange={(e) => setNamingScheme(e.target.value || null)}
className="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
>
<option value="">None (manual nicknames)</option>
{namingCategories.map((cat) => (
<option key={cat} value={cat}>
{cat.charAt(0).toUpperCase() + cat.slice(1)}
</option>
))}
</select>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
Get nickname suggestions from a themed word list when catching Pokemon.
</p>
</div>
)}
<div className="border-t border-gray-200 dark:border-gray-700 pt-4">
<h3 className="text-sm font-medium text-gray-500 dark:text-gray-400 mb-2">
Summary
@@ -203,6 +232,14 @@ export function NewRun() {
{enabledRuleCount} of {totalRuleCount} enabled
</dd>
</div>
<div className="flex justify-between">
<dt className="text-gray-600 dark:text-gray-400">Naming Scheme</dt>
<dd className="text-gray-900 dark:text-gray-100 font-medium">
{namingScheme
? namingScheme.charAt(0).toUpperCase() + namingScheme.slice(1)
: 'None'}
</dd>
</div>
</dl>
</div>
</div>

View File

@@ -1,6 +1,6 @@
import { useMemo, useState } from 'react'
import { useParams, Link } from 'react-router-dom'
import { useRun, useUpdateRun } from '../hooks/useRuns'
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'
@@ -51,6 +51,7 @@ export function RunDashboard() {
const createEncounter = useCreateEncounter(runIdNum)
const updateEncounter = useUpdateEncounter(runIdNum)
const updateRun = useUpdateRun(runIdNum)
const { data: namingCategories } = useNamingCategories()
const [selectedEncounter, setSelectedEncounter] =
useState<EncounterDetail | null>(null)
const [showEndRun, setShowEndRun] = useState(false)
@@ -197,6 +198,37 @@ export function RunDashboard() {
<RuleBadges rules={run.rules} />
</div>
{/* Naming Scheme */}
{namingCategories && namingCategories.length > 0 && (
<div className="mb-6">
<h2 className="text-sm font-medium text-gray-500 dark:text-gray-400 mb-2">
Naming Scheme
</h2>
{isActive ? (
<select
value={run.namingScheme ?? ''}
onChange={(e) =>
updateRun.mutate({ namingScheme: e.target.value || null })
}
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="">None</option>
{namingCategories.map((cat) => (
<option key={cat} value={cat}>
{cat.charAt(0).toUpperCase() + cat.slice(1)}
</option>
))}
</select>
) : (
<span className="text-sm text-gray-900 dark:text-gray-100">
{run.namingScheme
? run.namingScheme.charAt(0).toUpperCase() + run.namingScheme.slice(1)
: 'None'}
</span>
)}
</div>
)}
{/* Active Team */}
<div className="mb-6">
<div className="flex items-center justify-between mb-3">

View File

@@ -90,6 +90,7 @@ export interface NuzlockeRun {
status: RunStatus
rules: NuzlockeRules
hofEncounterIds: number[] | null
namingScheme: string | null
startedAt: string
completedAt: string | null
}
@@ -132,6 +133,7 @@ export interface CreateRunInput {
gameId: number
name: string
rules?: NuzlockeRules
namingScheme?: string | null
}
export interface UpdateRunInput {
@@ -139,6 +141,7 @@ export interface UpdateRunInput {
status?: RunStatus
rules?: NuzlockeRules
hofEncounterIds?: number[]
namingScheme?: string | null
}
export interface CreateEncounterInput {