Compare commits

...

8 Commits

Author SHA1 Message Date
30ee7b6198 AR-5: Seed-Artefakte auf finalem Katalog regeneriert (70 Eintraege, GEN-4/4b)
npm run gen:catalog lief auf rebased HEAD (post-GEN-4b, main 7fe538d).
Beide Artefakte aktualisiert:
- colorVarietySeed.generated.json: 70 Zeilen, Klammer-Notation (Display)
  Neue Namen: Dilute Gold/Platin/Agouti/Silberagouti/Kohlfuchs/Anthrazit/
  Topas/Blaufuchs + Dilute Fuchs-Varianten (Algierfuchs/Goldfuchs/Rotfuchs/Polarfuchs)
- colorVarietySeed.backend.json: 70 Zeilen, frozen symbols (ef/cchm/ch)
  Quelle fuer Pams Backend-Re-Seed-Migration (Guardrail: nie Klammern)
Drift-Guard (catalog-drift.test.ts) laeuft gruen.
Gate: build ✓  eslint ✓  vitest 109/109 ✓
2026-06-06 21:52:44 +02:00
29207e41da AR-5: Committed Katalog-Generator + Drift-Guard + Backend-Artefakt
- gen-seed.mts: deterministischer Generator aus catalog.ts (Single Source);
  emittiert BEIDE Artefakte: generated.json (Klammer-Notation, Display) +
  backend.json (frozen ef/cchm/ch, für Pam EF-Migrationen). Kein magisches
  Artefakt mehr.
- package.json: npm run gen:catalog (npx tsx gen-seed.mts)
- catalog-drift.test.ts: Drift-Guard — liest generated.json von Disk via
  import.meta.url + readFileSync, vergleicht mit live CATALOG; Fail-Meldung
  zeigt 'npm run gen:catalog'. 5 Test-Files, 93 Tests grün.
- colorVarietySeed.backend.json: 66 Zeilen frozen symbols (ef/cchm/ch),
  kein sofortiger Backend-Eingriff (AR-5 Guardrail; Pam konsumiert bei
  nächster Reseed-Migration).
- README.md: Katalog-Generator-Doku (wann laufen, was erzeugt wird).
Gate: build ✓  eslint ✓  vitest 93/93 ✓
2026-06-06 21:51:55 +02:00
7fe538dd5d Merge feature/gen-4 (GEN-4b): 7 Farbarten als Engine-Guard (keine Farbschläge) + Schimmel-Naming-Fix
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 59s
CI / Docker Build & Push (push) Has been cancelled
CI / Frontend Tests (Node/Vite) (push) Has been cancelled
ff42341: CATEGORY_NAMES-Guard (Standard/Colourpoint/Dilute/Fuchs/Fuchsschimmel/Schimmel/CP-Dilute nie als Ergebnis, FK=0);
locusToken E ef/e→'ef' (het-Schimmel matchen konkrete Katalog-Eintraege statt Family-Fallback 'Fuchsschimmel');
baseColourFor Family-Fallback→null; Orangeschimmel A-Restriktion (aa→Kohlfuchsschimmel). Frontend-only. vitest 96, e2e 148.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 21:49:20 +02:00
90f998d189 Merge feature/animal-litters (ANIMAL-LITTERS): 'Wuerfe als Elternteil' auf Tier-Detailseite
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 59s
CI / Docker Build & Push (push) Has been cancelled
CI / Frontend Tests (Node/Vite) (push) Has been cancelled
GerbilDetailPage: Sektion listet Wuerfe wo fatherId|motherId==Tier (Gridify OR-Filter),
je Link /wuerfe/{id} + Vater/Mutter-Badge + Datum + totalBorn; Leer-Zustand. Rueckweg Tier→Wuerfe.
Frontend-only. vitest 104, e2e 160.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 21:42:04 +02:00
d5f4be76be ANIMAL-LITTERS: Tier-Detailseite zeigt Würfe dieses Tiers als Elternteil
- GerbilDetailPage: listLittersPaged({filter: fatherId={id}|motherId={id}}) + neue Sektion 'Würfe als Elternteil' vor den Tabs; je Wurf: Link /wuerfe/{id}, Vater/Mutter-Badge, Datum, Wurfstärke; Leer-Zustand wenn keine Würfe
- de.ts: parentLittersTitle, parentLittersEmpty, parentLittersRoleVater/Mutter (append-only in detail-Sektion)
- e2e: +2 Tests (Fridolin hat Wurf K als Vater; Krümel hat Leer-Zustand)

Gate: vitest 104/104, e2e 160/160, tsc clean
2026-06-06 21:39:14 +02:00
433f9ee592 Merge feature/gen-4 (GEN-4): Farbschlag-Benennung (Dilute-Praefix, REW, Fuchs spezifisch) + BreedingView
Some checks failed
CI / Docker Build & Push (push) Has been cancelled
CI / Frontend Tests (Node/Vite) (push) Has been cancelled
CI / Backend Tests (.NET) (push) Successful in 58s
- dd → 'Dilute '-Praefix (Agouti dd→Dilute Agouti etc.); 4 Dilute-Fuchs-Eintraege gegen nackten 'Fuchs'
- REW = beide C-Allele reduziert (kein volles C) + pp (alle 3 Kombis, A-unabhaengig)
- CP-Fuchs in berechneter Verteilung unterdrueckt
- BreedingResultView: Gencode (Klammer) pro Karte + Karten-Layout-Fix (overflow/responsive)
Frontend-only (engine+catalog.ts+BreedingResultView+index.css). vitest 95, e2e 148.
OFFEN als Follow-up: 7 Kategorie-Eintraege entfernen, PEW=REW-Konsolidierung, Backend-Re-Seed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 21:34:17 +02:00
92dd614269 Merge feature/namegen-frontend (FEAT-NAMEGEN-FE UC-1): 'Name vorschlagen'-Button (Gemini, live)
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 1m2s
CI / Frontend Tests (Node/Vite) (push) Successful in 9m33s
CI / Docker Build & Push (push) Failing after 9s
NameSuggestPanel + names.ts + GerbilForm-Button (Anfangsbuchstabe/Geschlecht/Kulturen → /names/suggest),
Vorschlag-Klick befuellt Namensfeld; 503 NamesKeyMissing-Hinweis. vitest 104, e2e 156. Frontend-only.
Namensgenerator UC-1 LIVE (Gemini-Key free-tier aktiv).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 21:19:29 +02:00
097a04cbd8 FEAT-NAMEGEN UC-1: 'Name vorschlagen'-Panel auf GerbilFormPage
- api/names.ts: suggestNames() + buildSuggestPath() + NAMEGEN_USAGES (norn/japa/mythg/ger/arb)
- de.ts: namegen-Sektion (button, panelTitle, letter/usages/load/states)
- NameSuggestPanel.tsx: inline-Panel mit Buchstabe/Herkunftskultur-Multiselect, Vorschlagsliste; 503 NamesKeyMissing → freundlicher Hinweis
- GerbilFormPage: 'Name vorschlagen'-Button neben Namensfeld (htmlFor-Pattern), Panel schließt+befüllt bei Auswahl
- vitest: 11 Tests (buildSuggestPath URL-Logik + NAMEGEN_USAGES-Struktur)
- e2e: mock /names/suggest + namesConfigured-Flag; 4 Specs (Panel öffnen, Klick befüllt, Buchstabe-Filter, 503-Fallback)

Gate: vitest 104/104, e2e 156/156, tsc clean
2026-06-06 21:17:42 +02:00
17 changed files with 1126 additions and 53 deletions

View File

@@ -40,6 +40,25 @@ npm run build
npm run preview npm run preview
``` ```
## Farbschlag-Katalog (AR-5)
Der Katalog lebt in `src/genetics/catalog.ts` (Single Source of Truth).
Nach jeder Änderung dort den Generator laufen lassen:
```bash
npm run gen:catalog
```
Erzeugt zwei Artefakte und committet beide:
| Datei | Notation | Verwendung |
|---|---|---|
| `src/genetics/colorVarietySeed.generated.json` | Klammer (`e[f]`, `c[chm]`) | UI-Dropdowns, Frontend-Suche |
| `src/genetics/colorVarietySeed.backend.json` | Frozen symbols (`ef`, `cchm`) | EF-Seed-Migrationen (Pam, DATA-Lane) |
Der vitest-Drift-Guard (`catalog-drift.test.ts`) schlägt fehl, wenn
`generated.json` nach einer Katalog-Änderung nicht aktualisiert wurde.
## E2E-Tests (QA-1, Playwright) ## E2E-Tests (QA-1, Playwright)
```bash ```bash

