Compare commits

...

12 Commits

Author SHA1 Message Date
d116b8d8f1 UX-MOBILE-2: Genotyp-Tabellen-Overflow behoben (scrollbar statt page-overflow)
- BreedingResultView: <table class="genotype-table"> in <div class="genotype-table-scroll"> gewrapped.
- index.css: .genotype-table-scroll { overflow-x: auto; -webkit-overflow-scrolling: touch }.
  .genotype-table td { white-space: nowrap } → Tabelle scrollt statt Zellen umzubrechen.
  width: 100% entfernt (Tabelle auto-sized to content).
- index.css: .app-main { overflow-x: hidden } → verhindert, dass breite Inhalte
  den Page-Body horizontal überlaufen lassen (document.scrollWidth == clientWidth).
- e2e/genotype-table.spec.ts (4 Tests): Wrapper sichtbar, Wrapper selbst scrollbar
  (scrollWidth > clientWidth), keine Page-Overflow auf 390px. Beide Viewports.
- Vitest 78 / e2e 106 grün.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-06-06 15:41:59 +02:00
a2f5e848d1 Merge feature/qa-2: Verbatim-few-shots (POLISH-2), CI-Flake-Fix, skipUnlessLive+Live-Smoke, Abgabe-Wizard-e2e [god-QA validated]
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 52s
CI / Docker Build & Push (push) Has been cancelled
CI / Frontend Tests (Node/Vite) (push) Has been cancelled
2026-06-06 15:34:38 +02:00
de293904bb Merge feature/ux-mobile-1: FilterPanel (mobil: Suche sichtbar, Filter im Drawer mit Aktiv-Badge; Desktop inline) auf rennmaeuse/wuerfe/anfragen + e2e phone-coverage [god-QA validated]
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 52s
CI / Docker Build & Push (push) Has been cancelled
CI / Frontend Tests (Node/Vite) (push) Has been cancelled
2026-06-06 15:31:24 +02:00
68447896ee QA-2: Fix smart-quote raw-string delimiters in SaleAdPromptBuilder (POLISH-2 BOUNCE) 2026-06-06 15:30:46 +02:00
5f26594a12 Merge feature/gen-3g: 'hell'-Regel (cchm/ch = -Hell-Variante, cchm/cchm = Basis) + CP-Fuchs-Sammelbegriff ratifiziert, Katalog 61->66 (5 -Hell-Restaurationen), Seed regeneriert [god-QA validated]
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 52s
CI / Docker Build & Push (push) Has been cancelled
CI / Frontend Tests (Node/Vite) (push) Has been cancelled
2026-06-06 15:29:26 +02:00
6eb82da90c UX-MOBILE-1: FilterPanel — einklappbare Filter auf Mobil (390px)
Neues FilterPanel-Muster (src/components/FilterPanel.tsx + filterPanel.css):
- Mobil (<768px): Suchfeld immer sichtbar; alle weiteren Filter hinter einem
  'Filter (N)'-Button eingeklappt (Badge = Anzahl aktiver Nicht-Default-Filter).
  Tippen öffnet/schließt den Drawer. 'Filter zurücksetzen' im Drawer.
- Desktop (>=768px): Toggle versteckt, Drawer als display:contents → alle Controls
  inline in der bestehenden .filters-Flexzeile, wie bisher.
Auf GerbilsPage, WuerfeListPage und AnfragenPage ausgerollt.
BeckenPage/KontaktePage nur ein Suchfeld → kein Panel nötig.

e2e: filter-panel.spec.ts (8 Tests, phone+desktop); bestand/tiere/anfragen-Specs
erhalten openFilterPanel-Aufruf vor Drawer-Controls; fixtures.ts +openFilterPanel().
Vitest 78 / e2e 102 grün.

Mobil-Pass (390px, Durchsicht): Hauptproblem behoben. Beobachtete Restpunkte
für god (nicht selbst gefixed):
- .genotype-table in Genetik/Probeverpaarung: Genotyp-Strings können eng werden
- Stammbaum (react-d3-tree SVG): kein explizites mobile Viewport-Handling
- BreedingResultView Genotyp-Detail-Tabelle: ggf. horizontal scrollbar-los

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-06-06 15:28:13 +02:00
c357a18418 QA-2: Fix Abgabe-e2e Selektoren + ForSale in enc-leer (no Becken-Konflikt) + charakter scoped 2026-06-06 15:22:34 +02:00
791e9f91cc QA-2: Abgabe-Wizard e2e (ForSale-Seed, sale-ad+contracts Mock, abgabe.spec.ts) 2026-06-06 15:16:38 +02:00
efce79b3fa GEN-3g: CP colourpoint het-naming rule — '-Hell' == cchm/ch (breeder C9 answer)
- colourpointName(): A- cchm/ch now yields 'CP-<base>-Hell' (was 'CP-<base>');
  A- cchm/cchm unchanged. Non-agouti branch (Marder/Siam/Zobel/Zobel-Hell)
  unchanged — already used the het token.
- Added 5 new catalog entries: CP-Agouti-Hell, CP-Silberagouti-Hell,
  CP-Algierfuchs-Hell, CP-Polarfuchs-Hell, CP-Orangeschimmel-Hell.
  CP-Fuchs-Hell was already present. Catalog 61 -> 66.
- Regenerated colorVarietySeed.generated.json (66 rows for Pam DATA-2 re-seed).
- Tests: 82 -> 82 (reworded het case) + 1 new GEN-3g describe block. All pass.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-06-06 15:14:23 +02:00
868c5f5cd7 QA-2: skipUnlessLive() + Live-Smoke (Liste>=10, Suche Treffer, Eltern-Links) 2026-06-06 15:11:22 +02:00
ddfa3614a4 QA-2: CI-Flake fix — explizite Heading-Waits nach gotoSection in 'Kontakt anlegen' 2026-06-06 15:09:15 +02:00
13da8f2502 QA-2: POLISH-2 — verbatim few-shots von kleine-chaoten.jimdofree.com (Gruppe 1+4) 2026-06-06 15:08:56 +02:00
25 changed files with 749 additions and 93 deletions

View File

@@ -67,9 +67,9 @@ namespace GerbilManager.Tests
Assert.Contains("Zucht der kleinen Chaoten", prompt);
Assert.Contains("NIEMALS Preise", prompt);
Assert.Contains("KEINE Fakten erfinden", prompt);
// few-shot: die beiden Beispiel-Inserate (Status-Zeilen der Vorlage)
// few-shot: verbatim listings from kleine-chaoten.jimdofree.com
Assert.Contains("Status: LOCKER RESERVIERT LEONIE", prompt);
Assert.Contains("Status: FREI", prompt);
Assert.Contains("Status: LOCKER RESERVIERT Anna", prompt);
Assert.Contains("Großer und kleiner Bruder Dynamik", prompt);
}

View File

