From 61cfab046ab9730bb711b9157612a4c09caca5ed Mon Sep 17 00:00:00 2001 From: Gulum Date: Sat, 6 Jun 2026 00:17:49 +0200 Subject: [PATCH] FEAT-4: pedigree data module - depth-limited ancestor traversal, lazy expand, cycle guard, rd3t conversion (15 tests) Co-Authored-By: Claude Opus 4.8 (1M context) --- gerbil-manager-web/src/api/litters.ts | 7 + gerbil-manager-web/src/api/pedigree.ts | 22 ++ .../src/pedigree/__tests__/build.test.ts | 302 ++++++++++++++++++ gerbil-manager-web/src/pedigree/build.ts | 182 +++++++++++ gerbil-manager-web/src/pedigree/chipColors.ts | 53 +++ gerbil-manager-web/src/pedigree/source.ts | 38 +++ gerbil-manager-web/src/pedigree/types.ts | 50 +++ 7 files changed, 654 insertions(+) create mode 100644 gerbil-manager-web/src/api/litters.ts create mode 100644 gerbil-manager-web/src/api/pedigree.ts create mode 100644 gerbil-manager-web/src/pedigree/__tests__/build.test.ts create mode 100644 gerbil-manager-web/src/pedigree/build.ts create mode 100644 gerbil-manager-web/src/pedigree/chipColors.ts create mode 100644 gerbil-manager-web/src/pedigree/source.ts create mode 100644 gerbil-manager-web/src/pedigree/types.ts diff --git a/gerbil-manager-web/src/api/litters.ts b/gerbil-manager-web/src/api/litters.ts new file mode 100644 index 0000000..26760ad --- /dev/null +++ b/gerbil-manager-web/src/api/litters.ts @@ -0,0 +1,7 @@ +/** Typed API functions for the Würfe (litters) resource. */ +import { api, resources } from './client' +import type { Litter } from './types' + +export function getLitter(id: string): Promise { + return api.get(`${resources.litters}/${id}`) +} diff --git a/gerbil-manager-web/src/api/pedigree.ts b/gerbil-manager-web/src/api/pedigree.ts new file mode 100644 index 0000000..a0e0659 --- /dev/null +++ b/gerbil-manager-web/src/api/pedigree.ts @@ -0,0 +1,22 @@ +/** + * FEAT-4: Stammbaum-bezogene API-Aufrufe. + * + * Der Inzuchtkoeffizient wird serverseitig über die VOLLE Ahnentiefe berechnet + * (Board-Entscheidung; UI zeigt 4 Generationen, Koeffizient zählt alle). + * Endpunkt entsteht in DATA-2-Folgearbeit — bis dahin antwortet er mit 404 und + * die UI zeigt einen „wird berechnet …“-Zustand. + */ +import { api, resources } from './client' + +export interface InbreedingCoefficient { + /** Koeffizient nach Wright, 0..1. */ + coefficient: number +} + +export async function getInbreedingCoefficient(gerbilId: string): Promise { + const raw = await api.get( + `${resources.gerbils}/${gerbilId}/inbreeding-coefficient`, + ) + // Defensiv: Endpunkt-Form ist noch nicht final (nacktes number vs. Objekt). + return typeof raw === 'number' ? raw : raw.coefficient +} diff --git a/gerbil-manager-web/src/pedigree/__tests__/build.test.ts b/gerbil-manager-web/src/pedigree/__tests__/build.test.ts new file mode 100644 index 0000000..1d30804 --- /dev/null +++ b/gerbil-manager-web/src/pedigree/__tests__/build.test.ts @@ -0,0 +1,302 @@ +/** + * FEAT-4: Tests für den Ahnenbaum-Builder (build.ts). + * + * Reine Daten-Tests (keine DOM/React): In-Memory-PedigreeSource mit + * Aufruf-Zählern statt API. + */ +import { describe, expect, it } from 'vitest' +import type { Gerbil, Litter } from '../../api/types' +import { + ancestorsAt, + buildPedigree, + collectNodes, + expandPedigree, + toRawNodeDatum, +} from '../build' +import type { AnimalNode, PedigreeNode, PedigreeSource } from '../types' + +/* ── Testdaten-Helfer ─────────────────────────────────────────── */ + +function makeGerbil(id: string, name: string, litterId: string | null = null): Gerbil { + return { + id, + name, + gender: 'unknown', + status: 'Active', + dateOfBirth: null, + dateOfDeath: null, + causeOfDeath: null, + goHomeDate: null, + litterId, + enclosureId: null, + colorVarietyId: null, + originContactId: null, + receiverContactId: null, + genotype: null, + notes: null, + } +} + +function makeLitter(id: string, fatherId: string | null, motherId: string | null): Litter { + return { id, name: `Wurf ${id}`, date: '2025-01-01', totalBorn: null, fatherId, motherId } +} + +interface CountingSource extends PedigreeSource { + gerbilCalls: Map + litterCalls: Map +} + +function makeSource(gerbils: Gerbil[], litters: Litter[]): CountingSource { + const gerbilMap = new Map(gerbils.map((g) => [g.id, g])) + const litterMap = new Map(litters.map((l) => [l.id, l])) + const gerbilCalls = new Map() + const litterCalls = new Map() + return { + gerbilCalls, + litterCalls, + getGerbil: (id) => { + gerbilCalls.set(id, (gerbilCalls.get(id) ?? 0) + 1) + return Promise.resolve(gerbilMap.get(id) ?? null) + }, + getLitter: (id) => { + litterCalls.set(id, (litterCalls.get(id) ?? 0) + 1) + return Promise.resolve(litterMap.get(id) ?? null) + }, + } +} + +/** + * Standard-Familie: + * kind ← (vater, mutter); vater ← (opa, oma); mutter ohne Wurf. + * opa hat einen Wurf (uropas), der NICHT vorab geladen werden soll, + * wenn die Tiefe es begrenzt. + */ +function familySource(): CountingSource { + return makeSource( + [ + makeGerbil('kind', 'Kind', 'wurf-kind'), + makeGerbil('vater', 'Vater', 'wurf-vater'), + makeGerbil('mutter', 'Mutter', null), + makeGerbil('opa', 'Opa', 'wurf-opa'), + makeGerbil('oma', 'Oma', null), + makeGerbil('uropa', 'Uropa', null), + ], + [ + makeLitter('wurf-kind', 'vater', 'mutter'), + makeLitter('wurf-vater', 'opa', 'oma'), + makeLitter('wurf-opa', 'uropa', null), + ], + ) +} + +function animal(node: PedigreeNode | undefined): AnimalNode { + if (!node || node.kind !== 'animal') throw new Error(`Tier-Knoten erwartet, war: ${node?.kind}`) + return node +} + +/* ── buildPedigree ────────────────────────────────────────────── */ + +describe('buildPedigree', () => { + it('baut den Baum mit Pfaden, Vater-vor-Mutter und Platzhaltern für Gründertiere', async () => { + const root = await buildPedigree(familySource(), 'kind') + expect(root).not.toBeNull() + expect(root!.gerbil.name).toBe('Kind') + expect(root!.path).toBe('') + + const vater = animal(root!.father) + const mutter = animal(root!.mother) + expect(vater.gerbil.name).toBe('Vater') + expect(vater.path).toBe('v') + expect(mutter.gerbil.name).toBe('Mutter') + expect(mutter.path).toBe('m') + + // Mutter ohne Wurf: beide Eltern als „unbekannt“-Platzhalter (Symmetrie). + expect(mutter.father).toEqual({ kind: 'unknown', path: 'mv' }) + expect(mutter.mother).toEqual({ kind: 'unknown', path: 'mm' }) + expect(mutter.expandable).toBe(false) + }) + + it('liefert null, wenn das Wurzeltier nicht existiert', async () => { + expect(await buildPedigree(familySource(), 'gibt-es-nicht')).toBeNull() + }) + + it('begrenzt die Tiefe und markiert die Ladegrenze als erweiterbar', async () => { + const root = await buildPedigree(familySource(), 'kind', 1) + const vater = animal(root!.father) + const mutter = animal(root!.mother) + + // Generation 1 ist die Grenze: keine Eltern geladen. + expect(vater.father).toBeUndefined() + expect(vater.mother).toBeUndefined() + // Vater hat einen Wurf → nachladbar; Mutter ohne Wurf → nicht. + expect(vater.expandable).toBe(true) + expect(mutter.expandable).toBe(false) + }) + + it('Standardtiefe 4: lädt genau 4 Generationen', async () => { + const root = await buildPedigree(familySource(), 'kind') + // Uropa liegt in Generation 3 (kind → vater → opa → uropa) und hat keinen + // Wurf → seine Eltern sind als Platzhalter geladen (Generation 4 = Grenze). + const uropa = animal(animal(animal(root!.father).father).father) + expect(uropa.gerbil.name).toBe('Uropa') + expect(uropa.father).toEqual({ kind: 'unknown', path: 'vvvv' }) + }) + + it('setzt Platzhalter für fehlenden Eltern-Eintrag im Wurf und für 404-Tiere', async () => { + const source = makeSource( + [makeGerbil('a', 'A', 'wurf-a')], + // Vater-Id zeigt ins Leere (gelöschtes Tier), Mutter fehlt im Wurf. + [makeLitter('wurf-a', 'geloescht', null)], + ) + const root = await buildPedigree(source, 'a') + expect(root!.father).toEqual({ kind: 'unknown', path: 'v' }) + expect(root!.mother).toEqual({ kind: 'unknown', path: 'm' }) + }) + + it('behandelt einen 404-Wurf wie unbekannte Eltern', async () => { + const source = makeSource([makeGerbil('a', 'A', 'wurf-weg')], []) + const root = await buildPedigree(source, 'a') + expect(root!.father).toEqual({ kind: 'unknown', path: 'v' }) + expect(root!.mother).toEqual({ kind: 'unknown', path: 'm' }) + }) + + it('beendet Zyklen (Tier als eigener Vorfahre) ohne Endlosschleife', async () => { + const source = makeSource( + [makeGerbil('a', 'A', 'wurf-a'), makeGerbil('b', 'B', 'wurf-b')], + [makeLitter('wurf-a', 'b', null), makeLitter('wurf-b', 'a', null)], + ) + const root = await buildPedigree(source, 'a') + const b = animal(root!.father) + const aAgain = animal(b.father) + expect(aAgain.gerbil.id).toBe('a') + // Zyklus: keine Eltern, nicht erweiterbar (trotz vorhandenem Wurf). + expect(aAgain.father).toBeUndefined() + expect(aAgain.expandable).toBe(false) + }) + + it('dasselbe Tier darf an mehreren Positionen stehen (Inzucht ist kein Zyklus)', async () => { + // Vater und Mutter haben denselben Vater (Opa) — Ahnenschwund, kein Zyklus. + const source = makeSource( + [ + makeGerbil('kind', 'Kind', 'w1'), + makeGerbil('vater', 'Vater', 'w2'), + makeGerbil('mutter', 'Mutter', 'w3'), + makeGerbil('opa', 'Opa', null), + ], + [makeLitter('w1', 'vater', 'mutter'), makeLitter('w2', 'opa', null), makeLitter('w3', 'opa', null)], + ) + const root = await buildPedigree(source, 'kind') + expect(animal(animal(root!.father).father).gerbil.id).toBe('opa') + expect(animal(animal(root!.mother).father).gerbil.id).toBe('opa') + }) +}) + +/* ── expandPedigree ───────────────────────────────────────────── */ + +describe('expandPedigree', () => { + it('lädt an der Grenze eine weitere Generation nach und teilt unveränderte Äste', async () => { + const source = familySource() + const root = await buildPedigree(source, 'kind', 1) + const vorher = animal(root!.father) + expect(vorher.father).toBeUndefined() + + const next = await expandPedigree(source, root!, 'v') + // Neuer Baum: erweiterter Ast hat jetzt Eltern … + const vater = animal(next.father) + expect(animal(vater.father).gerbil.name).toBe('Opa') + expect(animal(vater.mother).gerbil.name).toBe('Oma') + // … Wurzel ist ein neues Objekt, der unberührte Mutter-Ast wird geteilt. + expect(next).not.toBe(root) + expect(next.mother).toBe(root!.mother) + }) + + it('erweiterte Knoten an der neuen Grenze sind wiederum erweiterbar', async () => { + const source = familySource() + const root = await buildPedigree(source, 'kind', 1) + const next = await expandPedigree(source, root!, 'v') + // Opa (Generation 2, neue Grenze) hat einen Wurf → erweiterbar. + expect(animal(animal(next.father).father).expandable).toBe(true) + }) +}) + +/* ── Caching-Vertrag der Quelle ───────────────────────────────── */ + +describe('Quellen-Aufrufe', () => { + it('fragt jedes Tier und jeden Wurf höchstens einmal pro Aufbau an', async () => { + const source = familySource() + await buildPedigree(source, 'kind') + for (const [id, count] of source.gerbilCalls) { + expect(count, `Tier ${id} mehrfach geladen`).toBe(1) + } + for (const [id, count] of source.litterCalls) { + expect(count, `Wurf ${id} mehrfach geladen`).toBe(1) + } + }) +}) + +/* ── toRawNodeDatum / collectNodes ────────────────────────────── */ + +describe('toRawNodeDatum', () => { + it('konvertiert in die react-d3-tree-Struktur (Name, Pfad, Kinder [Vater, Mutter])', async () => { + const root = await buildPedigree(familySource(), 'kind', 1) + const datum = toRawNodeDatum(root!, 'unbekannt') + + expect(datum.name).toBe('Kind') + expect(datum.attributes).toMatchObject({ path: '', kind: 'animal', expandable: false }) + expect(datum.children).toHaveLength(2) + expect(datum.children![0].name).toBe('Vater') + expect(datum.children![0].attributes).toMatchObject({ path: 'v', expandable: true }) + expect(datum.children![1].name).toBe('Mutter') + // Grenz-Knoten: keine children-Eigenschaft (nicht geladen ≠ leer). + expect(datum.children![0].children).toBeUndefined() + }) + + it('rendert Platzhalter mit dem übergebenen Label', async () => { + const root = await buildPedigree(familySource(), 'mutter') + const datum = toRawNodeDatum(root!, 'unbekannt') + expect(datum.children![0]).toEqual({ + name: 'unbekannt', + attributes: { path: 'v', kind: 'unknown' }, + }) + }) + + it('collectNodes indiziert jeden Knoten unter seinem Pfad', async () => { + const root = await buildPedigree(familySource(), 'kind') + const nodes = collectNodes(root!) + expect(nodes.get('')).toBe(root) + expect(animal(nodes.get('vv')).gerbil.name).toBe('Opa') + expect(nodes.get('mv')?.kind).toBe('unknown') + }) +}) + +/* ── ancestorsAt (Druckansicht) ───────────────────────────────── */ + +describe('ancestorsAt', () => { + it('liefert die Plätze einer Generation in Ahnentafel-Ordnung mit Lücken', async () => { + const root = await buildPedigree(familySource(), 'kind') + + expect(ancestorsAt(root!, 0)).toEqual([root]) + + const gen1 = ancestorsAt(root!, 1) + expect(gen1.map((n) => (n?.kind === 'animal' ? n.gerbil.name : n?.kind))).toEqual([ + 'Vater', + 'Mutter', + ]) + + const gen2 = ancestorsAt(root!, 2) + expect(gen2).toHaveLength(4) + expect(animal(gen2[0]!).gerbil.name).toBe('Opa') + expect(animal(gen2[1]!).gerbil.name).toBe('Oma') + expect(gen2[2]?.kind).toBe('unknown') // Mutter-Seite: Platzhalter + expect(gen2[3]?.kind).toBe('unknown') + + const gen3 = ancestorsAt(root!, 3) + expect(gen3).toHaveLength(8) + expect(animal(gen3[0]!).gerbil.name).toBe('Uropa') + expect(gen3[1]?.kind).toBe('unknown') // Opas Wurf ohne Mutter + // Hinter Oma (ohne Wurf, Platzhalter-Eltern) … + expect(gen3[2]?.kind).toBe('unknown') + // … hinter Platzhaltern: leere Plätze. + expect(gen3.slice(4)).toEqual([null, null, null, null]) + }) +}) diff --git a/gerbil-manager-web/src/pedigree/build.ts b/gerbil-manager-web/src/pedigree/build.ts new file mode 100644 index 0000000..2e5b60d --- /dev/null +++ b/gerbil-manager-web/src/pedigree/build.ts @@ -0,0 +1,182 @@ +/** + * FEAT-4: Aufbau und Erweiterung des Ahnenbaums + Konvertierung nach + * react-d3-tree. + * + * Clientseitige Traversierung über die vorhandenen Endpunkte: + * Tier → sein Wurf (litterId) → Vater/Mutter → rekursiv, tiefenbegrenzt + * (Standard 4 Generationen, Board-Entscheidung). Tiefere Generationen werden + * auf Wunsch nachgeladen (expandPedigree). + * + * Zyklenschutz: Steht ein Tier bereits auf dem Pfad zur Wurzel (defekte Daten: + * „eigener Vorfahre“), wird die Rekursion dort beendet und der Knoten nicht + * erweiterbar markiert. + */ +import type { RawNodeDatum } from 'react-d3-tree' +import type { Gerbil } from '../api/types' +import type { AnimalNode, PedigreeNode, PedigreePath, PedigreeSource, UnknownNode } from './types' + +/** Standard-Generationstiefe der Erstanzeige (Board: UI 4 Generationen). */ +export const DEFAULT_GENERATIONS = 4 + +function unknown(path: PedigreePath): UnknownNode { + return { kind: 'unknown', path } +} + +async function parentNode( + source: PedigreeSource, + id: string | null | undefined, + path: PedigreePath, + generationsLeft: number, + ancestors: ReadonlySet, +): Promise { + if (!id) return unknown(path) + const gerbil = await source.getGerbil(id) + if (!gerbil) return unknown(path) + return buildAnimal(source, gerbil, path, generationsLeft, ancestors) +} + +async function buildAnimal( + source: PedigreeSource, + gerbil: Gerbil, + path: PedigreePath, + generationsLeft: number, + ancestors: ReadonlySet, +): Promise { + const isCycle = ancestors.has(gerbil.id) + if (isCycle || generationsLeft <= 0) { + // Ladegrenze (oder Zyklus): Eltern nicht laden. Erweiterbar nur, wenn es + // einen Wurf gibt und kein Zyklus vorliegt. + return { kind: 'animal', path, gerbil, expandable: !isCycle && gerbil.litterId != null } + } + + if (!gerbil.litterId) { + // Gründertier ohne erfassten Wurf: Eltern unbekannt. Platzhalter wahren + // die Symmetrie der Ahnentafel. + return { + kind: 'animal', + path, + gerbil, + father: unknown(path + 'v'), + mother: unknown(path + 'm'), + expandable: false, + } + } + + // Wurf laden; bei 404 (gelöschter Wurf) zählen die Eltern als unbekannt. + const litter = await source.getLitter(gerbil.litterId) + const nextAncestors = new Set(ancestors) + nextAncestors.add(gerbil.id) + const [father, mother] = await Promise.all([ + parentNode(source, litter?.fatherId, path + 'v', generationsLeft - 1, nextAncestors), + parentNode(source, litter?.motherId, path + 'm', generationsLeft - 1, nextAncestors), + ]) + return { kind: 'animal', path, gerbil, father, mother, expandable: false } +} + +/** + * Baut den Ahnenbaum für ein Wurzeltier. `null`, wenn das Tier selbst nicht + * existiert (404). + */ +export async function buildPedigree( + source: PedigreeSource, + rootId: string, + generations: number = DEFAULT_GENERATIONS, +): Promise { + const root = await source.getGerbil(rootId) + if (!root) return null + return buildAnimal(source, root, '', generations, new Set()) +} + +/** + * Lädt für den Knoten an `path` weitere `generations` Vorfahren-Generationen + * nach und liefert eine NEUE Wurzel (unveränderte Äste werden strukturell + * geteilt — geeignet für React-State). + */ +export async function expandPedigree( + source: PedigreeSource, + root: AnimalNode, + path: PedigreePath, + generations = 1, +): Promise { + const next = await expandAt(source, root, path, new Set(), generations) + return next as AnimalNode +} + +async function expandAt( + source: PedigreeSource, + node: PedigreeNode, + target: PedigreePath, + ancestors: ReadonlySet, + generations: number, +): Promise { + if (node.kind === 'unknown') return node + if (node.path === target) { + return buildAnimal(source, node.gerbil, node.path, generations, ancestors) + } + if (!target.startsWith(node.path)) return node + + const branch = target.charAt(node.path.length) // 'v' | 'm' + const nextAncestors = new Set(ancestors) + nextAncestors.add(node.gerbil.id) + if (branch === 'v' && node.father) { + return { ...node, father: await expandAt(source, node.father, target, nextAncestors, generations) } + } + if (branch === 'm' && node.mother) { + return { ...node, mother: await expandAt(source, node.mother, target, nextAncestors, generations) } + } + return node +} + +/* ── Konvertierung für react-d3-tree ──────────────────────────── */ + +/** + * Konvertiert den Ahnenbaum in die react-d3-tree-Struktur. Kinder eines + * Knotens sind [Vater, Mutter] (Vater oben — klassische Ahnentafel-Ordnung). + * Die Karten-Renderfunktion findet den vollen Knoten über `attributes.path` + * (siehe collectNodes); RawNodeDatum.attributes erlaubt nur flache Primitive. + */ +export function toRawNodeDatum(node: PedigreeNode, unknownLabel: string): RawNodeDatum { + if (node.kind === 'unknown') { + return { name: unknownLabel, attributes: { path: node.path, kind: 'unknown' } } + } + const datum: RawNodeDatum = { + name: node.gerbil.name, + attributes: { path: node.path, kind: 'animal', expandable: node.expandable }, + } + if (node.father && node.mother) { + datum.children = [ + toRawNodeDatum(node.father, unknownLabel), + toRawNodeDatum(node.mother, unknownLabel), + ] + } + return datum +} + +/** Index aller Knoten nach Position (für die Karten-Renderfunktion). */ +export function collectNodes(root: PedigreeNode): Map { + const map = new Map() + const walk = (node: PedigreeNode): void => { + map.set(node.path, node) + if (node.kind === 'animal') { + if (node.father) walk(node.father) + if (node.mother) walk(node.mother) + } + } + walk(root) + return map +} + +/** + * Alle Plätze einer Generation in Ahnentafel-Ordnung (Vater-Linie zuerst). + * Länge = 2^generation; `null` = Platz nicht belegt/geladen (Druckansicht + * rendert dafür leere Zellen). + */ +export function ancestorsAt(root: PedigreeNode, generation: number): (PedigreeNode | null)[] { + let slots: (PedigreeNode | null)[] = [root] + for (let g = 0; g < generation; g++) { + slots = slots.flatMap((node) => + node && node.kind === 'animal' ? [node.father ?? null, node.mother ?? null] : [null, null], + ) + } + return slots +} diff --git a/gerbil-manager-web/src/pedigree/chipColors.ts b/gerbil-manager-web/src/pedigree/chipColors.ts new file mode 100644 index 0000000..4cb1d98 --- /dev/null +++ b/gerbil-manager-web/src/pedigree/chipColors.ts @@ -0,0 +1,53 @@ +/** + * FEAT-4: Anzeigefarben der Farbschlag-Chips auf den Stammbaum-Karten. + * + * Reine Präsentation (angenäherte Fellfarben) — KEINE Genetik. Schlüssel sind + * die Basisnamen aus dem GEN-1-Katalog (src/genetics/catalog.ts BASE_COLORS); + * Modifikatoren („… Schecke“, „… Rex“) werden über den Basisnamen-Präfix + * aufgelöst. Unbekannte Namen → null, die Karte nutzt dann den neutralen Chip. + */ +export interface ChipColor { + readonly bg: string + readonly fg: string +} + +const CHIP_COLORS: Record = { + // Colourpoint / C-Serie + 'Pink Eyed White (PEW)': { bg: '#f7f3ec', fg: '#8a7d6b' }, + Hermelin: { bg: '#f3ede2', fg: '#8a7d6b' }, + Himalaya: { bg: '#efe6d8', fg: '#8a7d6b' }, + Zobel: { bg: '#6b4f3a', fg: '#ffffff' }, + // Schimmel + Schwarzschimmel: { bg: '#7a7470', fg: '#ffffff' }, + Rotaugenschimmel: { bg: '#c0a99a', fg: '#3b3026' }, + // Schwarzäugige Vollfarben + Agouti: { bg: '#b3854d', fg: '#ffffff' }, + Schwarz: { bg: '#2f2a26', fg: '#ffffff' }, + Silberagouti: { bg: '#b9b3a8', fg: '#3b3026' }, + Anthrazit: { bg: '#5a5550', fg: '#ffffff' }, + Algierfuchs: { bg: '#c98a3d', fg: '#ffffff' }, + Blau: { bg: '#8a93a5', fg: '#ffffff' }, + // Rotäugige Vollfarben + Gold: { bg: '#d9a441', fg: '#3b3026' }, + Platin: { bg: '#c8c2bd', fg: '#3b3026' }, + Goldfuchs: { bg: '#e0b15e', fg: '#3b3026' }, + Rotfuchs: { bg: '#b56a3c', fg: '#ffffff' }, + 'dd Gold': { bg: '#e3c98f', fg: '#3b3026' }, + 'dd Platin': { bg: '#d9d4cf', fg: '#3b3026' }, +} + +/** Chipfarbe für einen Farbschlag-Namen (inkl. Modifikator-Suffixen). */ +export function chipColorFor(farbschlag: string): ChipColor | null { + const exact = CHIP_COLORS[farbschlag] + if (exact) return exact + // Modifikatoren abgetrennt: längster passender Basisname gewinnt. + let best: ChipColor | null = null + let bestLength = 0 + for (const [name, color] of Object.entries(CHIP_COLORS)) { + if (farbschlag.startsWith(name + ' ') && name.length > bestLength) { + best = color + bestLength = name.length + } + } + return best +} diff --git a/gerbil-manager-web/src/pedigree/source.ts b/gerbil-manager-web/src/pedigree/source.ts new file mode 100644 index 0000000..cd1231c --- /dev/null +++ b/gerbil-manager-web/src/pedigree/source.ts @@ -0,0 +1,38 @@ +/** + * FEAT-4: API-gestützte PedigreeSource mit Cache. + * + * Der Cache lebt so lange wie die Stammbaum-Seite gemountet ist und bleibt + * beim Umwurzeln (Tipp auf eine Karte) erhalten — gemeinsame Vorfahren werden + * nur einmal geladen, der Wechsel fühlt sich dadurch unmittelbar an. + * + * 404 → null („unbekannt“); andere Fehler werden NICHT gecacht (erneuter + * Versuch möglich) und an den Aufrufer durchgereicht. + */ +import { ApiError } from '../api/client' +import { getGerbil } from '../api/gerbils' +import { getLitter } from '../api/litters' +import type { Gerbil, Litter } from '../api/types' +import type { PedigreeSource } from './types' + +function cached(load: (id: string) => Promise): (id: string) => Promise { + const cache = new Map>() + return (id: string) => { + let entry = cache.get(id) + if (!entry) { + entry = load(id).catch((err: unknown) => { + if (err instanceof ApiError && err.status === 404) return null + cache.delete(id) + throw err + }) + cache.set(id, entry) + } + return entry + } +} + +export function createApiPedigreeSource(): PedigreeSource { + return { + getGerbil: cached(getGerbil), + getLitter: cached(getLitter), + } +} diff --git a/gerbil-manager-web/src/pedigree/types.ts b/gerbil-manager-web/src/pedigree/types.ts new file mode 100644 index 0000000..d862c8e --- /dev/null +++ b/gerbil-manager-web/src/pedigree/types.ts @@ -0,0 +1,50 @@ +/** + * FEAT-4: Datenmodell des Ahnenbaums (Stammbaum). + * + * Reines TypeScript-Modul ohne React/DOM — testbar und sprachneutral + * (deutsche Texte kommen ausschließlich aus de.ts, in der UI-Schicht). + */ +import type { Gerbil, Litter } from '../api/types' + +/** + * Datenquelle für die Traversierung. In der App von einer gecachten + * API-Implementierung erfüllt (source.ts); in Tests von In-Memory-Maps. + * `null` = nicht vorhanden (z. B. 404) → wird als „unbekannt“ gerendert. + */ +export interface PedigreeSource { + getGerbil(id: string): Promise + getLitter(id: string): Promise +} + +/** + * Position eines Knotens im Ahnenbaum: '' = Wurzeltier, danach pro Generation + * ein Buchstabe — 'v' = Vater, 'm' = Mutter. Beispiel: 'vm' = Großmutter + * väterlicherseits. Der Pfad ist der eindeutige Schlüssel einer POSITION; + * dasselbe Tier kann (bei Inzucht) an mehreren Positionen stehen. + */ +export type PedigreePath = string + +export type PedigreeNode = AnimalNode | UnknownNode + +export interface AnimalNode { + readonly kind: 'animal' + readonly path: PedigreePath + readonly gerbil: Gerbil + /** + * Eltern: entweder BEIDE gesetzt (geladen; ggf. „unbekannt“-Platzhalter) + * oder BEIDE undefined (Ladegrenze der aktuellen Generationstiefe). + */ + readonly father?: PedigreeNode + readonly mother?: PedigreeNode + /** True: Tier hat einen Wurf, dessen Eltern noch nachgeladen werden können. */ + readonly expandable: boolean +} + +/** + * Platzhalter für einen unbekannten Vorfahren. Hält die Geometrie der + * Ahnentafel symmetrisch (Zertifikats-Optik), hat selbst keine Eltern. + */ +export interface UnknownNode { + readonly kind: 'unknown' + readonly path: PedigreePath +}