Compare commits

..

3 Commits

Author SHA1 Message Date
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
0a1e63bec6 Merge feature/import-counter: 31 undatierte Wurfchronik-Eintraege ehrlich als WithoutDate gezaehlt statt als Phantom-created (+latente litterIdMap-FK-Falle entschaerft) [god-QA validated]
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 53s
CI / Docker Build & Push (push) Has been cancelled
CI / Frontend Tests (Node/Vite) (push) Has been cancelled
2026-06-06 15:25:51 +02:00
5b30407257 IMPORT-COUNTER-BUG: undatierte Wuerfe als WithoutDate zaehlen, nicht als Created
31 undatierte Wurfchronik-Eintraege (leeres Datumsfeld) wurden bei jedem Lauf
als littersCreated++ gezaehlt, weil ihr Idempotenz-Key (kein Datum) nie in
existingLitterKeySet stand -- aber kein DB-Insert folgte, da date is DateOnly d
false war. Das verfaelschte Arithmetik und Julians Bericht (752 vs 723).

Fix: undatierte Eintraege werden am Schleifenanfang uebersprungen
(littersWithoutDate++, continue) bevor sie in litterIdMap oder den
Created-Zaehler einfliessen. Execute-Block ohne redundante date-Pruefung.
Neues Feld LitterSummary.WithoutDate; Report-Notiz wenn WithoutDate > 0.

Gate: 126/126 C#-Tests, has-pending-model-changes = No.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-06-06 15:24:55 +02:00
17 changed files with 334 additions and 129 deletions

View File

