Compare commits

...

24 Commits

Author SHA1 Message Date
84365bba7f feat(import): 6 weitere RPRO3-Dubletten-Entscheidungen (Runde 2)
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 1m6s
CI / Frontend Tests (Node/Vite) (push) Failing after 4m58s
CI / Docker Build & Push (push) Has been skipped
Bali, Aladin, Lexi, Pinto, Jasper, Nestor — aus der zweiten Ticket-Runde.
rpro3-decisions.json: 14 → 20 Einträge.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 11:35:57 +02:00
a333bce1b3 feat(import): RPRO3-Dubletten-Entscheidungen der Züchterin als Override
Manuelle "gleich/verschieden?"-Entscheidungen aus den rpro3-import-Tickets
fließen jetzt in den RennmausPro-III-Import ein:

- Rpro3Decisions (Modell + JSON-Loader): same (Force-Merge), different
  (Force-Split), fields (Farbe/DOB/Herkunft/resident/Notiz). Schlüssel = rid.
- Rpro3Dedup.Run(animals, decisions): honoriert die Overrides nach dem
  automatischen Dedup (eine rid zieht ihren Cluster mit).
- Rpro3ImportService: lädt Import/Rpro3/rpro3-decisions.json, wendet
  Feld-Overrides im Plan-Builder an.
- rpro3-decisions.json: 14 bestätigte Entscheidungen (Eiji, Momo, Samuel,
  Female, Kennedy, Fegur, Tuli, Bura, Mister X, Zoey, Max, Akiro, Merlin,
  Snickers).
- tools/import/rpro3_lookup.py: Triage-Helfer (Farbe/Gencode/Eltern/
  Nachzucht+Partner aus _rpro3.db) für die Rückfragen an die Züchterin.
- 4 neue Tests (Force-Merge/Force-Split/Loader); Suite 268 grün.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 11:26:50 +02:00
bbd305861f feat(import): Hiros Eltern aus RennmausPro ergänzt (Ticket f3ad5ec9)
Hiro of Golden Lights erschien fälschlich als Gründertier (keine Eltern im Import).
Aus dem RennmausPro-Backup sicher zugeordnet (gleiches Geburtsdatum 13.02.2012):
Eltern Wynn × Amidala — als nicht-residente Ahnen-Stubs angelegt und verknüpft.

Bewusst nur EINE Generation: tiefere Ahnen würden über addAnimals-Stubs nicht
verkettet (der Eltern-Override greift nur auf echte Stammbaum-/Wurfchronik-Tiere,
nicht auf Stubs) — und mehrere Großeltern-Namen (Harry/Shy/Jack/Speedy) kollidieren
mit bestehenden, anderen Tieren. Voller mehrstufiger RennmausPro-Ahnen-Import =
eigenes Feature (Import-Code-Änderung + robustes Matching), nicht blind machbar.

python test_merge_resolve.py + test_extract.py grün.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 15:51:06 +02:00
8aaddea4ef fix(tickets): Tab + Scrollposition überleben Browser-Zurück
Zwei Ursachen, warum es auf der Tickets-Seite nicht ging (am echten Firefox/Chromium
gegen die Live-App verifiziert):

1) Tab ging verloren: der gewählte Filter-Tab (z. B. „Geschlossen") war reiner
   Component-State und sprang nach „Zurück" (Remount) auf „Offen" zurück — also war man
   in der falschen, kurzen Liste. Jetzt wird der Tab in der Session gemerkt und
   wiederhergestellt (userPicked startet weiterhin false, damit ?focus=-Deep-Links
   weiter funktionieren).

2) Scroll-Save-Heuristik zu unzuverlässig: Beim Wegnavigieren schrumpft der Inhalt von
   .app-main → der Container klemmt auf eine kleinere Position → dieses scroll-Event
   überschrieb die gemerkte Stelle. Erkennung präzisiert: nur verwerfen, wenn Position
   UND scrollHeight gleichzeitig SINKEN (= Inhalt geschrumpft/Seitenwechsel). Eine echte
   Nutzer-Scrollung verkleinert die scrollHeight nie — damit kein fälschliches Verwerfen
   echter Positionen mehr (vorherige „Höhe geändert"-Heuristik schluckte zu viel).

Tests: scroll-restore.spec erweitert (realistisches 2-Schritt-Scrollen + Tickets-Tab
überlebt Zurück), desktop+phone grün; vitest 149 grün.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 14:54:55 +02:00
54cc493230 test(e2e): Scroll-Wiederherstellung der Rennmausliste (desktop + phone)
Deckt die Regression ab, die zuletzt durchrutschte:
- Liste scrollen → Tier öffnen → Browser-Zurück → Position bleibt erhalten.
- Position überlebt App-Hintergrund/Wiederanzeige (visibilitychange).
Läuft im phone-Projekt (390x844, Touch) UND desktop, am echten Scroll-Container .app-main.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 14:33:17 +02:00
bb1b8f8c76 fix(ux): Scrollposition nicht durch Seitenwechsel-Kollaps auf 0 überschreiben
Echte Ursache (per Firefox/Chromium gegen die Live-App verifiziert): Beim Wegklicken
eines Listeneintrags wird die Seite ausgehängt → der Inhalt von .app-main kollabiert →
der Container springt auf scrollTop 0 → das löste ein scroll-Event aus, das 0 als neue
Position speicherte und die gemerkte Stelle ZERSTÖRTE, kurz bevor wir wegnavigierten.

Fix:
- scroll-Events ignorieren, wenn der Container gerade NICHT scrollbar ist
  (scrollHeight ~ clientHeight = Inhalt kollabiert/Seitenwechsel) → kein „unechter" 0-Save.
- Wiederherstellung hält den Schutz über die gesamte Settling-Phase: bei jeder DOM-Änderung
  Position erneut setzen, erst nach ~350 ms Ruhe freigeben (gegen Reflow-Races beim Nachladen).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 14:28:32 +02:00
00a3919c9f fix(ux): Scroll-Wiederherstellung am echten Scroll-Container (.app-main), nicht am Fenster
Die App scrollt NICHT das Fenster: .app-shell ist height:100dvh/overflow:hidden, der
Inhaltsbereich .app-main hat overflow-y:auto. window.scrollY/scrollTo waren daher
wirkungslos (am Desktop sichtbar). Der Hook arbeitet jetzt auf .app-main (scrollTop/
scrollTo, Scroll-Listener am Container) und beobachtet den Inhalt per MutationObserver,
um nach dem Nachladen exakt zur gemerkten Position zu springen. Fallback auf window,
falls der Container fehlt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 14:06:56 +02:00
b0e92c363a fix(ux): zuverlässige Scroll-Wiederherstellung für Infinite-Scroll-Listen
Zwei Probleme bei der großen Rennmausliste (und allen Infinite-Scroll-Listen) nach
Zurück-Navigation behoben:

1) Event- statt Timer-basiert: Die Scroll-Wiederherstellung hängt sich jetzt per
   ResizeObserver an die Höhenänderung der Seite (Liste ist wieder da) und springt dann
   zur gemerkten Position — statt auf einen festen Timer zu warten. Bricht bei eigener
   Scroll-Eingabe ab; Sicherheitsnetz nach 15 s.

2) Infinite-Scroll baut Höhe wieder auf: useInfiniteList merkt sich (optionaler
   restoreKey) die geladene Seitenzahl je Query und lädt sie nach einer Zurück-
   Navigation wieder nach — sonst war die Liste nur 1 Seite hoch und die Position
   unerreichbar. Aktiviert für Rennmäuse, Kontakte, Würfe, Becken, Anfragen, Verträge.

tsc/eslint/vitest grün; e2e (Listen + Navigation, 41) grün.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 14:01:28 +02:00
7262b34ae9 fix(ux): Scrollposition übersteht auch Dev-Server-Hot-Reload (Vite HMR)
Bei einem HMR-/Fast-Refresh-Update rendert die Seite kurz neu (Liste kollabiert →
Sprung nach oben), ohne Navigation/pagehide. Jetzt vor `vite:beforeUpdate`/
`vite:beforeFullReload` sichern und nach `vite:afterUpdate` wiederherstellen (nur Dev;
import.meta.hot ist in Produktion undefiniert). Restore-Fenster auf 4 s erhöht, damit
auch langsam nachladende Listen die Position noch erreichen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 13:52:16 +02:00
ebc70cc0b8 feat(ux): globale Scroll-Wiederherstellung für ALLE Listen/Seiten
Neuer Hook useScrollRestoration (einmal im AppShell gemountet) erhält die
Scrollposition je Route über:
- Zurück/Vorwärts-Navigation (POP restauriert, PUSH startet oben),
- Bildschirm-Sperre / App-Hintergrund (visibilitychange, pagehide/pageshow) —
  damit das Handy nach dem Entsperren nicht mehr oben landet.

Robust gegen nachladende Listen (rAF-Nachjustierung bis ~2,5 s, während die Liste
wächst), bricht bei eigener Scroll-Eingabe ab, Deep-Links (?focus=, #anchor) haben
Vorrang. Die bisher nur in der Tickets-Seite eingebaute Logik wurde durch den
globalen Hook ersetzt.

tsc/eslint/vitest grün; e2e (navigation/listen/tickets, 42) grün.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 13:48:02 +02:00
f9e672c707 fix(tickets): Scrollposition übersteht Handy-Sperre/Hintergrund (nicht mehr Sprung nach oben)
Bisher wurde die Scrollposition nur einmal beim Mount wiederhergestellt — sperrte das
Handy und kam zurück, war die Seite oben. Jetzt:
- Position wird beim Ausblenden sicher gesichert (visibilitychange→hidden, pagehide),
  nicht nur bei Scroll-Events.
- Wiederherstellung beim Wiederanzeigen (visibilitychange→visible, pageshow) UND nach
  dem Laden — mit mehreren rAF-Versuchen gegen nachwachsende Inhalte.
- Schutz gegen den Aufwach-Sprung nach oben: während des Wiederherstellens werden
  Scroll-Speicherungen blockiert, damit ein vom Browser ausgelöster 0-Sprung die
  gemerkte Position nicht überschreibt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 13:41:05 +02:00
fe79be2eee feat(import): isResident-Override + Vorfahren Bentley/Alexandria/Bugatti
- Neuer isResident-Override im Import (merge_and_resolve, NACH dem Dedup, damit die
  Zusammenführung ihn nicht überschreibt). Matcht über externalRef (präzise, auch für
  namenlose Tiere) oder normalize(call-name)+dob. Ein Nicht-Bestandstier verliert den
  eigenen Zucht-Breeder.
- conflict-decisions.json: Bentley, Alexandria, Bugatti = nur Vorfahren (isResident=false),
  bestätigt von der Züchterin (Tickets 09bcac78 / 45cc501b).
- Regressionstests ergänzt/aktualisiert (Bentley/Alexandria/Bugatti isResident; Danielle
  Vater Makoto; Catelyn Eddard×Milena; Gaida Zhuāngzǐ×Zaibunissa). Alle python test_*.py grün.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 12:29:25 +02:00
41ee4ee3ab fix(import): Eltern für Danielle, Catelyn, Gaida (Triage-Antworten der Züchterin)
- Danielle: Vater Makoto ergänzt (Mutter Ella war schon gesetzt) — Geschwisterverpaarung.
- Catelyn: Eltern Eddard Stark of Sunset Glow × Milena.
- Gaida: Eltern über Geburtsdatum gefunden — Zhuāngzǐ × Zaibunissa (beide *21.02.2020,
  Geschwisterverpaarung, Kinder von Nisha × Zenon).

Per conflict-decisions.json (re-ingest verifiziert: Eltern live korrekt). Tickets
4692fd5c / 7bbc045c / ba63325a geschlossen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 12:18:06 +02:00
f7a1a7724b feat(tickets): Web-Push-Benachrichtigungen (PWA)
- Die Züchterin kann auf der Tickets-Seite Push-Benachrichtigungen aktivieren
  (🔔-Schalter). Service Worker ist BEWUSST push-only (kein fetch/Caching), damit das
  Laden der App nie beeinträchtigt wird.
- Backend: WebPush-Bibliothek + VAPID; PushNotifier sendet an alle Abos, räumt veraltete
  (404/410) auf. Endpoints: GET /push/vapid-public-key, POST /push/subscribe|unsubscribe.
- Ausgelöst über ein notify-Flag auf PUT /feedback: NUR die KI setzt es (z. B. neue
  Rückfrage / Ticket gelöst) → keine Selbst-Pushes durch Aktionen der Züchterin. Text wird
  aus dem Status abgeleitet, Klick öffnet das Ticket (?focus=).
- Schalter/Logik blenden sich aus, wenn das Gerät kein Push kann oder der Server keine
  VAPID-Schlüssel hat (z. B. e2e-Mock).

Migration WebPushSubscriptions. VAPID-Schlüssel in appsettings.json (lokale Single-User-App
im Heimnetz — bewusste, vertretbare Vereinfachung). Tests: 264 Backend grün (+Push-Endpoints),
e2e Tickets Desktop grün, vitest 149.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 11:32:26 +02:00
750619d3d7 feat(tickets): Foto-/Datei-Anhänge an Tickets
- Anhänge im Melde-Fenster (beim Erstellen) und direkt an bestehenden Tickets:
  Vorschaubilder, Öffnen im neuen Tab, Entfernen.
- Bytes liegen in eigener Tabelle (FeedbackAttachment, lose FeedbackId ohne FK →
  übersteht den Ingest-Wipe); GET /feedback liefert nur Metadaten (id/Name/Typ/Größe),
  die Bytes über /feedback/attachments/{id}. Größenlimit 10 MB.
- Endpoints: POST /feedback/{id}/attachments (base64), GET /feedback/attachments/{id}
  (Bytes), DELETE /feedback/attachments/{id}.

Migration FeedbackAttachments. Tests: 262 Backend grün (+Upload/Serve/Delete +Validierung),
e2e Tickets Desktop+Phone grün (+Foto-Upload), vitest 149.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 11:24:02 +02:00
48b138bbfd feat(tickets): Volltextsuche + Kategorien, 👍/👎-Rückmeldung & ähnliche gelöste Tickets
- Suche: Volltext über Nachricht/Frage/Antwort/Changelog/Name/Kategorie, plus
  Kategorie-Filter (KI-gesetzte Kategorie als Chip + Auswahlmenü), je innerhalb der
  aktiven Ansicht.
- 👍/👎 auf gelösten Tickets: 👍 speichert die Rückmeldung, 👎 öffnet das Ticket
  wieder (zurück in die Rückfragen) und bittet um die fehlende Info.
- Melde-Fenster: schlägt beim Tippen ähnliche bereits gelöste Tickets vor
  (Wort-Überlappung + Bonus bei gleichem Tier/Wurf/Kontakt) — Self-Service.

Backend: Feedback.Category + Feedback.Helpful (+ PUT-Handling), Migration
FeedbackCategoryAndHelpful (beide nullable). Tests: 260 Backend grün, e2e Tickets
Desktop +4 (Suche/Kategorie/👍/👎), vitest 149.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 11:14:07 +02:00
c0a7b1a5ee feat(tickets): Wiederöffnen→Rückfrage mit Zeitstempel + Soft-Delete-Papierkorb + Scroll-Erhalt
Wiederöffnen eines gelösten Tickets:
- landet jetzt in „Rückfragen" (NeedsInfo) statt „Offen".
- War es ein echt gelöstes Ticket OHNE Rückfrage, wird um mehr Infos gebeten und
  „Wieder geöffnet am" (ReopenedAt) gestempelt.
- Hatte es bereits eine offene Rückfrage (z. B. manuell gelöste Rückfrage), kehrt es
  ohne neuen Hinweis und ohne Zeitstempel in genau diese Frage zurück.

Löschen über die UI = Soft-Delete:
- Tickets wandern in die neue Kategorie „Gelöscht" (Papierkorb) statt entfernt zu werden.
- Von dort wiederherstellbar (POST /feedback/{id}/restore → DeletedAt = null).
- Countdown „Wird in N Tagen endgültig gelöscht"; nach 30 Tagen endgültige Löschung
  (lazy beim Abruf in GET /feedback).

Außerdem: Scrollposition bleibt beim Zurück-Navigieren (z. B. nach Klick auf einen
Tier-Link) erhalten, statt an den Seitenanfang zu springen.

EF-Migration FeedbackReopenedAndSoftDelete (ReopenedAt, DeletedAt — beide nullable).
Tests: 260 Backend grün (+2 Reopen-Fälle, CRUD auf Soft-Delete umgestellt), e2e Tickets
Desktop+Phone grün (+Soft-Delete/Restore/Reopen), vitest 149.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 11:02:04 +02:00
a0fa33da42 feat(tickets): Erst-Text nach Antwort sperren, nur neueste Antwort editierbar
Sobald ein Ticket eine Antwort hat, ist der ursprüngliche Ticket-Text nicht
mehr bearbeitbar. Editierbar ist dann nur noch die neueste Antwort — und auch
die nur, bis die KI darauf reagiert hat (Status verlässt „Answered"). Ältere
Antworten im Verlauf (thread) bleiben wie bisher schreibgeschützt.

Regressionstest + Answered-Mock-Ticket ergänzt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 10:32:04 +02:00
6b264a9b71 fix(import): Farbschlag aus Genotyp ableiten + Mamta-Eltern — Ticket-Triage
genotype.py: Python-Port von genotypeToFarbschlag (0 Abw. über 3402 Genotypen).
resolve_color_and_genotype: bei vorhandenem Genotyp gewinnt der berechnete Farbschlag
(Goldfuchs≠Gold, Dilute Agouti/Anthrazit, Blaufuchs statt -schimmel bei (schimmel),
spsp statt Schecke). Mamta Mini: Ee + Eltern Geely×Gaida am Wurf. Regressionstests je Fall.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 10:08:13 +02:00
26977d73ff docs(mempalace): Subagenten müssen MemPalace zu Aufgabenbeginn abfragen
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 09:43:12 +02:00
12f5de00a0 docs: Genetik-Engine immer voll mit Tests absichern (Konvention) + E-Locus-Regel
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 09:36:12 +02:00
0540498e85 Merge branch 'worktree-agent-a7281df1f2cc3f1f1'
# Conflicts:
#	gerbil-manager-web/src/genetics/catalog.ts
#	gerbil-manager-web/src/genetics/genotype.ts
#	gerbil-manager-web/src/genetics/punnett.ts
2026-06-23 09:29:42 +02:00
a87d1ec865 fix(api): API-Host im Dev-Modus aus window.location ableiten (LAN/Handy-Zugriff)
Statt fest http://localhost:5179 (auf dem Handy = das Handy selbst) nutzt der
Client im Dev-Modus den Host, von dem die Seite geladen wurde → 192.168.x.y:5179.
e2e/Rechner bleiben bei localhost.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 09:18:55 +02:00
8c1ee75b81 fix(genetics): Engine-Fixes Ticket-Triage (Goldfuchsschimmel, Dilute CP-Blaufuchs, Eltern-Inferenz, Punnett, uw[d])
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 09:05:35 +02:00
69 changed files with 13662 additions and 645 deletions

View File

@@ -110,7 +110,13 @@ python test_extract.py test_extract_docx.py test_genotype.py test_merge_resol
- **Box-Farbe im Stammbaum-xlsx = Geschlecht**: weiß = weiblich, blau = männlich.
- **Genotyp/Farbschlag**: 8-Locus-Notation (siehe `tools/import/genotype.py`,
`gerbil-manager-web/src/genetics`). Unbekanntes Allel = `-` (nicht `?`).
E-Locus: `ee`=Fuchs, `eef`=Fuchsschimmel, `efef`=Schimmel.
E-Locus: `ee`=Fuchs, `eef`=Fuchsschimmel, `efef`=Schimmel. Rezessiver Fuchs ist
zwingend homozygot → `e-` ist ungültig (wirft), `ee[-]``ee`.
- **Genetik-Engine = korrektheitskritisch: IMMER vollständig mit Tests absichern.**
Jede Änderung an `src/genetics/**` (+ Backend-Mirror + `genotype.py`) braucht pro
Use-Case/Ticket einen Regressionstest (`src/genetics/__tests__/genetics.test.ts`
und `tools/import/test_genotype.py`), damit nie eine Regression entsteht. `npx vitest
run` + `python test_genotype.py` müssen grün sein, bevor „fertig".
- **Stammbaum-Charts**: Generationen = Spaltenbänder (Proband links in Spalte B/2,
je Generation +3 Spalten); Eltern-Position: Vater oben, Mutter unten. Fotos liegen
je nach Datei links/auf der Namenszelle — die Seite wird heuristisch über die
@@ -175,6 +181,13 @@ Die Anbindung ist im Repo persistiert und lädt automatisch:
(`mempalace_search` / `mempalace_kg_query`), bevor du im Code suchst; am Ende neue
durable Erkenntnisse ablegen.
**Subagenten MÜSSEN MemPalace ebenfalls nutzen** — das im Subagent-Prompt IMMER explizit
anweisen: zu Aufgabenbeginn den Palast abfragen (am einfachsten per CLI, kein Key nötig:
`mempalace search "<keywords>"` bzw. `mempalace search "<…>" --wing gerbilmanager`; falls
MCP-Tools verfügbar auch `mempalace_search`/`mempalace_kg_query`) — relevante Domänenregeln,
frühere Entscheidungen und Stolperfallen holen, BEVOR im Code gesucht wird; durable
Erkenntnisse am Ende ablegen (`mempalace add-drawer`/MCP). Gilt für JEDEN gespawnten Agenten.
### Memory-Policy — WANN / WIE / WO eine Memory anlegen
**WANN** (anlegen): nur **dauerhaft** nützliches, **nicht-offensichtliches** Wissen, das

View File

@@ -100,15 +100,138 @@ public class FeedbackEndpointTests : IClassFixture<ApiFactory>
Assert.Equal("Open", reopened.GetProperty("status").GetString());
Assert.Equal(JsonValueKind.Null, reopened.GetProperty("resolvedAt").ValueKind);
// Delete -> 204, then 404 on subsequent edit/delete
// Soft-Delete -> 204; das Ticket bleibt erhalten (DeletedAt gesetzt) und ist über GET noch da.
var del = await client.DeleteAsync($"/feedback/{id}");
Assert.Equal(HttpStatusCode.NoContent, del.StatusCode);
var delAgain = await client.DeleteAsync($"/feedback/{id}");
Assert.Equal(HttpStatusCode.NotFound, delAgain.StatusCode);
var afterDelete = JsonDocument.Parse(await client.GetStringAsync("/feedback")).RootElement;
var deletedRow = afterDelete.EnumerateArray().Single(f => f.GetProperty("id").GetString() == id);
Assert.NotEqual(JsonValueKind.Null, deletedRow.GetProperty("deletedAt").ValueKind);
var editGone = await client.PutAsJsonAsync($"/feedback/{id}", new { message = "noch da?" });
Assert.Equal(HttpStatusCode.NotFound, editGone.StatusCode);
// Wiederherstellen -> DeletedAt wieder null, Ticket weiterhin bearbeitbar.
var restore = await client.PostAsync($"/feedback/{id}/restore", null);
Assert.Equal(HttpStatusCode.OK, restore.StatusCode);
var restored = JsonDocument.Parse(await restore.Content.ReadAsStringAsync()).RootElement;
Assert.Equal(JsonValueKind.Null, restored.GetProperty("deletedAt").ValueKind);
// Delete/Restore eines unbekannten Tickets -> 404.
var delMissing = await client.DeleteAsync($"/feedback/{Guid.NewGuid()}");
Assert.Equal(HttpStatusCode.NotFound, delMissing.StatusCode);
var restoreMissing = await client.PostAsync($"/feedback/{Guid.NewGuid()}/restore", null);
Assert.Equal(HttpStatusCode.NotFound, restoreMissing.StatusCode);
}
[Fact]
public async Task Reopen_resolved_ticket_without_Rueckfrage_sets_NeedsInfo_and_ReopenedAt()
{
var client = _factory.CreateClient();
var create = await client.PostAsJsonAsync("/feedback", new
{
message = "Farbschlag stimmt nicht.",
context = "gerbil-detail",
});
var id = JsonDocument.Parse(await create.Content.ReadAsStringAsync()).RootElement.GetProperty("id").GetString();
// Direkt (ohne Rückfrage) lösen …
await client.PutAsJsonAsync($"/feedback/{id}", new { status = "Resolved", fixNote = "behoben" });
// … und wieder öffnen: Frontend schickt NeedsInfo + Bitte-um-Infos-Frage.
var reopen = await client.PutAsJsonAsync($"/feedback/{id}", new
{
status = "NeedsInfo",
question = "Bitte beschreibe, was noch fehlt.",
});
var r = JsonDocument.Parse(await reopen.Content.ReadAsStringAsync()).RootElement;
Assert.Equal("NeedsInfo", r.GetProperty("status").GetString());
Assert.Equal("Bitte beschreibe, was noch fehlt.", r.GetProperty("question").GetString());
// Echt gelöstes Ticket ohne vorherige Rückfrage -> ReopenedAt gesetzt.
Assert.NotEqual(JsonValueKind.Null, r.GetProperty("reopenedAt").ValueKind);
}
[Fact]
public async Task Attachment_upload_list_serve_delete_lifecycle()
{
var client = _factory.CreateClient();
var create = await client.PostAsJsonAsync("/feedback", new
{
message = "Foto vom Fellschlag.",
context = "gerbil-detail",
});
var id = JsonDocument.Parse(await create.Content.ReadAsStringAsync()).RootElement.GetProperty("id").GetString();
// 1×1-PNG hochladen.
const string pngB64 =
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==";
var up = await client.PostAsJsonAsync($"/feedback/{id}/attachments", new
{
fileName = "maus.png",
contentType = "image/png",
dataBase64 = pngB64,
});
Assert.Equal(HttpStatusCode.Created, up.StatusCode);
var att = JsonDocument.Parse(await up.Content.ReadAsStringAsync()).RootElement;
var attId = att.GetProperty("id").GetString();
Assert.Equal("maus.png", att.GetProperty("fileName").GetString());
Assert.True(att.GetProperty("size").GetInt32() > 0);
// GET /feedback liefert die Metadaten (ohne Bytes).
var listed = JsonDocument.Parse(await client.GetStringAsync("/feedback")).RootElement;
var row = listed.EnumerateArray().Single(f => f.GetProperty("id").GetString() == id);
Assert.Equal(1, row.GetProperty("attachments").GetArrayLength());
// Bytes ausliefern.
var bytes = await client.GetByteArrayAsync($"/feedback/attachments/{attId}");
Assert.Equal(Convert.FromBase64String(pngB64).Length, bytes.Length);
// Löschen -> danach keine Anhänge mehr.
var del = await client.DeleteAsync($"/feedback/attachments/{attId}");
Assert.Equal(HttpStatusCode.NoContent, del.StatusCode);
var after = JsonDocument.Parse(await client.GetStringAsync("/feedback")).RootElement;
var rowAfter = after.EnumerateArray().Single(f => f.GetProperty("id").GetString() == id);
Assert.Equal(0, rowAfter.GetProperty("attachments").GetArrayLength());
}
[Fact]
public async Task Attachment_upload_rejects_invalid_base64()
{
var client = _factory.CreateClient();
var create = await client.PostAsJsonAsync("/feedback", new { message = "x", context = "gerbil-detail" });
var id = JsonDocument.Parse(await create.Content.ReadAsStringAsync()).RootElement.GetProperty("id").GetString();
var up = await client.PostAsJsonAsync($"/feedback/{id}/attachments", new
{
fileName = "x.png",
contentType = "image/png",
dataBase64 = "###nicht base64###",
});
Assert.Equal(HttpStatusCode.BadRequest, up.StatusCode);
}
[Fact]
public async Task Reopen_resolved_ticket_that_had_a_Rueckfrage_keeps_question_and_no_ReopenedAt()
{
var client = _factory.CreateClient();
var create = await client.PostAsJsonAsync("/feedback", new
{
message = "Welches Tier ist gemeint?",
context = "gerbil-detail",
});
var id = JsonDocument.Parse(await create.Content.ReadAsStringAsync()).RootElement.GetProperty("id").GetString();
// Rückfrage stellen (NeedsInfo) …
await client.PutAsJsonAsync($"/feedback/{id}", new { question = "Meinst du A oder B?" });
// … manuell lösen …
await client.PutAsJsonAsync($"/feedback/{id}", new { status = "Resolved" });
// … und wieder öffnen: Frontend schickt dieselbe Frage erneut (keine Bitte-um-Infos).
var reopen = await client.PutAsJsonAsync($"/feedback/{id}", new
{
status = "NeedsInfo",
question = "Meinst du A oder B?",
});
var r = JsonDocument.Parse(await reopen.Content.ReadAsStringAsync()).RootElement;
Assert.Equal("NeedsInfo", r.GetProperty("status").GetString());
Assert.Equal("Meinst du A oder B?", r.GetProperty("question").GetString());
// Hatte bereits eine offene Rückfrage -> KEIN „Wieder geöffnet am"-Zeitstempel.
Assert.Equal(JsonValueKind.Null, r.GetProperty("reopenedAt").ValueKind);
}
[Fact]

View File

@@ -0,0 +1,44 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
namespace GerbilManager.Tests;
/// <summary>
/// WEB-PUSH: VAPID-Public-Key abrufen + Abo speichern/entfernen.
/// (Das tatsächliche Versenden wird nicht getestet — es geht an externe Push-Dienste.)
/// </summary>
public class PushEndpointTests : IClassFixture<ApiFactory>
{
private readonly ApiFactory _factory;
public PushEndpointTests(ApiFactory factory) => _factory = factory;
[Fact]
public async Task Vapid_public_key_is_exposed()
{
var client = _factory.CreateClient();
var doc = JsonDocument.Parse(await client.GetStringAsync("/push/vapid-public-key")).RootElement;
Assert.True(doc.GetProperty("enabled").GetBoolean());
Assert.False(string.IsNullOrWhiteSpace(doc.GetProperty("publicKey").GetString()));
}
[Fact]
public async Task Subscribe_is_idempotent_and_unsubscribe_works()
{
var client = _factory.CreateClient();
var endpoint = $"https://push.example.com/{Guid.NewGuid()}";
var body = new { endpoint, p256dh = "abc123", auth = "def456" };
var first = await client.PostAsJsonAsync("/push/subscribe", body);
Assert.Equal(HttpStatusCode.OK, first.StatusCode);
// Erneutes Abo mit derselben Endpoint-URL -> weiterhin OK (Upsert, kein Duplikat).
var second = await client.PostAsJsonAsync("/push/subscribe", body);
Assert.Equal(HttpStatusCode.OK, second.StatusCode);
var bad = await client.PostAsJsonAsync("/push/subscribe", new { endpoint = "", p256dh = "", auth = "" });
Assert.Equal(HttpStatusCode.BadRequest, bad.StatusCode);
var unsub = await client.PostAsJsonAsync("/push/unsubscribe", new { endpoint });
Assert.Equal(HttpStatusCode.NoContent, unsub.StatusCode);
}
}

View File

@@ -89,6 +89,73 @@ namespace GerbilManager.Tests
Assert.Equal(2, r.Placeholders.Count);
}
[Fact]
public void Dedup_force_merges_variants_declared_same_by_breeder()
{
// Zwei Varianten, die der Auto-Dedup wegen Farbkonflikt getrennt lässt …
var animals = new List<Rpro3Animal>
{
Ext("u1", "Akiro", new DateOnly(2008, 2, 5), "Polarfuchs", "Privatzucht"), // Variante B
Ext("u2", "Akiro", new DateOnly(2008, 2, 6), "Polarfuchs, hell", "unbekannt"), // Variante C
};
// ohne Entscheidung: getrennt (Farbe + DOB unterschiedlich → Konflikt)
Assert.Empty(Rpro3Dedup.Run(animals).MergeClusters);
// … werden per „Same"-Entscheidung der Züchterin zusammengelegt.
var decisions = new Rpro3Decisions
{
Decisions = { new Rpro3Decision { Name = "Akiro", Same = { new() { "u1", "u2" } } } }
};
var r = Rpro3Dedup.Run(animals, decisions);
Assert.Single(r.MergeClusters);
Assert.Equal(2, r.MergeClusters.Values.First().Count);
Assert.Equal(r.RidToRoot["u1"], r.RidToRoot["u2"]);
}
[Fact]
public void Dedup_force_splits_variants_declared_different_by_breeder()
{
// Zwei Datensätze, die der Auto-Dedup zusammenlegen würde (kompatibel) …
var animals = new List<Rpro3Animal>
{
Ext("u1", "Max", new DateOnly(2013, 2, 1), "Marder", "Clan A"),
Ext("u2", "Max", new DateOnly(2013, 2, 1), "Marder", "Clan A"),
};
Assert.Single(Rpro3Dedup.Run(animals).MergeClusters);
// … bleiben durch „Different" getrennt.
var decisions = new Rpro3Decisions
{
Decisions = { new Rpro3Decision { Name = "Max", Different = { new() { "u1" }, new() { "u2" } } } }
};
var r = Rpro3Dedup.Run(animals, decisions);
Assert.Empty(r.MergeClusters);
Assert.NotEqual(r.RidToRoot["u1"], r.RidToRoot["u2"]);
}
[Fact]
public void Rpro3Decisions_load_returns_empty_when_file_missing()
{
var d = Rpro3Decisions.Load(Path.Combine(Path.GetTempPath(), "does-not-exist-" + Guid.NewGuid().ToString("N") + ".json"));
Assert.Empty(d.Decisions);
}
[Fact]
public void Rpro3Decisions_roundtrips_through_json()
{
var path = Path.Combine(Path.GetTempPath(), "rpro3-dec-" + Guid.NewGuid().ToString("N") + ".json");
File.WriteAllText(path,
"{\"decisions\":[{\"name\":\"Bura\",\"same\":[[\"228\",\"u2128\"]],\"fields\":{\"228\":{\"origin\":\"Sarah Wörz\"}}}]}");
try
{
var d = Rpro3Decisions.Load(path);
Assert.Single(d.Decisions);
Assert.Equal("Sarah Wörz", d.BuildFieldIndex()["228"].Origin);
Assert.Equal(new[] { "228", "u2128" }, d.SameGroups().First());
}
finally { File.Delete(path); }
}
// ── Integration: Reader + Execute gegen eine Mini-RPRO3-DB ────
[Fact]

View File

@@ -25,6 +25,8 @@ public class ApplicationContext : DbContext
public DbSet<Request> Requests => Set<Request>();
public DbSet<MailSettings> MailSettings => Set<MailSettings>();
public DbSet<Feedback> Feedback => Set<Feedback>();
public DbSet<FeedbackAttachment> FeedbackAttachments => Set<FeedbackAttachment>();
public DbSet<WebPushSubscription> WebPushSubscriptions => Set<WebPushSubscription>();
public DbSet<AcquisitionRecord> AcquisitionRecords => Set<AcquisitionRecord>();
public DbSet<SaleReservation> SaleReservations => Set<SaleReservation>();
public DbSet<WaitingListEntry> WaitingListEntries => Set<WaitingListEntry>();
@@ -400,25 +402,28 @@ public class ApplicationContext : DbContext
("Dilute Anthrazit", "aa CC dd EE gg PP spsp rere", 31),
// --- Schimmel / Fuchsschimmel (IDs 33-37) ---
("Silberschimmel", "AA CC DD efef gg PP spsp rere", 36),
("Polarfuchsschimmel", "AA CC DD efef gg PP spsp rere", 37),
("Algierfuchsschimmel", "AA CC DD efef GG PP spsp rere", 38),
("Kohlfuchsschimmel", "aa CC DD efef GG PP spsp rere", 39),
("Blaufuchsschimmel", "aa CC DD efef gg PP spsp rere", 40),
// GEN-5 (ticket 5826e8e2): *Fuchsschimmel = HET ef/e (internal 'efe'),
// not hom ef/ef — a Schimmel-modified Fox. Pure *schimmel stay efef.
("Polarfuchsschimmel", "AA CC DD efe gg PP spsp rere", 37),
("Algierfuchsschimmel", "AA CC DD efe GG PP spsp rere", 38),
("Kohlfuchsschimmel", "aa CC DD efe GG PP spsp rere", 39),
("Blaufuchsschimmel", "aa CC DD efe gg PP spsp rere", 40),
// --- Hell variants (IDs 38-48) ---
("Kohlfuchs, hell", "aa CC DD ee GG PP spsp rere", 41),
("Goldfuchs, hell", "AA CC DD ee GG pp spsp rere", 42),
("Goldfuchsschimmel", "AA CC DD efef GG pp spsp rere", 43),
("Goldfuchsschimmel", "AA CC DD efe GG pp spsp rere", 43),
("Gold-Hell", "AA CC DD EE GG pp spsp rere", 44),
("Blaufuchs, hell", "aa CC DD ee gg PP spsp rere", 45),
("Rotfuchsschimmel", "aa CC DD efef GG pp spsp rere", 46),
("Rotfuchsschimmel", "aa CC DD efe GG pp spsp rere", 46),
("Polarfuchs, hell", "AA CC DD ee gg PP spsp rere", 47),
("Kohlfuchsschimmel, hell","aa CC DD efef GG PP spsp rere", 48),
("Kohlfuchsschimmel, hell","aa CC DD efe GG PP spsp rere", 48),
("Rotfuchs, hell", "aa CC DD ee GG pp spsp rere", 49),
("Kohlfuchs-Hell", "aa CC DD ee GG PP spsp rere", 50),
("Algierfuchs, hell", "AA CC DD ee GG PP spsp rere", 51),
// --- Dilute (dd) renamed variants (IDs 49-50) ---
("Dilute Topas", "AA CC dd EE GG pp spsp rere", 52),
("Dilute Blaufuchs","aa CC dd ee gg pp spsp rere", 53),
// GEN-5 (ticket 3deab547): Blaufuchs is black-eyed; dilution is P-independent.
("Dilute Blaufuchs","aa CC dd ee gg PP spsp rere", 53),
// --- Marder / Siam / CP- series (IDs 51-66) ---
("Marder", "aa cchmcchm DD EE GG PP spsp rere", 54),
("Siam", "aa cchmch DD EE GG PP spsp rere", 55),

View File

@@ -20,6 +20,19 @@ namespace GerbilManagerWebAPI.Dtos
string Text,
DateTimeOffset? At);
/// <summary>FEEDBACK: lightweight metadata for an attachment (no bytes).</summary>
public record FeedbackAttachmentDto(
Guid Id,
string FileName,
string ContentType,
int Size);
/// <summary>FEEDBACK: payload to upload an attachment (base64-encoded bytes).</summary>
public record FeedbackAttachmentInput(
string FileName,
string ContentType,
string DataBase64);
/// <summary>FEEDBACK: response DTO for a stored report.</summary>
public record FeedbackDto(
Guid Id,
@@ -43,7 +56,17 @@ namespace GerbilManagerWebAPI.Dtos
/// <summary>INTERNAL agent working memory — exposed for tooling, NEVER shown to the breeder.</summary>
string? AgentContext,
/// <summary>Earlier Q&A rounds, oldest first; the current open exchange stays in Question/Answer.</summary>
IReadOnlyList<FeedbackThreadEntry> Thread);
IReadOnlyList<FeedbackThreadEntry> Thread,
/// <summary>When a genuinely-resolved ticket (no pending Rückfrage) was reopened; else null.</summary>
DateTimeOffset? ReopenedAt = null,
/// <summary>Soft-delete marker: when the ticket was deleted via the UI (recoverable); else null.</summary>
DateTimeOffset? DeletedAt = null,
/// <summary>Optional AI-set category/topic for filtering (e.g. "Genetik", "Import"); null = none.</summary>
string? Category = null,
/// <summary>Was the resolution helpful? true=👍, false=👎, null=no feedback yet.</summary>
bool? Helpful = null,
/// <summary>Attachment metadata (no bytes); fetch bytes via /feedback/attachments/{id}.</summary>
IReadOnlyList<FeedbackAttachmentDto>? Attachments = null);
/// <summary>
/// FEEDBACK: payload for PUT /feedback/{id}. Edit the message and/or toggle status,
@@ -56,5 +79,15 @@ namespace GerbilManagerWebAPI.Dtos
string? Question,
string? Answer,
string? FixNote,
string? AgentContext);
string? AgentContext,
/// <summary>Set/clear the ticket category. Empty/whitespace clears it.</summary>
string? Category = null,
/// <summary>👍/👎 on a resolved ticket. null leaves it unchanged.</summary>
bool? Helpful = null,
/// <summary>
/// Wenn true, wird nach dem Update eine Push-Benachrichtigung an die Züchterin gesendet.
/// Nur die KI/der Betreuer setzt das (z. B. neue Rückfrage / Ticket gelöst); das Frontend
/// setzt es NIE — so löst die Züchterin mit eigenen Aktionen keine Selbst-Pushes aus.
/// </summary>
bool? Notify = null);
}

