diff --git a/.beans/nuzlocke-tracker-x4p6--genlocke-overview-page.md b/.beans/nuzlocke-tracker-x4p6--genlocke-overview-page.md index 54793aa..fe71e78 100644 --- a/.beans/nuzlocke-tracker-x4p6--genlocke-overview-page.md +++ b/.beans/nuzlocke-tracker-x4p6--genlocke-overview-page.md @@ -1,11 +1,11 @@ --- # nuzlocke-tracker-x4p6 title: Genlocke overview page -status: todo +status: in-progress type: feature priority: normal created_at: 2026-02-09T07:42:19Z -updated_at: 2026-02-09T09:07:40Z +updated_at: 2026-02-09T09:33:02Z parent: nuzlocke-tracker-25mh blocking: - nuzlocke-tracker-lsc2 diff --git a/backend/src/app/api/genlockes.py b/backend/src/app/api/genlockes.py index 790ef97..98ee358 100644 --- a/backend/src/app/api/genlockes.py +++ b/backend/src/app/api/genlockes.py @@ -1,6 +1,6 @@ from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel -from sqlalchemy import select +from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload @@ -10,12 +10,148 @@ from app.models.evolution import Evolution from app.models.game import Game from app.models.genlocke import Genlocke, GenlockeLeg from app.models.nuzlocke_run import NuzlockeRun -from app.schemas.genlocke import GenlockeCreate, GenlockeResponse +from app.models.pokemon import Pokemon +from app.schemas.genlocke import ( + GenlockeCreate, + GenlockeDetailResponse, + GenlockeLegDetailResponse, + GenlockeListItem, + GenlockeResponse, + GenlockeStatsResponse, + RetiredPokemonResponse, +) from app.services.families import build_families router = APIRouter() +@router.get("", response_model=list[GenlockeListItem]) +async def list_genlockes(session: AsyncSession = Depends(get_session)): + result = await session.execute( + select(Genlocke) + .options( + selectinload(Genlocke.legs).selectinload(GenlockeLeg.run), + ) + .order_by(Genlocke.created_at.desc()) + ) + genlockes = result.scalars().all() + + items = [] + for g in genlockes: + completed_legs = 0 + current_leg_order = None + for leg in g.legs: + if leg.run and leg.run.status == "completed": + completed_legs += 1 + elif leg.run and leg.run.status == "active": + current_leg_order = leg.leg_order + + items.append( + GenlockeListItem( + id=g.id, + name=g.name, + status=g.status, + created_at=g.created_at, + total_legs=len(g.legs), + completed_legs=completed_legs, + current_leg_order=current_leg_order, + ) + ) + return items + + +@router.get("/{genlocke_id}", response_model=GenlockeDetailResponse) +async def get_genlocke( + genlocke_id: int, session: AsyncSession = Depends(get_session) +): + result = await session.execute( + select(Genlocke) + .where(Genlocke.id == genlocke_id) + .options( + selectinload(Genlocke.legs).selectinload(GenlockeLeg.game), + selectinload(Genlocke.legs).selectinload(GenlockeLeg.run), + ) + ) + genlocke = result.scalar_one_or_none() + if genlocke is None: + raise HTTPException(status_code=404, detail="Genlocke not found") + + # Collect run IDs for aggregate query + run_ids = [leg.run_id for leg in genlocke.legs if leg.run_id is not None] + + stats_by_run: dict[int, tuple[int, int]] = {} + if run_ids: + stats_result = await session.execute( + select( + Encounter.run_id, + func.count().label("encounter_count"), + func.count(Encounter.faint_level).label("death_count"), + ) + .where(Encounter.run_id.in_(run_ids)) + .group_by(Encounter.run_id) + ) + for row in stats_result: + stats_by_run[row.run_id] = (row.encounter_count, row.death_count) + + legs = [] + total_encounters = 0 + total_deaths = 0 + legs_completed = 0 + for leg in genlocke.legs: + run_status = leg.run.status if leg.run else None + enc_count, death_count = stats_by_run.get(leg.run_id, (0, 0)) if leg.run_id else (0, 0) + total_encounters += enc_count + total_deaths += death_count + if run_status == "completed": + legs_completed += 1 + + legs.append( + GenlockeLegDetailResponse( + id=leg.id, + leg_order=leg.leg_order, + game=leg.game, + run_id=leg.run_id, + run_status=run_status, + encounter_count=enc_count, + death_count=death_count, + retired_pokemon_ids=leg.retired_pokemon_ids, + ) + ) + + # Fetch retired Pokemon data + retired_pokemon: dict[int, RetiredPokemonResponse] = {} + all_retired_ids: set[int] = set() + for leg in genlocke.legs: + if leg.retired_pokemon_ids: + all_retired_ids.update(leg.retired_pokemon_ids) + + if all_retired_ids: + pokemon_result = await session.execute( + select(Pokemon).where(Pokemon.id.in_(all_retired_ids)) + ) + for p in pokemon_result.scalars().all(): + retired_pokemon[p.id] = RetiredPokemonResponse( + id=p.id, name=p.name, sprite_url=p.sprite_url + ) + + return GenlockeDetailResponse( + id=genlocke.id, + name=genlocke.name, + status=genlocke.status, + genlocke_rules=genlocke.genlocke_rules, + nuzlocke_rules=genlocke.nuzlocke_rules, + created_at=genlocke.created_at, + legs=legs, + stats=GenlockeStatsResponse( + total_encounters=total_encounters, + total_deaths=total_deaths, + legs_completed=legs_completed, + total_legs=len(genlocke.legs), + ), + retired_pokemon=retired_pokemon, + ) + + @router.post("", response_model=GenlockeResponse, status_code=201) async def create_genlocke( data: GenlockeCreate, session: AsyncSession = Depends(get_session) diff --git a/backend/src/app/schemas/genlocke.py b/backend/src/app/schemas/genlocke.py index 56d704c..af6d838 100644 --- a/backend/src/app/schemas/genlocke.py +++ b/backend/src/app/schemas/genlocke.py @@ -17,6 +17,7 @@ class GenlockeLegResponse(CamelModel): game_id: int run_id: int | None = None leg_order: int + retired_pokemon_ids: list[int] | None = None game: GameResponse @@ -28,3 +29,52 @@ class GenlockeResponse(CamelModel): nuzlocke_rules: dict created_at: datetime legs: list[GenlockeLegResponse] = [] + + +# --- List / Detail schemas --- + + +class RetiredPokemonResponse(CamelModel): + id: int + name: str + sprite_url: str | None = None + + +class GenlockeLegDetailResponse(CamelModel): + id: int + leg_order: int + game: GameResponse + run_id: int | None = None + run_status: str | None = None + encounter_count: int = 0 + death_count: int = 0 + retired_pokemon_ids: list[int] | None = None + + +class GenlockeStatsResponse(CamelModel): + total_encounters: int + total_deaths: int + legs_completed: int + total_legs: int + + +class GenlockeListItem(CamelModel): + id: int + name: str + status: str + created_at: datetime + total_legs: int + completed_legs: int + current_leg_order: int | None = None + + +class GenlockeDetailResponse(CamelModel): + id: int + name: str + status: str + genlocke_rules: dict + nuzlocke_rules: dict + created_at: datetime + legs: list[GenlockeLegDetailResponse] = [] + stats: GenlockeStatsResponse + retired_pokemon: dict[int, RetiredPokemonResponse] = {} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 62c24b6..6108aff 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,7 +1,7 @@ import { Routes, Route, Navigate } from 'react-router-dom' import { Layout } from './components' import { AdminLayout } from './components/admin' -import { Home, NewGenlocke, NewRun, RunList, RunEncounters, Stats } from './pages' +import { GenlockeDetail, GenlockeList, Home, NewGenlocke, NewRun, RunList, RunEncounters, Stats } from './pages' import { AdminGames, AdminGameDetail, @@ -19,7 +19,9 @@ function App() { } /> } /> } /> + } /> } /> + } /> } /> } /> }> diff --git a/frontend/src/api/genlockes.ts b/frontend/src/api/genlockes.ts index e8e3df6..5b481bc 100644 --- a/frontend/src/api/genlockes.ts +++ b/frontend/src/api/genlockes.ts @@ -1,5 +1,13 @@ import { api } from './client' -import type { Genlocke, CreateGenlockeInput, Region } from '../types/game' +import type { Genlocke, GenlockeListItem, GenlockeDetail, CreateGenlockeInput, Region } from '../types/game' + +export function getGenlockes(): Promise { + return api.get('/genlockes') +} + +export function getGenlocke(id: number): Promise { + return api.get(`/genlockes/${id}`) +} export function createGenlocke(data: CreateGenlockeInput): Promise { return api.post('/genlockes', data) diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index a78cd87..01e8339 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -29,7 +29,7 @@ export function Layout() { My Runs Genlockes @@ -100,7 +100,7 @@ export function Layout() { My Runs setMenuOpen(false)} className="block px-3 py-2 rounded-md text-base font-medium hover:bg-gray-100 dark:hover:bg-gray-700" > diff --git a/frontend/src/hooks/useGenlockes.ts b/frontend/src/hooks/useGenlockes.ts index 8fd63d2..42352f6 100644 --- a/frontend/src/hooks/useGenlockes.ts +++ b/frontend/src/hooks/useGenlockes.ts @@ -1,7 +1,21 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' -import { advanceLeg, createGenlocke, getGamesByRegion } from '../api/genlockes' +import { advanceLeg, createGenlocke, getGamesByRegion, getGenlockes, getGenlocke } from '../api/genlockes' import type { CreateGenlockeInput } from '../types/game' +export function useGenlockes() { + return useQuery({ + queryKey: ['genlockes'], + queryFn: getGenlockes, + }) +} + +export function useGenlocke(id: number) { + return useQuery({ + queryKey: ['genlockes', id], + queryFn: () => getGenlocke(id), + }) +} + export function useRegions() { return useQuery({ queryKey: ['games', 'by-region'], @@ -15,6 +29,7 @@ export function useCreateGenlocke() { mutationFn: (data: CreateGenlockeInput) => createGenlocke(data), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['runs'] }) + queryClient.invalidateQueries({ queryKey: ['genlockes'] }) }, }) } @@ -26,6 +41,7 @@ export function useAdvanceLeg() { advanceLeg(genlockeId, legOrder), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['runs'] }) + queryClient.invalidateQueries({ queryKey: ['genlockes'] }) }, }) } diff --git a/frontend/src/pages/GenlockeDetail.tsx b/frontend/src/pages/GenlockeDetail.tsx new file mode 100644 index 0000000..4931cc7 --- /dev/null +++ b/frontend/src/pages/GenlockeDetail.tsx @@ -0,0 +1,305 @@ +import { Link, useParams } from 'react-router-dom' +import { useGenlocke } from '../hooks/useGenlockes' +import { usePokemonFamilies } from '../hooks/usePokemon' +import { StatCard, RuleBadges } from '../components' +import type { GenlockeLegDetail, RetiredPokemon, RunStatus } from '../types' +import { useMemo } from 'react' + +const statusColors: Record = { + completed: 'bg-blue-500', + active: 'bg-green-500', + failed: 'bg-red-500', +} + +const statusRing: Record = { + completed: 'ring-blue-500', + active: 'ring-green-500 animate-pulse', + failed: 'ring-red-500', +} + +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', + failed: 'bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-300', +} + +function LegIndicator({ leg }: { leg: GenlockeLegDetail }) { + const hasRun = leg.runId !== null + const status = leg.runStatus as RunStatus | null + + const dot = status ? ( +
+ ) : ( +
+ ) + + const content = ( +
+ {dot} + + {leg.game.name} + + {status && ( + + {status} + + )} +
+ ) + + if (hasRun) { + return ( + + {content} + + ) + } + + return content +} + +function PokemonSprite({ pokemon }: { pokemon: RetiredPokemon }) { + if (pokemon.spriteUrl) { + return ( + {pokemon.name} + ) + } + return ( +
+ {pokemon.name[0].toUpperCase()} +
+ ) +} + +export function GenlockeDetail() { + const { genlockeId } = useParams<{ genlockeId: string }>() + const id = Number(genlockeId) + const { data: genlocke, isLoading, error } = useGenlocke(id) + const { data: familiesData } = usePokemonFamilies() + + const activeLeg = useMemo(() => { + if (!genlocke) return null + return genlocke.legs.find((l) => l.runStatus === 'active') ?? null + }, [genlocke]) + + // Group retired Pokemon by leg, showing only the "base" Pokemon per family + const retiredByLeg = useMemo(() => { + if (!genlocke || !familiesData) return [] + const familyMap = new Map() + for (const family of familiesData.families) { + for (const id of family) { + familyMap.set(id, family) + } + } + + return genlocke.legs + .filter((leg) => leg.retiredPokemonIds && leg.retiredPokemonIds.length > 0) + .map((leg) => { + // Find base Pokemon (lowest ID) for each family in this leg's retired list + const seen = new Set() + const bases: number[] = [] + for (const pid of leg.retiredPokemonIds!) { + const family = familyMap.get(pid) + const key = family ? family.join(',') : String(pid) + if (!seen.has(key)) { + seen.add(key) + bases.push(family ? Math.min(...family) : pid) + } + } + return { legOrder: leg.legOrder, gameName: leg.game.name, pokemonIds: bases.sort((a, b) => a - b) } + }) + }, [genlocke, familiesData]) + + if (isLoading) { + return ( +
+
+
+ ) + } + + if (error || !genlocke) { + return ( +
+
+ Failed to load genlocke. Please try again. +
+
+ ) + } + + const survivalRate = + genlocke.stats.totalEncounters > 0 + ? Math.round( + ((genlocke.stats.totalEncounters - genlocke.stats.totalDeaths) / + genlocke.stats.totalEncounters) * + 100 + ) + : 0 + + return ( +
+ {/* Header */} +
+ + ← Back to Genlockes + +
+

+ {genlocke.name} +

+ + {genlocke.status} + +
+
+ + {/* Progress Timeline */} +
+

+ Progress +

+
+
+ {genlocke.legs.map((leg, i) => ( +
+ + {i < genlocke.legs.length - 1 && ( +
+ )} +
+ ))} +
+
+
+ + {/* Cumulative Stats */} +
+

+ Cumulative Stats +

+
+ + + + +
+
+ + {/* Configuration */} +
+

+ Configuration +

+
+
+

+ Genlocke Rules +

+
+ {genlocke.genlockeRules.retireHoF ? ( + + Retire HoF Teams + + ) : ( + + No genlocke-specific rules enabled + + )} +
+
+
+

+ Nuzlocke Rules +

+ +
+
+
+ + {/* Retired Families */} + {genlocke.genlockeRules.retireHoF && retiredByLeg.length > 0 && ( +
+

+ Retired Families +

+
+ {retiredByLeg.map((leg) => ( +
+

+ Leg {leg.legOrder} — {leg.gameName} +

+
+ {leg.pokemonIds.map((pid) => { + const pokemon = genlocke.retiredPokemon[pid] + if (!pokemon) return null + return + })} +
+
+ ))} +
+
+ )} + + {/* Quick Actions */} +
+

+ Quick Actions +

+
+ {activeLeg && ( + + Go to Active Leg (Leg {activeLeg.legOrder}) + + )} + + +
+
+
+ ) +} diff --git a/frontend/src/pages/GenlockeList.tsx b/frontend/src/pages/GenlockeList.tsx new file mode 100644 index 0000000..bcb91fd --- /dev/null +++ b/frontend/src/pages/GenlockeList.tsx @@ -0,0 +1,93 @@ +import { Link } from 'react-router-dom' +import { useGenlockes } from '../hooks/useGenlockes' +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', + failed: 'bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-300', +} + +export function GenlockeList() { + const { data: genlockes, isLoading, error } = useGenlockes() + + return ( +
+
+