@@ -507,6 +507,44 @@ namespace GerbilManager.Tests
} }
} }
[Fact]
public async Task UndatedLitters_counted_as_WithoutDate_not_Created()
{
// COUNTER-BUG regression: litters with no parseable date must go into WithoutDate,
// NOT Created. On re-import, Created must be 0 (not 31-phantom-phantom-phantom...).
var dir = Path.Combine(Path.GetTempPath(), "undated-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
try
{
// One dated litter, one undated litter (blank date field)
File.WriteAllText(Path.Combine(dir, "litters.json"), """
[
{"id":"L-dated","litterId":"A","date":"01.02.2020","damName":"Mutter","sireName":"Vater","totalBorn":3,"zuchtnummer":"","note":""},
{"id":"L-undated","litterId":"B","date":"","damName":"Mutter","sireName":"Vater","totalBorn":0,"zuchtnummer":"","note":""}
]
""");
File.WriteAllText(Path.Combine(dir, "animals.json"), "[]");
using var db = NewDb();
// First run
var r1 = await new ImportService(db, dir, dir).RunAsync(execute: true);
Assert.Equal(2, r1.Litters.InSource);
Assert.Equal(1, r1.Litters.Created); // only the dated one
Assert.Equal(0, r1.Litters.AlreadyImported);
Assert.Equal(1, r1.Litters.WithoutDate); // the undated one
Assert.Equal(1, await db.Litters.CountAsync()); // only 1 persisted
// Second run (re-import): dated litter is now existing, undated still WithoutDate
var r2 = await new ImportService(db, dir, dir).RunAsync(execute: true);
Assert.Equal(0, r2.Litters.Created); // no phantom "created"
Assert.Equal(1, r2.Litters.AlreadyImported);
Assert.Equal(1, r2.Litters.WithoutDate);
Assert.Equal(1, await db.Litters.CountAsync()); // still only 1 row
}
finally { try { Directory.Delete(dir, recursive: true); } catch { } }
}
[Theory] [Theory]
[InlineData("01.02.2020", 2020, 2, 1)] [InlineData("01.02.2020", 2020, 2, 1)]
[InlineData("5.3.21", 2021, 3, 5)] [InlineData("5.3.21", 2021, 3, 5)]

View File

@@ -87,7 +87,7 @@ namespace GerbilManagerWebAPI.Import
public sealed record LitterSummary(int InSource, int Created, int AlreadyImported, public sealed record LitterSummary(int InSource, int Created, int AlreadyImported,
int DerivedFromChart = 0, int DerivedSkipped = 0, int ParentFksDropped = 0, int DerivedFromChart = 0, int DerivedSkipped = 0, int ParentFksDropped = 0,
int ParentFksBackfilled = 0); int ParentFksBackfilled = 0, int WithoutDate = 0);
public sealed record AnimalSummary( public sealed record AnimalSummary(
int InSource, int InSource,

View File

@@ -94,11 +94,17 @@ namespace GerbilManagerWebAPI.Import
var damNames = litters.Select(l => Normalize(StripZucht(l.DamName))).Where(s => s.Length > 0).ToHashSet(); var damNames = litters.Select(l => Normalize(StripZucht(l.DamName))).Where(s => s.Length > 0).ToHashSet();
// ---- litters: create map source.id -> Litter (for high-confidence animal links) ---- // ---- litters: create map source.id -> Litter (for high-confidence animal links) ----
int littersCreated = 0, littersExisting = 0; // COUNTER-BUG FIX: undated litters (31 in the Wurfchronik) have no parseable date,
// so their existingLitterKeySet key was always "" → they were always counted as
// "created" even though the execute block skipped them (date is DateOnly d = false).
// Fix: skip undated litters early — they can never be created or linked to animals.
int littersCreated = 0, littersExisting = 0, littersWithoutDate = 0;
var litterIdMap = new Dictionary<string, Guid>(); // source litter id -> Litter.Id var litterIdMap = new Dictionary<string, Guid>(); // source litter id -> Litter.Id
foreach (var sl in litters) foreach (var sl in litters)
{ {
var date = ParseDate(sl.Date); var date = ParseDate(sl.Date);
if (date is null) { littersWithoutDate++; continue; } // undated: skip entirely
var name = $"Wurf {sl.LitterId}".Trim(); var name = $"Wurf {sl.LitterId}".Trim();
var key = $"{name}|{date:yyyy-MM-dd}"; var key = $"{name}|{date:yyyy-MM-dd}";
if (existingLitterKeySet.Contains(key)) { littersExisting++; continue; } if (existingLitterKeySet.Contains(key)) { littersExisting++; continue; }
@@ -106,19 +112,19 @@ namespace GerbilManagerWebAPI.Import
var id = Guid.NewGuid(); var id = Guid.NewGuid();
litterIdMap[sl.Id] = id; litterIdMap[sl.Id] = id;
littersCreated++; littersCreated++;
if (execute && date is DateOnly d) if (execute)
{ {
_db.Litters.Add(new Litter _db.Litters.Add(new Litter
{ {
Id = id, Id = id,
Name = name, Name = name,
Date = d, Date = date.Value,
TotalBorn = sl.TotalBorn, TotalBorn = sl.TotalBorn,
Notes = string.IsNullOrWhiteSpace(sl.Note) ? null : sl.Note, Notes = string.IsNullOrWhiteSpace(sl.Note) ? null : sl.Note,
PairingCode = string.IsNullOrWhiteSpace(sl.Zuchtnummer) ? null : sl.Zuchtnummer, PairingCode = string.IsNullOrWhiteSpace(sl.Zuchtnummer) ? null : sl.Zuchtnummer,
}); });
} }
if (samples.Count < 8 && date is not null) if (samples.Count < 8)
samples.Add($"Wurf: {name} ({sl.Date}) — {sl.DamName} × {sl.SireName}"); samples.Add($"Wurf: {name} ({sl.Date}) — {sl.DamName} × {sl.SireName}");
} }
if (execute) await _db.SaveChangesAsync(); if (execute) await _db.SaveChangesAsync();
@@ -454,6 +460,8 @@ namespace GerbilManagerWebAPI.Import
if (execute && parentFksBackfilled > 0) await _db.SaveChangesAsync(); if (execute && parentFksBackfilled > 0) await _db.SaveChangesAsync();
} }
if (littersWithoutDate > 0)
notes.Add($"Würfe ohne Datum: {littersWithoutDate} Wurfchronik-Einträge ohne parsbares Geburtsdatum übersprungen (weder erstellt noch verknüpft).");
notes.Add("Quarantäne (kein Import): Konflikte + Stubs ohne Geburtsdatum + unsichere Wurf-Zuordnungen — warten auf die Prüfung durch die Züchterin."); notes.Add("Quarantäne (kein Import): Konflikte + Stubs ohne Geburtsdatum + unsichere Wurf-Zuordnungen — warten auf die Prüfung durch die Züchterin.");
if (parentLinksAdded > 0) if (parentLinksAdded > 0)
notes.Add($"Stammbaum-Diagramm: {parentLinksAdded} Tiere über Eltern-Verknüpfung einem (abgeleiteten) Wurf zugeordnet ({derivedLitters} abgeleitete Würfe)."); notes.Add($"Stammbaum-Diagramm: {parentLinksAdded} Tiere über Eltern-Verknüpfung einem (abgeleiteten) Wurf zugeordnet ({derivedLitters} abgeleitete Würfe).");
@@ -468,7 +476,7 @@ namespace GerbilManagerWebAPI.Import
return new ImportReport( return new ImportReport(
Executed: execute, Executed: execute,
Litters: new LitterSummary(litters.Count, littersCreated, littersExisting, derivedLitters, derivedLittersSkipped, litterParentFksDropped, parentFksBackfilled), Litters: new LitterSummary(litters.Count, littersCreated, littersExisting, derivedLitters, derivedLittersSkipped, litterParentFksDropped, parentFksBackfilled, littersWithoutDate),
Animals: new AnimalSummary( Animals: new AnimalSummary(
animals.Count, animalsCreated, linked, fbMatched, fbUnmatched, animalsExisting, animals.Count, animalsCreated, linked, fbMatched, fbUnmatched, animalsExisting,
new QuarantineSummary(conflicts, stubs, dateOnly, ambiguous, conflicts + stubs), new QuarantineSummary(conflicts, stubs, dateOnly, ambiguous, conflicts + stubs),

View File

@@ -3,7 +3,7 @@
* AiKeyMissing-Hinweis), Senden (inkl. MailNotConfigured-Hinweis). * AiKeyMissing-Hinweis), Senden (inkl. MailNotConfigured-Hinweis).
* Mock-gebunden (Seed-Anfragen + Fehlerpfad-Flags) → skipUnlessMock. * 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 ta = de.pages.anfragen
const td = ta.detail const td = ta.detail
@@ -23,6 +23,8 @@ test.describe('Anfragen', () => {
// Status-Badge auf der Karte // Status-Badge auf der Karte
await expect(cards.nth(0)).toContainText(ta.statusLabels.New) 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 // Filter: nur Beantwortet
await page.getByLabel(td.statusLabel).selectOption('Answered') await page.getByLabel(td.statusLabel).selectOption('Answered')
await expect(cards).toHaveCount(1) await expect(cards).toHaveCount(1)

View File

@@ -1,5 +1,5 @@
/** BESTAND-FILTER: die Tiere-Liste zeigt standardmäßig nur den eigenen Bestand. */ /** 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 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) 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() skipUnlessMock()
await gotoSection(page, de.nav.gerbils) await gotoSection(page, de.nav.gerbils)
await expect(page.locator('.gerbil-row', { hasText: 'Krümel' })).toBeVisible() 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() await page.getByRole('checkbox', { name: t.filters.showExternal }).check()
const maxRow = page.locator('.gerbil-row', { hasText: 'Max' }) const maxRow = page.locator('.gerbil-row', { hasText: 'Max' })

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

@@ -63,6 +63,18 @@ export function acceptNextDialog(page: Page) {
page.once('dialog', (d) => void d.accept()) 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. */ /** Eindeutiger Name für LIVE-taugliche Create-Flows. */
export const uniqueName = (prefix: string) => export const uniqueName = (prefix: string) =>
`${prefix} E2E ${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}` `${prefix} E2E ${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`

View File

@@ -1,5 +1,5 @@
/** QA-1: Tiere (Rennmäuse) — Liste/Suche, CRUD, Detail-Tabs (FEAT-1 + FEAT-6). */ /** 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 t = de.pages.gerbils
const tabs = de.pages.tierTabs 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: /Krümel/ })).toBeVisible()
await expect(page.getByRole('link', { name: /Fridolin/ })).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). // Herkunft (originBreeder) auf die Seed-Zucht 'Clan-Kleine-Chaoten' (nur Krümel).
await page await page
.locator('label.field', { has: page.locator(`span:text-is("${t.fields.origin}")`) }) .locator('label.field', { has: page.locator(`span:text-is("${t.fields.origin}")`) })

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,11 +179,10 @@ describe('Farbschlag catalog', () => {
expect(match.name).toBe('Unbekannter Farbschlag') expect(match.name).toBe('Unbekannter Farbschlag')
}) })
it('has the expected catalogue coverage (GEN-3g: 66 after adding CP-*-Hell het variants)', () => { it('has the expected catalogue coverage (GEN-3f: 61 after cchm CP reconciliation)', () => {
// GEN-3f: 73 -> 61 (cchm CP reconciliation). // GEN-3f collapsed the 24 portal cchm colourpoint rows to 12 breeder-named
// GEN-3g: +5 het variants (CP-Agouti/Silberagouti/Algierfuchs/Polarfuchs/Orangeschimmel -Hell), // varieties (Marder/Siam/Zobel/Zobel-Hell + CP-<base>), so 73 -> 61.
// giving 61 + 5 = 66. CP-Fuchs-Hell was already counted. expect(CATALOG_SIZE).toBe(61)
expect(CATALOG_SIZE).toBe(66)
}) })
it('frozen contract names round-trip to themselves (DB-key guard)', () => { it('frozen contract names round-trip to themselves (DB-key guard)', () => {
@@ -415,12 +414,8 @@ describe('GEN-3e: C-locus colourpoint naming', () => {
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-Agouti')
}) })
it('A- + cchm/ch -> CP-<base colour>-Hell (GEN-3g: het gets -Hell suffix)', () => { it('A- + cchm/ch -> CP-<base colour>', () => {
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-Agouti')
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)', () => { it('aa fixed colourpoint names (Marder/Siam/Zobel/Zobel-Hell)', () => {
@@ -476,50 +471,10 @@ 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') expect(name('AA cchmcchm DD efef GG PP spsp rere')).toBe('CP-Orangeschimmel')
}) })
it('het cchm/ch colourpoints (Siam, Zobel-Hell, CP-*-Hell) resolve to their own names', () => { it('het cchm/ch colourpoints (Siam, Zobel-Hell) resolve to their own names', () => {
const siam = BASE_COLORS.find((e) => e.name === 'Siam')! const siam = BASE_COLORS.find((e) => e.name === 'Siam')!
const zh = BASE_COLORS.find((e) => e.name === 'Zobel-Hell')! 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(siam))).toBe('Siam')
expect(genotypeToFarbschlag(representativeGenotype(zh))).toBe('Zobel-Hell') 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,29 +107,27 @@ 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: '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' }, { name: 'Blaufuchs dd', tokens: { A: 'a', C: 'C', D: 'd', E: 'e', G: 'g', P: 'p' }, image: 'blaufuchs-dd.jpg' },
// ── GEN-3f/3g: c^chm colourpoint varieties ── // ── GEN-3f: c^chm colourpoint varieties, reconciled to the breeder's CP- naming ──
// GEN-3f: aa points = marten/sable group (Marder/Siam, +gg Zobel/Zobel-Hell). // The merged GEN-3e colourpointName() rule is authoritative: aa points are the
// GEN-3g (breeder rule): '-Hell' == cchm/ch het; no '-Hell' == cchm/cchm hom. // marten/sable group (Marder/Siam, +gg Zobel/Zobel-Hell — E and D irrelevant);
// A- points: hom -> 'CP-<base>', het -> 'CP-<base>-Hell' (colourpointName()). // A- points take the 'CP-<base colour>' prefix and the '-Hell' shade variants
// CP-Fuchs is a Sammelbegriff (unknown loci); its -Hell het = CP-Fuchs-Hell. // collapse (other loci irrelevant for the CP prefix). These names == the strings
// CP-Blaufuchs (D:d, G:g) still resolves engine-side to 'CP-Fuchs' (dd/gg // in her live data (god: extract animals.json) so the re-import name-matches and
// fox CP has no dedicated base entry); kept for import name-match + hand-pick. // 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.
{ name: 'Marder', tokens: { A: 'a', C: 'cchm', D: 'D', E: 'E', G: 'G', P: 'P' }, image: 'marder.JPG' }, { 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: '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: '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', 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', 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', 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', 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', 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-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-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', 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' export const UNKNOWN_FARBSCHLAG = 'Unbekannter Farbschlag'
@@ -209,14 +207,12 @@ function baseColourFor(g: Genotype): string | null {
} }
/** /**
* GEN-3e/3g: the C-locus colourpoint NAMING transform (breeder-authoritative). * GEN-3e: the C-locus colourpoint NAMING transform (breeder-authoritative).
* Returns the colourpoint name, or null when it doesn't apply (full C present, * 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). * or chch — which the base matcher names Hermelin/Himalaya, preserving both).
* aa cchm/cchm -> Marder | aa cchm/ch -> Siam * aa cchm/cchm -> Marder | aa cchm/ch -> Siam
* aa cchm/cchm gg -> Zobel | aa cchm/ch gg -> Zobel-Hell * aa cchm/cchm gg -> Zobel | aa cchm/ch gg -> Zobel-Hell
* A- cchm/cchm -> CP-<base> | A- cchm/ch -> CP-<base>-Hell * A- cchm/cchm | cchm/ch -> CP-<base colour> (base computed as if C were full)
* 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 { function colourpointName(g: Genotype): string | null {
const c = resolvedPair(g, 'C') const c = resolvedPair(g, 'C')
@@ -231,9 +227,9 @@ function colourpointName(g: Genotype): string | null {
if (grey) return bothCchm ? 'Zobel' : 'Zobel-Hell' if (grey) return bothCchm ? 'Zobel' : 'Zobel-Hell'
return bothCchm ? 'Marder' : 'Siam' return bothCchm ? 'Marder' : 'Siam'
} }
// A- colourpoint: base as if C were full; het (cchm/ch) -> '-Hell' suffix. // A- colourpoint -> CP-<base colour>, base as if C were full.
const base = baseColourFor(makeGenotype({ ...g, C: ['C', 'C'] })) const base = baseColourFor(makeGenotype({ ...g, C: ['C', 'C'] }))
return base ? `CP-${base}${bothCchm ? '' : '-Hell'}` : null return base ? `CP-${base}` : null
} }
export function farbschlagFor(g: Genotype): FarbschlagMatch { export function farbschlagFor(g: Genotype): FarbschlagMatch {

View File

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

View File

@@ -15,6 +15,7 @@ import {
} from '../api/requests' } from '../api/requests'
import { useApi, useMutation } from '../hooks/useApi' import { useApi, useMutation } from '../hooks/useApi'
import { formatDateTime } from '../format/labels' import { formatDateTime } from '../format/labels'
import { FilterPanel } from '../components/FilterPanel'
import './anfragen.css' import './anfragen.css'
const PAGE_SIZE = 20 const PAGE_SIZE = 20
@@ -75,7 +76,10 @@ export default function AnfragenPage() {
{sync.error && <div className="alert alert--error">{sync.error}</div>} {sync.error && <div className="alert alert--error">{sync.error}</div>}
{/* Status-Filter */} {/* Status-Filter */}
<div className="filters"> <FilterPanel
activeCount={status !== '' ? 1 : 0}
onReset={() => { setStatus(''); setPage(1) }}
>
<label className="field"> <label className="field">
<span>{t.detail.statusLabel}</span> <span>{t.detail.statusLabel}</span>
<select <select
@@ -93,7 +97,7 @@ export default function AnfragenPage() {
))} ))}
</select> </select>
</label> </label>
</div> </FilterPanel>
{requests.loading && <p className="muted">{de.common.loading}</p>} {requests.loading && <p className="muted">{de.common.loading}</p>}
{requests.error && ( {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 { GENDERS, GERBIL_STATUSES, type Gender, type GerbilStatus } from '../api/types'
import { useApi, useMutation } from '../hooks/useApi' import { useApi, useMutation } from '../hooks/useApi'
import { formatDate, genderLabel, statusLabel } from '../format/labels' import { formatDate, genderLabel, statusLabel } from '../format/labels'
import { FilterPanel } from '../components/FilterPanel'
import './gerbils.css' import './gerbils.css'
const PAGE_SIZE = 20 const PAGE_SIZE = 20
@@ -82,6 +83,14 @@ export default function GerbilsPage() {
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE)) const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
const items = gerbils.data?.items ?? [] 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". // Multi-select bulk "Zur Abgabe stellen".
const [selected, setSelected] = useState<Set<string>>(new Set()) const [selected, setSelected] = useState<Set<string>>(new Set())
const toggleSelect = (id: string) => const toggleSelect = (id: string) =>
@@ -118,15 +127,20 @@ export default function GerbilsPage() {
</Link> </Link>
</header> </header>
<div className="filters"> <FilterPanel
<input searchField={
type="search" <input
className="input" type="search"
placeholder={t.searchPlaceholder} className="input"
value={search} placeholder={t.searchPlaceholder}
onChange={(e) => onFilterChange(setSearch)(e.target.value)} value={search}
aria-label={t.fields.name} onChange={(e) => onFilterChange(setSearch)(e.target.value)}
/> aria-label={t.fields.name}
/>
}
activeCount={activeFilterCount}
onReset={resetFilters}
>
<label className="field"> <label className="field">
<span>{t.filters.status}</span> <span>{t.filters.status}</span>
<select <select
@@ -200,10 +214,7 @@ export default function GerbilsPage() {
onChange={(e) => onFilterChange(setShowExternal)(e.target.checked)} onChange={(e) => onFilterChange(setShowExternal)(e.target.checked)}
/> />
</label> </label>
<button type="button" className="btn" onClick={resetFilters}> </FilterPanel>
{t.filters.reset}
</button>
</div>
{gerbils.loading && <p className="muted">{de.common.loading}</p>} {gerbils.loading && <p className="muted">{de.common.loading}</p>}
{gerbils.error && ( {gerbils.error && (

View File

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

View File

@@ -807,6 +807,12 @@ export const de = {
{ key: 'schreckhaft', label: 'schreckhaft' }, { 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 } as const
export type Strings = typeof de export type Strings = typeof de