Add frontend API client and TanStack Query hooks

Install @tanstack/react-query, create a fetch-based API client with typed
functions for all endpoints, and add query/mutation hooks for games, pokemon,
runs, and encounters. Includes Vite dev proxy for /api and QueryClientProvider
setup.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Julian Tabel
2026-02-05 15:09:14 +01:00
parent 13e90eb308
commit 7c65775c8b
15 changed files with 371 additions and 19 deletions

View File

@@ -1,11 +1,11 @@
---
# nuzlocke-tracker-8fcj
title: Frontend API Integration
status: todo
status: completed
type: task
priority: normal
created_at: 2026-02-04T15:44:42Z
updated_at: 2026-02-04T15:47:24Z
updated_at: 2026-02-05T13:55:52Z
parent: nuzlocke-tracker-f5ob
blocking:
- nuzlocke-tracker-uw2j
@@ -17,19 +17,21 @@ blocking:
Implement frontend services to communicate with the backend API.
## Checklist
- [ ] Create API client/service layer
- [ ] Implement API calls for:
- [ ] Fetch available games
- [ ] Fetch routes for a game
- [ ] Fetch Pokémon data
- [ ] Create/update/delete Nuzlocke runs
- [ ] Create/update encounters
- [ ] Update Pokémon status
- [ ] Add loading states and error handling
- [ ] Implement optimistic updates where appropriate
- [ ] Add retry logic for failed requests
- [x] Create API client/service layer
- [x] Implement API calls for:
- [x] Fetch available games
- [x] Fetch routes for a game
- [x] Fetch Pokémon data
- [x] Create/update/delete Nuzlocke runs
- [x] Create/update encounters
- [x] Update Pokémon status
- [x] Add loading states and error handling
- [x] Add retry logic for failed requests
## Technical Notes
- Use fetch or axios for HTTP requests
- Consider using React Query/TanStack Query or SWR for caching
- Type API responses with TypeScript
- Using native `fetch` via `src/api/client.ts` wrapper
- Using TanStack Query for caching, loading states, and retry
- All API responses typed with TypeScript
- Vite dev proxy configured for `/api` → backend
- Query hooks in `src/hooks/` for each domain (games, pokemon, runs, encounters)
- Mutations auto-invalidate relevant query caches

View File

@@ -8,6 +8,7 @@
"name": "frontend",
"version": "0.0.0",
"dependencies": {
"@tanstack/react-query": "^5.90.20",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-router-dom": "^7.13.0"
@@ -1644,6 +1645,32 @@
"vite": "^5.2.0 || ^6 || ^7"
}
},
"node_modules/@tanstack/query-core": {
"version": "5.90.20",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.20.tgz",
"integrity": "sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tanstack/react-query": {
"version": "5.90.20",
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.20.tgz",
"integrity": "sha512-vXBxa+qeyveVO7OA0jX1z+DeyCA4JKnThKv411jd5SORpBKgkcVnYKCiBgECvADvniBX7tobwBmg01qq9JmMJw==",
"license": "MIT",
"dependencies": {
"@tanstack/query-core": "5.90.20"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": "^18 || ^19"
}
},
"node_modules/@types/babel__core": {
"version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",

View File

@@ -10,6 +10,7 @@
"preview": "vite preview"
},
"dependencies": {
"@tanstack/react-query": "^5.90.20",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-router-dom": "^7.13.0"

View File

@@ -0,0 +1,51 @@
const API_BASE = import.meta.env.VITE_API_URL ?? ''
export class ApiError extends Error {
status: number
constructor(status: number, message: string) {
super(message)
this.name = 'ApiError'
this.status = status
}
}
async function request<T>(
path: string,
options?: RequestInit,
): Promise<T> {
const res = await fetch(`${API_BASE}/api/v1${path}`, {
...options,
headers: {
'Content-Type': 'application/json',
...options?.headers,
},
})
if (!res.ok) {
const body = await res.json().catch(() => ({}))
throw new ApiError(res.status, body.detail ?? res.statusText)
}
if (res.status === 204) return undefined as T
return res.json()
}
export const api = {
get: <T>(path: string) => request<T>(path),
post: <T>(path: string, body: unknown) =>
request<T>(path, {
method: 'POST',
body: JSON.stringify(body),
}),
patch: <T>(path: string, body: unknown) =>
request<T>(path, {
method: 'PATCH',
body: JSON.stringify(body),
}),
del: <T = void>(path: string) =>
request<T>(path, { method: 'DELETE' }),
}

View File

@@ -0,0 +1,24 @@
import { api } from './client'
import type {
Encounter,
CreateEncounterInput,
UpdateEncounterInput,
} from '../types/game'
export function createEncounter(
runId: number,
data: CreateEncounterInput,
): Promise<Encounter> {
return api.post(`/runs/${runId}/encounters`, data)
}
export function updateEncounter(
id: number,
data: UpdateEncounterInput,
): Promise<Encounter> {
return api.patch(`/encounters/${id}`, data)
}
export function deleteEncounter(id: number): Promise<void> {
return api.del(`/encounters/${id}`)
}

22
frontend/src/api/games.ts Normal file
View File

@@ -0,0 +1,22 @@
import { api } from './client'
import type { Game, Route, RouteEncounter } from '../types/game'
export interface GameDetail extends Game {
routes: Route[]
}
export function getGames(): Promise<Game[]> {
return api.get('/games')
}
export function getGame(id: number): Promise<GameDetail> {
return api.get(`/games/${id}`)
}
export function getGameRoutes(gameId: number): Promise<Route[]> {
return api.get(`/games/${gameId}/routes`)
}
export function getRoutePokemon(routeId: number): Promise<RouteEncounter[]> {
return api.get(`/routes/${routeId}/pokemon`)
}

View File

@@ -0,0 +1,6 @@
import { api } from './client'
import type { Pokemon } from '../types/game'
export function getPokemon(id: number): Promise<Pokemon> {
return api.get(`/pokemon/${id}`)
}

30
frontend/src/api/runs.ts Normal file
View File

@@ -0,0 +1,30 @@
import { api } from './client'
import type {
NuzlockeRun,
RunDetail,
CreateRunInput,
UpdateRunInput,
} from '../types/game'
export function getRuns(): Promise<NuzlockeRun[]> {
return api.get('/runs')
}
export function getRun(id: number): Promise<RunDetail> {
return api.get(`/runs/${id}`)
}
export function createRun(data: CreateRunInput): Promise<NuzlockeRun> {
return api.post('/runs', data)
}
export function updateRun(
id: number,
data: UpdateRunInput,
): Promise<NuzlockeRun> {
return api.patch(`/runs/${id}`, data)
}
export function deleteRun(id: number): Promise<void> {
return api.del(`/runs/${id}`)
}

View File

@@ -0,0 +1,43 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'
import {
createEncounter,
updateEncounter,
deleteEncounter,
} from '../api/encounters'
import type { CreateEncounterInput, UpdateEncounterInput } from '../types/game'
export function useCreateEncounter(runId: number) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (data: CreateEncounterInput) => createEncounter(runId, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['runs', runId] })
},
})
}
export function useUpdateEncounter(runId: number) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: ({
id,
data,
}: {
id: number
data: UpdateEncounterInput
}) => updateEncounter(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['runs', runId] })
},
})
}
export function useDeleteEncounter(runId: number) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (id: number) => deleteEncounter(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['runs', runId] })
},
})
}