+ Your Genlockes +

+ + Start New Genlocke + +
+ + {isLoading && ( +
+
+
+ )} + + {error && ( +
+ Failed to load genlockes. Please try again. +
+ )} + + {genlockes && genlockes.length === 0 && ( +
+

+ No genlockes yet. Start your first Generation Locke! +

+ + Start New Genlocke + +
+ )} + + {genlockes && genlockes.length > 0 && ( +
+ {genlockes.map((g) => ( + +
+
+

+ {g.name} +

+

+ {g.currentLegOrder !== null + ? `Leg ${g.currentLegOrder} / ${g.totalLegs}` + : `${g.completedLegs} / ${g.totalLegs} legs completed`} + {' \u00b7 '} + Started{' '} + {new Date(g.createdAt).toLocaleDateString(undefined, { + year: 'numeric', + month: 'short', + day: 'numeric', + })} +

+
+ + {g.status} + +
+ + ))} +
+ )} +
+ ) +} diff --git a/frontend/src/pages/index.ts b/frontend/src/pages/index.ts index 1901faa..41f10f9 100644 --- a/frontend/src/pages/index.ts +++ b/frontend/src/pages/index.ts @@ -1,3 +1,5 @@ +export { GenlockeDetail } from './GenlockeDetail' +export { GenlockeList } from './GenlockeList' export { Home } from './Home' export { NewGenlocke } from './NewGenlocke' export { NewRun } from './NewRun' diff --git a/frontend/src/types/game.ts b/frontend/src/types/game.ts index 94ee152..d8df373 100644 --- a/frontend/src/types/game.ts +++ b/frontend/src/types/game.ts @@ -236,3 +236,51 @@ export interface CreateGenlockeInput { genlockeRules: GenlockeRules nuzlockeRules: NuzlockeRules } + +// Genlocke list / detail types + +export interface GenlockeLegDetail { + id: number + legOrder: number + game: Game + runId: number | null + runStatus: RunStatus | null + encounterCount: number + deathCount: number + retiredPokemonIds: number[] | null +} + +export interface GenlockeStats { + totalEncounters: number + totalDeaths: number + legsCompleted: number + totalLegs: number +} + +export interface GenlockeListItem { + id: number + name: string + status: 'active' | 'completed' | 'failed' + createdAt: string + totalLegs: number + completedLegs: number + currentLegOrder: number | null +} + +export interface RetiredPokemon { + id: number + name: string + spriteUrl: string | null +} + +export interface GenlockeDetail { + id: number + name: string + status: 'active' | 'completed' | 'failed' + genlockeRules: GenlockeRules + nuzlockeRules: NuzlockeRules + createdAt: string + legs: GenlockeLegDetail[] + stats: GenlockeStats + retiredPokemon: Record +}