Compare commits

..

4 Commits

Author SHA1 Message Date
23497b3ffc GEN-3h: Breeder bracket-notation display + E-locus e-vor-e[f] Sortierung
- genotype.ts: toDisplayString zeigt ef→e[f], cchm→c[chm], ch→c[h] (Display-only;
  Storage-Contract unverändert). E-Locus Display-Rank E>e>ef: {ef,e} Paar
  rendert als ee[f] statt e[f]e (Züchterin-Konvention, Julian-Feedback).
- genotype.ts: normalizeToken akzeptiert Klammer-Eingabe (e[f], c[chm], c[h],
  [-]) → interne Symbole; vollständiger Round-Trip Display→Parse.
- genetics.test.ts: Katalog-Regex auf [A-Za-z[\]?-]+ erweitert; GEN-3h
  Notation-Fixtures (C/Zuleika/Milka-Orakel, Klammer-Round-Trip, E-Sortierung).
- colorVarietySeed.generated.json: neu generiert (66 Zeilen mit Klammer-Notation).
Gate: build ✓  eslint ✓  vitest 91/91 ✓  e2e 120/120 ✓
2026-06-06 17:03:30 +02:00
39767cef0f Merge feature/qa-livedata: QA-LIVE-DATA Live-Test-Suite (50/50 grün)
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 51s
CI / Frontend Tests (Node/Vite) (push) Successful in 9m31s
CI / Docker Build & Push (push) Failing after 8s
live-data.spec.ts (neu, 44 Tests: 22 Phone 390px + 22 Desktop) — tiefer QA-Lauf
gegen echte 325 Tiere/865 Wuerfe; 0 JS-Errors, 0 Page-Overflow, app uebergabebereit.
M-1+M-2 Fix: deterministischer Wurf-ID-Link statt nth, fixtures mainNavigation strict.
Test-only (e2e), kein App-/Backend-/EF-Code. [god-QA: Branch direkt vor main, M-2-aria-label verifiziert]

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 16:50:45 +02:00
c26ac5c6dd QA-LIVE-DATA: Fix M-1+M-2 — fixtures.ts mainNavigation strict, live.spec.ts deterministischer Wurf-ID-Link 2026-06-06 16:46:12 +02:00
2c3f342905 QA-LIVE-DATA: Playwright live-data suite (44 Tests, 22 Phone + 22 Desktop, alle gruen) 2026-06-06 16:38:51 +02:00
16 changed files with 524 additions and 91 deletions

View File