View File

@@ -361,6 +361,26 @@ export async function installMockApi(page: Page): Promise<MockDb> {
return json(route, 201, contract) return json(route, 201, contract)
} }
// FEAT-NAMEGEN: /names/suggest
if (path === '/names/suggest' && method === 'GET') {
if (!db.namesConfigured) {
return json(route, 503, { code: 'NamesKeyMissing', message: 'Kein API-Key konfiguriert' })
}
const letter = url.searchParams.get('letter')?.toUpperCase()
const allSuggestions = [
{ name: 'Fenrir', meaning: 'Wolf aus der Nordischen Mythologie', origin: 'Nordisch' },
{ name: 'Freya', meaning: 'Göttin der Liebe und Fruchtbarkeit', origin: 'Nordisch' },
{ name: 'Artemis', meaning: 'Göttin der Jagd und des Mondlichts', origin: 'Griech. Mythologie' },
{ name: 'Kira', meaning: 'Strahlendes Licht', origin: 'Japanisch' },
{ name: 'Luna', meaning: 'Mondgöttin', origin: 'Griech. Mythologie' },
{ name: 'Baldur', meaning: 'Gott des Lichts und der Reinheit', origin: 'Nordisch' },
]
const result = letter
? allSuggestions.filter((s) => s.name.startsWith(letter))
: allSuggestions
return json(route, 200, result)
}
// Generische Kollektionen: /<resource> und /<resource>/<id> // Generische Kollektionen: /<resource> und /<resource>/<id>
m = path.match(/^\/([a-z-]+)(?:\/([^/]+))?$/) m = path.match(/^\/([a-z-]+)(?:\/([^/]+))?$/)
const col = m ? collections[m[1]] : undefined const col = m ? collections[m[1]] : undefined

View File

@@ -74,6 +74,8 @@ export interface MockDb {
// ABGABE: Verträge + KI-Inserat-Flag // ABGABE: Verträge + KI-Inserat-Flag
contracts: MockContract[] contracts: MockContract[]
saleAdConfigured: boolean saleAdConfigured: boolean
// FEAT-NAMEGEN: Namensvorschläge — false = 503 NamesKeyMissing simulieren
namesConfigured: boolean
} }
function gerbil( function gerbil(
@@ -292,5 +294,6 @@ export function seedDb(): MockDb {
mailConfigured: true, mailConfigured: true,
contracts: [], contracts: [],
saleAdConfigured: true, saleAdConfigured: true,
namesConfigured: true,
} }
} }

View File

@@ -0,0 +1,59 @@
/** FEAT-NAMEGEN: UC-1 — 'Name vorschlagen'-Panel auf der GerbilFormPage. */
import { de, expect, skipUnlessMock, test } from './fixtures'
const t = de.namegen
const tf = de.pages.gerbils.form
test('Name-vorschlagen-Panel öffnet sich und zeigt Vorschläge (FEAT-NAMEGEN)', async ({ page }) => {
skipUnlessMock()
await page.goto('/rennmaeuse/neu')
await expect(page.getByRole('heading', { name: tf.createTitle })).toBeVisible()
// Panel öffnen
await page.getByRole('button', { name: t.button }).click()
await expect(page.getByText(t.panelTitle)).toBeVisible()
// Vorschläge laden
await page.getByRole('button', { name: t.loadButton }).click()
// Fenrir ist im Mock immer dabei (kein Buchstabe-Filter)
await expect(page.getByRole('button', { name: 'Fenrir' })).toBeVisible()
await expect(page.getByText('Wolf aus der Nordischen Mythologie')).toBeVisible()
})
test('Klick auf Vorschlag befüllt Namensfeld und schließt Panel (FEAT-NAMEGEN)', async ({ page }) => {
skipUnlessMock()
await page.goto('/rennmaeuse/neu')
await page.getByRole('button', { name: t.button }).click()
await page.getByRole('button', { name: t.loadButton }).click()
await expect(page.getByRole('button', { name: 'Fenrir' })).toBeVisible()
// Klick auf Vorschlag 'Fenrir'
await page.getByRole('button', { name: 'Fenrir' }).click()
// Panel geschlossen, Name-Feld befüllt
await expect(page.getByText(t.panelTitle)).toBeHidden()
await expect(page.getByLabel(`${de.pages.gerbils.fields.name} *`)).toHaveValue('Fenrir')
})
test('Buchstabe-Filter schränkt Vorschläge ein (FEAT-NAMEGEN)', async ({ page }) => {
skipUnlessMock()
await page.goto('/rennmaeuse/neu')
await page.getByRole('button', { name: t.button }).click()
await page.getByLabel(t.letterLabel).fill('F')
await page.getByRole('button', { name: t.loadButton }).click()
// Mock gibt nur Namen mit F zurück: Fenrir + Freya
await expect(page.getByRole('button', { name: 'Fenrir' })).toBeVisible()
await expect(page.getByRole('button', { name: 'Freya' })).toBeVisible()
// Artemis (A) nicht sichtbar
await expect(page.getByRole('button', { name: 'Artemis' })).toBeHidden()
})
test('503 NamesKeyMissing zeigt freundlichen Hinweis (FEAT-NAMEGEN)', async ({ page, mockDb }) => {
skipUnlessMock()
if (mockDb) mockDb.namesConfigured = false
await page.goto('/rennmaeuse/neu')
await page.getByRole('button', { name: t.button }).click()
await page.getByRole('button', { name: t.loadButton }).click()
await expect(page.getByText(t.keyMissing)).toBeVisible()
})

View File

@@ -172,3 +172,27 @@ test('Tier bearbeiten — isDeaf Tristate round-trip (FORM-FIELDS-1)', async ({
await page.goto('/rennmaeuse/kruemel/bearbeiten') await page.goto('/rennmaeuse/kruemel/bearbeiten')
await expect(page.getByLabel(t.fields.isDeaf)).toHaveValue('true') await expect(page.getByLabel(t.fields.isDeaf)).toHaveValue('true')
}) })
test('Detailseite: Würfe als Elternteil zeigt verlinkten Wurf (ANIMAL-LITTERS)', async ({ page }) => {
skipUnlessMock()
// Fridolin ist fatherId von 'w-kruemel' (Wurf K)
await page.goto('/rennmaeuse/fridolin')
await expect(page.getByRole('heading', { name: 'Fridolin' })).toBeVisible()
await expect(page.getByText(t.detail.parentLittersTitle)).toBeVisible()
// Wurf K als Link sichtbar
const litterLink = page.getByRole('link', { name: /Wurf K/ })
await expect(litterLink).toBeVisible()
// Vater-Badge sichtbar
await expect(page.getByText(t.detail.parentLittersRoleVater)).toBeVisible()
// Navigation zum Wurf funktioniert
await litterLink.first().click()
await expect(page.getByRole('heading', { name: 'Wurf K' })).toBeVisible()
})
test('Detailseite: Keine Würfe als Elternteil zeigt Leer-Zustand (ANIMAL-LITTERS)', async ({ page }) => {
skipUnlessMock()
// Krümel hat keine Würfe als Elternteil (nur als Kind von w-kruemel)
await page.goto('/rennmaeuse/kruemel')
await expect(page.getByText(t.detail.parentLittersTitle)).toBeVisible()
await expect(page.getByText(t.detail.parentLittersEmpty)).toBeVisible()
})

View File