View File

@@ -18,6 +18,12 @@ namespace GerbilManagerWebAPI.Endpoints
/// </summary>
public static class FeedbackEndpoints
{
/// <summary>Aufbewahrungsfrist im Papierkorb: danach werden Tickets endgültig gelöscht.</summary>
private const int TrashRetentionDays = 30;
/// <summary>Maximale Anhang-Größe (10 MB) — Fotos vom Handy passen locker, schützt aber die DB.</summary>
private const int MaxAttachmentBytes = 10 * 1024 * 1024;
public static IEndpointRouteBuilder MapFeedbackEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/feedback").WithTags("Feedback");
@@ -51,21 +57,50 @@ namespace GerbilManagerWebAPI.Endpoints
group.MapGet("/", async (ApplicationContext db) =>
{
// Order in memory: SQLite (test host) cannot ORDER BY a DateTimeOffset column.
var rows = await db.Feedback.AsNoTracking().ToListAsync();
// In memory verarbeiten: SQLite (Test-Host) kann weder ORDER BY noch WHERE-Vergleiche
// auf DateTimeOffset-Spalten übersetzen.
var rows = await db.Feedback.ToListAsync();
// Aufräumen: soft-gelöschte Tickets werden nach TrashRetentionDays endgültig entfernt
// (lazy beim Abruf — genügt für Single-User, kein Hintergrunddienst nötig).
var cutoff = DateTimeOffset.UtcNow.AddDays(-TrashRetentionDays);
var expired = rows.Where(f => f.DeletedAt is { } d && d < cutoff).ToList();
if (expired.Count > 0)
{
db.Feedback.RemoveRange(expired);
await db.SaveChangesAsync();
rows = rows.Except(expired).ToList();
}
// Anhang-Metadaten (OHNE Bytes) laden und je Ticket zuordnen.
var attMeta = await db.FeedbackAttachments
.Select(a => new { a.Id, a.FeedbackId, a.FileName, a.ContentType, a.Size })
.ToListAsync();
var byTicket = attMeta
.GroupBy(a => a.FeedbackId)
.ToDictionary(
g => g.Key,
g => (IReadOnlyList<FeedbackAttachmentDto>)g
.Select(a => new FeedbackAttachmentDto(a.Id, a.FileName, a.ContentType, a.Size))
.ToList());
return TypedResults.Ok(rows
.OrderByDescending(f => f.CreatedAt)
.Select(ToDto)
.Select(f => ToDto(f, byTicket.GetValueOrDefault(f.Id)))
.ToList());
});
group.MapPut("/{id:guid}", async Task<Results<Ok<FeedbackDto>, NotFound, BadRequest<string>>> (
Guid id, FeedbackUpdate input, ApplicationContext db) =>
Guid id, FeedbackUpdate input, ApplicationContext db, Push.PushNotifier push) =>
{
var entity = await db.Feedback.FirstOrDefaultAsync(f => f.Id == id);
if (entity is null)
return TypedResults.NotFound();
// Zustand VOR den Mutationen merken — für die Wiederöffnen-Erkennung weiter unten.
var wasResolved = entity.Status.Equals("Resolved", StringComparison.OrdinalIgnoreCase);
var hadOpenRueckfrage = !string.IsNullOrWhiteSpace(entity.Question);
if (input.Message is not null)
{
if (string.IsNullOrWhiteSpace(input.Message))
@@ -133,6 +168,13 @@ namespace GerbilManagerWebAPI.Endpoints
entity.ResolvedAt = resolved
? (entity.ResolvedAt ?? DateTimeOffset.UtcNow)
: null;
// Wiederöffnen-Zeitstempel: nur, wenn ein ECHT gelöstes Ticket (ohne offene
// Rückfrage) wieder geöffnet wird. Tickets, die nur eine offene Rückfrage hatten
// und manuell auf gelöst gesetzt wurden, kehren beim Wiederöffnen einfach in
// ihre Rückfrage zurück und bekommen KEINEN Zeitstempel.
if (!resolved && wasResolved && !hadOpenRueckfrage)
entity.ReopenedAt = DateTimeOffset.UtcNow;
}
// FIX-NOTE: breeder-friendly changelog (typically set together with status=Resolved).
@@ -151,10 +193,46 @@ namespace GerbilManagerWebAPI.Endpoints
entity.AgentContext = c.Length == 0 ? null : c;
}
// KATEGORIE: frei wählbares Thema für Filter/Übersicht; leer = löschen.
if (input.Category is not null)
{
var cat = input.Category.Trim();
entity.Category = cat.Length == 0 ? null : cat;
}
// HILFREICH (👍/👎): nur setzen, wenn übermittelt (null = unverändert).
if (input.Helpful is not null)
entity.Helpful = input.Helpful;
await db.SaveChangesAsync();
// Push an die Züchterin, wenn die KI das anfordert (notify=true). Nachricht aus dem
// resultierenden Status ableiten. Fehler dürfen die Antwort nicht stören.
if (input.Notify == true && push.Enabled)
{
var name = string.IsNullOrWhiteSpace(entity.EntityName) ? "" : $" ({entity.EntityName})";
var (title, body) = entity.Status switch
{
"NeedsInfo" => ("Neue Rückfrage" + name, Trim(entity.Question) ?? "Bitte schau in deine Tickets."),
"Resolved" => ("Ticket gelöst" + name, Trim(entity.FixNote) ?? Trim(entity.Message) ?? "Erledigt."),
_ => ("Neues zu deinem Ticket" + name, Trim(entity.Message) ?? ""),
};
try
{
await push.NotifyAllAsync(title, body, $"/hilfe/tickets?focus={entity.Id}");
}
catch (Exception)
{
// Push ist best-effort — niemals die API-Antwort daran scheitern lassen.
}
}
return TypedResults.Ok(ToDto(entity));
});
// SOFT-DELETE: das Ticket wird NICHT entfernt, sondern als gelöscht markiert
// (DeletedAt = jetzt) und wandert in die „Gelöscht"-Kategorie. Wiederherstellbar
// über POST /feedback/{id}/restore.
group.MapDelete("/{id:guid}", async Task<Results<NoContent, NotFound>> (
Guid id, ApplicationContext db) =>
{
@@ -162,7 +240,88 @@ namespace GerbilManagerWebAPI.Endpoints
if (entity is null)
return TypedResults.NotFound();
db.Feedback.Remove(entity);
entity.DeletedAt ??= DateTimeOffset.UtcNow;
await db.SaveChangesAsync();
return TypedResults.NoContent();
});
// WIEDERHERSTELLEN: hebt das Soft-Delete auf (DeletedAt → null); das Ticket kehrt in
// seinen vorherigen Status (Offen/Rückfrage/…) zurück.
group.MapPost("/{id:guid}/restore", async Task<Results<Ok<FeedbackDto>, NotFound>> (
Guid id, ApplicationContext db) =>
{
var entity = await db.Feedback.FirstOrDefaultAsync(f => f.Id == id);
if (entity is null)
return TypedResults.NotFound();
entity.DeletedAt = null;
await db.SaveChangesAsync();
return TypedResults.Ok(ToDto(entity));
});
// ANHANG hochladen (base64). Bild/Datei zu einem Ticket. Größenlimit MaxAttachmentBytes.
group.MapPost("/{id:guid}/attachments", async Task<Results<Created<FeedbackAttachmentDto>, NotFound, BadRequest<string>>> (
Guid id, FeedbackAttachmentInput input, ApplicationContext db) =>
{
var ticket = await db.Feedback.FirstOrDefaultAsync(f => f.Id == id);
if (ticket is null)
return TypedResults.NotFound();
if (string.IsNullOrWhiteSpace(input.DataBase64) || string.IsNullOrWhiteSpace(input.FileName))
return TypedResults.BadRequest("FileName und Daten sind erforderlich.");
byte[] bytes;
try
{
// erlaubt sowohl reines base64 als auch eine data:-URL
var raw = input.DataBase64;
var comma = raw.IndexOf(',');
if (raw.StartsWith("data:", StringComparison.OrdinalIgnoreCase) && comma >= 0)
raw = raw[(comma + 1)..];
bytes = Convert.FromBase64String(raw);
}
catch (FormatException)
{
return TypedResults.BadRequest("Daten sind kein gültiges base64.");
}
if (bytes.Length == 0)
return TypedResults.BadRequest("Datei ist leer.");
if (bytes.Length > MaxAttachmentBytes)
return TypedResults.BadRequest($"Datei zu groß (max. {MaxAttachmentBytes / (1024 * 1024)} MB).");
var att = new FeedbackAttachment
{
Id = Guid.NewGuid(),
FeedbackId = id,
FileName = input.FileName.Trim(),
ContentType = string.IsNullOrWhiteSpace(input.ContentType) ? "application/octet-stream" : input.ContentType.Trim(),
Size = bytes.Length,
Data = bytes,
CreatedAt = DateTimeOffset.UtcNow,
};
db.FeedbackAttachments.Add(att);
await db.SaveChangesAsync();
return TypedResults.Created($"/feedback/attachments/{att.Id}",
new FeedbackAttachmentDto(att.Id, att.FileName, att.ContentType, att.Size));
});
// ANHANG-Bytes ausliefern (für <img>/Download).
group.MapGet("/attachments/{attId:guid}", async Task<Results<FileContentHttpResult, NotFound>> (
Guid attId, ApplicationContext db) =>
{
var att = await db.FeedbackAttachments.AsNoTracking().FirstOrDefaultAsync(a => a.Id == attId);
if (att is null)
return TypedResults.NotFound();
return TypedResults.File(att.Data, att.ContentType, att.FileName);
});
// ANHANG löschen.
group.MapDelete("/attachments/{attId:guid}", async Task<Results<NoContent, NotFound>> (
Guid attId, ApplicationContext db) =>
{
var att = await db.FeedbackAttachments.FirstOrDefaultAsync(a => a.Id == attId);
if (att is null)
return TypedResults.NotFound();
db.FeedbackAttachments.Remove(att);
await db.SaveChangesAsync();
return TypedResults.NoContent();
});
@@ -172,6 +331,14 @@ namespace GerbilManagerWebAPI.Endpoints
private static readonly JsonSerializerOptions ThreadJson = new(JsonSerializerDefaults.Web);
/// <summary>Für Push-Texte: leeren Wert zu null, sonst auf ~140 Zeichen kürzen.</summary>
private static string? Trim(string? s)
{
if (string.IsNullOrWhiteSpace(s)) return null;
var t = s.Trim();
return t.Length > 140 ? t[..139] + "…" : t;
}
/// <summary>
/// Append the ticket's current (Question, Answer) exchange to the thread/history JSON
/// before it gets overwritten by a new round, then clear the current Answer. Only the
@@ -216,10 +383,11 @@ namespace GerbilManagerWebAPI.Endpoints
}
}
private static FeedbackDto ToDto(Feedback f) =>
private static FeedbackDto ToDto(Feedback f, IReadOnlyList<FeedbackAttachmentDto>? attachments = null) =>
new(f.Id, f.Message, f.Context, f.GerbilId, f.LitterId, f.ContactId, f.EntityName, f.Url,
f.ClientTimestamp, f.UserAgent, f.CreatedAt, f.Status, f.ResolvedAt,
f.Question, f.Answer, f.AnsweredAt, f.FixNote, f.AgentContext,
DeserializeThread(f.Thread));
DeserializeThread(f.Thread), f.ReopenedAt, f.DeletedAt, f.Category, f.Helpful,
attachments ?? Array.Empty<FeedbackAttachmentDto>());
}
}

View File

@@ -0,0 +1,74 @@
using GerbilManagerWebAPI.Models;
using GerbilManagerWebAPI.Push;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.EntityFrameworkCore;
namespace GerbilManagerWebAPI.Endpoints
{
/// <summary>
/// WEB-PUSH: PWA-Benachrichtigungen für die Züchterin.
/// GET /push/vapid-public-key -> öffentlicher VAPID-Schlüssel (für das Abonnieren im Browser).
/// POST /push/subscribe -> Browser-Abo speichern (idempotent über die Endpoint-URL).
/// POST /push/unsubscribe -> Abo entfernen.
/// Das eigentliche Senden passiert über <see cref="PushNotifier"/> (z. B. wenn die KI eine
/// Rückfrage stellt oder ein Ticket löst — gesteuert über das notify-Flag auf PUT /feedback).
/// </summary>
public static class PushEndpoints
{
public record PushSubscriptionInput(string Endpoint, string P256dh, string Auth);
public record PushUnsubscribeInput(string Endpoint);
public static IEndpointRouteBuilder MapPushEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/push").WithTags("Push");
group.MapGet("/vapid-public-key", (PushNotifier push) =>
TypedResults.Ok(new { publicKey = push.PublicKey, enabled = push.Enabled }));
group.MapPost("/subscribe", async Task<Results<Ok, BadRequest<string>>> (
PushSubscriptionInput input, ApplicationContext db) =>
{
if (string.IsNullOrWhiteSpace(input.Endpoint)
|| string.IsNullOrWhiteSpace(input.P256dh)
|| string.IsNullOrWhiteSpace(input.Auth))
return TypedResults.BadRequest("Endpoint, P256dh und Auth sind erforderlich.");
var existing = await db.WebPushSubscriptions
.FirstOrDefaultAsync(s => s.Endpoint == input.Endpoint);
if (existing is null)
{
db.WebPushSubscriptions.Add(new WebPushSubscription
{
Id = Guid.NewGuid(),
Endpoint = input.Endpoint,
P256dh = input.P256dh,
Auth = input.Auth,
CreatedAt = DateTimeOffset.UtcNow,
});
}
else
{
existing.P256dh = input.P256dh;
existing.Auth = input.Auth;
}
await db.SaveChangesAsync();
return TypedResults.Ok();
});
group.MapPost("/unsubscribe", async (PushUnsubscribeInput input, ApplicationContext db) =>
{
var subs = await db.WebPushSubscriptions
.Where(s => s.Endpoint == input.Endpoint)
.ToListAsync();
if (subs.Count > 0)
{
db.WebPushSubscriptions.RemoveRange(subs);
await db.SaveChangesAsync();
}
return TypedResults.NoContent();
});
return app;
}
}
}

View File

@@ -24,6 +24,7 @@
<PackageReference Include="Gridify.EntityFramework" Version="2.19.1" />
<PackageReference Include="QuestPDF" Version="2026.6.0" />
<PackageReference Include="Scalar.AspNetCore" Version="2.14.14" />
<PackageReference Include="WebPush" Version="1.0.13" />
</ItemGroup>
<ItemGroup>
@@ -35,6 +36,12 @@
<EmbeddedResource Include="Contracts\Templates\Abgabevertrag.docx" />
</ItemGroup>
<ItemGroup>
<!-- Manuelle RPRO3-Dubletten-Entscheidungen der Züchterin (Rpro3Decisions) — ins Output kopieren,
damit der Importer sie zur Laufzeit findet. -->
<Content Update="Import\Rpro3\rpro3-decisions.json" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
<ItemGroup>
<!-- FEAT-12a: Tests prüfen interne Bausteine (z. B. die Completions-URL). -->
<InternalsVisibleTo Include="GerbilManager.Tests" />

View File

@@ -0,0 +1,112 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace GerbilManagerWebAPI.Import.Rpro3
{
/// <summary>
/// Manuelle Entscheidungen der Züchterin zu mehrdeutigen RPRO3-Namensdubletten
/// (aus den „Fehler melden"-Tickets, Kontext „rpro3-import"). Der automatische
/// <see cref="Rpro3Dedup"/> ist konservativ und legt nur bei positiver Evidenz zusammen;
/// diese Overrides erlauben der Züchterin, gezielt zu korrigieren:
///
/// • <c>Same</c> — Gruppen von RPRO3-Nummern (rids), die DASSELBE Tier sind
/// (zwingt ein Merge über Varianten hinweg; ein rid je Variante genügt,
/// der ganze Cluster wird mitgezogen).
/// • <c>Different</c>— Gruppen, die VERSCHIEDENE Tiere sind (verhindert ein automatisches
/// Zusammenlegen über Gruppengrenzen).
/// • <c>Fields</c> — Feld-Korrekturen am resultierenden Tier (Farbe/Geburtsdatum/Herkunft/
/// Bestands-Flag/Zusatznotiz), adressiert über IRGENDEINE rid des Clusters.
///
/// Datenquelle: <c>Import/Rpro3/rpro3-decisions.json</c> (siehe <see cref="Load"/>).
/// Stabiler Schlüssel ist die RPRO3-Nummer (rid), nicht der Variantenbuchstabe — Buchstaben
/// verschieben sich, sobald sich die Clusterbildung ändert.
/// </summary>
public sealed class Rpro3Decisions
{
public List<Rpro3Decision> Decisions { get; set; } = new();
public static Rpro3Decisions Empty { get; } = new();
/// <summary>Lädt die Entscheidungen aus JSON; fehlt die Datei oder ist sie leer/kaputt,
/// kommt eine leere Menge zurück (Import läuft dann mit reinem Auto-Dedup).</summary>
public static Rpro3Decisions Load(string? path)
{
if (string.IsNullOrWhiteSpace(path) || !File.Exists(path))
return Empty;
try
{
var json = File.ReadAllText(path);
var d = JsonSerializer.Deserialize<Rpro3Decisions>(json, JsonOpts);
return d ?? Empty;
}
catch (JsonException)
{
return Empty;
}
}
private static readonly JsonSerializerOptions JsonOpts = new(JsonSerializerDefaults.Web)
{
ReadCommentHandling = JsonCommentHandling.Skip,
AllowTrailingCommas = true,
};
/// <summary>Alle „Same"-rid-Gruppen (für Force-Merge).</summary>
public IEnumerable<IReadOnlyList<string>> SameGroups()
{
foreach (var d in Decisions)
foreach (var g in d.Same)
if (g.Count >= 2) yield return g;
}
/// <summary>rid → numerische Split-Gruppe je Entscheidung. Zwei rids mit unterschiedlicher
/// Split-Gruppe (gleiche Entscheidung) dürfen NICHT automatisch zusammengelegt werden.</summary>
public Dictionary<string, int> BuildSplitGroups()
{
var map = new Dictionary<string, int>(StringComparer.Ordinal);
int gid = 0;
foreach (var d in Decisions)
{
if (d.Different.Count < 2) continue;
foreach (var grp in d.Different)
{
gid++;
foreach (var rid in grp)
map[rid] = gid;
}
}
return map;
}
/// <summary>rid → Feld-Override (jede rid eines Clusters zeigt auf denselben Override).</summary>
public Dictionary<string, Rpro3FieldOverride> BuildFieldIndex()
{
var map = new Dictionary<string, Rpro3FieldOverride>(StringComparer.Ordinal);
foreach (var d in Decisions)
foreach (var (rid, ov) in d.Fields)
map[rid] = ov;
return map;
}
}
public sealed class Rpro3Decision
{
/// <summary>Anzeigename (nur zur Lesbarkeit der JSON; Logik nutzt rids).</summary>
public string? Name { get; set; }
/// <summary>Ticket-Id (Rückverfolgbarkeit).</summary>
public string? Ticket { get; set; }
public List<List<string>> Same { get; set; } = new();
public List<List<string>> Different { get; set; } = new();
public Dictionary<string, Rpro3FieldOverride> Fields { get; set; } = new();
}
public sealed class Rpro3FieldOverride
{
public string? Color { get; set; }
public string? Dob { get; set; } // ISO yyyy-MM-dd
public string? Origin { get; set; }
public bool? Resident { get; set; }
/// <summary>Zusatz, der an die Notizen des Tiers angehängt wird (z. B. abweichendes DOB).</summary>
public string? Note { get; set; }
}
}

View File

@@ -65,9 +65,19 @@ namespace GerbilManagerWebAPI.Import.Rpro3
return UnknownValues.Contains(v) ? "" : v;
}
public static DedupResult Run(IReadOnlyList<Rpro3Animal> animals)
public static DedupResult Run(IReadOnlyList<Rpro3Animal> animals) => Run(animals, null);
/// <summary>
/// Wie <see cref="Run(IReadOnlyList{Rpro3Animal})"/>, berücksichtigt aber manuelle
/// Entscheidungen der Züchterin (<paramref name="decisions"/>): „Different"-Gruppen
/// verhindern automatisches Zusammenlegen, „Same"-Gruppen erzwingen ein Merge über
/// Varianten hinweg (jede rid zieht ihren ganzen Auto-Cluster mit).
/// </summary>
public static DedupResult Run(IReadOnlyList<Rpro3Animal> animals, Rpro3Decisions? decisions)
{
var result = new DedupResult();
decisions ??= Rpro3Decisions.Empty;
var splitGroup = decisions.BuildSplitGroups();
foreach (var a in animals)
{
@@ -123,12 +133,31 @@ namespace GerbilManagerWebAPI.Import.Rpro3
for (int j = i + 1; j < group.Count; j++)
{
var a = group[i]; var b = group[j];
// Force-Split: von der Züchterin als verschieden markierte Tiere nie zusammenlegen.
if (splitGroup.TryGetValue(a.Rid, out var ga) && splitGroup.TryGetValue(b.Rid, out var gb) && ga != gb)
continue;
bool comp = CompatDob(a.Dob, b.Dob) && Compat(a.FarbeKey, b.FarbeKey) && Compat(a.OriginKey, b.OriginKey);
if (comp && Positive(a, b) >= 1 && Conflict(a, b) == 0)
Union(a.Rid, b.Rid);
}
}
// Force-Merge: von der Züchterin als dasselbe Tier bestätigte Varianten zusammenlegen
// (eine rid je Variante genügt — Find/Union zieht den ganzen Auto-Cluster mit). Nur rids,
// die es im Datensatz auch gibt, werden berücksichtigt.
var knownRids = new HashSet<string>(animals.Select(a => a.Rid), StringComparer.Ordinal);
foreach (var grp in decisions.SameGroups())
{
string? anchor = null;
foreach (var rid in grp)
{
if (!knownRids.Contains(rid)) continue;
parent.TryAdd(rid, rid);
if (anchor is null) anchor = rid;
else Union(anchor, rid);
}
}
// Cluster sammeln
var clusters = new Dictionary<string, List<Rpro3Animal>>();
foreach (var a in animals)

View File

@@ -20,20 +20,30 @@ namespace GerbilManagerWebAPI.Import.Rpro3
{
private readonly ApplicationContext _db;
private readonly string _photoRoot;
private readonly Rpro3Decisions _decisions;
private Dictionary<string, Rpro3FieldOverride>? _fieldIndex;
public Rpro3ImportService(ApplicationContext db, IConfiguration config, IWebHostEnvironment? env)
{
_db = db;
var contentRoot = env?.ContentRootPath ?? Directory.GetCurrentDirectory();
_photoRoot = config["Photos:RootPath"] ?? Path.Combine(contentRoot, "photo-storage");
// Manuelle Dubletten-Entscheidungen der Züchterin (siehe Rpro3Decisions). Pfad
// überschreibbar per Config; Default: neben dem Importer-Code (wird mit ins Output kopiert).
var decPath = config["Rpro3:DecisionsPath"]
?? Path.Combine(contentRoot, "Import", "Rpro3", "rpro3-decisions.json");
_decisions = Rpro3Decisions.Load(decPath);
}
private Dictionary<string, Rpro3FieldOverride> FieldIndex =>
_fieldIndex ??= _decisions.BuildFieldIndex();
// ───────────────────────── ANALYZE ─────────────────────────
public async Task<Rpro3AnalyzeResult> AnalyzeAsync(
Rpro3Data data, bool photosProvided, IReadOnlySet<string>? availablePhotoFiles)
{
var dedup = Rpro3Dedup.Run(data.Animals);
var dedup = Rpro3Dedup.Run(data.Animals, _decisions);
var plan = BuildPlan(data, dedup);
// Abgleich gegen Bestand: Match über separator-insensitiven NameSearch + DOB-Toleranz.
@@ -122,7 +132,7 @@ namespace GerbilManagerWebAPI.Import.Rpro3
public async Task<Rpro3ExecuteResult> ExecuteAsync(
Rpro3Data data, string workDir, bool photosProvided, IReadOnlyDictionary<string, string>? photoSourcePaths)
{
var dedup = Rpro3Dedup.Run(data.Animals);
var dedup = Rpro3Dedup.Run(data.Animals, _decisions);
var plan = BuildPlan(data, dedup);
// Change-Tracker leeren: ExecuteDelete/Update umgehen den Tracker; bei wiederholtem
@@ -407,6 +417,19 @@ namespace GerbilManagerWebAPI.Import.Rpro3
var origin = members.Select(m => m.Origin).FirstOrDefault(o => Rpro3Dedup.NormValue(o).Length > 0) ?? rep.Origin;
var herkId = members.Select(m => m.OriginHerkId).FirstOrDefault(h => h is not null and not 1);
// Manuelle Feld-Korrektur der Züchterin (Rpro3Decisions): adressiert über IRGENDEINE
// rid des Clusters. Überschreibt das automatisch gewählte Feld; "Note" wird angehängt.
Rpro3FieldOverride? ov = null;
foreach (var m in members)
if (FieldIndex.TryGetValue(m.Rid, out ov)) break;
bool? residentOverride = ov?.Resident;
if (ov is not null)
{
if (!string.IsNullOrWhiteSpace(ov.Color)) farbe = ov.Color;
if (!string.IsNullOrWhiteSpace(ov.Origin)) origin = ov.Origin;
if (!string.IsNullOrWhiteSpace(ov.Dob) && DateOnly.TryParse(ov.Dob, out var od)) dob = od;
}
var gp = new GerbilPlan
{
Id = id,
@@ -419,7 +442,7 @@ namespace GerbilManagerWebAPI.Import.Rpro3
Genotype = CleanGenotype(fcode),
OriginBreeder = herkId == 1 ? "eigene Zucht" : (Rpro3Dedup.NormValue(origin).Length > 0 ? origin : null),
OriginContactId = HerkContactId(herkId),
IsResident = resident,
IsResident = residentOverride ?? resident,
IsCastrated = members.Any(m => m.IsCastrated),
MotherRid = rep.MidRaw,
FatherRid = rep.PidRaw,
@@ -440,6 +463,8 @@ namespace GerbilManagerWebAPI.Import.Rpro3
}
gp.Notes = BuildNotes(members);
if (!string.IsNullOrWhiteSpace(ov?.Note))
gp.Notes = string.IsNullOrWhiteSpace(gp.Notes) ? ov!.Note : $"{gp.Notes}\n{ov!.Note}";
gp.Provenance = BuildProvenance(data, members, dedup);
plan.Gerbils[id] = gp;
}

View File

@@ -0,0 +1,303 @@
{
"decisions": [
{
"name": "Eiji",
"ticket": "6aad4527-0401-43de-bda9-f649042bd1db",
"same": [
[
"u735",
"u469"
]
]
},
{
"name": "Momo",
"ticket": "7937b283-2056-435d-8821-c68ada8b1bd2",
"same": [
[
"u7453",
"u7345"
]
],
"different": [
[
"u1014"
],
[
"u7453"
]
]
},
{
"name": "Samuel",
"ticket": "6b3f28c6-47a5-4752-be94-93cda5d6d29c",
"same": [
[
"u2207",
"u263"
]
],
"different": [
[
"u2207"
],
[
"u144"
]
]
},
{
"name": "Female",
"ticket": "2d77eefe-f3a1-4be4-a8ee-7cb73cee14ab",
"different": [
[
"u649"
],
[
"u683"
],
[
"u647"
],
[
"u685"
]
]
},
{
"name": "Kennedy",
"ticket": "e5fc2c01-d443-445b-8530-ff5b86be27b1",
"same": [
[
"354",
"u6929"
]
],
"fields": {
"354": {
"color": "Blau, meliert-Starkschecke"
}
}
},
{
"name": "Fegur",
"ticket": "746a965b-a2e7-4d88-a497-640784b66818",
"same": [
[
"231",
"u6395"
]
]
},
{
"name": "Tuli",
"ticket": "06dd2e3d-5bed-469a-b975-d698712fd8ac",
"same": [
[
"229",
"u2255"
]
]
},
{
"name": "Bura",
"ticket": "7dbeb108-453d-4930-b615-a1054f0df8ad",
"same": [
[
"228",
"u2128"
]
],
"fields": {
"228": {
"origin": "Sarah Wörz"
}
}
},
{
"name": "Mister X",
"ticket": "ebd80367-a80c-426e-9541-1325274ba206",
"same": [
[
"227",
"u7065"
]
],
"fields": {
"227": {
"origin": "Sarah Wörz"
}
}
},
{
"name": "Zoey",
"ticket": "3ad458f7-645f-447f-b217-7b3b7382f380",
"different": [
[
"224"
],
[
"u2887"
]
],
"fields": {
"224": {
"origin": "Sarah Wörz"
}
}
},
{
"name": "Max",
"ticket": "625a86ab-51d8-4612-8292-5dbd93be2a5c",
"different": [
[
"133"
],
[
"u7533"
]
]
},
{
"name": "Akiro",
"ticket": "9216ab27-407d-4f4c-abb2-51160fa33683",
"same": [
[
"u1227",
"u1865"
]
],
"different": [
[
"u1339"
],
[
"u1227"
]
],
"fields": {
"u1227": {
"dob": "2008-02-05",
"color": "Polarfuchs, hell",
"note": "Abweichendes Geburtsdatum in RennmausPro: 2008-02-06 (laut Variante C)."
}
}
},
{
"name": "Merlin",
"ticket": "d748b16a-18b5-48ec-b8a6-a4c8df66ad80",
"same": [
[
"u2859",
"u2626"
],
[
"u7537",
"u7536"
]
],
"fields": {
"u7537": {
"dob": "2021-02-10",
"note": "Abweichendes Geburtsdatum in RennmausPro: 2012-08-07 (laut Variante G)."
}
}
},
{
"name": "Snickers",
"ticket": "f469352a-644b-43a1-9fb2-884600e4b9d3",
"same": [
[
"384",
"u6937"
]
],
"fields": {
"384": {
"resident": false,
"note": "Reiner Zuchtvorfahre (kein eigenes Zuchttier)."
}
}
},
{
"name": "Bali",
"ticket": "8c7246da-a9c6-406d-b857-8d9fcb9884f1",
"same": [
[
"u3410",
"u6997"
]
]
},
{
"name": "Aladin",
"ticket": "08be9baa-b0c8-4dd8-8fbe-daea1d647344",
"different": [
[
"u480"
],
[
"u1098"
]
]
},
{
"name": "Lexi",
"ticket": "16b449e8-50b1-4bed-b772-ab2c461a8984",
"different": [
[
"u275"
],
[
"u648"
]
]
},
{
"name": "Pinto",
"ticket": "08d00bca-70c0-4c14-88c8-0d3181313ba3",
"same": [
[
"385",
"u3709"
]
],
"fields": {
"385": {
"note": "Angaben/Schenkung von Variante A (eigenes Tier) übernehmen."
}
}
},
{
"name": "Jasper",
"ticket": "46951886-2e1c-40c8-a567-ca74e2a20de7",
"different": [
[
"351"
],
[
"u1289"
],
[
"u7492"
]
],
"fields": {
"u7492": {
"origin": "Clan of Colourful furry gerbils"
}
}
},
{
"name": "Nestor",
"ticket": "61b31c30-a24e-4dea-8ecb-9daeae5affd6",
"same": [
[
"200",
"u3459",
"u2634"
]
]
}
]
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,131 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace GerbilManagerWebAPI.Migrations
{
/// <inheritdoc />
public partial class Gen5FuchsschimmelHetSeedFix : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000034"),
column: "CanonicalGenotype",
value: "AA CC DD efe gg PP spsp rere");
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000035"),
column: "CanonicalGenotype",
value: "AA CC DD efe GG PP spsp rere");
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000036"),
column: "CanonicalGenotype",
value: "aa CC DD efe GG PP spsp rere");
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000037"),
column: "CanonicalGenotype",
value: "aa CC DD efe gg PP spsp rere");
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000040"),
column: "CanonicalGenotype",
value: "AA CC DD efe GG pp spsp rere");
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000043"),
column: "CanonicalGenotype",
value: "aa CC DD efe GG pp spsp rere");
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000045"),
column: "CanonicalGenotype",
value: "aa CC DD efe GG PP spsp rere");
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000050"),
column: "CanonicalGenotype",
value: "aa CC dd ee gg PP spsp rere");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000034"),
column: "CanonicalGenotype",
value: "AA CC DD efef gg PP spsp rere");
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000035"),
column: "CanonicalGenotype",
value: "AA CC DD efef GG PP spsp rere");
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000036"),
column: "CanonicalGenotype",
value: "aa CC DD efef GG PP spsp rere");
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000037"),
column: "CanonicalGenotype",
value: "aa CC DD efef gg PP spsp rere");
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000040"),
column: "CanonicalGenotype",
value: "AA CC DD efef GG pp spsp rere");
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000043"),
column: "CanonicalGenotype",
value: "aa CC DD efef GG pp spsp rere");
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000045"),
column: "CanonicalGenotype",
value: "aa CC DD efef GG PP spsp rere");
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000050"),
column: "CanonicalGenotype",
value: "aa CC dd ee gg pp spsp rere");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,39 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace GerbilManagerWebAPI.Migrations
{
/// <inheritdoc />
public partial class FeedbackReopenedAndSoftDelete : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<DateTimeOffset>(
name: "DeletedAt",
table: "Feedback",
type: "timestamp with time zone",
nullable: true);
migrationBuilder.AddColumn<DateTimeOffset>(
name: "ReopenedAt",
table: "Feedback",
type: "timestamp with time zone",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "DeletedAt",
table: "Feedback");
migrationBuilder.DropColumn(
name: "ReopenedAt",
table: "Feedback");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,38 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace GerbilManagerWebAPI.Migrations
{
/// <inheritdoc />
public partial class FeedbackCategoryAndHelpful : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "Category",
table: "Feedback",
type: "text",
nullable: true);
migrationBuilder.AddColumn<bool>(
name: "Helpful",
table: "Feedback",
type: "boolean",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Category",
table: "Feedback");
migrationBuilder.DropColumn(
name: "Helpful",
table: "Feedback");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,39 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace GerbilManagerWebAPI.Migrations
{
/// <inheritdoc />
public partial class FeedbackAttachments : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "FeedbackAttachments",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
FeedbackId = table.Column<Guid>(type: "uuid", nullable: false),
FileName = table.Column<string>(type: "text", nullable: false),
ContentType = table.Column<string>(type: "text", nullable: false),
Size = table.Column<int>(type: "integer", nullable: false),
Data = table.Column<byte[]>(type: "bytea", nullable: false),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_FeedbackAttachments", x => x.Id);
});
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "FeedbackAttachments");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,37 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace GerbilManagerWebAPI.Migrations
{
/// <inheritdoc />
public partial class WebPushSubscriptions : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "WebPushSubscriptions",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
Endpoint = table.Column<string>(type: "text", nullable: false),
P256dh = table.Column<string>(type: "text", nullable: false),
Auth = table.Column<string>(type: "text", nullable: false),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_WebPushSubscriptions", x => x.Id);
});
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "WebPushSubscriptions");
}
}
}

View File

