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:
Julian Tabel
2026-02-09 11:58:38 +01:00
parent 4e00e3cad8
commit d3b65e3c79
9 changed files with 541 additions and 8 deletions

View File

@@ -1,5 +1,5 @@
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[]> {
return api.get('/genlockes')
@@ -21,6 +21,10 @@ export function getGenlockeGraveyard(id: number): Promise<GenlockeGraveyard> {
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[]> {
return api.get(`/genlockes/${genlockeId}/legs/${legOrder}/survivors`)
}

View 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>
)
}

View File

@@ -4,6 +4,7 @@ export { EncounterModal } from './EncounterModal'
export { EndRunModal } from './EndRunModal'
export { GameCard } from './GameCard'
export { GenlockeGraveyard } from './GenlockeGraveyard'
export { GenlockeLineage } from './GenlockeLineage'
export { HofTeamModal } from './HofTeamModal'
export { GameGrid } from './GameGrid'
export { Layout } from './Layout'

View File

@@ -1,5 +1,5 @@
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'
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() {
return useQuery({
queryKey: ['games', 'by-region'],

View File

@@ -1,7 +1,7 @@
import { Link, useParams } from 'react-router-dom'
import { useGenlocke } from '../hooks/useGenlockes'
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 { useMemo, useState } from 'react'
@@ -87,6 +87,7 @@ export function GenlockeDetail() {
const { data: familiesData } = usePokemonFamilies()
const [showGraveyard, setShowGraveyard] = useState(false)
const [showLineage, setShowLineage] = useState(false)
const activeLeg = useMemo(() => {
if (!genlocke) return null
@@ -297,9 +298,12 @@ export function GenlockeDetail() {
Graveyard
</button>
<button
disabled
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"
title="Coming soon"
onClick={() => setShowLineage((v) => !v)}
className={`px-4 py-2 rounded-lg font-medium transition-colors ${
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
</button>
@@ -315,6 +319,16 @@ export function GenlockeDetail() {
<GenlockeGraveyard genlockeId={id} />
</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>
)
}

View File

@@ -302,6 +302,37 @@ export interface AdvanceLegInput {
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
export interface GraveyardEntry {