@@ -0,0 +1,49 @@
/**
* Katalog-Generator — AR-5
*
* Erzeugt zwei Artefakte aus catalog.ts (Single Source of Truth):
*
* colorVarietySeed.generated.json — Display-Notation (Klammer: e[f]/c[chm]/c[h])
* → Quelle für UI-Dropdowns, Frontend-Suche.
*
* colorVarietySeed.backend.json — Frozen internal symbols (ef/cchm/ch)
* → Quelle für künftige EF-Seed-Migrationen (Pam).
* NICHT in Bracket-Notation ändern — Backend-Parser
* erwartet frozen symbols (CR-11-Matcher-Guardrail).
*
* Ausführen nach jeder Änderung an catalog.ts:
* npm run gen:catalog
*
* Der vitest-Drift-Guard (catalog-drift.test.ts) schlägt fehl, wenn
* generated.json veraltet ist — Fehler macht den fehlenden Generator-Lauf sichtbar.
*/
import { BASE_COLORS, CATALOG, representativeGenotype } from './src/genetics/catalog.ts'
import { LOCUS_ORDER } from './src/genetics/loci.ts'
import type { Genotype } from './src/genetics/genotype.ts'
import { writeFileSync } from 'fs'
/** Internal (frozen) display string — concatenates canonical allele symbols without bracket mapping. */
function toInternalString(g: Genotype): string {
return LOCUS_ORDER.filter(
(locus) => locus !== 'Sls' || !(g.Sls[0] === 'sl' && g.Sls[1] === 'sl'),
)
.map((locus) => (g[locus][0] + g[locus][1]).replace(/\?/g, '-'))
.join(' ')
}
// ── Display artefact (bracket notation) ─────────────────────────────────────
const displayPath = './src/genetics/colorVarietySeed.generated.json'
writeFileSync(displayPath, JSON.stringify(CATALOG, null, 2) + '\n')
console.log(`[gen:catalog] display → ${displayPath} (${CATALOG.length} rows)`)
// ── Backend artefact (frozen internal symbols) ───────────────────────────────
const backendSeed = BASE_COLORS.map((entry, i) => ({
name: entry.name,
...(entry.english !== undefined ? { english: entry.english } : {}),
canonicalGenotype: toInternalString(representativeGenotype(entry)),
sortOrder: i,
...(entry.image !== undefined ? { image: entry.image } : {}),
}))
const backendPath = './src/genetics/colorVarietySeed.backend.json'
writeFileSync(backendPath, JSON.stringify(backendSeed, null, 2) + '\n')
console.log(`[gen:catalog] backend → ${backendPath} (${backendSeed.length} rows)`)

View File