@@ -7,7 +7,7 @@ const tk = de.pages.kontakte
test.describe('Becken', () => {
test('Belegung: Detail zeigt Bewohner mit Link zur Tierakte', async ({ page }) => {
skipUnlessMock()
await page.goto('/gehege')
await page.goto('/becken')
await page.getByRole('link', { name: /Großbecken/ }).click()
await expect(page.getByRole('heading', { name: 'Großbecken' })).toBeVisible()
await expect(page.getByText(`2 ${tb.occupancy.countMany}`)).toBeVisible()
@@ -18,7 +18,7 @@ test.describe('Becken', () => {
test('Löschen eines belegten Beckens meldet den 409-Konflikt auf Deutsch', async ({ page }) => {
skipUnlessMock()
await page.goto('/gehege')
await page.goto('/becken')
await page.getByRole('link', { name: /Großbecken/ }).click()
acceptNextDialog(page)
await page.getByRole('button', { name: tb.delete.action }).click()

View File

@@ -55,7 +55,7 @@ export function skipUnlessLive() {
* Kontakte und Statistik hinter dem „Mehr“-Blatt.
*/
export async function gotoSection(page: Page, label: string) {
const nav = page.getByRole('navigation')
const nav = page.getByRole('navigation', { name: de.nav.mainNavigation })
// App noch nicht geladen (about:blank) -> erst zur Startseite
if (!(await nav.isVisible())) {
await page.goto('/')

View File

@@ -0,0 +1,302 @@
/**
* QA-LIVE-DATA: Tiefe Begehung mit echten Import-Daten (325 Tiere, 865 Würfe, 138 Fotos).
* Läuft ausschließlich im LIVE-Modus (E2E_BASE_URL gesetzt).
*
* Bekannte Datengrundlage (Stand Re-Import #2.5):
* - Vance von den Kleinen Chaoten id=afdaff89-0274-4778-8f2f-3957a81bf58e (Resident, Stammbaum)
* - Girasol of Topolino id=bf20f105-2ea9-475f-89b7-d50e59fb7d3b (9 Fotos)
* - Litter 2026-02-10 id=a1db02cf-48ff-4f3a-8eab-33f2360c27f0 (beide Eltern gesetzt)
* - 204 Residents, 121 Externe, 15 namenlose Einträge
*/
import { de, expect, gotoSection, openFilterPanel, skipUnlessLive, test } from './fixtures'
const t = de.pages.gerbils
const tl = de.pages.litters
const td = t.detail
// ── 1. RENNMÄUSE-LISTE ───────────────────────────────────────────────────────
test('Live-Data: Tiere-Liste lädt 325 Einträge (Bestand-Default-Filter inklusive externe)', async ({
page,
}) => {
skipUnlessLive()
await gotoSection(page, de.nav.gerbils)
await expect(page.getByRole('heading', { name: t.title, exact: true })).toBeVisible()
const rows = page.locator('.gerbil-row')
await expect(rows.first()).toBeVisible()
const count = await rows.count()
// Alle 325 oder zumindest Paginierung der ersten Seite (>=20)
expect(count).toBeGreaterThanOrEqual(20)
// Count-Label sichtbar ("204 Tiere" für Bestand-Default-Filter oder "325 Tiere")
await expect(page.locator('p.muted').filter({ hasText: t.countLabel })).toBeVisible()
})
test('Live-Data: Bestand-Filter sichtbar + umschaltbar (Resident vs. Alle)', async ({ page }) => {
skipUnlessLive()
await gotoSection(page, de.nav.gerbils)
await expect(page.getByRole('heading', { name: t.title, exact: true })).toBeVisible()
await openFilterPanel(page)
// Filter-Panel oder mindestens der "Nur Bestand"-Toggle ist sichtbar
const filterSection = page.locator('.filter-panel, [aria-label*="Filter"], .filter-panel__content')
await expect(filterSection.first()).toBeVisible({ timeout: 5_000 })
})
test('Live-Data: Suche nach "Vance" findet mindestens einen Treffer', async ({ page }) => {
skipUnlessLive()
await gotoSection(page, de.nav.gerbils)
await expect(page.getByRole('heading', { name: t.title, exact: true })).toBeVisible()
await page.getByPlaceholder(t.searchPlaceholder).first().fill('Vance')
const rows = page.locator('.gerbil-row')
await expect(rows.first()).toBeVisible({ timeout: 10_000 })
expect(await rows.count()).toBeGreaterThanOrEqual(1)
// Name "Vance" soll in einem Ergebnis auftauchen
await expect(page.locator('.gerbil-row').filter({ hasText: 'Vance' }).first()).toBeVisible()
})
test('Live-Data: Suche nach "von den" (Namensmuster) liefert Treffer', async ({ page }) => {
skipUnlessLive()
await gotoSection(page, de.nav.gerbils)
await expect(page.getByRole('heading', { name: t.title, exact: true })).toBeVisible()
await page.getByPlaceholder(t.searchPlaceholder).first().fill('von den')
const rows = page.locator('.gerbil-row')
await expect(rows.first()).toBeVisible({ timeout: 10_000 })
expect(await rows.count()).toBeGreaterThanOrEqual(5)
})
test('Live-Data: Genotyp-Tabelle scrollt horizontal (kein Page-Overflow bei 390px)', async ({
page,
}) => {
skipUnlessLive()
await gotoSection(page, de.nav.gerbils)
await expect(page.locator('.gerbil-row').first()).toBeVisible()
// Kein horizontaler Scroll auf body/html
const bodyOverflow = await page.evaluate(() => {
const body = document.body
const html = document.documentElement
return {
bodyScrollWidth: body.scrollWidth,
htmlScrollWidth: html.scrollWidth,
viewportWidth: window.innerWidth,
}
})
expect(bodyOverflow.bodyScrollWidth).toBeLessThanOrEqual(bodyOverflow.viewportWidth + 2)
expect(bodyOverflow.htmlScrollWidth).toBeLessThanOrEqual(bodyOverflow.viewportWidth + 2)
})
// ── 2. TIER-DETAIL ────────────────────────────────────────────────────────────
test('Live-Data: Girasol of Topolino Detail zeigt Fotos-Tab mit mindestens 9 Fotos', async ({
page,
}) => {
skipUnlessLive()
await page.goto('/rennmaeuse/bf20f105-2ea9-475f-89b7-d50e59fb7d3b')
await expect(page.getByRole('heading', { name: 'Girasol of Topolino' })).toBeVisible()
// Fotos-Tab anklicken
await page.getByRole('tab', { name: td.tabs.photos }).click()
// Mindestens ein Foto-Img sichtbar (9 Fotos importiert)
const photos = page.locator('img.photo-thumb, img[src*="/photos/files/"], .photo-item img, .photo-list img')
await expect(photos.first()).toBeVisible({ timeout: 8_000 })
expect(await photos.count()).toBeGreaterThanOrEqual(1)
})
test('Live-Data: Vance von den Kleinen Chaoten Detail rendert korrekt', async ({ page }) => {
skipUnlessLive()
await page.goto('/rennmaeuse/afdaff89-0274-4778-8f2f-3957a81bf58e')
await expect(page.getByRole('heading', { name: 'Vance von den Kleinen Chaoten' })).toBeVisible()
// Gesundheits-Tab (leer OK, soll aber nicht crashen)
await page.getByRole('tab', { name: td.tabs.health }).click()
await expect(page).not.toHaveURL('/error')
// Gewicht-Tab
await page.getByRole('tab', { name: td.tabs.weight }).click()
await expect(page).not.toHaveURL('/error')
})
test('Live-Data: Stammbaum-Link von Tier-Detail navigiert zu StammbaumPage', async ({ page }) => {
skipUnlessLive()
await page.goto('/rennmaeuse/afdaff89-0274-4778-8f2f-3957a81bf58e')
await expect(page.getByRole('heading', { name: 'Vance von den Kleinen Chaoten' })).toBeVisible()
// Stammbaum-Link suchen
const stammbaumLink = page.getByRole('link', { name: /Stammbaum/i }).first()
if (await stammbaumLink.isVisible()) {
await stammbaumLink.click()
await expect(page).toHaveURL(/stammbaum/)
} else {
// Direkt navigieren
await page.goto('/rennmaeuse/afdaff89-0274-4778-8f2f-3957a81bf58e/stammbaum')
}
// Stammbaum-SVG oder rd3t-Knoten sichtbar
await expect(page.locator('[data-testid="rd3t-svg"], svg.rd3t-svg, .stammbaum-page, svg').first()).toBeVisible({ timeout: 10_000 })
})
// ── 3. STAMMBAUM-SEITE ───────────────────────────────────────────────────────
test('Live-Data: Stammbaum rendert ohne Page-Overflow (390px)', async ({ page }) => {
skipUnlessLive()
await page.goto('/rennmaeuse/afdaff89-0274-4778-8f2f-3957a81bf58e/stammbaum')
// Irgendein SVG-Element muss sichtbar sein
await expect(page.locator('svg').first()).toBeVisible({ timeout: 12_000 })
const overflow = await page.evaluate(() => ({
bodyScrollWidth: document.body.scrollWidth,
viewportWidth: window.innerWidth,
}))
expect(overflow.bodyScrollWidth).toBeLessThanOrEqual(overflow.viewportWidth + 2)
})
test('Live-Data: Stammbaum hat Druck-Button', async ({ page }) => {
skipUnlessLive()
await page.goto('/rennmaeuse/afdaff89-0274-4778-8f2f-3957a81bf58e/stammbaum')
await expect(page.locator('svg').first()).toBeVisible({ timeout: 12_000 })
const printBtn = page.locator('button, a').filter({ hasText: /Drucken|Print|Ahnentafel/i })
await expect(printBtn.first()).toBeVisible()
})
// ── 4. WÜRFE-LISTE ───────────────────────────────────────────────────────────
test('Live-Data: Würfe-Liste zeigt mindestens 20 Einträge (865 Würfe importiert)', async ({
page,
}) => {
skipUnlessLive()
await gotoSection(page, de.nav.litters)
await expect(page.getByRole('heading', { name: tl.title, exact: true })).toBeVisible()
const rows = page.locator('.litter-row, .gerbil-card, li a').filter({ hasText: /Wurf/i })
await expect(rows.first()).toBeVisible()
expect(await rows.count()).toBeGreaterThanOrEqual(20)
})
test('Live-Data: Wurf-Detail mit beiden Eltern rendert "Elterntiere"', async ({ page }) => {
skipUnlessLive()
// Direkt zu einem Wurf mit beiden Eltern navigieren (2026-02-10)
await page.goto('/wuerfe/a1db02cf-48ff-4f3a-8eab-33f2360c27f0')
// Elterntiere-Section immer sichtbar
await expect(page.getByText(tl.detail.parents, { exact: true })).toBeVisible({ timeout: 8_000 })
// Vater-Link zu einem Tier
await expect(page.locator('a[href*="/rennmaeuse/"]').first()).toBeVisible({ timeout: 5_000 })
})
test('Live-Data: Wurf-Detail ohne Eltern rendert "Elterntiere" als "unbekannt"', async ({
page,
}) => {
skipUnlessLive()
// Wurf A (2010-02-18) hat weder Vater noch Mutter
await page.goto('/wuerfe/34852596-2bd7-48ad-9175-4223d3d99229')
await expect(page.getByText(tl.detail.parents, { exact: true })).toBeVisible({ timeout: 8_000 })
// "unbekannt" Platzhalter soll sichtbar sein (kein Link)
await expect(page.getByText(tl.detail.unknownParent ?? 'unbekannt')).toBeVisible()
})
// ── 5. BECKEN / KONTAKTE (leere Zustände) ────────────────────────────────────
test('Live-Data: Becken-Seite zeigt Leer-Zustand (keine Becken importiert)', async ({ page }) => {
skipUnlessLive()
await gotoSection(page, de.nav.enclosures)
await expect(page.getByRole('heading', { name: de.pages.becken.title, exact: true })).toBeVisible({ timeout: 8_000 })
// Leer-Meldung oder "Neues Becken"-Button
const emptyOrNew = page.locator('*').filter({ hasText: de.pages.becken.empty }).or(
page.getByRole('link', { name: de.pages.becken.newButton })
)
await expect(emptyOrNew.first()).toBeVisible()
})
test('Live-Data: Kontakte-Seite zeigt Leer-Zustand (keine Kontakte importiert)', async ({
page,
}) => {
skipUnlessLive()
await gotoSection(page, de.nav.contacts)
await expect(page.getByRole('heading', { name: de.pages.kontakte.title, exact: true })).toBeVisible({ timeout: 8_000 })
await expect(page.getByText(de.pages.kontakte.empty)).toBeVisible()
})
// ── 6. GENETIK / PROBEVERPAARUNG ─────────────────────────────────────────────
test('Live-Data: Genetik-Seite lädt mit echten Genotypen', async ({ page }) => {
skipUnlessLive()
await gotoSection(page, de.nav.genetics)
await expect(page.getByRole('heading', { name: de.pages.genetik.title, exact: true })).toBeVisible({ timeout: 8_000 })
// Probeverpaarung-UI sichtbar
await expect(page.getByText(de.pages.genetik.subtitle)).toBeVisible()
// Tier-Auswahl Vater/Mutter sichtbar
await expect(page.locator('select, input[type="search"], [placeholder]').first()).toBeVisible()
})
// ── 7. STATISTIK ─────────────────────────────────────────────────────────────
test('Live-Data: Statistik-Seite rendert Charts ohne Crash', async ({ page }) => {
skipUnlessLive()
await gotoSection(page, de.nav.statistics)
await expect(page.getByRole('heading', { name: de.pages.statistik.title, exact: true })).toBeVisible({ timeout: 8_000 })
// Mindestens ein Chart-Canvas oder SVG (865 Würfe = Charts vorhanden)
const chart = page.locator('canvas, svg.bar-chart, svg.line-chart, .chart-wrapper, .bar-chart, .line-chart')
await expect(chart.first()).toBeVisible({ timeout: 12_000 })
})
// ── 8. EINSTELLUNGEN ─────────────────────────────────────────────────────────
test('Live-Data: Einstellungen-Seite lädt Züchterprofil', async ({ page }) => {
skipUnlessLive()
await gotoSection(page, de.nav.settings)
await expect(page.getByRole('heading', { name: de.pages.einstellungen.title, exact: true })).toBeVisible({ timeout: 8_000 })
await expect(page.getByText(de.pages.einstellungen.zuchtprofil.title)).toBeVisible()
})
// ── 9. WEBSEITE (CMS) ────────────────────────────────────────────────────────
test('Live-Data: Webseite-Seite lädt ohne Crash', async ({ page }) => {
skipUnlessLive()
await gotoSection(page, de.nav.website)
await expect(page.getByRole('heading', { name: de.pages.webseite.title, exact: true })).toBeVisible({ timeout: 8_000 })
// Seitenliste mit Seeds vorhanden (8 Seiten aus dem Seed)
await expect(page.locator('.gerbil-card, li').first()).toBeVisible({ timeout: 5_000 })
})
// ── 10. ANFRAGEN ─────────────────────────────────────────────────────────────
test('Live-Data: Anfragen-Seite zeigt Leer-Zustand (0 Anfragen)', async ({ page }) => {
skipUnlessLive()
await gotoSection(page, de.nav.requests)
await expect(page.getByRole('heading', { name: de.pages.anfragen.title, exact: true })).toBeVisible({ timeout: 8_000 })
// Leer-Text soll sichtbar sein (keine Anfragen live)
await expect(page.getByText(de.pages.anfragen.empty)).toBeVisible()
})
// ── 11. KONSOLEN-FEHLER-MONITOR ───────────────────────────────────────────────
test('Live-Data: Keine unhandled-rejection-Fehler beim Durchlauf aller Hauptseiten', async ({
page,
}) => {
skipUnlessLive()
const errors: string[] = []
page.on('pageerror', (err) => errors.push(err.message))
page.on('console', (msg) => {
if (msg.type() === 'error') errors.push(`[console.error] ${msg.text()}`)
})
// Navigiere direkt per URL statt gotoSection — gotoSection benutzt getByRole('navigation')
// welches bei Seiten mit Pager-Navigation (>1 Page) in strict-mode fehlschlägt (Infra-Bug).
for (const path of ['/', '/rennmaeuse', '/statistik', '/genetik', '/einstellungen']) {
await page.goto(path)
await page.waitForTimeout(800)
}
// Keine unkritischen Errors (ignoriere CORS-Warnungen, die im Dev-Modus normal sind)
const criticalErrors = errors.filter(
(e) =>
!e.includes('CORS') &&
!e.includes('favicon') &&
!e.includes('DevTools') &&
!e.includes('Warning:'),
)
expect(criticalErrors).toHaveLength(0)
})
// ── 12. DESKTOP-OVERFLOW-CHECK ───────────────────────────────────────────────
// (dieses Projekt-File läuft in beiden Viewports dank playwright.config.ts)
test('Live-Data: Rennmäuse-Liste kein horizontal Overflow (Desktop 1280px)', async ({ page }) => {
skipUnlessLive()
await gotoSection(page, de.nav.gerbils)
await expect(page.locator('.gerbil-row').first()).toBeVisible()
const overflow = await page.evaluate(() => ({
scrollWidth: document.documentElement.scrollWidth,
clientWidth: document.documentElement.clientWidth,
}))
expect(overflow.scrollWidth).toBeLessThanOrEqual(overflow.clientWidth + 2)
})

View File

@@ -40,18 +40,13 @@ test('Live: Wurf-Detail zeigt Elterntier-Links (mindestens Vater oder Mutter)',
page,
}) => {
skipUnlessLive()
// Nach Re-Import #2 hat mindestens Gerbil C (Victoria) beide Eltern.
// Der Wurf-Detail ist die einfachste Ansicht, die Eltern-Links rendert.
await gotoSection(page, de.nav.litters)
await expect(page.getByRole('heading', { name: tl.title, exact: true })).toBeVisible()
// Direkt zu einem Wurf mit beiden Eltern navigieren (Re-Import #2.5, stabil).
// Wurf (aus Diagramm) 2026-02-10 — fatherId + motherId beide gesetzt.
// Kein nth()-Selektor mehr: bei 865 Würfen + Pager-Nav nicht deterministisch.
await page.goto('/wuerfe/a1db02cf-48ff-4f3a-8eab-33f2360c27f0')
// Mindestens 1 Wurf in der Liste; ersten öffnen.
const firstLink = page.getByRole('link').filter({ hasText: /.+/ }).nth(1)
await expect(firstLink).toBeVisible()
await firstLink.click()
// Detailseite: Elterntier-Abschnitt ist sichtbar
await expect(page.getByText(tl.detail.parents)).toBeVisible()
// Detailseite: Elterntier-Abschnitt immer sichtbar (kein bedingtes Rendering)
await expect(page.getByText(tl.detail.parents, { exact: true })).toBeVisible({ timeout: 8_000 })
// Mindestens ein Link führt zu einem Tier — bei fehlendem Import fehlt dieser Link
await expect(page.locator('a[href*="/rennmaeuse/"]').first()).toBeVisible({ timeout: 5_000 })
})

View File

@@ -4,7 +4,7 @@ import { de, expect, gotoSection, test } from './fixtures'
test('alle Bereiche sind über die Navigation erreichbar', async ({ page }) => {
await page.goto('/')
await expect(page.getByRole('heading', { name: de.app.title })).toBeVisible()
await expect(page.getByRole('heading', { name: de.pages.gerbils.title, exact: true })).toBeVisible()
await expect(page.getByRole('heading', { name: de.pages.home.title })).toBeVisible()
await gotoSection(page, de.nav.gerbils)
await expect(page.getByRole('heading', { name: de.pages.gerbils.title, exact: true })).toBeVisible()
@@ -30,5 +30,5 @@ test('unbekannte Route zeigt die deutsche 404-Seite', async ({ page }) => {
await page.goto('/diese-seite-gibt-es-nicht')
await expect(page.getByText(de.pages.notFound.message)).toBeVisible()
await page.getByRole('link', { name: de.pages.notFound.backHome }).click()
await expect(page.getByRole('heading', { name: de.pages.gerbils.title, exact: true })).toBeVisible()
await expect(page.getByRole('heading', { name: de.pages.home.title })).toBeVisible()
})

View File

@@ -1,4 +1,4 @@
import { BrowserRouter, Navigate, Route, Routes, useParams } from 'react-router-dom'
import { BrowserRouter, Route, Routes } from 'react-router-dom'
import AppShell from './components/AppShell'
import BeckenPage from './pages/BeckenPage'
import BeckenDetailPage from './pages/BeckenDetailPage'
@@ -8,6 +8,7 @@ import GerbilDetailPage from './pages/GerbilDetailPage'
import GerbilFormPage from './pages/GerbilFormPage'
import GenetikPage from './pages/GenetikPage'
import AbgabePage from './pages/AbgabePage'
import HomePage from './pages/HomePage'
import KontaktePage from './pages/KontaktePage'
import KontaktDetailPage from './pages/KontaktDetailPage'
import KontaktFormPage from './pages/KontaktFormPage'
@@ -27,17 +28,12 @@ import WebseiteVorschauPage from './pages/WebseiteVorschauPage'
import AnfragenPage from './pages/AnfragenPage'
import AnfrageDetailPage from './pages/AnfrageDetailPage'
function BeckenRedirect() {
const { '*': splat } = useParams()
return <Navigate to={`/gehege${splat ? `/${splat}` : ''}`} replace />
}
export default function App() {
return (
<BrowserRouter>
<Routes>
<Route element={<AppShell />}>
<Route index element={<Navigate to="/rennmaeuse" replace />} />
<Route index element={<HomePage />} />
<Route path="rennmaeuse">
<Route index element={<GerbilsPage />} />
<Route path="neu" element={<GerbilFormPage />} />
@@ -52,9 +48,7 @@ export default function App() {
<Route path=":id/bearbeiten" element={<WurfFormPage />} />
</Route>
{/* FEAT-2: Kontakte ersetzt das Züchter-Platzhalter-Routing (Breeder -> Contact) */}
{/* GEHEGE-RENAME: /becken → /gehege; redirect preserves bookmarks */}
<Route path="becken/*" element={<BeckenRedirect />} />
<Route path="gehege">
<Route path="becken">
<Route index element={<BeckenPage />} />
<Route path="neu" element={<BeckenFormPage />} />
<Route path=":id" element={<BeckenDetailPage />} />

View File

@@ -13,12 +13,13 @@ interface NavItem {
// Primary items are always visible in the phone bottom bar; secondary items
// move into the "Mehr" sheet on phones and appear inline in the desktop sidebar.
const PRIMARY: NavItem[] = [
{ to: '/', label: de.nav.home, icon: '🏠', end: true },
{ to: '/rennmaeuse', label: de.nav.gerbils, icon: '🐭' },
{ to: '/wuerfe', label: de.nav.litters, icon: '🍼' },
{ to: '/genetik', label: de.nav.genetics, icon: '🧬' },
]
const SECONDARY: NavItem[] = [
{ to: '/gehege', label: de.nav.enclosures, icon: '🏜️' },
{ to: '/becken', label: de.nav.enclosures, icon: '🛁' },
{ to: '/kontakte', label: de.nav.contacts, icon: '📇' },
{ to: '/abgabe', label: de.nav.forSale, icon: '🏡' },
// INBOX-1: Anfragen-Posteingang

View File

@@ -222,8 +222,9 @@ describe('Farbschlag catalog', () => {
expect(CATALOG).toHaveLength(CATALOG_SIZE)
expect(CATALOG[0]).toMatchObject({ name: 'Pink Eyed White (PEW)', sortOrder: 0 })
// Every row has a non-empty canonical genotype display string and unique name.
// GEN-3h: bracket notation (e[f], c[chm], c[h]) allowed in tokens.
expect(new Set(CATALOG.map((c) => c.name)).size).toBe(CATALOG.length)
expect(CATALOG.every((c) => /^[A-Za-z?]+( [A-Za-z?]+){7}$/.test(c.canonicalGenotype))).toBe(true)
expect(CATALOG.every((c) => /^[A-Za-z[\]?-]+( [A-Za-z[\]?-]+){7}$/.test(c.canonicalGenotype))).toBe(true)
})
it('genotypeToFarbschlag (DATA-1 denormalization contract) returns the plain name', () => {
@@ -523,3 +524,90 @@ describe('GEN-3g: "-Hell" in variety name == cchm/ch het; hom == cchm/cchm', ()
expect(name('aa cchmch DD EE gg PP spsp rere')).toBe('Zobel-Hell')
})
})
describe('GEN-3h: breeder bracket-notation display + E-locus e-before-ef order', () => {
// ── Display symbols ────────────────────────────────────────────────────
it('ef displays as e[f], cchm as c[chm], ch as c[h]', () => {
// Fuchsschimmel: E=[ef,ef] hom
expect(toDisplayString(fromDisplayString('AA CC DD efef GG PP spsp rere'))).toBe(
'AA CC DD e[f]e[f] GG PP spsp rere',
)
// C-locus het: cchm + ch
expect(toDisplayString(fromDisplayString('aa cchmch DD EE GG PP spsp rere'))).toBe(
'aa c[chm]c[h] DD EE GG PP spsp rere',
)
// C-locus hom cchm
expect(toDisplayString(fromDisplayString('aa cchmcchm DD EE GG PP spsp rere'))).toBe(
'aa c[chm]c[chm] DD EE GG PP spsp rere',
)
})
// ── E-locus display order: E > e > e[f] ─────────────────────────────
it('Fuchsschimmel het pair {ef,e} displays as ee[f] (e before e[f])', () => {
// Stored canonical: [ef, e] (ef dominant over e in storage).
// Display must swap to [e, ef] per breeder convention.
const g = fromDisplayString('aa CC DD eef Gg Pp spsp rere')
expect(g.E).toEqual(['ef', 'e']) // storage order unchanged
expect(toDisplayString(g)).toBe('aa CC DD ee[f] Gg Pp spsp rere')
})
it('E+e stays Ee (E dominant over e, no swap needed)', () => {
expect(toDisplayString(fromDisplayString('aa CC DD Ee GG PP spsp rere'))).toBe(
'aa CC DD Ee GG PP spsp rere',
)
})
it('E+ef displays Ee[f] (E dominant stays first, ef renders as e[f])', () => {
expect(toDisplayString(fromDisplayString('aa CC DD Eef GG PP spsp rere'))).toBe(
'aa CC DD Ee[f] GG PP spsp rere',
)
})
// ── Julian oracle fixtures (HUMANQUESTION D3/D4) ─────────────────────
it('Tier C: oracle display string round-trips exactly (aa C- D- ee[f] Gg Pp spsp rere)', () => {
const display = 'aa C- D- ee[f] Gg Pp spsp rere'
const g = fromDisplayString(display)
expect(g.E).toEqual(['ef', 'e'])
expect(g.C).toEqual(['C', '?'])
expect(toDisplayString(g)).toBe(display)
// Farbschlag scope is outside GEN-3h; god confirmed colour is correct as-is.
})
it('Zuleika oracle: aa c[chm]c[h] DD Ee Gg PP spsp rere', () => {
const display = 'aa c[chm]c[h] DD Ee Gg PP spsp rere'
const g = fromDisplayString(display)
expect(g.C).toEqual(['cchm', 'ch'])
expect(g.E).toEqual(['E', 'e'])
expect(toDisplayString(g)).toBe(display)
})
it('Milka oracle: aa Cc[h] dd EE Gg P- Spsp rere', () => {
const display = 'aa Cc[h] dd EE Gg P- Spsp rere'
const g = fromDisplayString(display)
expect(g.C).toEqual(['C', 'ch'])
expect(g.D).toEqual(['d', 'd'])
expect(toDisplayString(g)).toBe(display)
})
// ── Parser accepts both forms ─────────────────────────────────────────
it('bracket input round-trips identically to internal-symbol input', () => {
expect(toDisplayString(fromDisplayString('AA c[chm]c[chm] DD EE GG PP spsp rere'))).toBe(
toDisplayString(fromDisplayString('AA cchmcchm DD EE GG PP spsp rere')),
)
expect(toDisplayString(fromDisplayString('AA CC DD e[f]e[f] GG PP spsp rere'))).toBe(
toDisplayString(fromDisplayString('AA CC DD efef GG PP spsp rere')),
)
expect(toDisplayString(fromDisplayString('AA CC DD ee[f] GG PP spsp rere'))).toBe(
toDisplayString(fromDisplayString('AA CC DD eef GG PP spsp rere')),
)
})
it('[-] bracket-unknown parses as wildcard (e[-] → E=[e,?], displays e-)', () => {
// NOTE: oracle for Silvain shows "ee[-]" which contains 3 E-allele tokens
// (e + e + [-]) and cannot be parsed. Flagged to god — see done-report.
// This test documents what [e,?] at E produces: "e-".
const g = fromDisplayString('aa CC DD e[-] GG PP spsp rere')
expect(g.E).toEqual(['e', '?'])
expect(toDisplayString(g)).toBe('aa CC DD e- GG PP spsp rere')
})
})

View File

@@ -2,35 +2,35 @@
{
"name": "Pink Eyed White (PEW)",
"english": "Pink Eyed White",
"canonicalGenotype": "AA chch DD EE GG pp spsp rere",
"canonicalGenotype": "AA c[h]c[h] 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",
"canonicalGenotype": "aa c[h]c[h] DD EE GG PP spsp rere",
"sortOrder": 1,
"image": "hermelin.jpeg"
},
{
"name": "Himalaya",
"english": "Himalayan",
"canonicalGenotype": "AA chch DD EE GG PP spsp rere",
"canonicalGenotype": "AA c[h]c[h] DD EE GG PP spsp rere",
"sortOrder": 2,
"image": "himalaya.jpg"
},
{
"name": "Zobel",
"english": "Sable",
"canonicalGenotype": "aa cchmcchm DD EE gg PP spsp rere",
"canonicalGenotype": "aa c[chm]c[chm] 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",
"canonicalGenotype": "AA CC DD e[f]e[f] GG pp spsp rere",
"sortOrder": 4,
"image": "rotaugen-schimmel.jpg"
},
@@ -168,7 +168,7 @@
},
{
"name": "Orangeschimmel",
"canonicalGenotype": "AA CC DD efef GG PP spsp rere",
"canonicalGenotype": "AA CC DD e[f]e[f] GG PP spsp rere",
"sortOrder": 25,
"image": "schimmel-orangeschimmel.jpg"
},
@@ -210,31 +210,31 @@
},
{
"name": "Silberschimmel",
"canonicalGenotype": "AA CC DD efef gg PP spsp rere",
"canonicalGenotype": "AA CC DD e[f]e[f] gg PP spsp rere",
"sortOrder": 32,
"image": "silberschimmel.jpg"
},
{
"name": "Polarfuchsschimmel",
"canonicalGenotype": "AA CC DD efef gg PP spsp rere",
"canonicalGenotype": "AA CC DD e[f]e[f] gg PP spsp rere",
"sortOrder": 33,
"image": "polarfuchsschimmel.jpg"
},
{
"name": "Algierfuchsschimmel",
"canonicalGenotype": "AA CC DD efef GG PP spsp rere",
"canonicalGenotype": "AA CC DD e[f]e[f] GG PP spsp rere",
"sortOrder": 34,
"image": "algierfuchsschimmel.jpg"
},
{
"name": "Kohlfuchsschimmel",
"canonicalGenotype": "aa CC DD efef GG PP spsp rere",
"canonicalGenotype": "aa CC DD e[f]e[f] GG PP spsp rere",
"sortOrder": 35,
"image": "kohlfuchsschimmel.jpg"
},
{
"name": "Blaufuchsschimmel",
"canonicalGenotype": "aa CC DD efef gg PP spsp rere",
"canonicalGenotype": "aa CC DD e[f]e[f] gg PP spsp rere",
"sortOrder": 36,
"image": "blaufuchsschimmel.jpg"
},
@@ -252,7 +252,7 @@
},
{
"name": "Goldfuchsschimmel",
"canonicalGenotype": "AA CC DD efef GG pp spsp rere",
"canonicalGenotype": "AA CC DD e[f]e[f] GG pp spsp rere",
"sortOrder": 39,
"image": "goldfuchsschimmel.jpg"
},
@@ -270,7 +270,7 @@
},
{
"name": "Rotfuchsschimmel",
"canonicalGenotype": "aa CC DD efef GG pp spsp rere",
"canonicalGenotype": "aa CC DD e[f]e[f] GG pp spsp rere",
"sortOrder": 42,
"image": "rotfuchsschimmel.jpg"
},
@@ -282,7 +282,7 @@
},
{
"name": "Kohlfuchsschimmel, hell",
"canonicalGenotype": "aa CC DD efef GG PP spsp rere",
"canonicalGenotype": "aa CC DD e[f]e[f] GG PP spsp rere",
"sortOrder": 44,
"image": "kohlfuchsschimmel-hell.jpg"
},
@@ -318,89 +318,89 @@
},
{
"name": "Marder",
"canonicalGenotype": "aa cchmcchm DD EE GG PP spsp rere",
"canonicalGenotype": "aa c[chm]c[chm] DD EE GG PP spsp rere",
"sortOrder": 50,
"image": "marder.JPG"
},
{
"name": "Siam",
"canonicalGenotype": "aa cchmch DD EE GG PP spsp rere",
"canonicalGenotype": "aa c[chm]c[h] DD EE GG PP spsp rere",
"sortOrder": 51,
"image": "siam-marder-hell.JPG"
},
{
"name": "Zobel-Hell",
"canonicalGenotype": "aa cchmch DD EE gg PP spsp rere",
"canonicalGenotype": "aa c[chm]c[h] DD EE gg PP spsp rere",
"sortOrder": 52,
"image": "zobel-hell.jpg"
},
{
"name": "CP-Agouti",
"canonicalGenotype": "AA cchmcchm DD EE GG PP spsp rere",
"canonicalGenotype": "AA c[chm]c[chm] DD EE GG PP spsp rere",
"sortOrder": 53,
"image": "agouti-cp.jpg"
},
{
"name": "CP-Agouti-Hell",
"canonicalGenotype": "AA cchmch DD EE GG PP spsp rere",
"canonicalGenotype": "AA c[chm]c[h] DD EE GG PP spsp rere",
"sortOrder": 54
},
{
"name": "CP-Silberagouti",
"canonicalGenotype": "AA cchmcchm DD EE gg PP spsp rere",
"canonicalGenotype": "AA c[chm]c[chm] DD EE gg PP spsp rere",
"sortOrder": 55,
"image": "silberagouti-cp.JPG"
},
{
"name": "CP-Silberagouti-Hell",
"canonicalGenotype": "AA cchmch DD EE gg PP spsp rere",
"canonicalGenotype": "AA c[chm]c[h] DD EE gg PP spsp rere",
"sortOrder": 56
},
{
"name": "CP-Algierfuchs",
"canonicalGenotype": "AA cchmcchm DD ee GG PP spsp rere",
"canonicalGenotype": "AA c[chm]c[chm] DD ee GG PP spsp rere",
"sortOrder": 57,
"image": "algierfuchs-cp.jpg"
},
{
"name": "CP-Algierfuchs-Hell",
"canonicalGenotype": "AA cchmch DD ee GG PP spsp rere",
"canonicalGenotype": "AA c[chm]c[h] DD ee GG PP spsp rere",
"sortOrder": 58
},
{
"name": "CP-Polarfuchs",
"canonicalGenotype": "AA cchmcchm DD ee gg PP spsp rere",
"canonicalGenotype": "AA c[chm]c[chm] DD ee gg PP spsp rere",
"sortOrder": 59,
"image": "polarfuchs-cp.jpg"
},
{
"name": "CP-Polarfuchs-Hell",
"canonicalGenotype": "AA cchmch DD ee gg PP spsp rere",
"canonicalGenotype": "AA c[chm]c[h] DD ee gg PP spsp rere",
"sortOrder": 60
},
{
"name": "CP-Fuchs",
"canonicalGenotype": "AA cchmcchm dd ee GG PP spsp rere",
"canonicalGenotype": "AA c[chm]c[chm] dd ee GG PP spsp rere",
"sortOrder": 61
},
{
"name": "CP-Fuchs-Hell",
"canonicalGenotype": "AA cchmch dd ee GG PP spsp rere",
"canonicalGenotype": "AA c[chm]c[h] dd ee GG PP spsp rere",
"sortOrder": 62
},
{
"name": "CP-Blaufuchs",
"canonicalGenotype": "AA cchmcchm dd ee gg PP spsp rere",
"canonicalGenotype": "AA c[chm]c[chm] dd ee gg PP spsp rere",
"sortOrder": 63
},
{
"name": "CP-Orangeschimmel",
"canonicalGenotype": "AA cchmcchm DD efef GG PP spsp rere",
"canonicalGenotype": "AA c[chm]c[chm] DD e[f]e[f] GG PP spsp rere",
"sortOrder": 64
},
{
"name": "CP-Orangeschimmel-Hell",
"canonicalGenotype": "AA cchmch DD efef GG PP spsp rere",
"canonicalGenotype": "AA c[chm]c[h] DD e[f]e[f] GG PP spsp rere",
"sortOrder": 65
}
]

View File

@@ -74,6 +74,32 @@ export function wildType(): Genotype {
return out
}
/**
* GEN-3h: breeder bracket-notation display symbols.
* STORAGE symbols (ef / cchm / ch) are frozen; only the rendered form changes.
*/
const DISPLAY_SYMBOL: Readonly<Partial<Record<string, string>>> = {
ef: 'e[f]',
cchm: 'c[chm]',
ch: 'c[h]',
}
function displaySymbol(allele: string): string {
return DISPLAY_SYMBOL[allele] ?? allele
}
/**
* GEN-3h: E-locus display order — breeder convention is E > e > e[f].
* Storage/dominance order is E > ef > e; display swaps ef and e so that
* a Fuchsschimmel (E=[ef,e] stored) renders as "ee[f]" not "e[f]e".
*/
const E_DISPLAY_RANK: Readonly<Record<string, number>> = { E: 0, e: 1, ef: 2 }
function displayPair(locus: LocusKey, pair: AllelePair): AllelePair {
if (locus !== 'E') return pair
const rank = (x: string) => E_DISPLAY_RANK[x] ?? Number.MAX_SAFE_INTEGER
return rank(pair[0]) <= rank(pair[1]) ? pair : [pair[1], pair[0]]
}
/**
* Compact display string, e.g. "Aa CC Dd EE GG Pp spsp rere".
* The Sls locus is OMITTED when wild-type (sl/sl) so legacy 8-locus strings and
@@ -81,12 +107,18 @@ export function wildType(): Genotype {
* (e.g. "… spsp rere Slsl"). Round-trips: a missing Sls re-parses to sl/sl.
* GEN-3c: unknown alleles are STORED as '?' but DISPLAYED as '-' (breeder
* convention) — e.g. ['C','?'] renders "C-".
* GEN-3h: sub-alleles use breeder bracket notation (e[f], c[chm], c[h]);
* E-locus display order is E > e > e[f] (e before e[f] in het pairs).
*/
export function toDisplayString(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, '-'))
.map((locus) => {
const [a, b] = displayPair(locus, g[locus])
return displaySymbol(a) + displaySymbol(b)
})
.map((s) => s.replace(/\?/g, '-'))
.join(' ')
}
@@ -146,8 +178,13 @@ function normalizeToken(tok: string): string | null {
if (t === 'WP') t = 'Slsl'
t = t.replace(/S\(l\)/g, 'Sl').replace(/s\(l\)/g, 'sl')
t = t.replace(/Uw/g, 'G').replace(/uw/g, 'g')
// GEN-3c: '-' is the breeder's UNKNOWN marker on input; store internally as '?'
// (the frozen storage contract keeps '?'; only DISPLAY renders '-').
// GEN-3h: accept bracket display notation → canonical internal symbols.
// Order matters: [-] must be replaced before the plain-dash rule below.
t = t.replace(/\[-\]/g, '?') // bracket-unknown [-] → internal wildcard
t = t.replace(/e\[f\]/g, 'ef') // Schimmel display form → internal
t = t.replace(/c\[chm\]/g, 'cchm') // Colourpoint display form → internal
t = t.replace(/c\[h\]/g, 'ch') // Himalayan display form → internal
// GEN-3c: plain dash is the breeder's UNKNOWN marker on input; store internally as '?'.
t = t.replace(/-/g, '?')
return t
}

View File

@@ -44,7 +44,7 @@ export default function BeckenDetailPage() {
setDeleteError(null)
const result = await removal.run()
if (result.ok) {
navigate('/gehege')
navigate('/becken')
} else if (result.cause instanceof ApiError && result.cause.status === 409) {
// Backend meldet Konflikt, wenn noch Tiere im Becken wohnen.
setDeleteError(t.delete.conflict)
@@ -58,7 +58,7 @@ export default function BeckenDetailPage() {
return (
<section className="page">
<p className="muted">{enclosure.error ?? t.detail.notFound}</p>
<Link to="/gehege" className="btn">
<Link to="/becken" className="btn">
{t.detail.back}
</Link>
</section>
@@ -81,7 +81,7 @@ export default function BeckenDetailPage() {
)}
</div>
<div className="head-actions">
<Link to={`/gehege/${e.id}/bearbeiten`} className="btn btn--primary">
<Link to={`/becken/${e.id}/bearbeiten`} className="btn btn--primary">
{t.detail.edit}
</Link>
<button
@@ -92,7 +92,7 @@ export default function BeckenDetailPage() {
>
{t.delete.action}
</button>
<Link to="/gehege" className="btn">
<Link to="/becken" className="btn">
{t.detail.back}
</Link>
</div>

View File

@@ -45,7 +45,7 @@ export default function BeckenFormPage() {
}
setErrors({})
const result = await mutation.run({ name: form.name.trim(), notes: nn(form.notes) })
if (result.ok) navigate(`/gehege/${result.value.id}`)
if (result.ok) navigate(`/becken/${result.value.id}`)
// On failure mutation.error drives the inline alert; run() never throws.
}
@@ -81,7 +81,7 @@ export default function BeckenFormPage() {
<button type="submit" className="btn btn--primary" disabled={mutation.pending}>
{mutation.pending ? t.form.saving : t.form.save}
</button>
<Link to={isEdit && id ? `/gehege/${id}` : '/gehege'} className="btn">
<Link to={isEdit && id ? `/becken/${id}` : '/becken'} className="btn">
{t.form.cancel}
</Link>
</div>

View File

@@ -35,7 +35,7 @@ export default function BeckenPage() {
</p>
)}
</div>
<Link to="/gehege/neu" className="btn btn--primary">
<Link to="/becken/neu" className="btn btn--primary">
+ {t.newButton}
</Link>
</header>
@@ -72,7 +72,7 @@ export default function BeckenPage() {
<ul className="card-list">
{items.map((e) => (
<li key={e.id}>
<Link to={`/gehege/${e.id}`} className="gerbil-card">
<Link to={`/becken/${e.id}`} className="gerbil-card">
<span className="gerbil-card__name">{e.name}</span>
<span className="gerbil-card__meta">{e.notes ?? ''}</span>
</Link>

View File

@@ -38,12 +38,12 @@ const sections: Section[] = [
<p>Die Navigation erreichst du unten (Smartphone) oder links (Laptop). Mit <strong>{nav.more}"</strong> öffnest du alle weiteren Bereiche.</p>
<p>Empfohlene Reihenfolge beim ersten Mal:</p>
<ol>
<li>Gehege anlegen (wo leben deine Tiere?)</li>
<li>Becken anlegen (wo leben deine Tiere?)</li>
<li>Rennmäuse anlegen mit Foto</li>
<li>Würfe eintragen</li>
<li>Genetik erkunden</li>
</ol>
<p className="hilfe-hint">Tipp: Lege zuerst die Gehege an — dann kannst du beim Erfassen der Tiere direkt das richtige Gehege auswählen.</p>
<p className="hilfe-hint">Tipp: Lege zuerst die Becken an — dann kannst du beim Erfassen der Tiere direkt das richtige Becken auswählen.</p>
</>
),
},
@@ -53,7 +53,7 @@ const sections: Section[] = [
content: (
<>
<p>Unter <strong>„{nav.gerbils}"</strong> siehst du alle deine Rennmäuse. Mit <strong>{g.newButton}"</strong> legst du ein neues Tier an.</p>
<p>Pflichtfelder sind <strong>„{g.fields.name}"</strong> und <strong>{g.fields.gender}"</strong>. Optional kannst du Geburtsdatum, Farbschlag, Genotyp, Gehege und Herkunft eintragen.</p>
<p>Pflichtfelder sind <strong>„{g.fields.name}"</strong> und <strong>{g.fields.gender}"</strong>. Optional kannst du Geburtsdatum, Farbschlag, Genotyp, Becken und Herkunft eintragen.</p>
<p>Auf der Detailseite findest du drei Tabs:</p>
<ul>
<li><strong>„{g.detail.tabs.photos}"</strong> Fotos hochladen; das erste Foto wird als Profilfoto angezeigt.</li>
@@ -69,9 +69,9 @@ const sections: Section[] = [
title: t.sections.becken,
content: (
<>
<p>Unter <strong>{nav.enclosures}"</strong> verwaltest du deine Gehege. Mit <strong>„{b.newButton}"</strong> legst du ein neues Gehege an (nur Name ist Pflicht).</p>
<p>Unter <strong>{nav.enclosures}"</strong> verwaltest du deine Gehege. Mit <strong>„{b.newButton}"</strong> legst du ein neues Becken an (nur Name ist Pflicht).</p>
<p>Die Detailseite zeigt dir die aktuellen <strong>{b.occupancy.title}"</strong> — mit Links direkt zur Tierakte. So siehst du auf einen Blick, wer wo lebt.</p>
<p>Ein Gehege lässt sich nur löschen, wenn es leer ist — das schützt vor versehentlichem Datenverlust.</p>
<p>Ein Becken lässt sich nur löschen, wenn es leer ist — das schützt vor versehentlichem Datenverlust.</p>
<p>Unter <strong>„{nav.contacts}"</strong> kannst du Züchter, Käufer und andere Kontakte anlegen. Diese lassen sich dann als Herkunft oder Abnehmer bei Tieren eintragen.</p>
</>
),

View File

@@ -0,0 +1,10 @@
import { de } from '../strings/de'
export default function HomePage() {
return (
<section>
<h2>{de.pages.home.title}</h2>
<p>{de.pages.home.intro}</p>
</section>
)
}

View File

@@ -9,12 +9,13 @@ export const de = {
tagline: 'Verwaltung für Rennmäuse, Würfe und Züchter',
},
nav: {
home: 'Start',
gerbils: 'Rennmäuse',
litters: 'Würfe',
breeders: 'Züchter',
genetics: 'Genetik',
// FEAT-2 (Oscar): Becken + Kontakte (GEHEGE-RENAME: Becken → Gehege)
enclosures: 'Gehege',
// FEAT-2 (Oscar): Becken + Kontakte
enclosures: 'Becken',
contacts: 'Kontakte',
// FEAT-7 (Kelly): Statistik
statistics: 'Statistik',
@@ -36,6 +37,11 @@ export const de = {
website: 'Webseite',
},
pages: {
home: {
title: 'Willkommen',
intro:
'Hier verwaltest du deine Rennmäuse, Würfe und Züchter. Wähle einen Bereich aus der Navigation.',
},
gerbils: {
title: 'Rennmäuse',
// Liste
@@ -76,7 +82,7 @@ export const de = {
causeOfDeath: 'Todesursache',
goHomeDate: 'Abgabedatum',
litter: 'Wurf',
enclosure: 'Gehege',
enclosure: 'Becken',
colorVariety: 'Farbschlag',
origin: 'Herkunft',
receiver: 'Abnehmer',
@@ -247,8 +253,8 @@ export const de = {
markMultiAction: 'Ausgewählte zur Abgabe stellen',
grouping: {
title: 'Gruppen',
byEnclosure: 'Nach Gehege vorgeschlagen',
ungrouped: 'Ohne Gehege',
byEnclosure: 'Nach Becken vorgeschlagen',
ungrouped: 'Ohne Becken',
moveTo: 'Verschieben nach',
newGroup: 'Neue Gruppe',
groupLabel: 'Gruppe',
@@ -292,12 +298,12 @@ export const de = {
notConfigured: 'KI-Funktion: API-Schlüssel noch nicht konfiguriert.',
},
},
// ── FEAT-2 (Oscar): Gehege (Enclosures) — GEHEGE-RENAME: Becken → Gehege ──
// ── FEAT-2 (Oscar): Becken (Enclosures) ──
becken: {
title: 'Gehege',
newButton: 'Neues Gehege',
empty: 'Keine Gehege gefunden.',
countLabel: 'Gehege',
title: 'Becken',
newButton: 'Neues Becken',
empty: 'Keine Becken gefunden.',
countLabel: 'Becken',
searchPlaceholder: 'Name suchen …',
fields: {
name: 'Name',
@@ -305,18 +311,18 @@ export const de = {
},
occupancy: {
title: 'Bewohner',
empty: 'Dieses Gehege ist derzeit leer.',
empty: 'Dieses Becken ist derzeit leer.',
countOne: 'Tier',
countMany: 'Tiere',
},
detail: {
edit: 'Bearbeiten',
back: 'Zurück zur Liste',
notFound: 'Dieses Gehege wurde nicht gefunden.',
notFound: 'Dieses Becken wurde nicht gefunden.',
},
form: {
createTitle: 'Neues Gehege anlegen',
editTitle: 'Gehege bearbeiten',
createTitle: 'Neues Becken anlegen',
editTitle: 'Becken bearbeiten',
save: 'Speichern',
cancel: 'Abbrechen',
saving: 'Speichern …',
@@ -326,9 +332,9 @@ export const de = {
},
delete: {
action: 'Löschen',
confirmMessage: 'Soll dieses Gehege wirklich gelöscht werden?',
confirmMessage: 'Soll dieses Becken wirklich gelöscht werden?',
conflict:
'Gehege ist nicht leer — bitte zuerst alle Tiere in ein anderes Gehege umsetzen.',
'Becken ist nicht leer — bitte zuerst alle Tiere in ein anderes Becken umsetzen.',
},
},
// ── FEAT-4 (Kelly): Stammbaum (Ahnentafel) ──
@@ -725,7 +731,7 @@ export const de = {
sections: {
ersteSchritte: 'Erste Schritte',
tiere: 'Rennmäuse anlegen & Fotos',
becken: 'Gehege',
becken: 'Becken',
wuerfe: 'Würfe & Jungtiere',
genetik: 'Probeverpaarung & Genetik',
stammbaum: 'Stammbaum',