@@ -483,28 +483,28 @@ namespace GerbilManagerWebAPI.Migrations
new
{
Id = new Guid("00000000-0000-0000-0000-000000000034"),
CanonicalGenotype = "AA CC DD efef gg PP spsp rere",
CanonicalGenotype = "AA CC DD efe gg PP spsp rere",
Name = "Polarfuchsschimmel",
SortOrder = 37
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000035"),
CanonicalGenotype = "AA CC DD efef GG PP spsp rere",
CanonicalGenotype = "AA CC DD efe GG PP spsp rere",
Name = "Algierfuchsschimmel",
SortOrder = 38
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000036"),
CanonicalGenotype = "aa CC DD efef GG PP spsp rere",
CanonicalGenotype = "aa CC DD efe GG PP spsp rere",
Name = "Kohlfuchsschimmel",
SortOrder = 39
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000037"),
CanonicalGenotype = "aa CC DD efef gg PP spsp rere",
CanonicalGenotype = "aa CC DD efe gg PP spsp rere",
Name = "Blaufuchsschimmel",
SortOrder = 40
},
@@ -525,7 +525,7 @@ namespace GerbilManagerWebAPI.Migrations
new
{
Id = new Guid("00000000-0000-0000-0000-000000000040"),
CanonicalGenotype = "AA CC DD efef GG pp spsp rere",
CanonicalGenotype = "AA CC DD efe GG pp spsp rere",
Name = "Goldfuchsschimmel",
SortOrder = 43
},
@@ -546,7 +546,7 @@ namespace GerbilManagerWebAPI.Migrations
new
{
Id = new Guid("00000000-0000-0000-0000-000000000043"),
CanonicalGenotype = "aa CC DD efef GG pp spsp rere",
CanonicalGenotype = "aa CC DD efe GG pp spsp rere",
Name = "Rotfuchsschimmel",
SortOrder = 46
},
@@ -560,7 +560,7 @@ namespace GerbilManagerWebAPI.Migrations
new
{
Id = new Guid("00000000-0000-0000-0000-000000000045"),
CanonicalGenotype = "aa CC DD efef GG PP spsp rere",
CanonicalGenotype = "aa CC DD efe GG PP spsp rere",
Name = "Kohlfuchsschimmel, hell",
SortOrder = 48
},
@@ -595,7 +595,7 @@ namespace GerbilManagerWebAPI.Migrations
new
{
Id = new Guid("00000000-0000-0000-0000-000000000050"),
CanonicalGenotype = "aa CC dd ee gg pp spsp rere",
CanonicalGenotype = "aa CC dd ee gg PP spsp rere",
Name = "Dilute Blaufuchs",
SortOrder = 53
},
@@ -893,6 +893,9 @@ namespace GerbilManagerWebAPI.Migrations
b.Property<DateTimeOffset?>("AnsweredAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Category")
.HasColumnType("text");
b.Property<DateTimeOffset?>("ClientTimestamp")
.HasColumnType("timestamp with time zone");
@@ -906,6 +909,9 @@ namespace GerbilManagerWebAPI.Migrations
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset?>("DeletedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("EntityName")
.HasColumnType("text");
@@ -915,6 +921,9 @@ namespace GerbilManagerWebAPI.Migrations
b.Property<Guid?>("GerbilId")
.HasColumnType("uuid");
b.Property<bool?>("Helpful")
.HasColumnType("boolean");
b.Property<Guid?>("LitterId")
.HasColumnType("uuid");
@@ -925,6 +934,9 @@ namespace GerbilManagerWebAPI.Migrations
b.Property<string>("Question")
.HasColumnType("text");
b.Property<DateTimeOffset?>("ReopenedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset?>("ResolvedAt")
.HasColumnType("timestamp with time zone");
@@ -948,6 +960,38 @@ namespace GerbilManagerWebAPI.Migrations
b.ToTable("Feedback");
});
modelBuilder.Entity("GerbilManagerWebAPI.Models.FeedbackAttachment", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("ContentType")
.IsRequired()
.HasColumnType("text");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<byte[]>("Data")
.IsRequired()
.HasColumnType("bytea");
b.Property<Guid>("FeedbackId")
.HasColumnType("uuid");
b.Property<string>("FileName")
.IsRequired()
.HasColumnType("text");
b.Property<int>("Size")
.HasColumnType("integer");
b.HasKey("Id");
b.ToTable("FeedbackAttachments");
});
modelBuilder.Entity("GerbilManagerWebAPI.Models.Gerbil", b =>
{
b.Property<Guid>("Id")
@@ -1602,6 +1646,32 @@ namespace GerbilManagerWebAPI.Migrations
b.ToTable("WaitingListEntries");
});
modelBuilder.Entity("GerbilManagerWebAPI.Models.WebPushSubscription", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Auth")
.IsRequired()
.HasColumnType("text");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Endpoint")
.IsRequired()
.HasColumnType("text");
b.Property<string>("P256dh")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.ToTable("WebPushSubscriptions");
});
modelBuilder.Entity("GerbilManagerWebAPI.Models.WeightRecord", b =>
{
b.Property<Guid>("Id")

View File

@@ -55,6 +55,33 @@ namespace GerbilManagerWebAPI.Models
/// <summary>When the ticket was marked resolved; null while open.</summary>
public DateTimeOffset? ResolvedAt { get; set; }
/// <summary>
/// When the ticket was reopened FROM a resolved state that had no pending Rückfrage —
/// i.e. a genuinely-fixed ticket the breeder wants reworked. Stays null for tickets that
/// merely had an open question, got marked resolved, and were reopened (those just return
/// to their existing Rückfrage). Shown as "Wieder geöffnet am" in the UI.
/// </summary>
public DateTimeOffset? ReopenedAt { get; set; }
/// <summary>
/// SOFT-DELETE: when the breeder deleted the ticket via the UI. Soft-deleted tickets are
/// NOT removed from the DB — they move into the "Gelöscht" category and can be restored
/// (DeletedAt → null) from there. null = not deleted.
/// </summary>
public DateTimeOffset? DeletedAt { get; set; }
/// <summary>
/// Optionale Kategorie/Thema (von der KI gesetzt) für Filter + Übersicht, z. B.
/// "Genetik", "Import", "Stammbaum", "Daten", "Foto". Frei wählbar (kein FK), null = ohne.
/// </summary>
public string? Category { get; set; }
/// <summary>
/// War die Lösung hilfreich? (Daumen hoch/runter auf gelösten Tickets.) true = 👍,
/// false = 👎 (löst i. d. R. ein Wiederöffnen aus), null = noch keine Rückmeldung.
/// </summary>
public bool? Helpful { get; set; }
/// <summary>A clarifying question (Rückfrage) a maintainer attaches to the ticket; null if none.</summary>
public string? Question { get; set; }

View File

@@ -0,0 +1,31 @@
using System.ComponentModel.DataAnnotations;
namespace GerbilManagerWebAPI.Models
{
/// <summary>
/// An image/file attached to a feedback ticket (z. B. ein Foto vom Tier/Fellschlag/Stammbaum).
/// Like <see cref="Feedback"/> it is decoupled (loose FeedbackId, no FK) so it survives the
/// import re-ingest wipe. The bytes live in the DB (single-user app, gelegentliche Fotos) —
/// die Liste GET /feedback liefert nur Metadaten, die Bytes kommen über einen eigenen Endpoint.
/// </summary>
public class FeedbackAttachment
{
[Key]
public Guid Id { get; set; }
/// <summary>Loose reference (no FK) to the feedback ticket this belongs to.</summary>
public Guid FeedbackId { get; set; }
public required string FileName { get; set; }
public required string ContentType { get; set; }
/// <summary>Größe in Bytes (separat gespeichert, damit Listen-Abfragen die Bytes nicht laden).</summary>
public int Size { get; set; }
/// <summary>The raw file bytes.</summary>
public required byte[] Data { get; set; }
public DateTimeOffset CreatedAt { get; set; }
}
}

View File

@@ -0,0 +1,26 @@
using System.ComponentModel.DataAnnotations;
namespace GerbilManagerWebAPI.Models
{
/// <summary>
/// A browser's Web-Push subscription (PWA-Benachrichtigungen). Single-user app, daher i. d. R.
/// nur wenige Einträge (ein Gerät der Züchterin). Endpoint ist eindeutig; veraltete Abos werden
/// beim Senden (404/410) automatisch entfernt.
/// </summary>
public class WebPushSubscription
{
[Key]
public Guid Id { get; set; }
/// <summary>Die vom Browser vergebene Push-Endpoint-URL (eindeutig).</summary>
public required string Endpoint { get; set; }
/// <summary>Öffentlicher Client-Schlüssel (keys.p256dh).</summary>
public required string P256dh { get; set; }
/// <summary>Auth-Secret des Clients (keys.auth).</summary>
public required string Auth { get; set; }
public DateTimeOffset CreatedAt { get; set; }
}
}

View File

@@ -71,6 +71,7 @@ builder.Services.AddScoped<GerbilManagerWebAPI.Inbox.IGmailMailReader, GerbilMan
builder.Services.AddScoped<GerbilManagerWebAPI.Inbox.RequestSyncService>();
builder.Services.AddScoped<GerbilManagerWebAPI.Inbox.IGmailMailSender, GerbilManagerWebAPI.Inbox.GmailMailSender>();
builder.Services.AddScoped<GerbilManagerWebAPI.Inbox.SendReplyService>();
builder.Services.AddSingleton<GerbilManagerWebAPI.Push.PushNotifier>();
var app = builder.Build();
@@ -127,6 +128,7 @@ app.MapCmsEndpoints();
app.MapRequestEndpoints();
app.MapNamesEndpoints();
app.MapFeedbackEndpoints();
app.MapPushEndpoints();
app.MapAcquisitionEndpoints();
app.MapSaleReservationEndpoints();
app.MapWaitingListEndpoints();

View File

@@ -0,0 +1,76 @@
using System.Text.Json;
using GerbilManagerWebAPI.Models;
using Microsoft.EntityFrameworkCore;
using WebPush;
namespace GerbilManagerWebAPI.Push
{
/// <summary>
/// Versendet Web-Push-Benachrichtigungen an alle gespeicherten Abos (PWA der Züchterin).
/// Ist kein VAPID-Schlüsselpaar konfiguriert, sind die Methoden No-Ops (Push deaktiviert).
/// Veraltete Abos (404/410) werden beim Senden entfernt.
/// </summary>
public class PushNotifier
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly ILogger<PushNotifier> _log;
private readonly VapidDetails? _vapid;
public PushNotifier(IConfiguration config, IServiceScopeFactory scopeFactory, ILogger<PushNotifier> log)
{
_scopeFactory = scopeFactory;
_log = log;
var subject = config["WebPush:Subject"];
var publicKey = config["WebPush:PublicKey"];
var privateKey = config["WebPush:PrivateKey"];
if (!string.IsNullOrWhiteSpace(subject)
&& !string.IsNullOrWhiteSpace(publicKey)
&& !string.IsNullOrWhiteSpace(privateKey))
{
_vapid = new VapidDetails(subject, publicKey, privateKey);
}
}
/// <summary>true, wenn Push konfiguriert ist (VAPID-Schlüssel vorhanden).</summary>
public bool Enabled => _vapid is not null;
public string? PublicKey => _vapid?.PublicKey;
/// <summary>Eine Benachrichtigung an ALLE Abos senden. Fehler einzelner Abos werden geschluckt.</summary>
public async Task NotifyAllAsync(string title, string body, string url, CancellationToken ct = default)
{
if (_vapid is null) return;
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationContext>();
var subs = await db.WebPushSubscriptions.ToListAsync(ct);
if (subs.Count == 0) return;
var client = new WebPushClient();
var payload = JsonSerializer.Serialize(new { title, body, url });
var stale = new List<WebPushSubscription>();
foreach (var s in subs)
{
try
{
var pushSub = new WebPush.PushSubscription(s.Endpoint, s.P256dh, s.Auth);
await client.SendNotificationAsync(pushSub, payload, _vapid);
}
catch (WebPushException ex) when (ex.StatusCode is System.Net.HttpStatusCode.NotFound
or System.Net.HttpStatusCode.Gone)
{
stale.Add(s); // Abo abgelaufen/abgemeldet -> aufräumen
}
catch (Exception ex)
{
_log.LogWarning(ex, "Push an {Endpoint} fehlgeschlagen", s.Endpoint);
}
}
if (stale.Count > 0)
{
db.WebPushSubscriptions.RemoveRange(stale);
await db.SaveChangesAsync(ct);
}
}
}
}

View File

@@ -5,5 +5,10 @@
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
"AllowedHosts": "*",
"WebPush": {
"Subject": "mailto:zucht-kleine-chaoten@example.com",
"PublicKey": "BGRqOfadYjRZFJaFYjPq2cThD7MpMHJHiRPrv9ZGSHJAl5eeqeCCZHj4I4_h-RcrjCF2MXhN34dI_RcACz36qnk",
"PrivateKey": "_HJTA1qtsJ-azLAaRFyxJqmJCym2qBQaSbYFCLjVSAA"
}
}

View File

@@ -431,12 +431,71 @@ export async function installMockApi(page: Page): Promise<MockDb> {
fixNote: null,
agentContext: null,
thread: [],
reopenedAt: null,
deletedAt: null,
category: null,
helpful: null,
attachments: [],
}
db.feedback.push(created)
return json(route, 201, created)
}
if (method === 'GET') {
return json(route, 200, [...db.feedback].reverse())
// attachments immer als Array liefern (Mock-Daten haben das Feld evtl. nicht).
const rows = [...db.feedback].reverse().map((f) => ({ ...f, attachments: f.attachments ?? [] }))
return json(route, 200, rows)
}
return json(route, 405)
}
// WEB-PUSH: im Mock deaktiviert (keine VAPID-Schlüssel) → PushToggle blendet sich aus.
if (path === '/push/vapid-public-key' && method === 'GET') {
return json(route, 200, { enabled: false, publicKey: null })
}
if ((path === '/push/subscribe' || path === '/push/unsubscribe') && method === 'POST') {
return json(route, 200, {})
}
// FEEDBACK-ANHÄNGE: hochladen (POST /feedback/{id}/attachments).
const attUpload = path.match(/^\/feedback\/([^/]+)\/attachments$/)
if (attUpload && method === 'POST') {
const fid = decodeURIComponent(attUpload[1])
const row = db.feedback.find((f) => f.id === fid)
if (!row) return json(route, 404, { title: 'Not Found' })
const body = request.postDataJSON() as { fileName?: string; contentType?: string; dataBase64?: string }
const meta = {
id: newId('att'),
fileName: body.fileName ?? 'datei',
contentType: body.contentType ?? 'application/octet-stream',
size: (body.dataBase64 ?? '').length,
}
const list = (row.attachments as unknown[] | undefined) ?? []
list.push(meta)
row.attachments = list
return json(route, 201, meta)
}
// FEEDBACK-ANHÄNGE: löschen (DELETE /feedback/attachments/{attId}).
const attDelete = path.match(/^\/feedback\/attachments\/([^/]+)$/)
if (attDelete && method === 'DELETE') {
const attId = decodeURIComponent(attDelete[1])
for (const f of db.feedback) {
const list = (f.attachments as { id: string }[] | undefined) ?? []
const i = list.findIndex((a) => a.id === attId)
if (i >= 0) {
list.splice(i, 1)
f.attachments = list
return json(route, 204)
}
}
return json(route, 404, { title: 'Not Found' })
}
// FEEDBACK-TICKETS: Wiederherstellen aus dem Papierkorb (Soft-Delete aufheben).
const restoreMatch = path.match(/^\/feedback\/([^/]+)\/restore$/)
if (restoreMatch) {
const fid = decodeURIComponent(restoreMatch[1])
const row = db.feedback.find((f) => f.id === fid)
if (!row) return json(route, 404, { title: 'Not Found' })
if (method === 'POST') {
row.deletedAt = null
return json(route, 200, row)
}
return json(route, 405)
}
@@ -454,8 +513,13 @@ export async function installMockApi(page: Page): Promise<MockDb> {
answer?: string
fixNote?: string
agentContext?: string
category?: string
helpful?: boolean
}
const row = db.feedback[idx]
// Zustand vor den Mutationen (für die Wiederöffnen-Erkennung).
const wasResolved = row.status === 'Resolved'
const hadOpenRueckfrage = typeof row.question === 'string' && !!(row.question as string).trim()
if (typeof body.message === 'string') {
if (!body.message.trim()) return json(route, 400, 'Message darf nicht leer sein.')
row.message = body.message.trim()
@@ -504,6 +568,10 @@ export async function installMockApi(page: Page): Promise<MockDb> {
? s
: 'Open'
row.resolvedAt = resolved ? (row.resolvedAt ?? new Date().toISOString()) : null
// „Wieder geöffnet am" nur, wenn ein echt gelöstes Ticket OHNE offene Rückfrage
// wieder geöffnet wird (s. Backend).
if (!resolved && wasResolved && !hadOpenRueckfrage)
row.reopenedAt = new Date().toISOString()
}
// Changelog (fixNote) — laienverständlich, sichtbar.
if (typeof body.fixNote === 'string') {
@@ -515,10 +583,17 @@ export async function installMockApi(page: Page): Promise<MockDb> {
const c = body.agentContext.trim()
row.agentContext = c.length === 0 ? null : c
}
if (typeof body.category === 'string') {
const cat = body.category.trim()
row.category = cat.length === 0 ? null : cat
}
if (typeof body.helpful === 'boolean') row.helpful = body.helpful
return json(route, 200, row)
}
if (method === 'DELETE') {
db.feedback.splice(idx, 1)
// Soft-Delete: in den Papierkorb verschieben (nicht entfernen).
const target = db.feedback[idx]
if (!target.deletedAt) target.deletedAt = new Date().toISOString()
return json(route, 204)
}
return json(route, 405)

View File

@@ -442,6 +442,7 @@ export function seedDb(): MockDb {
userAgent: null,
createdAt: '2026-06-10T09:00:00Z',
status: 'Resolved',
category: 'Stammbaum',
resolvedAt: '2026-06-12T14:00:00Z',
question: null,
answer: null,
@@ -480,6 +481,29 @@ export function seedDb(): MockDb {
{ role: 'breeder', text: 'Im Juni.', at: '2026-06-13T18:00:00Z' },
],
},
{
// Beantwortetes Ticket (Answered): die KI hat noch NICHT reagiert. Der Erst-Text ist
// jetzt gesperrt (kein „Bearbeiten"), nur die neueste Antwort ist noch änderbar.
id: 'feedback-answered',
message: 'Bei Karlsson stimmt der Farbschlag nicht.',
context: 'gerbil-detail',
gerbilId: 'karlsson',
litterId: null,
contactId: null,
entityName: 'Karlsson',
url: 'http://localhost:5173/rennmaeuse/karlsson',
clientTimestamp: '2026-06-16T09:00:00Z',
userAgent: null,
createdAt: '2026-06-16T09:00:00Z',
status: 'Answered',
resolvedAt: null,
question: 'Welcher Farbschlag ist korrekt?',
answer: 'Karlsson ist Blau, nicht Schwarz.',
answeredAt: '2026-06-16T18:00:00Z',
fixNote: null,
agentContext: null,
thread: [],
},
{
id: 'feedback-open',
message: 'Das Gewicht wird auf der Verlaufskurve falsch gerundet.',

View File

@@ -0,0 +1,128 @@
/**
* Scroll-Wiederherstellung (useScrollRestoration im AppShell).
*
* Regression: Liste runterscrollen → Eintrag öffnen → Browser-Zurück → die Position
* muss erhalten bleiben (vorher sprang sie auf 0, weil beim Wegnavigieren der kollabierende
* Inhalt ein scroll→0 auslöste, das die gemerkte Position überschrieb).
*
* Gescrollt wird der Container `.app-main` (nicht das Fenster). Läuft in beiden Projekten
* (desktop + phone) — die Rennmausliste ist in beiden Viewports hoch genug zum Scrollen.
*/
import { test, expect, skipUnlessMock, de } from './fixtures'
const SCROLLER = '.app-main'
async function scrollerTop(page: import('@playwright/test').Page) {
return page.evaluate((sel) => document.querySelector(sel)?.scrollTop ?? 0, SCROLLER)
}
/**
* Realistisch runterscrollen: ein Warm-up-Schritt (synchronisiert die gemerkte Höhe nach
* dem ersten Render) + der eigentliche Schritt — wie echtes Scrollen, das viele Events feuert.
*/
async function scrollDown(page: import('@playwright/test').Page, target: number) {
await page.evaluate((sel) => {
const el = document.querySelector(sel)
if (el) el.scrollTop = 60
}, SCROLLER)
await page.waitForTimeout(80)
await page.evaluate(
([sel, t]) => {
const el = document.querySelector(sel as string)
if (el) el.scrollTop = Math.min(t as number, el.scrollHeight - el.clientHeight)
},
[SCROLLER, target] as const,
)
await page.waitForTimeout(150) // rAF-gedrosseltes Speichern abwarten
}
test.describe('Scroll-Wiederherstellung', () => {
test.beforeEach(() => skipUnlessMock())
test('Rennmausliste: Position überlebt Detail-Öffnen + Browser-Zurück', async ({ page }) => {
await page.goto('/rennmaeuse')
// Warten bis Tierkarten-Links da sind.
await expect(page.locator('a[href^="/rennmaeuse/"]').first()).toBeVisible()
// Container muss scrollbar sein, sonst ist der Test sinnlos.
const scrollable = await page.evaluate((sel) => {
const el = document.querySelector(sel)
return el ? el.scrollHeight > el.clientHeight + 50 : false
}, SCROLLER)
expect(scrollable, 'Liste muss scrollbar sein').toBeTruthy()
// Ein Stück runterscrollen.
await scrollDown(page, 400)
const before = await scrollerTop(page)
expect(before).toBeGreaterThan(50)
// Einen AKTUELL SICHTBAREN Tier-Link öffnen (kein Auto-Scroll, wie ein echter Klick).
const href = await page.evaluate(() => {
for (const a of document.querySelectorAll('a[href^="/rennmaeuse/"]')) {
const r = a.getBoundingClientRect()
if (r.top >= 0 && r.bottom <= window.innerHeight && a.getAttribute('href') !== '/rennmaeuse/neu') {
;(a as HTMLElement).click()
return a.getAttribute('href')
}
}
return null
})
expect(href).toBeTruthy()
await expect(page).toHaveURL(/\/rennmaeuse\/[^/]+$/)
// Zurück — Position muss (nahezu) wiederhergestellt sein.
await page.goBack()
await expect(page.locator('a[href^="/rennmaeuse/"]').first()).toBeVisible()
await expect
.poll(async () => scrollerTop(page), { timeout: 4000, message: 'Scrollposition wiederhergestellt' })
.toBeGreaterThan(before - 30)
})
test('Rennmausliste: Position überlebt App-Hintergrund (visibilitychange)', async ({ page }) => {
await page.goto('/rennmaeuse')
await expect(page.locator('a[href^="/rennmaeuse/"]').first()).toBeVisible()
await scrollDown(page, 400)
const before = await scrollerTop(page)
test.skip(before < 50, 'Liste in diesem Viewport nicht hoch genug')
// App in den Hintergrund (Handy-Sperre) und zurück simulieren.
await page.evaluate(() => {
Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => 'hidden' })
document.dispatchEvent(new Event('visibilitychange'))
})
// Inhalt „springt" (wie beim Aufwachen) künstlich nach oben …
await page.evaluate((sel) => {
const el = document.querySelector(sel)
if (el) el.scrollTop = 0
}, SCROLLER)
await page.evaluate(() => {
Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => 'visible' })
document.dispatchEvent(new Event('visibilitychange'))
})
// … und muss wiederhergestellt werden.
await expect
.poll(async () => scrollerTop(page), { timeout: 4000 })
.toBeGreaterThan(before - 30)
})
test('Tickets: gewählter Tab überlebt Browser-Zurück (nicht zurück auf „Offen")', async ({ page }) => {
const tt = de.feedback.tickets
await page.goto('/hilfe/tickets')
// In die „Geschlossen"-Ansicht wechseln.
await page.locator('.tickets-filter').filter({ hasText: tt.filters.closed }).click()
await expect(
page.locator('.tickets-filter--active').filter({ hasText: tt.filters.closed }),
).toBeVisible()
// Über die Brotkrümel weg und per Browser-Zurück wieder her.
await page.locator('.tickets-breadcrumb a[href="/hilfe"]').click()
await expect(page).toHaveURL(/\/hilfe$/)
await page.goBack()
// Tab muss weiterhin „Geschlossen" sein (vorher sprang er zurück auf „Offen").
await expect(
page.locator('.tickets-filter--active').filter({ hasText: tt.filters.closed }),
).toBeVisible()
})
})

View File