@@ -10,7 +10,8 @@
"preview": "vite preview", "preview": "vite preview",
"test": "vitest run", "test": "vitest run",
"test:watch": "vitest", "test:watch": "vitest",
"e2e": "playwright test" "e2e": "playwright test",
"gen:catalog": "npx tsx gen-seed.mts"
}, },
"dependencies": { "dependencies": {
"jszip": "^3.10.1", "jszip": "^3.10.1",

View File

@@ -0,0 +1,73 @@
/** FEAT-NAMEGEN: Tests für buildSuggestPath + NAMEGEN_USAGES. */
import { describe, expect, it } from 'vitest'
import { buildSuggestPath, NAMEGEN_USAGES } from '../names'
describe('buildSuggestPath', () => {
it('includes uppercased letter', () => {
const path = buildSuggestPath({ letter: 'f', gender: 'female', usages: ['norn'] })
expect(path).toContain('letter=F')
})
it('omits letter when blank', () => {
const path = buildSuggestPath({ letter: '', usages: ['norn'] })
expect(path).not.toContain('letter=')
})
it('omits letter when only whitespace', () => {
const path = buildSuggestPath({ letter: ' ', usages: ['norn'] })
expect(path).not.toContain('letter=')
})
it('includes gender', () => {
const path = buildSuggestPath({ gender: 'male', usages: ['norn'] })
expect(path).toContain('gender=male')
})
it('omits gender for empty string', () => {
const path = buildSuggestPath({ gender: '', usages: ['norn'] })
expect(path).not.toContain('gender=')
})
it('joins multiple usages without encoding commas', () => {
const path = buildSuggestPath({ usages: ['norn', 'mythg'] })
expect(path).toContain('usages=norn,mythg')
})
it('omits usages when array is empty', () => {
const path = buildSuggestPath({ usages: [] })
expect(path).not.toContain('usages=')
})
it('defaults count to 6', () => {
const path = buildSuggestPath({ usages: ['norn'] })
expect(path).toContain('count=6')
})
it('uses provided count', () => {
const path = buildSuggestPath({ usages: ['norn'], count: 8 })
expect(path).toContain('count=8')
})
it('starts with /names/suggest', () => {
const path = buildSuggestPath({ usages: ['norn'] })
expect(path).toMatch(/^\/names\/suggest\?/)
})
})
describe('NAMEGEN_USAGES', () => {
it('contains all 5 expected culture codes', () => {
const codes = NAMEGEN_USAGES.map((u) => u.code)
expect(codes).toContain('norn')
expect(codes).toContain('japa')
expect(codes).toContain('mythg')
expect(codes).toContain('ger')
expect(codes).toContain('arb')
expect(codes).toHaveLength(5)
})
it('every usage has a non-empty label', () => {
for (const u of NAMEGEN_USAGES) {
expect(u.label.length).toBeGreaterThan(0)
}
})
})

View File

@@ -0,0 +1,40 @@
/** FEAT-NAMEGEN: Namensvorschläge — Schnittstelle zum Backend /names/suggest. */
import { api } from './client'
export interface NameSuggestion {
name: string
meaning: string
origin: string
}
export const NAMEGEN_USAGES = [
{ code: 'norn', label: 'Nordisch' },
{ code: 'japa', label: 'Japanisch' },
{ code: 'mythg', label: 'Griech. Mythologie' },
{ code: 'ger', label: 'Deutsch' },
{ code: 'arb', label: 'Arabisch' },
] as const
export type NamegenUsageCode = (typeof NAMEGEN_USAGES)[number]['code']
export interface SuggestNamesParams {
letter?: string
gender?: string
usages: NamegenUsageCode[]
count?: number
}
/** Exported for unit tests — builds the query path without a network call. */
export function buildSuggestPath(params: SuggestNamesParams): string {
const parts: string[] = []
const letter = params.letter?.trim().toUpperCase()
if (letter) parts.push(`letter=${encodeURIComponent(letter)}`)
if (params.gender && params.gender !== '') parts.push(`gender=${encodeURIComponent(params.gender)}`)
if (params.usages.length > 0) parts.push(`usages=${params.usages.join(',')}`)
parts.push(`count=${params.count ?? 6}`)
return `/names/suggest?${parts.join('&')}`
}
export function suggestNames(params: SuggestNamesParams): Promise<NameSuggestion[]> {
return api.get<NameSuggestion[]>(buildSuggestPath(params))
}

View File

@@ -0,0 +1,114 @@
.namegen-name-row {
display: flex;
gap: 0.5rem;
align-items: center;
}
.namegen-name-row .input {
flex: 1;
min-width: 0;
}
.namegen-panel {
border: 1px solid var(--color-border);
border-radius: 0.5rem;
padding: 1rem;
margin-bottom: 1rem;
background: var(--color-bg, #fff);
}
.namegen-panel__header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.75rem;
font-weight: 600;
}
.namegen-panel__filters {
display: flex;
flex-wrap: wrap;
gap: 1rem;
align-items: flex-start;
margin-bottom: 0.75rem;
}
.namegen-panel__letter {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.namegen-panel__letter-input {
width: 5rem;
}
.namegen-panel__usages {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.namegen-usages-grid {
display: flex;
flex-wrap: wrap;
gap: 0.4rem 1rem;
}
.namegen-usages-grid label {
display: flex;
align-items: center;
gap: 0.3rem;
font-size: 0.9rem;
cursor: pointer;
}
.namegen-suggestions {
list-style: none;
margin: 0.75rem 0 0;
padding: 0;
border-top: 1px solid var(--color-border);
}
.namegen-suggestion {
display: flex;
align-items: baseline;
gap: 0.5rem;
padding: 0.5rem 0;
border-bottom: 1px solid var(--color-border);
flex-wrap: wrap;
}
.namegen-suggestion:last-child {
border-bottom: none;
}
.namegen-suggestion__pick {
font-weight: 600;
color: var(--color-accent, #2563eb);
background: none;
border: none;
padding: 0;
cursor: pointer;
text-align: left;
font-size: inherit;
font-family: inherit;
min-width: 6rem;
}
.namegen-suggestion__pick:hover {
text-decoration: underline;
}
.namegen-suggestion__meaning {
flex: 1;
font-size: 0.88rem;
color: var(--color-text-muted);
min-width: 8rem;
}
.namegen-suggestion__origin {
font-size: 0.85rem;
color: var(--color-text-muted);
white-space: nowrap;
}

View File

@@ -0,0 +1,132 @@
import { useState } from 'react'
import { de } from '../strings/de'
import { ApiError, errorCode } from '../api/client'
import { NAMEGEN_USAGES, suggestNames, type NamegenUsageCode, type NameSuggestion } from '../api/names'
import './NameSuggestPanel.css'
interface NameSuggestPanelProps {
gender: string
onPick: (name: string) => void
onClose: () => void
}
const ALL_CODES = NAMEGEN_USAGES.map((u) => u.code) as NamegenUsageCode[]
export default function NameSuggestPanel({ gender, onPick, onClose }: NameSuggestPanelProps) {
const t = de.namegen
const [letter, setLetter] = useState('')
const [usages, setUsages] = useState<Set<NamegenUsageCode>>(new Set(ALL_CODES))
const [loading, setLoading] = useState(false)
const [keyMissing, setKeyMissing] = useState(false)
const [error, setError] = useState<string | null>(null)
const [suggestions, setSuggestions] = useState<NameSuggestion[]>([])
const [fetched, setFetched] = useState(false)
function toggleUsage(code: NamegenUsageCode) {
setUsages((prev) => {
const next = new Set(prev)
if (next.has(code)) next.delete(code)
else next.add(code)
return next
})
}
async function load() {
setLoading(true)
setError(null)
setKeyMissing(false)
setSuggestions([])
try {
const result = await suggestNames({
letter: letter.trim() || undefined,
gender: gender || undefined,
usages: [...usages] as NamegenUsageCode[],
count: 6,
})
setSuggestions(result)
setFetched(true)
} catch (err) {
if (errorCode(err) === 'NamesKeyMissing') {
setKeyMissing(true)
} else {
setError(err instanceof ApiError ? err.message : de.api.errors.unknown)
}
} finally {
setLoading(false)
}
}
return (
<div className="namegen-panel" role="region" aria-label={t.panelTitle}>
<div className="namegen-panel__header">
<span>{t.panelTitle}</span>
<button type="button" className="btn" onClick={onClose} aria-label={t.close}>
</button>
</div>
<div className="namegen-panel__filters">
<div className="namegen-panel__letter">
<label htmlFor="namegen-letter">{t.letterLabel}</label>
<input
id="namegen-letter"
className="input namegen-panel__letter-input"
value={letter}
onChange={(e) => setLetter(e.target.value)}
placeholder={t.letterPlaceholder}
maxLength={1}
/>
</div>
<div className="namegen-panel__usages">
<span>{t.usagesLabel}</span>
<div className="namegen-usages-grid">
{NAMEGEN_USAGES.map(({ code, label }) => (
<label key={code}>
<input
type="checkbox"
checked={usages.has(code)}
onChange={() => toggleUsage(code)}
/>
{label}
</label>
))}
</div>
</div>
</div>
<button
type="button"
className="btn btn--primary"
onClick={load}
disabled={loading || usages.size === 0}
>
{loading ? t.loading : t.loadButton}
</button>
{keyMissing && <p className="muted">{t.keyMissing}</p>}
{error && <p className="error-text">{error}</p>}
{fetched && !loading && !keyMissing && !error && suggestions.length === 0 && (
<p className="muted">{t.empty}</p>
)}
{suggestions.length > 0 && (
<ul className="namegen-suggestions" aria-label={t.panelTitle}>
{suggestions.map((s, i) => (
<li key={`${s.name}-${i}`} className="namegen-suggestion">
<button
type="button"
className="namegen-suggestion__pick"
onClick={() => onPick(s.name)}
>
{s.name}
</button>
<span className="namegen-suggestion__meaning">{s.meaning}</span>
<span className="namegen-suggestion__origin">{s.origin}</span>
</li>
))}
</ul>
)}
</div>
)
}

View File

@@ -0,0 +1,24 @@
/**
* AR-5 Drift-Guard: colorVarietySeed.generated.json muss mit dem Live-Output
* aus catalog.ts übereinstimmen.
*
* Schlägt dieser Test fehl, wurde catalog.ts verändert ohne danach
* `npm run gen:catalog` auszuführen. Fix: `npm run gen:catalog` laufen lassen
* und die geänderten JSON-Dateien committen.
*/
import { readFileSync } from 'fs'
import { fileURLToPath } from 'url'
import { dirname, join } from 'path'
import { describe, it, expect } from 'vitest'
import { CATALOG } from '../catalog'
const __dir = dirname(fileURLToPath(import.meta.url))
describe('AR-5 Catalog drift-guard', () => {
it('colorVarietySeed.generated.json stimmt mit catalog.ts überein (sonst: npm run gen:catalog)', () => {
const jsonPath = join(__dir, '..', 'colorVarietySeed.generated.json')
const committed = JSON.parse(readFileSync(jsonPath, 'utf-8'))
// CATALOG is readonly — deep equality against the plain parsed array is sufficient.
expect(committed).toEqual(Array.from(CATALOG))
})
})

View File

@@ -0,0 +1,426 @@
[
{
"name": "Pink Eyed White (PEW)",
"english": "Pink Eyed White",
"canonicalGenotype": "AA chch DD EE GG pp spsp rere",
"sortOrder": 0,
"image": "rotaugen-weiss-pew-d-sep-e-sep.jpg"
},
{
"name": "Hermelin",
"english": "Dark Tailed White",
"canonicalGenotype": "aa chch DD EE GG PP spsp rere",
"sortOrder": 1,
"image": "hermelin.jpeg"
},
{
"name": "Himalaya",
"english": "Himalayan",
"canonicalGenotype": "AA chch DD EE GG PP spsp rere",
"sortOrder": 2,
"image": "himalaya.jpg"
},
{
"name": "Zobel",
"english": "Sable",
"canonicalGenotype": "aa cchmcchm DD EE gg PP spsp rere",
"sortOrder": 3,
"image": "zobel.jpeg"
},
{
"name": "Rotaugenschimmel",
"english": "Red-Eyed Roan",
"canonicalGenotype": "AA CC DD efef GG pp spsp rere",
"sortOrder": 4,
"image": "rotaugen-schimmel.jpg"
},
{
"name": "Agouti",
"english": "Golden Agouti",
"canonicalGenotype": "AA CC DD EE GG PP spsp rere",
"sortOrder": 5,
"image": "agouti-mit-erklaerung-der-genloci.JPG"
},
{
"name": "Schwarz",
"english": "Black",
"canonicalGenotype": "aa CC DD EE GG PP spsp rere",
"sortOrder": 6,
"image": "schwarz.jpg"
},
{
"name": "Silberagouti",
"english": "Grey Agouti",
"canonicalGenotype": "AA CC DD EE gg PP spsp rere",
"sortOrder": 7,
"image": "silberagouti.jpg"
},
{
"name": "Anthrazit",
"english": "Slate",
"canonicalGenotype": "aa CC DD EE gg PP spsp rere",
"sortOrder": 8,
"image": "anthrazit.jpg"
},
{
"name": "Algierfuchs",
"english": "Dark-Eyed Honey",
"canonicalGenotype": "AA CC DD ee GG PP spsp rere",
"sortOrder": 9,
"image": "algierfuchs.jpg"
},
{
"name": "Blau",
"english": "Blue",
"canonicalGenotype": "aa CC dd EE GG PP spsp rere",
"sortOrder": 10,
"image": "blau-schwarz-dd.JPG"
},
{
"name": "Gold",
"english": "Argente Golden",
"canonicalGenotype": "AA CC DD EE GG pp spsp rere",
"sortOrder": 11,
"image": "gold.jpg"
},
{
"name": "Platin",
"english": "Lilac",
"canonicalGenotype": "aa CC DD EE GG pp spsp rere",
"sortOrder": 12,
"image": "platin.JPG"
},
{
"name": "Goldfuchs",
"english": "Yellow Fox",
"canonicalGenotype": "AA CC DD ee GG pp spsp rere",
"sortOrder": 13,
"image": "goldfuchs.jpg"
},
{
"name": "Rotfuchs",
"english": "Argente Nutmeg",
"canonicalGenotype": "aa CC DD ee GG pp spsp rere",
"sortOrder": 14,
"image": "rotfuchs.JPG"
},
{
"name": "Dilute Gold",
"english": "dd Argente Golden",
"canonicalGenotype": "AA CC dd EE GG pp spsp rere",
"sortOrder": 15,
"image": "gold-dd.jpg"
},
{
"name": "Dilute Platin",
"english": "dd Lilac",
"canonicalGenotype": "aa CC dd EE GG pp spsp rere",
"sortOrder": 16,
"image": "platin-dd.jpg"
},
{
"name": "Altweiss (REW)",
"canonicalGenotype": "aa CC DD EE gg pp spsp rere",
"sortOrder": 17,
"image": "altweiss-rew.jpeg"
},
{
"name": "Apricot (Blassfuchs)",
"canonicalGenotype": "AA CC DD ee gg pp spsp rere",
"sortOrder": 18,
"image": "apricot-blassfuchs.jpg"
},
{
"name": "Blaufuchs",
"canonicalGenotype": "aa CC DD ee gg PP spsp rere",
"sortOrder": 19,
"image": "blaufuchs.jpg"
},
{
"name": "C-Separator",
"canonicalGenotype": "aa CC DD ee gg pp spsp rere",
"sortOrder": 20,
"image": "c-separator.jpg"
},
{
"name": "Elfenbein",
"canonicalGenotype": "AA CC DD EE gg pp spsp rere",
"sortOrder": 21,
"image": "elfenbein.jpg"
},
{
"name": "Kohlfuchs",
"canonicalGenotype": "aa CC DD ee GG PP spsp rere",
"sortOrder": 22,
"image": "kohlfuchs.jpg"
},
{
"name": "Polarfuchs",
"canonicalGenotype": "AA CC DD ee gg PP spsp rere",
"sortOrder": 23,
"image": "polarfuchs.jpg"
},
{
"name": "Saphir",
"canonicalGenotype": "aa CC DD EE GG pp spsp rere",
"sortOrder": 24,
"image": "saphir.jpg"
},
{
"name": "Orangeschimmel",
"canonicalGenotype": "AA CC DD efef GG PP spsp rere",
"sortOrder": 25,
"image": "schimmel-orangeschimmel.jpg"
},
{
"name": "Topas",
"canonicalGenotype": "AA CC DD EE GG pp spsp rere",
"sortOrder": 26,
"image": "topas.jpg"
},
{
"name": "Platin-Hell",
"canonicalGenotype": "aa CC DD EE GG pp spsp rere",
"sortOrder": 27,
"image": "platin-hell.jpg"
},
{
"name": "Dilute Agouti",
"canonicalGenotype": "AA CC dd EE GG PP spsp rere",
"sortOrder": 28,
"image": "agouti-dd.jpg"
},
{
"name": "Dilute Silberagouti",
"canonicalGenotype": "AA CC dd EE gg PP spsp rere",
"sortOrder": 29,
"image": "silberagouti-dd.jpg"
},
{
"name": "Dilute Kohlfuchs",
"canonicalGenotype": "aa CC dd ee GG PP spsp rere",
"sortOrder": 30,
"image": "kohlfuchs-dd.jpg"
},
{
"name": "Dilute Anthrazit",
"canonicalGenotype": "aa CC dd EE gg PP spsp rere",
"sortOrder": 31,
"image": "anthrazit-dd.jpg"
},
{
"name": "Dilute Algierfuchs",
"canonicalGenotype": "AA CC dd ee GG PP spsp rere",
"sortOrder": 32
},
{
"name": "Dilute Goldfuchs",
"canonicalGenotype": "AA CC dd ee GG pp spsp rere",
"sortOrder": 33
},
{
"name": "Dilute Rotfuchs",
"canonicalGenotype": "aa CC dd ee GG pp spsp rere",
"sortOrder": 34
},
{
"name": "Dilute Polarfuchs",
"canonicalGenotype": "AA CC dd ee gg PP spsp rere",
"sortOrder": 35
},
{
"name": "Silberschimmel",
"canonicalGenotype": "AA CC DD efef gg PP spsp rere",
"sortOrder": 36,
"image": "silberschimmel.jpg"
},
{
"name": "Polarfuchsschimmel",
"canonicalGenotype": "AA CC DD efef gg PP spsp rere",
"sortOrder": 37,
"image": "polarfuchsschimmel.jpg"
},
{
"name": "Algierfuchsschimmel",
"canonicalGenotype": "AA CC DD efef GG PP spsp rere",
"sortOrder": 38,
"image": "algierfuchsschimmel.jpg"
},
{
"name": "Kohlfuchsschimmel",
"canonicalGenotype": "aa CC DD efef GG PP spsp rere",
"sortOrder": 39,
"image": "kohlfuchsschimmel.jpg"
},
{
"name": "Blaufuchsschimmel",
"canonicalGenotype": "aa CC DD efef gg PP spsp rere",
"sortOrder": 40,
"image": "blaufuchsschimmel.jpg"
},
{
"name": "Kohlfuchs, hell",
"canonicalGenotype": "aa CC DD ee GG PP spsp rere",
"sortOrder": 41,
"image": "kohlfuchs-hell.jpg"
},
{
"name": "Goldfuchs, hell",
"canonicalGenotype": "AA CC DD ee GG pp spsp rere",
"sortOrder": 42,
"image": "goldfuchs-hell.jpg"
},
{
"name": "Goldfuchsschimmel",
"canonicalGenotype": "AA CC DD efef GG pp spsp rere",
"sortOrder": 43,
"image": "goldfuchsschimmel.jpg"
},
{
"name": "Gold-Hell",
"canonicalGenotype": "AA CC DD EE GG pp spsp rere",
"sortOrder": 44,
"image": "gold-hell.jpg"
},
{
"name": "Blaufuchs, hell",
"canonicalGenotype": "aa CC DD ee gg PP spsp rere",
"sortOrder": 45,
"image": "blaufuchs-hell.jpeg"
},
{
"name": "Rotfuchsschimmel",
"canonicalGenotype": "aa CC DD efef GG pp spsp rere",
"sortOrder": 46,
"image": "rotfuchsschimmel.jpg"
},
{
"name": "Polarfuchs, hell",
"canonicalGenotype": "AA CC DD ee gg PP spsp rere",
"sortOrder": 47,
"image": "polarfuchs-hell.jpeg"
},
{
"name": "Kohlfuchsschimmel, hell",
"canonicalGenotype": "aa CC DD efef GG PP spsp rere",
"sortOrder": 48,
"image": "kohlfuchsschimmel-hell.jpg"
},
{
"name": "Rotfuchs, hell",
"canonicalGenotype": "aa CC DD ee GG pp spsp rere",
"sortOrder": 49,
"image": "rotfuchs-hell.jpg"
},
{
"name": "Kohlfuchs-Hell",
"canonicalGenotype": "aa CC DD ee GG PP spsp rere",
"sortOrder": 50,
"image": "kohlfuchs-hell-2.jpg"
},
{
"name": "Algierfuchs, hell",
"canonicalGenotype": "AA CC DD ee GG PP spsp rere",
"sortOrder": 51,
"image": "algierfuchs-hell.JPG"
},
{
"name": "Dilute Topas",
"canonicalGenotype": "AA CC dd EE GG pp spsp rere",
"sortOrder": 52,
"image": "topas-dd.jpg"
},
{
"name": "Dilute Blaufuchs",
"canonicalGenotype": "aa CC dd ee gg pp spsp rere",
"sortOrder": 53,
"image": "blaufuchs-dd.jpg"
},
{
"name": "Marder",
"canonicalGenotype": "aa cchmcchm DD EE GG PP spsp rere",
"sortOrder": 54,
"image": "marder.JPG"
},
{
"name": "Siam",
"canonicalGenotype": "aa cchmch DD EE GG PP spsp rere",
"sortOrder": 55,
"image": "siam-marder-hell.JPG"
},
{
"name": "Zobel-Hell",
"canonicalGenotype": "aa cchmch DD EE gg PP spsp rere",
"sortOrder": 56,
"image": "zobel-hell.jpg"
},
{
"name": "CP-Agouti",
"canonicalGenotype": "AA cchmcchm DD EE GG PP spsp rere",
"sortOrder": 57,
"image": "agouti-cp.jpg"
},
{
"name": "CP-Agouti-Hell",
"canonicalGenotype": "AA cchmch DD EE GG PP spsp rere",
"sortOrder": 58
},
{
"name": "CP-Silberagouti",
"canonicalGenotype": "AA cchmcchm DD EE gg PP spsp rere",
"sortOrder": 59,
"image": "silberagouti-cp.JPG"
},
{
"name": "CP-Silberagouti-Hell",
"canonicalGenotype": "AA cchmch DD EE gg PP spsp rere",
"sortOrder": 60
},
{
"name": "CP-Algierfuchs",
"canonicalGenotype": "AA cchmcchm DD ee GG PP spsp rere",
"sortOrder": 61,
"image": "algierfuchs-cp.jpg"
},
{
"name": "CP-Algierfuchs-Hell",
"canonicalGenotype": "AA cchmch DD ee GG PP spsp rere",
"sortOrder": 62
},
{
"name": "CP-Polarfuchs",
"canonicalGenotype": "AA cchmcchm DD ee gg PP spsp rere",
"sortOrder": 63,
"image": "polarfuchs-cp.jpg"
},
{
"name": "CP-Polarfuchs-Hell",
"canonicalGenotype": "AA cchmch DD ee gg PP spsp rere",
"sortOrder": 64
},
{
"name": "CP-Fuchs",
"canonicalGenotype": "AA cchmcchm dd ee GG PP spsp rere",
"sortOrder": 65
},
{
"name": "CP-Fuchs-Hell",
"canonicalGenotype": "AA cchmch dd ee GG PP spsp rere",
"sortOrder": 66
},
{
"name": "CP-Blaufuchs",
"canonicalGenotype": "AA cchmcchm dd ee gg PP spsp rere",
"sortOrder": 67
},
{
"name": "CP-Orangeschimmel",
"canonicalGenotype": "AA cchmcchm DD efef GG PP spsp rere",
"sortOrder": 68
},
{
"name": "CP-Orangeschimmel-Hell",
"canonicalGenotype": "AA cchmch DD efef GG PP spsp rere",
"sortOrder": 69
}
]

View File

@@ -105,14 +105,14 @@
"image": "rotfuchs.JPG" "image": "rotfuchs.JPG"
}, },
{ {
"name": "dd Gold", "name": "Dilute Gold",
"english": "dd Argente Golden", "english": "dd Argente Golden",
"canonicalGenotype": "AA CC dd EE GG pp spsp rere", "canonicalGenotype": "AA CC dd EE GG pp spsp rere",
"sortOrder": 15, "sortOrder": 15,
"image": "gold-dd.jpg" "image": "gold-dd.jpg"
}, },
{ {
"name": "dd Platin", "name": "Dilute Platin",
"english": "dd Lilac", "english": "dd Lilac",
"canonicalGenotype": "aa CC dd EE GG pp spsp rere", "canonicalGenotype": "aa CC dd EE GG pp spsp rere",
"sortOrder": 16, "sortOrder": 16,
@@ -185,222 +185,242 @@
"image": "platin-hell.jpg" "image": "platin-hell.jpg"
}, },
{ {
"name": "Agouti dd", "name": "Dilute Agouti",
"canonicalGenotype": "AA CC dd EE GG PP spsp rere", "canonicalGenotype": "AA CC dd EE GG PP spsp rere",
"sortOrder": 28, "sortOrder": 28,
"image": "agouti-dd.jpg" "image": "agouti-dd.jpg"
}, },
{ {
"name": "Silberagouti dd", "name": "Dilute Silberagouti",
"canonicalGenotype": "AA CC dd EE gg PP spsp rere", "canonicalGenotype": "AA CC dd EE gg PP spsp rere",
"sortOrder": 29, "sortOrder": 29,
"image": "silberagouti-dd.jpg" "image": "silberagouti-dd.jpg"
}, },
{ {
"name": "Kohlfuchs dd", "name": "Dilute Kohlfuchs",
"canonicalGenotype": "aa CC dd ee GG PP spsp rere", "canonicalGenotype": "aa CC dd ee GG PP spsp rere",
"sortOrder": 30, "sortOrder": 30,
"image": "kohlfuchs-dd.jpg" "image": "kohlfuchs-dd.jpg"
}, },
{ {
"name": "Anthrazit dd", "name": "Dilute Anthrazit",
"canonicalGenotype": "aa CC dd EE gg PP spsp rere", "canonicalGenotype": "aa CC dd EE gg PP spsp rere",
"sortOrder": 31, "sortOrder": 31,
"image": "anthrazit-dd.jpg" "image": "anthrazit-dd.jpg"
}, },
{
"name": "Dilute Algierfuchs",
"canonicalGenotype": "AA CC dd ee GG PP spsp rere",
"sortOrder": 32
},
{
"name": "Dilute Goldfuchs",
"canonicalGenotype": "AA CC dd ee GG pp spsp rere",
"sortOrder": 33
},
{
"name": "Dilute Rotfuchs",
"canonicalGenotype": "aa CC dd ee GG pp spsp rere",
"sortOrder": 34
},
{
"name": "Dilute Polarfuchs",
"canonicalGenotype": "AA CC dd ee gg PP spsp rere",
"sortOrder": 35
},
{ {
"name": "Silberschimmel", "name": "Silberschimmel",
"canonicalGenotype": "AA CC DD e[f]e[f] gg PP spsp rere", "canonicalGenotype": "AA CC DD e[f]e[f] gg PP spsp rere",
"sortOrder": 32, "sortOrder": 36,
"image": "silberschimmel.jpg" "image": "silberschimmel.jpg"
}, },
{ {
"name": "Polarfuchsschimmel", "name": "Polarfuchsschimmel",
"canonicalGenotype": "AA CC DD e[f]e[f] gg PP spsp rere", "canonicalGenotype": "AA CC DD e[f]e[f] gg PP spsp rere",
"sortOrder": 33, "sortOrder": 37,
"image": "polarfuchsschimmel.jpg" "image": "polarfuchsschimmel.jpg"
}, },
{ {
"name": "Algierfuchsschimmel", "name": "Algierfuchsschimmel",
"canonicalGenotype": "AA CC DD e[f]e[f] GG PP spsp rere", "canonicalGenotype": "AA CC DD e[f]e[f] GG PP spsp rere",
"sortOrder": 34, "sortOrder": 38,
"image": "algierfuchsschimmel.jpg" "image": "algierfuchsschimmel.jpg"
}, },
{ {
"name": "Kohlfuchsschimmel", "name": "Kohlfuchsschimmel",
"canonicalGenotype": "aa CC DD e[f]e[f] GG PP spsp rere", "canonicalGenotype": "aa CC DD e[f]e[f] GG PP spsp rere",
"sortOrder": 35, "sortOrder": 39,
"image": "kohlfuchsschimmel.jpg" "image": "kohlfuchsschimmel.jpg"
}, },
{ {
"name": "Blaufuchsschimmel", "name": "Blaufuchsschimmel",
"canonicalGenotype": "aa CC DD e[f]e[f] gg PP spsp rere", "canonicalGenotype": "aa CC DD e[f]e[f] gg PP spsp rere",
"sortOrder": 36, "sortOrder": 40,
"image": "blaufuchsschimmel.jpg" "image": "blaufuchsschimmel.jpg"
}, },
{ {
"name": "Kohlfuchs, hell", "name": "Kohlfuchs, hell",
"canonicalGenotype": "aa CC DD ee GG PP spsp rere", "canonicalGenotype": "aa CC DD ee GG PP spsp rere",
"sortOrder": 37, "sortOrder": 41,
"image": "kohlfuchs-hell.jpg" "image": "kohlfuchs-hell.jpg"
}, },
{ {
"name": "Goldfuchs, hell", "name": "Goldfuchs, hell",
"canonicalGenotype": "AA CC DD ee GG pp spsp rere", "canonicalGenotype": "AA CC DD ee GG pp spsp rere",
"sortOrder": 38, "sortOrder": 42,
"image": "goldfuchs-hell.jpg" "image": "goldfuchs-hell.jpg"
}, },
{ {
"name": "Goldfuchsschimmel", "name": "Goldfuchsschimmel",
"canonicalGenotype": "AA CC DD e[f]e[f] GG pp spsp rere", "canonicalGenotype": "AA CC DD e[f]e[f] GG pp spsp rere",
"sortOrder": 39, "sortOrder": 43,
"image": "goldfuchsschimmel.jpg" "image": "goldfuchsschimmel.jpg"
}, },
{ {
"name": "Gold-Hell", "name": "Gold-Hell",
"canonicalGenotype": "AA CC DD EE GG pp spsp rere", "canonicalGenotype": "AA CC DD EE GG pp spsp rere",
"sortOrder": 40, "sortOrder": 44,
"image": "gold-hell.jpg" "image": "gold-hell.jpg"
}, },
{ {
"name": "Blaufuchs, hell", "name": "Blaufuchs, hell",
"canonicalGenotype": "aa CC DD ee gg PP spsp rere", "canonicalGenotype": "aa CC DD ee gg PP spsp rere",
"sortOrder": 41, "sortOrder": 45,
"image": "blaufuchs-hell.jpeg" "image": "blaufuchs-hell.jpeg"
}, },
{ {
"name": "Rotfuchsschimmel", "name": "Rotfuchsschimmel",
"canonicalGenotype": "aa CC DD e[f]e[f] GG pp spsp rere", "canonicalGenotype": "aa CC DD e[f]e[f] GG pp spsp rere",
"sortOrder": 42, "sortOrder": 46,
"image": "rotfuchsschimmel.jpg" "image": "rotfuchsschimmel.jpg"
}, },
{ {
"name": "Polarfuchs, hell", "name": "Polarfuchs, hell",
"canonicalGenotype": "AA CC DD ee gg PP spsp rere", "canonicalGenotype": "AA CC DD ee gg PP spsp rere",
"sortOrder": 43, "sortOrder": 47,
"image": "polarfuchs-hell.jpeg" "image": "polarfuchs-hell.jpeg"
}, },
{ {
"name": "Kohlfuchsschimmel, hell", "name": "Kohlfuchsschimmel, hell",
"canonicalGenotype": "aa CC DD e[f]e[f] GG PP spsp rere", "canonicalGenotype": "aa CC DD e[f]e[f] GG PP spsp rere",
"sortOrder": 44, "sortOrder": 48,
"image": "kohlfuchsschimmel-hell.jpg" "image": "kohlfuchsschimmel-hell.jpg"
}, },
{ {
"name": "Rotfuchs, hell", "name": "Rotfuchs, hell",
"canonicalGenotype": "aa CC DD ee GG pp spsp rere", "canonicalGenotype": "aa CC DD ee GG pp spsp rere",
"sortOrder": 45, "sortOrder": 49,
"image": "rotfuchs-hell.jpg" "image": "rotfuchs-hell.jpg"
}, },
{ {
"name": "Kohlfuchs-Hell", "name": "Kohlfuchs-Hell",
"canonicalGenotype": "aa CC DD ee GG PP spsp rere", "canonicalGenotype": "aa CC DD ee GG PP spsp rere",
"sortOrder": 46, "sortOrder": 50,
"image": "kohlfuchs-hell-2.jpg" "image": "kohlfuchs-hell-2.jpg"
}, },
{ {
"name": "Algierfuchs, hell", "name": "Algierfuchs, hell",
"canonicalGenotype": "AA CC DD ee GG PP spsp rere", "canonicalGenotype": "AA CC DD ee GG PP spsp rere",
"sortOrder": 47, "sortOrder": 51,
"image": "algierfuchs-hell.JPG" "image": "algierfuchs-hell.JPG"
}, },
{ {
"name": "Topas dd", "name": "Dilute Topas",
"canonicalGenotype": "AA CC dd EE GG pp spsp rere", "canonicalGenotype": "AA CC dd EE GG pp spsp rere",
"sortOrder": 48, "sortOrder": 52,
"image": "topas-dd.jpg" "image": "topas-dd.jpg"
}, },
{ {
"name": "Blaufuchs dd", "name": "Dilute Blaufuchs",
"canonicalGenotype": "aa CC dd ee gg pp spsp rere", "canonicalGenotype": "aa CC dd ee gg pp spsp rere",
"sortOrder": 49, "sortOrder": 53,
"image": "blaufuchs-dd.jpg" "image": "blaufuchs-dd.jpg"
}, },
{ {
"name": "Marder", "name": "Marder",
"canonicalGenotype": "aa c[chm]c[chm] DD EE GG PP spsp rere", "canonicalGenotype": "aa c[chm]c[chm] DD EE GG PP spsp rere",
"sortOrder": 50, "sortOrder": 54,
"image": "marder.JPG" "image": "marder.JPG"
}, },
{ {
"name": "Siam", "name": "Siam",
"canonicalGenotype": "aa c[chm]c[h] DD EE GG PP spsp rere", "canonicalGenotype": "aa c[chm]c[h] DD EE GG PP spsp rere",
"sortOrder": 51, "sortOrder": 55,
"image": "siam-marder-hell.JPG" "image": "siam-marder-hell.JPG"
}, },
{ {
"name": "Zobel-Hell", "name": "Zobel-Hell",
"canonicalGenotype": "aa c[chm]c[h] DD EE gg PP spsp rere", "canonicalGenotype": "aa c[chm]c[h] DD EE gg PP spsp rere",
"sortOrder": 52, "sortOrder": 56,
"image": "zobel-hell.jpg" "image": "zobel-hell.jpg"
}, },
{ {
"name": "CP-Agouti", "name": "CP-Agouti",
"canonicalGenotype": "AA c[chm]c[chm] DD EE GG PP spsp rere", "canonicalGenotype": "AA c[chm]c[chm] DD EE GG PP spsp rere",
"sortOrder": 53, "sortOrder": 57,
"image": "agouti-cp.jpg" "image": "agouti-cp.jpg"
}, },
{ {
"name": "CP-Agouti-Hell", "name": "CP-Agouti-Hell",
"canonicalGenotype": "AA c[chm]c[h] DD EE GG PP spsp rere", "canonicalGenotype": "AA c[chm]c[h] DD EE GG PP spsp rere",
"sortOrder": 54 "sortOrder": 58
}, },
{ {
"name": "CP-Silberagouti", "name": "CP-Silberagouti",
"canonicalGenotype": "AA c[chm]c[chm] DD EE gg PP spsp rere", "canonicalGenotype": "AA c[chm]c[chm] DD EE gg PP spsp rere",
"sortOrder": 55, "sortOrder": 59,
"image": "silberagouti-cp.JPG" "image": "silberagouti-cp.JPG"
}, },
{ {
"name": "CP-Silberagouti-Hell", "name": "CP-Silberagouti-Hell",
"canonicalGenotype": "AA c[chm]c[h] DD EE gg PP spsp rere", "canonicalGenotype": "AA c[chm]c[h] DD EE gg PP spsp rere",
"sortOrder": 56 "sortOrder": 60
}, },
{ {
"name": "CP-Algierfuchs", "name": "CP-Algierfuchs",
"canonicalGenotype": "AA c[chm]c[chm] DD ee GG PP spsp rere", "canonicalGenotype": "AA c[chm]c[chm] DD ee GG PP spsp rere",
"sortOrder": 57, "sortOrder": 61,
"image": "algierfuchs-cp.jpg" "image": "algierfuchs-cp.jpg"
}, },
{ {
"name": "CP-Algierfuchs-Hell", "name": "CP-Algierfuchs-Hell",
"canonicalGenotype": "AA c[chm]c[h] DD ee GG PP spsp rere", "canonicalGenotype": "AA c[chm]c[h] DD ee GG PP spsp rere",
"sortOrder": 58 "sortOrder": 62
}, },
{ {
"name": "CP-Polarfuchs", "name": "CP-Polarfuchs",
"canonicalGenotype": "AA c[chm]c[chm] DD ee gg PP spsp rere", "canonicalGenotype": "AA c[chm]c[chm] DD ee gg PP spsp rere",
"sortOrder": 59, "sortOrder": 63,
"image": "polarfuchs-cp.jpg" "image": "polarfuchs-cp.jpg"
}, },
{ {
"name": "CP-Polarfuchs-Hell", "name": "CP-Polarfuchs-Hell",
"canonicalGenotype": "AA c[chm]c[h] DD ee gg PP spsp rere", "canonicalGenotype": "AA c[chm]c[h] DD ee gg PP spsp rere",
"sortOrder": 60 "sortOrder": 64
}, },
{ {
"name": "CP-Fuchs", "name": "CP-Fuchs",
"canonicalGenotype": "AA c[chm]c[chm] dd ee GG PP spsp rere", "canonicalGenotype": "AA c[chm]c[chm] dd ee GG PP spsp rere",
"sortOrder": 61 "sortOrder": 65
}, },
{ {
"name": "CP-Fuchs-Hell", "name": "CP-Fuchs-Hell",
"canonicalGenotype": "AA c[chm]c[h] dd ee GG PP spsp rere", "canonicalGenotype": "AA c[chm]c[h] dd ee GG PP spsp rere",
"sortOrder": 62 "sortOrder": 66
}, },
{ {
"name": "CP-Blaufuchs", "name": "CP-Blaufuchs",
"canonicalGenotype": "AA c[chm]c[chm] dd ee gg PP spsp rere", "canonicalGenotype": "AA c[chm]c[chm] dd ee gg PP spsp rere",
"sortOrder": 63 "sortOrder": 67
}, },
{ {
"name": "CP-Orangeschimmel", "name": "CP-Orangeschimmel",
"canonicalGenotype": "AA c[chm]c[chm] DD e[f]e[f] GG PP spsp rere", "canonicalGenotype": "AA c[chm]c[chm] DD e[f]e[f] GG PP spsp rere",
"sortOrder": 64 "sortOrder": 68
}, },
{ {
"name": "CP-Orangeschimmel-Hell", "name": "CP-Orangeschimmel-Hell",
"canonicalGenotype": "AA c[chm]c[h] DD e[f]e[f] GG PP spsp rere", "canonicalGenotype": "AA c[chm]c[h] DD e[f]e[f] GG PP spsp rere",
"sortOrder": 65 "sortOrder": 69
} }
] ]

View File

@@ -2,6 +2,7 @@ import { useMemo, useState, type ReactNode } from 'react'
import { Link, useParams } from 'react-router-dom' import { Link, useParams } from 'react-router-dom'
import { de } from '../strings/de' import { de } from '../strings/de'
import { getGerbil, updateGerbil } from '../api/gerbils' import { getGerbil, updateGerbil } from '../api/gerbils'
import { listLitters as listLittersPaged } from '../api/litters'
import { listColorVarieties, listContacts, listEnclosures, listLitters } from '../api/lookups' import { listColorVarieties, listContacts, listEnclosures, listLitters } from '../api/lookups'
import { useApi, useMutation } from '../hooks/useApi' import { useApi, useMutation } from '../hooks/useApi'
import { formatDate, genderLabel, statusLabel } from '../format/labels' import { formatDate, genderLabel, statusLabel } from '../format/labels'
@@ -55,6 +56,10 @@ export default function GerbilDetailPage() {
const enclosures = useApi(() => listEnclosures(), []) const enclosures = useApi(() => listEnclosures(), [])
const contacts = useApi(() => listContacts(), []) const contacts = useApi(() => listContacts(), [])
const litters = useApi(() => listLitters(), []) const litters = useApi(() => listLitters(), [])
const parentLitters = useApi(
() => listLittersPaged({ filter: `fatherId=${id}|motherId=${id}`, orderBy: 'date desc', pageSize: 50 }),
[id],
)
const colorName = useMemo( const colorName = useMemo(
() => new Map((colorVarieties.data ?? []).map((c) => [c.id, c.name])), () => new Map((colorVarieties.data ?? []).map((c) => [c.id, c.name])),
@@ -237,6 +242,32 @@ export default function GerbilDetailPage() {
{saveCharacter.error && <span className="error-text">{saveCharacter.error}</span>} {saveCharacter.error && <span className="error-text">{saveCharacter.error}</span>}
</div> </div>
<h3>{t.detail.parentLittersTitle}</h3>
{parentLitters.loading && <p className="muted">{de.common.loading}</p>}
{!parentLitters.loading && (parentLitters.data?.items.length ?? 0) === 0 && (
<p className="muted">{t.detail.parentLittersEmpty}</p>
)}
{(parentLitters.data?.items.length ?? 0) > 0 && (
<ul className="card-list">
{(parentLitters.data?.items ?? []).map((litter) => (
<li key={litter.id}>
<Link to={`/wuerfe/${litter.id}`} className="gerbil-card">
<span className="gerbil-card__name">{litter.name}</span>
<span className="badge badge--active">
{litter.fatherId === id ? t.detail.parentLittersRoleVater : t.detail.parentLittersRoleMutter}
</span>
<span className="gerbil-card__meta">{formatDate(litter.date)}</span>
{litter.totalBorn !== null && (
<span className="gerbil-card__meta">
{litter.totalBorn} {de.pages.litters.countLabel}
</span>
)}
</Link>
</li>
))}
</ul>
)}
<h3 className="visually-hidden">{t.detail.moreData}</h3> <h3 className="visually-hidden">{t.detail.moreData}</h3>
<div className="tabs" role="tablist"> <div className="tabs" role="tablist">
{(['photos', 'health', 'weight'] as const).map((key) => ( {(['photos', 'health', 'weight'] as const).map((key) => (

View File

@@ -8,6 +8,8 @@ import { useApi, useMutation } from '../hooks/useApi'
import { genderLabel, statusLabel } from '../format/labels' import { genderLabel, statusLabel } from '../format/labels'
import { fromDisplayString } from '../genetics' import { fromDisplayString } from '../genetics'
import FarbschlagImage from '../components/FarbschlagImage' import FarbschlagImage from '../components/FarbschlagImage'
import NameSuggestPanel from '../components/NameSuggestPanel'
import '../components/NameSuggestPanel.css'
interface FormState { interface FormState {
name: string name: string
@@ -111,6 +113,7 @@ export default function GerbilFormPage() {
const [form, setForm] = useState<FormState>(EMPTY) const [form, setForm] = useState<FormState>(EMPTY)
const [errors, setErrors] = useState<Partial<Record<keyof FormState, string>>>({}) const [errors, setErrors] = useState<Partial<Record<keyof FormState, string>>>({})
const [initializedFor, setInitializedFor] = useState<string | null>(null) const [initializedFor, setInitializedFor] = useState<string | null>(null)
const [showNameSuggest, setShowNameSuggest] = useState(false)
const existing = useApi(() => (id ? getGerbil(id) : Promise.resolve(null)), [id]) const existing = useApi(() => (id ? getGerbil(id) : Promise.resolve(null)), [id])
const colorVarieties = useApi(() => listColorVarieties(), []) const colorVarieties = useApi(() => listColorVarieties(), [])
@@ -197,16 +200,33 @@ export default function GerbilFormPage() {
<h2>{isEdit ? t.form.editTitle : t.form.createTitle}</h2> <h2>{isEdit ? t.form.editTitle : t.form.createTitle}</h2>
<form className="form" onSubmit={onSubmit} noValidate> <form className="form" onSubmit={onSubmit} noValidate>
<label className="field"> <div className="field">
<span>{t.fields.name} *</span> <label htmlFor="gerbil-name">{t.fields.name} *</label>
<div className="namegen-name-row">
<input <input
id="gerbil-name"
className="input" className="input"
value={form.name} value={form.name}
onChange={(e) => set('name', e.target.value)} onChange={(e) => set('name', e.target.value)}
aria-invalid={Boolean(errors.name)} aria-invalid={Boolean(errors.name)}
/> />
<button
type="button"
className="btn"
onClick={() => setShowNameSuggest((v) => !v)}
>
{de.namegen.button}
</button>
</div>
{errors.name && <small className="error-text">{errors.name}</small>} {errors.name && <small className="error-text">{errors.name}</small>}
</label> </div>
{showNameSuggest && (
<NameSuggestPanel
gender={form.gender}
onPick={(name) => { set('name', name); setShowNameSuggest(false) }}
onClose={() => setShowNameSuggest(false)}
/>
)}
<label className="field"> <label className="field">
<span>{t.fields.gender} *</span> <span>{t.fields.gender} *</span>

View File

@@ -105,6 +105,11 @@ export const de = {
tabPlaceholder: 'Dieser Bereich entsteht in einem späteren Schritt.', tabPlaceholder: 'Dieser Bereich entsteht in einem späteren Schritt.',
notFound: 'Diese Rennmaus wurde nicht gefunden.', notFound: 'Diese Rennmaus wurde nicht gefunden.',
moreData: 'Weitere Daten', moreData: 'Weitere Daten',
// ANIMAL-LITTERS: Würfe als Elternteil
parentLittersTitle: 'Würfe als Elternteil',
parentLittersEmpty: 'Keine Würfe als Elternteil erfasst.',
parentLittersRoleVater: 'Vater',
parentLittersRoleMutter: 'Mutter',
}, },
// Formular (anlegen/bearbeiten) // Formular (anlegen/bearbeiten)
form: { form: {
@@ -813,6 +818,19 @@ export const de = {
resetButton: 'Filter zurücksetzen', resetButton: 'Filter zurücksetzen',
closeButton: 'Schließen', closeButton: 'Schließen',
}, },
// ── FEAT-NAMEGEN: Namensvorschläge (KI-gestützt, UC-1 Einzeltier) ──
namegen: {
button: 'Name vorschlagen',
panelTitle: 'Namensvorschläge',
letterLabel: 'Anfangsbuchstabe',
letterPlaceholder: 'z. B. A',
usagesLabel: 'Herkunftskultur',
loadButton: 'Vorschläge laden',
loading: 'Lade Vorschläge …',
empty: 'Keine Vorschläge — andere Einstellungen versuchen.',
keyMissing: 'Namensvorschläge benötigen einen API-Key — bitte in den Einstellungen konfigurieren.',
close: 'Schließen',
},
} as const } as const
export type Strings = typeof de export type Strings = typeof de