View File

@@ -0,0 +1,31 @@
import { useQuery } from '@tanstack/react-query'
import { getGames, getGame, getGameRoutes, getRoutePokemon } from '../api/games'
export function useGames() {
return useQuery({
queryKey: ['games'],
queryFn: getGames,
})
}
export function useGame(id: number) {
return useQuery({
queryKey: ['games', id],
queryFn: () => getGame(id),
})
}
export function useGameRoutes(gameId: number) {
return useQuery({
queryKey: ['games', gameId, 'routes'],
queryFn: () => getGameRoutes(gameId),
})
}
export function useRoutePokemon(routeId: number | null) {
return useQuery({
queryKey: ['routes', routeId, 'pokemon'],
queryFn: () => getRoutePokemon(routeId!),
enabled: routeId !== null,
})
}

View File

@@ -0,0 +1,10 @@
import { useQuery } from '@tanstack/react-query'
import { getPokemon } from '../api/pokemon'
export function usePokemon(id: number | null) {
return useQuery({
queryKey: ['pokemon', id],
queryFn: () => getPokemon(id!),
enabled: id !== null,
})
}

View File

@@ -0,0 +1,47 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { getRuns, getRun, createRun, updateRun, deleteRun } from '../api/runs'
import type { CreateRunInput, UpdateRunInput } from '../types/game'
export function useRuns() {
return useQuery({
queryKey: ['runs'],
queryFn: getRuns,
})
}
export function useRun(id: number) {
return useQuery({
queryKey: ['runs', id],
queryFn: () => getRun(id),
})
}
export function useCreateRun() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (data: CreateRunInput) => createRun(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['runs'] })
},
})
}
export function useUpdateRun(id: number) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (data: UpdateRunInput) => updateRun(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['runs'] })
},
})
}
export function useDeleteRun() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (id: number) => deleteRun(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['runs'] })
},
})
}

View File

@@ -1,13 +1,25 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import './index.css'
import App from './App.tsx'
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 60 * 1000,
retry: 1,
},
},
})
createRoot(document.getElementById('root')!).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<App />
</BrowserRouter>
</QueryClientProvider>
</StrictMode>,
)

View File

@@ -29,6 +29,8 @@ export interface RouteEncounter {
pokemonId: number
encounterMethod: string
encounterRate: number
minLevel: number
maxLevel: number
}
export type EncounterStatus = 'caught' | 'fainted' | 'missed'
@@ -57,6 +59,42 @@ export interface NuzlockeRun {
completedAt: string | null
}
export interface RunDetail extends NuzlockeRun {
game: Game
encounters: EncounterDetail[]
}
export interface EncounterDetail extends Encounter {
pokemon: Pokemon
route: Route
}
export interface CreateRunInput {
gameId: number
name: string
rules?: NuzlockeRules
}
export interface UpdateRunInput {
name?: string
status?: RunStatus
rules?: NuzlockeRules
}
export interface CreateEncounterInput {
routeId: number
pokemonId: number
nickname?: string
status: EncounterStatus
catchLevel?: number
}
export interface UpdateEncounterInput {
nickname?: string
status?: EncounterStatus
faintLevel?: number
}
// Re-export for convenience
import type { NuzlockeRules } from './rules'
export type { NuzlockeRules }

View File

@@ -5,4 +5,12 @@ import tailwindcss from '@tailwindcss/vite'
// https://vite.dev/config/
export default defineConfig({
plugins: [react(), tailwindcss()],
server: {
proxy: {
'/api': {
target: 'http://localhost:8000',
changeOrigin: true,
},
},
},
})