@@ -1,4 +1,4 @@
using System.Globalization;
using System.Globalization;
using System.Text;
namespace GerbilManagerWebAPI.SaleAd
@@ -13,37 +13,31 @@ namespace GerbilManagerWebAPI.SaleAd
public static class SaleAdPromptBuilder
{
/// <summary>
/// Two few-shot examples derived from the documented listing template.
/// NOTE: the research file documents the format and one real tagline;
/// these examples are synthesized to that template. Swap in verbatim
/// listings from kleine-chaoten.jimdofree.com when available.
/// Two verbatim listings from kleine-chaoten.jimdofree.com (retrieved 2026-06-06).
/// These are real listings in the breeder's own voice — the strongest grounding source.
/// </summary>
private const string ExampleListing1 = """
Status: FREI
Status: LOCKER RESERVIERT LEONIE
Großer und kleiner Bruder Dynamik Männliches Rennmaus-Duo sucht ein liebevolles Zuhause
Großer und kleiner Bruder Dynamik Männliches Rennmaus Duo sucht ein liebevolles Zuhause
Balu CP-Agouti, geboren am 12.03.2024
Balu ist der ruhige Pol des Duos: Er beobachtet erst in aller Seelenruhe und buddelt sich dann zielstrebig durch jedes Einstreu-Projekt. Aus der Hand nimmt er Leckerlis schon ganz vorsichtig.
Hier steht ein außergewöhnlich harmonisches Rennmaus-Duo zur Abgabe frei, das nicht nur durch seinen Charakter, sondern auch durch seine besondere Geschichte überzeugt. Die zwei verhalten sich als ob sie Brüder wären und sind unzertrennlich. Das Duo kann ab sofort ausziehen.
Benny Schwarz Schecke, geboren am 12.03.2024
Benny ist der Entdecker: kein Röhrchen bleibt unerforscht, kein Häuschen unbewohnt. Mit seinem Bruder kuschelt er sich abends ins Nest getrennt werden die beiden deshalb nicht.
Der Ältere von beiden trägt die Farbe CP-Agouti und ist am 11.11.2025 geboren. Er ist ein neugieriger, aktiver und sozialer kleiner Kerl. Er liebt es, seine Umgebung zu erkunden, ist gerne in Bewegung und bringt dabei eine angenehme Ausgeglichenheit mit. Er wirkt sicher, aufmerksam und zeigt ein rundum unkompliziertes Wesen.
Die zwei werden nur gemeinsam in ein rennmausgerechtes Zuhause abgegeben.
Sein, etwas jünger, Partner trägt die Farbe Schwarz Schecke und ist am 26.02.2026 geboren. Statt des in diesem Alter oft typischen Kräftemessens zeigt er ein erstaunlich sanftes, ruhiges und sehr soziales Verhalten. Man merkt ihm an, wie feinfühlig und verträglich er ist.
""";
private const string ExampleListing2 = """
Status: LOCKER RESERVIERT Anna
Status: FREI
Zwei Schwestern, ein Herz und ganz viel Neugier Weibliches Duo sucht seine Menschen
Unzertrennliches Mutter-Tochter Duo sucht gemeinsam neue Körnergeber
Frieda Gold, geboren am 28.06.2024
Frieda ist die Mutige der beiden und steht beim Öffnen des Geheges sofort am Glas. Sie liebt Kolbenhirse und nimmt sie dir behutsam aus den Fingern.
Die Mama ist am 13.08.2025 geboren und trägt die Farbe Kohlfuchs. Sie ist eine eher ruhige und vorsichtige Rennmaus, die neuen Situationen zunächst aufmerksam begegnet. Gibt man ihr etwas Zeit, zeigt sich schnell ihre neugierige Seite und sie kommt gerne schauen, was im Gehege passiert. Sie ist ein echtes Energiebündel, liebt es zu buddeln, zu rennen und alles an Beschäftigungsmaterial besonders Klorollen kreativ umzugestalten. Charakterlich zeigt sie sich sehr sozial und friedlich; besonders hervorzuheben ist ihre fürsorgliche Art als Mutter.
Fine Agouti, geboren am 28.06.2024
Fine ist etwas zurückhaltender, taut aber neben ihrer Schwester schnell auf. Beim abendlichen Buddeln sind die zwei ein unschlagbares Team.
Die Tochter ist am 26.02.2026 geboren und trägt die Farbe Agouti-Schecke. Sie ist ihrer Mama sehr ähnlich, dabei aber schon ein kleines bisschen mutiger. Auch sie beobachtet erst gerne in Ruhe, bevor die Neugier siegt. Sie ist sehr anhänglich und orientiert sich stark an ihrer Mutter.
Die Schwestern ziehen selbstverständlich nur zusammen um.
Die zwei werden nur gemeinsam in ein rennmausgerechtes Zuhause abgegeben.
""";
/// <summary>System prompt: role, style description, hard rules, few-shot examples.</summary>

View File

@@ -0,0 +1,129 @@
/**
* QA-2: Abgabe-Wizard (FEAT-12a + FEAT-13) — Abgabetiere gruppieren,
* KI-Inserat generieren, und Vertrag-Handoff via ?tiere= Query-Parameter.
*
* Mock-gebunden (Seed: 2 ForSale-Tiere im Quarantänebecken) → skipUnlessMock.
*/
import { de, expect, gotoSection, skipUnlessMock, test } from './fixtures'
const ta = de.pages.abgabe
const tw = de.pages.vertraege.wizard
test.describe('Abgabe', () => {
test('Abgabe-Seite zeigt ForSale-Tiere und Inserat-Vorschau', async ({ page }) => {
skipUnlessMock()
await gotoSection(page, de.nav.forSale)
await expect(page.getByRole('heading', { name: ta.title, exact: true })).toBeVisible()
await expect(page.getByText(ta.grouping.title)).toBeVisible()
// Die beiden ForSale-Tiere müssen als Tiername (strong-Element im Komposer) sichtbar sein
await expect(page.locator('.group-composer__animal-head strong').filter({ hasText: 'Balu Abgabe' })).toBeVisible()
await expect(page.locator('.group-composer__animal-head strong').filter({ hasText: 'Benny Abgabe' })).toBeVisible()
// Inserat-Vorschau (listing-preview) wird gerendert
await expect(page.locator('.listing-preview').first()).toBeVisible()
// Status-Dropdown standard: FREI
await expect(
page.locator('label.field').filter({ has: page.locator(`span:text-is("${ta.listing.status}")`) }).locator('select').first()
).toHaveValue('free')
})
test('KI-Inserat: "Text mit KI verbessern" — Mock liefert Inserat-Text', async ({ page }) => {
skipUnlessMock()
await gotoSection(page, de.nav.forSale)
await expect(page.locator('.group-composer__animal-head strong').filter({ hasText: 'Balu Abgabe' })).toBeVisible()
await page.getByRole('button', { name: ta.ai.improve }).first().click()
// Mock antwortet synchron; der generierte Text erscheint in der Vorschau
await expect(page.locator('.listing-preview').first()).toContainText('Generierter Inserat-Text')
})
test('KI-Inserat: 503 zeigt deutschen Hinweis (saleAdConfigured=false)', async ({ page, mockDb }) => {
skipUnlessMock()
mockDb!.saleAdConfigured = false
await gotoSection(page, de.nav.forSale)
await expect(page.locator('.group-composer__animal-head strong').filter({ hasText: 'Balu Abgabe' })).toBeVisible()
await page.getByRole('button', { name: ta.ai.improve }).first().click()
await expect(page.getByText(ta.ai.notConfigured)).toBeVisible()
})
test('Vertrag-Handoff: "Abgabe abschließen" öffnet Wizard mit vorausgewählten Tieren', async ({
page,
}) => {
skipUnlessMock()
await gotoSection(page, de.nav.forSale)
await expect(page.locator('.group-composer__animal-head strong').filter({ hasText: 'Balu Abgabe' })).toBeVisible()
// Wizard öffnen (navigiert zu /vertraege/neu?tiere=sale-balu,sale-benny)
await page.getByRole('button', { name: ta.export.finish }).first().click()
await expect(page).toHaveURL(/\/vertraege\/neu\?tiere=/)
await expect(page.getByRole('heading', { name: tw.title })).toBeVisible()
// Schritt 1: Abnehmer auswählen
await expect(page.getByRole('heading', { name: tw.pickContact })).toBeVisible()
})
})
test.describe('Vertrag-Wizard', () => {
test('4-Schritte-Wizard: Abnehmer → Tiere → Preis → Vertrag erzeugen', async ({ page }) => {
skipUnlessMock()
// Direkt mit vorausgewählten Tieren öffnen (wie über Abgabe-Seite)
await page.goto('/vertraege/neu?tiere=sale-balu,sale-benny')
await expect(page.getByRole('heading', { name: tw.title })).toBeVisible()
// Schritt 1: Abnehmer wählen — Zoohandlung Meier aus dem Seed
await expect(page.getByRole('heading', { name: tw.pickContact })).toBeVisible()
await page.locator('.wizard-pick').filter({ hasText: 'Zoohandlung Meier' }).getByRole('radio').check()
await page.getByRole('button', { name: tw.next }).click()
// Schritt 2: Tiere — Vorauswahl aus ?tiere= ist aktiv
await expect(page.getByRole('heading', { name: tw.pickAnimals })).toBeVisible()
// Beide ForSale-Tiere sind vorausgewählt (Checkboxen angehakt)
await expect(
page.locator('.wizard-pick').filter({ hasText: 'Balu Abgabe' }).getByRole('checkbox')
).toBeChecked()
await expect(
page.locator('.wizard-pick').filter({ hasText: 'Benny Abgabe' }).getByRole('checkbox')
).toBeChecked()
await page.getByRole('button', { name: tw.next }).click()
// Schritt 3: Preis & Datum
await page.getByLabel(tw.priceLabel).fill('50,00')
await page.getByRole('button', { name: tw.next }).click()
// Schritt 4: Zusammenfassung + Erzeugen
await expect(page.getByText(tw.summaryTitle)).toBeVisible()
await expect(page.getByText('Zoohandlung Meier')).toBeVisible()
await expect(page.getByText('Balu Abgabe')).toBeVisible()
await page.getByRole('button', { name: tw.generate }).click()
// Erfolg: Vertrag erstellt, Download-Link vorhanden
await expect(page.getByRole('heading', { name: tw.successTitle })).toBeVisible()
await expect(page.getByRole('link', { name: tw.downloadDocx })).toBeVisible()
})
test('Schritt 1: fehlender Abnehmer zeigt Validierungsfehler', async ({ page }) => {
skipUnlessMock()
await page.goto('/vertraege/neu?tiere=sale-balu')
await expect(page.getByRole('heading', { name: tw.pickContact })).toBeVisible()
// Ohne Auswahl direkt auf Weiter klicken → Fehler-Alert erscheint
await page.getByRole('button', { name: tw.next }).click()
// Alert enthält den Hinweis (stepError), Schritt bleibt auf 1
await expect(page.locator('.alert--error').filter({ hasText: tw.pickContact })).toBeVisible()
})
test('Schritt 3: ungültiger Preis zeigt Validierungsmeldung', async ({ page }) => {
skipUnlessMock()
await page.goto('/vertraege/neu?tiere=sale-balu')
// Schritt 1: Abnehmer wählen
await page.locator('.wizard-pick').filter({ hasText: 'Zoohandlung Meier' }).getByRole('radio').check()
await page.getByRole('button', { name: tw.next }).click()
// Schritt 2: Tiere bestätigen
await expect(page.getByRole('heading', { name: tw.pickAnimals })).toBeVisible()
await page.getByRole('button', { name: tw.next }).click()
// Schritt 3: leeres Preisfeld -> Fehler
await expect(page.getByLabel(tw.priceLabel)).toBeVisible()
await page.getByRole('button', { name: tw.next }).click()
await expect(page.locator('.alert--error').filter({ hasText: tw.priceInvalid })).toBeVisible()
})
})

View File

@@ -3,7 +3,7 @@
* AiKeyMissing-Hinweis), Senden (inkl. MailNotConfigured-Hinweis).
* Mock-gebunden (Seed-Anfragen + Fehlerpfad-Flags) → skipUnlessMock.
*/
import { acceptNextDialog, de, expect, gotoSection, skipUnlessMock, test } from './fixtures'
import { acceptNextDialog, de, expect, gotoSection, openFilterPanel, skipUnlessMock, test } from './fixtures'
const ta = de.pages.anfragen
const td = ta.detail
@@ -23,6 +23,8 @@ test.describe('Anfragen', () => {
// Status-Badge auf der Karte
await expect(cards.nth(0)).toContainText(ta.statusLabels.New)
// UX-MOBILE-1: Status-Select liegt im Filter-Drawer — auf Mobil erst öffnen.
await openFilterPanel(page)
// Filter: nur Beantwortet
await page.getByLabel(td.statusLabel).selectOption('Answered')
await expect(cards).toHaveCount(1)

View File

@@ -72,7 +72,13 @@ test.describe('Kontakte', () => {
test('Kontakt anlegen und (unverknüpft) wieder löschen', async ({ page }) => {
await gotoSection(page, de.nav.contacts)
// Explizit auf die Listenüberschrift warten, bevor wir klicken — verhindert den
// CI-Flake, der auftrat wenn der Vite-Server unter paralleler Last noch nicht
// alle Routen der Seite gerendert hatte (gotoSection klickt nur den Nav-Link,
// wartet aber nicht auf den vollständigen Seitenaufbau).
await expect(page.getByRole('heading', { name: tk.title, exact: true })).toBeVisible()
await page.getByRole('link', { name: tk.newButton }).click()
await expect(page.getByRole('heading', { name: tk.form.createTitle })).toBeVisible()
const name = uniqueName('Kontakt')
await page.getByLabel(`${tk.fields.name} *`).fill(name)
// FEAT-13: strukturierte Felder statt Freitext-Kontaktdaten

View File

@@ -1,5 +1,5 @@
/** BESTAND-FILTER: die Tiere-Liste zeigt standardmäßig nur den eigenen Bestand. */
import { de, expect, gotoSection, skipUnlessMock, test } from './fixtures'
import { de, expect, gotoSection, openFilterPanel, skipUnlessMock, test } from './fixtures'
const t = de.pages.gerbils
@@ -14,11 +14,13 @@ test('Tiere-Liste blendet externe Ahnen standardmäßig aus', async ({ page }) =
await expect(page.locator('.gerbil-row', { hasText: 'Max' })).toHaveCount(0)
})
test('Toggle „Externe Ahnen einblenden zeigt externe Tiere mit Extern-Markierung', async ({ page }) => {
test('Toggle „Externe Ahnen einblenden zeigt externe Tiere mit Extern-Markierung', async ({ page }) => {
skipUnlessMock()
await gotoSection(page, de.nav.gerbils)
await expect(page.locator('.gerbil-row', { hasText: 'Krümel' })).toBeVisible()
// UX-MOBILE-1: Checkbox liegt im Filter-Drawer — auf Mobil erst öffnen.
await openFilterPanel(page)
await page.getByRole('checkbox', { name: t.filters.showExternal }).check()
const maxRow = page.locator('.gerbil-row', { hasText: 'Max' })

View File

@@ -17,5 +17,10 @@ test('Charakterbogen: Eigenschaft umschalten, speichern und im Abgabe-Komposer w
// Tier zur Abgabe stellen -> erscheint im /abgabe-Komposer mit gesetzter Eigenschaft.
await page.getByRole('button', { name: de.pages.abgabe.markAction }).click()
await page.goto('/abgabe')
await expect(page.getByRole('checkbox', { name: 'neugierig' })).toBeChecked()
// Auf Krümels Tier-Abschnitt eingrenzen — andere ForSale-Tiere haben ebenfalls
// Charakterbögen, deshalb darf die Assertion nicht seitenbreit nach 'neugierig' suchen.
const kruemelSection = page.locator('.group-composer__animal').filter({
has: page.locator('strong', { hasText: 'Krümel' }),
})
await expect(kruemelSection.getByRole('checkbox', { name: 'neugierig' })).toBeChecked()
})

View File

@@ -0,0 +1,85 @@
/**
* UX-MOBILE-1: FilterPanel — einklappbare Filter auf Smartphone, inline auf Desktop.
*
* Telefon (390px): Filter-Button sichtbar, Drawer eingeklappt; Tippen öffnet/schliesst.
* Desktop (1280px): Alle Controls direkt sichtbar, kein Toggle-Button.
*/
import { de, expect, gotoSection, skipUnlessMock, test } from './fixtures'
const t = de.pages.gerbils
test.describe('FilterPanel Rennmäuse-Liste', () => {
test('Phone: Filter-Drawer standardmäßig eingeklappt, Toggle-Button sichtbar', async ({
page,
}, testInfo) => {
skipUnlessMock()
if (testInfo.project.name !== 'phone') return
await gotoSection(page, de.nav.gerbils)
await expect(page.getByRole('heading', { name: t.title, exact: true })).toBeVisible()
// Toggle-Button sichtbar.
const toggle = page.locator('.filter-panel__toggle')
await expect(toggle).toBeVisible()
// Status-Beschriftung im Drawer ist noch verborgen.
await expect(page.getByText(t.filters.status, { exact: true }).first()).not.toBeVisible()
})
test('Phone: Toggle öffnet und schließt den Filter-Drawer', async ({
page,
}, testInfo) => {
skipUnlessMock()
if (testInfo.project.name !== 'phone') return
await gotoSection(page, de.nav.gerbils)
const toggle = page.locator('.filter-panel__toggle')
// Öffnen → Status-Feld wird sichtbar.
await toggle.click()
await expect(page.getByText(t.filters.status, { exact: true }).first()).toBeVisible()
// Schließen → wieder verborgen.
await toggle.click()
await expect(page.getByText(t.filters.status, { exact: true }).first()).not.toBeVisible()
})
test('Phone: Badge zählt aktive Filter korrekt', async ({
page,
}, testInfo) => {
skipUnlessMock()
if (testInfo.project.name !== 'phone') return
await gotoSection(page, de.nav.gerbils)
const toggle = page.locator('.filter-panel__toggle')
// Initial: Status='Active' ist Default → Badge zeigt kein „(N)".
await expect(toggle).toHaveText('Filter')
// Filter-Drawer öffnen und Geschlecht setzen → 1 aktiver Filter.
await toggle.click()
await page.locator('.filter-panel__drawer select').nth(1).selectOption('male')
await expect(toggle).toHaveText('Filter (1)')
// Zurücksetzen → Badge weg.
await page.getByRole('button', { name: de.filterPanel.resetButton }).click()
await expect(toggle).toHaveText('Filter')
})
test('Desktop: Toggle-Button nicht sichtbar, alle Controls inline', async ({
page,
}, testInfo) => {
skipUnlessMock()
if (testInfo.project.name !== 'desktop') return
await gotoSection(page, de.nav.gerbils)
await expect(page.getByRole('heading', { name: t.title, exact: true })).toBeVisible()
// Kein Toggle-Button auf Desktop (display:none via Media Query).
const toggle = page.locator('.filter-panel__toggle')
await expect(toggle).toBeHidden()
// Status-Beschriftung direkt sichtbar.
await expect(page.getByText(t.filters.status, { exact: true }).first()).toBeVisible()
})
})

View File

@@ -39,6 +39,17 @@ export function skipUnlessMock() {
test.skip(LIVE, 'benötigt die Mock-Seed-Daten (läuft nicht im LIVE-Modus)')
}
/**
* Test überspringen im Mock-Modus — nur im LIVE-Modus sinnvoll.
* Live-Tests MÜSSEN Ergebnisse assertieren (z. B. Liste nicht leer,
* konkreter Wert sichtbar), nicht nur HTTP-200. Lesson: der Mock spiegelt
* den Frontend-Code, nicht das echte Backend — ein Bug bleibt unsichtbar,
* wenn wir nur testen, ob die Seite lädt.
*/
export function skipUnlessLive() {
test.skip(!LIVE, 'nur im LIVE-Modus sinnvoll (E2E_BASE_URL setzen)')
}
/**
* Zu einem Navigationsziel wechseln — auf dem Smartphone liegen Becken,
* Kontakte und Statistik hinter dem „Mehr“-Blatt.
@@ -63,6 +74,18 @@ export function acceptNextDialog(page: Page) {
page.once('dialog', (d) => void d.accept())
}
/**
* UX-MOBILE-1: Filter-Drawer öffnen, falls der Toggle-Button sichtbar ist
* (= Smartphone-Ansicht). Auf Desktop-Ansicht ist er per CSS versteckt, dann
* kein Klick nötig — Controls sind direkt sichtbar.
*/
export async function openFilterPanel(page: Page) {
const toggle = page.locator('.filter-panel__toggle')
if (await toggle.isVisible()) {
await toggle.click()
}
}
/** Eindeutiger Name für LIVE-taugliche Create-Flows. */
export const uniqueName = (prefix: string) =>
`${prefix} E2E ${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`

View File

@@ -0,0 +1,54 @@
/**
* UX-MOBILE-2: Genotyp-Tabellen-Overflow — die Tabelle scrollt horizontal,
* die Seite selbst bleibt NICHT breiter als der Viewport (kein horizontaler
* Page-Overflow auf 390px).
*/
import { de, expect, skipUnlessMock, test } from './fixtures'
const tl = de.pages.litters
test('Phone: Genotyp-Detail-Tabelle scrollt im eigenen Wrapper, kein Page-Overflow', async ({
page,
}, testInfo) => {
skipUnlessMock()
if (testInfo.project.name !== 'phone') return
// Wurf w-kruemel hat beide Eltern mit Genotypen → BreedingResultView rendert.
await page.goto('/wuerfe/w-kruemel')
await expect(page.getByRole('heading', { name: 'Wurf K' })).toBeVisible()
await expect(page.getByText(tl.detail.expectedColors).first()).toBeVisible()
// Genotyp-Detail-Tabelle ausklappen.
await page.getByRole('button', { name: de.pages.genetik.showGenotypes }).click()
// Scroll-Wrapper und Tabelle müssen vorhanden und sichtbar sein.
const wrapper = page.locator('.genotype-table-scroll').first()
await expect(wrapper).toBeVisible()
await expect(wrapper.locator('.genotype-table')).toBeVisible()
// Wrapper ist selbst scrollbar (Tabelle breiter als der sichtbare Bereich).
const wrapperScrollable = await wrapper.evaluate(
(el) => el.scrollWidth > el.clientWidth,
)
expect(wrapperScrollable).toBe(true)
// Die Seite selbst darf NICHT breiter als der Viewport sein.
const pageOverflow = await page.evaluate(
() => document.documentElement.scrollWidth > document.documentElement.clientWidth,
)
expect(pageOverflow).toBe(false)
})
test('Desktop: Genotyp-Detail-Tabelle zeigt Wrapper auch auf breitem Viewport', async ({
page,
}, testInfo) => {
skipUnlessMock()
if (testInfo.project.name !== 'desktop') return
await page.goto('/wuerfe/w-kruemel')
await expect(page.getByText(tl.detail.expectedColors).first()).toBeVisible()
await page.getByRole('button', { name: de.pages.genetik.showGenotypes }).click()
await expect(page.locator('.genotype-table-scroll').first()).toBeVisible()
await expect(page.locator('.genotype-table').first()).toBeVisible()
})

View File

@@ -0,0 +1,57 @@
/**
* QA-2: Live-Modus-Smoke — läuft NUR gegen das echte Backend (E2E_BASE_URL).
*
* Lektion (aus dem Such-Bug): Mock-Tests spiegeln nur den Frontend-Code.
* Ein Backend-Bug (falscher Filter, leere Antwort, fehlende Migration) bleibt
* unsichtbar, wenn Live-Tests nur "Seite lädt" prüfen. Diese Tests assertieren
* ERGEBNISSE: Liste nicht leer, Suche liefert Treffer, Detail zeigt Stammdaten
* und Eltern-Links.
*
* Voraussetzung für den Eltern-Link-Test: Re-Import #2 durchgeführt
* (322 Tiere, 882 Würfe live; mindestens ein Wurf hat beide Eltern).
*/
import { de, expect, gotoSection, skipUnlessLive, test } from './fixtures'
const t = de.pages.gerbils
const tl = de.pages.litters
test('Live: Tiere-Liste enthält nach Re-Import mindestens 10 Einträge', async ({ page }) => {
skipUnlessLive()
await gotoSection(page, de.nav.gerbils)
await expect(page.getByRole('heading', { name: t.title, exact: true })).toBeVisible()
// Leere Liste = Backend-Bug (falscher Filter, fehlende DB-Migration o. ä.)
const rows = page.locator('.gerbil-row')
await expect(rows.first()).toBeVisible()
expect(await rows.count()).toBeGreaterThanOrEqual(10)
})
test('Live: Namenssuche gibt Treffer zurück — nicht nur HTTP 200', async ({ page }) => {
skipUnlessLive()
await gotoSection(page, de.nav.gerbils)
await expect(page.getByRole('heading', { name: t.title, exact: true })).toBeVisible()
// Kurzer Prefix: bei 322 Tieren mindestens 1 Treffer. Kein Treffer = Gridify-Bug.
await page.getByPlaceholder(t.searchPlaceholder).first().fill('a')
const rows = page.locator('.gerbil-row')
await expect(rows.first()).toBeVisible({ timeout: 10_000 })
expect(await rows.count()).toBeGreaterThanOrEqual(1)
})
test('Live: Wurf-Detail zeigt Elterntier-Links (mindestens Vater oder Mutter)', async ({
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()
// 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()
// 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

@@ -101,6 +101,7 @@ export async function installMockApi(page: Page): Promise<MockDb> {
'color-varieties': collection(db.colorVarieties as unknown as Row[], 'cv'),
'health-records': collection(db.healthRecords as unknown as Row[], 'hr'),
'weight-records': collection(db.weightRecords as unknown as Row[], 'wr'),
contracts: collection(db.contracts as unknown as Row[], 'contract'),
}
const handler = async (route: Route) => {
@@ -205,6 +206,25 @@ export async function installMockApi(page: Page): Promise<MockDb> {
return json(route, 405)
}
// ABGABE: KI-Inserat-Generator (POST /gerbils/sale-ad) — vor der generischen Route
if (path === '/gerbils/sale-ad' && method === 'POST') {
if (!db.saleAdConfigured) {
return json(route, 503, { code: 'AiKeyMissing', message: 'AI nicht konfiguriert' })
}
return json(route, 200, { text: 'Status: FREI\n\n„Zwei Freunde suchen ein Zuhause" Generierter Inserat-Text für den Mock.' })
}
// ABGABE: Vertrag-Download — /contracts/{id}/file (3 Segmente, nicht vom Generic-Handler bedient)
const cm = path.match(/^\/contracts\/([^/]+)\/file$/)
if (cm && method === 'GET') {
return route.fulfill({
status: 200,
contentType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
headers: { 'Content-Disposition': `attachment; filename="vertrag-${cm[1]}.docx"` },
body: Buffer.from([0x50, 0x4b, 0x05, 0x06, ...new Array(18).fill(0)]),
})
}
// SEARCH-2b: distinct Herkunft (originBreeder) values, sorted — vor der
// generischen /gerbils/:id-Route abfangen.
if (path === '/gerbils/breeders' && method === 'GET') {
@@ -313,6 +333,34 @@ export async function installMockApi(page: Page): Promise<MockDb> {
}
}
// ABGABE: POST /contracts — baut url-Feld und markiert Tiere als Abgegeben
if (path === '/contracts' && method === 'POST') {
const body = request.postDataJSON() as { contactId: string; gerbilIds: string[]; price: number; handoverDate: string; contractDate?: string | null }
const id = newId('contract')
const contract = {
id,
contactId: body.contactId,
gerbilIds: body.gerbilIds ?? [],
price: body.price,
handoverDate: body.handoverDate,
contractDate: body.contractDate ?? body.handoverDate,
fileName: `vertrag-${id}.docx`,
createdAt: new Date().toISOString(),
url: `/contracts/${id}/file`,
}
;(db.contracts as unknown as Row[]).push(contract as unknown as Row)
// Tiere auf GivenAway setzen (wie das echte Backend)
for (const gid of body.gerbilIds ?? []) {
const g = db.gerbils.find((x) => x.id === gid)
if (g) {
g.status = 'GivenAway'
g.receiverContactId = body.contactId
g.goHomeDate = body.handoverDate
}
}
return json(route, 201, contract)
}
// Generische Kollektionen: /<resource> und /<resource>/<id>
m = path.match(/^\/([a-z-]+)(?:\/([^/]+))?$/)
const col = m ? collections[m[1]] : undefined

View File

@@ -44,6 +44,19 @@ export interface MockPage {
blocks: MockBlock[]
}
/** ABGABE: Abgabevertrag (mirrors SaleContract in src/api/contracts.ts). */
export interface MockContract {
id: string
contactId: string
price: number
handoverDate: string
contractDate: string
fileName: string
createdAt: string
gerbilIds: string[]
url: string
}
export interface MockDb {
gerbils: Gerbil[]
litters: Litter[]
@@ -58,6 +71,9 @@ export interface MockDb {
requests: InboxRequest[]
aiConfigured: boolean
mailConfigured: boolean
// ABGABE: Verträge + KI-Inserat-Flag
contracts: MockContract[]
saleAdConfigured: boolean
}
function gerbil(
@@ -119,6 +135,20 @@ export function seedDb(): MockDb {
{ ...gerbil('willi', 'Willi', 'male', '2019-09-09', null, 'cv-schwarz'), status: 'Deceased', dateOfDeath: '2022-04-04' },
{ ...gerbil('rosa', 'Rosa', 'female', '2020-02-02', null, 'cv-gold'), status: 'Deceased', dateOfDeath: '2023-08-15' },
{ ...gerbil('pauli', 'Pauli', 'male', '2023-05-01', null, 'cv-himalaya'), status: 'GivenAway', goHomeDate: '2023-07-15', receiverContactId: 'con-huber' },
// ABGABE: zwei Tiere zur Abgabe im Quarantänebecken (enc-leer ist leer, kein Konflikt
// mit dem Becken-Belegungstest der Großbecken-2-Tiere-Assertion).
{
...gerbil('sale-balu', 'Balu Abgabe', 'male', '2024-03-12', null, 'cv-agouti'),
status: 'ForSale' as const,
enclosureId: 'enc-leer',
notes: 'ruhig, nimmt Leckerlis aus der Hand',
},
{
...gerbil('sale-benny', 'Benny Abgabe', 'male', '2024-03-12', null, 'cv-schwarz-schecke'),
status: 'ForSale' as const,
enclosureId: 'enc-leer',
notes: 'neugieriger Entdecker',
},
]
const litters: Litter[] = [
@@ -258,5 +288,7 @@ export function seedDb(): MockDb {
requests,
aiConfigured: true,
mailConfigured: true,
contracts: [],
saleAdConfigured: true,
}
}

View File

@@ -1,5 +1,5 @@
/** QA-1: Tiere (Rennmäuse) — Liste/Suche, CRUD, Detail-Tabs (FEAT-1 + FEAT-6). */
import { de, expect, gotoSection, skipUnlessMock, test, uniqueName } from './fixtures'
import { de, expect, gotoSection, openFilterPanel, skipUnlessMock, test, uniqueName } from './fixtures'
const t = de.pages.gerbils
const tabs = de.pages.tierTabs
@@ -23,6 +23,8 @@ test('Herkunft-Filter (originBreeder) zeigt nur Tiere der gewählten Zucht (SEAR
await expect(page.getByRole('link', { name: /Krümel/ })).toBeVisible()
await expect(page.getByRole('link', { name: /Fridolin/ })).toBeVisible()
// UX-MOBILE-1: Herkunft-Select liegt im Filter-Drawer — auf Mobil erst öffnen.
await openFilterPanel(page)
// Herkunft (originBreeder) auf die Seed-Zucht 'Clan-Kleine-Chaoten' (nur Krümel).
await page
.locator('label.field', { has: page.locator(`span:text-is("${t.fields.origin}")`) })

View File

@@ -65,28 +65,30 @@ export default function BreedingResultView({ result, title }: BreedingResultView
</button>
{showGenotypes && (
<table className="genotype-table">
<thead>
<tr>
<th>{t.genotypeLabel}</th>
<th>{t.genotypePreview}</th>
<th>{t.probability}</th>
</tr>
</thead>
<tbody>
{result.offspring.map((o) => (
<tr key={o.genotype}>
<td>
<code>{o.genotype}</code>
</td>
<td>{o.farbschlag}</td>
<td>
{o.probability.percent} <small>({o.probability.text})</small>
</td>
<div className="genotype-table-scroll">
<table className="genotype-table">
<thead>
<tr>
<th>{t.genotypeLabel}</th>
<th>{t.genotypePreview}</th>
<th>{t.probability}</th>
</tr>
))}
</tbody>
</table>
</thead>
<tbody>
{result.offspring.map((o) => (
<tr key={o.genotype}>
<td>
<code>{o.genotype}</code>
</td>
<td>{o.farbschlag}</td>
<td>
{o.probability.percent} <small>({o.probability.text})</small>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</>
)}

View File

@@ -0,0 +1,52 @@
import { useState, type ReactNode } from 'react'
import { de } from '../strings/de'
import './filterPanel.css'
interface FilterPanelProps {
/** Always visible on mobile (typically the search text input). Optional. */
searchField?: ReactNode
/** Collapsible filters (hidden behind toggle on mobile; inline on desktop). */
children: ReactNode
/** Number of currently active (non-default) filter values. Shown as badge. */
activeCount: number
/** Called when the reset button is clicked. */
onReset: () => void
}
/**
* UX-MOBILE-1: wraps a set of filter controls so they collapse on mobile.
* Render inside the existing `.filters` div (or replace it entirely).
*
* Desktop (>=768px): renders all children inline, identical to today.
* Mobile (<768px): shows searchField + a "Filter (N)" toggle; tapping reveals
* the rest of the controls in a column drawer + a reset button.
*/
export function FilterPanel({ searchField, children, activeCount, onReset }: FilterPanelProps) {
const [open, setOpen] = useState(false)
const t = de.filterPanel
const label = activeCount > 0 ? `${t.toggleButton} (${activeCount})` : t.toggleButton
return (
<div className={`filters filter-panel${open ? ' filter-panel--open' : ''}`}>
{searchField}
<button
type="button"
className="btn btn--ghost filter-panel__toggle"
onClick={() => setOpen((v) => !v)}
aria-expanded={open}
aria-label={label}
>
{label}
</button>
<div className="filter-panel__drawer">
{children}
{activeCount > 0 && (
<button type="button" className="btn btn--ghost filter-panel__reset-btn" onClick={onReset}>
{t.resetButton}
</button>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,53 @@
/*
* UX-MOBILE-1: FilterPanel — collapsible filter drawer on mobile.
*
* Desktop (>=768px): toggle hidden, drawer shows as display:contents so its
* children participate directly in the parent .filters flex row.
* Mobile (<768px): searchField inline, then toggle button. Tap opens a full-
* width drawer (flex column) with the remaining filters + reset button.
*/
/* ── Mobile default ── */
.filter-panel__toggle {
display: inline-flex;
align-items: center;
gap: 0.25rem;
white-space: nowrap;
}
.filter-panel__drawer {
display: none;
flex-direction: column;
gap: 0.75rem;
width: 100%;
padding-top: 0.25rem;
}
.filter-panel--open .filter-panel__drawer {
display: flex;
}
/* ── Desktop ── */
@media (min-width: 768px) {
.filter-panel__toggle {
display: none;
}
.filter-panel__drawer {
/* Let children participate directly in the parent flex row. */
display: contents;
}
/* Reset button sits in the flex row on desktop when active filters exist. */
.filter-panel__reset-btn {
align-self: flex-end;
}
}
/* ── Mobile reset button ── */
@media (max-width: 767px) {
.filter-panel__reset-btn {
align-self: flex-start;
margin-top: 0.25rem;
}
}

View File

@@ -179,10 +179,11 @@ describe('Farbschlag catalog', () => {
expect(match.name).toBe('Unbekannter Farbschlag')
})
it('has the expected catalogue coverage (GEN-3f: 61 after cchm CP reconciliation)', () => {
// GEN-3f collapsed the 24 portal cchm colourpoint rows to 12 breeder-named
// varieties (Marder/Siam/Zobel/Zobel-Hell + CP-<base>), so 73 -> 61.
expect(CATALOG_SIZE).toBe(61)
it('has the expected catalogue coverage (GEN-3g: 66 after adding CP-*-Hell het variants)', () => {
// GEN-3f: 73 -> 61 (cchm CP reconciliation).
// GEN-3g: +5 het variants (CP-Agouti/Silberagouti/Algierfuchs/Polarfuchs/Orangeschimmel -Hell),
// giving 61 + 5 = 66. CP-Fuchs-Hell was already counted.
expect(CATALOG_SIZE).toBe(66)
})
it('frozen contract names round-trip to themselves (DB-key guard)', () => {
@@ -414,8 +415,12 @@ describe('GEN-3e: C-locus colourpoint naming', () => {
expect(name('AA cchmcchm DD EE GG PP spsp rere')).toBe('CP-Agouti')
})
it('A- + cchm/ch -> CP-<base colour>', () => {
expect(name('AA cchmch DD EE GG PP spsp rere')).toBe('CP-Agouti')
it('A- + cchm/ch -> CP-<base colour>-Hell (GEN-3g: het gets -Hell suffix)', () => {
expect(name('AA cchmch DD EE GG PP spsp rere')).toBe('CP-Agouti-Hell')
expect(name('AA cchmch DD EE gg PP spsp rere')).toBe('CP-Silberagouti-Hell')
expect(name('AA cchmch DD ee GG PP spsp rere')).toBe('CP-Algierfuchs-Hell')
expect(name('AA cchmch DD ee gg PP spsp rere')).toBe('CP-Polarfuchs-Hell')
expect(name('AA cchmch dd ee GG PP spsp rere')).toBe('CP-Fuchs-Hell')
})
it('aa fixed colourpoint names (Marder/Siam/Zobel/Zobel-Hell)', () => {
@@ -471,10 +476,50 @@ describe('GEN-3f: CP catalog reconciled to the breeder CP- naming (matches her l
expect(name('AA cchmcchm DD efef GG PP spsp rere')).toBe('CP-Orangeschimmel')
})
it('het cchm/ch colourpoints (Siam, Zobel-Hell) resolve to their own names', () => {
it('het cchm/ch colourpoints (Siam, Zobel-Hell, CP-*-Hell) resolve to their own names', () => {
const siam = BASE_COLORS.find((e) => e.name === 'Siam')!
const zh = BASE_COLORS.find((e) => e.name === 'Zobel-Hell')!
const cpah = BASE_COLORS.find((e) => e.name === 'CP-Agouti-Hell')!
const cpfh = BASE_COLORS.find((e) => e.name === 'CP-Fuchs-Hell')!
expect(genotypeToFarbschlag(representativeGenotype(siam))).toBe('Siam')
expect(genotypeToFarbschlag(representativeGenotype(zh))).toBe('Zobel-Hell')
expect(genotypeToFarbschlag(representativeGenotype(cpah))).toBe('CP-Agouti-Hell')
expect(genotypeToFarbschlag(representativeGenotype(cpfh))).toBe('CP-Fuchs-Hell')
})
})
describe('GEN-3g: "-Hell" in variety name == cchm/ch het; hom == cchm/cchm', () => {
const name = (s: string) => genotypeToFarbschlag(fromDisplayString(s))
const has = (n: string) => BASE_COLORS.some((e) => e.name === n)
it('all new -Hell het entries exist in catalog', () => {
for (const n of [
'CP-Agouti-Hell', 'CP-Silberagouti-Hell', 'CP-Algierfuchs-Hell',
'CP-Polarfuchs-Hell', 'CP-Orangeschimmel-Hell',
]) {
expect(has(n)).toBe(true)
}
})
it('engine correctly maps het (cchm/ch) to -Hell suffix for all A- bases', () => {
expect(name('AA cchmch DD EE GG PP spsp rere')).toBe('CP-Agouti-Hell')
expect(name('AA cchmch DD EE gg PP spsp rere')).toBe('CP-Silberagouti-Hell')
expect(name('AA cchmch DD ee GG PP spsp rere')).toBe('CP-Algierfuchs-Hell')
expect(name('AA cchmch DD ee gg PP spsp rere')).toBe('CP-Polarfuchs-Hell')
expect(name('AA cchmch dd ee GG PP spsp rere')).toBe('CP-Fuchs-Hell')
expect(name('AA cchmch DD efef GG PP spsp rere')).toBe('CP-Orangeschimmel-Hell')
})
it('hom (cchm/cchm) still maps without -Hell suffix', () => {
expect(name('AA cchmcchm DD EE GG PP spsp rere')).toBe('CP-Agouti')
expect(name('AA cchmcchm DD EE gg PP spsp rere')).toBe('CP-Silberagouti')
expect(name('AA cchmcchm DD efef GG PP spsp rere')).toBe('CP-Orangeschimmel')
})
it('aa non-agouti branch is unchanged (Marder/Siam/Zobel/Zobel-Hell)', () => {
expect(name('aa cchmcchm DD EE GG PP spsp rere')).toBe('Marder')
expect(name('aa cchmch DD EE GG PP spsp rere')).toBe('Siam')
expect(name('aa cchmcchm DD EE gg PP spsp rere')).toBe('Zobel')
expect(name('aa cchmch DD EE gg PP spsp rere')).toBe('Zobel-Hell')
})
})

View File

@@ -107,27 +107,29 @@ export const BASE_COLORS: readonly FarbschlagEntry[] = [
{ name: 'Topas dd', tokens: { A: 'A', C: 'C', D: 'd', E: 'E', G: 'G', P: 'p' }, image: 'topas-dd.jpg' },
{ name: 'Blaufuchs dd', tokens: { A: 'a', C: 'C', D: 'd', E: 'e', G: 'g', P: 'p' }, image: 'blaufuchs-dd.jpg' },
// ── GEN-3f: c^chm colourpoint varieties, reconciled to the breeder's CP- naming ──
// The merged GEN-3e colourpointName() rule is authoritative: aa points are the
// marten/sable group (Marder/Siam, +gg Zobel/Zobel-Hell — E and D irrelevant);
// A- points take the 'CP-<base colour>' prefix and the '-Hell' shade variants
// collapse (other loci irrelevant for the CP prefix). These names == the strings
// in her live data (god: extract animals.json) so the re-import name-matches and
// the Farbschlag mismatch hint stops. The het cchm/ch points (Siam, Zobel-Hell,
// CP-Fuchs-Hell) use the 'cchm/ch' pair token. The agouti fox/dilute points
// (CP-Fuchs/CP-Blaufuchs) resolve through the engine's E-family fallback to
// 'CP-Fuchs'; their distinct dropdown names remain for hand-pick + import match.
// ── GEN-3f/3g: c^chm colourpoint varieties ──
// GEN-3f: aa points = marten/sable group (Marder/Siam, +gg Zobel/Zobel-Hell).
// GEN-3g (breeder rule): '-Hell' == cchm/ch het; no '-Hell' == cchm/cchm hom.
// A- points: hom -> 'CP-<base>', het -> 'CP-<base>-Hell' (colourpointName()).
// CP-Fuchs is a Sammelbegriff (unknown loci); its -Hell het = CP-Fuchs-Hell.
// CP-Blaufuchs (D:d, G:g) still resolves engine-side to 'CP-Fuchs' (dd/gg
// fox CP has no dedicated base entry); kept for import name-match + hand-pick.
{ name: 'Marder', tokens: { A: 'a', C: 'cchm', D: 'D', E: 'E', G: 'G', P: 'P' }, image: 'marder.JPG' },
{ name: 'Siam', tokens: { A: 'a', C: 'cchm/ch', D: 'D', E: 'E', G: 'G', P: 'P' }, image: 'siam-marder-hell.JPG' },
{ name: 'Zobel-Hell', tokens: { A: 'a', C: 'cchm/ch', D: 'D', E: 'E', G: 'g', P: 'P' }, image: 'zobel-hell.jpg' },
{ name: 'CP-Agouti', tokens: { A: 'A', C: 'cchm', D: 'D', E: 'E', G: 'G', P: 'P' }, image: 'agouti-cp.jpg' },
{ name: 'CP-Agouti-Hell', tokens: { A: 'A', C: 'cchm/ch', D: 'D', E: 'E', G: 'G', P: 'P' } },
{ name: 'CP-Silberagouti', tokens: { A: 'A', C: 'cchm', D: 'D', E: 'E', G: 'g', P: 'P' }, image: 'silberagouti-cp.JPG' },
{ name: 'CP-Silberagouti-Hell', tokens: { A: 'A', C: 'cchm/ch', D: 'D', E: 'E', G: 'g', P: 'P' } },
{ name: 'CP-Algierfuchs', tokens: { A: 'A', C: 'cchm', D: 'D', E: 'e', G: 'G', P: 'P' }, image: 'algierfuchs-cp.jpg' },
{ name: 'CP-Algierfuchs-Hell', tokens: { A: 'A', C: 'cchm/ch', D: 'D', E: 'e', G: 'G', P: 'P' } },
{ name: 'CP-Polarfuchs', tokens: { A: 'A', C: 'cchm', D: 'D', E: 'e', G: 'g', P: 'P' }, image: 'polarfuchs-cp.jpg' },
{ name: 'CP-Polarfuchs-Hell', tokens: { A: 'A', C: 'cchm/ch', D: 'D', E: 'e', G: 'g', P: 'P' } },
{ name: 'CP-Fuchs', tokens: { A: 'A', C: 'cchm', D: 'd', E: 'e', G: 'G', P: 'P' } },
{ name: 'CP-Fuchs-Hell', tokens: { A: 'A', C: 'cchm/ch', D: 'd', E: 'e', G: 'G', P: 'P' } },
{ name: 'CP-Blaufuchs', tokens: { A: 'A', C: 'cchm', D: 'd', E: 'e', G: 'g', P: 'P' } },
{ name: 'CP-Orangeschimmel', tokens: { C: 'cchm', D: 'D', E: 'ef', G: 'G', P: 'P' } },
{ name: 'CP-Orangeschimmel-Hell', tokens: { C: 'cchm/ch', D: 'D', E: 'ef', G: 'G', P: 'P' } },
]
export const UNKNOWN_FARBSCHLAG = 'Unbekannter Farbschlag'
@@ -207,12 +209,14 @@ function baseColourFor(g: Genotype): string | null {
}
/**
* GEN-3e: the C-locus colourpoint NAMING transform (breeder-authoritative).
* GEN-3e/3g: the C-locus colourpoint NAMING transform (breeder-authoritative).
* Returns the colourpoint name, or null when it doesn't apply (full C present,
* or chch — which the base matcher names Hermelin/Himalaya, preserving both).
* aa cchm/cchm -> Marder | aa cchm/ch -> Siam
* aa cchm/cchm gg -> Zobel | aa cchm/ch gg -> Zobel-Hell
* A- cchm/cchm | cchm/ch -> CP-<base colour> (base computed as if C were full)
* A- cchm/cchm -> CP-<base> | A- cchm/ch -> CP-<base>-Hell
* GEN-3g (breeder rule): "-Hell" in variety name == c[h]-Allel (cchm/ch het);
* no "-Hell" == cchm/cchm hom. CP-Fuchs is a Sammelbegriff (unknown loci).
*/
function colourpointName(g: Genotype): string | null {
const c = resolvedPair(g, 'C')
@@ -227,9 +231,9 @@ function colourpointName(g: Genotype): string | null {
if (grey) return bothCchm ? 'Zobel' : 'Zobel-Hell'
return bothCchm ? 'Marder' : 'Siam'
}
// A- colourpoint -> CP-<base colour>, base as if C were full.
// A- colourpoint: base as if C were full; het (cchm/ch) -> '-Hell' suffix.
const base = baseColourFor(makeGenotype({ ...g, C: ['C', 'C'] }))
return base ? `CP-${base}` : null
return base ? `CP-${base}${bothCchm ? '' : '-Hell'}` : null
}
export function farbschlagFor(g: Genotype): FarbschlagMatch {

View File

@@ -340,42 +340,67 @@
"sortOrder": 53,
"image": "agouti-cp.jpg"
},
{
"name": "CP-Agouti-Hell",
"canonicalGenotype": "AA cchmch DD EE GG PP spsp rere",
"sortOrder": 54
},
{
"name": "CP-Silberagouti",
"canonicalGenotype": "AA cchmcchm DD EE gg PP spsp rere",
"sortOrder": 54,
"sortOrder": 55,
"image": "silberagouti-cp.JPG"
},
{
"name": "CP-Silberagouti-Hell",
"canonicalGenotype": "AA cchmch DD EE gg PP spsp rere",
"sortOrder": 56
},
{
"name": "CP-Algierfuchs",
"canonicalGenotype": "AA cchmcchm DD ee GG PP spsp rere",
"sortOrder": 55,
"sortOrder": 57,
"image": "algierfuchs-cp.jpg"
},
{
"name": "CP-Algierfuchs-Hell",
"canonicalGenotype": "AA cchmch DD ee GG PP spsp rere",
"sortOrder": 58
},
{
"name": "CP-Polarfuchs",
"canonicalGenotype": "AA cchmcchm DD ee gg PP spsp rere",
"sortOrder": 56,
"sortOrder": 59,
"image": "polarfuchs-cp.jpg"
},
{
"name": "CP-Polarfuchs-Hell",
"canonicalGenotype": "AA cchmch DD ee gg PP spsp rere",
"sortOrder": 60
},
{
"name": "CP-Fuchs",
"canonicalGenotype": "AA cchmcchm dd ee GG PP spsp rere",
"sortOrder": 57
"sortOrder": 61
},
{
"name": "CP-Fuchs-Hell",
"canonicalGenotype": "AA cchmch dd ee GG PP spsp rere",
"sortOrder": 58
"sortOrder": 62
},
{
"name": "CP-Blaufuchs",
"canonicalGenotype": "AA cchmcchm dd ee gg PP spsp rere",
"sortOrder": 59
"sortOrder": 63
},
{
"name": "CP-Orangeschimmel",
"canonicalGenotype": "AA cchmcchm DD efef GG PP spsp rere",
"sortOrder": 60
"sortOrder": 64
},
{
"name": "CP-Orangeschimmel-Hell",
"canonicalGenotype": "AA cchmch DD efef GG PP spsp rere",
"sortOrder": 65
}
]

View File

@@ -77,6 +77,10 @@ a {
padding: 1rem;
max-width: 60rem;
width: 100%;
/* UX-MOBILE-2: prevent any wide content (e.g. genotype table) from making
the page body scroll horizontally. The .genotype-table-scroll wrapper
provides the per-table horizontal scroll. */
overflow-x: hidden;
}
/* Tab-Leiste unten */
@@ -559,8 +563,14 @@ textarea {
white-space: nowrap;
}
/* UX-MOBILE-2: scroll wrapper so genotype table scrolls horizontally on mobile
instead of overflowing the page. */
.genotype-table-scroll {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.genotype-table {
width: 100%;
border-collapse: collapse;
margin-top: 0.75rem;
font-size: 0.9rem;
@@ -571,6 +581,7 @@ textarea {
text-align: left;
padding: 0.4rem 0.5rem;
border-bottom: 1px solid var(--color-border);
white-space: nowrap;
}
.genotype-table code {

View File

@@ -15,6 +15,7 @@ import {
} from '../api/requests'
import { useApi, useMutation } from '../hooks/useApi'
import { formatDateTime } from '../format/labels'
import { FilterPanel } from '../components/FilterPanel'
import './anfragen.css'
const PAGE_SIZE = 20
@@ -75,7 +76,10 @@ export default function AnfragenPage() {
{sync.error && <div className="alert alert--error">{sync.error}</div>}
{/* Status-Filter */}
<div className="filters">
<FilterPanel
activeCount={status !== '' ? 1 : 0}
onReset={() => { setStatus(''); setPage(1) }}
>
<label className="field">
<span>{t.detail.statusLabel}</span>
<select
@@ -93,7 +97,7 @@ export default function AnfragenPage() {
))}
</select>
</label>
</div>
</FilterPanel>
{requests.loading && <p className="muted">{de.common.loading}</p>}
{requests.error && (

View File

@@ -7,6 +7,7 @@ import { andFilter, condition, type GridifyQuery } from '../api/gridify'
import { GENDERS, GERBIL_STATUSES, type Gender, type GerbilStatus } from '../api/types'
import { useApi, useMutation } from '../hooks/useApi'
import { formatDate, genderLabel, statusLabel } from '../format/labels'
import { FilterPanel } from '../components/FilterPanel'
import './gerbils.css'
const PAGE_SIZE = 20
@@ -82,6 +83,14 @@ export default function GerbilsPage() {
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
const items = gerbils.data?.items ?? []
// UX-MOBILE-1: count non-default filter values for the badge.
const activeFilterCount =
(status !== 'Active' ? 1 : 0) +
(gender !== '' ? 1 : 0) +
(colorVarietyId !== '' ? 1 : 0) +
(originBreeder !== '' ? 1 : 0) +
(showExternal ? 1 : 0)
// Multi-select bulk "Zur Abgabe stellen".
const [selected, setSelected] = useState<Set<string>>(new Set())
const toggleSelect = (id: string) =>
@@ -118,15 +127,20 @@ export default function GerbilsPage() {
</Link>
</header>
<div className="filters">
<input
type="search"
className="input"
placeholder={t.searchPlaceholder}
value={search}
onChange={(e) => onFilterChange(setSearch)(e.target.value)}
aria-label={t.fields.name}
/>
<FilterPanel
searchField={
<input
type="search"
className="input"
placeholder={t.searchPlaceholder}
value={search}
onChange={(e) => onFilterChange(setSearch)(e.target.value)}
aria-label={t.fields.name}
/>
}
activeCount={activeFilterCount}
onReset={resetFilters}
>
<label className="field">
<span>{t.filters.status}</span>
<select
@@ -200,10 +214,7 @@ export default function GerbilsPage() {
onChange={(e) => onFilterChange(setShowExternal)(e.target.checked)}
/>
</label>
<button type="button" className="btn" onClick={resetFilters}>
{t.filters.reset}
</button>
</div>
</FilterPanel>
{gerbils.loading && <p className="muted">{de.common.loading}</p>}
{gerbils.error && (

View File

@@ -7,6 +7,7 @@ import { andFilter, condition, type GridifyQuery } from '../api/gridify'
import type { Litter } from '../api/types'
import { useApi } from '../hooks/useApi'
import { formatDate } from '../format/labels'
import { FilterPanel } from '../components/FilterPanel'
const PAGE_SIZE = 20
@@ -111,7 +112,10 @@ export default function WuerfeListPage() {
{tab === 'litters' && (
<>
<div className="filters">
<FilterPanel
activeCount={year !== '' ? 1 : 0}
onReset={() => { setYear(''); setSort('dateDesc'); setPage(1) }}
>
<label className="field">
<span>{t.filterYear}</span>
<select
@@ -136,7 +140,7 @@ export default function WuerfeListPage() {
<option value="dateAsc">{t.sort.dateAsc}</option>
</select>
</label>
</div>
</FilterPanel>
{litters.loading && <p className="muted">{de.common.loading}</p>}
{litters.error && (

View File

@@ -807,6 +807,12 @@ export const de = {
{ key: 'schreckhaft', label: 'schreckhaft' },
],
},
// ── UX-MOBILE-1 (Kevin): FilterPanel — einklappbare Filter auf Mobil ──
filterPanel: {
toggleButton: 'Filter',
resetButton: 'Filter zurücksetzen',
closeButton: 'Schließen',
},
} as const
export type Strings = typeof de