Add genlocke lineage tracking with aligned timeline view
Implement read-only lineage view that traces Pokemon across genlocke legs via existing transfer records. Backend walks transfer chains to build lineage entries; frontend renders them as cards with a column-aligned timeline grid so leg dots line up vertically across all lineages. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,11 +1,11 @@
|
|||||||
---
|
---
|
||||||
# nuzlocke-tracker-lsc2
|
# nuzlocke-tracker-lsc2
|
||||||
title: Genlocke lineage tracking
|
title: Genlocke lineage tracking
|
||||||
status: todo
|
status: in-progress
|
||||||
type: feature
|
type: feature
|
||||||
priority: normal
|
priority: normal
|
||||||
created_at: 2026-02-09T07:42:41Z
|
created_at: 2026-02-09T07:42:41Z
|
||||||
updated_at: 2026-02-09T07:46:15Z
|
updated_at: 2026-02-09T10:51:22Z
|
||||||
parent: nuzlocke-tracker-25mh
|
parent: nuzlocke-tracker-25mh
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -20,12 +20,15 @@ from app.schemas.genlocke import (
|
|||||||
GenlockeDetailResponse,
|
GenlockeDetailResponse,
|
||||||
GenlockeGraveyardResponse,
|
GenlockeGraveyardResponse,
|
||||||
GenlockeLegDetailResponse,
|
GenlockeLegDetailResponse,
|
||||||
|
GenlockeLineageResponse,
|
||||||
GenlockeListItem,
|
GenlockeListItem,
|
||||||
GenlockeResponse,
|
GenlockeResponse,
|
||||||
GenlockeStatsResponse,
|
GenlockeStatsResponse,
|
||||||
GenlockeUpdate,
|
GenlockeUpdate,
|
||||||
GraveyardEntryResponse,
|
GraveyardEntryResponse,
|
||||||
GraveyardLegSummary,
|
GraveyardLegSummary,
|
||||||
|
LineageEntry,
|
||||||
|
LineageLegEntry,
|
||||||
RetiredPokemonResponse,
|
RetiredPokemonResponse,
|
||||||
SurvivorResponse,
|
SurvivorResponse,
|
||||||
)
|
)
|
||||||
@@ -261,6 +264,171 @@ async def get_genlocke_graveyard(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/{genlocke_id}/lineages",
|
||||||
|
response_model=GenlockeLineageResponse,
|
||||||
|
)
|
||||||
|
async def get_genlocke_lineages(
|
||||||
|
genlocke_id: int, session: AsyncSession = Depends(get_session)
|
||||||
|
):
|
||||||
|
# Load genlocke with legs + game
|
||||||
|
result = await session.execute(
|
||||||
|
select(Genlocke)
|
||||||
|
.where(Genlocke.id == genlocke_id)
|
||||||
|
.options(
|
||||||
|
selectinload(Genlocke.legs).selectinload(GenlockeLeg.game),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
genlocke = result.scalar_one_or_none()
|
||||||
|
if genlocke is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Genlocke not found")
|
||||||
|
|
||||||
|
# Query all transfers for this genlocke
|
||||||
|
transfer_result = await session.execute(
|
||||||
|
select(GenlockeTransfer).where(
|
||||||
|
GenlockeTransfer.genlocke_id == genlocke_id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
transfers = transfer_result.scalars().all()
|
||||||
|
|
||||||
|
if not transfers:
|
||||||
|
return GenlockeLineageResponse(lineages=[], total_lineages=0)
|
||||||
|
|
||||||
|
# Build forward/backward maps
|
||||||
|
forward: dict[int, GenlockeTransfer] = {} # source_encounter_id -> transfer
|
||||||
|
backward: set[int] = set() # target_encounter_ids
|
||||||
|
for t in transfers:
|
||||||
|
forward[t.source_encounter_id] = t
|
||||||
|
backward.add(t.target_encounter_id)
|
||||||
|
|
||||||
|
# Find roots: sources that are NOT targets
|
||||||
|
roots = [t.source_encounter_id for t in transfers if t.source_encounter_id not in backward]
|
||||||
|
# Deduplicate while preserving order
|
||||||
|
seen_roots: set[int] = set()
|
||||||
|
unique_roots: list[int] = []
|
||||||
|
for r in roots:
|
||||||
|
if r not in seen_roots:
|
||||||
|
seen_roots.add(r)
|
||||||
|
unique_roots.append(r)
|
||||||
|
|
||||||
|
# Walk forward from each root to build chains
|
||||||
|
chains: list[list[int]] = []
|
||||||
|
for root in unique_roots:
|
||||||
|
chain = [root]
|
||||||
|
current = root
|
||||||
|
while current in forward:
|
||||||
|
target = forward[current].target_encounter_id
|
||||||
|
chain.append(target)
|
||||||
|
current = target
|
||||||
|
chains.append(chain)
|
||||||
|
|
||||||
|
# Batch-load all encounters in the chains
|
||||||
|
all_encounter_ids: set[int] = set()
|
||||||
|
for chain in chains:
|
||||||
|
all_encounter_ids.update(chain)
|
||||||
|
|
||||||
|
enc_result = await session.execute(
|
||||||
|
select(Encounter)
|
||||||
|
.where(Encounter.id.in_(all_encounter_ids))
|
||||||
|
.options(
|
||||||
|
selectinload(Encounter.pokemon),
|
||||||
|
selectinload(Encounter.current_pokemon),
|
||||||
|
selectinload(Encounter.route),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
encounter_map: dict[int, Encounter] = {
|
||||||
|
enc.id: enc for enc in enc_result.scalars().all()
|
||||||
|
}
|
||||||
|
|
||||||
|
# Build run_id -> (leg_order, game_name) lookup
|
||||||
|
run_lookup: dict[int, tuple[int, str]] = {}
|
||||||
|
for leg in genlocke.legs:
|
||||||
|
if leg.run_id is not None:
|
||||||
|
run_lookup[leg.run_id] = (leg.leg_order, leg.game.name)
|
||||||
|
|
||||||
|
# Load HoF encounter IDs for all runs
|
||||||
|
run_ids = [leg.run_id for leg in genlocke.legs if leg.run_id is not None]
|
||||||
|
hof_encounter_ids: set[int] = set()
|
||||||
|
if run_ids:
|
||||||
|
run_result = await session.execute(
|
||||||
|
select(NuzlockeRun.id, NuzlockeRun.hof_encounter_ids).where(
|
||||||
|
NuzlockeRun.id.in_(run_ids)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for row in run_result:
|
||||||
|
if row.hof_encounter_ids:
|
||||||
|
hof_encounter_ids.update(row.hof_encounter_ids)
|
||||||
|
|
||||||
|
# Build lineage entries
|
||||||
|
lineages: list[LineageEntry] = []
|
||||||
|
for chain in chains:
|
||||||
|
legs: list[LineageLegEntry] = []
|
||||||
|
first_pokemon = None
|
||||||
|
|
||||||
|
for enc_id in chain:
|
||||||
|
enc = encounter_map.get(enc_id)
|
||||||
|
if enc is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
leg_order, game_name = run_lookup.get(enc.run_id, (0, "Unknown"))
|
||||||
|
is_alive = enc.faint_level is None and enc.status == "caught"
|
||||||
|
entered_hof = enc.id in hof_encounter_ids
|
||||||
|
was_transferred = enc.id in forward
|
||||||
|
|
||||||
|
pokemon_resp = PokemonResponse.model_validate(enc.pokemon)
|
||||||
|
if first_pokemon is None:
|
||||||
|
first_pokemon = pokemon_resp
|
||||||
|
|
||||||
|
current_pokemon_resp = (
|
||||||
|
PokemonResponse.model_validate(enc.current_pokemon)
|
||||||
|
if enc.current_pokemon
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
|
||||||
|
legs.append(
|
||||||
|
LineageLegEntry(
|
||||||
|
leg_order=leg_order,
|
||||||
|
game_name=game_name,
|
||||||
|
encounter_id=enc.id,
|
||||||
|
pokemon=pokemon_resp,
|
||||||
|
current_pokemon=current_pokemon_resp,
|
||||||
|
nickname=enc.nickname,
|
||||||
|
catch_level=enc.catch_level,
|
||||||
|
faint_level=enc.faint_level,
|
||||||
|
death_cause=enc.death_cause,
|
||||||
|
is_shiny=enc.is_shiny,
|
||||||
|
route_name=enc.route.name if enc.route else "Unknown",
|
||||||
|
is_alive=is_alive,
|
||||||
|
entered_hof=entered_hof,
|
||||||
|
was_transferred=was_transferred,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if not legs or first_pokemon is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Status based on last encounter in the chain
|
||||||
|
last_leg = legs[-1]
|
||||||
|
status = "alive" if last_leg.is_alive else "dead"
|
||||||
|
|
||||||
|
lineages.append(
|
||||||
|
LineageEntry(
|
||||||
|
nickname=legs[0].nickname,
|
||||||
|
pokemon=first_pokemon,
|
||||||
|
legs=legs,
|
||||||
|
status=status,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Sort by first leg order, then by encounter ID
|
||||||
|
lineages.sort(key=lambda l: (l.legs[0].leg_order, l.legs[0].encounter_id))
|
||||||
|
|
||||||
|
return GenlockeLineageResponse(
|
||||||
|
lineages=lineages,
|
||||||
|
total_lineages=len(lineages),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("", response_model=GenlockeResponse, status_code=201)
|
@router.post("", response_model=GenlockeResponse, status_code=201)
|
||||||
async def create_genlocke(
|
async def create_genlocke(
|
||||||
data: GenlockeCreate, session: AsyncSession = Depends(get_session)
|
data: GenlockeCreate, session: AsyncSession = Depends(get_session)
|
||||||
|
|||||||
@@ -132,3 +132,35 @@ class GenlockeGraveyardResponse(CamelModel):
|
|||||||
total_deaths: int
|
total_deaths: int
|
||||||
deaths_per_leg: list[GraveyardLegSummary]
|
deaths_per_leg: list[GraveyardLegSummary]
|
||||||
deadliest_leg: GraveyardLegSummary | None = None
|
deadliest_leg: GraveyardLegSummary | None = None
|
||||||
|
|
||||||
|
|
||||||
|
# --- Lineage schemas ---
|
||||||
|
|
||||||
|
|
||||||
|
class LineageLegEntry(CamelModel):
|
||||||
|
leg_order: int
|
||||||
|
game_name: str
|
||||||
|
encounter_id: int
|
||||||
|
pokemon: PokemonResponse
|
||||||
|
current_pokemon: PokemonResponse | None = None
|
||||||
|
nickname: str | None = None
|
||||||
|
catch_level: int | None = None
|
||||||
|
faint_level: int | None = None
|
||||||
|
death_cause: str | None = None
|
||||||
|
is_shiny: bool = False
|
||||||
|
route_name: str
|
||||||
|
is_alive: bool
|
||||||
|
entered_hof: bool
|
||||||
|
was_transferred: bool
|
||||||
|
|
||||||
|
|
||||||
|
class LineageEntry(CamelModel):
|
||||||
|
nickname: str | None
|
||||||
|
pokemon: PokemonResponse # base form from first leg
|
||||||
|
legs: list[LineageLegEntry]
|
||||||
|
status: str # "alive" | "dead"
|
||||||
|
|
||||||
|
|
||||||
|
class GenlockeLineageResponse(CamelModel):
|
||||||
|
lineages: list[LineageEntry]
|
||||||
|
total_lineages: int
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { api } from './client'
|
import { api } from './client'
|
||||||
import type { Genlocke, GenlockeListItem, GenlockeDetail, GenlockeGraveyard, 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<GenlockeListItem[]> {
|
export function getGenlockes(): Promise<GenlockeListItem[]> {
|
||||||
return api.get('/genlockes')
|
return api.get('/genlockes')
|
||||||
@@ -21,6 +21,10 @@ export function getGenlockeGraveyard(id: number): Promise<GenlockeGraveyard> {
|
|||||||
return api.get(`/genlockes/${id}/graveyard`)
|
return api.get(`/genlockes/${id}/graveyard`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getGenlockeLineages(id: number): Promise<GenlockeLineage> {
|
||||||
|
return api.get(`/genlockes/${id}/lineages`)
|
||||||
|
}
|
||||||
|
|
||||||
export function getLegSurvivors(genlockeId: number, legOrder: number): Promise<SurvivorEncounter[]> {
|
export function getLegSurvivors(genlockeId: number, legOrder: number): Promise<SurvivorEncounter[]> {
|
||||||
return api.get(`/genlockes/${genlockeId}/legs/${legOrder}/survivors`)
|
return api.get(`/genlockes/${genlockeId}/legs/${legOrder}/survivors`)
|
||||||
}
|
}
|
||||||
|
|||||||
276
frontend/src/components/GenlockeLineage.tsx
Normal file
276
frontend/src/components/GenlockeLineage.tsx
Normal file
@@ -0,0 +1,276 @@
|
|||||||
|
import { useMemo } from 'react'
|
||||||
|
import { useGenlockeLineages } from '../hooks/useGenlockes'
|
||||||
|
import type { LineageEntry, LineageLegEntry } from '../types'
|
||||||
|
|
||||||
|
interface GenlockeLineageProps {
|
||||||
|
genlockeId: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function LegDot({ leg }: { leg: LineageLegEntry }) {
|
||||||
|
let color: string
|
||||||
|
let label: string
|
||||||
|
|
||||||
|
if (leg.faintLevel !== null) {
|
||||||
|
color = 'bg-red-500'
|
||||||
|
label = 'Dead'
|
||||||
|
} else if (leg.wasTransferred) {
|
||||||
|
color = 'bg-blue-500'
|
||||||
|
label = 'Transferred'
|
||||||
|
} else if (leg.enteredHof) {
|
||||||
|
color = 'bg-yellow-500'
|
||||||
|
label = 'Hall of Fame'
|
||||||
|
} else {
|
||||||
|
color = 'bg-green-500'
|
||||||
|
label = 'Alive'
|
||||||
|
}
|
||||||
|
|
||||||
|
const displayPokemon = leg.currentPokemon ?? leg.pokemon
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="group relative flex flex-col items-center">
|
||||||
|
<div className={`w-4 h-4 rounded-full ${color} ring-2 ring-offset-1 ring-offset-white dark:ring-offset-gray-800 ring-gray-200 dark:ring-gray-600`} />
|
||||||
|
|
||||||
|
{/* Tooltip */}
|
||||||
|
<div className="absolute bottom-full mb-2 hidden group-hover:flex flex-col items-center z-10">
|
||||||
|
<div className="bg-gray-900 dark:bg-gray-700 text-white text-xs rounded-lg px-3 py-2 whitespace-nowrap shadow-lg space-y-1">
|
||||||
|
<div className="font-semibold">{leg.gameName}</div>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
{displayPokemon.spriteUrl && (
|
||||||
|
<img src={displayPokemon.spriteUrl} alt={displayPokemon.name} className="w-6 h-6" />
|
||||||
|
)}
|
||||||
|
<span>{displayPokemon.name}</span>
|
||||||
|
</div>
|
||||||
|
{leg.catchLevel !== null && (
|
||||||
|
<div>Caught Lv. {leg.catchLevel}</div>
|
||||||
|
)}
|
||||||
|
{leg.faintLevel !== null && (
|
||||||
|
<div className="text-red-300">Died Lv. {leg.faintLevel}</div>
|
||||||
|
)}
|
||||||
|
{leg.deathCause && (
|
||||||
|
<div className="text-red-300 italic">{leg.deathCause}</div>
|
||||||
|
)}
|
||||||
|
<div className={`font-medium ${
|
||||||
|
leg.faintLevel !== null ? 'text-red-300' :
|
||||||
|
leg.wasTransferred ? 'text-blue-300' :
|
||||||
|
leg.enteredHof ? 'text-yellow-300' :
|
||||||
|
'text-green-300'
|
||||||
|
}`}>
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
{leg.enteredHof && leg.faintLevel === null && (
|
||||||
|
<div className="text-yellow-300">Hall of Fame</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="w-2 h-2 bg-gray-900 dark:bg-gray-700 rotate-45 -mt-1" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TimelineGrid({
|
||||||
|
lineage,
|
||||||
|
allLegOrders,
|
||||||
|
}: {
|
||||||
|
lineage: LineageEntry
|
||||||
|
allLegOrders: number[]
|
||||||
|
}) {
|
||||||
|
const legMap = new Map(lineage.legs.map((l) => [l.legOrder, l]))
|
||||||
|
const minLeg = lineage.legs[0].legOrder
|
||||||
|
const maxLeg = lineage.legs[lineage.legs.length - 1].legOrder
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="grid"
|
||||||
|
style={{
|
||||||
|
gridTemplateColumns: `repeat(${allLegOrders.length}, minmax(48px, 1fr))`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{allLegOrders.map((legOrder, i) => {
|
||||||
|
const leg = legMap.get(legOrder)
|
||||||
|
const inRange = legOrder >= minLeg && legOrder <= maxLeg
|
||||||
|
const showLeftLine = inRange && i > 0 && allLegOrders[i - 1] >= minLeg
|
||||||
|
const showRightLine =
|
||||||
|
inRange &&
|
||||||
|
i < allLegOrders.length - 1 &&
|
||||||
|
allLegOrders[i + 1] <= maxLeg
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={legOrder}
|
||||||
|
className="flex justify-center relative"
|
||||||
|
style={{ height: '20px' }}
|
||||||
|
>
|
||||||
|
{/* Left half connector */}
|
||||||
|
{showLeftLine && (
|
||||||
|
<div className="absolute top-[9px] left-0 right-1/2 h-0.5 bg-gray-300 dark:bg-gray-600" />
|
||||||
|
)}
|
||||||
|
{/* Right half connector */}
|
||||||
|
{showRightLine && (
|
||||||
|
<div className="absolute top-[9px] left-1/2 right-0 h-0.5 bg-gray-300 dark:bg-gray-600" />
|
||||||
|
)}
|
||||||
|
{/* Dot or empty */}
|
||||||
|
{leg ? (
|
||||||
|
<div className="relative z-10">
|
||||||
|
<LegDot leg={leg} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="h-4" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function LineageCard({
|
||||||
|
lineage,
|
||||||
|
allLegOrders,
|
||||||
|
}: {
|
||||||
|
lineage: LineageEntry
|
||||||
|
allLegOrders: number[]
|
||||||
|
}) {
|
||||||
|
const firstLeg = lineage.legs[0]
|
||||||
|
const displayPokemon = firstLeg.currentPokemon ?? firstLeg.pokemon
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-4 flex items-center gap-4">
|
||||||
|
{/* Left: Pokemon sprite + nickname */}
|
||||||
|
<div className="flex flex-col items-center min-w-[80px]">
|
||||||
|
{displayPokemon.spriteUrl ? (
|
||||||
|
<img
|
||||||
|
src={displayPokemon.spriteUrl}
|
||||||
|
alt={displayPokemon.name}
|
||||||
|
className="w-16 h-16"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-16 h-16 rounded-full bg-gray-200 dark:bg-gray-600 flex items-center justify-center text-xl font-bold text-gray-600 dark:text-gray-300">
|
||||||
|
{displayPokemon.name[0].toUpperCase()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<span className="text-sm font-semibold text-gray-900 dark:text-gray-100 mt-1 text-center">
|
||||||
|
{lineage.nickname || lineage.pokemon.name}
|
||||||
|
</span>
|
||||||
|
{lineage.nickname && (
|
||||||
|
<span className="text-[10px] text-gray-500 dark:text-gray-400">
|
||||||
|
{lineage.pokemon.name}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Center: Timeline */}
|
||||||
|
<div className="flex-1 overflow-x-auto py-2">
|
||||||
|
<TimelineGrid lineage={lineage} allLegOrders={allLegOrders} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right: Status badge */}
|
||||||
|
<div className="shrink-0">
|
||||||
|
<span
|
||||||
|
className={`px-2.5 py-1 rounded-full text-xs font-medium ${
|
||||||
|
lineage.status === 'alive'
|
||||||
|
? 'bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300'
|
||||||
|
: 'bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{lineage.status === 'alive' ? 'Alive' : 'Dead'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GenlockeLineage({ genlockeId }: GenlockeLineageProps) {
|
||||||
|
const { data, isLoading, error } = useGenlockeLineages(genlockeId)
|
||||||
|
|
||||||
|
const allLegOrders = useMemo(() => {
|
||||||
|
if (!data) return []
|
||||||
|
return [...new Set(data.lineages.flatMap((l) => l.legs.map((leg) => leg.legOrder)))].sort(
|
||||||
|
(a, b) => a - b
|
||||||
|
)
|
||||||
|
}, [data])
|
||||||
|
|
||||||
|
const legGameNames = useMemo(() => {
|
||||||
|
if (!data) return new Map<number, string>()
|
||||||
|
const map = new Map<number, string>()
|
||||||
|
for (const lineage of data.lineages) {
|
||||||
|
for (const leg of lineage.legs) {
|
||||||
|
map.set(leg.legOrder, leg.gameName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map
|
||||||
|
}, [data])
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<div className="w-6 h-6 border-4 border-blue-600 border-t-transparent rounded-full animate-spin" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg bg-red-50 dark:bg-red-900/20 p-4 text-red-700 dark:text-red-400">
|
||||||
|
Failed to load lineage data.
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data || data.totalLineages === 0) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg bg-gray-50 dark:bg-gray-800/50 p-6 text-center text-gray-500 dark:text-gray-400">
|
||||||
|
No Pokemon have been transferred between legs yet.
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Summary bar */}
|
||||||
|
<div className="flex flex-wrap items-center gap-4 text-sm">
|
||||||
|
<span className="font-semibold text-gray-900 dark:text-gray-100">
|
||||||
|
{data.totalLineages} lineage{data.totalLineages !== 1 ? 's' : ''} across{' '}
|
||||||
|
{allLegOrders.length} leg{allLegOrders.length !== 1 ? 's' : ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Column header row */}
|
||||||
|
<div className="flex items-center gap-4 px-4">
|
||||||
|
{/* Spacer matching pokemon info column */}
|
||||||
|
<div className="min-w-[80px]" />
|
||||||
|
{/* Leg headers */}
|
||||||
|
<div
|
||||||
|
className="flex-1 grid"
|
||||||
|
style={{
|
||||||
|
gridTemplateColumns: `repeat(${allLegOrders.length}, minmax(48px, 1fr))`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{allLegOrders.map((legOrder) => (
|
||||||
|
<div key={legOrder} className="flex flex-col items-center">
|
||||||
|
<span className="text-[10px] font-medium text-gray-500 dark:text-gray-400 whitespace-nowrap">
|
||||||
|
Leg {legOrder}
|
||||||
|
</span>
|
||||||
|
<span className="text-[9px] text-gray-400 dark:text-gray-500 whitespace-nowrap truncate max-w-[48px]">
|
||||||
|
{legGameNames.get(legOrder)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{/* Spacer matching status badge */}
|
||||||
|
<div className="shrink-0 w-[52px]" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Lineage cards */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
{data.lineages.map((lineage) => (
|
||||||
|
<LineageCard
|
||||||
|
key={lineage.legs[0].encounterId}
|
||||||
|
lineage={lineage}
|
||||||
|
allLegOrders={allLegOrders}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ export { EncounterModal } from './EncounterModal'
|
|||||||
export { EndRunModal } from './EndRunModal'
|
export { EndRunModal } from './EndRunModal'
|
||||||
export { GameCard } from './GameCard'
|
export { GameCard } from './GameCard'
|
||||||
export { GenlockeGraveyard } from './GenlockeGraveyard'
|
export { GenlockeGraveyard } from './GenlockeGraveyard'
|
||||||
|
export { GenlockeLineage } from './GenlockeLineage'
|
||||||
export { HofTeamModal } from './HofTeamModal'
|
export { HofTeamModal } from './HofTeamModal'
|
||||||
export { GameGrid } from './GameGrid'
|
export { GameGrid } from './GameGrid'
|
||||||
export { Layout } from './Layout'
|
export { Layout } from './Layout'
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { advanceLeg, createGenlocke, getGamesByRegion, getGenlockes, getGenlocke, getGenlockeGraveyard, getLegSurvivors } from '../api/genlockes'
|
import { advanceLeg, createGenlocke, getGamesByRegion, getGenlockes, getGenlocke, getGenlockeGraveyard, getGenlockeLineages, getLegSurvivors } from '../api/genlockes'
|
||||||
import type { AdvanceLegInput, CreateGenlockeInput } from '../types/game'
|
import type { AdvanceLegInput, CreateGenlockeInput } from '../types/game'
|
||||||
|
|
||||||
export function useGenlockes() {
|
export function useGenlockes() {
|
||||||
@@ -23,6 +23,13 @@ export function useGenlockeGraveyard(id: number) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useGenlockeLineages(id: number) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['genlockes', id, 'lineages'],
|
||||||
|
queryFn: () => getGenlockeLineages(id),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export function useRegions() {
|
export function useRegions() {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['games', 'by-region'],
|
queryKey: ['games', 'by-region'],
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Link, useParams } from 'react-router-dom'
|
import { Link, useParams } from 'react-router-dom'
|
||||||
import { useGenlocke } from '../hooks/useGenlockes'
|
import { useGenlocke } from '../hooks/useGenlockes'
|
||||||
import { usePokemonFamilies } from '../hooks/usePokemon'
|
import { usePokemonFamilies } from '../hooks/usePokemon'
|
||||||
import { GenlockeGraveyard, StatCard, RuleBadges } from '../components'
|
import { GenlockeGraveyard, GenlockeLineage, StatCard, RuleBadges } from '../components'
|
||||||
import type { GenlockeLegDetail, RetiredPokemon, RunStatus } from '../types'
|
import type { GenlockeLegDetail, RetiredPokemon, RunStatus } from '../types'
|
||||||
import { useMemo, useState } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
|
|
||||||
@@ -87,6 +87,7 @@ export function GenlockeDetail() {
|
|||||||
const { data: familiesData } = usePokemonFamilies()
|
const { data: familiesData } = usePokemonFamilies()
|
||||||
|
|
||||||
const [showGraveyard, setShowGraveyard] = useState(false)
|
const [showGraveyard, setShowGraveyard] = useState(false)
|
||||||
|
const [showLineage, setShowLineage] = useState(false)
|
||||||
|
|
||||||
const activeLeg = useMemo(() => {
|
const activeLeg = useMemo(() => {
|
||||||
if (!genlocke) return null
|
if (!genlocke) return null
|
||||||
@@ -297,9 +298,12 @@ export function GenlockeDetail() {
|
|||||||
Graveyard
|
Graveyard
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
disabled
|
onClick={() => setShowLineage((v) => !v)}
|
||||||
className="px-4 py-2 bg-gray-200 dark:bg-gray-700 text-gray-500 dark:text-gray-400 rounded-lg font-medium cursor-not-allowed"
|
className={`px-4 py-2 rounded-lg font-medium transition-colors ${
|
||||||
title="Coming soon"
|
showLineage
|
||||||
|
? 'bg-blue-600 text-white hover:bg-blue-700'
|
||||||
|
: 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-600'
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
Lineage
|
Lineage
|
||||||
</button>
|
</button>
|
||||||
@@ -315,6 +319,16 @@ export function GenlockeDetail() {
|
|||||||
<GenlockeGraveyard genlockeId={id} />
|
<GenlockeGraveyard genlockeId={id} />
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Lineage */}
|
||||||
|
{showLineage && (
|
||||||
|
<section>
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">
|
||||||
|
Pokemon Lineages
|
||||||
|
</h2>
|
||||||
|
<GenlockeLineage genlockeId={id} />
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -302,6 +302,37 @@ export interface AdvanceLegInput {
|
|||||||
transferEncounterIds: number[]
|
transferEncounterIds: number[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Lineage types
|
||||||
|
|
||||||
|
export interface LineageLegEntry {
|
||||||
|
legOrder: number
|
||||||
|
gameName: string
|
||||||
|
encounterId: number
|
||||||
|
pokemon: Pokemon
|
||||||
|
currentPokemon: Pokemon | null
|
||||||
|
nickname: string | null
|
||||||
|
catchLevel: number | null
|
||||||
|
faintLevel: number | null
|
||||||
|
deathCause: string | null
|
||||||
|
isShiny: boolean
|
||||||
|
routeName: string
|
||||||
|
isAlive: boolean
|
||||||
|
enteredHof: boolean
|
||||||
|
wasTransferred: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LineageEntry {
|
||||||
|
nickname: string | null
|
||||||
|
pokemon: Pokemon
|
||||||
|
legs: LineageLegEntry[]
|
||||||
|
status: 'alive' | 'dead'
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GenlockeLineage {
|
||||||
|
lineages: LineageEntry[]
|
||||||
|
totalLineages: number
|
||||||
|
}
|
||||||
|
|
||||||
// Graveyard types
|
// Graveyard types
|
||||||
|
|
||||||
export interface GraveyardEntry {
|
export interface GraveyardEntry {
|
||||||
|
|||||||
Reference in New Issue
Block a user