@@ -38,7 +38,8 @@ test.describe('Meine Tickets', () => {
const open = page.locator('.tickets-filter').filter({ hasText: tt.filters.open })
const closed = page.locator('.tickets-filter').filter({ hasText: tt.filters.closed })
await expect(dialog.locator('.tickets-filter__count')).toHaveText('1')
await expect(open.locator('.tickets-filter__count')).toHaveText('1')
// „Offen" = Open + Answered → das offene + das beantwortete Ticket = 2.
await expect(open.locator('.tickets-filter__count')).toHaveText('2')
await expect(closed.locator('.tickets-filter__count')).toHaveText('1')
// „Offen": nur das offene Ticket.
@@ -131,6 +132,20 @@ test.describe('Meine Tickets', () => {
await expect(page.getByText('Korrigierte Beschreibung des Fehlers.')).toBeVisible()
})
test('beantwortetes Ticket: Erst-Text gesperrt, nur die neueste Antwort ist änderbar', async ({
page,
}) => {
await page.goto('/hilfe/tickets')
// „Offen"-Ansicht (Default) zeigt auch beantwortete Tickets (Answered).
const card = page.locator('.ticket-card').filter({ hasText: 'Karlsson ist Blau' })
await expect(card).toBeVisible()
// Erst-Text ist gesperrt: kein „Bearbeiten"-Button mehr, sobald es eine Antwort gibt.
await expect(card.getByRole('button', { name: tt.edit })).toHaveCount(0)
// Die neueste Antwort ist dagegen noch änderbar (KI hat noch nicht reagiert → Answered).
await expect(card.getByRole('button', { name: tt.amendAnswer })).toBeVisible()
})
test('Rückfrage beantworten: Züchterin sieht Frage, antwortet, Badge wird „Beantwortet"', async ({
page,
}) => {
@@ -173,7 +188,9 @@ test.describe('Meine Tickets', () => {
await expect(card.locator('.ticket-card__answer-form textarea')).toBeVisible()
})
test('Ticket löschen', async ({ page }) => {
test('Ticket löschen = Soft-Delete: wandert in den Papierkorb, mit Countdown + Wiederherstellen', async ({
page,
}) => {
await page.goto('/hilfe/tickets')
await selectView(page, tt.filters.closed)
@@ -185,8 +202,134 @@ test.describe('Meine Tickets', () => {
acceptNextDialog(page)
await card.getByRole('button', { name: tt.delete }).click()
// Aus „Geschlossen" verschwunden …
await expect(
page.getByText('Der Stammbaum zeigt den falschen Vater bei Krümel.'),
).toHaveCount(0)
// … aber im Papierkorb („Gelöscht") vorhanden, mit Countdown + Wiederherstellen-Button.
await selectView(page, tt.filters.deleted)
const trashed = page
.locator('.ticket-card')
.filter({ hasText: 'Der Stammbaum zeigt den falschen Vater bei Krümel.' })
await expect(trashed).toBeVisible()
await expect(trashed.locator('.ticket-card__trash-notice')).toBeVisible()
await expect(trashed.getByText('endgültig gelöscht')).toBeVisible()
// Wiederherstellen → zurück aus dem Papierkorb (kehrt in „Geschlossen" zurück).
await trashed.getByRole('button', { name: tt.restore }).click()
await expect(
page.locator('.ticket-card').filter({ hasText: 'Der Stammbaum zeigt den falschen Vater bei Krümel.' }),
).toHaveCount(0)
await selectView(page, tt.filters.closed)
await expect(
page.getByText('Der Stammbaum zeigt den falschen Vater bei Krümel.'),
).toBeVisible()
})
test('Wiederöffnen eines gelösten Tickets ohne Rückfrage → Rückfragen + Bitte um Infos + Zeitstempel', async ({
page,
}) => {
await page.goto('/hilfe/tickets')
await selectView(page, tt.filters.closed)
// „Krümel"-Ticket ist Resolved und hatte KEINE Rückfrage (question === null).
const card = page
.locator('.ticket-card')
.filter({ hasText: 'Der Stammbaum zeigt den falschen Vater bei Krümel.' })
await card.getByRole('button', { name: tt.reopen }).click()
// Landet in den „Rückfragen" mit der Bitte um mehr Infos + „Wieder geöffnet am".
await selectView(page, tt.filters.dialog)
const reopened = page
.locator('.ticket-card')
.filter({ hasText: 'Der Stammbaum zeigt den falschen Vater bei Krümel.' })
await expect(reopened.locator('.ticket-badge--needsinfo')).toBeVisible()
await expect(reopened.getByText(tt.reopenRequest)).toBeVisible()
await expect(reopened.getByText(tt.reopenedLabel)).toBeVisible()
})
test('Volltextsuche filtert die Ticket-Liste', async ({ page }) => {
await page.goto('/hilfe/tickets')
await selectView(page, tt.filters.closed)
const search = page.getByPlaceholder(tt.searchPlaceholder)
await search.fill('Krümel')
await expect(
page.getByText('Der Stammbaum zeigt den falschen Vater bei Krümel.'),
).toBeVisible()
await search.fill('xyzgibtsnicht')
await expect(page.getByText(tt.emptySearch)).toBeVisible()
})
test('Kategorie-Filter zeigt nur Tickets der gewählten Kategorie', async ({ page }) => {
await page.goto('/hilfe/tickets')
await selectView(page, tt.filters.closed)
// Das Krümel-Ticket hat die Kategorie „Stammbaum" → als Chip sichtbar.
const card = page
.locator('.ticket-card')
.filter({ hasText: 'Der Stammbaum zeigt den falschen Vater bei Krümel.' })
await expect(card.locator('.ticket-card__category')).toHaveText('Stammbaum')
// Über das Kategorie-Menü filtern.
await page.getByLabel(tt.categoryFilterLabel).selectOption('Stammbaum')
await expect(
page.getByText('Der Stammbaum zeigt den falschen Vater bei Krümel.'),
).toBeVisible()
})
test('👍 auf gelöstem Ticket speichert Rückmeldung; 👎 öffnet es wieder', async ({ page }) => {
await page.goto('/hilfe/tickets')
await selectView(page, tt.filters.closed)
const card = page
.locator('.ticket-card')
.filter({ hasText: 'Der Stammbaum zeigt den falschen Vater bei Krümel.' })
await expect(card.getByText(tt.helpfulQuestion)).toBeVisible()
// 👍 → Dank-Text, kein Wiederöffnen.
await card.getByRole('button', { name: tt.helpfulYes }).click()
await expect(card.getByText(tt.helpfulThanks)).toBeVisible()
})
test('👎 auf gelöstem Ticket öffnet es als Rückfrage wieder', async ({ page }) => {
await page.goto('/hilfe/tickets')
await selectView(page, tt.filters.closed)
const card = page
.locator('.ticket-card')
.filter({ hasText: 'Der Stammbaum zeigt den falschen Vater bei Krümel.' })
await card.getByRole('button', { name: tt.helpfulNo }).click()
// Verlässt „Geschlossen", landet in „Rückfragen" mit Bitte um Infos.
await selectView(page, tt.filters.dialog)
const reopened = page
.locator('.ticket-card')
.filter({ hasText: 'Der Stammbaum zeigt den falschen Vater bei Krümel.' })
await expect(reopened.locator('.ticket-badge--needsinfo')).toBeVisible()
await expect(reopened.getByText(tt.helpfulReopenNote)).toBeVisible()
})
test('Foto an ein Ticket anhängen erscheint als Vorschaubild', async ({ page }) => {
await page.goto('/hilfe/tickets')
// Offenes Ticket (Fridolin) — Anhang-Bereich mit „Foto anhängen".
const card = page.locator('.ticket-card').filter({ hasText: 'Fridolin' })
await expect(card.getByText(tt.attachmentsLabel, { exact: true })).toBeVisible()
// 1×1-PNG als Datei hochladen.
const png = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==',
'base64',
)
await card.locator('input[type="file"]').setInputFiles({
name: 'maus.png',
mimeType: 'image/png',
buffer: png,
})
// Nach dem Upload erscheint ein Vorschaubild im Anhang-Raster.
await expect(card.locator('.ticket-card__attachment img')).toHaveCount(1)
})
})

View File

@@ -0,0 +1,49 @@
/*
* Service Worker — NUR für Web-Push-Benachrichtigungen.
* BEWUSST OHNE fetch-Handler/Caching, damit das Laden der App niemals beeinflusst wird.
*/
self.addEventListener('install', () => self.skipWaiting())
self.addEventListener('activate', (event) => event.waitUntil(self.clients.claim()))
self.addEventListener('push', (event) => {
let data = {}
try {
data = event.data ? event.data.json() : {}
} catch {
data = {}
}
const title = data.title || 'Zucht der kleinen Chaoten'
const body = data.body || ''
const url = data.url || '/hilfe/tickets'
event.waitUntil(
self.registration.showNotification(title, {
body,
icon: '/icon-192.png',
badge: '/icon-192.png',
data: { url },
}),
)
})
self.addEventListener('notificationclick', (event) => {
event.notification.close()
const url = (event.notification.data && event.notification.data.url) || '/hilfe/tickets'
event.waitUntil(
self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clients) => {
for (const client of clients) {
if ('focus' in client) {
if ('navigate' in client) {
try {
client.navigate(url)
} catch {
/* ignorieren */
}
}
return client.focus()
}
}
if (self.clients.openWindow) return self.clients.openWindow(url)
return undefined
}),
)
})

View File

@@ -3,11 +3,18 @@ import { de } from '../strings/de'
/**
* Basis-URL der GerbilManagerWebAPI.
* Dev (Vite dev server / e2e mock): absolute URL, damit Playwright-Route-Interception greift.
* Der Host wird aus der AKTUELLEN Adresse abgeleitet (window.location.hostname), damit der
* Zugriff vom Handy/anderen LAN-Gerät funktioniert: lädt die Seite von 192.168.x.y:5173,
* geht die API an 192.168.x.y:5179 (nicht „localhost", was auf dem Handy das Handy selbst wäre).
* Auf dem Rechner/in e2e ist der Host „localhost" → unverändert http://localhost:5179.
* Produktion (Vite build → nginx): relativer Pfad /api; nginx proxyt zum API-Container.
* Aspire (VITE_API_BASE_URL gesetzt): überschreibt immer (LAN-IP + Port für Handy-Zugriff).
* Aspire (VITE_API_BASE_URL gesetzt): überschreibt immer.
*/
const devApiBase = `http://${
typeof window !== 'undefined' && window.location.hostname ? window.location.hostname : 'localhost'
}:5179`
export const API_BASE_URL: string =
import.meta.env.VITE_API_BASE_URL ?? (import.meta.env.PROD ? '/api' : 'http://localhost:5179')
import.meta.env.VITE_API_BASE_URL ?? (import.meta.env.PROD ? '/api' : devApiBase)
export class ApiError extends Error {
readonly status: number | null

View File

@@ -1,8 +1,16 @@
/** FEEDBACK: API client for the "Fehler melden" report sink (POST /feedback). */
import { api } from './client'
import { api, API_BASE_URL } from './client'
const RESOURCE = '/feedback'
/** Metadaten eines Ticket-Anhangs (Bytes separat über attachmentUrl). */
export interface FeedbackAttachment {
id: string
fileName: string
contentType: string
size: number
}
/** Which view a report was filed from (matches the backend Context contract). */
export type FeedbackContext = 'stammbaum' | 'gerbil-detail' | 'litter-detail' | 'contact-detail'
@@ -50,6 +58,22 @@ export interface Feedback {
createdAt: string
status: FeedbackStatus
resolvedAt: string | null
/**
* Zeitpunkt des Wiederöffnens — gesetzt, wenn ein ECHT gelöstes Ticket (ohne offene
* Rückfrage) wieder geöffnet wurde; null sonst. Wird als „Wieder geöffnet am" angezeigt.
*/
reopenedAt: string | null
/**
* Soft-Delete-Marker: gesetzt, wenn das Ticket über die UI gelöscht wurde (wiederherstellbar).
* Solche Tickets erscheinen nur in der „Gelöscht"-Kategorie. null = nicht gelöscht.
*/
deletedAt: string | null
/** Optionale Kategorie/Thema (von der KI gesetzt), z. B. „Genetik", „Import". null = ohne. */
category: string | null
/** War die Lösung hilfreich? true=👍, false=👎, null=keine Rückmeldung. */
helpful: boolean | null
/** Angehängte Dateien/Fotos (nur Metadaten; Bytes über attachmentUrl laden). */
attachments: FeedbackAttachment[]
/** Rückfrage einer/eines Betreuenden an die Züchterin (falls vorhanden). */
question: string | null
/** Antwort der Züchterin auf die Rückfrage (falls vorhanden). */
@@ -85,6 +109,10 @@ export interface FeedbackUpdate {
fixNote?: string
/** INTERN: Arbeitsgedächtnis des Agenten (ändert den Status nicht). */
agentContext?: string
/** Kategorie/Thema setzen (leer ⇒ löschen). */
category?: string
/** 👍/👎-Rückmeldung auf ein gelöstes Ticket. */
helpful?: boolean
}
export function submitFeedback(body: FeedbackInput): Promise<Feedback> {
@@ -106,7 +134,50 @@ export function answerTicket(id: string, answer: string): Promise<Feedback> {
return updateFeedback(id, { answer })
}
/** Delete a ticket. */
/** Soft-delete a ticket (moves it into the "Gelöscht" category; recoverable via restore). */
export function deleteFeedback(id: string): Promise<void> {
return api.delete(`${RESOURCE}/${id}`)
}
/** Restore a soft-deleted ticket (DeletedAt → null); it returns to its prior status. */
export function restoreFeedback(id: string): Promise<Feedback> {
return api.post<Feedback>(`${RESOURCE}/${id}/restore`, {})
}
/** Volle URL zu den Bytes eines Anhangs (für <img src> / Download). */
export function attachmentUrl(attachmentId: string): string {
return `${API_BASE_URL}${RESOURCE}/attachments/${attachmentId}`
}
/** Einen Anhang (base64) an ein Ticket hochladen. */
export function uploadAttachment(
feedbackId: string,
body: { fileName: string; contentType: string; dataBase64: string },
): Promise<FeedbackAttachment> {
return api.post<FeedbackAttachment>(`${RESOURCE}/${feedbackId}/attachments`, body)
}
/** Einen Anhang löschen. */
export function deleteAttachment(attachmentId: string): Promise<void> {
return api.delete(`${RESOURCE}/attachments/${attachmentId}`)
}
/** Eine Browser-Datei als Upload-Payload (base64) einlesen. */
export function readFileAsUpload(
file: File,
): Promise<{ fileName: string; contentType: string; dataBase64: string }> {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => {
const result = String(reader.result)
const comma = result.indexOf(',')
resolve({
fileName: file.name,
contentType: file.type || 'application/octet-stream',
dataBase64: comma >= 0 ? result.slice(comma + 1) : result,
})
}
reader.onerror = () => reject(reader.error)
reader.readAsDataURL(file)
})
}

View File

@@ -4,6 +4,7 @@ import { NavLink, Outlet } from 'react-router-dom'
import { de } from '../strings/de'
import GerbilIcon from './GerbilIcon'
import { BreederSuffixProvider } from './BreederSuffixProvider'
import { useScrollRestoration } from '../hooks/useScrollRestoration'
import './appShell.css'
interface NavItem {
@@ -52,6 +53,9 @@ const linkClass = ({ isActive }: { isActive: boolean }) =>
*/
export default function AppShell() {
const [moreOpen, setMoreOpen] = useState(false)
// Scrollposition je Seite erhalten (Zurück-Navigation + Handy-Sperre/Hintergrund) — gilt für
// ALLE scrollbaren Listen der App.
useScrollRestoration()
return (
<div className="app-shell">

View File

@@ -0,0 +1,64 @@
/**
* Schalter „Benachrichtigungen aktivieren" (Web-Push / PWA).
* Rendert nichts, wenn Push nicht unterstützt wird ODER der Server keine VAPID-Schlüssel hat
* (z. B. im e2e-Mock) — so bleibt die UI dort unverändert.
*/
import { useEffect, useState } from 'react'
import { de } from '../strings/de'
import { useToast } from './toast'
import { disablePush, enablePush, fetchPushConfig, isPushSupported, isSubscribed } from '../push'
export default function PushToggle() {
const t = de.feedback.tickets
const toast = useToast()
const [available, setAvailable] = useState(false)
const [subscribed, setSubscribed] = useState(false)
const [busy, setBusy] = useState(false)
useEffect(() => {
let cancelled = false
;(async () => {
if (!isPushSupported()) return
const cfg = await fetchPushConfig()
if (cancelled || !cfg.enabled) return
setAvailable(true)
setSubscribed(await isSubscribed())
})()
return () => {
cancelled = true
}
}, [])
if (!available) return null
async function toggle() {
setBusy(true)
try {
if (subscribed) {
await disablePush()
setSubscribed(false)
toast.success(t.pushDisabledToast)
} else {
const res = await enablePush()
if (res === 'enabled') {
setSubscribed(true)
toast.success(t.pushEnabledToast)
} else if (res === 'denied') {
toast.error(t.pushDenied)
} else {
toast.error(t.pushUnavailable)
}
}
} catch {
toast.error(t.pushUnavailable)
} finally {
setBusy(false)
}
}
return (
<button type="button" className="btn tickets-push-toggle" onClick={toggle} disabled={busy}>
{subscribed ? `🔕 ${t.pushDisable}` : `🔔 ${t.pushEnable}`}
</button>
)
}

View File

@@ -5,13 +5,26 @@
* dialog captures the current URL and a client timestamp itself and POSTs everything
* to /feedback. Success/error is surfaced via the existing toast system.
*/
import { useEffect, useRef, useState } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { Link } from 'react-router-dom'
import { de } from '../strings/de'
import { ApiError } from '../api/client'
import { submitFeedback, type FeedbackContext } from '../api/feedback'
import {
listFeedback,
readFileAsUpload,
submitFeedback,
uploadAttachment,
type FeedbackContext,
type FeedbackTicket,
} from '../api/feedback'
import { useToast } from './toast'
import './reportErrorDialog.css'
/** Wörter (≥3 Zeichen) für einen einfachen Ähnlichkeits-Score. */
function wordSet(s: string): Set<string> {
return new Set((s.toLowerCase().match(/[a-zäöüß0-9]{3,}/g) ?? []))
}
export interface ReportErrorContext {
context: FeedbackContext
gerbilId?: string | null
@@ -58,8 +71,49 @@ function ReportErrorDialogBody({
}
})
const [submitting, setSubmitting] = useState(false)
const [files, setFiles] = useState<File[]>([])
const textareaRef = useRef<HTMLTextAreaElement | null>(null)
// Bereits gelöste Tickets einmalig laden, um beim Tippen ähnliche vorzuschlagen.
const [resolvedTickets, setResolvedTickets] = useState<FeedbackTicket[]>([])
useEffect(() => {
let cancelled = false
listFeedback()
.then((all) => {
if (!cancelled)
setResolvedTickets(all.filter((tk) => tk.status === 'Resolved' && !tk.deletedAt))
})
.catch(() => {
/* ohne Vorschläge weiter */
})
return () => {
cancelled = true
}
}, [])
// Ähnliche gelöste Tickets: Wort-Überlappung + Bonus bei gleichem Tier/Wurf/Kontakt.
const similar = useMemo(() => {
const msgWords = wordSet(message)
if (msgWords.size < 2) return []
const scored = resolvedTickets.map((tk) => {
const hay = wordSet(`${tk.message} ${tk.fixNote ?? ''} ${tk.entityName ?? ''}`)
let score = 0
for (const w of msgWords) if (hay.has(w)) score++
if (
(context.gerbilId && tk.gerbilId === context.gerbilId) ||
(context.litterId && tk.litterId === context.litterId) ||
(context.contactId && tk.contactId === context.contactId)
)
score += 2
return { tk, score }
})
return scored
.filter((s) => s.score >= 2)
.sort((a, b) => b.score - a.score)
.slice(0, 3)
.map((s) => s.tk)
}, [message, resolvedTickets, context.gerbilId, context.litterId, context.contactId])
// Entwurf laufend sichern (bei jeder Eingabe) bzw. entfernen, wenn leer.
useEffect(() => {
try {
@@ -96,7 +150,7 @@ function ReportErrorDialogBody({
}
setSubmitting(true)
try {
await submitFeedback({
const created = await submitFeedback({
message: trimmed,
context: context.context,
gerbilId: context.gerbilId ?? null,
@@ -106,6 +160,14 @@ function ReportErrorDialogBody({
url: window.location.href,
clientTimestamp: new Date().toISOString(),
})
// Ausgewählte Anhänge nach dem Anlegen hochladen (Ticket-ID liegt erst jetzt vor).
for (const file of files) {
try {
await uploadAttachment(created.id, await readFileAsUpload(file))
} catch {
toast.error(t.attachmentError)
}
}
try {
localStorage.removeItem(draftKey)
} catch {
@@ -161,6 +223,41 @@ function ReportErrorDialogBody({
rows={5}
/>
{similar.length > 0 && (
<div className="report-error__similar">
<div className="report-error__similar-title">{t.similarTitle}</div>
<ul className="report-error__similar-list">
{similar.map((tk) => (
<li key={tk.id}>
<Link to={`/hilfe/tickets?focus=${tk.id}`} onClick={onClose}>
{tk.message.length > 80 ? `${tk.message.slice(0, 80)}` : tk.message}
</Link>
</li>
))}
</ul>
</div>
)}
<div className="report-error__attach">
<label className="report-error__label" htmlFor="report-error-files">
{t.attachLabel}
</label>
<input
id="report-error-files"
type="file"
accept="image/*"
multiple
onChange={(e) => setFiles(Array.from(e.target.files ?? []))}
/>
{files.length > 0 && (
<ul className="report-error__attach-list">
{files.map((f, i) => (
<li key={i}>{f.name}</li>
))}
</ul>
)}
</div>
<div className="report-error__debug">
<div className="report-error__debug-title">{t.debugTitle}</div>
<dl className="report-error__debug-list">

View File

@@ -147,3 +147,24 @@
gap: 0.5rem;
margin-top: 1.1rem;
}
/* Vorschlag: ähnliche bereits gelöste Tickets. */
.report-error__similar {
margin-top: 0.85rem;
padding: 0.6rem 0.75rem;
background: var(--color-accent-bg, #eef2ff);
border-radius: 8px;
}
.report-error__similar-title {
font-size: 0.85rem;
font-weight: 600;
margin-bottom: 0.35rem;
}
.report-error__similar-list {
margin: 0;
padding-left: 1.1rem;
display: flex;
flex-direction: column;
gap: 0.25rem;
font-size: 0.9rem;
}

View File

@@ -17,6 +17,8 @@ import {
wildType,
extractGenotypeFlags,
displayGenotypeSafe,
resolveAllelePair,
inferUnknownsFromParents,
} from '../genotype'
import { combineLocus } from '../punnett'
import { LOCI, type LocusKey } from '../loci'
@@ -239,10 +241,11 @@ describe('Farbschlag catalog', () => {
})
describe('Partially-unknown parents (wildcards)', () => {
it('handles an A? parent (phenotype agouti, genotype unknown)', () => {
// A? x aa at the A locus -> father gamete: 1/2 A, 1/4 (each of A,a) from "?"
// = effectively 3/4 A, 1/4 a ; mother always a.
// Offspring: 3/4 Aa, 1/4 aa.
it('GEN-5: an A? parent reads as AA (unknown copies the known allele)', () => {
// GEN-5 (ticket 3e643ef1, breeder rule): the unknown allele '?' is a COPY of
// the known partner 'A', so A? = AA. AA × aa → all Aa → 100% Agouti.
// (Previously '?' spread uniformly → 3/4 Agouti : 1/4 Schwarz, which invented
// a recessive 'a' gamete the parent demonstrably does not show.)
const father = makeGenotype({
A: ['A', '?'],
C: ['C', 'C'],
@@ -257,10 +260,10 @@ describe('Partially-unknown parents (wildcards)', () => {
const mother = fromDisplayString('aa CC DD EE GG PP spsp rere')
const result = breed(father, mother)
const agouti = result.byFarbschlag.find((f) => f.farbschlag === 'Agouti')!
const schwarz = result.byFarbschlag.find((f) => f.farbschlag === 'Schwarz')!
expect(agouti.probability.text).toBe('3/4')
expect(schwarz.probability.text).toBe('1/4')
expect(result.offspring).toHaveLength(1)
expect(result.offspring[0].farbschlag).toBe('Agouti')
expect(result.offspring[0].probability.text).toBe('1')
expect(result.byFarbschlag.some((f) => f.farbschlag === 'Schwarz')).toBe(false)
})
})
@@ -603,7 +606,10 @@ describe('GEN-4: Dilute prefix, REW, no-bare-Fuchs', () => {
expect(name('AA CC dd EE GG PP spsp rere')).toBe('Dilute Agouti')
expect(name('aa CC dd EE gg PP spsp rere')).toBe('Dilute Anthrazit')
expect(name('aa CC dd ee GG PP spsp rere')).toBe('Dilute Kohlfuchs')
expect(name('aa CC dd ee gg pp spsp rere')).toBe('Dilute Blaufuchs')
// GEN-5 (ticket 3deab547): Blaufuchs is BLACK-eyed (P), and dilution is
// independent of the P-locus, so Dilute Blaufuchs is aa dd ee gg P- (was
// wrongly P:'p'). The pink-eyed variant is a different (REW-adjacent) colour.
expect(name('aa CC dd ee gg PP spsp rere')).toBe('Dilute Blaufuchs')
expect(name('AA CC dd EE GG pp spsp rere')).toBe('Dilute Gold')
expect(name('aa CC dd EE GG pp spsp rere')).toBe('Dilute Platin')
})
@@ -628,10 +634,13 @@ describe('GEN-4: Dilute prefix, REW, no-bare-Fuchs', () => {
it('Farbarten (categories) never appear as computed results', () => {
// 'Fuchs', 'Fuchsschimmel', 'Schimmel' etc. are Farbarten — blocked by category guard.
// het ef/e now resolves to specific variety via locusToken ef/e -> 'ef' fix.
// GEN-5 (ticket 5826e8e2): het ef/e is the FUCHSSCHIMMEL family — it resolves to
// a *fuchsschimmel variety, NEVER a pure Schimmel (Rotaugen-/Orangeschimmel).
expect(genotypeToFarbschlag(fromDisplayString('aa CC DD eef GG PP spsp rere'))).toBe('Kohlfuchsschimmel')
// Agouti ef/e: 'Orangeschimmel' wins (same token-set as Algierfuchsschimmel, listed first)
expect(genotypeToFarbschlag(fromDisplayString('AA CC DD eef GG PP spsp rere'))).toBe('Orangeschimmel')
// Agouti ef/e black-eyed → Algierfuchsschimmel (A:A,E:ef,G:G,P:P), NOT Orangeschimmel.
expect(genotypeToFarbschlag(fromDisplayString('AA CC DD eef GG PP spsp rere'))).toBe('Algierfuchsschimmel')
// hom ef/ef agouti black-eyed → the pure Orangeschimmel (Schimmel family).
expect(genotypeToFarbschlag(fromDisplayString('AA CC DD efef GG PP spsp rere'))).toBe('Orangeschimmel')
// Unusual combo not in catalog -> Unbekannt (not 'Fuchsschimmel')
expect(farbschlagFor(fromDisplayString('aa CC dd eef GG PP spsp rere')).unknown).toBe(true)
// FK check: none of the 7 category names are in BASE_COLORS (no DB entries -> no FK risk)
@@ -789,3 +798,118 @@ describe('GEN-3h: breeder bracket-notation display + E-locus e-before-ef order',
expect(displayGenotypeSafe('')).toBe('')
})
})
describe('GEN-5: unknown allele = copy of known (resolveAllelePair, breeder rule)', () => {
it('a single-unknown pair resolves to the homozygote of the KNOWN allele', () => {
expect(resolveAllelePair('A', ['A', '?'])).toEqual(['A', 'A'])
expect(resolveAllelePair('A', ['?', 'a'])).toEqual(['a', 'a'])
expect(resolveAllelePair('D', ['D', '?'])).toEqual(['D', 'D'])
expect(resolveAllelePair('E', ['e', '?'])).toEqual(['e', 'e']) // ee[-] = Fuchs
expect(resolveAllelePair('E', ['ef', '?'])).toEqual(['ef', 'ef']) // ef[-] = Schimmel
})
it('a fully-unknown pair falls back to wild-type (markers stay unmarked)', () => {
expect(resolveAllelePair('A', ['?', '?'])).toEqual(['A', 'A'])
expect(resolveAllelePair('C', ['?', '?'])).toEqual(['C', 'C'])
expect(resolveAllelePair('Sp', ['?', '?'])).toEqual(['sp', 'sp']) // never implies Schecke
})
it('a fully-known pair is returned unchanged', () => {
expect(resolveAllelePair('C', ['C', 'ch'])).toEqual(['C', 'ch'])
})
})
describe('GEN-5: genetics-engine ticket reproductions (real stored genotypes)', () => {
const name = (s: string) => genotypeToFarbschlag(fromDisplayString(s))
it('5826e8e2: ee[f] het (Fuchsschimmel) + pp → Goldfuchsschimmel, NOT Rotaugenschimmel', () => {
// Tier bf6f4507: Aa C- D- ee[f] G- pp Spsp — het ef/e is a Fuchsschimmel.
expect(name('Aa C- D- ee[f] G- pp Spsp')).toBe('Goldfuchsschimmel Schecke')
// and the pure hom ef/ef pp stays the pure Schimmel:
expect(name('AA CC DD efef GG pp spsp')).toBe('Rotaugenschimmel')
})
it('b034ddd2: ee[-] (e + unknown) → ee Fuchs → Algierfuchs, NOT Agouti', () => {
// Tier 33a7c1f9: Aa CC D- ee[-] Gg Pp spsp. Old engine read [e,?] as e/E → Agouti.
expect(name('Aa CC D- ee[-] Gg Pp spsp')).toBe('Algierfuchs')
})
it('3deab547 / efa2b232: aa cchm dd ee[-] gg P- → Dilute CP-Blaufuchs, NOT Zobel/blau/Unbekannt', () => {
// Tier 6864eaef. Non-agouti Fuchs colourpoint is NOT a marten (Zobel) — it
// derives a CP-fox base with the Dilute prefix.
expect(name('aa c[chm]c[chm] dd ee[-] gg Pp Spsp')).toBe('Dilute CP-Blaufuchs Schecke')
})
it('473dc345 / 5151ab20: Vance uw[d] (dense underwhite) parses (no crash) → Kohlfuchs', () => {
// Tier f31eb1f9: aa Cc[chm] D- ee Uwuw[d] PP spsp. uw[d] is the G locus;
// it must parse and NEVER render 'uw'.
const g = fromDisplayString('aa Cc[chm] D- ee Uwuw[d] PP spsp')
expect(g.G).toEqual(['G', 'g'])
expect(toDisplayString(g)).not.toContain('uw')
expect(genotypeToFarbschlag(g)).toBe('Kohlfuchs')
})
it('Fuchsschimmel family never resolves to a pure Schimmel variety', () => {
// Agouti het ef/e black-eyed → Algierfuchsschimmel; hom ef/ef → Orangeschimmel.
expect(name('AA CC DD eef GG PP spsp')).toBe('Algierfuchsschimmel')
expect(name('AA CC DD efef GG PP spsp')).toBe('Orangeschimmel')
})
})
describe('GEN-5: no phantom colours in the expected-litter list (3e643ef1/c8ce27e2/3c46d0b4/1e7b66e6)', () => {
it('Mamta Mini (D-, Ee[-]) × Gold (D-, Ee): unknown D copies known D → no Dilute, no Unbekannt, no efef', () => {
// Real litter 98bfdf92. Both parents carry D- (unknown D) and an unknown E
// partner. The old uniform-spread invented dd / ef / 'Unbekannt' offspring.
const father = fromDisplayString('AA CC D- Ee[-] Gg PP spsp') // Mamta Mini
const mother = fromDisplayString('Aa CC D- Ee Gg pp spsp') // Gold
const result = breed(father, mother)
const names = result.byFarbschlag.map((f) => f.farbschlag)
expect(names).not.toContain('Unbekannter Farbschlag')
expect(names.some((n) => n.startsWith('Dilute'))).toBe(false)
expect(result.offspring.every((o) => !o.genotype.includes('e[f]'))).toBe(true)
// Probabilities still sum to exactly 1.
const sum = result.offspring.reduce((acc, o) => acc + o.probability.value, 0)
expect(sum).toBeCloseTo(1, 10)
// Only agouti vs silver-agouti can fall here (G locus segregates; everything else fixed).
expect(new Set(names)).toEqual(new Set(['Agouti', 'Silberagouti']))
})
it('D- × D- never yields a dd (dilute) offspring at all', () => {
const p = fromDisplayString('AA CC D- EE GG PP spsp')
const result = breed(p, p)
expect(result.offspring.every((o) => !o.genotype.includes('dd'))).toBe(true)
})
})
describe('GEN-5: parent inference fills unknown alleles (cc9ea3fe / 1a508c04)', () => {
it('Mamta Mini Ee[-] + homozygous ee father Geely → Ee', () => {
const child = fromDisplayString('AA CC DD Ee[-] Gg PP spsp')
const geely = fromDisplayString('aa CC DD ee gg PP spsp') // father: ee (hom fox)
const res = inferUnknownsFromParents(child, geely, null)
expect(res.genotype.E).toEqual(['E', 'e'])
expect(toDisplayString(res.genotype)).toBe('AA CC DD Ee Gg PP spsp')
expect(res.inferred).toEqual([{ locus: 'E', allele: 'e', from: 'father' }])
})
it('falls back to the mother when only she is homozygous', () => {
const child = fromDisplayString('AA CC DD Ee[-] GG PP spsp')
const father = fromDisplayString('AA CC DD Ee GG PP spsp') // het → no force
const mother = fromDisplayString('aa CC DD ee GG PP spsp') // ee → forces e
const res = inferUnknownsFromParents(child, father, mother)
expect(res.genotype.E).toEqual(['E', 'e'])
expect(res.inferred).toEqual([{ locus: 'E', allele: 'e', from: 'mother' }])
})
it('leaves the genotype untouched when no parent is homozygous at the unknown locus', () => {
const child = fromDisplayString('AA CC DD Ee[-] GG PP spsp')
const father = fromDisplayString('AA CC DD Ee GG PP spsp')
const res = inferUnknownsFromParents(child, father, null)
expect(res.inferred).toEqual([])
expect(res.genotype.E).toEqual(child.E)
})
it('no-op when there is nothing unknown', () => {
const child = fromDisplayString('AA CC DD Ee GG PP spsp')
const res = inferUnknownsFromParents(child, child, child)
expect(res.inferred).toEqual([])
})
})

View File

@@ -20,12 +20,12 @@
* meta rows dropped, 17 matched the frozen names). Genotypes normalized from
* portal notation (c[chm]->cchm, c[h]->ch, e[f]->ef, '-'/'--' = unknown).
*/
import { LOCI, LOCUS_ORDER, dominantAllele, type LocusKey } from './loci'
import { LOCUS_ORDER, dominantAllele, type LocusKey } from './loci'
import {
makeGenotype,
resolveAllelePair,
toDisplayString,
wildType,
WILDCARD,
type AllelePair,
type Genotype,
} from './genotype'
@@ -113,7 +113,11 @@ export const BASE_COLORS: readonly FarbschlagEntry[] = [
{ name: 'Kohlfuchs-Hell', tokens: { A: 'a', C: 'C', D: 'D', E: 'e', G: 'G', P: 'P' }, image: 'kohlfuchs-hell-2.jpg' },
{ name: 'Algierfuchs, hell', tokens: { A: 'A', C: 'C', D: 'D', E: 'e', G: 'G', P: 'P' }, image: 'algierfuchs-hell.JPG' },
{ name: 'Dilute Topas', tokens: { A: 'A', C: 'C', D: 'd', E: 'E', G: 'G', P: 'p' }, image: 'topas-dd.jpg' },
{ name: 'Dilute Blaufuchs', tokens: { A: 'a', C: 'C', D: 'd', E: 'e', G: 'g', P: 'p' }, image: 'blaufuchs-dd.jpg' },
// GEN-5 (ticket 3deab547): Blaufuchs is black-eyed (P, line 75); dilution dd is
// independent of the eye-pigment P-locus, so the dilute form is ALSO P:'P'
// (was P:'p', which made it an unreachable phantom and left dd CP-fox animals
// 'Unbekannt'/'blau'). Now aa cchm dd ee gg P- → 'Dilute CP-Blaufuchs'.
{ name: 'Dilute Blaufuchs', 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: aa points = marten/sable group (Marder/Siam, +gg Zobel/Zobel-Hell).
@@ -163,13 +167,10 @@ export interface FarbschlagMatch {
* variety specifically.
*/
function locusToken(g: Genotype, locus: LocusKey): string {
// Default an unknown allele to the WILD-TYPE reading: most-dominant for the
// colour loci (unknown-C => full-colour 'C', not a white), but the recessive
// UNMARKED allele for the spotting/rex markers (unknown-Sp must NOT imply Schecke).
const alleles = LOCI[locus].alleles
const isMarker = locus === 'Sp' || locus === 'Re' || locus === 'Sls'
const fallback = isMarker ? alleles[alleles.length - 1] : alleles[0]
const [x, y] = g[locus].map((a) => (a === WILDCARD ? fallback : a))
// GEN-5: an unknown allele is a COPY of the known partner (resolveAllelePair),
// so e.g. [e,?] reads as ee (Fuchs), NOT e/E. Only a fully-unknown locus falls
// back to the wild-type reading (most-dominant colour / unmarked marker).
const [x, y] = resolveAllelePair(locus, g[locus])
if (locus === 'E') {
if (x === y) return x // ee->'e', efef->'ef', EE->'E'
// GEN-4: het ef/e → 'ef' (ef is dominant for the Schimmel phenotype;
@@ -195,29 +196,50 @@ function matches(g: Genotype, entry: FarbschlagEntry): boolean {
* computed farbschlag output (the farbschlagFor category guard blocks them).
*/
function eFamily(g: Genotype): string | null {
const [x, y] = g.E
// GEN-5: resolve unknown E as a copy of the known allele first ([e,?]→ee Fuchs,
// [ef,?]→ef/ef Schimmel, [E,?]→EE full), so families are decided consistently.
const [x, y] = resolveAllelePair('E', g.E)
if (x === 'e' && y === 'e') return 'Fuchs'
if ((x === 'e' && y === 'ef') || (x === 'ef' && y === 'e')) return 'Fuchsschimmel'
if (x === 'ef' && y === 'ef') return 'Schimmel'
if ((x === 'e' || y === 'e') && (x === WILDCARD || y === WILDCARD)) return 'Fuchs'
return null
}
/** Resolve a genotype to its German Farbschlag (with Schecke/Rex modifiers). */
/** Resolve an allele pair to concrete alleles, defaulting unknown to wild-type. */
/**
* Resolve an allele pair to concrete alleles. GEN-5: an unknown allele copies the
* known partner (resolveAllelePair); a fully-unknown locus falls back to wild-type.
*/
function resolvedPair(g: Genotype, locus: LocusKey): [string, string] {
const alleles = LOCI[locus].alleles
const isMarker = locus === 'Sp' || locus === 'Re' || locus === 'Sls'
const fallback = isMarker ? alleles[alleles.length - 1] : alleles[0]
const [x, y] = g[locus].map((a) => (a === WILDCARD ? fallback : a))
return [x, y]
return resolveAllelePair(locus, g[locus])
}
/**
* Whether a catalog entry belongs to the given E-family by NAME. The Schimmel
* entries split into two breeder groups that share the same E:'ef' token but
* differ by zygosity of the live animal:
* - 'Fuchsschimmel' family (ef/e het) → only *fuchsschimmel entries
* (Goldfuchsschimmel, Kohlfuchsschimmel, …).
* - 'Schimmel' family (ef/ef hom) → the pure roan entries whose name ends
* in 'schimmel' but NOT 'fuchsschimmel' (Rotaugenschimmel, Orangeschimmel,
* Silberschimmel, …).
* GEN-5 (ticket 5826e8e2): this is why ef/e must NOT match a pure-Schimmel entry
* (Rotaugenschimmel) — a het Fuchsschimmel animal is a Goldfuchsschimmel.
*/
function entryInEFamily(entry: FarbschlagEntry, family: string): boolean {
if (entry.tokens.E === undefined) return false
const n = entry.name.toLowerCase()
if (family === 'Fuchsschimmel') return n.includes('fuchsschimmel')
if (family === 'Schimmel') return n.includes('schimmel') && !n.includes('fuchsschimmel')
// 'Fuchs' family: fox entries are E:'e' (no 'schimmel' in the name).
return !n.includes('schimmel')
}
/** Base colour name (no modifiers, no colourpoint prefix), via E-family + matches. */
function baseColourFor(g: Genotype): string | null {
const family = eFamily(g)
const base = family
? (BASE_COLORS.find((e) => e.tokens.E !== undefined && matches(g, e)) ?? null)
? (BASE_COLORS.find((e) => entryInEFamily(e, family) && matches(g, e)) ?? null)
: (BASE_COLORS.find((e) => matches(g, e)) ?? null)
// GEN-4: never fall back to the family name — Fuchs/Fuchsschimmel/Schimmel are
// Farbarten (categories), not concrete Farbschläge. If no catalog entry matches,
@@ -242,32 +264,20 @@ function colourpointName(g: Genotype): string | null {
// Remaining: cchm/cchm or cchm/ch (colourpoint, no full C, not chch).
const bothCchm = c[0] === 'cchm' && c[1] === 'cchm'
const agouti = resolvedPair(g, 'A').includes('A')
if (!agouti) {
// #3: the aa colourpoint branch must respect D (dilute) and E (Fuchs/Schimmel)
// instead of hard-coding Marder/Siam/Zobel. The frozen breeder names
// Marder/Siam/Zobel/Zobel-Hell only describe the wild D + full-extension case
// (aa cchm DD EE [gg]); they are kept for that case. Any non-wild D or E (e.g.
// dd dilute or ee Fuchs) is named from the resolved base colour, so
// 'aa cchm dd ee gg' no longer collapses to Zobel.
const [d1, d2] = resolvedPair(g, 'D')
const wildD = d1 === 'D' && d2 === 'D'
const fullExtension = eFamily(g) === null // E expresses full 'E' (not Fuchs/Schimmel)
if (wildD && fullExtension) {
const [g1, g2] = resolvedPair(g, 'G')
const grey = g1 === 'g' && g2 === 'g'
if (grey) return bothCchm ? 'Zobel' : 'Zobel-Hell'
return bothCchm ? 'Marder' : 'Siam'
}
// dilute and/or Fuchs/Schimmel aa colourpoint → derive from the base colour.
const base = baseColourFor(makeGenotype({ ...g, C: ['C', 'C'] }))
if (!base) return null
const DILUTE = 'Dilute '
if (base.startsWith(DILUTE)) {
return `${DILUTE}CP-${base.slice(DILUTE.length)}${bothCchm ? '' : '-Hell'}`
}
return `CP-${base}${bothCchm ? '' : '-Hell'}`
// GEN-5 (tickets 3deab547 / efa2b232): the aa marten names (Marder/Siam/Zobel/
// Zobel-Hell) are FULL-EXTENSION (E) sable varieties only. A non-agouti
// colourpoint that is Fuchs (ee) or Schimmel (ef) is NOT a Marder/Zobel — it
// must derive its base generically like the A- branch, so e.g.
// aa cchm dd ee gg → 'Dilute CP-Polarfuchs' (dilute + fox + grey), never Zobel.
if (!agouti && eFamily(g) === null) {
const [g1, g2] = resolvedPair(g, 'G')
const grey = g1 === 'g' && g2 === 'g'
if (grey) return bothCchm ? 'Zobel' : 'Zobel-Hell'
return bothCchm ? 'Marder' : 'Siam'
}
// A- colourpoint: base as if C were full; het (cchm/ch) -> '-Hell' suffix.
// Colourpoint base derivation: name the colour as if C were full, then prefix
// 'CP-'; het (cchm/ch) gets the '-Hell' suffix. Used by A- and by non-agouti
// Fuchs/Schimmel colourpoints (which have no dedicated marten name).
const base = baseColourFor(makeGenotype({ ...g, C: ['C', 'C'] }))
if (!base) return null
// GEN-4: if base is a Dilute variety, prefix ordering is 'Dilute CP-X' not 'CP-Dilute X'.
@@ -333,12 +343,22 @@ export function genotypeToFarbschlag(g: Genotype): string {
export function representativeGenotype(entry: FarbschlagEntry): Genotype {
const base = wildType()
const out = {} as Record<LocusKey, AllelePair>
// GEN-5 (ticket 5826e8e2): a *Fuchsschimmel variety is the HETEROZYGOUS ef/e
// animal (a Schimmel-modified Fox), whereas the pure *schimmel varieties
// (Rotaugen-/Orange-/Silberschimmel) are HOMOZYGOUS ef/ef. The E token is the
// shared phenotype letter 'ef'; the representative genotype must encode the
// right zygosity so each entry round-trips back to its own family.
const isFuchsschimmel = entry.name.toLowerCase().includes('fuchsschimmel')
for (const locus of LOCUS_ORDER) {
const token = entry.tokens[locus]
if (!token) {
out[locus] = base[locus]
continue
}
if (locus === 'E' && token === 'ef' && isFuchsschimmel) {
out[locus] = ['ef', 'e'] // het Fuchsschimmel (ef/e), not hom ef/ef
continue
}
// GEN-3f: a token may encode a HETEROZYGOUS pair as "x/y" (e.g. the het
// colourpoints Siam/Zobel-Hell use C: 'cchm/ch'); otherwise it's homozygous.
const [a, b] = token.includes('/') ? (token.split('/') as [string, string]) : [token, token]

View File

@@ -236,25 +236,25 @@
},
{
"name": "Polarfuchsschimmel",
"canonicalGenotype": "AA CC DD efef gg PP spsp rere",
"canonicalGenotype": "AA CC DD efe gg PP spsp rere",
"sortOrder": 37,
"image": "polarfuchsschimmel.jpg"
},
{
"name": "Algierfuchsschimmel",
"canonicalGenotype": "AA CC DD efef GG PP spsp rere",
"canonicalGenotype": "AA CC DD efe GG PP spsp rere",
"sortOrder": 38,
"image": "algierfuchsschimmel.jpg"
},
{
"name": "Kohlfuchsschimmel",
"canonicalGenotype": "aa CC DD efef GG PP spsp rere",
"canonicalGenotype": "aa CC DD efe GG PP spsp rere",
"sortOrder": 39,
"image": "kohlfuchsschimmel.jpg"
},
{
"name": "Blaufuchsschimmel",
"canonicalGenotype": "aa CC DD efef gg PP spsp rere",
"canonicalGenotype": "aa CC DD efe gg PP spsp rere",
"sortOrder": 40,
"image": "blaufuchsschimmel.jpg"
},
@@ -272,7 +272,7 @@
},
{
"name": "Goldfuchsschimmel",
"canonicalGenotype": "AA CC DD efef GG pp spsp rere",
"canonicalGenotype": "AA CC DD efe GG pp spsp rere",
"sortOrder": 43,
"image": "goldfuchsschimmel.jpg"
},
@@ -290,7 +290,7 @@
},
{
"name": "Rotfuchsschimmel",
"canonicalGenotype": "aa CC DD efef GG pp spsp rere",
"canonicalGenotype": "aa CC DD efe GG pp spsp rere",
"sortOrder": 46,
"image": "rotfuchsschimmel.jpg"
},
@@ -302,7 +302,7 @@
},
{
"name": "Kohlfuchsschimmel, hell",
"canonicalGenotype": "aa CC DD efef GG PP spsp rere",
"canonicalGenotype": "aa CC DD efe GG PP spsp rere",
"sortOrder": 48,
"image": "kohlfuchsschimmel-hell.jpg"
},
@@ -332,7 +332,7 @@
},
{
"name": "Dilute Blaufuchs",
"canonicalGenotype": "aa CC dd ee gg pp spsp rere",
"canonicalGenotype": "aa CC dd ee gg PP spsp rere",
"sortOrder": 53,
"image": "blaufuchs-dd.jpg"
},

View File

@@ -236,25 +236,25 @@
},
{
"name": "Polarfuchsschimmel",
"canonicalGenotype": "AA CC DD e[f]e[f] gg PP spsp",
"canonicalGenotype": "AA CC DD ee[f] gg PP spsp",
"sortOrder": 37,
"image": "polarfuchsschimmel.jpg"
},
{
"name": "Algierfuchsschimmel",
"canonicalGenotype": "AA CC DD e[f]e[f] GG PP spsp",
"canonicalGenotype": "AA CC DD ee[f] GG PP spsp",
"sortOrder": 38,
"image": "algierfuchsschimmel.jpg"
},
{
"name": "Kohlfuchsschimmel",
"canonicalGenotype": "aa CC DD e[f]e[f] GG PP spsp",
"canonicalGenotype": "aa CC DD ee[f] GG PP spsp",
"sortOrder": 39,
"image": "kohlfuchsschimmel.jpg"
},
{
"name": "Blaufuchsschimmel",
"canonicalGenotype": "aa CC DD e[f]e[f] gg PP spsp",
"canonicalGenotype": "aa CC DD ee[f] gg PP spsp",
"sortOrder": 40,
"image": "blaufuchsschimmel.jpg"
},
@@ -272,7 +272,7 @@
},
{
"name": "Goldfuchsschimmel",
"canonicalGenotype": "AA CC DD e[f]e[f] GG pp spsp",
"canonicalGenotype": "AA CC DD ee[f] GG pp spsp",
"sortOrder": 43,
"image": "goldfuchsschimmel.jpg"
},
@@ -290,7 +290,7 @@
},
{
"name": "Rotfuchsschimmel",
"canonicalGenotype": "aa CC DD e[f]e[f] GG pp spsp",
"canonicalGenotype": "aa CC DD ee[f] GG pp spsp",
"sortOrder": 46,
"image": "rotfuchsschimmel.jpg"
},
@@ -302,7 +302,7 @@
},
{
"name": "Kohlfuchsschimmel, hell",
"canonicalGenotype": "aa CC DD e[f]e[f] GG PP spsp",
"canonicalGenotype": "aa CC DD ee[f] GG PP spsp",
"sortOrder": 48,
"image": "kohlfuchsschimmel-hell.jpg"
},
@@ -332,7 +332,7 @@
},
{
"name": "Dilute Blaufuchs",
"canonicalGenotype": "aa CC dd ee gg pp spsp",
"canonicalGenotype": "aa CC dd ee gg PP spsp",
"sortOrder": 53,
"image": "blaufuchs-dd.jpg"
},

View File

@@ -40,6 +40,38 @@ export function canonicalPair(locus: LocusKey, a: string, b: string): AllelePair
return rank(a) <= rank(b) ? [a, b] : [b, a]
}
/**
* GEN-5 — the breeder's UNKNOWN-allele rule (ticket 3e643ef1, confirmed by the
* Züchterin): an unknown allele '?' is a COPY of the known, visible partner
* allele until the gene is determined. So a single-unknown pair resolves to the
* homozygote of the KNOWN allele:
* A? → AA D? → DD [e,?] → ee [E,?] → EE [ef,?] → ef/ef
* Only when BOTH alleles are unknown is the locus genuinely undetermined; it then
* falls back to the wild-type reading (most-dominant colour allele, but the
* recessive UNMARKED allele for the Sp/Re/Sls markers so an unknown marker never
* implies Schecke/Rex/WP).
*
* This single rule is shared by phenotype/catalog resolution (catalog.ts) and the
* Punnett gamete weights (punnett.ts), so an unknown allele never invents a
* recessive phenotype (no phantom Dilute/efef/Unbekannt in offspring lists).
*/
export function resolveAllelePair(locus: LocusKey, pair: AllelePair): [string, string] {
const [a, b] = pair
const aUnknown = a === WILDCARD
const bUnknown = b === WILDCARD
if (!aUnknown && !bUnknown) return [a, b]
if (aUnknown && bUnknown) {
// Fully unknown: wild-type reading (markers default to the unmarked recessive).
const alleles = LOCI[locus].alleles
const isMarker = locus === 'Sp' || locus === 'Re' || locus === 'Sls'
const fb = isMarker ? alleles[alleles.length - 1] : alleles[0]
return [fb, fb]
}
// Exactly one unknown → copy of the known partner allele (homozygous).
const known = aUnknown ? b : a
return [known, known]
}
function assertAllele(locus: LocusKey, allele: string): void {
if (allele === WILDCARD) return
if (!LOCI[locus].alleles.includes(allele)) {
@@ -180,27 +212,27 @@ function normalizeToken(tok: string): string | null {
let t = tok
if (t === 'WP') t = 'Slsl'
t = t.replace(/S\(l\)/g, 'Sl').replace(/s\(l\)/g, 'sl')
// GEN-3b (#32/#34): Underwhite == G locus. Strip the breeder's "[d]" (dense
// underwhite) annotation from the uw/Uw token BEFORE aliasing to G/g, so that
// "Uwuw[d]" → "Gg" and "uw[d]uw[d]" → "gg" (mirrors tools/import/genotype.py
// _rewrite_uw). Without this the "[d]" survived → splitToken("Gg[d]") threw and
// the frontend fell back to "Unbekannter Farbschlag" / leaked the raw uw token.
t = t.replace(/(Uw|uw)\[d\]/g, '$1')
// GEN-5: dense-underwhite modifier uw[d]/Uw[d] (G-locus). The German "[d]"
// dense marker is a shade qualifier, not a separate allele — strip it BEFORE
// the Uw→G alias so e.g. "Uwuw[d]" / "uw[d]uw[d]" parse as Gg / gg, not "Gg[d]"
// (which crashes splitToken). Mirrors tools/import/genotype.py _rewrite_uw.
t = t.replace(/uw\[d\]/gi, 'uw')
t = t.replace(/Uw/g, 'G').replace(/uw/g, 'g')
// GEN-3h: accept bracket display notation → canonical internal symbols.
t = t.replace(/e\[f\]/g, 'ef') // Schimmel allele 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
// #42 (E-locus e-dash): Fuchs (e) is RECESSIVE — a visible fox MUST be
// homozygous "ee". The herdbook form "ee[-]" (fox allele + unknown E-type
// second allele) therefore resolves to "ee" (Fuchs), NOT [e,?]; the recessive
// phenotype implies homozygosity. A bare "e-" / "e[-]" (a single recessive
// fox allele with an unknown partner) is genetically impossible and is left to
// be rejected by splitToken (invalid → genotypeInvalid path).
// #42 (E-locus): a visible Fuchs is RECESSIVE → MUST be homozygous "ee". The herdbook
// form "ee[-]" (fox allele + unknown E-type partner) therefore resolves to "ee" (Fuchs),
// NOT [e,?] — the recessive phenotype implies homozygosity. A bare "e-"/"e[-]" (a lone
// recessive fox with an unknown partner) is genetically impossible and is rejected below.
t = t.replace(/ee\[-\]/g, 'ee').replace(/ee-/g, 'ee')
// CR-1a: allele-prefixed bracket-unknown like cc[-]: when e[-]/c[-] is PRECEDED
// by a letter it is the second unknown allele in a 2-allele token. Lookbehind
// strips only the bracket part; the leading allele stays.
// CR-1a: allele-prefixed bracket-unknown like ee[-] (Silvain).
// When e[-] is PRECEDED by a letter it is the second unknown allele in a
// 2-allele token (e.g. ee[-] → e + e[-] → e + ?). Lookbehind strips only
// the e[-] part; the leading allele stays. Standalone e[-] falls through to
// the generic [-]→? rule below (which makes the bracket-dash a wildcard,
// leaving the leading allele intact for splitToken).
t = t.replace(/(?<=[A-Za-z])e\[-\]/g, '?')
t = t.replace(/(?<=[A-Za-z])c\[-\]/g, '?')
t = t.replace(/(?<=[A-Za-z])c$/g, '?')
@@ -266,12 +298,14 @@ export function fromDisplayString(input: string): Genotype {
const locus = ALLELE_TO_LOCUS[refAllele]
if (!locus) throw new Error(`Unknown allele "${refAllele}" in token "${token}"`)
if (acc[locus]) throw new Error(`Locus ${locus} given twice`)
// #42: a lone recessive Fuchs allele with an unknown partner ("e-"/"e[-]" →
// [e,?]) is genetically impossible — fox is recessive, so a fox allele is
// only visible homozygous (ee, written "ee[-]"). Reject it so the UI surfaces
// the genotypeInvalid message instead of silently mis-computing the colour.
// #42: a lone recessive Fuchs allele with an unknown partner ("e-"/"e[-]" → [e,?]) is
// genetically impossible — fox is recessive, so a fox allele is only visible homozygous
// ("ee", written "ee[-]"). Reject it so the UI surfaces the invalid-genotype message
// instead of silently mis-computing the colour. ("ee[-]" was already normalized to "ee".)
if (locus === 'E' && ((a === 'e' && b === WILDCARD) || (a === WILDCARD && b === 'e'))) {
throw new Error(`Invalid E-locus token "${token}": lone recessive "e" with unknown partner (use "ee[-]" for Fuchs or "E-" for unknown)`)
throw new Error(
`Invalid E-locus token "${token}": lone recessive "e" with unknown partner (use "ee[-]" for Fuchs or "E-" for unknown)`,
)
}
acc[locus] = canonicalPair(locus, a, b)
}
@@ -301,3 +335,75 @@ export function displayGenotypeSafe(raw: string | null | undefined): string {
export function hasUnknown(g: Genotype): boolean {
return LOCUS_ORDER.some((l) => g[l][0] === WILDCARD || g[l][1] === WILDCARD)
}
/** Per-locus note about an allele that parent-inference filled in. */
export interface ParentInferredLocus {
readonly locus: LocusKey
/** The allele a homozygous parent forced onto the child. */
readonly allele: string
/** 'father' | 'mother' — which parent was homozygous. */
readonly from: 'father' | 'mother'
}
export interface ParentInferenceResult {
readonly genotype: Genotype
/** Loci whose unknown allele was resolved from a parent (empty = nothing changed). */
readonly inferred: ParentInferredLocus[]
}
/**
* GEN-5 (tickets cc9ea3fe / 1a508c04, breeder rule via Mendel): a child's UNKNOWN
* allele can be filled in from a HOMOZYGOUS parent, which can only pass that one
* allele. E.g. Mamta Mini stored E = [E,?]; her father Geely is ee (homozygous
* fox) so he must pass an 'e' — the child's unknown E allele therefore IS 'e',
* giving Ee (not the copy-of-known EE default).
*
* Rule, per locus, ONLY for an allele still unknown ('?') in the child:
* - if a parent is homozygous (both alleles equal and known), that allele is
* forced onto the child's unknown slot.
* - the father is checked first; if he doesn't resolve it, the mother is tried.
* - a parent allele is only accepted if it is one the child could legitimately
* carry at that locus (it always is for a real parent, but we guard anyway).
* Pairs with no unknown, or where no parent is homozygous, are left untouched
* (still subject to the copy-of-known display/colour rule elsewhere).
*/
export function inferUnknownsFromParents(
child: Genotype,
father: Genotype | null | undefined,
mother: Genotype | null | undefined,
): ParentInferenceResult {
const out = {} as Record<LocusKey, AllelePair>
const inferred: ParentInferredLocus[] = []
for (const locus of LOCUS_ORDER) {
const [a, b] = child[locus]
const aUnknown = a === WILDCARD
const bUnknown = b === WILDCARD
if (!aUnknown && !bUnknown) {
out[locus] = child[locus]
continue
}
const homForced = (p: Genotype | null | undefined): string | null => {
if (!p) return null
const [pa, pb] = p[locus]
if (pa === WILDCARD || pb === WILDCARD) return null
return pa === pb ? pa : null
}
const fatherAllele = homForced(father)
const forced = fatherAllele ?? homForced(mother)
const from: 'father' | 'mother' = fatherAllele ? 'father' : 'mother'
if (forced && (aUnknown !== bUnknown)) {
// Exactly one unknown slot → fill it with the forced parent allele.
const known = aUnknown ? b : a
out[locus] = canonicalPair(locus, known, forced)
inferred.push({ locus, allele: forced, from })
} else if (forced && aUnknown && bUnknown) {
// Both unknown but a parent is homozygous → that allele is certain on one
// slot; the other stays unknown.
out[locus] = canonicalPair(locus, forced, WILDCARD)
inferred.push({ locus, allele: forced, from })
} else {
out[locus] = child[locus]
}
}
return { genotype: makeGenotype(out), inferred }
}

View File

@@ -21,9 +21,16 @@ export {
fromJSON,
hasUnknown,
displayGenotypeSafe,
resolveAllelePair,
inferUnknownsFromParents,
WILDCARD,
} from './genotype'
export type { Genotype, AllelePair } from './genotype'
export type {
Genotype,
AllelePair,
ParentInferenceResult,
ParentInferredLocus,
} from './genotype'
export { LOCI, LOCUS_ORDER } from './loci'
export type { LocusKey, LocusDef } from './loci'

View File

@@ -10,72 +10,40 @@
* Wildcard ("?") alleles are expanded uniformly over the locus' allele set
* before combining, so a parent known only by phenotype can still be paired.
*/
import { add, frac, multiply, ONE, type Fraction } from './fraction'
import { dominanceRank, LOCI, LOCUS_ORDER, type LocusKey } from './loci'
import { add, frac, multiply, type Fraction, ONE } from './fraction'
import { LOCUS_ORDER, type LocusKey } from './loci'
import {
canonicalPair,
resolveAllelePair,
toDisplayString,
WILDCARD,
type AllelePair,
type Genotype,
} from './genotype'
/**
* #37/#39/#40/#41: which concrete alleles an UNKNOWN partner allele may actually be,
* given the KNOWN allele it is paired with at this locus.
*
* A hidden allele is constrained by the recorded (visible) one:
* 1. It can NEVER be more dominant than the known allele — otherwise the animal's
* phenotype would be different from what the breeder recorded. So the unknown
* only ranges over alleles with dominance rank >= rank(known) (equal or more
* recessive). This kills impossible more-dominant offspring morphs.
* 2. It can never be an allele that is VISIBLE in the heterozygote, unless the
* animal already expresses it. At the E locus 'ef' (Schimmel/roan) shows even
* heterozygously, so a non-Schimmel animal (known E or e) cannot secretly carry
* 'ef'. Excluding it removes the phantom Schimmel/efef predictions (#41).
*
* When BOTH alleles are unknown the locus is genuinely unconstrained → full set.
*/
function unknownPartnerOptions(locus: LocusKey, known: string): readonly string[] {
const alleles = LOCI[locus].alleles
if (known === WILDCARD) return alleles // fully unknown locus: any allele
const knownRank = dominanceRank(locus, known)
return alleles.filter((a) => {
if (dominanceRank(locus, a) < knownRank) return false // can't outrank the visible allele
// E-locus 'ef' is visible in het: only possible if the animal is itself Schimmel.
if (locus === 'E' && a === 'ef' && known !== 'ef') return false
return true
})
}
/** A probability distribution over outcomes of type T (keyed by a string). */
export interface DistEntry<T> {
readonly value: T
readonly probability: Fraction
}
/** Expand a (possibly wildcard) parent allele pair into weighted concrete alleles. */
/**
* Expand a (possibly partly-unknown) parent allele pair into weighted concrete
* gamete alleles.
*
* GEN-5 (ticket 3e643ef1, breeder rule): an unknown allele '?' is a COPY of the
* known partner allele (A?→AA, D?→DD, [e,?]→ee), NOT a uniform spread over the
* whole allele set. Spreading wrongly invented recessive gametes (d, ef, e) that
* produced impossible offspring colours — phantom Dilute, efef Schimmel and
* 'Unbekannter Farbschlag' in the expected-litter list. After resolution each of
* the two (now concrete) alleles contributes 1/2 of the gamete. A fully-unknown
* locus resolves to the wild-type homozygote (see resolveAllelePair).
*/
function parentAlleleWeights(locus: LocusKey, pair: AllelePair): Map<string, Fraction> {
const weights = new Map<string, Fraction>()
const addWeight = (allele: string, w: Fraction) => {
weights.set(allele, add(weights.get(allele) ?? frac(0, 1), w))
}
// The "other" allele of the pair tells us what an unknown is allowed to be:
// an unknown partner is constrained by the known visible allele (see
// unknownPartnerOptions), not blown up uniformly over every allele.
const [a0, a1] = pair
for (let i = 0; i < 2; i++) {
const a = pair[i]
if (a === WILDCARD) {
const known = i === 0 ? a1 : a0
const options = unknownPartnerOptions(locus, known)
// Unknown allele contributes 1/2 of the gamete, split over its possible values.
const share = frac(1, 2 * options.length)
for (const concrete of options) addWeight(concrete, share)
} else {
addWeight(a, frac(1, 2))
}
}
for (const a of resolveAllelePair(locus, pair)) addWeight(a, frac(1, 2))
return weights
}

View File

@@ -50,17 +50,41 @@ interface Resolved<T> {
export function useInfiniteList<T>(
loader: (page: number) => Promise<Paged<T>>,
resetKey: string,
/**
* Optionaler stabiler Schlüssel je Liste/Route (z. B. "gerbils"). Wenn gesetzt, wird die
* geladene Seitenzahl in der Session gemerkt und nach einer Zurück-Navigation wieder
* aufgebaut — so ist die Liste hoch genug, dass die globale Scroll-Wiederherstellung greift.
*/
restoreKey?: string,
): InfiniteListState<T> {
const loaderRef = useRef(loader)
useEffect(() => {
loaderRef.current = loader
})
const storageKey = restoreKey ? `il-pages:${restoreKey}` : null
// Gemerkte Zielseite EINMAL beim Mount lesen (nur wenn dieselbe Filter-/Sortier-Signatur):
// bis hierhin werden nach einer Zurück-Navigation die Seiten wieder nachgeladen.
const [restoreTarget, setRestoreTarget] = useState<number>(() => {
if (!storageKey) return 0
try {
const saved = JSON.parse(sessionStorage.getItem(storageKey) || 'null')
if (saved && saved.key === resetKey && typeof saved.page === 'number' && saved.page > 1) {
return saved.page
}
} catch {
/* ignorieren */
}
return 0
})
const [req, setReq] = useState<Req>({ key: resetKey, page: 1, nonce: 0 })
// Adjust state during render when the query signature changes — the recommended
// pattern for deriving state from changed inputs (no setState-in-effect cascade).
if (req.key !== resetKey) {
setReq({ key: resetKey, page: 1, nonce: 0 })
setRestoreTarget(0) // Filter/Sortierung geändert → kein Seiten-Restore mehr
}
const queryId = `${req.nonce}:${req.key}`
@@ -123,6 +147,24 @@ export function useInfiniteList<T>(
}, [])
const reload = useCallback(() => setReq((r) => ({ key: r.key, page: 1, nonce: r.nonce + 1 })), [])
// Geladene Seitenzahl je Query merken (für die Wiederherstellung nach Zurück-Navigation).
useEffect(() => {
if (!storageKey || !sameQuery || inFlight) return
try {
sessionStorage.setItem(storageKey, JSON.stringify({ key: req.key, page: req.page }))
} catch {
/* ignorieren */
}
}, [storageKey, sameQuery, inFlight, req.key, req.page])
// Nach Zurück-Navigation die zuvor geladene Seitenzahl wieder aufbauen (eine Seite je
// abgeschlossener Anfrage), damit die Liste ihre alte Höhe erreicht.
useEffect(() => {
if (restoreTarget <= req.page || !sameQuery || inFlight || !hasMore) return
// eslint-disable-next-line react-hooks/set-state-in-effect -- kontrolliertes Nachladen bis zur gemerkten Seite
setReq((r) => ({ ...r, page: r.page + 1 }))
}, [restoreTarget, sameQuery, inFlight, hasMore, req.page])
return { items, total, loading, loadingMore, error, hasMore, loadMore, reload }
}

View File

@@ -0,0 +1,194 @@
/**
* Globale Scroll-Wiederherstellung (einmal im AppShell gemountet, gilt für ALLE Seiten/Listen).
*
* WICHTIG: Gescrollt wird NICHT das Fenster, sondern der Inhaltsbereich `.app-main`
* (`.app-shell` ist height:100dvh/overflow:hidden). Daher arbeiten wir auf diesem Container.
*
* Erhält die Scrollposition je Route über:
* - Zurück/Vorwärts-Navigation (POP) → gemerkte Position wiederherstellen,
* - Bildschirm-Sperre / App-Hintergrund (visibilitychange, pagehide/pageshow) → Position
* sichern und beim Zurückkommen wiederherstellen.
* Neue Navigation (PUSH/REPLACE) startet oben.
*
* EVENT-basiert (zuverlässiger als ein Timer): ein MutationObserver auf dem Inhalt springt
* erneut zur Zielposition, sobald die Liste (nach-)lädt und höher wird — bricht aber sofort ab,
* sobald die Nutzerin selbst scrollt. Deep-Links (?focus=, #anchor) haben Vorrang.
*/
import { useCallback, useEffect, useRef } from 'react'
import { useLocation, useNavigationType } from 'react-router-dom'
const PREFIX = 'scroll:'
// Sicherheitsnetz: Inhalts-Beobachtung nach dieser Zeit beenden (kein Dauer-Listener).
const RESTORE_OBSERVE_CAP_MS = 15000
function getScroller(): HTMLElement | null {
return document.querySelector<HTMLElement>('.app-main')
}
function getTop(el: HTMLElement | null): number {
return el ? el.scrollTop : window.scrollY
}
function setTop(el: HTMLElement | null, y: number): void {
if (el) el.scrollTo(0, y)
else window.scrollTo(0, y)
}
export function useScrollRestoration() {
const { pathname } = useLocation()
const navType = useNavigationType()
const pathRef = useRef(pathname)
const restoringRef = useRef(false)
const rafSaveRef = useRef<number | null>(null)
const lastHeightRef = useRef(0)
const lastTopRef = useRef(0)
const keyFor = (p: string) => PREFIX + p
const persist = useCallback(() => {
try {
sessionStorage.setItem(keyFor(pathRef.current), String(getTop(getScroller())))
} catch {
/* sessionStorage nicht verfügbar */
}
}, [])
const restore = useCallback((path: string, respectDeepLink: boolean) => {
if (respectDeepLink) {
if (window.location.hash) return
try {
if (new URLSearchParams(window.location.search).has('focus')) return
} catch {
/* ignorieren */
}
}
let y = NaN
try {
y = Number(sessionStorage.getItem(keyFor(path)))
} catch {
/* ignorieren */
}
if (!Number.isFinite(y) || y <= 0) return
// WICHTIG gegen Race: `restoringRef` bleibt aktiv, bis die Zielposition ERREICHT ist (oder
// die Nutzerin selbst scrollt / die harte Obergrenze greift). So kann ein durch das
// Re-Rendern/Nachladen ausgelöster Sprung nach oben (scroll→0) NICHT als neue Position
// gespeichert werden und die gemerkte Position überschreiben.
//
// KEIN „Ruhe-/Settling-Timer": Listen, die ihre Inhalte erst nach einer kurzen Pause am Stück
// rendern (z. B. die Tickets-Liste: ein Fetch, dann alle Karten auf einmal), würden sonst
// vorzeitig freigegeben — die Position griffe dann ins Leere. Stattdessen warten wir per
// MutationObserver auf JEDE Inhaltsänderung und springen erneut, bis die Position sitzt.
restoringRef.current = true
let done = false
const stop = () => {
if (done) return
done = true
restoringRef.current = false
mo.disconnect()
window.removeEventListener('wheel', onUser)
window.removeEventListener('touchmove', onUser)
window.removeEventListener('keydown', onUser)
window.clearTimeout(safety)
}
// Sobald die Nutzerin selbst scrollt, brechen wir ab — nie gegen sie ankämpfen.
const onUser = () => stop()
const tryReach = () => {
if (done) return
const el = getScroller()
setTop(el, y)
if (Math.abs(getTop(el) - y) <= 2) stop() // Zielposition sitzt → fertig
}
// EVENT-basiert: jede DOM-Änderung im Inhalt (Liste lädt/rendert) → erneut zur Zielposition.
const mo = new MutationObserver(() => requestAnimationFrame(tryReach))
const passive = { passive: true } as AddEventListenerOptions
window.addEventListener('wheel', onUser, passive)
window.addEventListener('touchmove', onUser, passive)
window.addEventListener('keydown', onUser)
// Harte Obergrenze nur als Notausstieg (z. B. wenn die Zielposition nie erreichbar ist, weil
// der Inhalt jetzt kürzer ist). Hält bis dahin nur den Speicher-Schutz — harmlos.
const safety = window.setTimeout(stop, RESTORE_OBSERVE_CAP_MS)
const scroller = getScroller()
if (scroller) mo.observe(scroller, { childList: true, subtree: true })
requestAnimationFrame(tryReach)
}, [])
// Auf Navigation reagieren: POP = wiederherstellen, sonst neue Seite oben starten.
useEffect(() => {
pathRef.current = pathname
if (navType === 'POP') {
restore(pathname, true)
} else {
restoringRef.current = true
requestAnimationFrame(() => {
setTop(getScroller(), 0)
restoringRef.current = false
})
}
}, [pathname, navType, restore])
// Laufendes Mitschreiben (rAF-gedrosselt) am Scroll-Container + Sichern beim Ausblenden +
// Wiederherstellen beim Wiederanzeigen (Handy entsperrt / aus dem Hintergrund).
useEffect(() => {
const onScroll = () => {
const el = getScroller()
if (el) {
const prevTop = lastTopRef.current
const prevHeight = lastHeightRef.current
lastTopRef.current = el.scrollTop
lastHeightRef.current = el.scrollHeight
if (restoringRef.current || document.visibilityState !== 'visible') return
// ENTSCHEIDEND: Beim Seitenwechsel wird der Inhalt aus-/eingehängt → die scrollHeight
// SCHRUMPFT und der Container KLEMMT auf eine kleinere Position. Genau dieser Fall
// (Position UND Höhe gleichzeitig gesunken) ist ein „unechter" Sprung — NICHT speichern,
// sonst überschreibt er die gemerkte Stelle der Seite, die wir gerade verlassen. Eine
// echte Nutzer-Scrollung verkleinert die scrollHeight nie.
if (el.scrollTop < prevTop && el.scrollHeight < prevHeight) return
if (el.scrollHeight <= el.clientHeight + 4) return // nicht scrollbar
} else if (restoringRef.current || document.visibilityState !== 'visible') {
return
}
if (rafSaveRef.current != null) return
rafSaveRef.current = requestAnimationFrame(() => {
rafSaveRef.current = null
persist()
})
}
const onVisibility = () => {
if (document.visibilityState === 'hidden') persist()
else restore(pathRef.current, false)
}
const onPageShow = () => restore(pathRef.current, false)
const scroller = getScroller()
lastHeightRef.current = scroller?.scrollHeight ?? 0
lastTopRef.current = scroller?.scrollTop ?? 0
const scrollTarget: HTMLElement | Window = scroller ?? window
scrollTarget.addEventListener('scroll', onScroll, { passive: true })
document.addEventListener('visibilitychange', onVisibility)
window.addEventListener('pagehide', persist)
window.addEventListener('pageshow', onPageShow)
return () => {
scrollTarget.removeEventListener('scroll', onScroll)
document.removeEventListener('visibilitychange', onVisibility)
window.removeEventListener('pagehide', persist)
window.removeEventListener('pageshow', onPageShow)
if (rafSaveRef.current != null) cancelAnimationFrame(rafSaveRef.current)
}
}, [persist, restore])
// NUR Dev (Vite-HMR / React Fast Refresh): vor dem Hot-Update sichern, danach wiederherstellen.
// In der Produktion ist import.meta.hot undefiniert → No-Op.
useEffect(() => {
const hot = import.meta.hot
if (!hot) return
const before = () => persist()
const after = () => restore(pathRef.current, false)
hot.on('vite:beforeUpdate', before)
hot.on('vite:afterUpdate', after)
hot.on('vite:beforeFullReload', before)
return () => {
hot.off?.('vite:beforeUpdate', before)
hot.off?.('vite:afterUpdate', after)
hot.off?.('vite:beforeFullReload', before)
}
}, [persist, restore])
}

View File

@@ -43,6 +43,7 @@ export default function AnfragenPage() {
filter: status === '' ? undefined : `status==${status}`,
}),
`${status}`,
'anfragen',
)
const sentinelRef = useInfiniteSentinel(requests)
const { items, total, loading } = requests

View File

@@ -19,6 +19,7 @@ export default function BeckenPage() {
const list = useInfiniteList(
(page) => listEnclosuresPaged({ filter, orderBy: 'name,id', page, pageSize: PAGE_SIZE }),
`${filter ?? ''}`,
'becken',
)
const sentinelRef = useInfiniteSentinel(list)
const { items, total, loading } = list

View File

@@ -7,7 +7,14 @@ import { listColorVarieties, listContacts, listEnclosures, listLitters } from '.
import { useApi, useMutation } from '../hooks/useApi'
import { formatDate, genderLabel, statusLabel } from '../format/labels'
import { ALL_TRAITS, TRAIT_CATEGORIES } from '../format/traits'
import { fromDisplayString, genotypeToFarbschlag, displayGenotypeSafe } from '../genetics'
import {
fromDisplayString,
genotypeToFarbschlag,
displayGenotypeSafe,
toDisplayString,
hasUnknown,
inferUnknownsFromParents,
} from '../genetics'
import type { Gender, GerbilStatus } from '../api/types'
import FarbschlagImage from '../components/FarbschlagImage'
import GerbilAcquisitionSection from '../components/GerbilAcquisitionSection'
@@ -140,6 +147,37 @@ export default function GerbilDetailPage() {
const showEnclosure = g.status !== 'Deceased' && g.status !== 'GivenAway'
const geno = describeGenotype(g.genotype)
// GEN-5 (Tickets cc9ea3fe / 1a508c04): unbekannte Gencode-Buchstaben aus einem
// reinerbigen Elternteil ergänzen (Mendel: ein reinerbiger Elternteil kann nur
// dieses eine Allel vererben). Greift nur, wenn der Genotyp ein '-' enthält UND
// mindestens ein Elternteil mit Genotyp am eigenen Wurf hinterlegt ist.
const genoInferred = (() => {
if (!g.genotype) return null
let child
try {
child = fromDisplayString(g.genotype)
} catch {
return null
}
if (!hasUnknown(child)) return null
const parse = (s: string | null | undefined) => {
if (!s) return null
try {
return fromDisplayString(s)
} catch {
return null
}
}
const f = parse(father.data?.genotype)
const m = parse(mother.data?.genotype)
if (!f && !m) return null
const res = inferUnknownsFromParents(child, f, m)
if (res.inferred.length === 0) return null
const display = toDisplayString(res.genotype)
if (display === geno?.display) return null
return { display, farbschlag: genotypeToFarbschlag(res.genotype) }
})()
const lookup = (map: Map<string, string>, key: string | null) => (key ? (map.get(key) ?? '—') : '—')
const storedColorName = g.colorVarietyId ? (colorName.get(g.colorVarietyId) ?? null) : null
@@ -346,14 +384,29 @@ export default function GerbilDetailPage() {
<dl className="ak-kvlist">
<Kv label={t.fields.genotype}>
<code className="ak-genotype">{geno.display}</code>
{genoInferred && (
<span className="ak-inferred" title={t.detail.genotypeInferredTitle}>
{' → '}
<code className="ak-genotype">{genoInferred.display}</code>{' '}
<small className="ak-inferred-chip"> {t.detail.genotypeInferred}</small>
</span>
)}
</Kv>
<Kv label={t.detail.resolvedPrefix}>
{geno.farbschlag}
{storedColorName &&
geno.farbschlag !== de.genetics.unknownFarbschlag &&
storedColorName !== geno.farbschlag.replace(' Schecke', '').replace(' Rex', '') && (
<small className="ak-mismatch"> {t.detail.farbschlagMismatch}</small>
)}
{(() => {
const resolvedFarbschlag = (genoInferred ?? geno).farbschlag
return (
<>
{resolvedFarbschlag}
{storedColorName &&
resolvedFarbschlag !== de.genetics.unknownFarbschlag &&
storedColorName !==
resolvedFarbschlag.replace(' Schecke', '').replace(' Rex', '') && (
<small className="ak-mismatch"> {t.detail.farbschlagMismatch}</small>
)}
</>
)
})()}
</Kv>
</dl>
) : (

View File

@@ -64,6 +64,7 @@ export default function KontaktePage() {
(page) =>
listContactsPaged({ filter: filter || undefined, orderBy: 'name,id', page, pageSize: PAGE_SIZE }),
filter,
'kontakte',
)
const sentinelRef = useInfiniteSentinel(list)
const { items, total, hasMore, loading, loadingMore } = list

View File

@@ -19,12 +19,18 @@ import {
listFeedback,
updateFeedback,
deleteFeedback,
restoreFeedback,
uploadAttachment,
deleteAttachment,
attachmentUrl,
readFileAsUpload,
type FeedbackTicket,
} from '../api/feedback'
import { getGerbil } from '../api/gerbils'
import { REF_ROUTE, REF_TOKEN_RE, resolveRefs, type RefType } from '../api/refs'
import { useApi } from '../hooks/useApi'
import { useToast } from '../components/toast'
import PushToggle from '../components/PushToggle'
import './tickets.css'
/** Aufgelöster Kurz-Verweis (Shortlink): null = unbekannt/mehrdeutig. */
@@ -227,19 +233,24 @@ function formatDate(iso: string | null): string {
}
/** Gefilterte Ansichten (Filter-Chips). */
type TicketView = 'dialog' | 'open' | 'closed'
type TicketView = 'dialog' | 'open' | 'closed' | 'deleted'
/** Auto-Fallback-Reihenfolge, wenn die gewünschte Ansicht leer ist — „Gelöscht" NICHT (Papierkorb). */
const FALLBACK_VIEWS: TicketView[] = ['open', 'dialog', 'closed']
/**
* Zuordnung Ticket-Status → gefilterte Ansicht.
* Zuordnung Ticket → gefilterte Ansicht.
* - „Gelöscht" (deleted): soft-gelöscht (deletedAt gesetzt) — hat Vorrang vor dem Status.
* - „Rückfragen" (dialog): NUR NeedsInfo — wartet auf die Antwort der Züchterin.
* - „Offen" (open): Open + Answered — von der KI zu erledigen. Sobald die Züchterin
* geantwortet hat (Answered), ist SIE fertig → das Ticket wandert zurück nach „Offen"
* (mit „Beantwortet"-Badge), statt in ihrer Rückfragen-Liste zu verbleiben.
* - „Geschlossen" (closed): Resolved.
*/
function viewOf(status: FeedbackTicket['status']): TicketView {
if (status === 'Resolved') return 'closed'
if (status === 'NeedsInfo') return 'dialog'
function viewOf(ticket: Pick<FeedbackTicket, 'status' | 'deletedAt'>): TicketView {
if (ticket.deletedAt) return 'deleted'
if (ticket.status === 'Resolved') return 'closed'
if (ticket.status === 'NeedsInfo') return 'dialog'
return 'open'
}
@@ -249,15 +260,39 @@ export default function TicketsPage() {
// Default-Ansicht: „Offen" (neue, noch unbearbeitete Tickets) — fällt auf die erste
// nicht-leere Gruppe zurück, falls „Offen" leer ist. Solange die Nutzerin keinen Filter
// selbst gewählt hat (userPicked), darf ein ?focus=-Sprung die Ansicht bestimmen.
const [view, setView] = useState<TicketView>('open')
// Der zuletzt gewählte Tab wird in der Session gemerkt, damit man nach „Zurück" (Remount)
// NICHT wieder im falschen Tab („Offen") landet. userPicked startet bewusst false, damit ein
// frischer ?focus=-Deep-Link weiterhin in den passenden Tab springen darf.
const [view, setView] = useState<TicketView>(() => {
try {
const v = sessionStorage.getItem('tickets-view')
if (v === 'dialog' || v === 'open' || v === 'closed' || v === 'deleted') return v
} catch {
/* ignorieren */
}
return 'open'
})
const [userPicked, setUserPicked] = useState(false)
async function handleToggleStatus(ticket: FeedbackTicket) {
const next = ticket.status === 'Resolved' ? 'Open' : 'Resolved'
try {
await updateFeedback(ticket.id, { status: next })
tickets.reload()
toast.success(next === 'Resolved' ? t.resolvedToast : t.reopenedToast)
if (ticket.status === 'Resolved') {
// Wiederöffnen → zurück in die „Rückfragen" (NeedsInfo). Hatte das Ticket bereits eine
// offene Rückfrage (z. B. eine manuell gelöste Rückfrage), wird genau diese Frage wieder
// geöffnet (kein neuer Hinweis, kein Zeitstempel). War es dagegen ein echt gelöstes Ticket
// ohne Rückfrage, wird um mehr Infos gebeten — und das Backend stempelt „Wieder geöffnet am".
const hadRueckfrage = !!ticket.question && ticket.question.trim().length > 0
await updateFeedback(ticket.id, {
status: 'NeedsInfo',
question: hadRueckfrage ? ticket.question! : t.reopenRequest,
})
tickets.reload()
toast.success(t.reopenedToast)
} else {
await updateFeedback(ticket.id, { status: 'Resolved' })
tickets.reload()
toast.success(t.resolvedToast)
}
} catch (err) {
toast.error(err instanceof ApiError ? err.message : t.updateError)
}
@@ -286,6 +321,59 @@ export default function TicketsPage() {
}
}
async function handleRestore(ticket: FeedbackTicket) {
try {
await restoreFeedback(ticket.id)
tickets.reload()
toast.success(t.restoredToast)
} catch (err) {
toast.error(err instanceof ApiError ? err.message : t.restoreError)
}
}
// 👍/👎 auf einem gelösten Ticket. 👍 = nur Rückmeldung speichern; 👎 = Ticket wieder öffnen
// (zurück in die Rückfragen) und um die fehlende Info bitten.
async function handleHelpful(ticket: FeedbackTicket, helpful: boolean) {
try {
if (helpful) {
await updateFeedback(ticket.id, { helpful: true })
tickets.reload()
toast.success(t.helpfulThanks)
} else {
const hadRueckfrage = !!ticket.question && ticket.question.trim().length > 0
await updateFeedback(ticket.id, {
helpful: false,
status: 'NeedsInfo',
question: hadRueckfrage ? ticket.question! : t.helpfulReopenNote,
})
tickets.reload()
toast.success(t.reopenedToast)
}
} catch (err) {
toast.error(err instanceof ApiError ? err.message : t.updateError)
}
}
async function handleAddAttachment(ticket: FeedbackTicket, file: File) {
try {
await uploadAttachment(ticket.id, await readFileAsUpload(file))
tickets.reload()
toast.success(t.attachmentAdded)
} catch (err) {
toast.error(err instanceof ApiError ? err.message : t.attachmentError)
}
}
async function handleDeleteAttachment(attachmentId: string) {
try {
await deleteAttachment(attachmentId)
tickets.reload()
toast.success(t.attachmentRemoved)
} catch (err) {
toast.error(err instanceof ApiError ? err.message : t.attachmentError)
}
}
const rows = tickets.data
// Verlinkung von Ticket zu Ticket: /hilfe/tickets?focus=<id> wechselt in die passende
@@ -300,8 +388,8 @@ export default function TicketsPage() {
// Gruppierung + Zählung je gefilterter Ansicht (für die Badges an den Filter-Chips).
const counts = useMemo(() => {
const c: Record<TicketView, number> = { dialog: 0, open: 0, closed: 0 }
for (const ticket of rows ?? []) c[viewOf(ticket.status)] += 1
const c: Record<TicketView, number> = { dialog: 0, open: 0, closed: 0, deleted: 0 }
for (const ticket of rows ?? []) c[viewOf(ticket)] += 1
return c
}, [rows])
@@ -309,7 +397,7 @@ export default function TicketsPage() {
const focusView = useMemo<TicketView | null>(() => {
if (!focusId || !rows) return null
const target = rows.find((r) => r.id === focusId)
return target ? viewOf(target.status) : null
return target ? viewOf(target) : null
}, [focusId, rows])
// Sichtbare Ansicht: Fokus-Sprung hat Vorrang (bis die Nutzerin selbst filtert),
@@ -318,9 +406,32 @@ export default function TicketsPage() {
const activeView: TicketView =
counts[desiredView] > 0
? desiredView
: (['open', 'dialog', 'closed'] as TicketView[]).find((v) => counts[v] > 0) ?? desiredView
: FALLBACK_VIEWS.find((v) => counts[v] > 0) ?? desiredView
const visible = (rows ?? []).filter((ticket) => viewOf(ticket.status) === activeView)
const visible = (rows ?? []).filter((ticket) => viewOf(ticket) === activeView)
// Suche (Volltext über Nachricht/Frage/Antwort/Name/Kategorie) + Kategorie-Filter, jeweils
// INNERHALB der aktiven Ansicht.
const [query, setQuery] = useState('')
const [categoryFilter, setCategoryFilter] = useState<string>('')
// Vorhandene Kategorien (über alle nicht-gelöschten Tickets) für das Auswahlmenü.
const categories = useMemo(() => {
const set = new Set<string>()
for (const tk of rows ?? []) if (!tk.deletedAt && tk.category) set.add(tk.category)
return [...set].sort((a, b) => a.localeCompare(b, 'de'))
}, [rows])
const displayed = useMemo(() => {
const q = query.trim().toLowerCase()
return visible.filter((tk) => {
if (categoryFilter && tk.category !== categoryFilter) return false
if (!q) return true
const hay = [tk.message, tk.question, tk.answer, tk.fixNote, tk.entityName, tk.category]
.filter(Boolean)
.join(' ')
.toLowerCase()
return hay.includes(q)
})
}, [visible, query, categoryFilter])
// Nach dem Rendern der sichtbaren Liste zum fokussierten Ticket scrollen + kurz hervorheben.
useEffect(() => {
@@ -331,7 +442,10 @@ export default function TicketsPage() {
el.classList.add('ticket-card--focus')
const timer = setTimeout(() => el.classList.remove('ticket-card--focus'), 2500)
return () => clearTimeout(timer)
}, [focusId, visible])
}, [focusId, displayed])
// (Scroll-Wiederherstellung läuft global über useScrollRestoration im AppShell — gilt für alle
// Listen, inkl. Handy-Sperre/Hintergrund. ?focus= unten hat hier Vorrang.)
// Nackte Tier-IDs in den sichtbaren Tickets sammeln und ihre Namen einmalig auflösen,
// damit die KI im Text einfach eine Tier-ID hinterlegen kann (→ Name + Link zur Akte).
@@ -418,6 +532,7 @@ export default function TicketsPage() {
dialog: t.emptyDialog,
open: t.emptyOpen,
closed: t.emptyClosed,
deleted: t.emptyDeleted,
}
return (
@@ -429,6 +544,7 @@ export default function TicketsPage() {
</nav>
<h2>{t.title}</h2>
<p className="tickets-subtitle">{t.subtitle}</p>
<PushToggle />
{tickets.error && <p className="tickets-error">{t.loadError}</p>}
@@ -437,7 +553,7 @@ export default function TicketsPage() {
) : (
<>
<div className="tickets-filters" role="group" aria-label={t.title}>
{(['dialog', 'open', 'closed'] as TicketView[]).map((v) => (
{(['dialog', 'open', 'closed', 'deleted'] as TicketView[]).map((v) => (
<button
key={v}
type="button"
@@ -446,6 +562,11 @@ export default function TicketsPage() {
onClick={() => {
setUserPicked(true)
setView(v)
try {
sessionStorage.setItem('tickets-view', v)
} catch {
/* ignorieren */
}
}}
>
{t.filters[v]}
@@ -454,12 +575,38 @@ export default function TicketsPage() {
))}
</div>
{visible.length === 0 ? (
<p className="tickets-empty">{emptyText[activeView]}</p>
<div className="tickets-search">
<input
type="search"
className="tickets-search__input"
placeholder={t.searchPlaceholder}
aria-label={t.searchPlaceholder}
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
{categories.length > 0 && (
<select
className="tickets-search__category"
aria-label={t.categoryFilterLabel}
value={categoryFilter}
onChange={(e) => setCategoryFilter(e.target.value)}
>
<option value="">{t.categoryAll}</option>
{categories.map((c) => (
<option key={c} value={c}>
{c}
</option>
))}
</select>
)}
</div>
{displayed.length === 0 ? (
<p className="tickets-empty">{query || categoryFilter ? t.emptySearch : emptyText[activeView]}</p>
) : (
<TicketRefsContext.Provider value={{ ticketIds, gerbilNames, refNames }}>
<ul className="tickets-list">
{visible.map((ticket) => (
{displayed.map((ticket) => (
<TicketCard
key={ticket.id}
ticket={ticket}
@@ -467,6 +614,10 @@ export default function TicketsPage() {
onSaveMessage={(message) => handleSaveMessage(ticket, message)}
onAnswer={(answer) => handleAnswer(ticket, answer)}
onDelete={() => handleDelete(ticket)}
onRestore={() => handleRestore(ticket)}
onHelpful={(helpful) => handleHelpful(ticket, helpful)}
onAddAttachment={(file) => handleAddAttachment(ticket, file)}
onDeleteAttachment={handleDeleteAttachment}
/>
))}
</ul>
@@ -484,6 +635,10 @@ interface TicketCardProps {
onSaveMessage: (message: string) => Promise<void>
onAnswer: (answer: string) => Promise<void>
onDelete: () => void
onRestore: () => void
onHelpful: (helpful: boolean) => void
onAddAttachment: (file: File) => void
onDeleteAttachment: (attachmentId: string) => void
}
/** Status-Badge: Beschriftung + Modifier-Klasse je Lebenszyklus-Zustand. */
@@ -500,7 +655,20 @@ function statusBadge(status: FeedbackTicket['status']): { label: string; modifie
}
}
function TicketCard({ ticket, onToggleStatus, onSaveMessage, onAnswer, onDelete }: TicketCardProps) {
/** Tage bis zur endgültigen Löschung (Soft-Delete-Aufbewahrung). */
const TRASH_RETENTION_DAYS = 30
function TicketCard({
ticket,
onToggleStatus,
onSaveMessage,
onAnswer,
onDelete,
onRestore,
onHelpful,
onAddAttachment,
onDeleteAttachment,
}: TicketCardProps) {
const toast = useToast()
const [editing, setEditing] = useState(false)
const [draft, setDraft] = useState(ticket.message)
@@ -526,7 +694,21 @@ function TicketCard({ ticket, onToggleStatus, onSaveMessage, onAnswer, onDelete
const [answering, setAnswering] = useState(false)
const entityLink = entityHref(ticket)
const resolved = ticket.status === 'Resolved'
const deleted = !!ticket.deletedAt
const badge = statusBadge(ticket.status)
// Verbleibende Tage bis zur endgültigen Löschung (Soft-Delete-Countdown). `new Date()` (wie
// anderswo im Code) statt Date.now() — letzteres verstößt gegen die Purity-Lintregel.
const trashDaysLeft = ticket.deletedAt
? Math.max(
0,
Math.ceil(
(new Date(ticket.deletedAt).getTime() +
TRASH_RETENTION_DAYS * 86_400_000 -
new Date().getTime()) /
86_400_000,
),
)
: null
const [answerEditing, setAnswerEditing] = useState(false)
// Erstantwort: Rückfrage offen, noch keine Antwort.
const canAnswerNew = !ticket.answer && (ticket.status === 'NeedsInfo' || (!!ticket.question && !resolved))
@@ -534,10 +716,13 @@ function TicketCard({ ticket, onToggleStatus, onSaveMessage, onAnswer, onDelete
// Info noch korrigieren, BEVOR die KI das Ticket bearbeitet.
const canAmend = !!ticket.answer && ticket.status === 'Answered'
// Antwort-Formular zeigen: neue Antwort, aktives Ändern, oder ein gepufferter Änderungs-Entwurf.
const showAnswerForm = canAnswerNew || answerEditing || (canAmend && !!answerDraft)
// Bearbeiten (Beschreibung) ist gesperrt, solange eine Rückfrage offen ist (NeedsInfo):
// dann soll die Züchterin ANTWORTEN, nicht den Text ändern.
const canEditMessage = ticket.status !== 'NeedsInfo'
// Im Papierkorb (deleted) ist nichts interaktiv außer „Wiederherstellen".
const showAnswerForm = !deleted && (canAnswerNew || answerEditing || (canAmend && !!answerDraft))
// Der ursprüngliche Ticket-Text ist nur bearbeitbar, SOLANGE es noch keine Antwort gibt
// UND keine Rückfrage offen ist (NeedsInfo). Sobald die Züchterin geantwortet hat (oder die
// KI nachgefragt hat), bleibt der Erst-Text fix — editierbar ist dann nur noch die neueste
// Antwort (und auch die nur, bis die KI darauf reagiert hat, s. canAmend).
const canEditMessage = !deleted && !ticket.answer && ticket.status !== 'NeedsInfo'
function startAmend() {
if (!answerDraft) setAnswerDraft(ticket.answer ?? '')
@@ -593,6 +778,7 @@ function TicketCard({ ticket, onToggleStatus, onSaveMessage, onAnswer, onDelete
<div className="ticket-card__top">
<span className={`ticket-badge ${badge.modifier}`}>{badge.label}</span>
<span className="ticket-card__context">{contextLabel(ticket.context)}</span>
{ticket.category && <span className="ticket-card__category">{ticket.category}</span>}
{ticket.entityName &&
(entityLink ? (
<Link className="ticket-card__entity ticket-card__entity--link" to={entityLink} title={t.entityLinkTitle}>
@@ -723,6 +909,101 @@ function TicketCard({ ticket, onToggleStatus, onSaveMessage, onAnswer, onDelete
</div>
)}
{resolved && !deleted && (
<div className="ticket-card__helpful">
{ticket.helpful === true ? (
<span className="ticket-card__helpful-thanks">{t.helpfulThanks}</span>
) : (
<>
<span className="ticket-card__helpful-q">{t.helpfulQuestion}</span>
<button type="button" className="btn btn--small" onClick={() => onHelpful(true)}>
{t.helpfulYes}
</button>
<button type="button" className="btn btn--small" onClick={() => onHelpful(false)}>
{t.helpfulNo}
</button>
</>
)}
</div>
)}
{(ticket.attachments.length > 0 || !deleted) && (
<div className="ticket-card__attachments">
<span className="ticket-card__attachments-label">{t.attachmentsLabel}</span>
<div className="ticket-card__attachments-grid">
{ticket.attachments.map((att) =>
att.contentType.startsWith('image/') ? (
<a
key={att.id}
href={attachmentUrl(att.id)}
target="_blank"
rel="noopener noreferrer"
className="ticket-card__attachment"
>
<img src={attachmentUrl(att.id)} alt={att.fileName} loading="lazy" />
{!deleted && (
<button
type="button"
className="ticket-card__attachment-remove"
title={t.removeAttachment}
aria-label={t.removeAttachment}
onClick={(e) => {
e.preventDefault()
onDeleteAttachment(att.id)
}}
>
</button>
)}
</a>
) : (
<span key={att.id} className="ticket-card__attachment ticket-card__attachment--file">
<a href={attachmentUrl(att.id)} target="_blank" rel="noopener noreferrer">
{att.fileName}
</a>
{!deleted && (
<button
type="button"
className="ticket-card__attachment-remove"
title={t.removeAttachment}
aria-label={t.removeAttachment}
onClick={() => onDeleteAttachment(att.id)}
>
</button>
)}
</span>
),
)}
{!deleted && (
<label className="ticket-card__attachment-add">
+ {t.addAttachment}
<input
type="file"
accept="image/*"
hidden
onChange={(e) => {
const f = e.target.files?.[0]
if (f) onAddAttachment(f)
e.currentTarget.value = ''
}}
/>
</label>
)}
</div>
</div>
)}
{deleted && trashDaysLeft !== null && (
<p className="ticket-card__trash-notice">
{trashDaysLeft === 0
? t.trashCountdownToday
: trashDaysLeft === 1
? t.trashCountdownTomorrow
: t.trashCountdownDays.replace('{n}', String(trashDaysLeft))}
</p>
)}
<dl className="ticket-card__meta">
<div>
<dt>{t.createdLabel}</dt>
@@ -734,19 +1015,39 @@ function TicketCard({ ticket, onToggleStatus, onSaveMessage, onAnswer, onDelete
<dd>{formatDate(ticket.resolvedAt)}</dd>
</div>
)}
{ticket.reopenedAt && (
<div>
<dt>{t.reopenedLabel}</dt>
<dd>{formatDate(ticket.reopenedAt)}</dd>
</div>
)}
{deleted && (
<div>
<dt>{t.deletedLabel}</dt>
<dd>{formatDate(ticket.deletedAt)}</dd>
</div>
)}
</dl>
<div className="ticket-card__actions">
<button type="button" className="btn" onClick={onToggleStatus}>
{resolved ? t.reopen : t.markResolved}
</button>
{canEditMessage && (
<button type="button" className="btn" onClick={startEdit}>
{t.edit}
{deleted ? (
<button type="button" className="btn btn--primary" onClick={onRestore}>
{t.restore}
</button>
) : (
<>
<button type="button" className="btn" onClick={onToggleStatus}>
{resolved ? t.reopen : t.markResolved}
</button>
{canEditMessage && (
<button type="button" className="btn" onClick={startEdit}>
{t.edit}
</button>
)}
<button type="button" className="btn btn--danger" onClick={onDelete}>
{t.delete}
</button>
</>
)}
<button type="button" className="btn btn--danger" onClick={onDelete}>
{t.delete}
</button>
</div>
</>
)}

View File

@@ -21,6 +21,7 @@ export default function VertraegeListPage() {
const contracts = useInfiniteList(
(page) => listContracts({ page, pageSize: PAGE_SIZE, orderBy: 'createdAt desc,id' }),
'contracts',
'vertraege',
)
const sentinelRef = useInfiniteSentinel(contracts)
const { items, total: totalCount, loading } = contracts

View File

@@ -78,6 +78,7 @@ export default function WuerfeListPage() {
const litters = useInfiniteList(
(page) => listLitters({ filter: filter || undefined, orderBy, page, pageSize: PAGE_SIZE }),
`${filter}|${orderBy}`,
'wuerfe',
)
const littersSentinelRef = useInfiniteSentinel(litters)

View File

@@ -286,6 +286,20 @@
color: var(--ak-warn);
font-weight: 600;
}
/* GEN-5: parent-inferred genotype hint (Ee[-] → Ee aus den Eltern ergänzt). */
.ak-inferred {
white-space: nowrap;
}
.ak-inferred-chip {
display: inline-block;
background: var(--ak-tan);
color: var(--color-text);
border-radius: 999px;
padding: 1px 9px;
font-weight: 600;
font-size: 12px;
vertical-align: middle;
}
/* ---------- Character chips ---------- */
.ak-cgroup {

View File

@@ -311,6 +311,148 @@
word-break: break-word;
}
/* Suche + Kategorie-Filter über der Ticket-Liste. */
.tickets-search {
display: flex;
gap: 0.5rem;
margin: 0 0 1rem;
flex-wrap: wrap;
}
.tickets-search__input {
flex: 1 1 16rem;
min-width: 0;
padding: 0.5rem 0.75rem;
border: 1px solid var(--color-border, #d1d5db);
border-radius: 8px;
font-size: 0.95rem;
}
.tickets-search__category {
padding: 0.5rem 0.75rem;
border: 1px solid var(--color-border, #d1d5db);
border-radius: 8px;
font-size: 0.95rem;
background: var(--color-surface, #fff);
}
/* Kategorie-Chip im Ticket-Kopf. */
.ticket-card__category {
font-size: 0.75rem;
font-weight: 600;
padding: 0.1rem 0.5rem;
border-radius: 999px;
background: var(--color-accent-bg, #eef2ff);
color: var(--color-accent-text, #3730a3);
}
/* „Hat das geholfen?"-Leiste auf gelösten Tickets. */
.ticket-card__helpful {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
margin: 0 0 0.85rem;
font-size: 0.9rem;
}
.ticket-card__helpful-q {
font-weight: 600;
}
.ticket-card__helpful-thanks {
color: var(--color-success-text, #166534);
font-weight: 600;
}
.btn--small {
padding: 0.25rem 0.6rem;
font-size: 0.85rem;
}
/* Anhänge (Fotos/Dateien) auf einem Ticket. */
.ticket-card__attachments {
margin: 0 0 0.85rem;
}
.ticket-card__attachments-label {
display: block;
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.03em;
color: var(--color-muted, #6b7280);
margin-bottom: 0.35rem;
}
.ticket-card__attachments-grid {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
}
.ticket-card__attachment {
position: relative;
display: inline-flex;
border-radius: 8px;
overflow: hidden;
}
.ticket-card__attachment img {
width: 72px;
height: 72px;
object-fit: cover;
display: block;
border: 1px solid var(--color-border, #d1d5db);
border-radius: 8px;
}
.ticket-card__attachment--file {
padding: 0.35rem 0.6rem;
border: 1px solid var(--color-border, #d1d5db);
border-radius: 8px;
font-size: 0.85rem;
gap: 0.4rem;
align-items: center;
}
.ticket-card__attachment-remove {
position: absolute;
top: 2px;
right: 2px;
border: none;
background: rgba(0, 0, 0, 0.6);
color: #fff;
border-radius: 50%;
width: 18px;
height: 18px;
line-height: 1;
font-size: 0.7rem;
cursor: pointer;
padding: 0;
}
.ticket-card__attachment--file .ticket-card__attachment-remove {
position: static;
background: transparent;
color: var(--color-danger, #ef4444);
}
.ticket-card__attachment-add {
display: inline-flex;
align-items: center;
justify-content: center;
width: 72px;
height: 72px;
border: 1px dashed var(--color-border, #9ca3af);
border-radius: 8px;
font-size: 0.75rem;
text-align: center;
cursor: pointer;
color: var(--color-muted, #6b7280);
padding: 0.25rem;
}
/* Papierkorb-Countdown: Hinweis, wann das Ticket endgültig gelöscht wird. */
.ticket-card__trash-notice {
border-left: 3px solid var(--color-danger, #ef4444);
background: var(--color-danger-bg, #fef2f2);
color: var(--color-danger-text, #991b1b);
padding: 0.55rem 0.75rem;
border-radius: 0 8px 8px 0;
margin: 0 0 0.85rem;
font-size: 0.9rem;
font-weight: 600;
}
.ticket-card__meta {
display: flex;
flex-wrap: wrap;

View File

@@ -0,0 +1,87 @@
/**
* WEB-PUSH (PWA-Benachrichtigungen) — Client-Seite.
* Registriert den Service Worker (push-only) und verwaltet das Abo gegen /push/*.
*/
import { API_BASE_URL } from './api/client'
export function isPushSupported(): boolean {
return (
typeof navigator !== 'undefined' &&
'serviceWorker' in navigator &&
typeof window !== 'undefined' &&
'PushManager' in window &&
'Notification' in window
)
}
/** Server-Status: ist Push konfiguriert + welcher VAPID-Public-Key. */
export async function fetchPushConfig(): Promise<{ enabled: boolean; publicKey: string | null }> {
try {
const r = await fetch(`${API_BASE_URL}/push/vapid-public-key`)
if (!r.ok) return { enabled: false, publicKey: null }
return await r.json()
} catch {
return { enabled: false, publicKey: null }
}
}
function urlBase64ToUint8Array(base64: string): Uint8Array {
const padding = '='.repeat((4 - (base64.length % 4)) % 4)
const b64 = (base64 + padding).replace(/-/g, '+').replace(/_/g, '/')
const raw = atob(b64)
const arr = new Uint8Array(raw.length)
for (let i = 0; i < raw.length; i++) arr[i] = raw.charCodeAt(i)
return arr
}
export async function isSubscribed(): Promise<boolean> {
if (!isPushSupported()) return false
const reg = await navigator.serviceWorker.getRegistration()
const sub = await reg?.pushManager.getSubscription()
return !!sub
}
export type EnableResult = 'enabled' | 'denied' | 'unavailable'
export async function enablePush(): Promise<EnableResult> {
if (!isPushSupported()) return 'unavailable'
const cfg = await fetchPushConfig()
if (!cfg.enabled || !cfg.publicKey) return 'unavailable'
const permission = await Notification.requestPermission()
if (permission !== 'granted') return 'denied'
const reg = await navigator.serviceWorker.register('/sw.js')
await navigator.serviceWorker.ready
const sub = await reg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(cfg.publicKey),
})
const json = sub.toJSON()
await fetch(`${API_BASE_URL}/push/subscribe`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
endpoint: sub.endpoint,
p256dh: json.keys?.p256dh ?? '',
auth: json.keys?.auth ?? '',
}),
})
return 'enabled'
}
export async function disablePush(): Promise<void> {
if (!isPushSupported()) return
const reg = await navigator.serviceWorker.getRegistration()
const sub = await reg?.pushManager.getSubscription()
if (!sub) return
try {
await fetch(`${API_BASE_URL}/push/unsubscribe`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ endpoint: sub.endpoint }),
})
} catch {
/* trotzdem lokal abmelden */
}
await sub.unsubscribe()
}

View File

@@ -112,6 +112,10 @@ export const de = {
resolvedPrefix: 'Errechnet',
farbschlagMismatch: 'Weicht vom eingetragenen Farbschlag ab.',
genotypeNotSet: 'Kein Genotyp hinterlegt.',
// GEN-5: ein unbekannter Gencode-Buchstabe wurde aus einem reinerbigen
// Elternteil ergänzt (z. B. Vater ee ⇒ Kind erbt e). %s = aufgelöster Genotyp.
genotypeInferred: 'Aus den Eltern ergänzt',
genotypeInferredTitle: 'Ein unbekanntes Gen wurde aus einem reinerbigen Elternteil abgeleitet.',
testMating: 'Probeverpaarung',
edit: 'Bearbeiten',
back: 'Zurück zur Liste',
@@ -1197,6 +1201,12 @@ export const de = {
success: 'Danke! Dein Fehlerbericht wurde gesendet.',
error: 'Fehlerbericht konnte nicht gesendet werden.',
emptyMessage: 'Bitte beschreibe den Fehler.',
/** „Ähnliche bereits gelöste Tickets" im Melde-Fenster. */
similarTitle: 'Schon mal gelöst? Vielleicht hilft eines davon:',
similarOpen: 'ansehen',
/** Datei-Anhänge. */
attachLabel: 'Fotos anhängen (optional)',
attachmentError: 'Ein Anhang konnte nicht hochgeladen werden.',
/** Toast nach erfolgreichem "ID kopieren". */
idCopied: 'ID kopiert',
idCopyFailed: 'ID konnte nicht kopiert werden.',
@@ -1217,20 +1227,31 @@ export const de = {
entityLabel: 'Datensatz',
createdLabel: 'Gesendet am',
resolvedLabel: 'Gelöst am',
reopenedLabel: 'Wieder geöffnet am',
markResolved: 'Als gelöst markieren',
reopen: 'Wieder öffnen',
reopenRequest:
'Du hast dieses Ticket wieder geöffnet. Bitte beschreibe kurz, was noch fehlt oder nicht stimmt — dann schaue ich es mir erneut an.',
edit: 'Bearbeiten',
save: 'Speichern',
cancel: 'Abbrechen',
saving: 'Wird gespeichert …',
delete: 'Löschen',
confirmDelete: 'Diesen Fehlerbericht wirklich löschen?',
confirmDelete: 'Dieses Ticket in den Papierkorb verschieben? Du kannst es unter „Gelöscht" wiederherstellen.',
editTitle: 'Fehlerbericht bearbeiten',
editLabel: 'Beschreibung',
saved: 'Änderung gespeichert.',
resolvedToast: 'Ticket als gelöst markiert.',
reopenedToast: 'Ticket wieder geöffnet.',
deletedToast: 'Ticket gelöscht.',
deletedToast: 'Ticket in den Papierkorb verschoben.',
restore: 'Wiederherstellen',
restoredToast: 'Ticket wiederhergestellt.',
restoreError: 'Ticket konnte nicht wiederhergestellt werden.',
/** Countdown im Papierkorb bis zur endgültigen Löschung. {n} = verbleibende Tage. */
trashCountdownDays: 'Wird in {n} Tagen endgültig gelöscht.',
trashCountdownTomorrow: 'Wird morgen endgültig gelöscht.',
trashCountdownToday: 'Wird heute endgültig gelöscht.',
deletedLabel: 'Gelöscht am',
updateError: 'Ticket konnte nicht aktualisiert werden.',
deleteError: 'Ticket konnte nicht gelöscht werden.',
emptyMessage: 'Bitte beschreibe den Fehler.',
@@ -1253,11 +1274,39 @@ export const de = {
open: 'Offen',
/** Gruppe „Geschlossen": erledigte Tickets mit Changelog. */
closed: 'Geschlossen',
/** Gruppe „Gelöscht": in den Papierkorb verschobene Tickets (wiederherstellbar). */
deleted: 'Gelöscht',
},
/** Leertext je gefilterter Ansicht. */
emptyDialog: 'Aktuell keine Tickets im Dialog.',
emptyOpen: 'Keine offenen Tickets.',
emptyClosed: 'Noch keine geschlossenen Tickets.',
emptyDeleted: 'Der Papierkorb ist leer.',
/** Suche + Kategorie-Filter. */
searchPlaceholder: 'Tickets durchsuchen …',
categoryFilterLabel: 'Nach Kategorie filtern',
categoryAll: 'Alle Kategorien',
emptySearch: 'Keine Tickets passen zu Suche/Filter.',
/** Daumen-Rückmeldung auf gelösten Tickets. */
helpfulQuestion: 'Hat dir das geholfen?',
helpfulYes: '👍 Ja',
helpfulNo: '👎 Nein',
helpfulThanks: 'Danke für die Rückmeldung!',
helpfulReopenNote: 'Schade — ich öffne das Ticket wieder. Bitte schreib kurz, was noch fehlt.',
/** Anhänge auf einem Ticket. */
attachmentsLabel: 'Anhänge',
addAttachment: 'Foto anhängen',
removeAttachment: 'Anhang entfernen',
attachmentAdded: 'Anhang hinzugefügt.',
attachmentRemoved: 'Anhang entfernt.',
attachmentError: 'Anhang konnte nicht verarbeitet werden.',
/** Push-Benachrichtigungen (PWA). */
pushEnable: 'Benachrichtigungen aktivieren',
pushDisable: 'Benachrichtigungen aus',
pushEnabledToast: 'Benachrichtigungen aktiviert — du wirst informiert, sobald ich antworte.',
pushDisabledToast: 'Benachrichtigungen deaktiviert.',
pushDenied: 'Benachrichtigungen wurden im Browser blockiert. Bitte in den Browser-Einstellungen erlauben.',
pushUnavailable: 'Benachrichtigungen sind auf diesem Gerät nicht verfügbar.',
/** Changelog (fixNote) auf geschlossenen Tickets. */
changelogLabel: 'Was wurde geändert',
/** Überschrift des Frage/Antwort-Verlaufs (frühere Runden). */

View File

@@ -1,382 +1,469 @@
{
"_doc": "Human conflict resolutions for the import quarantine (HUMANQUESTION section D / C6). The importer consumes this to UN-QUARANTINE an animal: for a matching (name + dob) it accepts the given authoritative field(s) — `genotype`, `farbschlag`, and/or `dateOfDeath` (DD.MM.YYYY) — and skips the conflict. Special field `correctDob` (DD.MM.YYYY): the matched (name + dob) record is a DUPLICATE with a WRONG birthdate — remap its DOB to `correctDob` BEFORE dedup so it merges into the canonical same-named animal. Other override fields per resolution: `gender` (male|female|m|w) — fix a misread box-colour gender (applies to stammbaum AND Wurfchronik/docx animals via merge_and_resolve.apply_decision_overrides); `father`/`mother` — authoritative parent NAMES; optional `fatherDob`/`motherDob` (DD.MM.YYYY) disambiguate a parent when several same-named animals exist. Special top-level array `addAnimals` [{name, gender, zucht?, dob?}] materialises a non-resident stub gerbil for a KNOWN parent that has no own source record (e.g. a mother named only on a Wurfchronik litter), so the litter's parent link resolves. Key match = normalize(call-name) + dob, same identity as dedup. Maintained by god (Michael) as Julian/his wife answer the D-conflicts; originals (xlsx) stay read-only.",
"resolutions": [
{
"name": "Firefly von den Kleinen Chaoten",
"dob": "18.12.2019",
"decision": "D-locus = D- (the DD in one source was a typo)",
"genotype": "Aa c[chm]c[chm] D- Ee Gg PP Spsp",
"source": "Julian 2026-06-06 — HUMANQUESTION D3"
},
{
"name": "WildFire von den Kleinen Chaoten",
"dob": "05.10.2017",
"decision": "P-locus = PP (not P-)",
"genotype": "aa c[chm]c[chm] D- Ee gg PP spsp",
"source": "Julian 2026-06-06 — HUMANQUESTION D3"
},
{
"name": "Flint von den Kleinen Chaoten",
"dob": "23.12.2017",
"decision": "death date = 10.05.2021 (the 10.05.2022 variant was a year typo)",
"dateOfDeath": "10.05.2021",
"source": "Julian 2026-06-06 — HUMANQUESTION D5"
},
{
"name": "Molly of Black Forest",
"dob": "13.09.2021",
"decision": "death date = 03.05.2022 (source 03.05.2021 was a year typo → fell before birth)",
"dateOfDeath": "03.05.2022",
"source": "Julian 2026-06-06 — HUMANQUESTION D5"
},
{
"name": "Daja of Little Rose",
"dob": "16.05.2021",
"decision": "keep spsp (present in one source, omitted in the other) — 'presence wins' rule",
"genotype": "aa c[chm]c[chm] D- EE Gg P- spsp",
"source": "Julian 2026-06-06 — HUMANQUESTION D3 / Beibehalten-Regel"
},
{
"name": "Ichika von den Kleinen Chaoten",
"dob": "19.04.2020",
"decision": "keep ee[f] (the [f] fox-modifier was present in one source, dropped in the other) — 'presence wins' rule",
"genotype": "aa CC D- ee[f] Gg pp spsp",
"source": "Julian 2026-06-06 — HUMANQUESTION D3 / Beibehalten-Regel"
},
{
"name": "Zuleika von den Kleinen Chaoten",
"dob": "24.10.2015",
"decision": "D=DD, E=Ee (one small e), G=Gg (one small g), P=PP (two big P)",
"genotype": "aa c[chm]c[h] DD Ee Gg PP spsp",
"source": "Julian 2026-06-06 — HUMANQUESTION D3"
},
{
"name": "Milka of LennyLengo",
"dob": "09.12.2018",
"decision": "C-locus = Cc[h], E-locus = EE",
"genotype": "aa Cc[h] dd EE Gg P- Spsp",
"source": "Julian 2026-06-06 — HUMANQUESTION D3"
},
{
"name": "Silvain von den Kleinen Chaoten",
"dob": "27.03.2022",
"decision": "E-locus = ee, P-locus = Pp",
"genotype": "aa c[chm]c[chm] Dd ee[-] Gg Pp Spsp",
"source": "Julian 2026-06-06 — HUMANQUESTION D3"
},
{
"name": "Chelsea von den Kleinen Chaoten",
"dob": "15.10.2021",
"decision": "duplicate with wrong birthdate — same animal as Chelsea *02.04.2021; merge into it",
"correctDob": "02.04.2021",
"source": "Julian 2026-06-06 — HUMANQUESTION D3 (Chelsea Dublette)"
},
{
"name": "Vestra von den Schlossmäusen",
"dob": "08.02.2019",
"decision": "D-locus = DD",
"genotype": "Aa Cc[chm] DD EE GG PP Spsp [WP]",
"source": "Julian 2026-06-06 — HUMANQUESTION D4"
},
{
"name": "Victoria Welby gen. Welby v.d. Kleinen Chaoten",
"dob": "16.01.2023",
"decision": "E-locus = ee[f] (Fuchs). This is the mother of animal 'C' (c-29042024) — un-quarantining her links C's second parent. Name in v.d. spelling (workaround from Re-Import #2); both spellings now match after FIX-1 (canon_pair identity).",
"genotype": "Aa CC D- ee[f] Gg pp Spsp [DP]",
"source": "Julian 2026-06-06 — HUMANQUESTION D4"
},
{
"name": "Little Runner's Big Ben",
"dob": "03.02.2020",
"decision": "P-locus = Pp (not PP) — the two sources differed only at P",
"genotype": "Aa Cc[chm] DD Ee Gg Pp Spsp",
"source": "Julian 2026-06-07 — HUMANQUESTION D6"
},
{
"name": "Vance Jr. von den Kleinen Chaoten",
"dob": "10.04.2022",
"decision": "Sp-locus = spsp (kleines spsp, ungescheckt) — die Quellen unterschieden sich nur bei Sp (Spsp // spsp). C-Locus 'c[hm]' im Extrakt → als c[chm] normalisiert (kein gültiges Symbol; c[chm] = offensichtliche Absicht).",
"genotype": "aa Cc[chm] Dd Ee gg P- spsp",
"source": "Julian 2026-06-07 — HUMANQUESTION D6"
},
{
"name": "Skarlett von den Kleinen Chaoten",
"dob": "14.07.2013",
"decision": "birth 2013 + 4 years = death year 2017, no exact date → year-only convention (01.01.2017). Previous entry (17.04.2016) was wrong.",
"dateOfDeath": "01.01.2017",
"source": "Julian 2026-06-07 — HUMANQUESTION D7 (final Skarlett resolution)"
},
{
"name": "Kazu von den Kleinen Chaoten",
"dob": "23.04.2013",
"decision": "E-locus = ee[f], G-locus = GG (one source wrote UwUw = GG in international notation), P-locus = PP — resolves the 3 contested loci",
"genotype": "Aa Cc[chm] DD ee[f] GG PP Spsp",
"source": "Julian 2026-06-07 — HUMANQUESTION D6"
},
{
"name": "Hanami von den Kleinen Chaoten",
"dob": "10.09.2015",
"decision": "death date = 12.12.2019 (confirmed; the 14.01.2020 variant was wrong)",
"dateOfDeath": "12.12.2019",
"source": "Julian 2026-06-07 — HUMANQUESTION D5/D6 (letzter D6-Konflikt)"
},
{
"name": "Kazumi von den Kleinen Chaoten",
"dob": "23.04.2013",
"decision": "voller Genotyp von der Züchterin — löst die 4 strittigen Loci: A=Aa, G=GG, P=PP, Sp=Spsp",
"genotype": "Aa Cc[chm] DD ee[f] GG PP Spsp",
"source": "Julian/Züchterin 2026-06-07 — HUMANQUESTION D7"
},
{
"name": "Filou von den Kleinen Chaoten",
"dob": "24.11.2014",
"decision": "Sterbedatum = 31.08.2019 (die 31.10.2019-Variante war falsch); Gencode war einig",
"dateOfDeath": "31.08.2019",
"source": "Julian/Züchterin 2026-06-07 — HUMANQUESTION D7"
},
{
"name": "Sokrates von den Kleinen Chaoten",
"dob": "14.12.2015",
"decision": "D-Locus = D- (nicht Dd) + Sterbedatum = 20.05.2019 (nicht 2020). Genotyp = die einigen Loci aus dem Extrakt mit D auf D- gesetzt (Uw→G normalisiert).",
"genotype": "aa Cc[-] D- ee Gg Pp spsp",
"dateOfDeath": "20.05.2019",
"source": "Julian/Züchterin 2026-06-07 — HUMANQUESTION D7"
},
{
"name": "Percy of little runners",
"dob": "16.12.2017",
"decision": "P-Locus = Pp (die Quellen unterschieden sich nur bei P: PP // Pp)",
"genotype": "aa Cc[chm] DD Ee Gg Pp Spsp",
"source": "Julian/Züchterin 2026-06-07 — HUMANQUESTION D7"
},
{
"name": "Iwana of little runners",
"dob": "02.10.2018",
"decision": "P-Locus = Pp (die Quellen unterschieden sich nur bei P: PP // Pp)",
"genotype": "Aa CC DD Ee Gg Pp spsp",
"source": "Julian/Züchterin 2026-06-07 — HUMANQUESTION D7"
},
{
"name": "Osamu von den Kleinen Chaoten",
"dob": "10.12.2015",
"decision": "Sterbedatum = 18.12.2020 (die 01.10.2020-Variante war falsch); Gencode war einig, taub-Flag bleibt via 'Vorhandensein gewinnt'",
"dateOfDeath": "18.12.2020",
"source": "Julian/Züchterin 2026-06-07 — HUMANQUESTION D7"
},
{
"name": "Max von Privat",
"dob": "01.02.2013",
"decision": "Genotyp von der Züchterin bestätigt (D-=D-, P=PP); Todesjahr 2014 (kein genaues Datum → Jahr-only-Konvention 01.01.2014). Reject 4 on-file-Varianten: 04.02.2016 / 04.03.2016 / 2014-raw / 30.12.2015.",
"genotype": "aa c[chm]c[chm] D- EE GG PP spsp",
"dateOfDeath": "01.01.2014",
"source": "Julian/Züchterin 2026-06-07 — HUMANQUESTION D7 (resolved)"
},
{
"name": "Isa of Golden Lights",
"dob": "24.12.2014",
"decision": "Sterbedatum von der Züchterin (DOB-key war im extract teils leer; diese Zeile matcht die konfliktbehaftete Zeile mit dob=24.12.2014)",
"dateOfDeath": "21.07.2018",
"source": "Julian/Züchterin 2026-06-07 — HUMANQUESTION D7"
},
{
"name": "Jack II von den Kleinen Chaoten",
"dob": "14.02.2016",
"decision": "Sterbedatum von der Züchterin",
"dateOfDeath": "06.10.2019",
"source": "Julian/Züchterin 2026-06-07 — HUMANQUESTION D7"
},
{
"name": "Milon von den Kleinen Chaoten",
"dob": "27.11.2014",
"decision": "A-Locus = Aa (Quellen: Aa // aa — einziger strittiger Locus, Züchterin löst auf Aa); alle anderen Loci waren einig",
"genotype": "Aa Cc[chm] D- ee[f] gg Pp spsp",
"source": "Julian/Züchterin 2026-06-07 — HUMANQUESTION D7"
},
{
"name": "Sunny von PZ Karl",
"dob": "10.04.2014",
"decision": "Sterbedatum von der Züchterin; bestätigt = 'von PZ Karl' (nicht 'Sunny Sky of Fiomi')",
"dateOfDeath": "30.04.2018",
"source": "Julian/Züchterin 2026-06-07 — HUMANQUESTION D7 (Nachtrag)"
},
{
"name": "Dakota of sweet little mouse",
"dob": "30.01.2015",
"decision": "Genotyp von Julian/Züchterin: A-Locus=Aa, P-Locus=pp, Sp-Locus=Spsp (offene Loci); übrige bestätigt.",
"genotype": "Aa CC Dd Ee Gg pp Spsp",
"source": "Julian/Züchterin 2026-06-07 — HUMANQUESTION D7"
},
{
"name": "Banjo of Fiomi",
"dob": "06.07.2015",
"decision": "E-Locus Korrektur: Julian — 'Goldfuchs Starkschecke, nicht Gold Starkschecke' → E-Locus muss ee sein, nicht E-. Extract hatte AA CC DD E- Gg pp Spsp [WP]; korrigiert zu ee (Goldfuchs-Definition). Sohn von Dakota of sweet little mouse.",
"genotype": "AA CC DD ee Gg pp Spsp",
"farbschlag": "Goldfuchs Starkschecke",
"source": "Julian 2026-06-07 — HUMANQUESTION D7 (Sohn-Korrektur, nicht ursprüngliche D7-Liste)"
},
{
"name": "Joghurt von Privat",
"dob": "06.09.2013",
"decision": "Genotype aa Cc[-] D- ee UwUw PP spsp as confirmed by Julian.",
"genotype": "aa Cc[-] D- ee UwUw PP spsp",
"source": "Julian 2026-06-12"
},
{
"name": "Ken'ichi",
"dob": "01.03.2015",
"decision": "Genotype AA CC DD Ee GG PP spsp is correct, farbschlag is Agouti.",
"genotype": "AA CC DD Ee GG PP spsp",
"farbschlag": "Agouti",
"source": "Julian 2026-06-12"
},
{
"name": "Harumi",
"dob": "21.02.2015",
"decision": "Death date is 11.01.2018 (died in 2018).",
"dateOfDeath": "11.01.2018",
"source": "Julian 2026-06-12"
},
{
"name": "Kleiner Warnowrenner Elieus gen. Eragon",
"dob": "18.05.2016",
"decision": "Genotype is aa CC D- Ee Gg pp Spsp [DP] (C locus is CC, full color, duplicate colourpoint record resolved).",
"genotype": "aa CC D- Ee Gg pp Spsp [DP]",
"source": "Julian 2026-06-12"
},
{
"name": "Kazuya von den Kleinen Chaoten",
"dob": "22.08.2018",
"decision": "duplicate with wrong birthdate and parents — same animal as Kazuya *14.07.2019; merge into it and correct parents to Wilbur + Naho",
"correctDob": "14.07.2019",
"source": "Stammbaum von Kazuya.xlsx"
},
{
"name": "Kazuya von den Kleinen Chaoten",
"dob": "14.07.2019",
"decision": "father = Wilbur von den Kleinen Chaoten, mother = Naho von den Kleinen Chaoten (from Stammbaum von Kazuya.xlsx)",
"father": "Wilbur von den Kleinen Chaoten",
"mother": "Naho von den Kleinen Chaoten",
"source": "Stammbaum von Kazuya.xlsx"
},
{
"name": "Naho von den Kleinen Chaoten",
"dob": "20.07.2017",
"decision": "father = Osamu von den Kleinen Chaoten, mother = Montana v.d. Kleinen Chaoten (from Stammbaum von Kazuya.xlsx)",
"father": "Osamu von den Kleinen Chaoten",
"mother": "Montana v.d. Kleinen Chaoten",
"source": "Stammbaum von Kazuya.xlsx"
},
{
"name": "Mozart of Lennylengo",
"dob": "12.03.2017",
"decision": "gender = female (box colour in Stammbaum von Fire Kids.xlsx was misread as male). Mozart is the mother of Stich von Privatzucht Giessen's litter.",
"gender": "female",
"source": "Züchterin 2026-06-22 — Ticket #2 (Stich, Mutter fehlte)"
},
{
"name": "Yuki von den Kleinen Chaoten",
"dob": "03.02.2020",
"decision": "parents are Camaro (Chevrolet Camaro of Topolino) × Izumi, NOT Benjiro + Louis (chart-position misread).",
"father": "Chevrolet Camaro of Topolino",
"mother": "Izumi von den Kleinen Chaoten",
"source": "Züchterin 2026-06-22 — Ticket #23"
},
{
"name": "Gold v.d. Kleinen Chaoten",
"dob": "03.08.2023",
"decision": "mother should be Chelsea von den Kleinen Chaoten (not Gaida — chart-position misread). Father Trogir confirmed via contract 'Platin (Trogir.Chelsea)'.",
"father": "Trogir von den Kleinen Chaoten",
"mother": "Chelsea von den Kleinen Chaoten",
"source": "Züchterin 2026-06-22 — Ticket #36"
},
{
"name": "Arya Stark von den Kleinen Chaoten",
"dob": "30.06.2020",
"decision": "parents are Sansa Stark (mother) × Vance (father) — a sibling pairing (both *09.04.2019). Chart-position had scrambled the roles (mother=Enya, no father).",
"father": "Vance von den Kleinen Chaoten",
"mother": "Sansa Stark von den Kleinen Chaoten",
"source": "Züchterin 2026-06-22 — Ticket #16"
},
{
"name": "Tony v.d. Kleinen Chaoten",
"dob": "16.09.2015",
"decision": "father is Sammy von den Kleinen Chaoten (Wurfchronik L7-Wurf: 'Parents Sammy + Queenie'). Chart-position had fabricated 'Jack Jr.'.",
"father": "Sammy von den Kleinen Chaoten",
"mother": "Qamikaze Queen von den Schlossmäusen",
"source": "Züchterin 2026-06-22 — Ticket #12"
},
{
"name": "Odelia von den Kleinen Chaoten",
"dob": "21.06.2015",
"decision": "parents are Cash × Elena (Wurfchronik O6-Wurf). Chart-position had wrong parents (Chap × Julietta).",
"father": "Cash v.d. Kleinen Chaoten",
"mother": "Elena",
"source": "Züchterin 2026-06-22 — Ticket #15"
},
{
"name": "Jamie von den kleinen Chaoten",
"dob": "27.12.2010",
"decision": "father is Danny von Privatzucht Maintal (Wurfchronik J-Wurf: 'Eltern: Danny + Jana'). Chart-position had wrong/age-impossible parents.",
"father": "Danny von PZ Maintal",
"mother": "Jana of little longnoses",
"source": "Züchterin 2026-06-22 — Ticket #31"
},
{
"name": "Silver von den kleinen Chaoten",
"dob": "20.03.2013",
"decision": "parents are Taro (kept from U1-Wurf) × Beatrice (Wurfchronik F2-Wurf). Cross-page chart reference to Taro was unresolvable.",
"father": "Taro von den kleinen Chaoten",
"mother": "Beatrice von den kleinen Chaoten",
"source": "Züchterin 2026-06-22 — Ticket #11"
},
{
"name": "Roni",
"dob": "27.01.2022",
"decision": "gender = male. Roni is the FATHER (not mother) of the T21/Z21/22 litters (Wurfchronik names father=Roni, mother=Fumi). Box colour / chart had her typed female, so role normalisation wrongly put Roni in the mother slot and left the father empty. Setting male lets Roni take the father role; Fumi (added via addAnimals) fills the mother slot.",
"gender": "male",
"source": "Züchterin 2026-06-22 — Ticket 88389f8e (Akane: Roni ist Vater)"
},
{
"name": "Cherry Berry's Quqquluuruu",
"dob": "",
"decision": "gender = female (weisse Box = weiblich, blaue = maennlich). Pure pedigree ancestor without a birthdate, so the box-colour gender was never read — the Stammbaum showed a question mark instead of the female icon. dob MUST be empty so the name-only override key matches.",
"gender": "female",
"source": "Züchterin 2026-06-22 — Ticket 94892100 (Weibchen-Icon fehlt)"
},
{
"name": "Sunny von PZ Karl",
"dob": "10.04.2014",
"decision": "father is Bill von Privat, mother is Melly von Privat (from Stammbaum von Yurikas und Pintos Sohn.xlsx / Renner-Pro-3). The chart-position resolver had wrongly given Hiro of Golden Lights as father.",
"father": "Bill von Privat",
"mother": "Melly von Privat",
"source": "Züchterin 2026-06-22 — Ticket 0ba551a3 (Sunny falscher Vater)"
},
{
"name": "Danielle von den Kleinen Chaoten",
"dob": "04.03.2020",
"decision": "mother is Ella (Tochter von Louis+Roswitha, *10.06.2019) — Louis & Roswitha are Danielle's GRANDparents, not her parents. The father is an unnamed brother of Ella (a sibling pairing) with no own record, so he stays unknown. Two 'Ella' exist; motherDob pins the correct one (*10.06.2019), and the second Ella (*13.04.2023, born after Danielle) is age-impossible anyway.",
"mother": "Ella",
"motherDob": "10.06.2019",
"source": "Züchterin 2026-06-22 — Tickets f77aa4a6 / 4692fd5c (Danielle echte Mutter Ella)"
},
{
"name": "Unbekannt",
"dob": "15.02.2024",
"decision": "Nameless buck *15.02.2024 (son of Inochi gen. Picu) is recorded twice — once via 'Stammbaum von Picus Son.xlsx' (externalRef stammbaum-unbekannt-15022024-2) and once via 'Stammbaum von Alberto Kids.xlsx' (externalRef stammbaum-unbekannt-15022024). Same buck. The two reconstructed litters (Picus Son vs Alberto Kids) differ only by the mother's birth YEAR (a typo): mother 13.11.2023 vs 13.11.2022. Merge the two bucks and the two mothers into one each via mergeExternalRefs.",
"mergeExternalRefs": [
["stammbaum-unbekannt-15022024-2", "stammbaum-unbekannt-15022024"],
["stammbaum-unbekannt-13112023", "stammbaum-unbekannt-13112022"]
],
"source": "Züchterin 2026-06-22 — Ticket f618dcc3 (doppelter namenloser Bock)"
}
],
"addAnimals": [
{
"name": "Fumi von den Kleinen Chaoten",
"gender": "female",
"zucht": "Zucht der kleinen Chaoten",
"decision": "Mother of the T21/Z21/22 litters (Wurfchronik names mother=Fumi, father=Roni) but she has no own source record, so her litters showed an empty mother. Materialised as a non-resident stub so the parent link resolves; her own parents stay unknown.",
"source": "Züchterin 2026-06-22 — Ticket 88389f8e (Akane: Mutter Fumi)"
}
]
}
{
"_doc": "Human conflict resolutions for the import quarantine (HUMANQUESTION section D / C6). The importer consumes this to UN-QUARANTINE an animal: for a matching (name + dob) it accepts the given authoritative field(s) — `genotype`, `farbschlag`, and/or `dateOfDeath` (DD.MM.YYYY) — and skips the conflict. Special field `externalRef` (the dedup slug / animals.json id, e.g. \"unbekannt-13082025-3\"): matches ONE specific record even when several NAMELESS animals share the same (name=\"\" + dob) key — externalRef wins over the name/dob keys. An externalRef-only resolution (no `name`) does NOT register a name/dob key. Special field `correctDob` (DD.MM.YYYY): the matched (name + dob) record is a DUPLICATE with a WRONG birthdate — remap its DOB to `correctDob` BEFORE dedup so it merges into the canonical same-named animal. Other override fields per resolution: `gender` (male|female|m|w) — fix a misread box-colour gender (applies to stammbaum AND Wurfchronik/docx animals via merge_and_resolve.apply_decision_overrides); `father`/`mother` — authoritative parent NAMES; optional `fatherDob`/`motherDob` (DD.MM.YYYY) disambiguate a parent when several same-named animals exist. Special top-level array `addAnimals` [{name, gender, zucht?, dob?}] materialises a non-resident stub gerbil for a KNOWN parent that has no own source record (e.g. a mother named only on a Wurfchronik litter), so the litter's parent link resolves. Key match = normalize(call-name) + dob, same identity as dedup. Maintained by god (Michael) as Julian/his wife answer the D-conflicts; originals (xlsx) stay read-only.",
"resolutions": [
{
"name": "Firefly von den Kleinen Chaoten",
"dob": "18.12.2019",
"decision": "D-locus = D- (the DD in one source was a typo)",
"genotype": "Aa c[chm]c[chm] D- Ee Gg PP Spsp",
"source": "Julian 2026-06-06 — HUMANQUESTION D3"
},
{
"name": "WildFire von den Kleinen Chaoten",
"dob": "05.10.2017",
"decision": "P-locus = PP (not P-)",
"genotype": "aa c[chm]c[chm] D- Ee gg PP spsp",
"source": "Julian 2026-06-06 — HUMANQUESTION D3"
},
{
"name": "Flint von den Kleinen Chaoten",
"dob": "23.12.2017",
"decision": "death date = 10.05.2021 (the 10.05.2022 variant was a year typo)",
"dateOfDeath": "10.05.2021",
"source": "Julian 2026-06-06 — HUMANQUESTION D5"
},
{
"name": "Molly of Black Forest",
"dob": "13.09.2021",
"decision": "death date = 03.05.2022 (source 03.05.2021 was a year typo → fell before birth)",
"dateOfDeath": "03.05.2022",
"source": "Julian 2026-06-06 — HUMANQUESTION D5"
},
{
"name": "Daja of Little Rose",
"dob": "16.05.2021",
"decision": "keep spsp (present in one source, omitted in the other) — 'presence wins' rule",
"genotype": "aa c[chm]c[chm] D- EE Gg P- spsp",
"source": "Julian 2026-06-06 — HUMANQUESTION D3 / Beibehalten-Regel"
},
{
"name": "Ichika von den Kleinen Chaoten",
"dob": "19.04.2020",
"decision": "keep ee[f] (the [f] fox-modifier was present in one source, dropped in the other) — 'presence wins' rule",
"genotype": "aa CC D- ee[f] Gg pp spsp",
"source": "Julian 2026-06-06 — HUMANQUESTION D3 / Beibehalten-Regel"
},
{
"name": "Zuleika von den Kleinen Chaoten",
"dob": "24.10.2015",
"decision": "D=DD, E=Ee (one small e), G=Gg (one small g), P=PP (two big P)",
"genotype": "aa c[chm]c[h] DD Ee Gg PP spsp",
"source": "Julian 2026-06-06 — HUMANQUESTION D3"
},
{
"name": "Milka of LennyLengo",
"dob": "09.12.2018",
"decision": "C-locus = Cc[h], E-locus = EE",
"genotype": "aa Cc[h] dd EE Gg P- Spsp",
"source": "Julian 2026-06-06 — HUMANQUESTION D3"
},
{
"name": "Silvain von den Kleinen Chaoten",
"dob": "27.03.2022",
"decision": "E-locus = ee, P-locus = Pp",
"genotype": "aa c[chm]c[chm] Dd ee[-] Gg Pp Spsp",
"source": "Julian 2026-06-06 — HUMANQUESTION D3"
},
{
"name": "Chelsea von den Kleinen Chaoten",
"dob": "15.10.2021",
"decision": "duplicate with wrong birthdate — same animal as Chelsea *02.04.2021; merge into it",
"correctDob": "02.04.2021",
"source": "Julian 2026-06-06 — HUMANQUESTION D3 (Chelsea Dublette)"
},
{
"name": "Vestra von den Schlossmäusen",
"dob": "08.02.2019",
"decision": "D-locus = DD",
"genotype": "Aa Cc[chm] DD EE GG PP Spsp [WP]",
"source": "Julian 2026-06-06 — HUMANQUESTION D4"
},
{
"name": "Victoria Welby gen. Welby v.d. Kleinen Chaoten",
"dob": "16.01.2023",
"decision": "E-locus = ee[f] (Fuchs). This is the mother of animal 'C' (c-29042024) — un-quarantining her links C's second parent. Name in v.d. spelling (workaround from Re-Import #2); both spellings now match after FIX-1 (canon_pair identity).",
"genotype": "Aa CC D- ee[f] Gg pp Spsp [DP]",
"source": "Julian 2026-06-06 — HUMANQUESTION D4"
},
{
"name": "Little Runner's Big Ben",
"dob": "03.02.2020",
"decision": "P-locus = Pp (not PP) — the two sources differed only at P",
"genotype": "Aa Cc[chm] DD Ee Gg Pp Spsp",
"source": "Julian 2026-06-07 — HUMANQUESTION D6"
},
{
"name": "Vance Jr. von den Kleinen Chaoten",
"dob": "10.04.2022",
"decision": "Sp-locus = spsp (kleines spsp, ungescheckt) — die Quellen unterschieden sich nur bei Sp (Spsp // spsp). C-Locus 'c[hm]' im Extrakt → als c[chm] normalisiert (kein gültiges Symbol; c[chm] = offensichtliche Absicht).",
"genotype": "aa Cc[chm] Dd Ee gg P- spsp",
"source": "Julian 2026-06-07 — HUMANQUESTION D6"
},
{
"name": "Skarlett von den Kleinen Chaoten",
"dob": "14.07.2013",
"decision": "birth 2013 + 4 years = death year 2017, no exact date → year-only convention (01.01.2017). Previous entry (17.04.2016) was wrong.",
"dateOfDeath": "01.01.2017",
"source": "Julian 2026-06-07 — HUMANQUESTION D7 (final Skarlett resolution)"
},
{
"name": "Kazu von den Kleinen Chaoten",
"dob": "23.04.2013",
"decision": "E-locus = ee[f], G-locus = GG (one source wrote UwUw = GG in international notation), P-locus = PP — resolves the 3 contested loci",
"genotype": "Aa Cc[chm] DD ee[f] GG PP Spsp",
"source": "Julian 2026-06-07 — HUMANQUESTION D6"
},
{
"name": "Hanami von den Kleinen Chaoten",
"dob": "10.09.2015",
"decision": "death date = 12.12.2019 (confirmed; the 14.01.2020 variant was wrong)",
"dateOfDeath": "12.12.2019",
"source": "Julian 2026-06-07 — HUMANQUESTION D5/D6 (letzter D6-Konflikt)"
},
{
"name": "Kazumi von den Kleinen Chaoten",
"dob": "23.04.2013",
"decision": "voller Genotyp von der Züchterin — löst die 4 strittigen Loci: A=Aa, G=GG, P=PP, Sp=Spsp",
"genotype": "Aa Cc[chm] DD ee[f] GG PP Spsp",
"source": "Julian/Züchterin 2026-06-07 — HUMANQUESTION D7"
},
{
"name": "Filou von den Kleinen Chaoten",
"dob": "24.11.2014",
"decision": "Sterbedatum = 31.08.2019 (die 31.10.2019-Variante war falsch); Gencode war einig",
"dateOfDeath": "31.08.2019",
"source": "Julian/Züchterin 2026-06-07 — HUMANQUESTION D7"
},
{
"name": "Sokrates von den Kleinen Chaoten",
"dob": "14.12.2015",
"decision": "D-Locus = D- (nicht Dd) + Sterbedatum = 20.05.2019 (nicht 2020). Genotyp = die einigen Loci aus dem Extrakt mit D auf D- gesetzt (Uw→G normalisiert).",
"genotype": "aa Cc[-] D- ee Gg Pp spsp",
"dateOfDeath": "20.05.2019",
"source": "Julian/Züchterin 2026-06-07 — HUMANQUESTION D7"
},
{
"name": "Percy of little runners",
"dob": "16.12.2017",
"decision": "P-Locus = Pp (die Quellen unterschieden sich nur bei P: PP // Pp)",
"genotype": "aa Cc[chm] DD Ee Gg Pp Spsp",
"source": "Julian/Züchterin 2026-06-07 — HUMANQUESTION D7"
},
{
"name": "Iwana of little runners",
"dob": "02.10.2018",
"decision": "P-Locus = Pp (die Quellen unterschieden sich nur bei P: PP // Pp)",
"genotype": "Aa CC DD Ee Gg Pp spsp",
"source": "Julian/Züchterin 2026-06-07 — HUMANQUESTION D7"
},
{
"name": "Osamu von den Kleinen Chaoten",
"dob": "10.12.2015",
"decision": "Sterbedatum = 18.12.2020 (die 01.10.2020-Variante war falsch); Gencode war einig, taub-Flag bleibt via 'Vorhandensein gewinnt'",
"dateOfDeath": "18.12.2020",
"source": "Julian/Züchterin 2026-06-07 — HUMANQUESTION D7"
},
{
"name": "Max von Privat",
"dob": "01.02.2013",
"decision": "Genotyp von der Züchterin bestätigt (D-=D-, P=PP); Todesjahr 2014 (kein genaues Datum → Jahr-only-Konvention 01.01.2014). Reject 4 on-file-Varianten: 04.02.2016 / 04.03.2016 / 2014-raw / 30.12.2015.",
"genotype": "aa c[chm]c[chm] D- EE GG PP spsp",
"dateOfDeath": "01.01.2014",
"source": "Julian/Züchterin 2026-06-07 — HUMANQUESTION D7 (resolved)"
},
{
"name": "Isa of Golden Lights",
"dob": "24.12.2014",
"decision": "Sterbedatum von der Züchterin (DOB-key war im extract teils leer; diese Zeile matcht die konfliktbehaftete Zeile mit dob=24.12.2014)",
"dateOfDeath": "21.07.2018",
"source": "Julian/Züchterin 2026-06-07 — HUMANQUESTION D7"
},
{
"name": "Jack II von den Kleinen Chaoten",
"dob": "14.02.2016",
"decision": "Sterbedatum von der Züchterin",
"dateOfDeath": "06.10.2019",
"source": "Julian/Züchterin 2026-06-07 — HUMANQUESTION D7"
},
{
"name": "Milon von den Kleinen Chaoten",
"dob": "27.11.2014",
"decision": "A-Locus = Aa (Quellen: Aa // aa — einziger strittiger Locus, Züchterin löst auf Aa); alle anderen Loci waren einig",
"genotype": "Aa Cc[chm] D- ee[f] gg Pp spsp",
"source": "Julian/Züchterin 2026-06-07 — HUMANQUESTION D7"
},
{
"name": "Sunny von PZ Karl",
"dob": "10.04.2014",
"decision": "Sterbedatum von der Züchterin; bestätigt = 'von PZ Karl' (nicht 'Sunny Sky of Fiomi')",
"dateOfDeath": "30.04.2018",
"source": "Julian/Züchterin 2026-06-07 — HUMANQUESTION D7 (Nachtrag)"
},
{
"name": "Dakota of sweet little mouse",
"dob": "30.01.2015",
"decision": "Genotyp von Julian/Züchterin: A-Locus=Aa, P-Locus=pp, Sp-Locus=Spsp (offene Loci); übrige bestätigt.",
"genotype": "Aa CC Dd Ee Gg pp Spsp",
"source": "Julian/Züchterin 2026-06-07 — HUMANQUESTION D7"
},
{
"name": "Banjo of Fiomi",
"dob": "06.07.2015",
"decision": "E-Locus Korrektur: Julian — 'Goldfuchs Starkschecke, nicht Gold Starkschecke' → E-Locus muss ee sein, nicht E-. Extract hatte AA CC DD E- Gg pp Spsp [WP]; korrigiert zu ee (Goldfuchs-Definition). Sohn von Dakota of sweet little mouse.",
"genotype": "AA CC DD ee Gg pp Spsp",
"farbschlag": "Goldfuchs Starkschecke",
"source": "Julian 2026-06-07 — HUMANQUESTION D7 (Sohn-Korrektur, nicht ursprüngliche D7-Liste)"
},
{
"name": "Joghurt von Privat",
"dob": "06.09.2013",
"decision": "Genotype aa Cc[-] D- ee UwUw PP spsp as confirmed by Julian.",
"genotype": "aa Cc[-] D- ee UwUw PP spsp",
"source": "Julian 2026-06-12"
},
{
"name": "Ken'ichi",
"dob": "01.03.2015",
"decision": "Genotype AA CC DD Ee GG PP spsp is correct, farbschlag is Agouti.",
"genotype": "AA CC DD Ee GG PP spsp",
"farbschlag": "Agouti",
"source": "Julian 2026-06-12"
},
{
"name": "Harumi",
"dob": "21.02.2015",
"decision": "Death date is 11.01.2018 (died in 2018).",
"dateOfDeath": "11.01.2018",
"source": "Julian 2026-06-12"
},
{
"name": "Kleiner Warnowrenner Elieus gen. Eragon",
"dob": "18.05.2016",
"decision": "Genotype is aa CC D- Ee Gg pp Spsp [DP] (C locus is CC, full color, duplicate colourpoint record resolved).",
"genotype": "aa CC D- Ee Gg pp Spsp [DP]",
"source": "Julian 2026-06-12"
},
{
"name": "Kazuya von den Kleinen Chaoten",
"dob": "22.08.2018",
"decision": "duplicate with wrong birthdate and parents — same animal as Kazuya *14.07.2019; merge into it and correct parents to Wilbur + Naho",
"correctDob": "14.07.2019",
"source": "Stammbaum von Kazuya.xlsx"
},
{
"name": "Kazuya von den Kleinen Chaoten",
"dob": "14.07.2019",
"decision": "father = Wilbur von den Kleinen Chaoten, mother = Naho von den Kleinen Chaoten (from Stammbaum von Kazuya.xlsx)",
"father": "Wilbur von den Kleinen Chaoten",
"mother": "Naho von den Kleinen Chaoten",
"source": "Stammbaum von Kazuya.xlsx"
},
{
"name": "Naho von den Kleinen Chaoten",
"dob": "20.07.2017",
"decision": "father = Osamu von den Kleinen Chaoten, mother = Montana v.d. Kleinen Chaoten (from Stammbaum von Kazuya.xlsx)",
"father": "Osamu von den Kleinen Chaoten",
"mother": "Montana v.d. Kleinen Chaoten",
"source": "Stammbaum von Kazuya.xlsx"
},
{
"name": "Mozart of Lennylengo",
"dob": "12.03.2017",
"decision": "gender = female (box colour in Stammbaum von Fire Kids.xlsx was misread as male). Mozart is the mother of Stich von Privatzucht Giessen's litter.",
"gender": "female",
"source": "Züchterin 2026-06-22 — Ticket #2 (Stich, Mutter fehlte)"
},
{
"name": "Yuki von den Kleinen Chaoten",
"dob": "03.02.2020",
"decision": "parents are Camaro (Chevrolet Camaro of Topolino) × Izumi, NOT Benjiro + Louis (chart-position misread).",
"father": "Chevrolet Camaro of Topolino",
"mother": "Izumi von den Kleinen Chaoten",
"source": "Züchterin 2026-06-22 — Ticket #23"
},
{
"name": "Gold v.d. Kleinen Chaoten",
"dob": "03.08.2023",
"decision": "mother should be Chelsea von den Kleinen Chaoten (not Gaida — chart-position misread). Father Trogir confirmed via contract 'Platin (Trogir.Chelsea)'.",
"father": "Trogir von den Kleinen Chaoten",
"mother": "Chelsea von den Kleinen Chaoten",
"source": "Züchterin 2026-06-22 — Ticket #36"
},
{
"name": "Arya Stark von den Kleinen Chaoten",
"dob": "30.06.2020",
"decision": "parents are Sansa Stark (mother) × Vance (father) — a sibling pairing (both *09.04.2019). Chart-position had scrambled the roles (mother=Enya, no father).",
"father": "Vance von den Kleinen Chaoten",
"mother": "Sansa Stark von den Kleinen Chaoten",
"source": "Züchterin 2026-06-22 — Ticket #16"
},
{
"name": "Tony v.d. Kleinen Chaoten",
"dob": "16.09.2015",
"decision": "father is Sammy von den Kleinen Chaoten (Wurfchronik L7-Wurf: 'Parents Sammy + Queenie'). Chart-position had fabricated 'Jack Jr.'.",
"father": "Sammy von den Kleinen Chaoten",
"mother": "Qamikaze Queen von den Schlossmäusen",
"source": "Züchterin 2026-06-22 — Ticket #12"
},
{
"name": "Odelia von den Kleinen Chaoten",
"dob": "21.06.2015",
"decision": "parents are Cash × Elena (Wurfchronik O6-Wurf). Chart-position had wrong parents (Chap × Julietta).",
"father": "Cash v.d. Kleinen Chaoten",
"mother": "Elena",
"source": "Züchterin 2026-06-22 — Ticket #15"
},
{
"name": "Jamie von den kleinen Chaoten",
"dob": "27.12.2010",
"decision": "father is Danny von Privatzucht Maintal (Wurfchronik J-Wurf: 'Eltern: Danny + Jana'). Chart-position had wrong/age-impossible parents.",
"father": "Danny von PZ Maintal",
"mother": "Jana of little longnoses",
"source": "Züchterin 2026-06-22 — Ticket #31"
},
{
"name": "Silver von den kleinen Chaoten",
"dob": "20.03.2013",
"decision": "parents are Taro (kept from U1-Wurf) × Beatrice (Wurfchronik F2-Wurf). Cross-page chart reference to Taro was unresolvable.",
"father": "Taro von den kleinen Chaoten",
"mother": "Beatrice von den kleinen Chaoten",
"source": "Züchterin 2026-06-22 — Ticket #11"
},
{
"name": "Roni",
"dob": "27.01.2022",
"decision": "gender = male. Roni is the FATHER (not mother) of the T21/Z21/22 litters (Wurfchronik names father=Roni, mother=Fumi). Box colour / chart had her typed female, so role normalisation wrongly put Roni in the mother slot and left the father empty. Setting male lets Roni take the father role; Fumi (added via addAnimals) fills the mother slot.",
"gender": "male",
"source": "Züchterin 2026-06-22 — Ticket 88389f8e (Akane: Roni ist Vater)"
},
{
"name": "Cherry Berry's Quqquluuruu",
"dob": "",
"decision": "gender = female (weisse Box = weiblich, blaue = maennlich). Pure pedigree ancestor without a birthdate, so the box-colour gender was never read — the Stammbaum showed a question mark instead of the female icon. dob MUST be empty so the name-only override key matches.",
"gender": "female",
"source": "Züchterin 2026-06-22 — Ticket 94892100 (Weibchen-Icon fehlt)"
},
{
"name": "Sunny von PZ Karl",
"dob": "10.04.2014",
"decision": "father is Bill von Privat, mother is Melly von Privat (from Stammbaum von Yurikas und Pintos Sohn.xlsx / Renner-Pro-3). The chart-position resolver had wrongly given Hiro of Golden Lights as father.",
"father": "Bill von Privat",
"mother": "Melly von Privat",
"source": "Züchterin 2026-06-22 — Ticket 0ba551a3 (Sunny falscher Vater)"
},
{
"name": "Danielle von den Kleinen Chaoten",
"dob": "04.03.2020",
"decision": "mother is Ella (*10.06.2019); father is Makoto von den Kleinen Chaoten (*16.07.2019), Ellas juengerer Bruder — eine Geschwisterverpaarung (Ella ist die aeltere Schwester). Louis & Roswitha sind Danielles GROSSeltern, nicht ihre Eltern.",
"mother": "Ella",
"motherDob": "10.06.2019",
"source": "Züchterin 2026-06-23 — Ticket 4692fd5c (Vater Makoto)",
"father": "Makoto von den Kleinen Chaoten",
"fatherDob": "16.07.2019"
},
{
"name": "Unbekannt",
"dob": "15.02.2024",
"decision": "Nameless buck *15.02.2024 (son of Inochi gen. Picu) is recorded twice — once via 'Stammbaum von Picus Son.xlsx' (externalRef stammbaum-unbekannt-15022024-2) and once via 'Stammbaum von Alberto Kids.xlsx' (externalRef stammbaum-unbekannt-15022024). Same buck. The two reconstructed litters (Picus Son vs Alberto Kids) differ only by the mother's birth YEAR (a typo): mother 13.11.2023 vs 13.11.2022. Merge the two bucks and the two mothers into one each via mergeExternalRefs.",
"mergeExternalRefs": [
[
"stammbaum-unbekannt-15022024-2",
"stammbaum-unbekannt-15022024"
],
[
"stammbaum-unbekannt-13112023",
"stammbaum-unbekannt-13112022"
]
],
"source": "Züchterin 2026-06-22 — Ticket f618dcc3 (doppelter namenloser Bock)"
},
{
"name": "Mamta Mini v.d. Kleinen Chaoten",
"dob": "11.11.2023",
"decision": "E-Locus = Ee (das unbekannte zweite Allel ist erzwungen 'e', weil Vater Geely von den Kleinen Chaoten am E-Locus reinerbig ee=Fuchs ist und nur 'e' vererben kann). Eltern Geely (Vater) × Gaida (Mutter) verbindlich am Geburtswurf verankert (die chart-position-Heuristik lieferte sie bereits) — als Entscheidung/high gesetzt, damit der Wurf die Eltern sicher verknuepft.",
"genotype": "AA CC D- Ee Gg PP spsp",
"father": "Geely von den Kleinen Chaoten",
"mother": "Gaida von den Kleinen Chaoten",
"fatherDob": "04.03.2023",
"motherDob": "02.08.2022",
"source": "Züchterin 2026-06-22 — Tickets cc9ea3fe / 1a508c04 (Mamta Mini Ee[-]→Ee, Eltern Geely×Gaida)"
},
{
"name": "",
"externalRef": "unbekannt-13082025-3",
"decision": "Sp-Locus = spsp (KEINE Schecke). Das namenlose Weibchen (*13.08.2025, Quelle 'Stammbaum von Martin.xlsx') war im Quell-Stammbaum als Spsp notiert, ist aber ungescheckt — der Sp-Locus muss spsp sein. Der Farbschlag bleibt der genotyp-berechnete Kohlfuchsschimmel (ee[f] = Fuchsschimmel). externalRef pinnt genau dieses Tier (mehrere namenlose Tiere teilen das Datum 13.08.2025).",
"genotype": "aa Cc[chm] D- ee[f] Gg Pp spsp",
"source": "Züchterin 2026-06-22 — Ticket e09d6f22 (faelschlich Schecke, soll spsp)"
},
{
"name": "Catelyn Stark von den Kleinen Chaoten",
"dob": "11.08.2015",
"decision": "parents are Eddard Stark of Sunset Glow (father, *20.06.2012) x Milena von den Kleinen Chaoten (mother, *19.03.2014). Chart-position had mother=Katara and no father.",
"father": "Eddard Stark of Sunset Glow",
"fatherDob": "20.06.2012",
"mother": "Milena von den Kleinen Chaoten",
"motherDob": "19.03.2014",
"source": "Züchterin 2026-06-23 — Ticket 7bbc045c"
},
{
"name": "Gaida von den Kleinen Chaoten",
"dob": "02.08.2022",
"decision": "parents are Zhuāngzǐ von den Kleinen Chaoten (father) x Zaibunissa von den Kleinen Chaoten (mother), beide *21.02.2020 — eine Geschwisterverpaarung (Kinder von Nisha x Zenon). Import hatte faelschlich Nisha (Grossmutter) als Mutter und keinen Vater.",
"father": "Zhuāngzǐ von den Kleinen Chaoten",
"fatherDob": "21.02.2020",
"mother": "Zaibunissa von den Kleinen Chaoten",
"motherDob": "21.02.2020",
"source": "Züchterin 2026-06-23 — Ticket ba63325a"
},
{
"name": "Bentley",
"externalRef": "Wurfchronik Teil 1_page_0054.md-50505050-0003-4000-8000-000000000003",
"isResident": false,
"decision": "Nur Vorfahre (Clan of Black Forest), kein eigenes Zuchttier. Bestätigt von der Züchterin.",
"source": "Züchterin 2026-06-23 — Ticket 09bcac78"
},
{
"name": "Alexandria",
"externalRef": "Wurfchronik Teil 1_page_0054.md-50505050-0004-4000-8000-000000000004",
"isResident": false,
"decision": "Nur Vorfahre, kein eigenes Zuchttier (wie Bentley).",
"source": "Züchterin 2026-06-23 — Ticket 45cc501b"
},
{
"name": "Bugatti",
"externalRef": "Wurfchronik Teil 1_page_0054.md-40404040-0003-4000-8000-000000000003",
"isResident": false,
"decision": "Bugatti stammt aus Clan of Black Forest, war nie ihr Zuchttier — nur Vorfahre.",
"source": "Züchterin 2026-06-23 — Ticket 09bcac78"
},
{
"name": "Hiro of Golden Lights",
"dob": "13.02.2012",
"decision": "Eltern aus RennmausPro ergänzt: Wynn × Amidala",
"source": "Züchterin 2026-06-23 — Ticket f3ad5ec9 (Hiro-Ahnen aus RennmausPro)",
"father": "Wynn",
"fatherDob": "16.05.2011",
"mother": "Amidala",
"motherDob": "06.07.2011"
}
],
"addAnimals": [
{
"name": "Fumi von den Kleinen Chaoten",
"gender": "female",
"zucht": "Zucht der kleinen Chaoten",
"decision": "Mother of the T21/Z21/22 litters (Wurfchronik names mother=Fumi, father=Roni) but she has no own source record, so her litters showed an empty mother. Materialised as a non-resident stub so the parent link resolves; her own parents stay unknown.",
"source": "Züchterin 2026-06-22 — Ticket 88389f8e (Akane: Mutter Fumi)"
},
{
"name": "Wynn",
"gender": "male",
"dob": "16.05.2011"
},
{
"name": "Amidala",
"gender": "female",
"dob": "06.07.2011"
}
]
}

View File

@@ -1099,9 +1099,19 @@ def apply_conflict_decisions(merged, conflicts, path):
Returns the number of conflicts resolved. (god/HUMANQUESTION D.)"""
decisions_full = {} # (nameCanon, zuchtCanon, dob) -> r — when decision carries a Zucht
decisions_name = {} # (nameCanon, dob) -> r — fallback, decision has no Zucht
decisions_ref = {} # externalRef (merged-animal id) -> r — for NAMELESS animals whose
# (name="" + dob) key is shared by several records: the externalRef
# (the dedup slug, e.g. „unbekannt-13082025-3") pins exactly one.
try:
with open(path, encoding="utf-8") as fh:
for r in (json.load(fh).get("resolutions") or []):
ref = r.get("externalRef")
if ref:
decisions_ref[ref] = r
# An externalRef-only decision (no name) must NOT register a name/dob
# key — a ("", "") key would match every nameless, dateless animal.
if not r.get("name"):
continue
nc, zc = canon_pair(r.get("name", ""))
dob = norm_dob(r.get("dob", ""))
if zc:
@@ -1110,14 +1120,17 @@ def apply_conflict_decisions(merged, conflicts, path):
decisions_name[(nc, dob)] = r
except (OSError, ValueError):
return 0
if not decisions_full and not decisions_name:
if not decisions_full and not decisions_name and not decisions_ref:
return 0
resolved = 0
for a in merged:
nc, zc = canon_pair(a["name"])
dob = norm_dob(a["dob"])
d = decisions_full.get((nc, zc, dob)) or decisions_name.get((nc, dob))
# externalRef (the dedup id) wins — it is the most specific key and the only
# way to address one of several same-(name,dob) nameless animals.
d = decisions_ref.get(a.get("id")) or decisions_full.get((nc, zc, dob)) \
or decisions_name.get((nc, dob))
if not d:
continue
a["resolvedByDecision"] = True

View File

@@ -176,3 +176,261 @@ def looks_like_genotype(text):
if any(p.match(t) for p in _LOCUS_TOKEN.values()):
n += 1
return n >= 3
# ────────────────────────────────────────────────────────────────────────────
# Genotype → Farbschlag (German variety name)
#
# A faithful Python port of gerbil-manager-web/src/genetics/catalog.ts
# (`genotypeToFarbschlag` + the loci/genotype helpers it relies on). The engine
# is the single source of truth for the colour names; the IMPORT mirrors it here
# so the stored colorVarietyId can be DERIVED from a known genotype instead of a
# fragile free-text colour label (ticket cluster genetics-farbschlag).
#
# Allele symbols here use the CATALOG form (cchm / ch / ef), so we normalise the
# parser's '^'-form ('c^chm' -> 'cchm', 'e^f' -> 'ef') and treat '?' as unknown.
# Keep this in lockstep with catalog.ts — when the TS catalog changes, change here
# too (the round-trip tests in test_genotype.py guard the mapping).
# ────────────────────────────────────────────────────────────────────────────
# Alleles per locus, MOST-DOMINANT FIRST (mirror of loci.ts LOCI).
_FARB_LOCI = {
"A": ["A", "a"],
"C": ["C", "cchm", "ch"],
"D": ["D", "d"],
"E": ["E", "ef", "e"],
"G": ["G", "g"],
"P": ["P", "p"],
"Sp": ["Sp", "sp"],
"Re": ["Re", "re"],
}
_MARKER_LOCI = ("Sp", "Re", "Sls")
UNKNOWN_FARBSCHLAG = "Unbekannter Farbschlag"
# BASE_COLORS — order matters (first match wins). Mirror of catalog.ts BASE_COLORS.
# Each entry: (name, {locus: token, ...}); omitted loci are wildcards.
_BASE_COLORS = [
# ── Frozen names (DB-key contract) ──
("REW", {"C": "ch", "P": "p"}),
("Hermelin", {"A": "a", "C": "ch", "D": "D", "P": "P"}),
("Himalaya", {"A": "A", "C": "ch", "D": "D", "P": "P"}),
("Zobel", {"A": "a", "C": "cchm", "D": "D", "E": "E", "G": "g", "P": "P"}),
("Rotaugenschimmel", {"C": "C", "D": "D", "E": "ef", "G": "G", "P": "p"}),
("Agouti", {"A": "A", "C": "C", "D": "D", "E": "E", "G": "G", "P": "P"}),
("Schwarz", {"A": "a", "C": "C", "D": "D", "E": "E", "G": "G", "P": "P"}),
("Silberagouti", {"A": "A", "C": "C", "D": "D", "E": "E", "G": "g", "P": "P"}),
("Anthrazit", {"A": "a", "C": "C", "D": "D", "E": "E", "G": "g", "P": "P"}),
("Algierfuchs", {"A": "A", "C": "C", "D": "D", "E": "e", "G": "G", "P": "P"}),
("Blau", {"A": "a", "C": "C", "D": "d", "E": "E", "G": "G", "P": "P"}),
("Gold", {"A": "A", "C": "C", "D": "D", "E": "E", "G": "G", "P": "p"}),
("Platin", {"A": "a", "C": "C", "D": "D", "E": "E", "G": "G", "P": "p"}),
("Goldfuchs", {"A": "A", "C": "C", "D": "D", "E": "e", "G": "G", "P": "p"}),
("Rotfuchs", {"A": "a", "C": "C", "D": "D", "E": "e", "G": "G", "P": "p"}),
("Dilute Gold", {"A": "A", "C": "C", "D": "d", "E": "E", "G": "G", "P": "p"}),
("Dilute Platin", {"A": "a", "C": "C", "D": "d", "E": "E", "G": "G", "P": "p"}),
# ── baseportal.de varieties ──
("Altweiss (REW)", {"A": "a", "C": "C", "D": "D", "E": "E", "G": "g", "P": "p"}),
("Apricot (Blassfuchs)", {"A": "A", "C": "C", "D": "D", "E": "e", "G": "g", "P": "p"}),
("Blaufuchs", {"A": "a", "C": "C", "D": "D", "E": "e", "G": "g", "P": "P"}),
("C-Separator", {"A": "a", "C": "C", "D": "D", "E": "e", "G": "g", "P": "p"}),
("Elfenbein", {"A": "A", "C": "C", "D": "D", "E": "E", "G": "g", "P": "p"}),
("Kohlfuchs", {"A": "a", "C": "C", "D": "D", "E": "e", "G": "G", "P": "P"}),
("Polarfuchs", {"A": "A", "C": "C", "D": "D", "E": "e", "G": "g", "P": "P"}),
("Saphir", {"A": "a", "C": "C", "D": "D", "E": "E", "G": "G", "P": "p"}),
("Orangeschimmel", {"A": "A", "C": "C", "D": "D", "E": "ef", "G": "G", "P": "P"}),
("Topas", {"A": "A", "C": "C", "D": "D", "E": "E", "G": "G", "P": "p"}),
("Platin-Hell", {"A": "a", "C": "C", "D": "D", "E": "E", "G": "G", "P": "p"}),
("Dilute Agouti", {"A": "A", "C": "C", "D": "d", "E": "E", "G": "G", "P": "P"}),
("Dilute Silberagouti", {"A": "A", "C": "C", "D": "d", "E": "E", "G": "g", "P": "P"}),
("Dilute Kohlfuchs", {"A": "a", "C": "C", "D": "d", "E": "e", "G": "G", "P": "P"}),
("Dilute Anthrazit", {"A": "a", "C": "C", "D": "d", "E": "E", "G": "g", "P": "P"}),
("Dilute Algierfuchs", {"A": "A", "C": "C", "D": "d", "E": "e", "G": "G", "P": "P"}),
("Dilute Goldfuchs", {"A": "A", "C": "C", "D": "d", "E": "e", "G": "G", "P": "p"}),
("Dilute Rotfuchs", {"A": "a", "C": "C", "D": "d", "E": "e", "G": "G", "P": "p"}),
("Dilute Polarfuchs", {"A": "A", "C": "C", "D": "d", "E": "e", "G": "g", "P": "P"}),
("Silberschimmel", {"C": "C", "D": "D", "E": "ef", "G": "g", "P": "P"}),
("Polarfuchsschimmel", {"A": "A", "C": "C", "D": "D", "E": "ef", "G": "g", "P": "P"}),
("Algierfuchsschimmel", {"A": "A", "C": "C", "D": "D", "E": "ef", "G": "G", "P": "P"}),
("Kohlfuchsschimmel", {"A": "a", "C": "C", "D": "D", "E": "ef", "G": "G", "P": "P"}),
("Blaufuchsschimmel", {"A": "a", "C": "C", "D": "D", "E": "ef", "G": "g", "P": "P"}),
("Kohlfuchs, hell", {"A": "a", "C": "C", "D": "D", "E": "e", "G": "G", "P": "P"}),
("Goldfuchs, hell", {"A": "A", "C": "C", "D": "D", "E": "e", "G": "G", "P": "p"}),
("Goldfuchsschimmel", {"A": "A", "C": "C", "D": "D", "E": "ef", "G": "G", "P": "p"}),
("Gold-Hell", {"A": "A", "C": "C", "D": "D", "E": "E", "G": "G", "P": "p"}),
("Blaufuchs, hell", {"A": "a", "C": "C", "D": "D", "E": "e", "G": "g", "P": "P"}),
("Rotfuchsschimmel", {"A": "a", "C": "C", "D": "D", "E": "ef", "G": "G", "P": "p"}),
("Polarfuchs, hell", {"A": "A", "C": "C", "D": "D", "E": "e", "G": "g", "P": "P"}),
("Kohlfuchsschimmel, hell", {"A": "a", "C": "C", "D": "D", "E": "ef", "G": "G", "P": "P"}),
("Rotfuchs, hell", {"A": "a", "C": "C", "D": "D", "E": "e", "G": "G", "P": "p"}),
("Kohlfuchs-Hell", {"A": "a", "C": "C", "D": "D", "E": "e", "G": "G", "P": "P"}),
("Algierfuchs, hell", {"A": "A", "C": "C", "D": "D", "E": "e", "G": "G", "P": "P"}),
("Dilute Topas", {"A": "A", "C": "C", "D": "d", "E": "E", "G": "G", "P": "p"}),
("Dilute Blaufuchs", {"A": "a", "C": "C", "D": "d", "E": "e", "G": "g", "P": "P"}),
# ── c^chm colourpoint varieties ──
("Marder", {"A": "a", "C": "cchm", "D": "D", "E": "E", "G": "G", "P": "P"}),
("Siam", {"A": "a", "C": "cchm/ch", "D": "D", "E": "E", "G": "G", "P": "P"}),
("Zobel-Hell", {"A": "a", "C": "cchm/ch", "D": "D", "E": "E", "G": "g", "P": "P"}),
("CP-Agouti", {"A": "A", "C": "cchm", "D": "D", "E": "E", "G": "G", "P": "P"}),
("CP-Agouti-Hell", {"A": "A", "C": "cchm/ch", "D": "D", "E": "E", "G": "G", "P": "P"}),
("CP-Silberagouti", {"A": "A", "C": "cchm", "D": "D", "E": "E", "G": "g", "P": "P"}),
("CP-Silberagouti-Hell", {"A": "A", "C": "cchm/ch", "D": "D", "E": "E", "G": "g", "P": "P"}),
("CP-Algierfuchs", {"A": "A", "C": "cchm", "D": "D", "E": "e", "G": "G", "P": "P"}),
("CP-Algierfuchs-Hell", {"A": "A", "C": "cchm/ch", "D": "D", "E": "e", "G": "G", "P": "P"}),
("CP-Polarfuchs", {"A": "A", "C": "cchm", "D": "D", "E": "e", "G": "g", "P": "P"}),
("CP-Polarfuchs-Hell", {"A": "A", "C": "cchm/ch", "D": "D", "E": "e", "G": "g", "P": "P"}),
("CP-Fuchs", {"A": "A", "C": "cchm", "D": "d", "E": "e", "G": "G", "P": "P"}),
("CP-Fuchs-Hell", {"A": "A", "C": "cchm/ch", "D": "d", "E": "e", "G": "G", "P": "P"}),
("CP-Blaufuchs", {"A": "A", "C": "cchm", "D": "d", "E": "e", "G": "g", "P": "P"}),
("CP-Orangeschimmel", {"C": "cchm", "D": "D", "E": "ef", "G": "G", "P": "P"}),
("CP-Orangeschimmel-Hell", {"C": "cchm/ch", "D": "D", "E": "ef", "G": "G", "P": "P"}),
]
def _normalize_allele(a):
"""Parser allele form -> catalog form. 'c^chm'->'cchm', 'e^f'->'ef', '-'/None->'?'."""
if a is None or a == "-":
return "?"
return a.replace("^", "")
def _resolve_allele_pair(locus, pair):
"""GEN-5 unknown-allele rule (mirror of genotype.ts resolveAllelePair).
An unknown '?' is a COPY of the known partner; both unknown -> wild-type
(markers default to the unmarked recessive)."""
a = _normalize_allele(pair[0] if len(pair) > 0 else "?")
b = _normalize_allele(pair[1] if len(pair) > 1 else "?")
a_unknown = a == "?"
b_unknown = b == "?"
if not a_unknown and not b_unknown:
return [a, b]
if a_unknown and b_unknown:
alleles = _FARB_LOCI.get(locus, ["?"])
fb = alleles[-1] if locus in _MARKER_LOCI else alleles[0]
return [fb, fb]
known = b if a_unknown else a
return [known, known]
def _dominance_rank(locus, allele):
alleles = _FARB_LOCI.get(locus, [])
return alleles.index(allele) if allele in alleles else len(alleles)
def _dominant_allele(locus, a, b):
return a if _dominance_rank(locus, a) <= _dominance_rank(locus, b) else b
def _locus_token(mapped, locus):
"""Expressed token at a locus (mirror of catalog.ts locusToken)."""
x, y = _resolve_allele_pair(locus, mapped.get(locus, ["?", "?"]))
if locus == "E":
if x == y:
return x
if (x == "e" and y == "ef") or (x == "ef" and y == "e"):
return "ef"
return _dominant_allele("E", x, y)
return _dominant_allele(locus, x, y)
def _e_family(mapped):
"""E-locus family tag ('Fuchs'/'Fuchsschimmel'/'Schimmel') or None."""
x, y = _resolve_allele_pair("E", mapped.get("E", ["?", "?"]))
if x == "e" and y == "e":
return "Fuchs"
if (x == "e" and y == "ef") or (x == "ef" and y == "e"):
return "Fuchsschimmel"
if x == "ef" and y == "ef":
return "Schimmel"
return None
def _entry_in_e_family(name, tokens, family):
if tokens.get("E") is None:
return False
n = name.lower()
if family == "Fuchsschimmel":
return "fuchsschimmel" in n
if family == "Schimmel":
return "schimmel" in n and "fuchsschimmel" not in n
return "schimmel" not in n
def _matches(mapped, tokens):
return all(_locus_token(mapped, locus) == tok for locus, tok in tokens.items())
def _base_colour_for(mapped):
family = _e_family(mapped)
if family:
for name, tokens in _BASE_COLORS:
if _entry_in_e_family(name, tokens, family) and _matches(mapped, tokens):
return name
return None
for name, tokens in _BASE_COLORS:
if _matches(mapped, tokens):
return name
return None
def _colourpoint_name(mapped):
"""C-locus colourpoint NAMING transform (mirror of catalog.ts colourpointName)."""
c = _resolve_allele_pair("C", mapped.get("C", ["?", "?"]))
if "C" in c:
return None
if c[0] == "ch" and c[1] == "ch":
return None
both_cchm = c[0] == "cchm" and c[1] == "cchm"
agouti = "A" in _resolve_allele_pair("A", mapped.get("A", ["?", "?"]))
if not agouti and _e_family(mapped) is None:
g1, g2 = _resolve_allele_pair("G", mapped.get("G", ["?", "?"]))
grey = g1 == "g" and g2 == "g"
if grey:
return "Zobel" if both_cchm else "Zobel-Hell"
return "Marder" if both_cchm else "Siam"
# Name the colour as if C were full, then prefix 'CP-'.
forced = dict(mapped)
forced["C"] = ["C", "C"]
base = _base_colour_for(forced)
if not base:
return None
DILUTE = "Dilute "
if base.startswith(DILUTE):
return f"{DILUTE}CP-{base[len(DILUTE):]}{'' if both_cchm else '-Hell'}"
return f"CP-{base}{'' if both_cchm else '-Hell'}"
_CATEGORY_NAMES = {
"Standard", "Colourpoint", "Dilute",
"Fuchs", "Fuchsschimmel", "Schimmel",
"Colourpoint Dilute",
}
def genotype_to_farbschlag(mapped):
"""Resolve a parsed `mapped8locus` dict to its German Farbschlag (BASE name,
WITHOUT Schecke/Rex modifiers), or `UNKNOWN_FARBSCHLAG`.
`mapped` is genotype.parse(...)['mapped8locus'] (allele '^'-form / '-' / missing
loci tolerated). Modifiers (Schecke/Rex) are intentionally OMITTED — the import
stores them as the genotype's Sp/Re loci and via the colour label, not in the
catalog colorVarietyId. Mirror of catalog.ts genotypeToFarbschlag (minus the
appended modifiers).
"""
if not mapped:
return UNKNOWN_FARBSCHLAG
# REW check: both C reduced (no full 'C') AND pink-eyed (pp).
c0, c1 = _resolve_allele_pair("C", mapped.get("C", ["?", "?"]))
p0, p1 = _resolve_allele_pair("P", mapped.get("P", ["?", "?"]))
c_reduced = lambda c: c in ("cchm", "ch")
if c_reduced(c0) and c_reduced(c1) and p0 == "p" and p1 == "p":
return "REW"
base_name = _colourpoint_name(mapped) or _base_colour_for(mapped)
if not base_name or base_name in _CATEGORY_NAMES:
return UNKNOWN_FARBSCHLAG
return base_name
def farbschlag_from_genotype_string(raw):
"""Convenience: raw genotype string -> Farbschlag base name (or UNKNOWN)."""
return genotype_to_farbschlag(parse(raw).get("mapped8locus") or {})

View File

@@ -5,6 +5,8 @@ import uuid
import sys
from datetime import datetime
import genotype as gt
# Prevent encoding crashes on Windows consoles when printing unicode
if sys.platform.startswith('win'):
try:
@@ -422,33 +424,39 @@ def get_dedup_name_key(name):
return "".join(c for c in n if c.isalnum())
def clean_color_name(c_desc):
"""Normalise a free-text colour label to a catalog key + Schecke flag.
Returns (clean_name, is_schecke). A PARENTHETICAL „(schimmel)" is NOT a
definitive Schimmel — the breeder writes it to mean „könnte sich später als
Schimmel entpuppen" (ticket e22764aa). So we STRIP the „(…)" instead of
folding it into the name (which used to turn „Blaufuchs(schimmel)" into the
wrong „blaufuchsschimmel"); the still-uncertain Schimmel-modifier is carried
by the genotype (ee[-] = Fuchs, Schimmel unknown), not the colour label.
"""
if not c_desc:
return "", False
# Lowercase and strip
c = c_desc.lower().strip()
# Check for Schecke
is_schecke = False
if re.search(r'\bsp\b|\bsp\d|\bsp[*(²³]|\bspotted|\bschecke|[- ]sp\b|\w+sp\b', c):
is_schecke = True
# Standardize parentheticals for schimmel
c = c.replace("(schimmel)", "schimmel")
c = c.replace("(schimmel-hell)", "schimmel hell")
c = c.replace("(schimmel hell)", "schimmel hell")
# Strip schecke/sp markers and any trailing text starting from sp
c = re.sub(r'\([- ]?sp(otted)?\)', '', c) # handles (-sp)
c = re.sub(r'[- ]?sp(otted)?\b.*', '', c) # handles -sp(k), -sp*(k), -sp, etc.
c = re.sub(r'[- ]?schecke\b.*', '', c)
c = re.sub(r'[- ]?spotted\b.*', '', c)
# Strip any other parentheticals, symbols, or trailing stars/numbers
# Strip any other parentheticals (incl. „(schimmel)" = „möglich/unbestimmt"),
# symbols, or trailing stars/numbers. The parenthetical is deliberately NOT
# promoted to a definitive part of the colour name (ticket e22764aa).
c = re.sub(r'\s*\(.*?\)\s*', ' ', c)
c = re.sub(r'[²³*]', '', c)
c = c.strip()
# Mapping table for abbreviations, typos, and specific combinations
mapping = {
"antra": "anthrazit",
@@ -476,27 +484,87 @@ def clean_color_name(c_desc):
return c, is_schecke
def resolve_color_and_genotype(color_val, existing_genotype, variety_map, variety_genotypes):
if not color_val:
return None, existing_genotype
color_str = str(color_val).strip()
clean_name, is_schecke = clean_color_name(color_str)
# Match color in variety_map
color_variety_id = None
def _match_color_label(clean_name, variety_map):
"""Map a cleaned colour label to a ColorVariety id (text-only path).
Exact name wins; otherwise pick the LONGEST/most-specific substring match
(ticket 3f5942a2 — the old code broke on the FIRST substring hit, so „Goldfuchs"
matched the shorter „Gold" first). Among substring candidates the longest seed
name wins, then the longest clean_name overlap; ties broken deterministically.
"""
if not clean_name:
return None
if clean_name in variety_map:
color_variety_id = variety_map[clean_name]
else:
for seed_name, seed_id in variety_map.items():
if seed_name in clean_name or clean_name in seed_name:
color_variety_id = seed_id
break
# Update genotype if it's a Schecke
return variety_map[clean_name]
candidates = []
for seed_name, seed_id in variety_map.items():
if not seed_name:
continue
if seed_name in clean_name or clean_name in seed_name:
# Specificity score: prefer the longer seed name (more specific),
# then the closeness of lengths so „goldfuchs" beats „gold" for the
# label „goldfuchs".
candidates.append((len(seed_name), -abs(len(seed_name) - len(clean_name)),
seed_name, seed_id))
if not candidates:
return None
candidates.sort(reverse=True)
return candidates[0][3]
def resolve_color_and_genotype(color_val, existing_genotype, variety_map, variety_genotypes):
"""Resolve a gerbil's stored ColorVariety id + genotype.
GENOTYPE WINS (ticket cluster genetics-farbschlag): when a parseable genotype
is present and the genetics engine (genotype.genotype_to_farbschlag — a faithful
Python mirror of catalog.ts) computes a KNOWN catalog variety, that variety is
authoritative for colorVarietyId. The free-text colour label is only a fallback
(no genotype, or genotype resolves to „Unbekannt"). This fixes the imports where
the source label ignored a locus (dd → „Agouti" instead of „Dilute Agouti",
ee → „Gold" instead of „Goldfuchs", parenthetical „(schimmel)", …).
Returns (color_variety_id, genotype). `genotype` is the (possibly Schecke-
annotated) genotype STRING — never silently flips an explicit spsp to Spsp.
"""
if not color_val and not existing_genotype:
return None, existing_genotype
clean_name, is_schecke = clean_color_name(str(color_val).strip()) if color_val else ("", False)
# 1) Genotype-derived variety (authoritative when it resolves to a known name).
# GUARD (VORSICHTIG): only trust the genotype when it parsed CLEANLY enough to
# decide a colour — both the C and E loci must be mapped. The breeder sometimes
# writes the genotype in the COMPACT catalog notation („cchmcchm", „efef",
# „chch") which this parser leaves UNMAPPED (it expects the bracketed „c[chm]"
# form); a dropped C/E locus would silently read as wild-type and mis-recolour
# an otherwise-correct animal (e.g. Marder→Schwarz, Orangeschimmel→Agouti). When
# the parse is incomplete we keep the source text label instead.
color_variety_id = None
geno_name = None
if existing_genotype:
try:
mapped = gt.parse(existing_genotype).get("mapped8locus") or {}
except Exception:
mapped = {}
if mapped.get("C") and mapped.get("E"):
fs = gt.genotype_to_farbschlag(mapped)
if fs and fs != gt.UNKNOWN_FARBSCHLAG:
geno_name = fs
color_variety_id = variety_map.get(fs.strip().lower())
# 2) Fall back to the text label when the genotype gave nothing usable.
if not color_variety_id:
color_variety_id = _match_color_label(clean_name, variety_map)
# Update genotype if the LABEL says Schecke — but never override an explicit
# Sp-locus already present in the source genotype (ticket e09d6f22: a source
# „spsp" must NOT be flipped to „Spsp" just because the label looked scheckig;
# the source genotype is authoritative for the Sp-locus). Only ADD Spsp when
# the genotype carries no Sp token at all.
genotype = existing_genotype
if is_schecke:
if genotype:
if "spsp" in genotype:
genotype = genotype.replace("spsp", "Spsp")
elif "Spsp" not in genotype and "Sp" not in genotype:
if "Sp" not in genotype and "sp" not in genotype:
genotype = f"{genotype} Spsp".strip()
else:
canonical = variety_genotypes.get(color_variety_id)
@@ -2948,6 +3016,51 @@ def main():
print(f"Deduplicated to {len(resolved_gerbils)} unique gerbil records.")
# ── isResident-Overrides (Mensch-Entscheidung) ────────────────────────────
# NACH dem Dedup anwenden, sonst würde die Dedup-Zusammenführung (IsResident=True
# falls eine Variante resident ist) sie wieder überschreiben. Matcht primär über
# `externalRef` (präzise, auch für namenlose Tiere) oder sonst über
# normalize(call-name)+ISO-dob (leere dob = name-only, für Vorfahren ohne Datum).
_isres_by_extref = {}
_isres_by_namedob = {}
for d in _decisions:
if "isResident" not in d:
continue
val = bool(d["isResident"])
er = (d.get("externalRef") or "").strip()
if er:
_isres_by_extref[er] = val
nm = d.get("name")
if nm is not None:
ck = normalize_name(get_call_name(nm or ""))
iso = parse_date(d.get("dob")) if d.get("dob") else ""
_isres_by_namedob[(ck, iso or "")] = val
_isres_applied = 0
if _isres_by_extref or _isres_by_namedob:
for g in resolved_gerbils:
er = g.get("ExternalRef") or ""
ck = normalize_name(get_call_name(g.get("Name") or ""))
iso = g.get("DateOfBirth") or ""
if er and er in _isres_by_extref:
val = _isres_by_extref[er]
elif er and any(er.endswith(k) for k in _isres_by_extref):
val = next(v for k, v in _isres_by_extref.items() if er.endswith(k))
elif (ck, iso) in _isres_by_namedob:
val = _isres_by_namedob[(ck, iso)]
elif (ck, "") in _isres_by_namedob:
val = _isres_by_namedob[(ck, "")]
else:
continue
if g.get("IsResident") != val:
g["IsResident"] = val
# Ein Nicht-Bestandstier ist kein eigenes Zuchttier → den eigenen
# Zucht-Breeder entfernen (nur, wenn er auf die eigene Zucht zeigt).
if not val and g.get("OriginBreeder") == "Zucht der kleinen Chaoten":
g["OriginBreeder"] = None
_isres_applied += 1
if _isres_applied:
print(f"isResident-Overrides angewandt: {_isres_applied}")
# Apply age-based death threshold (6.0 years) to all resolved gerbils
dt_now = datetime.now()
for g in resolved_gerbils:

View File

@@ -0,0 +1,233 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Triage-Helfer: Beantwortet die Gegenfragen der Züchterin zu RPRO3-Namensdubletten.
Liefert pro Variante (A/B/C … exakt wie im Rückfrage-Ticket) die Daten, die die
Züchterin typischerweise sehen will: Geburtsdatum, Farbe, **Gencode (Fcode)**,
Herkunft, Eltern (Vater/Mutter) sowie Nachzucht inkl. Co-Elternteil (Partner).
Die Variantenbildung/Label-Vergabe spiegelt rpro3_tickets.py + compare_rpro3.dedup
exakt (Union-Find über gleiche Namen; Filter „informativ"; Sortierung
(not is_own, -count) → A,B,C…). Im Gegensatz zum Ticket wird hier über den GESAMTEN
Cluster aggregiert (rpro3_tickets kappt rids auf 4 Nachzucht wäre sonst unvollständig).
Aufruf:
python rpro3_lookup.py <_rpro3.db> "<Name>" [LETTERS]
LETTERS optional, z. B. "B,C" → nur diese Varianten. Default: alle.
python rpro3_lookup.py <_rpro3.db> "<Name>" --json → strukturiert (für Agenten)
"""
from __future__ import annotations
import sys, json
from collections import defaultdict
import compare_rpro3 as C
LETTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
def informative(recs):
return any(r["dob"] or r["farbe"] or r["origin"] for r in recs)
def build_clusters(animals):
"""Spiegelt compare_rpro3.dedup: Union-Find je Name, gibt {name: [cluster_recs,...]}."""
def compat(x, y):
return (not x) or (not y) or (x == y)
for a in animals:
a["namek"] = C.norm_name(a["name"])
a["dobk"] = a["dob"]
a["farbek"] = C.norm_farbe(a["farbe"])
a["origink"] = C.norm_origin(a["origin"])
by_name = defaultdict(list)
for a in animals:
if a["namek"] in C.PLACEHOLDER_NAMES:
continue
by_name[a["namek"]].append(a)
parent = {}
def find(x):
while parent[x] != x:
parent[x] = parent[parent[x]]
x = parent[x]
return x
def union(x, y):
parent.setdefault(x, x); parent.setdefault(y, y)
parent[find(x)] = find(y)
def positive(a, b):
agree = 0
if a["dobk"] and b["dobk"] and a["dobk"] == b["dobk"]:
agree += 1
if a["farbek"] and b["farbek"] and a["farbek"] == b["farbek"]:
agree += 1
if a["origink"] and b["origink"] and a["origink"] == b["origink"]:
agree += 1
return agree
def conflict(a, b):
c = 0
if a["dobk"] and b["dobk"] and a["dobk"] != b["dobk"]:
c += 1
if a["farbek"] and b["farbek"] and a["farbek"] != b["farbek"]:
c += 1
if a["origink"] and b["origink"] and a["origink"] != b["origink"]:
c += 1
return c
for name, group in by_name.items():
for a in group:
parent.setdefault(a["rid"], a["rid"])
n = len(group)
for i in range(n):
for j in range(i + 1, n):
a, b = group[i], group[j]
comp = (compat(a["dobk"], b["dobk"]) and compat(a["farbek"], b["farbek"])
and compat(a["origink"], b["origink"]))
if comp and positive(a, b) >= 1 and conflict(a, b) == 0:
union(a["rid"], b["rid"])
# Cluster je Name sammeln
name_clusters = {}
by_rid = {a["rid"]: a for a in animals}
for name, group in by_name.items():
roots = defaultdict(list)
for a in group:
roots[find(a["rid"])].append(a)
name_clusters[name] = list(roots.values())
return name_clusters
def variants_for(animals, display_name):
"""Liefert [(letter, recs)] für einen Namen Reihenfolge wie im Ticket."""
namek = C.norm_name(display_name)
clusters = build_clusters(animals).get(namek, [])
info = [recs for recs in clusters if informative(recs)]
# Sortierung exakt wie rpro3_tickets.py: eigene Tiere zuerst, dann größere Cluster
info.sort(key=lambda recs: (not any(r["src"] == "stamm" for r in recs), -len(recs)))
return [(LETTERS[i], recs) for i, recs in enumerate(info)]
def aggregate(recs, animals):
"""Aggregiert Daten + Nachzucht über alle recs eines Clusters."""
names = {r["name"] for r in recs}
dob = sorted({C.iso(r["dob"]) for r in recs if r["dob"]})
farbe = sorted({r["farbe"] for r in recs if r["farbe"]})
fcode = sorted({r["fcode"] for r in recs if r["fcode"]})
origin = sorted({r["origin"] for r in recs if r["origin"]})
fathers = sorted({r["father"] for r in recs if r["father"]})
mothers = sorted({r["mother"] for r in recs if r["mother"]})
is_own = any(r["src"] == "stamm" for r in recs)
rids = [r["rid"] for r in recs]
# Nachzucht: alle Tiere, deren Eltern-rid auf eine rid dieses Clusters zeigt
rid_set = set(rids)
kids = []
seen = set()
for a in animals:
if str(a.get("mid_raw")) in rid_set or str(a.get("pid_raw")) in rid_set:
co = a["father"] if a["mother"] in names else a["mother"]
key = (C.norm_name(a["name"]), C.iso(a["dob"]) if a["dob"] else "")
if key in seen:
continue
seen.add(key)
kids.append({"name": a["name"], "dob": C.iso(a["dob"]) if a["dob"] else None,
"farbe": a["farbe"], "fcode": a["fcode"], "partner": co})
return {"names": sorted(names), "rids": rids, "count": len(recs),
"is_own": is_own, "dob": dob, "farbe": farbe, "fcode": fcode,
"origin": origin, "fathers": fathers, "mothers": mothers, "kids": kids}
def fmt_block(letter, agg):
src = "eigenes Tier" if agg["is_own"] else "externer Ahn"
L = []
L.append(f"**{letter}** ({src}, {agg['count']}× in RennmausPro):")
L.append(f"• Geburtsdatum: {', '.join(agg['dob']) or 'unbekannt'}")
L.append(f"• Farbe: {', '.join(agg['farbe']) or 'unbekannt'}")
L.append(f"• Gencode: {', '.join(agg['fcode']) or 'unbekannt'}")
L.append(f"• Herkunft: {', '.join(agg['origin']) or 'unbekannt'}")
vat = ', '.join(agg['fathers']) or 'unbekannt'
mut = ', '.join(agg['mothers']) or 'unbekannt'
L.append(f"• Eltern: Vater {vat} · Mutter {mut}")
if agg["kids"]:
L.append("• Nachzucht:")
for k in agg["kids"]:
d = k["dob"] or "?"
f = k["farbe"] or "?"
gc = f" [{k['fcode']}]" if k["fcode"] else ""
p = f" — Partner: {k['partner']}" if k["partner"] else ""
L.append(f" {k['name']} (geb. {d}, {f}{gc}){p}")
else:
L.append("• Nachzucht: keine in RennmausPro hinterlegt")
return "\n".join(L)
def variants_by_rids(animals, rid_groups):
"""rid_groups: Liste von rid-Listen (eine je Variante/Buchstabe). Aggregiert pro Gruppe
über den GANZEN Auto-Cluster (eine rid zieht ihren Cluster mit). Für Platzhalter-Namen
(„...") und Freitext-Tickets, wo der Name nicht greift, aber rids bekannt sind."""
from collections import defaultdict
# Auto-Cluster wie build_clusters, aber global über rids indizieren
clusters = build_clusters(animals)
rid_to_recs = {}
for recs_list in clusters.values():
for recs in recs_list:
for r in recs:
rid_to_recs[r["rid"]] = recs
out = []
for i, grp in enumerate(rid_groups):
merged, seen = [], set()
for rid in grp:
for r in rid_to_recs.get(rid, []):
if r["rid"] not in seen:
seen.add(r["rid"]); merged.append(r)
if merged:
out.append((LETTERS[i], merged))
return out
def main():
db = sys.argv[1]
name = sys.argv[2]
# --rids "u84,u83;u1" → Variante A=u84,u83 Variante B=u1 (per Semikolon getrennt)
if name == "--rids":
rid_spec = sys.argv[3]
R = C.load_rpro3(db)
groups = [[x.strip() for x in g.split(",") if x.strip()] for g in rid_spec.split(";") if g.strip()]
vs = variants_by_rids(R["animals"], groups)
print("=== (per rids) ===")
for letter, recs in vs:
print(fmt_block(letter, aggregate(recs, R["animals"])))
print()
return
as_json = "--json" in sys.argv[3:]
letters = None
for a in sys.argv[3:]:
if a != "--json":
letters = {x.strip().upper() for x in a.split(",") if x.strip()}
R = C.load_rpro3(db)
animals = R["animals"]
vs = variants_for(animals, name)
if not vs:
print(f"(keine Varianten für „{name}“ gefunden)")
return
out = []
for letter, recs in vs:
if letters and letter not in letters:
continue
agg = aggregate(recs, animals)
out.append((letter, agg))
if as_json:
print(json.dumps({"name": name, "variants": {l: a for l, a in out}},
ensure_ascii=False, indent=1))
else:
print(f"=== {name} ===")
for letter, agg in out:
print(fmt_block(letter, agg))
print()
if __name__ == "__main__":
main()

View File

@@ -28,6 +28,11 @@ r = g.parse("uwuw")
check("uwuw -> gg", r["mapped8locus"].get("G") == ["g", "g"])
r = g.parse("uw[d]uw[d]")
check("uw[d]uw[d] -> gg (dense underwhite)", r["mapped8locus"].get("G") == ["g", "g"])
# GEN-5 (ticket 5151ab20 / Vance): the het 'Uwuw[d]' (one Underwhite, one dense
# underwhite) must parse to the G locus as Gg — never crash on the [d] modifier.
r = g.parse("aa Cc[chm] D- ee Uwuw[d] PP spsp")
check("Uwuw[d] -> Gg (Vance, het dense underwhite)", r["mapped8locus"].get("G") == ["G", "g"])
check("Uwuw[d] full string: nothing unmapped", r["unmappedTokens"] == [])
# Gg and Uwuw must produce the SAME mapped locus (so they stop being a conflict)
check("Gg identical to Uwuw at G locus",
@@ -86,6 +91,42 @@ check("Algierfuchs genotype: no unmapped tokens", r["unmappedTokens"] == [])
check("looks_like_genotype sees Uw as G",
g.looks_like_genotype("aa Cc Uwuw") is True)
# ── genotype_to_farbschlag — Python mirror of catalog.ts genotypeToFarbschlag ──
# Genetik/Farbschlag ist korrektheitskritisch: jeder Ticket-Fall bekommt einen
# Regressionstest (Konvention). The base name is returned WITHOUT the Schecke/Rex
# modifier (the import carries those via the Sp/Re loci, not the variety id).
def fs(s):
return g.genotype_to_farbschlag(g.parse(s)["mapped8locus"])
# Ticket 3f5942a2 — Goldfuchs (ee) NOT Gold (EE): a fox genotype must resolve to
# a Fuchs variety, never the substring-shorter „Gold".
check("3f5942a2: ee Fuchs -> Goldfuchs (not Gold)", fs("AA CC DD ee GG pp spsp") == "Goldfuchs")
check("3f5942a2: ee[f] Fuchsschimmel -> Goldfuchsschimmel",
fs("Aa C- D- ee[f] G- pp Spsp") == "Goldfuchsschimmel")
# Ticket 1aac054f — namenloses Weibchen *13.08.2025: Kohlfuchsschimmel (not Gold).
check("1aac054f: aa ee[f] -> Kohlfuchsschimmel",
fs("aa Cc[chm] D- ee[f] Gg Pp Spsp") == "Kohlfuchsschimmel")
# Ticket 998087e2 — dd must NOT be ignored: Dilute Agouti (not Agouti).
check("998087e2: AA dd EE -> Dilute Agouti", fs("AA CC dd EE GG PP spsp") == "Dilute Agouti")
check("998087e2 counter: AA DD EE -> Agouti (no dilute)", fs("AA CC DD EE GG PP spsp") == "Agouti")
# Ticket 06217eb3 — dd Anthrazit: Dilute Anthrazit (not Anthrazit).
check("06217eb3: aa dd gg -> Dilute Anthrazit", fs("aa CC dd Ee gg P- spsp") == "Dilute Anthrazit")
# Ticket e22764aa — ee[-] = Fuchs (Schimmel-Modifier unbekannt) -> Blaufuchs,
# NEVER Blaufuchsschimmel (the „(schimmel)" parenthetical is „möglich", not definitiv).
check("e22764aa: aa ee[-] gg -> Blaufuchs (not …schimmel)",
fs("aa C- D- ee[-] gg P- spsp") == "Blaufuchs")
# Ticket cc9ea3fe / 1a508c04 — Mamta Mini Ee resolves to Agouti (AA, E_).
check("Mamta Mini: AA Ee -> Agouti", fs("AA CC D- Ee Gg PP spsp") == "Agouti")
# E-locus phenotype rules (breeder): ef/ef = Schimmel family, ef/e = Fuchsschimmel.
check("efef agouti base -> Orangeschimmel", fs("AA CC DD e[f]e[f] GG PP spsp") == "Orangeschimmel")
check("ef/e het -> Fuchsschimmel family (Kohlfuchsschimmel)",
fs("aa CC DD ee[f] GG PP spsp") == "Kohlfuchsschimmel")
# Unknown allele = copy of the visible partner (GEN-5): A? -> AA, D? -> DD.
check("unknown copies known: AA C? DD ee GG pp -> Goldfuchs", fs("AA C- DD ee GG pp spsp") == "Goldfuchs")
# An incomplete parse must NOT throw and must not be invented as a real colour.
check("empty mapping -> Unbekannt", g.genotype_to_farbschlag({}) == g.UNKNOWN_FARBSCHLAG)
if check.failed:
print(f"\n{check.failed} test(s) FAILED")
sys.exit(1)

View File

@@ -432,6 +432,61 @@ check("contracts: animal-less record has empty Animals list",
_sale3 and _sale3[0]["Animals"] == [])
# ── resolve_color_and_genotype + clean_color_name (genetics-farbschlag cluster) ──
# A tiny synthetic variety_map (name->id) with the keys these cases need.
_VM = {
"gold": "ID-gold", "goldfuchs": "ID-goldfuchs", "goldfuchsschimmel": "ID-gfs",
"agouti": "ID-agouti", "dilute agouti": "ID-dagouti",
"anthrazit": "ID-anthrazit", "dilute anthrazit": "ID-danthrazit",
"blaufuchs": "ID-blaufuchs", "blaufuchsschimmel": "ID-bfs",
"kohlfuchsschimmel": "ID-kfs", "marder": "ID-marder", "schwarz": "ID-schwarz",
"orangeschimmel": "ID-orange",
}
_VG = {}
def _rc(color, geno):
return m.resolve_color_and_genotype(color, geno, _VM, _VG)[0]
# Ticket 3f5942a2 — specificity: „Goldfuchs"-label must NOT collapse to „Gold".
check("3f5942a2 label: 'Goldfuchs' -> goldfuchs (not gold)",
m._match_color_label("goldfuchs", _VM) == "ID-goldfuchs")
# Genotype wins: ee fox genotype overrides a stale „Gold" label.
check("3f5942a2 genotype wins: ee -> Goldfuchs over 'Gold' label",
_rc("Gold", "AA CC DD ee GG pp spsp") == "ID-goldfuchs")
# Ticket 998087e2 — dd ignored by label: genotype gives Dilute Agouti.
check("998087e2: dd genotype -> Dilute Agouti over 'Agouti' label",
_rc("Agouti", "AA CC dd EE GG PP spsp") == "ID-dagouti")
# Ticket 06217eb3 — Dilute Anthrazit.
check("06217eb3: dd genotype -> Dilute Anthrazit over 'Anthrazit'",
_rc("Anthrazit", "aa CC dd Ee gg P- spsp") == "ID-danthrazit")
# Ticket 1aac054f — Kohlfuchsschimmel over a stale 'Gold' label.
check("1aac054f: ee[f] genotype -> Kohlfuchsschimmel over 'Gold'",
_rc("Gold", "aa Cc[chm] D- ee[f] Gg Pp Spsp") == "ID-kfs")
# Ticket e22764aa — „Blaufuchs(schimmel)" parenthetical is NOT definitive; the
# cleaned label is „blaufuchs" and the ee[-] genotype confirms Blaufuchs.
_cn, _sc = m.clean_color_name("Blaufuchs(schimmel)")
check("e22764aa: '(schimmel)' stripped, not promoted -> 'blaufuchs'", _cn == "blaufuchs")
check("e22764aa: ee[-] genotype -> Blaufuchs (not Blaufuchsschimmel)",
_rc("Blaufuchs(schimmel)", "aa C- D- ee[-] gg P- spsp") == "ID-blaufuchs")
# Ticket e09d6f22 — a Schecke-looking LABEL must not flip an explicit source spsp
# to Spsp (the source genotype is authoritative for the Sp-locus).
_, _g_spsp = m.resolve_color_and_genotype("Kohlfuchsschimmel, hell",
"aa Cc[chm] D- ee[f] Gg Pp spsp", _VM, _VG)
check("e09d6f22: explicit spsp kept (label-Schecke does not force Spsp)",
"Spsp" not in _g_spsp and "spsp" in _g_spsp)
# VORSICHTIG guard: a COMPACT-notation genotype (cchmcchm/efef) the parser can't
# read must fall back to the text label, NOT mis-recolour (e.g. Marder->Schwarz).
check("guard: compact 'cchmcchm' unparsable -> keep label 'Marder'",
_rc("Marder", "aa cchmcchm DD EE GG PP spsp rere") == "ID-marder")
check("guard: compact 'efef' unparsable -> keep label 'Orangeschimmel'",
_rc("Orangeschimmel", "AA CC DD efef GG PP spsp rere") == "ID-orange")
# A genuinely Schecke label with no Sp in the genotype still appends Spsp.
_, _g_add = m.resolve_color_and_genotype("Agouti Schecke", "AA CC DD EE GG PP", _VM, _VG)
check("schecke label + no Sp token -> appends Spsp", "Spsp" in _g_add)
# ── Integration: assert the resolved_import.json output reflects the ticket fixes ──
# (Only when the pipeline has already been run; tolerant if the file is absent.)
import os as _os, json as _json
@@ -504,17 +559,30 @@ if _os.path.exists(_resolved):
# Sunny von PZ Karl: father corrected Hiro → Bill von Privat.
_check_parents("Sunny (parents)", "Sunny von PZ Karl", "Bill von Privat", "Melly von Privat")
# Danielle: mother = Ella *10.06.2019 (the elder one), father unknown.
# Danielle: mother = Ella *10.06.2019, father = Makoto (sibling pairing; Ticket 4692fd5c).
_dan = _find("Danielle von den Kleinen")
_df, _dm = _parents(_dan)
check("Danielle: mother is Ella", (_dm or "") == "Ella")
check("Danielle: father unknown (sibling pairing)", _df is None)
check("Danielle: father is Makoto", (_df or "").startswith("Makoto"))
if _dan and _dan.get("LitterId"):
_dl = _L.get(_dan["LitterId"])
_dmom = _G.get(_dl.get("MotherId")) if _dl else None
check("Danielle: mother Ella is the *2019-06-10 one (not the *2023 Ella)",
_dmom is not None and _dmom.get("DateOfBirth") == "2019-06-10")
# Catelyn (Ticket 7bbc045c): father Eddard Stark of Sunset Glow, mother Milena.
_check_parents("Catelyn", "Catelyn Stark von den Kleinen", "Eddard Stark", "Milena")
# Gaida (Ticket ba63325a): Geschwisterverpaarung Zhuāngzǐ × Zaibunissa (beide *2020-02-21).
_check_parents("Gaida", "Gaida von den Kleinen Chaoten", "Zhuāngzǐ", "Zaibunissa")
# Bentley / Alexandria / Bugatti (Ticket 09bcac78 / 45cc501b): nur Vorfahren → isResident False.
for _tag, _ref in [("Bentley", "Wurfchronik Teil 1_page_0054.md-50505050-0003-4000-8000-000000000003"),
("Alexandria", "Wurfchronik Teil 1_page_0054.md-50505050-0004-4000-8000-000000000004"),
("Bugatti", "Wurfchronik Teil 1_page_0054.md-40404040-0003-4000-8000-000000000003")]:
_g = next((g for g in _d["gerbils"] if g.get("ExternalRef") == _ref), None)
check(f"{_tag}: isResident override == False", _g is not None and _g.get("IsResident") is False)
# Cherry Berry's Quqquluuruu: gender override female (box colour misread).
_cherry = _find("Cherry Berry")
check("Cherry Berry: gender override female",
@@ -534,6 +602,59 @@ if _os.path.exists(_resolved):
and "unbekannt" in (g.get("ExternalRef") or "").lower()]
check("Duplicate-merge: nameless buck *15.02.2024 deduped to one record",
len(_bucks) == 1)
# ── genetics-farbschlag cluster: the STORED colorVarietyId is now genotype-
# correct for the ticket animals. Build the id→name map from the authoritative
# ApplicationContext.cs catalog (same source the pipeline uses for the ids).
import re as _re
_app = _os.path.abspath(_os.path.join(_os.path.dirname(__file__),
"../../GerbilManagerWebAPI/ApplicationContext.cs"))
_idname = {}
if _os.path.exists(_app):
_cm = _re.search(r"catalog\s*=\s*\{(.*?)\};", open(_app, encoding="utf-8").read(), _re.DOTALL)
if _cm:
for _i, (_n, _g, _so) in enumerate(_re.findall(
r'\(\s*"([^"]+)"\s*,\s*"([^"]+)"\s*,\s*(\d+)\s*\)', _cm.group(1))):
_idname[f"00000000-0000-0000-0000-{_i + 1:012d}"] = _n
def _by_ref(ref):
return next((g for g in _d["gerbils"] if g.get("ExternalRef") == ref), None)
def _cv_name(g):
return _idname.get(g.get("ColorVarietyId")) if g else None
if _idname:
# Ticket 1aac054f — namenloses Weibchen *13.08.2025 -> Kohlfuchsschimmel.
_t1 = _by_ref("stammbaum-unbekannt-13082025-2")
check("1aac054f: nameless *13.08.2025 stored as Kohlfuchsschimmel",
_cv_name(_t1) == "Kohlfuchsschimmel")
# Ticket e09d6f22 — same litter, *-3: spsp (NOT Schecke) + Kohlfuchsschimmel.
_t2 = _by_ref("stammbaum-unbekannt-13082025-3")
check("e09d6f22: Sp-locus is spsp (no Schecke)",
_t2 is not None and "Spsp" not in (_t2.get("Genotype") or "")
and "spsp" in (_t2.get("Genotype") or ""))
check("e09d6f22: stored as Kohlfuchsschimmel", _cv_name(_t2) == "Kohlfuchsschimmel")
# Ticket 06217eb3 — Dilute Anthrazit (dd).
_t3 = _by_ref("stammbaum-unbekannt-27052025")
check("06217eb3: nameless dd-Weibchen stored as Dilute Anthrazit",
_cv_name(_t3) == "Dilute Anthrazit")
# Ticket e22764aa — Blaufuchs (NOT Blaufuchsschimmel).
_t4 = _by_ref("stammbaum-unbekannt-16012026")
check("e22764aa: '(schimmel)' animal stored as Blaufuchs",
_cv_name(_t4) == "Blaufuchs")
# Ticket 3f5942a2 — named fox animals are Goldfuchs (ee), not Gold (EE).
_banjo = _find("Banjo of Fiomi")
check("3f5942a2: Banjo of Fiomi stored as Goldfuchs",
_cv_name(_banjo) == "Goldfuchs")
# ── Mamta Mini (cc9ea3fe / 1a508c04): Ee[-] resolved to Ee + parents linked. ──
_mamta = _find("Mamta Mini")
check("Mamta Mini: E-locus resolved to Ee (no unknown [-])",
_mamta is not None and "Ee[-]" not in (_mamta.get("Genotype") or "")
and "Ee" in (_mamta.get("Genotype") or ""))
_mf, _mm = _parents(_mamta)
check("Mamta Mini: father Geely, mother Gaida linked at the litter",
(_mf or "").startswith("Geely") and (_mm or "").startswith("Gaida"))
else:
print("note: output/resolved_import.json not present — skipped integration assertions")