Compare commits

..

50 Commits

Author SHA1 Message Date
4d7333b714 D7-PREP: Dakota of sweet little mouse + Banjo of Fiomi (E-locus correction)
Dakota of sweet little mouse (DOB 30.01.2015):
- Genotype: Aa CC Dd Ee Gg pp Spsp
- A-Locus=Aa, P-Locus=pp, Sp-Locus=Spsp confirmed by Julian

Banjo of Fiomi (DOB 06.07.2015, Dakota's son):
- E-Locus correction: E- → ee (Julian: "Goldfuchs Starkschecke, nicht Gold Starkschecke")
- Corrected genotype: AA CC DD ee Gg pp Spsp
- ColorVariety: Goldfuchs Starkschecke

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-06-07 17:27:53 +02:00
62b07597f5 D7-PREP: Max von Privat death date corrected to 01.01.2014 (year-only convention, birth 01.02.2013 + deathYear 2014)
Genotype confirmed: aa c[chm]c[chm] D- EE GG PP spsp
Reject 4 on-file variants: 04.02.2016 / 04.03.2016 / 2014-raw / 30.12.2015

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-06-07 17:26:10 +02:00
5ce35af813 D7-PREP: Skarlett death date corrected to 01.01.2017 (year-only convention, birth 2013 + 4y)
Julian's final answer: 'She became 4 years old, no exact date.' Birth: 14.07.2013.
Calculated: 2013 + 4 = 2017. Applied year-only death convention: 01.01.2017.
Previous entry (17.04.2016) from HUMANQUESTION D6 was incorrect.
2026-06-07 16:40:55 +02:00
7f6855be62 D7: 5 Züchterin-Daten in conflict-decisions.json kodiert
Max von Privat (01.02.2013): genotype aa c[chm]c[chm] D- EE GG PP spsp.
  Tod-Jahr 2014 (Jahr-only) als _deathYear_pending kommentiert — Konvention offen.
Isa of Golden Lights (24.12.2014): dateOfDeath 21.07.2018.
Jack II von den Kleinen Chaoten (14.02.2016): dateOfDeath 06.10.2019.
Milon von den Kleinen Chaoten (27.11.2014): genotype Aa Cc[chm] D- ee[f] gg Pp spsp
  (A-Locus war der einzige Konflikt; Züchterin löst auf Aa).
Sunny von PZ Karl (10.04.2014): dateOfDeath 30.04.2018.

Dry-run delta: Konflikte 7→2 (resolved 15→20). Verbleibend: Skarlett + Dakota.
2026-06-07 03:29:19 +02:00
c1b215d06f Merge feature/stammbaum-fulldate (STAMMBAUM): volles Geburtsdatum + Gencode-Chip + GerbilIcon (🐭→Rennmaus app-weit)
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 1m0s
CI / Frontend Tests (Node/Vite) (push) Successful in 9m32s
CI / Docker Build & Push (push) Failing after 9s
2026-06-07 03:03:45 +02:00
7525970ffc STAMMBAUM-FULLDATE: GerbilIcon-SVG + Gencode-Chip + Gerbil-Icon ersetzt 🐭 app-weit 2026-06-07 02:53:31 +02:00
bfc90f8581 STAMMBAUM-FULLDATE: volles Geburtsdatum auf Knoten-Karten (formatDate statt slice 4) + e2e 2026-06-07 02:47:12 +02:00
c45bcc99f0 Merge feature/ops-2 (OPS-2): TrueNAS Goldeye deploy config (registry git.rismer.de + JailStorage paths) + ops runbook
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 59s
CI / Frontend Tests (Node/Vite) (push) Successful in 9m33s
CI / Docker Build & Push (push) Failing after 8s
# Conflicts:
#	deploy/truenas/compose.yaml
2026-06-07 02:28:00 +02:00
c4a661b5b6 Merge feature/search-1 (SEARCH-1): regression tests for separator-insensitive nameSearch + OriginBreeder filter 2026-06-07 02:27:21 +02:00
30b2fff775 Merge feature/web-2 (WEB-2): self-host public site — POST /api/publish (atomic swap) + publicsite nginx + vhost + docs
Some checks failed
CI / Frontend Tests (Node/Vite) (push) Successful in 9m32s
CI / Docker Build & Push (push) Failing after 10s
CI / Backend Tests (.NET) (push) Successful in 59s
2026-06-07 01:45:30 +02:00
c0ecf2022d WEB-2: Self-hosted public site (publicsite-nginx + POST /api/publish, atomic swap)
POST /api/publish: rendert SiteSnapshot->HTML in _staging_new/, atomic swap ->
live/ (rename, ein Syscall). publicsite-nginx:alpine serviert live/ read-only
auf Port 8081. Shared Volume api(rw)/publicsite(ro). Manager bleibt LAN-only.
5 neue Tests (atomic swap, UTF-8, mehrfach), 184/184 gruen. compose config OK.
Vhost-Snippet + web-deploy.md (Deutsch) beigelegt; <DOMAIN> wartet auf Julian.
2026-06-07 01:44:28 +02:00
2198c33898 OPS-2: TrueNAS Goldeye 25.10.2.1 Deploy finalisiert
Registry truenas:13000 -> git.rismer.de/gulum (alle 6 Stellen).
Dataset-Pfade auf /mnt/JailStorage/DockerVolumes/gerbilmanager/ gesetzt.
docs/ops.md: vollstaendiges Goldeye-Runbook (Ordner + UID-999-Perms,
Registry-Login, Port-80-Fallback, Restore-Drill-Pflichtschritt, ZFS-Pfade).
docker compose config: OK.
2026-06-07 01:38:25 +02:00
04971bf3bb Merge feature/stammbaum-litters (STAMMBAUM-LITTERS): Würfe des Wurzeltiers links im Viewer + Link
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 58s
CI / Docker Build & Push (push) Has been cancelled
CI / Frontend Tests (Node/Vite) (push) Has been cancelled
2026-06-07 01:38:10 +02:00
08500757d7 STAMMBAUM-LITTERS: Wuerfe des Wurzeltiers links im Stammbaum-Viewer
Layout: stammbaum-layout (flex-row Desktop / flex-column Mobil) wraps
[Wuerfe-Panel | Canvas]. Panel 148px breit, border-right Trenner;
auf Mobil (<=520px) horizontaler Scroll-Streifen ueber dem Canvas.

Daten: useApi(listLitters fatherId=id|motherId=id) reagiert automatisch
auf Umwurzeln (id-Param). Kein Panel wenn Wurzeltier keine Wuerfe hat.

Pro Wurf: Wurfname (fett) + N Junge + Link zu /wuerfe/{id}.
CSS: flex 0 0 auto Mobil-Override sichert Canvas-Hoehe im column-Mode.
de.ts: littersTitle, littersJunge (Stammbaum-Sektion, disjunkt).

Gate: vitest 122/122 e2e 20/20 Stammbaum (4 neue Tests x 2 Viewports)
build+tsc+eslint clean.
2026-06-07 01:37:08 +02:00
05ef3d9567 Merge feature/namegen-2-fe (NAMEGEN-2-FE): 10 Namens-Kategorien UI (disney/pokemon/EN+HR-Städte/US-Staaten)
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 59s
CI / Docker Build & Push (push) Has been cancelled
CI / Frontend Tests (Node/Vite) (push) Has been cancelled
2026-06-07 01:35:40 +02:00
7c12a65938 Merge feature/namegen-2-be (NAMEGEN-2-BE): 5 Themen-Kategorien disney/pokemon/encities/hrcities/usstates für /names/suggest
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 1m2s
CI / Docker Build & Push (push) Has been cancelled
CI / Frontend Tests (Node/Vite) (push) Has been cancelled
2026-06-07 01:34:52 +02:00
5361a292f1 NAMEGEN-2-BE: 5 neue Themen-Kategorien (disney/pokemon/encities/hrcities/usstates)
UsageMap bildet alle Codes auf deutsche Beschreibungen ab; thematische Kategorien
landen unter "Themen-Kategorien" im Prompt (kein Etymologie-Zwang, origin=Kategoriename).
22 neue Tests, 179/179 gruen.
2026-06-07 01:32:56 +02:00
cb5acd2005 Merge feature/genotype-display-rex (GEN-4d + UI-FIX): Rex-Wildtyp 'rere' in Anzeige ausblenden (7 Loci) + doppelte Wurf-Überschrift entfernt
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 1m0s
CI / Docker Build & Push (push) Has been cancelled
CI / Frontend Tests (Node/Vite) (push) Has been cancelled
2026-06-07 01:31:19 +02:00
bda5479b93 UI-FIX: doppelte Überschrift 'Erwartete Farbschläge' auf Wurf-Detailseite entfernt 2026-06-07 01:29:49 +02:00
d5c155953b GEN-4d: rere (Re-Wildtyp) aus Anzeige ausblenden (Julian)
- genotype.ts: toDisplayString filtert Re aus wenn re/re (Wildtyp) —
  analog zur bestehenden Sls-Regel; Rere/ReRe bleiben sichtbar.
  Wildtyp-Anzeige jetzt 7 Loci: AA CC DD EE GG PP spsp (ohne rere).
- StammbaumPage.tsx: Druck-Ahnentafel nutzt toDisplayString(fromDisplayString(g.genotype))
  statt rohem g.genotype-String (Audit: einzige Display-Stelle ausserhalb toDisplayString).
  (GerbilDetailPage/BreedingResultView gehen bereits via toDisplayString.)
- genetics.test.ts: Alle Display-String-Assertions auf 7-Loci-Format aktualisiert;
  Rere-Nachweis zum Sls/Re-Test ergaenzt; Katalog-Regex auf {6,8}.
- colorVarietySeed.generated.json regeneriert (kein rere in canonicalGenotype).
- backend.json unveraendert (Pam: DB-Speicherung bleibt volles 8-Loki-Format).
2026-06-07 01:28:06 +02:00
36795bd974 Merge feature/seed-rew (SEED-REW): Backend ColorVariety 'Pink Eyed White (PEW)' → 'REW' rename-in-place (FK-safe)
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 59s
CI / Frontend Tests (Node/Vite) (push) Successful in 9m33s
CI / Docker Build & Push (push) Failing after 9s
2026-06-07 01:19:26 +02:00
ae60b47ad3 D7: Quelldatei(en) je offenem Konflikt-Tier angereichert (Dakota/Max + alle 6 offenen)
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 1m1s
CI / Docker Build & Push (push) Has been cancelled
CI / Frontend Tests (Node/Vite) (push) Has been cancelled
2026-06-07 01:15:04 +02:00
adb9e93cdb SEED-REW: rename ColorVariety 'Pink Eyed White (PEW)' to 'REW'
Single UpdateData migration for ID 00000000-...-0001 (sortOrder 0).
Name-only rename, genotype/ID/FK unchanged — no drift risk.
Matches colorVarietySeed.backend.json after GEN-4c (main 3aa9ac9).

165/165 tests, ef has-pending=No.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-06-07 01:12:55 +02:00
3aa9ac9811 Merge feature/gen-4c (GEN-4c): PEW→REW konsolidiert (Julian REW-1) + REW-2 A-unabhängig verifiziert + aa-Test
Some checks failed
CI / Docker Build & Push (push) Has been cancelled
CI / Frontend Tests (Node/Vite) (push) Has been cancelled
CI / Backend Tests (.NET) (push) Successful in 58s
2026-06-07 01:10:18 +02:00
b72e0a9d9a GEN-4c: PEW=REW konsolidiert (Julian) + REW-2-Verifikation
- catalog.ts: 'Pink Eyed White (PEW)' -> 'REW' (frozen rename; chch+pp
  engine-computes 'REW' per Julian; entry now round-trips correctly)
- genetics.test.ts: REW_SHADOWED-Ausnahme entfernt (kein Bedarf mehr);
  CATALOG[0]-Assertion auf 'REW'; aa cchmcchm+pp-Test als REW-2-Nachweis
  (Julian: A-Locus egal fuer REW-Bedingung)
- chipColors.ts: 'REW'-Chip ergaenzt (gleiche Farbe wie PEW; additive;
  PEW-Eintrag bleibt fuer Rueckwaertskompatibilitaet waehrend DB-Migration)
- Seed-Artefakte regeneriert (70 Reihen, REW statt PEW an Position 0)
- FLAGGED to god: 'Altweiss (REW)' hat volles C (C:'C') -> faellt NICHT
  unter REW-Bedingung; warte auf Julian-Entscheid vor Aenderung
2026-06-07 01:08:23 +02:00
12d85d9796 WEB-Hosting-Pivot: Cloudflare → Self-Host auf TrueNAS (Strato/DynDNS + nginx-Reverse-Proxy); A4 umgestellt
Some checks failed
CI / Docker Build & Push (push) Has been cancelled
CI / Backend Tests (.NET) (push) Successful in 58s
CI / Frontend Tests (Node/Vite) (push) Has been cancelled
2026-06-07 01:07:30 +02:00
76bdde4649 B2 Firewall erledigt — Handy-WLAN-Zugriff (Ports 5173/5179) offen
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 59s
CI / Docker Build & Push (push) Has been cancelled
CI / Frontend Tests (Node/Vite) (push) Has been cancelled
2026-06-07 01:04:00 +02:00
025bca1840 REW-1/REW-2 beantwortet: PEW=REW (zusammenführen, GEN-4c) + REW A-unabhängig (schon implementiert)
Some checks failed
CI / Frontend Tests (Node/Vite) (push) Has been cancelled
CI / Docker Build & Push (push) Has been cancelled
CI / Backend Tests (.NET) (push) Has been cancelled
2026-06-07 01:03:05 +02:00
58dc811574 E1/E2 Charakterbogen als ERLEDIGT markiert (CHARAKTERBOGEN-2: 4 Kategorien, +10 Traits, Warnsignale) — war stale
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 1m0s
CI / Docker Build & Push (push) Has been cancelled
CI / Frontend Tests (Node/Vite) (push) Has been cancelled
2026-06-07 00:59:37 +02:00
367304940f D7: Osamu/Percy/Iwana Konfliktentscheidungen (Züchterin) + Eragon=Elieus C=CC bestätigt → conflict-decisions.json + HUMANQUESTION (7/13 erledigt)
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 1m0s
CI / Docker Build & Push (push) Has been cancelled
CI / Frontend Tests (Node/Vite) (push) Has been cancelled
2026-06-07 00:58:04 +02:00
7e1acdb5e4 A5 beantwortet: TrueNAS Goldeye 25.10 + eigener Postgres + Backups unter /mnt/JailStorage/DockerVolumes — OPS-2 entsperrt
Some checks failed
CI / Docker Build & Push (push) Has been cancelled
CI / Frontend Tests (Node/Vite) (push) Has been cancelled
CI / Backend Tests (.NET) (push) Successful in 1m2s
2026-06-07 00:54:26 +02:00
5ee6a4edf6 Merge feature/stammbaum-expand (STAMMBAUM-EXPAND): prominenter +-Vorfahren-Button (40px) + Bottom-Mini-Legende (Name→Akte)
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 58s
CI / Docker Build & Push (push) Has been cancelled
CI / Frontend Tests (Node/Vite) (push) Has been cancelled
2026-06-07 00:47:05 +02:00
ea527c7ec6 CI: Registry auf externes HTTPS git.rismer.de (statt 192.168.2.115:13000 HTTP) — keine insecure-registry-Konfig nötig
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 1m0s
CI / Docker Build & Push (push) Has been cancelled
CI / Frontend Tests (Node/Vite) (push) Has been cancelled
2026-06-07 00:45:39 +02:00
c80adcf060 Merge feature/fix-8d-retry (FIX-8D-RETRY P0): wrap docx import tx in CreateExecutionStrategy.ExecuteAsync (Npgsql retrying strategy) + regression
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 58s
CI / Docker Build & Push (push) Has been cancelled
CI / Frontend Tests (Node/Vite) (push) Has been cancelled
2026-06-07 00:40:11 +02:00
a42966ad8e STAMMBAUM-EXPAND: +-Button prominenter + Mini-Legende (Stammbaum-UX)
Punkt 1 - Expand-Button sichtbar/zuverlaessig:
- 40px solid-fill (war 32px outline), box-shadow, flex-centered;
  deutlich sichtbar auf 190px Karte, touch-tauglich (>=40px Tap-Target).
  Hover/focus-visible: opacity + outline.
- Edge-case (Litter mit null-Eltern): +-Button bleibt sichtbar --
  unbekannt-Karten sind korrektes Feedback fuer quarantaenierte Eltern.

Punkt 2 - Bottom-Hint Mini-Legende:
- stammbaum-hint -> stammbaum-hints ul (flex-wrap, 3 Items):
  Umwurzeln / Name=Akte / +=Nachladen. Gut lesbar auf 390px.
  de.ts: hintName + hintExpand (neue Keys; tapHint bleibt fuer card title).

e2e: +2 Specs x 2 Viewports = 4 neue Tests (Legende + Expand-Klick).
Emil (Gen 4, litterId w-emil) liefert Testanker im Mock-Stammbaum.

Gate: vitest 122/122 e2e 166+68skipped build+tsc+eslint clean.
2026-06-07 00:38:46 +02:00
886a6e3aae D7: Kazumi/Filou/Sokrates Konfliktentscheidungen (Züchterin) → conflict-decisions.json + HUMANQUESTION gestrichen
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 1m0s
CI / Docker Build & Push (push) Has been cancelled
CI / Frontend Tests (Node/Vite) (push) Has been cancelled
2026-06-07 00:38:05 +02:00
88e00b3718 FIX-8D-RETRY: wrap docx execute transaction in CreateExecutionStrategy
NpgsqlRetryingExecutionStrategy rejects user-initiated transactions: SaveChanges
inside a BeginTransactionAsync block triggers OnFirstExecution which throws
InvalidOperationException. Fix: CreateExecutionStrategy().ExecuteAsync wraps the
entire tx block; mutable state (counters, contactByNorm, change tracker) reset at
lambda top for idempotent retry. Logic extracted to RunLoopAsync local function
shared by dry-run and execute paths.

Regression test (Test 7): FakeRetryingStrategy with MaxRetryCount=1 reproduces
the OnFirstExecution check in CI without a live Npgsql instance.

165/165 tests, ef has-pending=No, no schema change.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-06-07 00:34:26 +02:00
3214855989 A5b2: konkrete Schritte für Registry-Secrets + insecure-registry-Hinweis
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 59s
CI / Docker Build & Push (push) Has been cancelled
CI / Frontend Tests (Node/Vite) (push) Has been cancelled
2026-06-07 00:33:40 +02:00
76e635a122 c
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 59s
CI / Frontend Tests (Node/Vite) (push) Successful in 9m33s
CI / Docker Build & Push (push) Failing after 9s
2026-06-07 00:02:20 +02:00
00886b1c6e Merge feature/fix-8d-dedup (FIX-8D-DEDUP P0): collision-free idempotent ExternalRef + single transaction + litter ±5d unique-date link
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 58s
CI / Frontend Tests (Node/Vite) (push) Successful in 9m33s
CI / Docker Build & Push (push) Failing after 8s
2026-06-06 23:33:06 +02:00
d5e8946d7d FIX-8D-DEDUP: P0 duplicate ExternalRef + WsCode→litter mismatch
FIX-1 DEDUP (P0): Two same-name siblings in one litter produced identical
ExternalRefs ('docx-{ws}-{name}-{dob}'), crashing execute on IX_Gerbils_ExternalRef.
Fix: ordinal counter per base-ref within the batch → first occurrence keeps the
base ref, subsequent ones get -2, -3 … suffix. Deterministic (JSON-order) → idempotent
re-runs find existing rows via ExternalRef path (PATH 1). HashSet guard added as a
belt-and-suspenders check.

FIX-2 TRANSACTION: execute now opens a single BeginTransactionAsync before the loop
and commits after the final SaveChangesAsync. Eager contact saves (within the tx) and
the gerbil batch save are fully atomic — crash → full rollback, no partial state.

FIX-3 LITTER LOOKUP: docx WsCode is a litter-size fraction ('4/4', '/5') — NOT a
PairingCode like 'G01/ZdkC'. The previous WsCode→PairingCode lookup was always a
no-op (hence litterLinked=0 in every dry-run). Fix: look up DB litters by LitterDob
(±5 days); link only when exactly ONE candidate exists (unambiguous, no false links).

P0 REGRESSION TEST (Test 6): SQLite + EnsureCreated → unique index enforced.
Two animals same name+litter → execute succeeds, both created, distinct ExternalRefs,
re-run = 0 new. This test would have caught the live crash.
Gate: 164/164 tests, has-pending=No, no schema change.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-06-06 23:31:46 +02:00
e367657254 Merge feature/feat-8d-create (FEAT-8d-CREATE): docx importer CREATE path for ~194 abgegebene Jungtiere
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 58s
CI / Frontend Tests (Node/Vite) (push) Successful in 9m32s
CI / Docker Build & Push (push) Failing after 9s
2026-06-06 23:15:00 +02:00
8c43a8dc03 FEAT-8d-CREATE: docx importer CREATE path for ~194 abgegebene Jungtiere
ImportDocxService gains a three-way match-or-create loop:
  Path 1 (ExternalRef 'docx-…'): previously-created docx animals → enrich fill-NULL-only (idempotency)
  Path 2 (name+dob): existing main-import animals → enrich fill-NULL-only (as before)
  Path 3 (no match): CREATE new Gerbil — abgegebene Jungtiere not in pedigree charts
                      Status=GivenAway (or Deceased), IsResident=false,
                      OriginBreeder='Zucht der Kleinen Chaoten', ImportSource='docx',
                      ExternalRef='docx-{ws}-{normname}-{litterDob}' (stable idempotent key),
                      Gender/ColorVariety/LitterId/ReceiverContact/GoHomeDate/DateOfDeath all set.

ImportDocxReport gains Created field; contact-creation eagerly saved before gerbil batch.
5 SQLite-backed tests: dry-run counts, create+FK, idempotency, enrich-not-duplicate, Deceased status.
Gate: 163/163 tests, has-pending=No, no schema change.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-06-06 23:13:21 +02:00
08732d8348 Merge feature/fix-import-cycle (FIX-IMPORT-CYCLE P0): break Gerbil<->Litter EF circular dependency on execute
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 59s
CI / Frontend Tests (Node/Vite) (push) Successful in 9m32s
CI / Docker Build & Push (push) Failing after 9s
Synth (chart-derived) litters now saved with FatherId/MotherId=null, then a deferred FK-update pass after all gerbils persist -> single-directional Gerbil->Litter dependency in the main save, no cycle. FK guard preserved. Regression: A-mother-is-B + B-mother-is-A cycle (SQLite, FK-enforced) asserts no throw + correct deferred FK. Gate: 158/158 (25/25 import), has-pending=No.

Unblocks the live WIPE+REIMPORT-3.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 22:48:52 +02:00
c6edb2e9d0 FIX-IMPORT-CYCLE: break Gerbil↔Litter circular FK dependency in ImportService
Root cause: synth litters (from chart parentRefs) were added with FatherId/MotherId pointing
to gerbils that were also [Added] in the same SaveChanges batch. With 337 derived litters from
41 pedigrees, multi-generational references created a true dependency cycle:
  Gerbil [Added] ← FK{MotherId} Litter [Added] ← FK{LitterId} Gerbil [Added]
EF's topo-sort threw InvalidOperationException; smaller datasets (REIMPORT-1/2) didn't
trigger it because they had fewer chart-derived litters.

Fix: add synth litters with FatherId=null/MotherId=null (deferred), track intended FKs
in synthLitterPendingParents, apply them in a separate SaveChanges after all gerbils are
persisted. Single-direction Gerbil→Litter dependency in the main save; no cycle possible.
No schema change, no migration.

Regression test: artificial A←B←A cycle in SQLite (FK-enforced) must not throw.
Also verifies deferred FK update sets litter parent correctly after execute.

GATE: 158/158 C# tests, has-pending-model-changes=No

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-06-06 22:47:18 +02:00
8e475dff6a Merge feature/charakterbogen-2 (CHARAKTERBOGEN-2 / E1): Trait-Kategorien + rennmaus-spezifische Eigenschaften + Warnsignale
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 59s
CI / Docker Build & Push (push) Has been cancelled
CI / Frontend Tests (Node/Vite) (push) Has been cancelled
de.character.traits flach -> 4 Kategorien (Sozialverhalten/Eignung&Umgang/Hobbys&Eigenarten/Wesen&Temperament). 15 bestehende Keys UNVERAENDERT (Live-Daten), 10 neue additiv. warn-Flag (schwer-vergesellschaftbar, territorial) visuell abgesetzt fuer ehrliche Inserate. Gate: vitest 122/122, tsc/build/eslint clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 22:43:13 +02:00
4403574f3d Merge feature/catalog-reseed (CATALOG-RESEED): Backend-ColorVariety auf finalen GEN-4/4b-Katalog (66->70)
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 58s
CI / Frontend Tests (Node/Vite) (push) Successful in 9m32s
CI / Docker Build & Push (push) Failing after 9s
ReseedColorVarietiesAR5: 8 Dilute-Praefix-Renames + 4 neue Dilute-Fuchs (IDs 67-70), SortOrder von ID entkoppelt, append-only (alle 66 ID->Name-Bindings erhalten, kein Live-FK-Drift). Quelle: frozen colorVarietySeed.backend.json. Gate: dotnet test 157/157, has-pending=No (Merge-Ergebnis verifiziert).

Letztes Gate vor WIPE+REIMPORT-3 (Julian-GO erteilt).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 22:31:46 +02:00
bcdea8011b CATALOG-RESEED: backend ColorVariety seed 66→70 from colorVarietySeed.backend.json (AR-5)
Source: gerbil-manager-web/src/genetics/colorVarietySeed.backend.json (70 entries, frozen symbols).
- 8 names updated to "Dilute" prefix (dd Gold→Dilute Gold, Agouti dd→Dilute Agouti, etc.)
- SortOrder decoupled from ID (CP-*-Hell interleaved per backend.json sortOrder values)
- 4 new entries appended at end (IDs 67-70): Dilute Algierfuchs/Goldfuchs/Rotfuchs/Polarfuchs
- All 66 existing ID→Name bindings preserved (append-only, no FK drift)
- Migration ReseedColorVarietiesAR5: UpdateData (renames+SortOrders) + InsertData (4 new)
- Tests updated: SeedGen3g_* asserts 70 entries + IDs 67-70 verified
- GATE: 157/157 C# tests, has-pending-model-changes=No

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-06-06 22:29:47 +02:00
c452b69dd6 NAMEGEN-2-FE: 5 neue Namenskategorien (Disney, Pokémon, Englische/Kroatische Städte, US-Bundesstaaten)
- NAMEGEN_USAGES: +disney, +pokemon, +encities, +hrcities, +usstates (jetzt 10 Einträge)
- names.test.ts: Count-Test 5→10, neue toContain-Checks für alle 5 Codes

Gate: vitest 107/107, e2e 160/160, tsc clean
2026-06-06 21:48:08 +02:00
f20a42a94b SEARCH-1: tests — Normalize unit + separator-insensitive nameSearch filter + OriginBreeder/breeders endpoint (SQLite in-memory round-trip) 2026-06-06 09:04:20 +02:00
41 changed files with 5765 additions and 605 deletions

View File

@@ -9,10 +9,10 @@
# 2. npm test + npm run build (Frontend) # 2. npm test + npm run build (Frontend)
# 3. Docker-Images bauen und in die Gitea-Registry pushen # 3. Docker-Images bauen und in die Gitea-Registry pushen
# #
# Registry: truenas:13000 (internes Gitea Container Registry) # Registry: git.rismer.de (Gitea Container Registry über HTTPS — keine insecure-registry-Konfig nötig)
# Images: # Images:
# truenas:13000/gulum/gerbilmanager-api:latest # git.rismer.de/gulum/gerbilmanager-api:latest
# truenas:13000/gulum/gerbilmanager-frontend:latest # git.rismer.de/gulum/gerbilmanager-frontend:latest
name: CI name: CI
@@ -25,7 +25,7 @@ on:
- main - main
env: env:
REGISTRY: truenas:13000 REGISTRY: git.rismer.de
REGISTRY_OWNER: gulum REGISTRY_OWNER: gulum
DOTNET_VERSION: "10.0.x" DOTNET_VERSION: "10.0.x"
NODE_VERSION: "22" NODE_VERSION: "22"

View File

@@ -0,0 +1,103 @@
using GerbilManagerWebAPI.Endpoints;
namespace GerbilManager.Tests;
/// <summary>WEB-2: POST /api/publish — atomic swap, file layout, staging cleanup.</summary>
public class CmsPublishTests
{
private static string TempRoot() =>
Path.Combine(Path.GetTempPath(), "gm-publish-test-" + Guid.NewGuid().ToString("N"));
[Fact]
public async Task Publish_erstellt_live_Verzeichnis_mit_allen_Dateien()
{
var root = TempRoot();
try
{
var files = new Dictionary<string, string>
{
["index.html"] = "<html>start</html>",
["kontakt/index.html"] = "<html>kontakt</html>",
["assets/site.css"] = "body {}",
};
await CmsEndpoints.PublishToDirectoryAsync(files, root);
Assert.True(File.Exists(Path.Combine(root, "live", "index.html")));
Assert.True(File.Exists(Path.Combine(root, "live", "kontakt", "index.html")));
Assert.True(File.Exists(Path.Combine(root, "live", "assets", "site.css")));
Assert.Equal("<html>start</html>",
await File.ReadAllTextAsync(Path.Combine(root, "live", "index.html")));
}
finally { if (Directory.Exists(root)) Directory.Delete(root, true); }
}
[Fact]
public async Task Publish_atomarer_Swap_ueberschreibt_alte_live_Version()
{
var root = TempRoot();
try
{
await CmsEndpoints.PublishToDirectoryAsync(
new Dictionary<string, string> { ["index.html"] = "version-1" }, root);
await CmsEndpoints.PublishToDirectoryAsync(
new Dictionary<string, string> { ["index.html"] = "version-2" }, root);
var content = await File.ReadAllTextAsync(Path.Combine(root, "live", "index.html"));
Assert.Equal("version-2", content);
}
finally { if (Directory.Exists(root)) Directory.Delete(root, true); }
}
[Fact]
public async Task Publish_kein_staging_oder_old_Verzeichnis_nach_Swap()
{
var root = TempRoot();
try
{
await CmsEndpoints.PublishToDirectoryAsync(
new Dictionary<string, string> { ["index.html"] = "x" }, root);
Assert.False(Directory.Exists(Path.Combine(root, "_staging_new")));
Assert.False(Directory.Exists(Path.Combine(root, "_old")));
}
finally { if (Directory.Exists(root)) Directory.Delete(root, true); }
}
[Fact]
public async Task Publish_mehrfach_ohne_Fehler()
{
var root = TempRoot();
try
{
for (int i = 1; i <= 3; i++)
{
await CmsEndpoints.PublishToDirectoryAsync(
new Dictionary<string, string> { ["index.html"] = $"v{i}" }, root);
}
Assert.Equal("v3",
await File.ReadAllTextAsync(Path.Combine(root, "live", "index.html")));
}
finally { if (Directory.Exists(root)) Directory.Delete(root, true); }
}
[Fact]
public async Task Publish_UTF8_Inhalt_korrekt_gespeichert()
{
var root = TempRoot();
try
{
const string german = "<html>Züchter — Rennmäuse & mehr</html>";
await CmsEndpoints.PublishToDirectoryAsync(
new Dictionary<string, string> { ["index.html"] = german }, root);
var content = await File.ReadAllTextAsync(
Path.Combine(root, "live", "index.html"),
System.Text.Encoding.UTF8);
Assert.Equal(german, content);
}
finally { if (Directory.Exists(root)) Directory.Delete(root, true); }
}
}

View File

@@ -0,0 +1,58 @@
using System.Net;
using System.Net.Http.Json;
using GerbilManagerWebAPI.Models;
namespace GerbilManager.Tests;
/// <summary>SEARCH-1: separator-insensitive name search + OriginBreeder (Herkunft) filter.</summary>
public class GerbilSearchTests : IClassFixture<ApiFactory>
{
private readonly HttpClient _client;
public GerbilSearchTests(ApiFactory factory) => _client = factory.CreateClient();
private static HttpContent Gerbil(string name, string? originBreeder = null) =>
JsonContent.Create(new
{
name,
gender = "female",
originBreeder,
});
[Theory]
[InlineData("Clan-Kleine-Chaoten", "clankleinechaoten")]
[InlineData("Clan Kleine Chaoten", "clankleinechaoten")]
[InlineData("v.d. Kleinen_Chaoten", "vdkleinenchaoten")]
public void Normalize_strips_separators_and_lowercases(string input, string expected) =>
Assert.Equal(expected, GerbilSearch.Normalize(input));
[Fact]
public async Task NameSearch_filter_matches_across_separators()
{
var create = await _client.PostAsync("/gerbils", Gerbil("Clan-Kleine-Chaoten"));
Assert.Equal(HttpStatusCode.Created, create.StatusCode);
// Gridify contains is "=*value" (no trailing '*', per the main hotfix 0ee2b53).
// The client normalizes the user's term the same way the column is normalized.
var json = await _client.GetStringAsync("/gerbils?filter=nameSearch=*clankleinechaoten");
Assert.Contains("Clan-Kleine-Chaoten", json);
// a term that doesn't normalize-match must NOT return it
var miss = await _client.GetStringAsync("/gerbils?filter=nameSearch=*completelyother");
Assert.DoesNotContain("Clan-Kleine-Chaoten", miss);
}
[Fact]
public async Task OriginBreeder_is_filterable_and_listed_in_breeders_endpoint()
{
await _client.PostAsync("/gerbils", Gerbil("Herkunftstier", originBreeder: "Zucht der kleinen Chaoten"));
// distinct-values dropdown source
var breeders = await _client.GetStringAsync("/gerbils/breeders");
Assert.Contains("Zucht der kleinen Chaoten", breeders);
// Gridify filter on the field
var filtered = await _client.GetStringAsync("/gerbils?filter=originBreeder==Zucht der kleinen Chaoten");
Assert.Contains("Herkunftstier", filtered);
}
}

View File

@@ -0,0 +1,343 @@
using GerbilManagerWebAPI.Import;
using GerbilManagerWebAPI.Models;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Storage;
namespace GerbilManager.Tests
{
/// <summary>
/// FEAT-8d-CREATE / FIX-8D-DEDUP: docx importer CREATE path tests.
/// Uses SQLite (not InMemory) so FK + unique-index constraints are enforced —
/// this is the only reliable way to catch duplicate-ExternalRef crashes.
/// </summary>
public class ImportDocxServiceTests : IDisposable
{
private readonly string _dir;
public ImportDocxServiceTests()
{
_dir = Path.Combine(Path.GetTempPath(), "feat8d-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(_dir);
}
public void Dispose()
{
try { Directory.Delete(_dir, recursive: true); } catch { }
}
// One open connection keeps the :memory: SQLite DB alive across calls.
private (ApplicationContext db, SqliteConnection conn) NewSqliteDb()
{
var conn = new SqliteConnection("DataSource=:memory:");
conn.Open();
var opts = new DbContextOptionsBuilder<ApplicationContext>().UseSqlite(conn).Options;
var db = new ApplicationContext(opts);
db.Database.EnsureCreated(); // schema WITH unique index on ExternalRef
return (db, conn);
}
private void WriteLitters(object litters) =>
File.WriteAllText(Path.Combine(_dir, "docx_litters.json"),
System.Text.Json.JsonSerializer.Serialize(litters));
private void WriteAnimals(object animals) =>
File.WriteAllText(Path.Combine(_dir, "docx_animals.json"),
System.Text.Json.JsonSerializer.Serialize(animals));
// ── Test 1: dry-run shows correct counts without writing ─────────────────
[Fact]
public async Task DryRun_counts_new_animal_without_writing()
{
var (db, conn) = NewSqliteDb();
await using (conn)
await using (db)
{
// Litter in DB — date-only lookup (±5 days, exactly one candidate)
var litter = new Litter
{
Id = Guid.NewGuid(),
Name = "Testwurf",
Date = new DateOnly(2023, 5, 1),
};
db.Litters.Add(litter);
await db.SaveChangesAsync();
WriteLitters(Array.Empty<object>());
WriteAnimals(new[]
{
new { wsCode = "4/4", litterDob = "01.05.2023", name = "Pepper", gender = "female",
owner = "Max Mustermann", abgabeDate = "01.07.2023",
deathDate = "", deathCause = "", farbschlag = "" }
});
var report = await new ImportDocxService(db, _dir).RunAsync(execute: false);
Assert.False(report.Executed);
Assert.Equal(1, report.Created);
Assert.Equal(1, report.LitterLinked); // date-only match finds the one litter
Assert.Equal(1, report.GoHomeFilled);
Assert.Equal(0, await db.Gerbils.CountAsync()); // nothing written
Assert.Equal(0, await db.Contacts.CountAsync()); // nothing written
}
}
// ── Test 2: execute creates animal with all fields + FK constraints ───────
[Fact]
public async Task Execute_creates_animal_with_litter_contact_and_goHomeDate()
{
var (db, conn) = NewSqliteDb();
await using (conn)
await using (db)
{
var litter = new Litter
{
Id = Guid.NewGuid(),
Name = "Testwurf",
Date = new DateOnly(2023, 5, 1),
};
db.Litters.Add(litter);
await db.SaveChangesAsync();
WriteLitters(Array.Empty<object>());
WriteAnimals(new[]
{
new { wsCode = "4/4", litterDob = "01.05.2023", name = "Pepper", gender = "female",
owner = "Max Mustermann", abgabeDate = "01.07.2023",
deathDate = "", deathCause = "", farbschlag = "" }
});
var report = await new ImportDocxService(db, _dir).RunAsync(execute: true);
Assert.True(report.Executed);
Assert.Equal(1, report.Created);
Assert.Equal(1, report.ContactsCreated);
var gerbil = await db.Gerbils.SingleAsync();
Assert.Equal("Pepper", gerbil.Name);
Assert.Equal(new DateOnly(2023, 5, 1), gerbil.DateOfBirth);
Assert.Equal(Gender.female, gerbil.Gender);
Assert.Equal(GerbilStatus.GivenAway, gerbil.Status);
Assert.Equal(litter.Id, gerbil.LitterId); // date-only link worked
Assert.Equal(new DateOnly(2023, 7, 1), gerbil.GoHomeDate);
Assert.Equal("Zucht der Kleinen Chaoten", gerbil.OriginBreeder);
Assert.False(gerbil.IsResident);
Assert.Equal("docx", gerbil.ImportSource);
Assert.StartsWith("docx-", gerbil.ExternalRef);
var contact = await db.Contacts.SingleAsync();
Assert.Equal("Max Mustermann", contact.Name);
Assert.Equal(contact.Id, gerbil.ReceiverContactId);
}
}
// ── Test 3: idempotency — second run creates zero ────────────────────────
[Fact]
public async Task Execute_is_idempotent_second_run_creates_zero()
{
var (db, conn) = NewSqliteDb();
await using (conn)
await using (db)
{
WriteLitters(Array.Empty<object>());
WriteAnimals(new[]
{
new { wsCode = "2/3", litterDob = "15.03.2023", name = "Flash", gender = "male",
owner = "", abgabeDate = "01.05.2023",
deathDate = "", deathCause = "", farbschlag = "" }
});
var first = await new ImportDocxService(db, _dir).RunAsync(execute: true);
Assert.Equal(1, first.Created);
var second = await new ImportDocxService(db, _dir).RunAsync(execute: true);
Assert.Equal(0, second.Created); // ExternalRef path, no dupe
Assert.Equal(1, await db.Gerbils.CountAsync()); // exactly one row
}
}
// ── Test 4: existing main-import animal → enrich only, no duplicate ──────
[Fact]
public async Task Execute_enriches_existing_animal_does_not_duplicate()
{
var (db, conn) = NewSqliteDb();
await using (conn)
await using (db)
{
// Animal already in DB (from main import, ExternalRef ≠ "docx-…")
var existing = new Gerbil
{
Id = Guid.NewGuid(),
Name = "Eddie",
DateOfBirth = new DateOnly(2022, 8, 10),
Gender = Gender.male,
ExternalRef = "main-eddie-xyz", // NOT a "docx-" ref
IsResident = false,
};
db.Gerbils.Add(existing);
await db.SaveChangesAsync();
WriteLitters(Array.Empty<object>());
WriteAnimals(new[]
{
// Same name+dob → enrich, not create
new { wsCode = "", litterDob = "10.08.2022", name = "Eddie", gender = "male",
owner = "Anna Beispiel", abgabeDate = "01.10.2022",
deathDate = "", deathCause = "", farbschlag = "" }
});
var report = await new ImportDocxService(db, _dir).RunAsync(execute: true);
Assert.Equal(0, report.Created);
Assert.Equal(1, await db.Gerbils.CountAsync()); // still exactly one
// GoHomeDate was enriched
var updated = await db.Gerbils.SingleAsync();
Assert.Equal(new DateOnly(2022, 10, 1), updated.GoHomeDate);
}
}
// ── Test 5: Deceased status when deathDate set ───────────────────────────
[Fact]
public async Task Execute_sets_Deceased_status_when_deathDate_provided()
{
var (db, conn) = NewSqliteDb();
await using (conn)
await using (db)
{
WriteLitters(Array.Empty<object>());
WriteAnimals(new[]
{
new { wsCode = "1/5", litterDob = "01.01.2022", name = "Ghost", gender = "male",
owner = "", abgabeDate = "",
deathDate = "15.06.2022", deathCause = "Tumor", farbschlag = "" }
});
await new ImportDocxService(db, _dir).RunAsync(execute: true);
var gerbil = await db.Gerbils.SingleAsync();
Assert.Equal(GerbilStatus.Deceased, gerbil.Status);
Assert.Equal(new DateOnly(2022, 6, 15), gerbil.DateOfDeath);
Assert.Equal("Tumor", gerbil.CauseOfDeath);
}
}
// ── Test 7: P0 REGRESSION — execute works under a retrying execution strategy ──
[Fact]
public async Task Execute_works_under_retrying_execution_strategy()
{
// Regression: NpgsqlRetryingExecutionStrategy (MaxRetryCount>0) calls
// OnFirstExecution() at the start of ExecuteAsync, which throws
// InvalidOperationException when it detects a user-initiated transaction
// that was NOT opened through the strategy. This test wires the same check
// (via FakeRetryingStrategy, MaxRetryCount=1) so the bug would surface in CI
// without a live Npgsql instance.
//
// With the BUG (direct BeginTransactionAsync before strategy.ExecuteAsync):
// → OnFirstExecution sees active user tx → InvalidOperationException
// With the FIX (BeginTransactionAsync inside strategy.ExecuteAsync lambda):
// → OnFirstExecution: no tx yet → OK
var conn = new SqliteConnection("DataSource=:memory:");
conn.Open();
var opts = new DbContextOptionsBuilder<ApplicationContext>()
.UseSqlite(conn)
.ReplaceService<IExecutionStrategyFactory, FakeRetryingStrategyFactory>()
.Options;
var db = new ApplicationContext(opts);
db.Database.EnsureCreated();
await using (conn)
await using (db)
{
WriteLitters(Array.Empty<object>());
WriteAnimals(new[]
{
new { wsCode = "3/3", litterDob = "01.01.2023", name = "Pixie", gender = "female",
owner = "Retry Adopter", abgabeDate = "01.03.2023",
deathDate = "", deathCause = "", farbschlag = "" }
});
// Must NOT throw InvalidOperationException (user-initiated tx rejected)
var report = await new ImportDocxService(db, _dir).RunAsync(execute: true);
Assert.True(report.Executed);
Assert.Equal(1, report.Created);
Assert.Equal(1, await db.Gerbils.CountAsync());
Assert.Equal(1, await db.Contacts.CountAsync());
}
}
// ── Test 6: P0 REGRESSION — same-name siblings get distinct ExternalRefs ─
[Fact]
public async Task Execute_same_name_siblings_created_with_distinct_ExternalRefs()
{
// P0 regression: two animals in the same litter with the same normalized name
// previously caused duplicate ExternalRef → Npgsql/SQLite 23505 unique-key crash.
// Fix: ordinal disambiguation (-2) ensures uniqueness within the batch.
// The unique index on IX_Gerbils_ExternalRef (via EnsureCreated on SQLite) makes
// this test an authoritative regression gate.
var (db, conn) = NewSqliteDb();
await using (conn)
await using (db)
{
WriteLitters(Array.Empty<object>());
WriteAnimals(new[]
{
new { wsCode = "4/4", litterDob = "10.06.2022", name = "Mochi", gender = "female",
owner = "Eva Müller", abgabeDate = "10.08.2022",
deathDate = "", deathCause = "", farbschlag = "" },
// Identical name+wsCode+litterDob → base ExternalRef collision
new { wsCode = "4/4", litterDob = "10.06.2022", name = "Mochi", gender = "female",
owner = "Lena Braun", abgabeDate = "11.08.2022",
deathDate = "", deathCause = "", farbschlag = "" },
});
// Must NOT throw unique-key violation
var report = await new ImportDocxService(db, _dir).RunAsync(execute: true);
Assert.Equal(2, report.Created);
Assert.Equal(2, await db.Gerbils.CountAsync());
var refs = (await db.Gerbils.Select(g => g.ExternalRef!).ToListAsync()).OrderBy(r => r).ToList();
// Both start with the docx- prefix
Assert.All(refs, r => Assert.StartsWith("docx-", r));
// Must be distinct (unique index enforces this in SQLite)
Assert.Equal(2, refs.Distinct().Count());
// Second occurrence gets the -2 suffix
Assert.Single(refs, r => r.EndsWith("-2"));
// Idempotent re-run: zero new, still 2 in DB
var second = await new ImportDocxService(db, _dir).RunAsync(execute: true);
Assert.Equal(0, second.Created);
Assert.Equal(2, await db.Gerbils.CountAsync());
}
}
}
// ── Helpers for Test 7 ────────────────────────────────────────────────────────
/// <summary>
/// Execution strategy with MaxRetryCount=1 so that EF Core's base
/// OnFirstExecution() throws when it detects a user-initiated transaction
/// that was not opened through CreateExecutionStrategy().ExecuteAsync().
/// ShouldRetryOn=false → no actual retry; the check alone is what we need.
/// </summary>
internal sealed class FakeRetryingStrategy(ExecutionStrategyDependencies deps)
: ExecutionStrategy(deps, maxRetryCount: 1, maxRetryDelay: TimeSpan.Zero)
{
protected override bool ShouldRetryOn(Exception exception) => false;
}
internal sealed class FakeRetryingStrategyFactory(ExecutionStrategyDependencies deps)
: IExecutionStrategyFactory
{
public IExecutionStrategy Create() => new FakeRetryingStrategy(deps);
}
}

View File

@@ -261,6 +261,54 @@ namespace GerbilManager.Tests
finally { try { Directory.Delete(dir, recursive: true); } catch { } } finally { try { Directory.Delete(dir, recursive: true); } catch { } }
} }
[Fact]
public async Task Execute_circular_gerbil_litter_dependency_does_not_throw()
{
// Regression for "Unable to save changes because a circular dependency was detected":
// Gerbil [Added] ← FK{MotherId} Litter [Added] ← FK{LitterId} Gerbil [Added].
// Triggered when a gerbil is both parent (in one synth litter) and offspring
// (LitterId → another synth litter) in the SAME SaveChanges batch — forms a cycle EF
// topo-sort cannot resolve. This test encodes the minimal reproducer: A's mother is B,
// B's mother is A (artificial genealogical cycle, but triggers the EF cycle reliably).
var dir = Path.Combine(Path.GetTempPath(), "cycle-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
using var conn = new SqliteConnection("DataSource=:memory:");
conn.Open();
try
{
File.WriteAllText(Path.Combine(dir, "litters.json"), "[]");
File.WriteAllText(Path.Combine(dir, "animals.json"), """
[
{"id":"alpha","name":"Alpha","dob":"01.01.2020","death":"","farbschlag":"",
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false,
"parentRefs":[{"name":"Beta","dob":"01.01.2019","roleGuess":"mother","method":"chart-position","confidence":"medium"}]},
{"id":"beta","name":"Beta","dob":"01.01.2019","death":"","farbschlag":"",
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false,
"parentRefs":[{"name":"Alpha","dob":"01.01.2020","roleGuess":"mother","method":"chart-position","confidence":"medium"}]}
]
""");
var opts = new DbContextOptionsBuilder<ApplicationContext>().UseSqlite(conn).Options;
using var db = new ApplicationContext(opts);
await db.Database.EnsureCreatedAsync();
// must NOT throw InvalidOperationException (circular dependency)
var report = await new ImportService(db, dir, dir).RunAsync(execute: true);
Assert.Equal(2, await db.Gerbils.CountAsync());
var alpha = await db.Gerbils.SingleAsync(g => g.ExternalRef == "alpha");
var beta = await db.Gerbils.SingleAsync(g => g.ExternalRef == "beta");
// both should be litter-linked
Assert.NotNull(alpha.LitterId);
Assert.NotNull(beta.LitterId);
// deferred FK update must have set the litter parents correctly
var alphaLitter = await db.Litters.SingleAsync(l => l.Id == alpha.LitterId);
Assert.Equal(beta.Id, alphaLitter.MotherId);
var betaLitter = await db.Litters.SingleAsync(l => l.Id == beta.LitterId);
Assert.Equal(alpha.Id, betaLitter.MotherId);
}
finally { try { Directory.Delete(dir, recursive: true); } catch { } }
}
[Fact] [Fact]
public async Task Derived_litter_with_quarantined_parent_leaves_FK_null_no_throw() public async Task Derived_litter_with_quarantined_parent_leaves_FK_null_no_throw()
{ {
@@ -573,27 +621,32 @@ namespace GerbilManager.Tests
[Fact] [Fact]
public void SeedGen3g_existing_varieties_preserve_id_name_binding() public void SeedGen3g_existing_varieties_preserve_id_name_binding()
{ {
// SEED-HELL bounce regression: the 61 existing entries must NOT change their // CATALOG-RESEED regression: ID->Name bindings for all 66 existing entries must be
// Id->Name binding after GEN-3g. The 5 new entries (IDs 62-66) are appended. // stable. 4 new Dilute Fuchs varieties appended (IDs 67-70). CP-Fuchs (ID 58) and
// A hand-assigned Gerbil.ColorVarietyId pointing to "CP-Fuchs" (ID 58) must // the CP-*-Hell entries (IDs 62-66) must survive with their original names intact.
// still map to CP-Fuchs after the migration runs (append-only, no rename-shift). using var db = NewDb(); // EnsureCreated applies HasData including 70-entry seed
using var db = NewDb(); // EnsureCreated applies HasData including new 66-entry seed
// ID 58 (index 57 in old catalog, 0-based) = CP-Fuchs — must still be CP-Fuchs // ID 58 = CP-Fuchs — must still be CP-Fuchs (no rename-shift from new entries)
var cpFuchsId = new Guid("00000000-0000-0000-0000-000000000058"); var cpFuchsId = new Guid("00000000-0000-0000-0000-000000000058");
var cpFuchs = db.ColorVarieties.Find(cpFuchsId); var cpFuchs = db.ColorVarieties.Find(cpFuchsId);
Assert.NotNull(cpFuchs); Assert.NotNull(cpFuchs);
Assert.Equal("CP-Fuchs", cpFuchs!.Name); Assert.Equal("CP-Fuchs", cpFuchs!.Name);
// New entries at IDs 62-66 exist with correct names // Existing CP-*-Hell entries (IDs 62-66) unchanged
Assert.Equal("CP-Agouti-Hell", db.ColorVarieties.Find(new Guid("00000000-0000-0000-0000-000000000062"))!.Name); Assert.Equal("CP-Agouti-Hell", db.ColorVarieties.Find(new Guid("00000000-0000-0000-0000-000000000062"))!.Name);
Assert.Equal("CP-Silberagouti-Hell", db.ColorVarieties.Find(new Guid("00000000-0000-0000-0000-000000000063"))!.Name); Assert.Equal("CP-Silberagouti-Hell", db.ColorVarieties.Find(new Guid("00000000-0000-0000-0000-000000000063"))!.Name);
Assert.Equal("CP-Algierfuchs-Hell", db.ColorVarieties.Find(new Guid("00000000-0000-0000-0000-000000000064"))!.Name); Assert.Equal("CP-Algierfuchs-Hell", db.ColorVarieties.Find(new Guid("00000000-0000-0000-0000-000000000064"))!.Name);
Assert.Equal("CP-Polarfuchs-Hell", db.ColorVarieties.Find(new Guid("00000000-0000-0000-0000-000000000065"))!.Name); Assert.Equal("CP-Polarfuchs-Hell", db.ColorVarieties.Find(new Guid("00000000-0000-0000-0000-000000000065"))!.Name);
Assert.Equal("CP-Orangeschimmel-Hell", db.ColorVarieties.Find(new Guid("00000000-0000-0000-0000-000000000066"))!.Name); Assert.Equal("CP-Orangeschimmel-Hell", db.ColorVarieties.Find(new Guid("00000000-0000-0000-0000-000000000066"))!.Name);
// Total count is exactly 66 // 4 new Dilute Fuchs varieties appended (IDs 67-70)
Assert.Equal(66, db.ColorVarieties.Count()); Assert.Equal("Dilute Algierfuchs", db.ColorVarieties.Find(new Guid("00000000-0000-0000-0000-000000000067"))!.Name);
Assert.Equal("Dilute Goldfuchs", db.ColorVarieties.Find(new Guid("00000000-0000-0000-0000-000000000068"))!.Name);
Assert.Equal("Dilute Rotfuchs", db.ColorVarieties.Find(new Guid("00000000-0000-0000-0000-000000000069"))!.Name);
Assert.Equal("Dilute Polarfuchs", db.ColorVarieties.Find(new Guid("00000000-0000-0000-0000-000000000070"))!.Name);
// Total count is exactly 70
Assert.Equal(70, db.ColorVarieties.Count());
} }
[Theory] [Theory]

View File

@@ -8,8 +8,8 @@ using Microsoft.Extensions.Options;
namespace GerbilManager.Tests namespace GerbilManager.Tests
{ {
/// <summary> /// <summary>
/// FEAT-NAMEGEN: NameSuggestionService — prompt assembly, JSON parse (incl. Markdown /// FEAT-NAMEGEN / NAMEGEN-2-BE: NameSuggestionService — prompt assembly, usage-code mapping,
/// fence strip), 503-not-configured path, upstream-error path. /// JSON parse (incl. Markdown fence strip), 503-not-configured path, upstream-error path.
/// </summary> /// </summary>
public class NameSuggestionTests public class NameSuggestionTests
{ {
@@ -26,6 +26,14 @@ namespace GerbilManager.Tests
Assert.Contains("origin", prompt); Assert.Contains("origin", prompt);
} }
[Fact]
public void SystemPrompt_erklärt_thematische_Kategorien()
{
var prompt = NameSuggestionService.BuildSystemPrompt();
Assert.Contains("thematischen Kategorien", prompt);
Assert.Contains("Erfinde KEINE Etymologie", prompt);
}
[Fact] [Fact]
public void UserPrompt_enthält_Anzahl_und_Anfangsbuchstaben() public void UserPrompt_enthält_Anzahl_und_Anfangsbuchstaben()
{ {
@@ -33,7 +41,10 @@ namespace GerbilManager.Tests
Assert.Contains("6", prompt); Assert.Contains("6", prompt);
Assert.Contains("\"A\"", prompt); Assert.Contains("\"A\"", prompt);
Assert.Contains("weibliche", prompt); Assert.Contains("weibliche", prompt);
Assert.Contains("norn,mythg", prompt); // codes are mapped to German descriptions
Assert.Contains("Nordische/Altnordische Etymologie", prompt);
Assert.Contains("Griechische Mythologie", prompt);
Assert.Contains("Kulturkreisen", prompt);
} }
[Fact] [Fact]
@@ -54,6 +65,67 @@ namespace GerbilManager.Tests
Assert.DoesNotContain("männliche", prompt); Assert.DoesNotContain("männliche", prompt);
} }
// ── NAMEGEN-2-BE: neue Themen-Kategorien ─────────────────────────────
[Theory]
[InlineData("disney", "Disney-Charaktere")]
[InlineData("pokemon", "Pokémon-Namen")]
[InlineData("encities", "Namen englischer Städte")]
[InlineData("hrcities", "Namen kroatischer Städte")]
[InlineData("usstates", "Namen von US-Bundesstaaten")]
public void UsageMap_enthält_alle_fünf_neuen_Codes(string code, string expectedDescription)
{
Assert.True(NameSuggestionService.UsageMap.TryGetValue(code, out var entry));
Assert.Equal(expectedDescription, entry.Description);
Assert.True(entry.Thematic);
}
[Theory]
[InlineData("disney", "Disney-Charaktere")]
[InlineData("pokemon", "Pokémon-Namen")]
[InlineData("encities", "Namen englischer Städte")]
[InlineData("hrcities", "Namen kroatischer Städte")]
[InlineData("usstates", "Namen von US-Bundesstaaten")]
public void UserPrompt_enthält_Themen_Kategorie_Beschreibung(string code, string expectedDescription)
{
var prompt = NameSuggestionService.BuildUserPrompt(null, null, code, 5);
Assert.Contains(expectedDescription, prompt);
Assert.Contains("Themen-Kategorien", prompt);
Assert.DoesNotContain("Kulturkreisen", prompt);
}
[Fact]
public void UserPrompt_trennt_etym_und_thematische_Kategorien()
{
var prompt = NameSuggestionService.BuildUserPrompt("D", null, "norn,disney", 4);
Assert.Contains("Nordische/Altnordische Etymologie", prompt);
Assert.Contains("Kulturkreisen", prompt);
Assert.Contains("Disney-Charaktere", prompt);
Assert.Contains("Themen-Kategorien", prompt);
}
[Fact]
public void UserPrompt_thematisch_enthält_Geschlecht_Hinweis()
{
var prompt = NameSuggestionService.BuildUserPrompt(null, "female", "encities", 3);
Assert.Contains("Geschlecht-Filter kann ignoriert werden", prompt);
}
[Fact]
public async Task SuggestAsync_parst_thematische_Kategorie_Antwort()
{
var payload = """[{"name":"Dumbo","meaning":"Elefant mit großen Ohren aus dem Disney-Film Dumbo (1941)","origin":"Disney"}]""";
var handler = new StubHandler(_ => Canned(payload));
var service = CreateService("https://api.example.com/v1", "k", "m", handler);
var result = await service.SuggestAsync("D", null, "disney", 1);
Assert.Equal(NameSuggestionStatus.Ok, result.Status);
Assert.NotNull(result.Suggestions);
Assert.Equal("Dumbo", result.Suggestions![0].Name);
Assert.Equal("Disney", result.Suggestions[0].Origin);
}
// ── JSON parsing ────────────────────────────────────────────────────── // ── JSON parsing ──────────────────────────────────────────────────────
[Fact] [Fact]

View File

@@ -282,79 +282,97 @@ public class ApplicationContext : DbContext
/// </summary> /// </summary>
private static void SeedColorVarieties(ModelBuilder modelBuilder) private static void SeedColorVarieties(ModelBuilder modelBuilder)
{ {
// GEN-3g (Kevin): 5 CP-*-Hell variants appended at the end (IDs 62-66). // CATALOG-RESEED (AR-5 / colorVarietySeed.backend.json): 70 entries, frozen symbols.
// Existing 61 entries are UNCHANGED (same ID->Name binding preserved). // ID = array_index + 1 (stable, never changes — live FKs safe).
// SortOrder for new entries is appended; Kevin's frontend catalog handles // SortOrder comes from colorVarietySeed.backend.json (decoupled from ID).
// the interleaved display order via its own sortOrder values. // New entries appended at end (IDs 67-70: Dilute Algierfuchs/Goldfuchs/Rotfuchs/Polarfuchs).
(string Name, string Genotype)[] catalog = // GUARDRAIL: DO NOT reseed from colorVarietySeed.generated.json — it uses bracket display
// notation (e[f]/c[chm]) which breaks CR-11 GenotypePotentiallyMatches() (frozen vs brackets).
(string Name, string Genotype, int SortOrder)[] catalog =
{ {
("Pink Eyed White (PEW)", "AA chch DD EE GG pp spsp rere"), // --- C-locus white / partial albino (IDs 1-3) ---
("Hermelin", "aa chch DD EE GG PP spsp rere"), ("REW", "AA chch DD EE GG pp spsp rere", 0),
("Himalaya", "AA chch DD EE GG PP spsp rere"), ("Hermelin", "aa chch DD EE GG PP spsp rere", 1),
("Zobel", "aa cchmcchm DD EE gg PP spsp rere"), ("Himalaya", "AA chch DD EE GG PP spsp rere", 2),
("Rotaugenschimmel", "AA CC DD efef GG pp spsp rere"), // --- Zobel / colourpoint dark (ID 4) ---
("Agouti", "AA CC DD EE GG PP spsp rere"), ("Zobel", "aa cchmcchm DD EE gg PP spsp rere", 3),
("Schwarz", "aa CC DD EE GG PP spsp rere"), // --- Schimmel family (IDs 5) ---
("Silberagouti", "AA CC DD EE gg PP spsp rere"), ("Rotaugenschimmel", "AA CC DD efef GG pp spsp rere", 4),
("Anthrazit", "aa CC DD EE gg PP spsp rere"), // --- Standard colours (IDs 6-15) ---
("Algierfuchs", "AA CC DD ee GG PP spsp rere"), ("Agouti", "AA CC DD EE GG PP spsp rere", 5),
("Blau", "aa CC dd EE GG PP spsp rere"), ("Schwarz", "aa CC DD EE GG PP spsp rere", 6),
("Gold", "AA CC DD EE GG pp spsp rere"), ("Silberagouti","AA CC DD EE gg PP spsp rere", 7),
("Platin", "aa CC DD EE GG pp spsp rere"), ("Anthrazit", "aa CC DD EE gg PP spsp rere", 8),
("Goldfuchs", "AA CC DD ee GG pp spsp rere"), ("Algierfuchs", "AA CC DD ee GG PP spsp rere", 9),
("Rotfuchs", "aa CC DD ee GG pp spsp rere"), ("Blau", "aa CC dd EE GG PP spsp rere", 10),
("dd Gold", "AA CC dd EE GG pp spsp rere"), ("Gold", "AA CC DD EE GG pp spsp rere", 11),
("dd Platin", "aa CC dd EE GG pp spsp rere"), ("Platin", "aa CC DD EE GG pp spsp rere", 12),
("Altweiss (REW)", "aa CC DD EE gg pp spsp rere"), ("Goldfuchs", "AA CC DD ee GG pp spsp rere", 13),
("Apricot (Blassfuchs)", "AA CC DD ee gg pp spsp rere"), ("Rotfuchs", "aa CC DD ee GG pp spsp rere", 14),
("Blaufuchs", "aa CC DD ee gg PP spsp rere"), // --- Dilute (dd) standard (IDs 16-17) ---
("C-Separator", "aa CC DD ee gg pp spsp rere"), ("Dilute Gold", "AA CC dd EE GG pp spsp rere", 15),
("Elfenbein", "AA CC DD EE gg pp spsp rere"), ("Dilute Platin","aa CC dd EE GG pp spsp rere", 16),
("Kohlfuchs", "aa CC DD ee GG PP spsp rere"), // --- REW / Apricot / misc C-locus (IDs 18-28) ---
("Polarfuchs", "AA CC DD ee gg PP spsp rere"), ("Altweiss (REW)", "aa CC DD EE gg pp spsp rere", 17),
("Saphir", "aa CC DD EE GG pp spsp rere"), ("Apricot (Blassfuchs)","AA CC DD ee gg pp spsp rere", 18),
("Orangeschimmel", "AA CC DD efef GG PP spsp rere"), ("Blaufuchs", "aa CC DD ee gg PP spsp rere", 19),
("Topas", "AA CC DD EE GG pp spsp rere"), ("C-Separator", "aa CC DD ee gg pp spsp rere", 20),
("Platin-Hell", "aa CC DD EE GG pp spsp rere"), ("Elfenbein", "AA CC DD EE gg pp spsp rere", 21),
("Agouti dd", "AA CC dd EE GG PP spsp rere"), ("Kohlfuchs", "aa CC DD ee GG PP spsp rere", 22),
("Silberagouti dd", "AA CC dd EE gg PP spsp rere"), ("Polarfuchs", "AA CC DD ee gg PP spsp rere", 23),
("Kohlfuchs dd", "aa CC dd ee GG PP spsp rere"), ("Saphir", "aa CC DD EE GG pp spsp rere", 24),
("Anthrazit dd", "aa CC dd EE gg PP spsp rere"), ("Orangeschimmel","AA CC DD efef GG PP spsp rere", 25),
("Silberschimmel", "AA CC DD efef gg PP spsp rere"), ("Topas", "AA CC DD EE GG pp spsp rere", 26),
("Polarfuchsschimmel", "AA CC DD efef gg PP spsp rere"), ("Platin-Hell", "aa CC DD EE GG pp spsp rere", 27),
("Algierfuchsschimmel", "AA CC DD efef GG PP spsp rere"), // --- Dilute (dd) varieties (IDs 29-32) ---
("Kohlfuchsschimmel", "aa CC DD efef GG PP spsp rere"), ("Dilute Agouti", "AA CC dd EE GG PP spsp rere", 28),
("Blaufuchsschimmel", "aa CC DD efef gg PP spsp rere"), ("Dilute Silberagouti","AA CC dd EE gg PP spsp rere", 29),
("Kohlfuchs, hell", "aa CC DD ee GG PP spsp rere"), ("Dilute Kohlfuchs", "aa CC dd ee GG PP spsp rere", 30),
("Goldfuchs, hell", "AA CC DD ee GG pp spsp rere"), ("Dilute Anthrazit", "aa CC dd EE gg PP spsp rere", 31),
("Goldfuchsschimmel", "AA CC DD efef GG pp spsp rere"), // --- Schimmel / Fuchsschimmel (IDs 33-37) ---
("Gold-Hell", "AA CC DD EE GG pp spsp rere"), ("Silberschimmel", "AA CC DD efef gg PP spsp rere", 36),
("Blaufuchs, hell", "aa CC DD ee gg PP spsp rere"), ("Polarfuchsschimmel", "AA CC DD efef gg PP spsp rere", 37),
("Rotfuchsschimmel", "aa CC DD efef GG pp spsp rere"), ("Algierfuchsschimmel", "AA CC DD efef GG PP spsp rere", 38),
("Polarfuchs, hell", "AA CC DD ee gg PP spsp rere"), ("Kohlfuchsschimmel", "aa CC DD efef GG PP spsp rere", 39),
("Kohlfuchsschimmel, hell", "aa CC DD efef GG PP spsp rere"), ("Blaufuchsschimmel", "aa CC DD efef gg PP spsp rere", 40),
("Rotfuchs, hell", "aa CC DD ee GG pp spsp rere"), // --- Hell variants (IDs 38-48) ---
("Kohlfuchs-Hell", "aa CC DD ee GG PP spsp rere"), ("Kohlfuchs, hell", "aa CC DD ee GG PP spsp rere", 41),
("Algierfuchs, hell", "AA CC DD ee GG PP spsp rere"), ("Goldfuchs, hell", "AA CC DD ee GG pp spsp rere", 42),
("Topas dd", "AA CC dd EE GG pp spsp rere"), ("Goldfuchsschimmel", "AA CC DD efef GG pp spsp rere", 43),
("Blaufuchs dd", "aa CC dd ee gg pp spsp rere"), ("Gold-Hell", "AA CC DD EE GG pp spsp rere", 44),
("Marder", "aa cchmcchm DD EE GG PP spsp rere"), ("Blaufuchs, hell", "aa CC DD ee gg PP spsp rere", 45),
("Siam", "aa cchmch DD EE GG PP spsp rere"), ("Rotfuchsschimmel", "aa CC DD efef GG pp spsp rere", 46),
("Zobel-Hell", "aa cchmch DD EE gg PP spsp rere"), ("Polarfuchs, hell", "AA CC DD ee gg PP spsp rere", 47),
("CP-Agouti", "AA cchmcchm DD EE GG PP spsp rere"), ("Kohlfuchsschimmel, hell","aa CC DD efef GG PP spsp rere", 48),
("CP-Silberagouti", "AA cchmcchm DD EE gg PP spsp rere"), ("Rotfuchs, hell", "aa CC DD ee GG pp spsp rere", 49),
("CP-Algierfuchs", "AA cchmcchm DD ee GG PP spsp rere"), ("Kohlfuchs-Hell", "aa CC DD ee GG PP spsp rere", 50),
("CP-Polarfuchs", "AA cchmcchm DD ee gg PP spsp rere"), ("Algierfuchs, hell", "AA CC DD ee GG PP spsp rere", 51),
("CP-Fuchs", "AA cchmcchm dd ee GG PP spsp rere"), // --- Dilute (dd) renamed variants (IDs 49-50) ---
("CP-Fuchs-Hell", "AA cchmch dd ee GG PP spsp rere"), ("Dilute Topas", "AA CC dd EE GG pp spsp rere", 52),
("CP-Blaufuchs", "AA cchmcchm dd ee gg PP spsp rere"), ("Dilute Blaufuchs","aa CC dd ee gg pp spsp rere", 53),
("CP-Orangeschimmel", "AA cchmcchm DD efef GG PP spsp rere"), // --- Marder / Siam / CP- series (IDs 51-66) ---
// GEN-3g: 5 new CP-*-Hell variants appended (IDs 62-66, no ID->Name drift) ("Marder", "aa cchmcchm DD EE GG PP spsp rere", 54),
("CP-Agouti-Hell", "AA cchmch DD EE GG PP spsp rere"), ("Siam", "aa cchmch DD EE GG PP spsp rere", 55),
("CP-Silberagouti-Hell", "AA cchmch DD EE gg PP spsp rere"), ("Zobel-Hell","aa cchmch DD EE gg PP spsp rere", 56),
("CP-Algierfuchs-Hell", "AA cchmch DD ee GG PP spsp rere"), ("CP-Agouti", "AA cchmcchm DD EE GG PP spsp rere", 57),
("CP-Polarfuchs-Hell", "AA cchmch DD ee gg PP spsp rere"), ("CP-Silberagouti","AA cchmcchm DD EE gg PP spsp rere", 59),
("CP-Orangeschimmel-Hell","AA cchmch DD efef GG PP spsp rere"), ("CP-Algierfuchs", "AA cchmcchm DD ee GG PP spsp rere", 61),
("CP-Polarfuchs", "AA cchmcchm DD ee gg PP spsp rere", 63),
("CP-Fuchs", "AA cchmcchm dd ee GG PP spsp rere", 65),
("CP-Fuchs-Hell", "AA cchmch dd ee GG PP spsp rere", 66),
("CP-Blaufuchs", "AA cchmcchm dd ee gg PP spsp rere", 67),
("CP-Orangeschimmel","AA cchmcchm DD efef GG PP spsp rere", 68),
// GEN-3g / CATALOG-RESEED: CP-*-Hell interleaved (IDs 62-66, SortOrders from backend.json)
("CP-Agouti-Hell", "AA cchmch DD EE GG PP spsp rere", 58),
("CP-Silberagouti-Hell", "AA cchmch DD EE gg PP spsp rere", 60),
("CP-Algierfuchs-Hell", "AA cchmch DD ee GG PP spsp rere", 62),
("CP-Polarfuchs-Hell", "AA cchmch DD ee gg PP spsp rere", 64),
("CP-Orangeschimmel-Hell","AA cchmch DD efef GG PP spsp rere", 69),
// CATALOG-RESEED: 4 new Dilute (dd) Fuchs varieties appended (IDs 67-70)
("Dilute Algierfuchs","AA CC dd ee GG PP spsp rere", 32),
("Dilute Goldfuchs", "AA CC dd ee GG pp spsp rere", 33),
("Dilute Rotfuchs", "aa CC dd ee GG pp spsp rere", 34),
("Dilute Polarfuchs", "AA CC dd ee gg PP spsp rere", 35),
}; };
var rows = new ColorVariety[catalog.Length]; var rows = new ColorVariety[catalog.Length];
@@ -362,11 +380,11 @@ public class ApplicationContext : DbContext
{ {
rows[i] = new ColorVariety rows[i] = new ColorVariety
{ {
// Stable, deterministic GUIDs so the HasData seed is migration-stable. // Stable, deterministic GUIDs — ID = array_index + 1 (never reorder existing entries).
Id = new Guid($"00000000-0000-0000-0000-{(i + 1):D12}"), Id = new Guid($"00000000-0000-0000-0000-{(i + 1):D12}"),
Name = catalog[i].Name, Name = catalog[i].Name,
CanonicalGenotype = catalog[i].Genotype, CanonicalGenotype = catalog[i].Genotype,
SortOrder = i, SortOrder = catalog[i].SortOrder,
}; };
} }
modelBuilder.Entity<ColorVariety>().HasData(rows); modelBuilder.Entity<ColorVariety>().HasData(rows);

View File

@@ -0,0 +1,16 @@
namespace GerbilManagerWebAPI.Cms
{
/// <summary>
/// WEB-2: path where POST /api/publish writes the rendered static site.
/// Env var: PublicSite__RootPath (empty = publish disabled, returns 503).
/// In production this volume is shared with the publicsite-nginx container.
/// </summary>
public sealed class PublicSiteOptions
{
public const string SectionName = "PublicSite";
public string? RootPath { get; set; }
public bool IsConfigured => !string.IsNullOrWhiteSpace(RootPath);
}
}

View File

@@ -1,3 +1,4 @@
using System.Text;
using System.Text.Json; using System.Text.Json;
using System.Text.Json.Nodes; using System.Text.Json.Nodes;
using GerbilManagerWebAPI.Cms; using GerbilManagerWebAPI.Cms;
@@ -5,6 +6,7 @@ using GerbilManagerWebAPI.Dtos;
using GerbilManagerWebAPI.Models; using GerbilManagerWebAPI.Models;
using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
namespace GerbilManagerWebAPI.Endpoints namespace GerbilManagerWebAPI.Endpoints
{ {
@@ -32,6 +34,21 @@ namespace GerbilManagerWebAPI.Endpoints
return TypedResults.Ok(files.Select(kv => new { path = kv.Key, size = kv.Value.Length }).ToList()); return TypedResults.Ok(files.Select(kv => new { path = kv.Key, size = kv.Value.Length }).ToList());
}); });
// ---- WEB-2: publish — rendert Snapshot auf Disk, atomic swap live/ ----
api.MapPost("/publish", async (ApplicationContext db, IOptions<PublicSiteOptions> opts) =>
{
if (!opts.Value.IsConfigured)
return Results.Problem(
detail: "PublicSite__RootPath ist nicht konfiguriert. Setze die Umgebungsvariable.",
statusCode: 503,
title: "PublicSite nicht konfiguriert");
var snapshot = await new SiteSnapshotService(db).BuildAsync();
var files = SiteRenderer.Render(snapshot);
await PublishToDirectoryAsync(files, opts.Value.RootPath!);
return Results.Ok(new { filesPublished = files.Count });
});
// ---- WEB-3: lokale Vorschau — rendert live (nur veröffentlichte Seiten) // ---- WEB-3: lokale Vorschau — rendert live (nur veröffentlichte Seiten)
// und liefert die Datei mit passendem Content-Type aus. Relative // und liefert die Datei mit passendem Content-Type aus. Relative
// Links/CSS der gerenderten Seite funktionieren dadurch im // Links/CSS der gerenderten Seite funktionieren dadurch im
@@ -169,6 +186,36 @@ namespace GerbilManagerWebAPI.Endpoints
return app; return app;
} }
/// <summary>
/// WEB-2: Writes rendered files to <paramref name="rootPath"/>/_staging_new, then
/// atomically swaps to live/ (rename on the same filesystem = one syscall, never partial).
/// </summary>
internal static async Task PublishToDirectoryAsync(
IReadOnlyDictionary<string, string> files, string rootPath)
{
var stagingDir = Path.Combine(rootPath, "_staging_new");
var liveDir = Path.Combine(rootPath, "live");
var oldDir = Path.Combine(rootPath, "_old");
if (Directory.Exists(stagingDir)) Directory.Delete(stagingDir, recursive: true);
Directory.CreateDirectory(stagingDir);
foreach (var (relativePath, content) in files)
{
var normalPath = relativePath.Replace('/', Path.DirectorySeparatorChar);
var fullPath = Path.Combine(stagingDir, normalPath);
Directory.CreateDirectory(Path.GetDirectoryName(fullPath)!);
await File.WriteAllTextAsync(fullPath, content, Encoding.UTF8);
}
// Atomic swap: _staging_new → live
if (Directory.Exists(oldDir)) Directory.Delete(oldDir, recursive: true);
if (Directory.Exists(liveDir)) Directory.Move(liveDir, oldDir);
Directory.Move(stagingDir, liveDir);
try { if (Directory.Exists(oldDir)) Directory.Delete(oldDir, recursive: true); }
catch { /* non-fatal — old dir gone on next publish */ }
}
/// <summary>WEB-3: Content-Type der Vorschau-Dateien (Renderer erzeugt HTML + CSS).</summary> /// <summary>WEB-3: Content-Type der Vorschau-Dateien (Renderer erzeugt HTML + CSS).</summary>
private static string PreviewContentType(string path) => private static string PreviewContentType(string path) =>
path.EndsWith(".css", StringComparison.OrdinalIgnoreCase) ? "text/css; charset=utf-8" path.EndsWith(".css", StringComparison.OrdinalIgnoreCase) ? "text/css; charset=utf-8"

View File

@@ -6,16 +6,26 @@ namespace GerbilManagerWebAPI.Import
{ {
/// <summary> /// <summary>
/// FEAT-8d docx loader. Consumes tools/import/output/docx_litters.json + /// FEAT-8d docx loader. Consumes tools/import/output/docx_litters.json +
/// docx_animals.json (produced by extract_docx.py) and enriches the database: /// docx_animals.json (produced by extract_docx.py) and enriches OR CREATES animals.
/// ///
/// Load policy (IDEMPOTENT NACHZUG after main WIPE+REIMPORT): /// Load policy (IDEMPOTENT NACHZUG after main WIPE+REIMPORT):
/// - Litter link: match docx WS-code to Litters.PairingCode → set Gerbil.LitterId /// Match order per docx row:
/// for animals matched by normalize(name) + litter birth date. /// 1. ExternalRef "docx-…" (previously created by this loader) → enrich (fill-NULL-only)
/// - ReceiverContact: lookup-or-create Contact by owner name → set ReceiverContactId. /// 2. normalize(name)+litterDob in Gerbils (from main import) → enrich (fill-NULL-only)
/// - GoHomeDate, DateOfDeath, CauseOfDeath: fill if currently null (fill-NULL-only). /// 3. No match → CREATE: abgegebene Jungtiere that never appeared in the pedigree charts.
/// - NEVER overwrites a manually-set non-null value.
/// ///
/// Idempotent: running multiple times is safe. Each run resolves whatever is still null. /// Created animals: Status=GivenAway (or Deceased), IsResident=false,
/// OriginBreeder='Zucht der Kleinen Chaoten', ImportSource="docx",
/// ExternalRef = stable "docx-{ws}-{normname}-{litterDob}[-N]" key (idempotent;
/// -N ordinal suffix disambiguates same-name siblings in a litter).
///
/// NEVER overwrites a manually-set non-null value (fill-NULL-only for all fields).
///
/// Idempotent: running multiple times is safe. Re-run finds existing rows via ExternalRef.
/// Execute wraps all writes in a single transaction via CreateExecutionStrategy() so that
/// providers using EnableRetryOnFailure (e.g. NpgsqlRetryingExecutionStrategy) are
/// compatible. The strategy lambda resets all mutable state at the top so it is safe
/// to re-run on transient-failure retry.
/// Execute is gated by the endpoint; this service only acts when asked. /// Execute is gated by the endpoint; this service only acts when asked.
/// </summary> /// </summary>
public sealed class ImportDocxService public sealed class ImportDocxService
@@ -48,29 +58,38 @@ namespace GerbilManagerWebAPI.Import
{ {
notes.Add($"Keine Quelldaten in {_sourceDir} (docx_litters.json/docx_animals.json). " + notes.Add($"Keine Quelldaten in {_sourceDir} (docx_litters.json/docx_animals.json). " +
"extract_docx.py zuerst ausführen."); "extract_docx.py zuerst ausführen.");
return new ImportDocxReport(false, 0, 0, 0, 0, 0, 0, notes); return new ImportDocxReport(false, 0, 0, 0, 0, 0, 0, 0, notes);
} }
// Build lookup: PairingCode → Litter.Id (WS-code normalised: spaces removed) // Litter lookup by birth date (DayNumber) → list of matching DB litters.
// NOTE: WsCode in docx is a litter-size fraction ("4/4", "/5") — NOT a PairingCode.
// Date-only lookup with uniqueness guard avoids false links (only link when
// exactly one DB litter falls within ±5 days of the docx litter DOB).
var littersInDb = await _db.Litters var littersInDb = await _db.Litters
.Where(l => l.PairingCode != null) .Select(l => new { l.Id, l.Date })
.Select(l => new { l.Id, l.Date, l.PairingCode })
.ToListAsync(); .ToListAsync();
var litterByWs = littersInDb var littersByDayNumber = littersInDb
.GroupBy(l => l.PairingCode!.Replace(" ", "")) .GroupBy(l => l.Date.DayNumber)
.ToDictionary(g => g.Key, g => g.ToList()); .ToDictionary(g => g.Key, g => g.ToList());
// Build animal lookup: normalize(name) + litter_dob → Gerbil (for litter-link) // normalize(name)+litterDob → Gerbil snapshot (main-import enrich path)
var gerbilsInDb = await _db.Gerbils var gerbilsInDb = await _db.Gerbils
.Select(g => new { g.Id, g.Name, g.DateOfBirth, g.LitterId, .Select(g => new { g.Id, g.Name, g.DateOfBirth, g.LitterId,
g.ReceiverContactId, g.GoHomeDate, g.DateOfDeath, g.CauseOfDeath }) g.ReceiverContactId, g.GoHomeDate, g.DateOfDeath, g.CauseOfDeath,
g.ExternalRef })
.ToListAsync(); .ToListAsync();
var gerbilByKey = gerbilsInDb var gerbilByKey = gerbilsInDb
.Where(g => g.DateOfBirth is not null) .Where(g => g.DateOfBirth is not null)
.GroupBy(g => NameDobKey(g.Name, g.DateOfBirth!.Value)) .GroupBy(g => NameDobKey(g.Name, g.DateOfBirth!.Value))
.ToDictionary(g => g.Key, g => g.ToList()); .ToDictionary(g => g.Key, g => g.ToList());
// Contact lookup: normalized name → existing Contact // ExternalRef → snapshot for previously docx-created animals (idempotency across runs)
var docxExternalRefs = gerbilsInDb
.Where(g => g.ExternalRef?.StartsWith("docx-") == true)
.ToDictionary(g => g.ExternalRef!,
g => new { g.Id, g.LitterId, g.GoHomeDate, g.DateOfDeath, g.ReceiverContactId });
// Contact lookup: normalized name → existing Contact.Id
var contactsInDb = await _db.Contacts var contactsInDb = await _db.Contacts
.Select(c => new { c.Id, c.Name }) .Select(c => new { c.Id, c.Name })
.ToListAsync(); .ToListAsync();
@@ -78,107 +97,231 @@ namespace GerbilManagerWebAPI.Import
.GroupBy(c => NormalizeName(c.Name)) .GroupBy(c => NormalizeName(c.Name))
.ToDictionary(g => g.Key, g => g.First().Id); .ToDictionary(g => g.Key, g => g.First().Id);
int litterLinked = 0, goHomeFilled = 0, deathFilled = 0; // Snapshot of DB contacts before any writes.
// Used to reset contactByNorm on strategy retry (rolled-back contacts vanish from DB
// but would remain in the in-memory dict without this reset).
var contactByNormBase = new Dictionary<string, Guid>(contactByNorm);
// ColorVariety lookup: normalized name → Id (for CREATE path Farbschlag matching)
var colorVarietyByName = (await _db.ColorVarieties
.Select(cv => new { cv.Id, cv.Name })
.ToListAsync())
.GroupBy(cv => NormalizeName(cv.Name))
.ToDictionary(g => g.Key, g => g.First().Id);
int animalsCreated = 0, litterLinked = 0, goHomeFilled = 0, deathFilled = 0;
int ownerLinked = 0, ownerCreated = 0, skipped = 0; int ownerLinked = 0, ownerCreated = 0, skipped = 0;
foreach (var da in docxAnimals) // Ordinal counter for collision-free ExternalRef within this batch.
var externalRefOrdinals = new Dictionary<string, int>();
// Belt-and-suspenders: guard against adding the same ExternalRef twice in one run.
var batchRefs = new HashSet<string>();
// Inner loop — shared by dry-run and execute paths.
// All local variables above are captured by reference (C# closure), so the strategy
// lambda can reset them before each retry and RunLoopAsync sees the fresh state.
async Task RunLoopAsync()
{ {
if (string.IsNullOrWhiteSpace(da.Name)) { skipped++; continue; } foreach (var da in docxAnimals)
// Resolve the litter by WS-code + approximate birth date
Guid? litterId = null;
if (!string.IsNullOrWhiteSpace(da.WsCode) && !string.IsNullOrWhiteSpace(da.LitterDob))
{ {
var litterDob = ParseDate(da.LitterDob); if (string.IsNullOrWhiteSpace(da.Name)) { skipped++; continue; }
if (litterDob is not null && litterByWs.TryGetValue(da.WsCode.Replace(" ", ""), out var cands))
// Collision-free ExternalRef: ordinal suffix for same-name siblings
var baseRef = DocxExternalRefBase(da);
externalRefOrdinals.TryGetValue(baseRef, out var ord);
ord++;
externalRefOrdinals[baseRef] = ord;
var externalRef = ord == 1 ? baseRef : $"{baseRef}-{ord}";
// Resolve litter: date ±5 days, unambiguous (exactly one candidate)
Guid? litterId = null;
if (!string.IsNullOrWhiteSpace(da.LitterDob))
{ {
// Pick the litter whose date matches (within ±5 days for rounding) var litterDob = ParseDate(da.LitterDob);
var match = cands.FirstOrDefault(l => if (litterDob is not null)
Math.Abs((l.Date.DayNumber - litterDob.Value.DayNumber)) <= 5);
litterId = match?.Id;
}
}
// Resolve the gerbil by name + litter birth date
var animalDob = litterId is not null
? (await _db.Litters.Where(l => l.Id == litterId).Select(l => (DateOnly?)l.Date).FirstOrDefaultAsync())
: ParseDate(da.LitterDob);
if (animalDob is null) { skipped++; continue; }
var key = NameDobKey(da.Name, animalDob.Value);
if (!gerbilByKey.TryGetValue(key, out var gerbilCands)) { skipped++; continue; }
// If multiple gerbils match (same name+dob), take the one without a litter link first
var gerbilSnap = gerbilCands.FirstOrDefault(g => g.LitterId == null)
?? gerbilCands.First();
// Resolve receiver contact (lookup-or-create)
Guid? receiverId = null;
if (!string.IsNullOrWhiteSpace(da.Owner))
{
var normOwner = NormalizeName(da.Owner);
if (contactByNorm.TryGetValue(normOwner, out var existingId))
{
receiverId = existingId;
ownerLinked++;
}
else
{
ownerCreated++;
if (execute)
{ {
var newContact = new Contact { Id = Guid.NewGuid(), Name = da.Owner.Trim() }; var candidates = new List<Guid>();
_db.Contacts.Add(newContact); for (int delta = -5; delta <= 5; delta++)
await _db.SaveChangesAsync(); {
receiverId = newContact.Id; if (littersByDayNumber.TryGetValue(litterDob.Value.DayNumber + delta, out var cl))
contactByNorm[normOwner] = receiverId.Value; candidates.AddRange(cl.Select(l => l.Id));
}
if (candidates.Count == 1)
litterId = candidates[0];
// If 0 or >1 candidates: no link (avoid false links)
} }
} }
}
var goHomeDate = ParseDate(da.AbgabeDate); // Animal DOB = litter birth date (docx has no per-animal DOB)
var deathDate = ParseDate(da.DeathDate); var animalDob = litterId is not null
? (await _db.Litters.Where(l => l.Id == litterId).Select(l => (DateOnly?)l.Date).FirstOrDefaultAsync())
: ParseDate(da.LitterDob);
// Count what will change if (animalDob is null) { skipped++; continue; }
bool willLinkLitter = litterId is not null && gerbilSnap.LitterId is null;
bool willFillGoHome = goHomeDate is not null && gerbilSnap.GoHomeDate is null;
bool willFillDeath = deathDate is not null && gerbilSnap.DateOfDeath is null;
if (willLinkLitter) litterLinked++; var goHomeDate = ParseDate(da.AbgabeDate);
if (willFillGoHome) goHomeFilled++; var deathDate = ParseDate(da.DeathDate);
if (willFillDeath) deathFilled++;
if (execute) // Resolve receiver contact (lookup-or-create; shared by all paths)
{ Guid? receiverId = null;
var row = await _db.Gerbils.FirstOrDefaultAsync(g => g.Id == gerbilSnap.Id); if (!string.IsNullOrWhiteSpace(da.Owner))
if (row is null) continue;
if (willLinkLitter) row.LitterId = litterId;
if (receiverId is not null && row.ReceiverContactId is null)
row.ReceiverContactId = receiverId;
if (willFillGoHome) row.GoHomeDate = goHomeDate;
if (willFillDeath)
{ {
row.DateOfDeath = deathDate; var normOwner = NormalizeName(da.Owner);
if (!string.IsNullOrWhiteSpace(da.DeathCause) && row.CauseOfDeath is null) if (contactByNorm.TryGetValue(normOwner, out var existingContactId))
row.CauseOfDeath = da.DeathCause.Trim(); {
receiverId = existingContactId;
ownerLinked++;
}
else
{
ownerCreated++;
if (execute)
{
var newContact = new Contact { Id = Guid.NewGuid(), Name = da.Owner.Trim() };
_db.Contacts.Add(newContact);
await _db.SaveChangesAsync(); // flush within the outer tx
receiverId = newContact.Id;
contactByNorm[normOwner] = receiverId.Value;
}
}
}
// ── PATH 1: previously docx-created animal (idempotent re-run) ──────────
if (docxExternalRefs.TryGetValue(externalRef, out var prevSnap))
{
bool willLink = litterId is not null && prevSnap.LitterId is null;
bool willHome = goHomeDate is not null && prevSnap.GoHomeDate is null;
bool willDeath = deathDate is not null && prevSnap.DateOfDeath is null;
if (willLink) litterLinked++;
if (willHome) goHomeFilled++;
if (willDeath) deathFilled++;
if (execute)
{
var row = await _db.Gerbils.FirstOrDefaultAsync(g => g.Id == prevSnap.Id);
if (row is null) continue;
if (willLink) row.LitterId = litterId;
if (receiverId is not null && row.ReceiverContactId is null) row.ReceiverContactId = receiverId;
if (willHome) row.GoHomeDate = goHomeDate;
if (willDeath)
{
row.DateOfDeath = deathDate;
if (!string.IsNullOrWhiteSpace(da.DeathCause) && row.CauseOfDeath is null)
row.CauseOfDeath = da.DeathCause.Trim();
}
}
continue;
}
// ── PATH 2: main-import animal matched by name+dob ───────────────────────
var nameKey = NameDobKey(da.Name, animalDob.Value);
if (gerbilByKey.TryGetValue(nameKey, out var gerbilCands))
{
var gerbilSnap = gerbilCands.FirstOrDefault(g => g.LitterId == null)
?? gerbilCands.First();
bool willLink = litterId is not null && gerbilSnap.LitterId is null;
bool willHome = goHomeDate is not null && gerbilSnap.GoHomeDate is null;
bool willDeath = deathDate is not null && gerbilSnap.DateOfDeath is null;
if (willLink) litterLinked++;
if (willHome) goHomeFilled++;
if (willDeath) deathFilled++;
if (execute)
{
var row = await _db.Gerbils.FirstOrDefaultAsync(g => g.Id == gerbilSnap.Id);
if (row is null) continue;
if (willLink) row.LitterId = litterId;
if (receiverId is not null && row.ReceiverContactId is null) row.ReceiverContactId = receiverId;
if (willHome) row.GoHomeDate = goHomeDate;
if (willDeath)
{
row.DateOfDeath = deathDate;
if (!string.IsNullOrWhiteSpace(da.DeathCause) && row.CauseOfDeath is null)
row.CauseOfDeath = da.DeathCause.Trim();
}
}
continue;
}
// ── PATH 3: no match → CREATE ────────────────────────────────────────────
// Belt-and-suspenders: ordinal should ensure uniqueness, but guard anyway
if (!batchRefs.Add(externalRef)) { skipped++; continue; }
animalsCreated++;
if (litterId is not null) litterLinked++;
if (goHomeDate is not null) goHomeFilled++;
if (deathDate is not null) deathFilled++;
if (execute)
{
colorVarietyByName.TryGetValue(NormalizeName(da.Farbschlag ?? ""), out var cvId);
_db.Gerbils.Add(new Gerbil
{
Id = Guid.NewGuid(),
Name = da.Name.Trim(),
DateOfBirth = animalDob,
Gender = ParseGender(da.Gender),
Status = deathDate is not null ? GerbilStatus.Deceased : GerbilStatus.GivenAway,
LitterId = litterId,
ReceiverContactId = receiverId,
GoHomeDate = goHomeDate,
DateOfDeath = deathDate,
CauseOfDeath = string.IsNullOrWhiteSpace(da.DeathCause) ? null : da.DeathCause.Trim(),
ColorVarietyId = cvId == default ? null : cvId,
OriginBreeder = "Zucht der Kleinen Chaoten",
IsResident = false,
ImportSource = "docx",
ExternalRef = externalRef,
});
} }
} }
} }
if (execute && (litterLinked + goHomeFilled + deathFilled + ownerLinked + ownerCreated) > 0) if (!execute)
await _db.SaveChangesAsync(); {
// Dry-run: just count, no writes, no transaction needed.
await RunLoopAsync();
}
else
{
// Execute: wrap the entire transaction in the execution strategy so that providers
// with EnableRetryOnFailure (NpgsqlRetryingExecutionStrategy) are compatible.
// The lambda resets all mutable state at the top so retries start clean.
var strategy = _db.Database.CreateExecutionStrategy();
await strategy.ExecuteAsync(async () =>
{
// Reset mutable state — idempotent on strategy retry
_db.ChangeTracker.Clear();
externalRefOrdinals.Clear();
batchRefs.Clear();
animalsCreated = 0; litterLinked = 0; goHomeFilled = 0; deathFilled = 0;
ownerLinked = 0; ownerCreated = 0; skipped = 0;
// Rebuild from DB snapshot: contacts added in a failed attempt were rolled back
contactByNorm = new Dictionary<string, Guid>(contactByNormBase);
await using var tx = await _db.Database.BeginTransactionAsync();
await RunLoopAsync();
if ((animalsCreated + litterLinked + goHomeFilled + deathFilled + ownerCreated) > 0)
await _db.SaveChangesAsync();
await tx.CommitAsync();
});
}
notes.Add($"Quelle: {docxLitters.Count} Würfe, {docxAnimals.Count} Tier-Zeilen aus der docx."); notes.Add($"Quelle: {docxLitters.Count} Würfe, {docxAnimals.Count} Tier-Zeilen aus der docx.");
notes.Add($"Litter-Links: {litterLinked} Tiere einem Wurf zugeordnet (WS-Code → PairingCode)."); notes.Add($"Neu angelegt: {animalsCreated} Jungtiere (abgegeben, nicht in Stammbäumen).");
notes.Add($"Litter-Links: {litterLinked} Tiere einem Wurf zugeordnet (DOB-Match ±5 Tage, eindeutig).");
notes.Add($"Abnehmer: {ownerLinked} bestehende Kontakte verknüpft, {ownerCreated} neue Kontakte angelegt."); notes.Add($"Abnehmer: {ownerLinked} bestehende Kontakte verknüpft, {ownerCreated} neue Kontakte angelegt.");
notes.Add($"GoHomeDate: {goHomeFilled} Abgabe-Daten nachgetragen."); notes.Add($"GoHomeDate: {goHomeFilled} Abgabe-Daten nachgetragen.");
notes.Add($"Tod-Datum: {deathFilled} Todesdaten nachgetragen."); notes.Add($"Tod-Datum: {deathFilled} Todesdaten nachgetragen.");
notes.Add($"Übersprungen: {skipped} Zeilen (kein Name oder kein DB-Match)."); notes.Add($"Übersprungen: {skipped} Zeilen (kein Name oder kein Datum).");
if (!execute) notes.Add("DRY-RUN: nichts gespeichert. /import/docx/execute schreibt die Änderungen."); if (!execute) notes.Add("DRY-RUN: nichts gespeichert. /import/docx/execute schreibt die Änderungen.");
return new ImportDocxReport(execute, litterLinked, ownerLinked + ownerCreated, return new ImportDocxReport(execute, animalsCreated, litterLinked, ownerLinked + ownerCreated,
goHomeFilled, deathFilled, ownerCreated, skipped, notes); goHomeFilled, deathFilled, ownerCreated, skipped, notes);
} }
@@ -215,6 +358,27 @@ namespace GerbilManagerWebAPI.Import
n = System.Text.RegularExpressions.Regex.Replace(n, @"\s+", " ").Trim(); n = System.Text.RegularExpressions.Regex.Replace(n, @"\s+", " ").Trim();
return n; return n;
} }
/// <summary>
/// Base ExternalRef key (before ordinal disambiguation). Two animals sharing the same
/// ws+name+litterDob get this same base; the caller appends -2, -3 … for duplicates.
/// </summary>
internal static string DocxExternalRefBase(DocxAnimal da)
{
var ws = (da.WsCode ?? "").Replace(" ", "").ToLowerInvariant();
var name = System.Text.RegularExpressions.Regex.Replace(
(da.Name ?? "").ToLowerInvariant(), @"[^a-z0-9äöüß]", "");
return $"docx-{ws}-{name}-{da.LitterDob}";
}
private static Gender ParseGender(string? s)
{
if (string.IsNullOrWhiteSpace(s)) return Gender.unknown;
var lower = s.ToLowerInvariant();
if (lower == "male" || lower == "m" || lower.Contains("männ")) return Gender.male;
if (lower == "female" || lower == "f" || lower == "w" || lower.Contains("weibl")) return Gender.female;
return Gender.unknown;
}
} }
// ---- Source shapes (from extract_docx.py output) ---- // ---- Source shapes (from extract_docx.py output) ----
@@ -249,6 +413,7 @@ namespace GerbilManagerWebAPI.Import
public sealed record ImportDocxReport( public sealed record ImportDocxReport(
bool Executed, bool Executed,
int Created,
int LitterLinked, int LitterLinked,
int OwnerLinked, int OwnerLinked,
int GoHomeFilled, int GoHomeFilled,

View File

@@ -289,10 +289,15 @@ namespace GerbilManagerWebAPI.Import
litterParents[lid] = (f, m); litterParents[lid] = (f, m);
} }
// PASS 2: stage synthesized litters (parents already guarded above). DO NOT save them // FIX-IMPORT-CYCLE: track deferred synth litter parent FKs (populated in PASS 2 below).
// before the animals — the offspring AND the parent gerbils are created in the loop // Synth litters are added with null FatherId/MotherId to break the Gerbil↔Litter cycle;
// below, so a single SaveChanges at the end lets EF order parents→litters→offspring // the actual FKs are applied AFTER SaveChanges once all gerbils are persisted.
// (all FKs are nullable). Saving litters first is exactly what caused the FK fault. var synthLitterPendingParents = new Dictionary<Guid, (Guid? Father, Guid? Mother)>();
// PASS 2: stage synthesized litters (parents already guarded above). Litters are added
// with FatherId/MotherId = null (deferred) so that the SaveChanges below has only a
// one-directional Gerbil→Litter dependency — no Litter→Gerbil FKs in the same batch,
// which would cause EF's topo-sort to throw "circular dependency detected".
if (execute) if (execute)
{ {
// reuse an existing litter with the same parents+date instead of duplicating. // reuse an existing litter with the same parents+date instead of duplicating.
@@ -316,13 +321,17 @@ namespace GerbilManagerWebAPI.Import
derivedLitters--; derivedLitters--;
continue; continue;
} }
// Defer FatherId/MotherId: both parent gerbils and offspring gerbils may be [Added]
// in this same batch. Setting them now causes EF circular dependency
// (Gerbil[Added] ← Litter.MotherId [Added] ← Gerbil.LitterId [Added]).
synthLitterPendingParents[sl.Id] = (sl.Father, sl.Mother);
_db.Litters.Add(new Litter _db.Litters.Add(new Litter
{ {
Id = sl.Id, Id = sl.Id,
Name = $"Wurf (aus Diagramm) {sl.Date:yyyy-MM-dd}".Trim(), Name = $"Wurf (aus Diagramm) {sl.Date:yyyy-MM-dd}".Trim(),
Date = sl.Date ?? default, Date = sl.Date ?? default,
FatherId = sl.Father, FatherId = null, // deferred — applied after gerbils SaveChanges
MotherId = sl.Mother, MotherId = null, // deferred — applied after gerbils SaveChanges
Notes = $"aus Stammbaum-Diagramm abgeleitet (Konfidenz: {sl.Confidence})", Notes = $"aus Stammbaum-Diagramm abgeleitet (Konfidenz: {sl.Confidence})",
}); });
} }
@@ -437,6 +446,20 @@ namespace GerbilManagerWebAPI.Import
} }
if (execute) await _db.SaveChangesAsync(); if (execute) await _db.SaveChangesAsync();
// Apply deferred synth litter parent FKs — all new gerbils are now persisted in the DB,
// so no cycle. FK guard already applied above (persisted set); values in the dict are safe.
if (execute && synthLitterPendingParents.Count > 0)
{
foreach (var (litId, (f, m)) in synthLitterPendingParents)
{
var row = await _db.Litters.FindAsync(litId);
if (row is null) continue;
if (f is not null) row.FatherId = f;
if (m is not null) row.MotherId = m;
}
await _db.SaveChangesAsync();
}
// ---- back-link Wurfchronik litter parents by name (best effort) ---- // ---- back-link Wurfchronik litter parents by name (best effort) ----
if (execute) if (execute)
{ {

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,598 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional
namespace GerbilManagerWebAPI.Migrations
{
/// <inheritdoc />
public partial class ReseedColorVarietiesAR5 : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000016"),
column: "Name",
value: "Dilute Gold");
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000017"),
column: "Name",
value: "Dilute Platin");
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000029"),
column: "Name",
value: "Dilute Agouti");
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000030"),
column: "Name",
value: "Dilute Silberagouti");
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000031"),
column: "Name",
value: "Dilute Kohlfuchs");
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000032"),
column: "Name",
value: "Dilute Anthrazit");
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000033"),
column: "SortOrder",
value: 36);
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000034"),
column: "SortOrder",
value: 37);
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000035"),
column: "SortOrder",
value: 38);
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000036"),
column: "SortOrder",
value: 39);
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000037"),
column: "SortOrder",
value: 40);
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000038"),
column: "SortOrder",
value: 41);
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000039"),
column: "SortOrder",
value: 42);
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000040"),
column: "SortOrder",
value: 43);
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000041"),
column: "SortOrder",
value: 44);
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000042"),
column: "SortOrder",
value: 45);
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000043"),
column: "SortOrder",
value: 46);
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000044"),
column: "SortOrder",
value: 47);
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000045"),
column: "SortOrder",
value: 48);
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000046"),
column: "SortOrder",
value: 49);
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000047"),
column: "SortOrder",
value: 50);
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000048"),
column: "SortOrder",
value: 51);
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000049"),
columns: new[] { "Name", "SortOrder" },
values: new object[] { "Dilute Topas", 52 });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000050"),
columns: new[] { "Name", "SortOrder" },
values: new object[] { "Dilute Blaufuchs", 53 });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000051"),
column: "SortOrder",
value: 54);
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000052"),
column: "SortOrder",
value: 55);
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000053"),
column: "SortOrder",
value: 56);
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000054"),
column: "SortOrder",
value: 57);
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000055"),
column: "SortOrder",
value: 59);
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000056"),
column: "SortOrder",
value: 61);
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000057"),
column: "SortOrder",
value: 63);
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000058"),
column: "SortOrder",
value: 65);
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000059"),
column: "SortOrder",
value: 66);
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000060"),
column: "SortOrder",
value: 67);
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000061"),
column: "SortOrder",
value: 68);
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000062"),
column: "SortOrder",
value: 58);
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000063"),
column: "SortOrder",
value: 60);
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000064"),
column: "SortOrder",
value: 62);
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000066"),
column: "SortOrder",
value: 69);
migrationBuilder.InsertData(
table: "ColorVarieties",
columns: new[] { "Id", "CanonicalGenotype", "Name", "SortOrder" },
values: new object[,]
{
{ new Guid("00000000-0000-0000-0000-000000000067"), "AA CC dd ee GG PP spsp rere", "Dilute Algierfuchs", 32 },
{ new Guid("00000000-0000-0000-0000-000000000068"), "AA CC dd ee GG pp spsp rere", "Dilute Goldfuchs", 33 },
{ new Guid("00000000-0000-0000-0000-000000000069"), "aa CC dd ee GG pp spsp rere", "Dilute Rotfuchs", 34 },
{ new Guid("00000000-0000-0000-0000-000000000070"), "AA CC dd ee gg PP spsp rere", "Dilute Polarfuchs", 35 }
});
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DeleteData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000067"));
migrationBuilder.DeleteData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000068"));
migrationBuilder.DeleteData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000069"));
migrationBuilder.DeleteData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000070"));
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000016"),
column: "Name",
value: "dd Gold");
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000017"),
column: "Name",
value: "dd Platin");
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000029"),
column: "Name",
value: "Agouti dd");
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000030"),
column: "Name",
value: "Silberagouti dd");
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000031"),
column: "Name",
value: "Kohlfuchs dd");
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000032"),
column: "Name",
value: "Anthrazit dd");
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000033"),
column: "SortOrder",
value: 32);
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000034"),
column: "SortOrder",
value: 33);
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000035"),
column: "SortOrder",
value: 34);
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000036"),
column: "SortOrder",
value: 35);
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000037"),
column: "SortOrder",
value: 36);
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000038"),
column: "SortOrder",
value: 37);
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000039"),
column: "SortOrder",
value: 38);
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000040"),
column: "SortOrder",
value: 39);
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000041"),
column: "SortOrder",
value: 40);
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000042"),
column: "SortOrder",
value: 41);
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000043"),
column: "SortOrder",
value: 42);
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000044"),
column: "SortOrder",
value: 43);
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000045"),
column: "SortOrder",
value: 44);
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000046"),
column: "SortOrder",
value: 45);
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000047"),
column: "SortOrder",
value: 46);
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000048"),
column: "SortOrder",
value: 47);
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000049"),
columns: new[] { "Name", "SortOrder" },
values: new object[] { "Topas dd", 48 });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000050"),
columns: new[] { "Name", "SortOrder" },
values: new object[] { "Blaufuchs dd", 49 });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000051"),
column: "SortOrder",
value: 50);
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000052"),
column: "SortOrder",
value: 51);
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000053"),
column: "SortOrder",
value: 52);
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000054"),
column: "SortOrder",
value: 53);
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000055"),
column: "SortOrder",
value: 54);
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000056"),
column: "SortOrder",
value: 55);
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000057"),
column: "SortOrder",
value: 56);
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000058"),
column: "SortOrder",
value: 57);
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000059"),
column: "SortOrder",
value: 58);
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000060"),
column: "SortOrder",
value: 59);
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000061"),
column: "SortOrder",
value: 60);
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000062"),
column: "SortOrder",
value: 61);
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000063"),
column: "SortOrder",
value: 62);
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000064"),
column: "SortOrder",
value: 63);
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000066"),
column: "SortOrder",
value: 65);
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,33 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace GerbilManagerWebAPI.Migrations
{
/// <inheritdoc />
public partial class RenameREW : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000001"),
column: "Name",
value: "REW");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000001"),
column: "Name",
value: "Pink Eyed White (PEW)");
}
}
}

View File

@@ -216,7 +216,7 @@ namespace GerbilManagerWebAPI.Migrations
{ {
Id = new Guid("00000000-0000-0000-0000-000000000001"), Id = new Guid("00000000-0000-0000-0000-000000000001"),
CanonicalGenotype = "AA chch DD EE GG pp spsp rere", CanonicalGenotype = "AA chch DD EE GG pp spsp rere",
Name = "Pink Eyed White (PEW)", Name = "REW",
SortOrder = 0 SortOrder = 0
}, },
new new
@@ -321,14 +321,14 @@ namespace GerbilManagerWebAPI.Migrations
{ {
Id = new Guid("00000000-0000-0000-0000-000000000016"), Id = new Guid("00000000-0000-0000-0000-000000000016"),
CanonicalGenotype = "AA CC dd EE GG pp spsp rere", CanonicalGenotype = "AA CC dd EE GG pp spsp rere",
Name = "dd Gold", Name = "Dilute Gold",
SortOrder = 15 SortOrder = 15
}, },
new new
{ {
Id = new Guid("00000000-0000-0000-0000-000000000017"), Id = new Guid("00000000-0000-0000-0000-000000000017"),
CanonicalGenotype = "aa CC dd EE GG pp spsp rere", CanonicalGenotype = "aa CC dd EE GG pp spsp rere",
Name = "dd Platin", Name = "Dilute Platin",
SortOrder = 16 SortOrder = 16
}, },
new new
@@ -412,28 +412,28 @@ namespace GerbilManagerWebAPI.Migrations
{ {
Id = new Guid("00000000-0000-0000-0000-000000000029"), Id = new Guid("00000000-0000-0000-0000-000000000029"),
CanonicalGenotype = "AA CC dd EE GG PP spsp rere", CanonicalGenotype = "AA CC dd EE GG PP spsp rere",
Name = "Agouti dd", Name = "Dilute Agouti",
SortOrder = 28 SortOrder = 28
}, },
new new
{ {
Id = new Guid("00000000-0000-0000-0000-000000000030"), Id = new Guid("00000000-0000-0000-0000-000000000030"),
CanonicalGenotype = "AA CC dd EE gg PP spsp rere", CanonicalGenotype = "AA CC dd EE gg PP spsp rere",
Name = "Silberagouti dd", Name = "Dilute Silberagouti",
SortOrder = 29 SortOrder = 29
}, },
new new
{ {
Id = new Guid("00000000-0000-0000-0000-000000000031"), Id = new Guid("00000000-0000-0000-0000-000000000031"),
CanonicalGenotype = "aa CC dd ee GG PP spsp rere", CanonicalGenotype = "aa CC dd ee GG PP spsp rere",
Name = "Kohlfuchs dd", Name = "Dilute Kohlfuchs",
SortOrder = 30 SortOrder = 30
}, },
new new
{ {
Id = new Guid("00000000-0000-0000-0000-000000000032"), Id = new Guid("00000000-0000-0000-0000-000000000032"),
CanonicalGenotype = "aa CC dd EE gg PP spsp rere", CanonicalGenotype = "aa CC dd EE gg PP spsp rere",
Name = "Anthrazit dd", Name = "Dilute Anthrazit",
SortOrder = 31 SortOrder = 31
}, },
new new
@@ -441,224 +441,224 @@ namespace GerbilManagerWebAPI.Migrations
Id = new Guid("00000000-0000-0000-0000-000000000033"), Id = new Guid("00000000-0000-0000-0000-000000000033"),
CanonicalGenotype = "AA CC DD efef gg PP spsp rere", CanonicalGenotype = "AA CC DD efef gg PP spsp rere",
Name = "Silberschimmel", Name = "Silberschimmel",
SortOrder = 32 SortOrder = 36
}, },
new new
{ {
Id = new Guid("00000000-0000-0000-0000-000000000034"), Id = new Guid("00000000-0000-0000-0000-000000000034"),
CanonicalGenotype = "AA CC DD efef gg PP spsp rere", CanonicalGenotype = "AA CC DD efef gg PP spsp rere",
Name = "Polarfuchsschimmel", Name = "Polarfuchsschimmel",
SortOrder = 33 SortOrder = 37
}, },
new new
{ {
Id = new Guid("00000000-0000-0000-0000-000000000035"), Id = new Guid("00000000-0000-0000-0000-000000000035"),
CanonicalGenotype = "AA CC DD efef GG PP spsp rere", CanonicalGenotype = "AA CC DD efef GG PP spsp rere",
Name = "Algierfuchsschimmel", Name = "Algierfuchsschimmel",
SortOrder = 34 SortOrder = 38
}, },
new new
{ {
Id = new Guid("00000000-0000-0000-0000-000000000036"), Id = new Guid("00000000-0000-0000-0000-000000000036"),
CanonicalGenotype = "aa CC DD efef GG PP spsp rere", CanonicalGenotype = "aa CC DD efef GG PP spsp rere",
Name = "Kohlfuchsschimmel", Name = "Kohlfuchsschimmel",
SortOrder = 35 SortOrder = 39
}, },
new new
{ {
Id = new Guid("00000000-0000-0000-0000-000000000037"), Id = new Guid("00000000-0000-0000-0000-000000000037"),
CanonicalGenotype = "aa CC DD efef gg PP spsp rere", CanonicalGenotype = "aa CC DD efef gg PP spsp rere",
Name = "Blaufuchsschimmel", Name = "Blaufuchsschimmel",
SortOrder = 36 SortOrder = 40
}, },
new new
{ {
Id = new Guid("00000000-0000-0000-0000-000000000038"), Id = new Guid("00000000-0000-0000-0000-000000000038"),
CanonicalGenotype = "aa CC DD ee GG PP spsp rere", CanonicalGenotype = "aa CC DD ee GG PP spsp rere",
Name = "Kohlfuchs, hell", Name = "Kohlfuchs, hell",
SortOrder = 37 SortOrder = 41
}, },
new new
{ {
Id = new Guid("00000000-0000-0000-0000-000000000039"), Id = new Guid("00000000-0000-0000-0000-000000000039"),
CanonicalGenotype = "AA CC DD ee GG pp spsp rere", CanonicalGenotype = "AA CC DD ee GG pp spsp rere",
Name = "Goldfuchs, hell", Name = "Goldfuchs, hell",
SortOrder = 38 SortOrder = 42
}, },
new new
{ {
Id = new Guid("00000000-0000-0000-0000-000000000040"), Id = new Guid("00000000-0000-0000-0000-000000000040"),
CanonicalGenotype = "AA CC DD efef GG pp spsp rere", CanonicalGenotype = "AA CC DD efef GG pp spsp rere",
Name = "Goldfuchsschimmel", Name = "Goldfuchsschimmel",
SortOrder = 39 SortOrder = 43
}, },
new new
{ {
Id = new Guid("00000000-0000-0000-0000-000000000041"), Id = new Guid("00000000-0000-0000-0000-000000000041"),
CanonicalGenotype = "AA CC DD EE GG pp spsp rere", CanonicalGenotype = "AA CC DD EE GG pp spsp rere",
Name = "Gold-Hell", Name = "Gold-Hell",
SortOrder = 40 SortOrder = 44
}, },
new new
{ {
Id = new Guid("00000000-0000-0000-0000-000000000042"), Id = new Guid("00000000-0000-0000-0000-000000000042"),
CanonicalGenotype = "aa CC DD ee gg PP spsp rere", CanonicalGenotype = "aa CC DD ee gg PP spsp rere",
Name = "Blaufuchs, hell", Name = "Blaufuchs, hell",
SortOrder = 41 SortOrder = 45
}, },
new new
{ {
Id = new Guid("00000000-0000-0000-0000-000000000043"), Id = new Guid("00000000-0000-0000-0000-000000000043"),
CanonicalGenotype = "aa CC DD efef GG pp spsp rere", CanonicalGenotype = "aa CC DD efef GG pp spsp rere",
Name = "Rotfuchsschimmel", Name = "Rotfuchsschimmel",
SortOrder = 42 SortOrder = 46
}, },
new new
{ {
Id = new Guid("00000000-0000-0000-0000-000000000044"), Id = new Guid("00000000-0000-0000-0000-000000000044"),
CanonicalGenotype = "AA CC DD ee gg PP spsp rere", CanonicalGenotype = "AA CC DD ee gg PP spsp rere",
Name = "Polarfuchs, hell", Name = "Polarfuchs, hell",
SortOrder = 43 SortOrder = 47
}, },
new new
{ {
Id = new Guid("00000000-0000-0000-0000-000000000045"), Id = new Guid("00000000-0000-0000-0000-000000000045"),
CanonicalGenotype = "aa CC DD efef GG PP spsp rere", CanonicalGenotype = "aa CC DD efef GG PP spsp rere",
Name = "Kohlfuchsschimmel, hell", Name = "Kohlfuchsschimmel, hell",
SortOrder = 44 SortOrder = 48
}, },
new new
{ {
Id = new Guid("00000000-0000-0000-0000-000000000046"), Id = new Guid("00000000-0000-0000-0000-000000000046"),
CanonicalGenotype = "aa CC DD ee GG pp spsp rere", CanonicalGenotype = "aa CC DD ee GG pp spsp rere",
Name = "Rotfuchs, hell", Name = "Rotfuchs, hell",
SortOrder = 45 SortOrder = 49
}, },
new new
{ {
Id = new Guid("00000000-0000-0000-0000-000000000047"), Id = new Guid("00000000-0000-0000-0000-000000000047"),
CanonicalGenotype = "aa CC DD ee GG PP spsp rere", CanonicalGenotype = "aa CC DD ee GG PP spsp rere",
Name = "Kohlfuchs-Hell", Name = "Kohlfuchs-Hell",
SortOrder = 46 SortOrder = 50
}, },
new new
{ {
Id = new Guid("00000000-0000-0000-0000-000000000048"), Id = new Guid("00000000-0000-0000-0000-000000000048"),
CanonicalGenotype = "AA CC DD ee GG PP spsp rere", CanonicalGenotype = "AA CC DD ee GG PP spsp rere",
Name = "Algierfuchs, hell", Name = "Algierfuchs, hell",
SortOrder = 47 SortOrder = 51
}, },
new new
{ {
Id = new Guid("00000000-0000-0000-0000-000000000049"), Id = new Guid("00000000-0000-0000-0000-000000000049"),
CanonicalGenotype = "AA CC dd EE GG pp spsp rere", CanonicalGenotype = "AA CC dd EE GG pp spsp rere",
Name = "Topas dd", Name = "Dilute Topas",
SortOrder = 48 SortOrder = 52
}, },
new new
{ {
Id = new Guid("00000000-0000-0000-0000-000000000050"), 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 = "Blaufuchs dd", Name = "Dilute Blaufuchs",
SortOrder = 49 SortOrder = 53
}, },
new new
{ {
Id = new Guid("00000000-0000-0000-0000-000000000051"), Id = new Guid("00000000-0000-0000-0000-000000000051"),
CanonicalGenotype = "aa cchmcchm DD EE GG PP spsp rere", CanonicalGenotype = "aa cchmcchm DD EE GG PP spsp rere",
Name = "Marder", Name = "Marder",
SortOrder = 50 SortOrder = 54
}, },
new new
{ {
Id = new Guid("00000000-0000-0000-0000-000000000052"), Id = new Guid("00000000-0000-0000-0000-000000000052"),
CanonicalGenotype = "aa cchmch DD EE GG PP spsp rere", CanonicalGenotype = "aa cchmch DD EE GG PP spsp rere",
Name = "Siam", Name = "Siam",
SortOrder = 51 SortOrder = 55
}, },
new new
{ {
Id = new Guid("00000000-0000-0000-0000-000000000053"), Id = new Guid("00000000-0000-0000-0000-000000000053"),
CanonicalGenotype = "aa cchmch DD EE gg PP spsp rere", CanonicalGenotype = "aa cchmch DD EE gg PP spsp rere",
Name = "Zobel-Hell", Name = "Zobel-Hell",
SortOrder = 52 SortOrder = 56
}, },
new new
{ {
Id = new Guid("00000000-0000-0000-0000-000000000054"), Id = new Guid("00000000-0000-0000-0000-000000000054"),
CanonicalGenotype = "AA cchmcchm DD EE GG PP spsp rere", CanonicalGenotype = "AA cchmcchm DD EE GG PP spsp rere",
Name = "CP-Agouti", Name = "CP-Agouti",
SortOrder = 53 SortOrder = 57
}, },
new new
{ {
Id = new Guid("00000000-0000-0000-0000-000000000055"), Id = new Guid("00000000-0000-0000-0000-000000000055"),
CanonicalGenotype = "AA cchmcchm DD EE gg PP spsp rere", CanonicalGenotype = "AA cchmcchm DD EE gg PP spsp rere",
Name = "CP-Silberagouti", Name = "CP-Silberagouti",
SortOrder = 54 SortOrder = 59
}, },
new new
{ {
Id = new Guid("00000000-0000-0000-0000-000000000056"), Id = new Guid("00000000-0000-0000-0000-000000000056"),
CanonicalGenotype = "AA cchmcchm DD ee GG PP spsp rere", CanonicalGenotype = "AA cchmcchm DD ee GG PP spsp rere",
Name = "CP-Algierfuchs", Name = "CP-Algierfuchs",
SortOrder = 55 SortOrder = 61
}, },
new new
{ {
Id = new Guid("00000000-0000-0000-0000-000000000057"), Id = new Guid("00000000-0000-0000-0000-000000000057"),
CanonicalGenotype = "AA cchmcchm DD ee gg PP spsp rere", CanonicalGenotype = "AA cchmcchm DD ee gg PP spsp rere",
Name = "CP-Polarfuchs", Name = "CP-Polarfuchs",
SortOrder = 56 SortOrder = 63
}, },
new new
{ {
Id = new Guid("00000000-0000-0000-0000-000000000058"), Id = new Guid("00000000-0000-0000-0000-000000000058"),
CanonicalGenotype = "AA cchmcchm dd ee GG PP spsp rere", CanonicalGenotype = "AA cchmcchm dd ee GG PP spsp rere",
Name = "CP-Fuchs", Name = "CP-Fuchs",
SortOrder = 57 SortOrder = 65
}, },
new new
{ {
Id = new Guid("00000000-0000-0000-0000-000000000059"), Id = new Guid("00000000-0000-0000-0000-000000000059"),
CanonicalGenotype = "AA cchmch dd ee GG PP spsp rere", CanonicalGenotype = "AA cchmch dd ee GG PP spsp rere",
Name = "CP-Fuchs-Hell", Name = "CP-Fuchs-Hell",
SortOrder = 58 SortOrder = 66
}, },
new new
{ {
Id = new Guid("00000000-0000-0000-0000-000000000060"), Id = new Guid("00000000-0000-0000-0000-000000000060"),
CanonicalGenotype = "AA cchmcchm dd ee gg PP spsp rere", CanonicalGenotype = "AA cchmcchm dd ee gg PP spsp rere",
Name = "CP-Blaufuchs", Name = "CP-Blaufuchs",
SortOrder = 59 SortOrder = 67
}, },
new new
{ {
Id = new Guid("00000000-0000-0000-0000-000000000061"), Id = new Guid("00000000-0000-0000-0000-000000000061"),
CanonicalGenotype = "AA cchmcchm DD efef GG PP spsp rere", CanonicalGenotype = "AA cchmcchm DD efef GG PP spsp rere",
Name = "CP-Orangeschimmel", Name = "CP-Orangeschimmel",
SortOrder = 60 SortOrder = 68
}, },
new new
{ {
Id = new Guid("00000000-0000-0000-0000-000000000062"), Id = new Guid("00000000-0000-0000-0000-000000000062"),
CanonicalGenotype = "AA cchmch DD EE GG PP spsp rere", CanonicalGenotype = "AA cchmch DD EE GG PP spsp rere",
Name = "CP-Agouti-Hell", Name = "CP-Agouti-Hell",
SortOrder = 61 SortOrder = 58
}, },
new new
{ {
Id = new Guid("00000000-0000-0000-0000-000000000063"), Id = new Guid("00000000-0000-0000-0000-000000000063"),
CanonicalGenotype = "AA cchmch DD EE gg PP spsp rere", CanonicalGenotype = "AA cchmch DD EE gg PP spsp rere",
Name = "CP-Silberagouti-Hell", Name = "CP-Silberagouti-Hell",
SortOrder = 62 SortOrder = 60
}, },
new new
{ {
Id = new Guid("00000000-0000-0000-0000-000000000064"), Id = new Guid("00000000-0000-0000-0000-000000000064"),
CanonicalGenotype = "AA cchmch DD ee GG PP spsp rere", CanonicalGenotype = "AA cchmch DD ee GG PP spsp rere",
Name = "CP-Algierfuchs-Hell", Name = "CP-Algierfuchs-Hell",
SortOrder = 63 SortOrder = 62
}, },
new new
{ {
@@ -672,7 +672,35 @@ namespace GerbilManagerWebAPI.Migrations
Id = new Guid("00000000-0000-0000-0000-000000000066"), Id = new Guid("00000000-0000-0000-0000-000000000066"),
CanonicalGenotype = "AA cchmch DD efef GG PP spsp rere", CanonicalGenotype = "AA cchmch DD efef GG PP spsp rere",
Name = "CP-Orangeschimmel-Hell", Name = "CP-Orangeschimmel-Hell",
SortOrder = 65 SortOrder = 69
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000067"),
CanonicalGenotype = "AA CC dd ee GG PP spsp rere",
Name = "Dilute Algierfuchs",
SortOrder = 32
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000068"),
CanonicalGenotype = "AA CC dd ee GG pp spsp rere",
Name = "Dilute Goldfuchs",
SortOrder = 33
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000069"),
CanonicalGenotype = "aa CC dd ee GG pp spsp rere",
Name = "Dilute Rotfuchs",
SortOrder = 34
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000070"),
CanonicalGenotype = "AA CC dd ee gg PP spsp rere",
Name = "Dilute Polarfuchs",
SortOrder = 35
}); });
}); });

View File

@@ -20,6 +20,23 @@ namespace GerbilManagerWebAPI.Names
PropertyNameCaseInsensitive = true, PropertyNameCaseInsensitive = true,
}; };
// Known usage codes → (German description, isThematic).
// Thematic categories provide real source names (not etymology); unknown codes fall through as etym.
internal static readonly Dictionary<string, (string Description, bool Thematic)> UsageMap =
new(StringComparer.OrdinalIgnoreCase)
{
["norn"] = ("Nordische/Altnordische Etymologie", false),
["japa"] = ("Japanische Etymologie", false),
["mythg"] = ("Griechische Mythologie", false),
["ger"] = ("Germanische/Deutsche Etymologie", false),
["arb"] = ("Arabische Etymologie", false),
["disney"] = ("Disney-Charaktere", true),
["pokemon"] = ("Pokémon-Namen", true),
["encities"] = ("Namen englischer Städte", true),
["hrcities"] = ("Namen kroatischer Städte", true),
["usstates"] = ("Namen von US-Bundesstaaten", true),
};
public async Task<NameSuggestionResult> SuggestAsync( public async Task<NameSuggestionResult> SuggestAsync(
string? letter, string? gender, string? usages, int count, string? letter, string? gender, string? usages, int count,
CancellationToken ct = default) CancellationToken ct = default)
@@ -44,7 +61,11 @@ namespace GerbilManagerWebAPI.Names
"Du bist ein Helfer für Rennmaus-Züchter. " + "Du bist ein Helfer für Rennmaus-Züchter. " +
"Antworte IMMER mit einem reinen JSON-Array — KEINE Markdown-Code-Blöcke, " + "Antworte IMMER mit einem reinen JSON-Array — KEINE Markdown-Code-Blöcke, " +
"KEINE Erklärungen, KEIN Text außerhalb des Arrays. " + "KEINE Erklärungen, KEIN Text außerhalb des Arrays. " +
"Jedes Element hat genau die Felder: name, meaning, origin (alle Strings, alle auf Deutsch)."; "Jedes Element hat genau die Felder: name, meaning, origin (alle Strings, alle auf Deutsch). " +
"Bei thematischen Kategorien (Disney, Pokémon, Städte, Bundesstaaten): " +
"origin = Kategoriename (z.B. \"Disney\", \"Pokémon\", \"Englische Stadt\", \"Kroatische Stadt\", \"US-Bundesstaat\"), " +
"meaning = kurzer Kontext aus der Quelle (z.B. Film/Figur-Beschreibung, Lage der Stadt, Bundesstaat-Bezug). " +
"Erfinde KEINE Etymologie für thematische Kategorien.";
internal static string BuildUserPrompt(string? letter, string? gender, string? usages, int count) internal static string BuildUserPrompt(string? letter, string? gender, string? usages, int count)
{ {
@@ -55,10 +76,44 @@ namespace GerbilManagerWebAPI.Names
if (!string.IsNullOrWhiteSpace(gender) && if (!string.IsNullOrWhiteSpace(gender) &&
!gender.Equals("any", StringComparison.OrdinalIgnoreCase)) !gender.Equals("any", StringComparison.OrdinalIgnoreCase))
sb.Append($", passend für {(gender.Equals("female", StringComparison.OrdinalIgnoreCase) ? "weibliche" : "männliche")} Tiere"); sb.Append($", passend für {(gender.Equals("female", StringComparison.OrdinalIgnoreCase) ? "weibliche" : "männliche")} Tiere");
bool hasThematic = false;
if (!string.IsNullOrWhiteSpace(usages)) if (!string.IsNullOrWhiteSpace(usages))
sb.Append($", aus den Kulturkreisen: {usages}"); {
sb.Append(". Jeder Name muss eine echte etymologische Bedeutung und Herkunft haben "); var codes = usages.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
sb.Append("(keine erfundenen oder zufälligen Namen). "); var etymDescriptions = new List<string>();
var thematicDescriptions = new List<string>();
foreach (var code in codes)
{
if (UsageMap.TryGetValue(code, out var entry))
{
if (entry.Thematic) thematicDescriptions.Add(entry.Description);
else etymDescriptions.Add(entry.Description);
}
else
{
etymDescriptions.Add(code);
}
}
if (etymDescriptions.Count > 0)
sb.Append($", aus den Kulturkreisen: {string.Join(", ", etymDescriptions)}");
if (thematicDescriptions.Count > 0)
sb.Append($", aus den Themen-Kategorien: {string.Join(", ", thematicDescriptions)}");
hasThematic = thematicDescriptions.Count > 0;
}
sb.Append(". ");
if (hasThematic)
{
sb.Append("Für Kulturkreis-Namen: echte etymologische Bedeutung und Herkunft. ");
sb.Append("Für Themen-Kategorien: echte Namen aus der Quelle, origin = Kategoriename, ");
sb.Append("Geschlecht-Filter kann ignoriert werden wenn nicht sinnvoll anwendbar. ");
}
else
{
sb.Append("Jeder Name muss eine echte etymologische Bedeutung und Herkunft haben ");
sb.Append("(keine erfundenen oder zufälligen Namen). ");
}
sb.Append($"Antworte mit genau {count} Elementen als reines JSON-Array: "); sb.Append($"Antworte mit genau {count} Elementen als reines JSON-Array: ");
sb.Append("[{\"name\":\"...\",\"meaning\":\"...\",\"origin\":\"...\"}]"); sb.Append("[{\"name\":\"...\",\"meaning\":\"...\",\"origin\":\"...\"}]");
return sb.ToString(); return sb.ToString();

View File

@@ -48,6 +48,9 @@ builder.Services.AddHttpClient<GerbilManagerWebAPI.Inbox.DraftReplyService>(
// FEAT-NAMEGEN: Name suggestions via Gemini (same AI section, same wire client). // FEAT-NAMEGEN: Name suggestions via Gemini (same AI section, same wire client).
builder.Services.AddHttpClient<GerbilManagerWebAPI.Names.NameSuggestionService>( builder.Services.AddHttpClient<GerbilManagerWebAPI.Names.NameSuggestionService>(
http => http.Timeout = TimeSpan.FromSeconds(60)); http => http.Timeout = TimeSpan.FromSeconds(60));
// WEB-2: public site publish path (env var PublicSite__RootPath; empty = disabled)
builder.Services.AddOptions<GerbilManagerWebAPI.Cms.PublicSiteOptions>()
.BindConfiguration(GerbilManagerWebAPI.Cms.PublicSiteOptions.SectionName);
// INBOX-0: Gmail inbox. App Password encrypted at rest via Data Protection. // INBOX-0: Gmail inbox. App Password encrypted at rest via Data Protection.
// AR-3: persist the key ring so encrypted passwords survive image redeployments. // AR-3: persist the key ring so encrypted passwords survive image redeployments.

View File

@@ -19,63 +19,61 @@ Feinschliff** — siehe unten._
| # | Was gebraucht wird | Schaltet frei | | # | Was gebraucht wird | Schaltet frei |
|---|---|---| |---|---|---|
| **A3** | **Gmail App-Passwort.** Google-Konto → 2-Faktor aktivieren → „App-Passwörter" → eines für „GerbilManager" → 16-stelligen Code an Michael. | E-Mail-Posteingang (Anfragen abrufen + KI-Antwortentwürfe). Backend ist fertig, wartet nur auf den Zugang. | | **A3** | **Gmail App-Passwort.** Google-Konto → 2-Faktor aktivieren → „App-Passwörter" → eines für „GerbilManager" → 16-stelligen Code an Michael. | E-Mail-Posteingang (Anfragen abrufen + KI-Antwortentwürfe). Backend ist fertig, wartet nur auf den Zugang. |
| **A4** | **Domain-Name** (registriert) + **Cloudflare-Konto & API-Token** (Berechtigung „Cloudflare Pages → Edit"). | Öffentliche Webseite veröffentlichen (Jimdo-Ersatz). Seite ist gebaut. | | **A4** (umgestellt 2026-06-07) | **Self-Host statt Cloudflare:** Strato-Domain → DynDNS → deine IP (auto-aktualisiert) + **nginx-Reverse-Proxy auf TrueNAS** leitet auf die statische Seite. Cloudflare NICHT mehr nötig. **Offen:** welcher Hostname/Subdomain für die öffentliche Seite? Bau = WEB-2 (Dwight, self-host-Variante). | Öffentliche Webseite (Jimdo-Ersatz), self-hosted auf dem NAS. |
| **A5** | **TrueNAS-Restfragen:** (a) SCALE-Version? · (c) eigener Postgres-Container (empfohlen) oder bestehender NAS-Postgres? · (d) Dataset-Pfad für Daten/Backups, Port 80 frei? | Produktiv-Betrieb auf dem NAS (compose ist fertig vorbereitet). | | ~~**A5**~~ | **TrueNAS beantwortet (2026-06-07):** (a) SCALE **25.10.2.1 Goldeye** → Custom App (Docker compose) ✓ · (c) **eigener Postgres-Container** (legen wir an, compose macht das) ✓ · (d) Daten/Backups unter **`/mnt/JailStorage/DockerVolumes/`** (neuer Ordner) ✓. **Rest-Detail:** Port **80** frei? (sonst `PORT` in .env ändern). → OPS-2 (Dwight) finalisiert compose/.env/ops.md auf diese Werte. | Produktiv-Betrieb auf dem NAS. |
| **A5b2** | **2 Gitea-Repo-Secrets** anlegen (Repo Einstellungen → Actions → Secrets): `REGISTRY_USER` (dein Gitea-Login) + `REGISTRY_TOKEN` (Token mit `write:package`). | CI pusht fertige Docker-Images in die Registry. (Die CI-Tests laufen bereits grün.) | | **A5b2** | **2 Gitea-Repo-Secrets** anlegen (Repo `Gulum/GerbilManager`**Einstellungen → Actions → Secrets → Secret hinzufügen**): `REGISTRY_USER` = `gulum` · `REGISTRY_TOKEN` = Gitea-Zugriffstoken mit Scope **`write:package`** (erzeugen unter **Benutzer-Einstellungen → Anwendungen → Zugriffstoken verwalten**, Token wird nur einmal angezeigt → in das Secret kopieren). Danach Action erneut laufen lassen. Registry ist jetzt **`git.rismer.de`** (externes HTTPS) → keine `insecure-registry`-Daemon-Konfig nötig. | CI pusht fertige Docker-Images in die Registry. (Tests laufen grün; aktuell rot ist NUR der Login-Schritt: `secrets.REGISTRY_USER`/`REGISTRY_TOKEN` sind leer → „Username and password required".) |
## B. Kleine Aktion (jederzeit) ## B. Kleine Aktion (jederzeit)
| # | Aktion | Warum | | # | Aktion | Warum |
|---|---|---| |---|---|---|
| **B2** | **Firewall-Regel** (PowerShell **als Administrator**): `New-NetFirewallRule -DisplayName "GerbilManager dev" -Direction Inbound -Action Allow -Protocol TCP -LocalPort 5173,5179 -Profile Any` | Damit das Handy deiner Frau im WLAN auf die App kommt (http://192.168.2.124:5173). | | ~~**B2**~~ | **Firewall-Regel erledigt** (2026-06-07) — Ports 5173/5179 inbound offen. Handy im WLAN kommt auf http://192.168.2.124:5173 (sobald App läuft). | — |
## C. Genetik-Feinschliff (optional, blockiert nichts) ## C. Genetik-Feinschliff (optional, blockiert nichts)
| # | Frage | Betrifft | | # | Frage | Betrifft |
|---|---|---| |---|---|---|
| **REW-1** | **PEW = REW?** „Pink Eyed White" (PEW) und „Rotaugenweiß" (REW) sind dasselbe — soll der alte PEW-Eintrag mit REW **zusammengeführt** werden? | Doppel-Eintrag im Farbkatalog. | | ~~**REW-1**~~ | **JA, PEW = REW (dasselbe)** PEW-Eintrag mit REW zusammenführen (GEN-4c, Kevin). | Doppel-Eintrag im Farbkatalog. |
| **REW-2** | **REW bei nicht-agouti?** Wird ein `aa`-Tier (z. B. Marder) mit Colourpoint + `pp` **auch** als REW gewertet, oder soll REW nur für agouti-basierte Tiere gelten? | REW-Erkennung (wird gerade A-unabhängig gebaut = aa zählt mit). | | ~~**REW-2**~~ ✅ | **A-unabhängig:** egal ob `AA`/`Aa` (groß) oder `aa` (klein) — wenn die REW-Bedingung erfüllt ist, ist es **immer REW**. Schon so implementiert (GEN-4, A/D/E/G-unabhängig). | REW-Erkennung. |
| **C7** | *(optional)* Was hat deiner Frau bei **Renner Pro** gefehlt? Lieblings-Auswertungen? | mögliche neue Funktionen | | **C7** | *(optional)* Was hat deiner Frau bei **Renner Pro** gefehlt? Lieblings-Auswertungen? | mögliche neue Funktionen |
## E. Charakterbogen-Eigenschaften (optional) ## E. Charakterbogen-Eigenschaften — ✅ ERLEDIGT (CHARAKTERBOGEN-2)
Aktuell eingebaute Häkchen-Eigenschaften (für die KI-Verkaufstexte) — **soll etwas ergänzt/gestrichen werden?** **E1 ✅** — Deine Frau hat die Eigenschaften geliefert, Kelly hat sie eingebaut: **4 Kategorien** (Sozialverhalten · Eignung & Umgang · Hobbys & Eigenarten · Wesen & Temperament), die 15 alten Häkchen unverändert + **10 neue** (dominant, rangniedrig, sozialkompetent, für erfahrene Halter, Beobachtungstier, familiengeeignet, Schredder-Meister, Nestbauer/Architekt, territorial …).
**E2 ✅** — Ehrliche **Warnsignale** (z. B. *schwer vergesellschaftbar*, *territorial*) sind als eigene, optisch abgesetzte Gruppe drin — fließen in die KI-Verkaufstexte ein.
> zutraulich · handzahm · neugierig · aufgeschlossen · ruhig/ausgeglichen · Weitere Eigenschaften lassen sich jederzeit **additiv** ergänzen (1-Zeilen-Änderung) — falls deiner Frau noch etwas einfällt, einfach sagen.
> lebhaft/aktiv · verschmust · eigenständig · anfängergeeignet · futterfreudig ·
> buddelt gern · klettert gern · läuft gern im Laufrad · gut verträglich · schreckhaft
- **E1** — Eigenschaften hinzufügen/entfernen? (z. B. *dominant, rangniedrig, für erfahrene Halter, einzelgängerisch*?)
- **E2** — Auch ehrliche **negative** Eigenschaften aufnehmen (z. B. *beißt manchmal*) für ehrliche Inserate?
--- ---
## D7 · Neue Konflikt-Tiere aus den 41 Stammbäumen (bitte entscheiden) ## D7 · Neue Konflikt-Tiere aus den 41 Stammbäumen (bitte entscheiden)
Durch die vielen neuen Stammbaum-Dateien sind **13 neue Konflikt-Tiere** aufgetaucht (gleicher Name+Datum, widersprüchliche Angaben in mehreren Diagrammen). Sie warten in Quarantäne — **nichts ist verloren**, sie laden automatisch nach, sobald du je Tier kurz sagst was stimmt. (Uw=G + „Vorhandensein gewinnt" sind schon angewendet; das hier ist der echte Rest.) Durch die vielen neuen Stammbaum-Dateien sind **13 neue Konflikt-Tiere** aufgetaucht (**7 erledigt:** Kazumi/Filou/Sokrates/Osamu/Percy/Iwana/Eragon ✅ — **6 offen**) (gleicher Name+Datum, widersprüchliche Angaben in mehreren Diagrammen). Sie warten in Quarantäne — **nichts ist verloren**, sie laden automatisch nach, sobald du je Tier kurz sagst was stimmt. (Uw=G + „Vorhandensein gewinnt" sind schon angewendet; das hier ist der echte Rest.)
_Quelldatei = die Stammbaum-Datei(en) in `C:\Users\gulum\dev\Sttammbäume\`, in denen das Tier vorkommt. **Mehrere Dateien = Ursache des Konflikts** (widersprüchliche Angaben in verschiedenen Diagrammen)._
**A) Nur Sterbedatum offen** (Gencode einig — bei Osamu/Filou/Sunny zusätzlich „taub" beibehalten): **A) Nur Sterbedatum offen** (Gencode einig — bei Osamu/Filou/Sunny zusätzlich „taub" beibehalten):
| Tier | Sterbedatum — welches? | | Tier | Sterbedatum — welches? | Quelldatei(en) |
|---|---| |---|---|---|
| Isa of Golden Lights (*24.12.2014) | 21.07.2018 ↔ 21.10.2018 | | Isa of Golden Lights (*24.12.2014) | 21.07.2018 ↔ 21.10.2018 | Stammbaum von **Ella**.xlsx · von **Kalea**.xlsx |
| Jack II v.d. K.C. (*14.02.2016) | 06.10.2019 ↔ 20.10.2019 | | Jack II v.d. K.C. (*14.02.2016) | 06.10.2019 ↔ 20.10.2019 | Stammbaum von **Kalea**.xlsx · von **Rainny**.xlsx · von **Ren**.xlsx |
| Osamu v.d. K.C. (*10.12.2015) | 01.10.2020 ↔ 18.12.2020 | | ~~Osamu v.d. K.C. (*10.12.2015)~~ ✅ | **18.12.2020** (erledigt) | — |
| Filou v.d. K.C. (*24.11.2014) | 31.08.2019 ↔ 31.10.2019 | | ~~Filou v.d. K.C. (*24.11.2014)~~ ✅ | **31.08.2019** (erledigt) | — |
| Sunny von PZ Karl (*10.04.2014) | 30.04.2019 ↔ 05.05.2019 | | Sunny von PZ Karl (*10.04.2014) | 30.04.2019 ↔ 05.05.2019 | Stammbaum von **Vance**.xlsx · von **Yurikas und Pintos Sohn**.xlsx |
**B) Gencode-Konflikt** (+ ggf. Sterbedatum): **B) Gencode-Konflikt** (+ ggf. Sterbedatum):
| Tier | Konflikt — was stimmt? | | Tier | Konflikt — was stimmt? | Quelldatei(en) |
|---|---| |---|---|---|
| Milon v.d. K.C. (*27.11.2014) | A-Locus: **Aa****aa** | | Milon v.d. K.C. (*27.11.2014) | A-Locus: **Aa****aa** | Stammbaum von **South Dakota**.xlsx · von **Tennessee**.xlsx |
| Percy of little runners (*16.12.2017) | P-Locus: **PP** **Pp** | | ~~Percy of little runners (*16.12.2017)~~ | P-Locus **Pp** (erledigt) | — |
| Iwana of little runners (*02.10.2018) | P-Locus: **PP** **Pp** | | ~~Iwana of little runners (*02.10.2018)~~ | P-Locus **Pp** (erledigt) | — |
| Sokrates v.d. K.C. (*14.12.2015) | D-Locus: **D-****Dd** · + Sterbedatum 20.05.**2019** ↔ **2020** | | ~~Sokrates v.d. K.C. (*14.12.2015)~~ ✅ | **D-** + Sterbedatum **20.05.2019** (erledigt) | — |
| Eragon (Elieus, *18.05.2016) | C-Locus: **CC** (vollfarbig) **c[chm]c[chm]** (Colourpoint) | | ~~Eragon (Elieus, *18.05.2016)~~ ✅ | C **CC** (vollfarbig) + Name **„Elieus gen. Eragon"**. Korrekter Datensatz lädt bereits (CC); Colourpoint-Variante „Kleiner Warnowrenner …" bleibt als Dublette in Quarantäne — kein weiterer Schritt nötig. | — |
| Dakota of sweet little mouse (*30.01.2015) | A: **Aa**↔**aa** · P: **pp**↔**PP** · Sp: **Spsp**↔**spsp** | | Dakota of sweet little mouse (*30.01.2015) | A: **Aa**↔**aa** · P: **pp**↔**PP** · Sp: **Spsp**↔**spsp** | Stammbaum von **Jiminy of Black Forest**.xlsx · von **Yurikas und Pintos Sohn**.xlsx |
| Kazumi v.d. K.C. (*23.04.2013) | A: **Aa**↔**aa** · G: **GG**↔**Gg** · P: **PP**↔**Pp** · Sp: **Spsp**↔**spsp** | | ~~Kazumi v.d. K.C. (*23.04.2013)~~ ✅ | **Aa Cc[chm] DD ee[f] GG PP Spsp** (erledigt) | — |
| Max von Privat (*01.02.2013) | D: **D-**↔**DD** · P: **P-**↔**PP** · Sterbedatum (4 Varianten: 04.02.2016 / 04.03.2016 / 2014 / 30.12.2015) | | Max von Privat (*01.02.2013) | D: **D-**↔**DD** · P: **P-**↔**PP** · Sterbedatum (4 Varianten: 04.02.2016 / 04.03.2016 / 2014 / 30.12.2015) | Stammbaum von **Danako**.xlsx · von **Kalea**.xlsx · von **Vance**.xlsx · von **Wildfire und Vestras Kids**.xlsx · von **Yurikas und Pintos Sohn**.xlsx |
*(Alle Gencode-Varianten + Quelldateien: `tools/import/output/review-report.md`.)* *(Alle Gencode-Varianten + Quelldateien-Details: `tools/import/output/review-report.md`.)*
--- ---
@@ -85,9 +83,9 @@ Durch die vielen neuen Stammbaum-Dateien sind **13 neue Konflikt-Tiere** aufgeta
|---|---|---| |---|---|---|
| KI-Verkaufstexte · Charakterbogen-KI · Namensgenerator | ✅ **LIVE & kostenlos** (Gemini Free-Tier) | — | | KI-Verkaufstexte · Charakterbogen-KI · Namensgenerator | ✅ **LIVE & kostenlos** (Gemini Free-Tier) | — |
| E-Mail-Posteingang (Anfragen) | gebaut | **A3** (Gmail-App-Passwort) | | E-Mail-Posteingang (Anfragen) | gebaut | **A3** (Gmail-App-Passwort) |
| Öffentliche Webseite + KI-Chat-Editor | gebaut | **A4** (Domain + Cloudflare) | | Öffentliche Webseite + KI-Chat-Editor | gebaut; Hosting **self-hosted** (Strato/DynDNS + TrueNAS nginx) | Hostname-Wahl + WEB-2-Bau (Dwight) |
| NAS-Produktiv-Deployment + Docker-Push | vorbereitet, CI grün | **A5** + **A5b2** | | NAS-Produktiv-Deployment + Docker-Push | vorbereitet, CI grün | **A5** + **A5b2** |
| Handy-Zugriff im WLAN | App läuft | **B2** (Firewall) | | Handy-Zugriff im WLAN | **B2 Firewall erledigt** — testbar sobald App läuft | — |
| Alle importierten Konflikt-Tiere | ✅ **alle 32 entschieden** | finaler Re-Import (startbereit, läuft demnächst) | | Alle importierten Konflikt-Tiere | ✅ **alle 32 entschieden** | finaler Re-Import (startbereit, läuft demnächst) |
--- ---

View File

@@ -7,20 +7,25 @@ POSTGRES_PASSWORD=aendere_mich_bitte
# Externer Port fuer das Frontend (Standard: 80) # Externer Port fuer das Frontend (Standard: 80)
PORT=80 PORT=80
# Gitea Container Registry (Standard: truenas:13000/gulum) # Container Registry (git.rismer.de/gulum)
REGISTRY=truenas:13000/gulum REGISTRY=git.rismer.de/gulum
TAG=latest TAG=latest
# NAS-Dataset-Pfade (TrueNAS SCALE: /mnt/<Pool>/<Dataset>) # NAS-Dataset-Pfade (TrueNAS SCALE Goldeye: /mnt/JailStorage/DockerVolumes/...)
PGDATA_PATH=/mnt/SSD/gerbil/pgdata PGDATA_PATH=/mnt/JailStorage/DockerVolumes/gerbilmanager/pgdata
PHOTOS_PATH=/mnt/SSD/gerbil/photos PHOTOS_PATH=/mnt/JailStorage/DockerVolumes/gerbilmanager/photos
BACKUPS_PATH=/mnt/SSD/gerbil/backups BACKUPS_PATH=/mnt/JailStorage/DockerVolumes/gerbilmanager/backups
# AR-3: Data Protection Key-Ring (Gmail-App-Passwort-Verschlüsselung) # AR-3: Data Protection Key-Ring (Gmail-App-Passwort-Verschlüsselung)
KEYS_PATH=/mnt/SSD/gerbil/keys KEYS_PATH=/mnt/JailStorage/DockerVolumes/gerbilmanager/keys
# Backup-Rotation: Anzahl Tage (Standard: 7) # Backup-Rotation: Anzahl Tage (Standard: 7)
BACKUP_KEEP_DAYS=7 BACKUP_KEEP_DAYS=7
# WEB-2: Oeffentliche Webseite (Shared Volume: api schreibt, publicsite-nginx liest)
PUBLICSITE_PATH=/mnt/JailStorage/DockerVolumes/gerbilmanager/publicsite
# Port fuer den publicsite-nginx (Julian's externer nginx leitet darauf weiter)
PUBLICSITE_PORT=8081
# KI-Funktionen (Verkaufstext + Posteingang-Entwurf) # KI-Funktionen (Verkaufstext + Posteingang-Entwurf)
# Beliebiger OpenAI-kompatibler Anbieter — Optionen in docs/ai-provider.md # Beliebiger OpenAI-kompatibler Anbieter — Optionen in docs/ai-provider.md
# Leer lassen = KI deaktiviert (kein Fehler, nur 503 AiKeyMissing) # Leer lassen = KI deaktiviert (kein Fehler, nur 503 AiKeyMissing)

View File

@@ -29,7 +29,7 @@ services:
# --- .NET API (GerbilManagerWebAPI) --- # --- .NET API (GerbilManagerWebAPI) ---
api: api:
image: "${REGISTRY:-truenas:13000/gulum}/gerbilmanager-api:${TAG:-latest}" image: "${REGISTRY:-git.rismer.de/gulum}/gerbilmanager-api:${TAG:-latest}"
build: build:
context: ../.. context: ../..
dockerfile: GerbilManagerWebAPI/Dockerfile dockerfile: GerbilManagerWebAPI/Dockerfile
@@ -48,9 +48,12 @@ services:
AI__BaseUrl: "${AI__BaseUrl:-}" AI__BaseUrl: "${AI__BaseUrl:-}"
AI__ApiKey: "${AI__ApiKey:-}" AI__ApiKey: "${AI__ApiKey:-}"
AI__Model: "${AI__Model:-gemini-flash-latest}" AI__Model: "${AI__Model:-gemini-flash-latest}"
# WEB-2: Pfad wo POST /api/publish die oeffentliche Seite hinschreibt
PublicSite__RootPath: /data/publicsite
volumes: volumes:
- photos:/data/photos - photos:/data/photos
- keys:/data/keys - keys:/data/keys
- publicsite:/data/publicsite
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy
@@ -63,7 +66,7 @@ services:
# --- nginx Frontend (React SPA + API-Proxy) --- # --- nginx Frontend (React SPA + API-Proxy) ---
frontend: frontend:
image: "${REGISTRY:-truenas:13000/gulum}/gerbilmanager-frontend:${TAG:-latest}" image: "${REGISTRY:-git.rismer.de/gulum}/gerbilmanager-frontend:${TAG:-latest}"
build: build:
context: ../.. context: ../..
dockerfile: gerbil-manager-web/Dockerfile dockerfile: gerbil-manager-web/Dockerfile
@@ -74,6 +77,21 @@ services:
api: api:
condition: service_healthy condition: service_healthy
# --- nginx Public Site (WEB-2) ---
# Serviert NUR die statische oeffentliche Seite (live/ aus dem publicsite-Volume).
# SICHERHEIT: Kein Proxy auf api/frontend — nur statisches HTML nach aussen.
# Julian's externer nginx-Proxy leitet <DOMAIN> auf Port 8081 weiter.
publicsite:
image: nginx:alpine
restart: unless-stopped
ports:
- "${PUBLICSITE_PORT:-8081}:80"
volumes:
- publicsite:/usr/share/nginx/html:ro
- ./nginx/publicsite.conf:/etc/nginx/conf.d/default.conf:ro
depends_on:
- api
# --- Backup-Sidecar (taeglicher pg_dump + Foto-Archiv + Rotation) --- # --- Backup-Sidecar (taeglicher pg_dump + Foto-Archiv + Rotation) ---
backup: backup:
image: postgres:17-alpine image: postgres:17-alpine
@@ -95,19 +113,19 @@ services:
volumes: volumes:
# NAS-Datasets als Bind-Mounts (Pfade in .env konfigurieren). # NAS-Datasets als Bind-Mounts (Pfade in .env konfigurieren).
# TrueNAS: Dataset-Pfad z.B. /mnt/SSD/gerbil/pgdata # TrueNAS Goldeye: /mnt/JailStorage/DockerVolumes/gerbilmanager/<name>
pgdata: pgdata:
driver: local driver: local
driver_opts: driver_opts:
type: none type: none
o: bind o: bind
device: "${PGDATA_PATH:-/mnt/gerbil/pgdata}" device: "${PGDATA_PATH:-/mnt/JailStorage/DockerVolumes/gerbilmanager/pgdata}"
photos: photos:
driver: local driver: local
driver_opts: driver_opts:
type: none type: none
o: bind o: bind
device: "${PHOTOS_PATH:-/mnt/gerbil/photos}" device: "${PHOTOS_PATH:-/mnt/JailStorage/DockerVolumes/gerbilmanager/photos}"
# AR-3: Data Protection key ring — persistiert Gmail-App-Passwort-Verschlüsselung. # AR-3: Data Protection key ring — persistiert Gmail-App-Passwort-Verschlüsselung.
# Muss ein persistentes NAS-Dataset sein (nicht dasselbe wie photos). # Muss ein persistentes NAS-Dataset sein (nicht dasselbe wie photos).
keys: keys:
@@ -115,10 +133,17 @@ volumes:
driver_opts: driver_opts:
type: none type: none
o: bind o: bind
device: "${KEYS_PATH:-/mnt/gerbil/keys}" device: "${KEYS_PATH:-/mnt/JailStorage/DockerVolumes/gerbilmanager/keys}"
backups: backups:
driver: local driver: local
driver_opts: driver_opts:
type: none type: none
o: bind o: bind
device: "${BACKUPS_PATH:-/mnt/gerbil/backups}" device: "${BACKUPS_PATH:-/mnt/JailStorage/DockerVolumes/gerbilmanager/backups}"
# WEB-2: gemeinsames Volume fuer api (rw) und publicsite-nginx (ro).
publicsite:
driver: local
driver_opts:
type: none
o: bind
device: "${PUBLICSITE_PATH:-/mnt/JailStorage/DockerVolumes/gerbilmanager/publicsite}"

View File

@@ -0,0 +1,29 @@
# GerbilManager — publicsite nginx (WEB-2)
# Serviert die statische oeffentliche Seite aus dem live/-Verzeichnis des Shared Volumes.
# SICHERHEIT: Kein Proxy auf die API, kein Zugriff auf den Manager.
server {
listen 80;
root /usr/share/nginx/html/live;
index index.html;
charset utf-8;
# Alle Seiten: no-cache (Aenderungen sofort sichtbar nach Veroeffentlichen)
location / {
try_files $uri $uri/index.html =404;
add_header Cache-Control "no-cache, must-revalidate";
add_header X-Content-Type-Options "nosniff";
add_header X-Frame-Options "SAMEORIGIN";
}
# CSS/Bilder: kurze TTL (1 Tag)
location ~* \.(css|png|jpg|jpeg|gif|ico|webp|svg)$ {
try_files $uri =404;
expires 1d;
add_header Cache-Control "public, max-age=86400";
}
# Kein Zugriff auf Staging-Verzeichnisse
location ~ ^/_(staging_new|old)/ {
return 403;
}
}

View File

@@ -0,0 +1,37 @@
# GerbilManager — Externer nginx-Vhost fuer die oeffentliche Webseite (WEB-2)
# In Julians bestehenden nginx-Reverse-Proxy einfuegen.
# <DOMAIN> ersetzen sobald der Hostname feststeht (Julian liefert ihn).
#
# SICHERHEIT: Dieser Vhost zeigt NUR auf den publicsite-Container (Port 8081).
# Der Manager (API + Frontend, Port 80) ist NICHT erreichbar von aussen —
# er hat keine Authentifizierung und muss LAN-only bleiben.
server {
listen 80;
server_name <DOMAIN>;
location / {
proxy_pass http://127.0.0.1:8081;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Kein Buffering fuer kleine statische HTML-Seiten
proxy_buffering off;
}
}
# Fuer HTTPS (empfohlen, z.B. per Let's Encrypt via certbot):
# server {
# listen 443 ssl;
# server_name <DOMAIN>;
# ssl_certificate /etc/letsencrypt/live/<DOMAIN>/fullchain.pem;
# ssl_certificate_key /etc/letsencrypt/live/<DOMAIN>/privkey.pem;
# location / {
# proxy_pass http://127.0.0.1:8081;
# proxy_set_header Host $host;
# proxy_set_header X-Real-IP $remote_addr;
# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# proxy_set_header X-Forwarded-Proto $scheme;
# }
# }

View File

@@ -1,4 +1,4 @@
# GerbilManager — Betriebsanleitung (TrueNAS) # GerbilManager — Betriebsanleitung (TrueNAS SCALE Goldeye)
> Zielgruppe: Julian (Systemadministration) und Ehefrau (tägliche Nutzung). > Zielgruppe: Julian (Systemadministration) und Ehefrau (tägliche Nutzung).
> Bookmark für die Ehefrau: **http://\<NAS-IP\>/** (z. B. http://truenas/) > Bookmark für die Ehefrau: **http://\<NAS-IP\>/** (z. B. http://truenas/)
@@ -9,13 +9,12 @@
1. [Übersicht & Architektur](#1-übersicht--architektur) 1. [Übersicht & Architektur](#1-übersicht--architektur)
2. [Voraussetzungen](#2-voraussetzungen) 2. [Voraussetzungen](#2-voraussetzungen)
3. [Erstinstallation auf TrueNAS](#3-erstinstallation-auf-truenas) 3. [Erstinstallation auf TrueNAS Goldeye](#3-erstinstallation-auf-truenas-goldeye)
4. [App starten / stoppen / aktualisieren](#4-app-starten--stoppen--aktualisieren) 4. [App starten / stoppen / aktualisieren](#4-app-starten--stoppen--aktualisieren)
5. [Backup & Wiederherstellung](#5-backup--wiederherstellung) 5. [Backup & Wiederherstellung](#5-backup--wiederherstellung)
6. [ZFS-Snapshot-Schichtung](#6-zfs-snapshot-schichtung) 6. [ZFS-Snapshot-Schichtung](#6-zfs-snapshot-schichtung)
7. [CI/CD via Gitea Actions](#7-cicd-via-gitea-actions) 7. [CI/CD via Gitea Actions](#7-cicd-via-gitea-actions)
8. [Offene Fragen (bitte beantworten)](#8-offene-fragen) 8. [Fehlerbehebung](#8-fehlerbehebung)
9. [Fehlerbehebung](#9-fehlerbehebung)
--- ---
@@ -23,7 +22,7 @@
``` ```
Browser / Handy Browser / Handy
| HTTP :80 | HTTP :80 (oder PORT aus .env, z.B. 8080)
v v
┌──────────────────┐ ┌──────────────────┐
│ frontend (nginx) │ statisches React-SPA + Reverse-Proxy │ frontend (nginx) │ statisches React-SPA + Reverse-Proxy
@@ -40,9 +39,10 @@ Browser / Handy
│ db (Postgres 17)│ │ backup (Sidecar) │ │ db (Postgres 17)│ │ backup (Sidecar) │
└──────────────────┘ │ pg_dump + tar + cron │ └──────────────────┘ │ pg_dump + tar + cron │
│ └──────────────────────┘ │ └──────────────────────┘
└─ pgdata-Volume (NAS-Dataset) └─ pgdata-Volume → /mnt/JailStorage/DockerVolumes/gerbilmanager/pgdata
photos-Volume (NAS-Dataset) photos-Volume → /mnt/JailStorage/DockerVolumes/gerbilmanager/photos
backups-Volume (NAS-Dataset) backups-Volume → /mnt/JailStorage/DockerVolumes/gerbilmanager/backups
keys-Volume → /mnt/JailStorage/DockerVolumes/gerbilmanager/keys
``` ```
**Einziger veröffentlichter Port:** `80` (konfigurierbar via `PORT` in `.env`). **Einziger veröffentlichter Port:** `80` (konfigurierbar via `PORT` in `.env`).
@@ -54,68 +54,107 @@ Alles andere läuft intern im Docker-Netz.
| Was | Details | | Was | Details |
|-----|---------| |-----|---------|
| TrueNAS SCALE | Electric Eel 24.10+ (native Docker Custom Apps) | | TrueNAS SCALE | **25.10.2.1 „Goldeye"** (native Docker Custom Apps) |
| Gitea | http://truenas:13000 — Repository `Gulum/GerbilManager` | | Container Registry | `git.rismer.de/gulum` (externes HTTPS) |
| Docker | bereits auf TrueNAS vorhanden (Custom Apps nutzen es) | | Docker | bereits auf TrueNAS Goldeye vorhanden |
| Datasets | Drei ZFS-Datasets anlegen (siehe Schritt 3) | | Verzeichnisse | 4 Ordner unter `/mnt/JailStorage/DockerVolumes/gerbilmanager/` anlegen (Schritt 3.1) |
--- ---
## 3. Erstinstallation auf TrueNAS ## 3. Erstinstallation auf TrueNAS Goldeye
### 3.1 ZFS-Datasets anlegen ### 3.1 Verzeichnisse anlegen und Berechtigungen setzen
In TrueNAS → **Datasets****Dataset hinzufügen** (je einmal wiederholen): Öffne eine Shell auf der NAS (TrueNAS → System → Shell oder SSH):
| Dataset-Name | Empfohlener Pfad | Verwendung |
|---|---|---|
| `gerbil/pgdata` | `/mnt/SSD/gerbil/pgdata` | Postgres-Datenbankdateien |
| `gerbil/photos` | `/mnt/SSD/gerbil/photos` | Hochgeladene Tierfotos |
| `gerbil/backups` | `/mnt/SSD/gerbil/backups` | Tägliche Backups |
> **Tipp:** Passe die Pool-Bezeichnung (`SSD`) an deinen tatsächlichen Pool an.
### 3.2 Repository klonen
```bash ```bash
# SSH in TrueNAS oder lokale Shell # Vier Ordner anlegen
git clone http://truenas:13000/Gulum/GerbilManager.git /opt/gerbilmanager mkdir -p /mnt/JailStorage/DockerVolumes/gerbilmanager/pgdata
mkdir -p /mnt/JailStorage/DockerVolumes/gerbilmanager/photos
mkdir -p /mnt/JailStorage/DockerVolumes/gerbilmanager/backups
mkdir -p /mnt/JailStorage/DockerVolumes/gerbilmanager/keys
# Postgres-Container läuft als UID 999 (postgres) / GID 999 intern.
# pgdata muss von UID 999 beschreibbar sein; postgres erzwingt chmod 0700.
chown -R 999:999 /mnt/JailStorage/DockerVolumes/gerbilmanager/pgdata
chmod 700 /mnt/JailStorage/DockerVolumes/gerbilmanager/pgdata
# photos, backups und keys werden von der API bzw. dem Sidecar beschrieben
# (laufen als root im Container) — keine weiteren ACL-Anpassungen nötig.
```
> **TrueNAS Dataset-ACL-Hinweis:** Falls `JailStorage` ein ZFS-Dataset mit NFSv4-ACLs ist,
> und `chown` meldet „Operation not permitted": setze in TrueNAS → Datasets →
> `JailStorage` → Berechtigungen → **ACL-Typ: POSIX** (oder nutze das UI-Formular
> „Eigentümer: 999, Gruppe: 999" für das `pgdata`-Unterverzeichnis).
### 3.2 Registry-Login auf der NAS
```bash
docker login git.rismer.de
# Benutzername und Token/Passwort eingeben (Gitea-Account oder Access Token mit read:packages)
```
Der Login wird unter `/root/.docker/config.json` gespeichert und bleibt nach Reboots erhalten.
### 3.3 Repository klonen
```bash
git clone https://git.rismer.de/gulum/GerbilManager.git /opt/gerbilmanager
cd /opt/gerbilmanager cd /opt/gerbilmanager
``` ```
### 3.3 Konfiguration anlegen ### 3.4 Konfiguration anlegen
```bash ```bash
cp deploy/truenas/.env.example deploy/truenas/.env cp deploy/truenas/.env.example deploy/truenas/.env
# Jetzt .env bearbeiten:
nano deploy/truenas/.env nano deploy/truenas/.env
``` ```
Mindestens setzen: Mindestens setzen:
- `POSTGRES_PASSWORD` — sicheres Passwort (mind. 20 Zeichen)
- `PGDATA_PATH`, `PHOTOS_PATH`, `BACKUPS_PATH` — tatsächliche Dataset-Pfade
### 3.4 Images bauen und App starten | Variable | Wert |
|----------|------|
| `POSTGRES_PASSWORD` | Sicheres Passwort (mind. 20 Zeichen, keine `"`) |
| `AI__BaseUrl` | Gemini: `https://generativelanguage.googleapis.com/v1beta/openai` |
| `AI__ApiKey` | Dein Gemini API-Key |
| `AI__Model` | `gemini-2.0-flash` (oder `gemini-flash-latest`) |
| `PORT` | `80` — falls Port 80 auf der NAS bereits belegt ist: **auf `8080` ändern** |
Die Pfad-Variablen (`PGDATA_PATH`, `PHOTOS_PATH`, etc.) sind bereits auf die Goldeye-Standardpfade
vorbelegt und müssen nur geändert werden, wenn du einen anderen Pool nutzt.
### 3.5 Images ziehen und App starten
```bash ```bash
cd /opt/gerbilmanager cd /opt/gerbilmanager
docker compose -f deploy/truenas/compose.yaml build docker compose -f deploy/truenas/compose.yaml pull
docker compose -f deploy/truenas/compose.yaml up -d docker compose -f deploy/truenas/compose.yaml up -d
``` ```
Erster Start dauert ca. 23 Minuten (Postgres-Init + EF-Migrationen). Erster Start dauert ca. 23 Minuten (Postgres-Init + EF-Migrationen).
### 3.5 Prüfen > **TrueNAS Goldeye Custom App (Alternative):**
> Statt der Shell kann die App auch über TrueNAS → Apps → „Custom App installieren" →
> „Install via YAML" deployt werden: compose-Inhalt einfügen, Volumes als Host-Pfade
> konfigurieren. Die Shell-Methode ist einfacher und gibt mehr Kontrolle.
### 3.6 Verifikation
```bash ```bash
# Alle Container laufen? # Alle 4 Container laufen?
docker compose -f deploy/truenas/compose.yaml ps docker compose -f deploy/truenas/compose.yaml ps
# API-Healthcheck # API-Healthcheck (erwartet: {"status":"Healthy"})
curl http://localhost/api/health curl -s http://localhost/api/health
# Webapp im Browser # Tier-Gesamtanzahl prüfen (erwartet > 0 nach Import)
http://<NAS-IP>/ curl -s "http://localhost/api/gerbils?pageSize=1" | grep -o '"totalCount":[0-9]*'
# API-Doku (Scalar) im Browser
http://<NAS-IP>/scalar
# Foto-Upload: in der Webapp ein Tier öffnen → Foto hochladen → Foto erscheint
``` ```
--- ---
@@ -125,6 +164,7 @@ http://<NAS-IP>/
### Starten ### Starten
```bash ```bash
cd /opt/gerbilmanager
docker compose -f deploy/truenas/compose.yaml up -d docker compose -f deploy/truenas/compose.yaml up -d
``` ```
@@ -134,26 +174,17 @@ docker compose -f deploy/truenas/compose.yaml up -d
docker compose -f deploy/truenas/compose.yaml down docker compose -f deploy/truenas/compose.yaml down
``` ```
### Aktualisieren (nach `git push` auf main) ### Aktualisieren (nach CI-Push auf main)
```bash ```bash
cd /opt/gerbilmanager cd /opt/gerbilmanager
git pull git pull
docker compose -f deploy/truenas/compose.yaml build docker compose -f deploy/truenas/compose.yaml pull
docker compose -f deploy/truenas/compose.yaml up -d docker compose -f deploy/truenas/compose.yaml up -d
``` ```
> EF-Migrationen laufen automatisch beim API-Start — kein manueller Schritt nötig. > EF-Migrationen laufen automatisch beim API-Start — kein manueller Schritt nötig.
### Mit Gitea CI (wenn Actions aktiviert)
Push auf `main` triggert automatisch Build → Test → Image-Push.
Danach auf der NAS:
```bash
docker compose -f deploy/truenas/compose.yaml pull
docker compose -f deploy/truenas/compose.yaml up -d
```
--- ---
## 5. Backup & Wiederherstellung ## 5. Backup & Wiederherstellung
@@ -165,12 +196,12 @@ Der `backup`-Sidecar-Container läuft dauerhaft und sichert täglich um **03:00
- Komprimiertes Foto-Archiv als `.tar.gz` - Komprimiertes Foto-Archiv als `.tar.gz`
- Rotation: Backups älter als `BACKUP_KEEP_DAYS` (Standard: 7) werden gelöscht - Rotation: Backups älter als `BACKUP_KEEP_DAYS` (Standard: 7) werden gelöscht
Backups liegen unter: `${BACKUPS_PATH}/YYYY-MM-DD_HH-MM/` Backups liegen unter: `/mnt/JailStorage/DockerVolumes/gerbilmanager/backups/YYYY-MM-DD_HH-MM/`
``` ```
/mnt/SSD/gerbil/backups/ /mnt/JailStorage/DockerVolumes/gerbilmanager/backups/
2026-06-06_03-00/ 2026-06-06_03-00/
gerbilmanager_2026-06-06_03-00.sql (Datenbank) gerbilmanager_2026-06-06_03-00.sql (Datenbank-Dump, Klartext SQL)
photos_2026-06-06_03-00.tar.gz (Fotos) photos_2026-06-06_03-00.tar.gz (Fotos)
backup.log (Protokoll) backup.log (Protokoll)
``` ```
@@ -184,52 +215,68 @@ docker compose -f deploy/truenas/compose.yaml exec backup /bin/sh /scripts/backu
### Backup-Log prüfen ### Backup-Log prüfen
```bash ```bash
tail -50 /mnt/SSD/gerbil/backups/backup.log tail -50 /mnt/JailStorage/DockerVolumes/gerbilmanager/backups/backup.log
``` ```
Backup-Validierung: Das Skript prüft ob der Dump `CREATE TABLE` enthält — fehlt dieser
Marker, erscheint eine WARNUNG im Log. Größe 0 KB bedeutet Fehlschlag.
### Wiederherstellung — Runbook ### Wiederherstellung — Runbook
> **WARNUNG:** Alle aktuellen Daten werden überschrieben! > **WARNUNG:** Alle aktuellen Datenbankdaten und Fotos werden überschrieben!
**Schritt 1:** App stoppen (optional, aber empfohlen) **Schritt 1:** API und Frontend stoppen (DB und backup-Sidecar laufen weiter)
```bash ```bash
docker compose -f deploy/truenas/compose.yaml stop api frontend docker compose -f deploy/truenas/compose.yaml stop api frontend
``` ```
**Schritt 2:** Restore ausführen **Schritt 2:** Restore ausführen
```bash
# Neuestes Backup wiederherstellen:
docker compose -f deploy/truenas/compose.yaml exec backup \
/bin/sh /scripts/restore.sh
# Bestimmtes Backup wiederherstellen: ```bash
docker compose -f deploy/truenas/compose.yaml exec backup \ # Neuestes Backup automatisch wählen und bestätigen:
/bin/sh /scripts/restore.sh 2026-06-05_03-00 docker compose -f deploy/truenas/compose.yaml exec -T backup \
/bin/sh /scripts/restore.sh latest -f
# Bestimmtes Backup (Datum aus Verzeichnisname):
docker compose -f deploy/truenas/compose.yaml exec -T backup \
/bin/sh /scripts/restore.sh 2026-06-06_03-00 -f
``` ```
Das Skript:
1. Trennt alle offenen DB-Verbindungen
2. Spielt den SQL-Dump mit `psql -h db -U postgres -d gerbilmanager < dump.sql` ein
3. Entpackt das Foto-Archiv nach `/data/photos`
**Schritt 3:** API neu starten **Schritt 3:** API neu starten
```bash ```bash
docker compose -f deploy/truenas/compose.yaml start api frontend docker compose -f deploy/truenas/compose.yaml start api frontend
``` ```
**Schritt 4:** Prüfen **Schritt 4 — Verifikation (Pflicht nach erstem Restore-Drill):**
```bash ```bash
curl http://localhost/api/color-varieties | grep -c '"id"' # Tier-Anzahl prüfen
# Erwarteter Wert: 73 curl -s "http://localhost/api/gerbils?pageSize=1" | grep -o '"totalCount":[0-9]*'
# ColorVariety-Anzahl (Stammdaten, erwartet: >= 60)
curl -s http://localhost/api/color-varieties | python3 -c "import sys,json; print(len(json.load(sys.stdin)))"
# Foto stichprobenartig prüfen
ls /mnt/JailStorage/DockerVolumes/gerbilmanager/photos/ | head -5
``` ```
### Restore-Nachweis (Round-Trip-Test) ### Restore-Nachweis (Round-Trip-Test, lokal 2026-06-06)
Protokoll vom Test auf lokalem Aspire-Postgres (Vorgänger-Instanz, 2026-06-06 07:09): Protokoll vom getesteten Restore auf lokalem Aspire-Postgres:
``` ```
73 ColorVarieties vorhanden 73 ColorVarieties vorhanden
→ DELETE 12 Zeilen → 61 verbleibend → DELETE 12 Zeilen → 61 verbleibend
→ pg_restore eingespielt → psql < dump.sql eingespielt
→ 73 ColorVarieties bestätigt → 73 ColorVarieties bestätigt
Exit-Code: 0 Exit-Code: 0
``` ```
Die Container-Restore-Skripte nutzen dieselbe `psql < dump.sql` Logik.
**Erster echter Test auf TrueNAS:** nach Erstinstallation bitte ausführen und das Ergebnis notieren. **Erster TrueNAS-Restore-Drill:** nach Erstinstallation bitte ausführen und Tier-Anzahl
notieren — beweist dass Backup + Restore auf dem NAS korrekt funktionieren.
--- ---
@@ -240,15 +287,15 @@ Sie schützen vor versehentlichem Datenverlust auf Dataset-Ebene.
### Empfohlene Snapshot-Konfiguration ### Empfohlene Snapshot-Konfiguration
In TrueNAS → **Datasets**Dataset auswählen**Snapshots****Regelmäßige Snapshots**: In TrueNAS → **Datasets**`JailStorage/DockerVolumes/gerbilmanager`**Snapshots****Regelmäßige Snapshots**:
| Dataset | Häufigkeit | Aufbewahrung | | Unterordner | Häufigkeit | Aufbewahrung |
|---------|-----------|--------------| |-------------|-----------|--------------|
| `gerbil/photos` | Stündlich | 24 Stunden | | `.../photos` | Stündlich | 24 Stunden |
| `gerbil/photos` | Täglich | 30 Tage | | `.../photos` | Täglich | 30 Tage |
| `gerbil/pgdata` | Stündlich | 24 Stunden | | `.../pgdata` | Stündlich | 24 Stunden |
| `gerbil/pgdata` | Täglich | 30 Tage | | `.../pgdata` | Täglich | 30 Tage |
| `gerbil/backups` | Täglich | 90 Tage | | `.../backups` | Täglich | 90 Tage |
> **Hinweis:** `pgdata` enthält Live-Postgres-Dateien. ZFS-Snapshots davon sind crash-konsistent, > **Hinweis:** `pgdata` enthält Live-Postgres-Dateien. ZFS-Snapshots davon sind crash-konsistent,
> aber **nicht** application-konsistent — für einen sauberen DB-Restore immer den `pg_dump` verwenden, > aber **nicht** application-konsistent — für einen sauberen DB-Restore immer den `pg_dump` verwenden,
@@ -257,94 +304,93 @@ In TrueNAS → **Datasets** → Dataset auswählen → **Snapshots** → **Regel
### Snapshot manuell erstellen (z. B. vor Update) ### Snapshot manuell erstellen (z. B. vor Update)
```bash ```bash
# TrueNAS CLI # Pool-/Dataset-Name anpassen falls nötig
zfs snapshot SSD/gerbil/photos@vor-update-$(date +%Y%m%d) zfs snapshot JailStorage/DockerVolumes/gerbilmanager/photos@vor-update-$(date +%Y%m%d)
zfs snapshot SSD/gerbil/backups@vor-update-$(date +%Y%m%d) zfs snapshot JailStorage/DockerVolumes/gerbilmanager/backups@vor-update-$(date +%Y%m%d)
``` ```
### Aus ZFS-Snapshot wiederherstellen (Fotos) ### Aus ZFS-Snapshot wiederherstellen (Fotos)
```bash ```bash
# Snapshot auflisten # Snapshots auflisten
zfs list -t snapshot SSD/gerbil/photos zfs list -t snapshot JailStorage/DockerVolumes/gerbilmanager/photos
# Datei aus Snapshot kopieren # Einzelne Datei aus Snapshot kopieren
cp /mnt/SSD/gerbil/photos/.zfs/snapshot/<NAME>/datei.jpg /mnt/SSD/gerbil/photos/ cp /mnt/JailStorage/DockerVolumes/gerbilmanager/photos/.zfs/snapshot/<NAME>/datei.jpg \
/mnt/JailStorage/DockerVolumes/gerbilmanager/photos/
``` ```
--- ---
## 7. CI/CD via Gitea Actions ## 7. CI/CD via Gitea Actions
Der Workflow `.gitea/workflows/ci.yml` ist als **Entwurf vorhanden, aber inaktiv**. CI pusht Images nach Erfolg zu `git.rismer.de/gulum/gerbilmanager-api` und
`git.rismer.de/gulum/gerbilmanager-frontend`.
### Aktivierung ### Registry-Secrets in Gitea
1. **Gitea Actions aktivieren:** Gitea → Repository → Einstellungen → Secrets:
Gitea → Repository `GerbilManager` → Einstellungen → Actions → "Actions aktivieren"
2. **Gitea Actions Runner installieren** (auf TrueNAS oder einem separaten Gerät): | Secret | Wert |
```bash |--------|------|
# Gitea Runner Container (einfachste Variante für TrueNAS) | `REGISTRY_USER` | Gitea-Benutzername |
docker run -d --name gitea-runner \ | `REGISTRY_TOKEN` | Gitea Access Token mit `package:write` |
-v /var/run/docker.sock:/var/run/docker.sock \
-v /opt/gitea-runner:/data \
-e GITEA_INSTANCE_URL=http://truenas:13000 \
-e GITEA_RUNNER_REGISTRATION_TOKEN=<TOKEN> \
gitea/act_runner:latest
```
Token: Gitea → Admin → Actions → Runner → "Runner hinzufügen"
3. **Registry-Secrets konfigurieren:** ### Update nach CI-Push
Gitea → Repository → Einstellungen → Secrets:
- `REGISTRY_USER` — dein Gitea-Benutzername
- `REGISTRY_TOKEN` — Gitea Access Token mit `package:write`-Berechtigung
### Workflow nach Aktivierung ```bash
# Auf der NAS nach erfolgreichem CI-Lauf:
``` cd /opt/gerbilmanager
git push origin main git pull
→ Gitea Actions: dotnet test + npm test + npm run build docker compose -f deploy/truenas/compose.yaml pull
→ Bei Erfolg: docker build + push zu truenas:13000/gulum/ docker compose -f deploy/truenas/compose.yaml up -d
→ Auf NAS: docker compose pull + up -d
``` ```
--- ---
## 8. Offene Fragen ## 8. Fehlerbehebung
Bitte beantworte diese Fragen, damit das Setup fertiggestellt werden kann:
| # | Frage | Auswirkung |
|---|-------|-----------|
| 1 | **TrueNAS SCALE Version?** Electric Eel 24.10 hat native Docker Custom Apps. Ältere Versionen nutzen Kubernetes. | Bestimmt ob `docker compose` direkt läuft |
| 2 | **Gitea Actions verfügbar/aktivierbar?** | CI/CD-Workflow aktiv oder nur manuell deployen |
| 3 | **Eigener Postgres-Container (empfohlen) oder vorhandene NAS-Postgres-App?** | Isolation vs. geteilte Instanz |
| 4 | **Genaue Dataset-Pfade?** Poolname und Pfad-Präfix | `.env`-Konfiguration |
| 5 | **Port-Wahl?** Standard 80 — frei auf der NAS? | `PORT`-Wert in `.env` |
---
## 9. Fehlerbehebung
### App startet nicht ### App startet nicht
```bash ```bash
# Logs aller Container
docker compose -f deploy/truenas/compose.yaml logs docker compose -f deploy/truenas/compose.yaml logs
# Logs eines bestimmten Containers
docker compose -f deploy/truenas/compose.yaml logs api docker compose -f deploy/truenas/compose.yaml logs api
docker compose -f deploy/truenas/compose.yaml logs db docker compose -f deploy/truenas/compose.yaml logs db
``` ```
### Port 80 belegt
Falls Port 80 vom TrueNAS-System selbst genutzt wird:
```bash
# In deploy/truenas/.env:
PORT=8080
# Dann neu starten:
docker compose -f deploy/truenas/compose.yaml up -d
```
### Postgres startet nicht (Permission denied auf pgdata)
```bash
# UID 999 muss Eigentümer des pgdata-Verzeichnisses sein:
chown -R 999:999 /mnt/JailStorage/DockerVolumes/gerbilmanager/pgdata
chmod 700 /mnt/JailStorage/DockerVolumes/gerbilmanager/pgdata
docker compose -f deploy/truenas/compose.yaml restart db
```
### Registry-Pull schlägt fehl
```bash
# Neu einloggen:
docker login git.rismer.de
# Dann pull wiederholen:
docker compose -f deploy/truenas/compose.yaml pull
```
### Datenbank nicht erreichbar ### Datenbank nicht erreichbar
```bash ```bash
# DB-Container läuft?
docker compose -f deploy/truenas/compose.yaml ps db docker compose -f deploy/truenas/compose.yaml ps db
# Verbindung testen
docker compose -f deploy/truenas/compose.yaml exec db \ docker compose -f deploy/truenas/compose.yaml exec db \
psql -U postgres -d gerbilmanager -c "\dt" psql -U postgres -d gerbilmanager -c "\dt"
``` ```
@@ -352,27 +398,16 @@ docker compose -f deploy/truenas/compose.yaml exec db \
### Backup-Fehler ### Backup-Fehler
```bash ```bash
# Backup-Log prüfen tail -50 /mnt/JailStorage/DockerVolumes/gerbilmanager/backups/backup.log
cat /mnt/SSD/gerbil/backups/backup.log | tail -30 docker compose -f deploy/truenas/compose.yaml exec backup /bin/sh /scripts/backup.sh
# Backup manuell starten (mit Fehlerausgabe)
docker compose -f deploy/truenas/compose.yaml exec backup \
/bin/sh /scripts/backup.sh
``` ```
### Fotos werden nicht angezeigt ### Fotos werden nicht angezeigt
Prüfe ob das `photos`-Volume korrekt gemounted ist:
```bash ```bash
docker compose -f deploy/truenas/compose.yaml exec api ls /data/photos docker compose -f deploy/truenas/compose.yaml exec api ls /data/photos
``` ```
### Container-Status zurücksetzen (Neustart)
```bash
docker compose -f deploy/truenas/compose.yaml restart api
```
### Kompletter Neustart (Daten bleiben erhalten) ### Kompletter Neustart (Daten bleiben erhalten)
```bash ```bash

136
docs/web-deploy.md Normal file
View File

@@ -0,0 +1,136 @@
# GerbilManager — Oeffentliche Webseite (Self-Hosted, TrueNAS)
> **Zielgruppe:** Julian.
> Die oeffentliche Seite (Jimdo-Ersatz) laeuft self-hosted auf der TrueNAS neben dem Manager.
> Strato-Domain → DynDNS → IP → Julians nginx-Proxy → publicsite-Container (Port 8081).
---
## Architektur
```
Internet
| HTTPS/HTTP
v
Julians nginx-Reverse-Proxy (laeuft schon auf NAS)
| proxy_pass http://127.0.0.1:8081
v
publicsite (nginx:alpine, Port 8081) ← liest nur: /usr/share/nginx/html/live/
| (Shared Volume, read-only)
| [POST /api/publish im Manager schreibt in dasselbe Volume]
v
api (.NET, Port 8080 intern) → schreibt: /data/publicsite/live/
| (Shared Volume, read-write)
v
Manager (frontend-nginx, Port 80) ← LAN-only, NIE internet-exponiert
```
**SICHERHEIT — harte Bedingung:**
- Nur `publicsite` (Port 8081) wird ins Internet weitergeleitet.
- Der Manager (API + Frontend, Port 80) hat KEINE Authentifizierung → LAN-only.
- Der `publicsite`-nginx proxied NICHT auf die API — er serviert nur statisches HTML.
---
## Erstinstallation
### 1. Verzeichnis anlegen
```bash
mkdir -p /mnt/JailStorage/DockerVolumes/gerbilmanager/publicsite
```
Das Verzeichnis wird von der API beschrieben (laeuft als root im Container) — keine ACL-Aenderung noetig.
Beim ersten `POST /api/publish` legt die API automatisch `live/` und `_staging_new/` darunter an.
### 2. .env erganzen
In `deploy/truenas/.env` hinzufuegen (oder aus `.env.example` uebernehmen):
```env
PUBLICSITE_PATH=/mnt/JailStorage/DockerVolumes/gerbilmanager/publicsite
PUBLICSITE_PORT=8081
```
### 3. Compose-Stack neu starten
```bash
cd /opt/gerbilmanager
docker compose -f deploy/truenas/compose.yaml up -d
```
Der neue `publicsite`-Container startet und serviert Port 8081.
Solange noch nicht veroeffentlicht wurde, zeigt er einen 404 (live/-Verzeichnis leer).
### 4. Julians externen nginx konfigurieren
Inhalt von `deploy/truenas/vhost-snippet.conf` in den bestehenden nginx-Proxy einfuegen
(als eigenen `server`-Block oder per `include`):
```bash
# Auf der NAS, nginx-Konfigverzeichnis (z.B. /etc/nginx/conf.d/ oder sites-available):
nano /etc/nginx/conf.d/gerbilmanager-public.conf
# <DOMAIN> durch den tatsaechlichen Hostnamen ersetzen
nginx -t && nginx -s reload
```
---
## Seite veroeffentlichen (Publish-Ablauf)
1. Im Manager einloggen (http://\<NAS-IP\>/)
2. Navigiere zu **Webseite** → Inhalte bearbeiten → **Veroeffentlichen**
3. Klick auf "Veroeffentlichen" loest `POST /api/publish` aus.
**Was passiert intern:**
```
POST /api/publish
→ API baut SiteSnapshot aus DB (alle Published-Seiten)
→ SiteRenderer rendert Snapshot → HTML-Dateien (path → content Map)
→ Schreibt Dateien nach /data/publicsite/_staging_new/
→ Atomic Swap: _staging_new/ → live/ (rename = ein Syscall, nie halb-geschrieben)
→ publicsite-nginx serviert beim naechsten Request sofort den neuen Stand
→ Kein Container-Restart, kein Image-Rebuild, kein CI
Response: { "filesPublished": N }
```
**Endergebnis:** publicsite-nginx liest sofort den neuen Stand aus `live/`.
---
## Verifikation
```bash
# publicsite-Container laeuft?
docker compose -f deploy/truenas/compose.yaml ps publicsite
# Seite lokal abrufbar?
curl -s http://localhost:8081/ | head -5
# live/-Verzeichnis gefuellt?
ls /mnt/JailStorage/DockerVolumes/gerbilmanager/publicsite/live/
# Oeffentlich erreichbar (nach DNS-Propagation)?
curl -s http://<DOMAIN>/ | grep "Kleine Chaoten"
```
---
## Sicherheitstrennung (Pflichtcheck)
| Was | Port | Internet-exponiert? |
|-----|------|---------------------|
| Manager (api + frontend) | 80 | **NEIN** — LAN-only |
| Oeffentliche Seite (publicsite) | 8081 | Ja, via Julians nginx-Proxy |
| API-Doku (Scalar) | 80/scalar | **NEIN** — LAN-only |
Der Manager-nginx (gerbilmanager-frontend, Port 80) und die API (Port 8080 intern)
sind NICHT in `vhost-snippet.conf` eingetragen und NICHT in Julians externem Proxy konfiguriert.
Sie sind ausschliesslich im Heimnetz erreichbar.
---
## Hostname noch ausstehend
`<DOMAIN>` in `deploy/truenas/vhost-snippet.conf` ist ein Platzhalter.
Julian nennt den Hostnamen/Subdomain → ersetzen und nginx neu laden.

View File

@@ -79,3 +79,78 @@ test('Namenloser Ahne zeigt Platzhalter in der Stammbaum-Karte (UI-POLISH-2)', a
// Karte selbst zeigt '(ohne Namen)' statt leer // Karte selbst zeigt '(ohne Namen)' statt leer
await expect(page.locator('.pedigree-card__nametext')).toHaveText(de.pages.gerbils.nameless) await expect(page.locator('.pedigree-card__nametext')).toHaveText(de.pages.gerbils.nameless)
}) })
test('Mini-Legende zeigt alle drei Interaktionshinweise (STAMMBAUM-EXPAND)', async ({ page }) => {
skipUnlessMock()
await page.goto('/rennmaeuse/kruemel/stammbaum')
await expect(page.locator('.pedigree-card').first()).toBeVisible()
const hints = page.locator('.stammbaum-hints')
await expect(hints).toBeVisible()
await expect(hints).toContainText(t.tapHint)
await expect(hints).toContainText(t.hintName)
await expect(hints).toContainText(t.hintExpand)
})
test('-Knopf ist sichtbar und lädt weitere Vorfahren nach (STAMMBAUM-EXPAND)', async ({ page }) => {
skipUnlessMock()
await page.goto('/rennmaeuse/kruemel/stammbaum')
await expect(page.locator('.pedigree-card').first()).toBeVisible()
// Einpassen, damit die 4. Generation (Emil mit ) im Viewport liegt.
const fit = page.getByRole('button', { name: t.zoomFit })
if (await fit.isVisible()) await fit.click()
await page.waitForTimeout(600)
// -Button muss an der Tiefengrenze erscheinen (Emil hat litterId w-emil).
const expandBtn = page.getByRole('button', { name: t.expand }).first()
await expect(expandBtn).toBeVisible()
// Klick auf : Emil wird aufgeklappt → Max (sein Vater) taucht als Link auf.
await expandBtn.click({ force: true })
await expect(page.getByRole('link', { name: 'Max' })).toBeVisible({ timeout: 8000 })
})
test('Würfe-Panel zeigt Würfe des Wurzeltiers + Link öffnet Wurf (STAMMBAUM-LITTERS)', async ({ page }) => {
skipUnlessMock()
// Fridolin ist Vater von Wurf K (5 Junge) — Panel muss erscheinen.
await page.goto('/rennmaeuse/fridolin/stammbaum')
await expect(page.locator('.pedigree-card').first()).toBeVisible()
const panel = page.locator('.stammbaum-litters-panel')
await expect(panel).toBeVisible()
await expect(panel).toContainText(t.littersTitle)
await expect(panel).toContainText('Wurf K')
await expect(panel).toContainText('5')
// Link-Klick → Wurf-Detailseite
const wurfLink = panel.getByRole('link', { name: /Wurf K/ })
await expect(wurfLink).toBeVisible()
await wurfLink.click()
await expect(page).toHaveURL(/\/wuerfe\/w-kruemel/)
})
test('Kein Würfe-Panel wenn Wurzeltier keine Würfe hat (STAMMBAUM-LITTERS)', async ({ page }) => {
skipUnlessMock()
// Krümel hat noch keine Würfe als Elternteil → Panel muss fehlen.
await page.goto('/rennmaeuse/kruemel/stammbaum')
await expect(page.locator('.pedigree-card').first()).toBeVisible()
await expect(page.locator('.stammbaum-litters-panel')).not.toBeVisible()
})
test('Knoten-Karte zeigt volles Geburtsdatum (STAMMBAUM-FULLDATE)', async ({ page }) => {
skipUnlessMock()
// Krümel: dateOfBirth '2025-03-12' → formatDate → '12.03.2025'
await page.goto('/rennmaeuse/kruemel/stammbaum')
await expect(page.locator('.pedigree-card').first()).toBeVisible()
await expect(page.locator('.pedigree-card__year').first()).toContainText('12.03.2025')
})
test('Gencode-Chip zeigt Genotyp statt Farbname (STAMMBAUM-GENCODE)', async ({ page }) => {
skipUnlessMock()
// Fridolin ist Vater von Krümel — genotype 'aa CC DD EE GG PP spsp rere'
await page.goto('/rennmaeuse/kruemel/stammbaum')
await expect(page.locator('.pedigree-card').first()).toBeVisible()
const fridolinCard = page.locator('.pedigree-card').filter({ hasText: 'Fridolin' })
await expect(fridolinCard.locator('.pedigree-chip')).toContainText('aa CC')
})

View File

@@ -62,7 +62,12 @@ describe('NAMEGEN_USAGES', () => {
expect(codes).toContain('mythg') expect(codes).toContain('mythg')
expect(codes).toContain('ger') expect(codes).toContain('ger')
expect(codes).toContain('arb') expect(codes).toContain('arb')
expect(codes).toHaveLength(5) expect(codes).toContain('disney')
expect(codes).toContain('pokemon')
expect(codes).toContain('encities')
expect(codes).toContain('hrcities')
expect(codes).toContain('usstates')
expect(codes).toHaveLength(10)
}) })
it('every usage has a non-empty label', () => { it('every usage has a non-empty label', () => {

View File

@@ -13,6 +13,11 @@ export const NAMEGEN_USAGES = [
{ code: 'mythg', label: 'Griech. Mythologie' }, { code: 'mythg', label: 'Griech. Mythologie' },
{ code: 'ger', label: 'Deutsch' }, { code: 'ger', label: 'Deutsch' },
{ code: 'arb', label: 'Arabisch' }, { code: 'arb', label: 'Arabisch' },
{ code: 'disney', label: 'Disney' },
{ code: 'pokemon', label: 'Pokémon' },
{ code: 'encities', label: 'Englische Städte' },
{ code: 'hrcities', label: 'Kroatische Städte' },
{ code: 'usstates', label: 'US-Bundesstaaten' },
] as const ] as const
export type NamegenUsageCode = (typeof NAMEGEN_USAGES)[number]['code'] export type NamegenUsageCode = (typeof NAMEGEN_USAGES)[number]['code']

View File

@@ -1,19 +1,21 @@
import { useState } from 'react' import { useState } from 'react'
import type { ReactNode } from 'react'
import { NavLink, Outlet } from 'react-router-dom' import { NavLink, Outlet } from 'react-router-dom'
import { de } from '../strings/de' import { de } from '../strings/de'
import GerbilIcon from './GerbilIcon'
import './appShell.css' import './appShell.css'
interface NavItem { interface NavItem {
to: string to: string
label: string label: string
icon: string icon: ReactNode
end?: boolean end?: boolean
} }
// Primary items are always visible in the phone bottom bar; secondary items // Primary items are always visible in the phone bottom bar; secondary items
// move into the "Mehr" sheet on phones and appear inline in the desktop sidebar. // move into the "Mehr" sheet on phones and appear inline in the desktop sidebar.
const PRIMARY: NavItem[] = [ const PRIMARY: NavItem[] = [
{ to: '/rennmaeuse', label: de.nav.gerbils, icon: '🐭' }, { to: '/rennmaeuse', label: de.nav.gerbils, icon: <GerbilIcon size="1.25em" /> },
{ to: '/wuerfe', label: de.nav.litters, icon: '🍼' }, { to: '/wuerfe', label: de.nav.litters, icon: '🍼' },
{ to: '/genetik', label: de.nav.genetics, icon: '🧬' }, { to: '/genetik', label: de.nav.genetics, icon: '🧬' },
] ]
@@ -46,7 +48,7 @@ export default function AppShell() {
return ( return (
<div className="app-shell"> <div className="app-shell">
<header className="app-header"> <header className="app-header">
<span className="app-logo" aria-hidden="true">🐭</span> <span className="app-logo" aria-hidden="true"><GerbilIcon size="1.5rem" /></span>
<h1 className="app-title">{de.app.title}</h1> <h1 className="app-title">{de.app.title}</h1>
</header> </header>

View File

@@ -0,0 +1,35 @@
import type { CSSProperties } from 'react'
interface Props {
size?: string | number
style?: CSSProperties
className?: string
}
/** Reusable gerbil silhouette icon — upright sitting pose, long tail with tuft. fill=currentColor. */
export default function GerbilIcon({ size = '1em', style, className }: Props) {
return (
<svg
viewBox="0 0 64 64"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
style={{ display: 'inline-block', verticalAlign: '-0.125em', flexShrink: 0, ...style }}
className={className}
role="img"
aria-label="Rennmaus"
>
{/* body — sitting upright */}
<path d="M40 14c6 0 11 5 11 12 0 4-2 7-2 11 0 10-6 16-15 16-8 0-14-6-14-15 0-13 9-24 20-24z" />
{/* ear */}
<circle cx="46" cy="15" r="5" />
{/* hind paw */}
<ellipse cx="24" cy="52" rx="9" ry="3.5" />
{/* long curved tail with tuft */}
<path d="M16 51c-7 1-13-2-15-9-1-4 0-8 3-10 1 3 1 6 3 8 3 3 7 4 11 4z" />
{/* front paw */}
<path d="M30 45c3 0 5 1 5 4-3 1-6 1-7-2 0-1 1-2 2-2z" />
</svg>
)
}

View File

@@ -46,8 +46,8 @@ describe('Fraction', () => {
describe('Genotype serialization', () => { describe('Genotype serialization', () => {
it('round-trips display string <-> structured form', () => { it('round-trips display string <-> structured form', () => {
const g = fromDisplayString('Aa CC Dd EE GG Pp Spsp rere') const g = fromDisplayString('Aa CC Dd EE GG Pp Spsp rere')
expect(toDisplayString(g)).toBe('Aa CC Dd EE GG Pp Spsp rere') expect(toDisplayString(g)).toBe('Aa CC Dd EE GG Pp Spsp')
expect(toDisplayString(fromJSON(toJSON(g)))).toBe('Aa CC Dd EE GG Pp Spsp rere') expect(toDisplayString(fromJSON(toJSON(g)))).toBe('Aa CC Dd EE GG Pp Spsp')
}) })
it('parses multi-char C-series alleles via maximal munch', () => { it('parses multi-char C-series alleles via maximal munch', () => {
@@ -72,8 +72,8 @@ describe('Genotype serialization', () => {
expect(g.P).toEqual(['P', 'p']) expect(g.P).toEqual(['P', 'p'])
}) })
it('wild type is AA CC DD EE GG PP spsp rere', () => { it('wild type is AA CC DD EE GG PP spsp (rere omitted)', () => {
expect(toDisplayString(wildType())).toBe('AA CC DD EE GG PP spsp rere') expect(toDisplayString(wildType())).toBe('AA CC DD EE GG PP spsp')
}) })
}) })
@@ -102,7 +102,7 @@ describe('Worked example from research report', () => {
expect(result.offspring).toHaveLength(1) expect(result.offspring).toHaveLength(1)
const only = result.offspring[0] const only = result.offspring[0]
expect(only.genotype).toBe('Aa CC DD EE GG Pp spsp rere') expect(only.genotype).toBe('Aa CC DD EE GG Pp spsp')
expect(only.probability.text).toBe('1') expect(only.probability.text).toBe('1')
expect(result.warnings).toHaveLength(0) expect(result.warnings).toHaveLength(0)
}) })
@@ -185,15 +185,11 @@ describe('Farbschlag catalog', () => {
}) })
it('frozen contract names round-trip to themselves (DB-key guard)', () => { it('frozen contract names round-trip to themselves (DB-key guard)', () => {
// The first 18 are the frozen ColorVariety keys — their representative // The first 17 frozen ColorVariety keys — their representative genotype
// genotype MUST resolve back to their own name, never a later variety. // MUST resolve back to their own name, never a later variety.
// GEN-4 exception: 'Pink Eyed White (PEW)' (ch/ch+pp) now computes 'REW' // GEN-4c: 'REW' (formerly 'Pink Eyed White (PEW)') round-trips correctly:
// because the REW engine check (both C-alleles reduced + pp) fires first. // chch+pp → REW engine check → 'REW' = entry.name.
// PEW stays in the catalog as a user-pickable import name; its computed
// farbschlag is intentionally 'REW' per Julian's extended rule.
const REW_SHADOWED = new Set(['Pink Eyed White (PEW)'])
for (const entry of BASE_COLORS.slice(0, FROZEN_COUNT)) { for (const entry of BASE_COLORS.slice(0, FROZEN_COUNT)) {
if (REW_SHADOWED.has(entry.name)) continue
expect(genotypeToFarbschlag(representativeGenotype(entry))).toBe(entry.name) expect(genotypeToFarbschlag(representativeGenotype(entry))).toBe(entry.name)
} }
}) })
@@ -224,11 +220,12 @@ describe('Farbschlag catalog', () => {
it('CATALOG seed view mirrors the ColorVariety table shape', () => { it('CATALOG seed view mirrors the ColorVariety table shape', () => {
expect(CATALOG).toHaveLength(CATALOG_SIZE) expect(CATALOG).toHaveLength(CATALOG_SIZE)
expect(CATALOG[0]).toMatchObject({ name: 'Pink Eyed White (PEW)', sortOrder: 0 }) expect(CATALOG[0]).toMatchObject({ name: 'REW', sortOrder: 0 })
// Every row has a non-empty canonical genotype display string and unique name. // Every row has a non-empty canonical genotype display string and unique name.
// GEN-3h: bracket notation (e[f], c[chm], c[h]) allowed in tokens. // GEN-3h: bracket notation (e[f], c[chm], c[h]) allowed in tokens.
// GEN-4d: rere omitted from display -> 7 tokens (no Rex, no Sls), 8 (Rex or Sls), 9 (both).
expect(new Set(CATALOG.map((c) => c.name)).size).toBe(CATALOG.length) expect(new Set(CATALOG.map((c) => c.name)).size).toBe(CATALOG.length)
expect(CATALOG.every((c) => /^[A-Za-z[\]?-]+( [A-Za-z[\]?-]+){7}$/.test(c.canonicalGenotype))).toBe(true) expect(CATALOG.every((c) => /^[A-Za-z[\]?-]+( [A-Za-z[\]?-]+){6,8}$/.test(c.canonicalGenotype))).toBe(true)
}) })
it('genotypeToFarbschlag (DATA-1 denormalization contract) returns the plain name', () => { it('genotypeToFarbschlag (DATA-1 denormalization contract) returns the plain name', () => {
@@ -289,7 +286,7 @@ describe('GEN-3a: Uw=G alias', () => {
it('always RENDERS G, never Uw (breeder preference)', () => { it('always RENDERS G, never Uw (breeder preference)', () => {
// Uw/uw is an input/import alias only; output must echo G/g. // Uw/uw is an input/import alias only; output must echo G/g.
expect(toDisplayString(fromDisplayString('AA CC DD EE Uwuw PP spsp rere'))).toBe( expect(toDisplayString(fromDisplayString('AA CC DD EE Uwuw PP spsp rere'))).toBe(
'AA CC DD EE Gg PP spsp rere', 'AA CC DD EE Gg PP spsp',
) )
expect(toDisplayString(fromDisplayString('AA CC DD EE uwuw PP spsp rere'))).not.toContain('uw') expect(toDisplayString(fromDisplayString('AA CC DD EE uwuw PP spsp rere'))).not.toContain('uw')
}) })
@@ -301,10 +298,16 @@ describe('GEN-3a: second spotting locus Sls (WP)', () => {
expect(fromDisplayString('AA CC DD EE GG PP spsp rere WP').Sls).toEqual(['Sl', 'sl']) expect(fromDisplayString('AA CC DD EE GG PP spsp rere WP').Sls).toEqual(['Sl', 'sl'])
}) })
it('toDisplayString omits wild-type Sls but shows Slsl', () => { it('toDisplayString omits wild-type Sls and Re, shows Slsl/Rere when non-wildtype', () => {
expect(toDisplayString(wildType())).toBe('AA CC DD EE GG PP spsp rere') // GEN-4d: Re (rere) omitted at wildtype, like Sls.
expect(toDisplayString(wildType())).toBe('AA CC DD EE GG PP spsp')
// Rex het → Rere shown
expect(toDisplayString(fromDisplayString('AA CC DD EE GG PP spsp Rere'))).toBe(
'AA CC DD EE GG PP spsp Rere',
)
// WP → Slsl shown, rere still omitted
expect(toDisplayString(fromDisplayString('AA CC DD EE GG PP spsp rere WP'))).toBe( expect(toDisplayString(fromDisplayString('AA CC DD EE GG PP spsp rere WP'))).toBe(
'AA CC DD EE GG PP spsp rere Slsl', 'AA CC DD EE GG PP spsp Slsl',
) )
}) })
@@ -334,7 +337,7 @@ describe('GEN-3a: second spotting locus Sls (WP)', () => {
describe('GEN-3a: flag/metadata tokens tolerated', () => { describe('GEN-3a: flag/metadata tokens tolerated', () => {
it('dea/taub/Dea/DP/WFNZ/RV/GV do not break parsing (stripped)', () => { it('dea/taub/Dea/DP/WFNZ/RV/GV do not break parsing (stripped)', () => {
const g = fromDisplayString('AA CC DD EE GG PP spsp rere dea WFNZ DP RV GV') const g = fromDisplayString('AA CC DD EE GG PP spsp rere dea WFNZ DP RV GV')
expect(toDisplayString(g)).toBe('AA CC DD EE GG PP spsp rere') expect(toDisplayString(g)).toBe('AA CC DD EE GG PP spsp')
}) })
it('extractGenotypeFlags reads deafness + tags', () => { it('extractGenotypeFlags reads deafness + tags', () => {
@@ -367,11 +370,11 @@ describe("GEN-3c: unknown allele displays as '-' (stored as '?')", () => {
it("accepts '-' input, stores '?', displays '-'", () => { it("accepts '-' input, stores '?', displays '-'", () => {
const g = fromDisplayString('Aa C- DD EE GG Pp spsp rere') const g = fromDisplayString('Aa C- DD EE GG Pp spsp rere')
expect(g.C).toEqual(['C', '?']) // stored internal contract stays '?' expect(g.C).toEqual(['C', '?']) // stored internal contract stays '?'
expect(toDisplayString(g)).toBe('Aa C- DD EE GG Pp spsp rere') // displayed as '-' expect(toDisplayString(g)).toBe('Aa C- DD EE GG Pp spsp') // displayed as '-'
}) })
it("'?' and '-' inputs are equivalent", () => { it("'?' and '-' inputs are equivalent", () => {
expect(toDisplayString(fromDisplayString('Aa C? DD EE GG Pp spsp rere'))).toBe( expect(toDisplayString(fromDisplayString('Aa C? DD EE GG Pp spsp rere'))).toBe(
'Aa C- DD EE GG Pp spsp rere', 'Aa C- DD EE GG Pp spsp',
) )
}) })
}) })
@@ -396,10 +399,10 @@ describe('GEN-3c: no Unbekannt when the E locus is known (family fallback)', ()
describe('GEN-3d: dominance tiebreak for unknown loci', () => { describe('GEN-3d: dominance tiebreak for unknown loci', () => {
it('unknown-C reads as full-colour, NOT a c^h/c^chm white', () => { it('unknown-C reads as full-colour, NOT a c^h/c^chm white', () => {
// 'aa C- DD EE GG PP' -> Schwarz (dominant C reading), never PEW/Hermelin/Himalaya. // 'aa C- DD EE GG PP' -> Schwarz (dominant C reading), never REW/Hermelin/Himalaya.
const name = genotypeToFarbschlag(fromDisplayString('aa C- DD EE GG PP spsp rere')) const name = genotypeToFarbschlag(fromDisplayString('aa C- DD EE GG PP spsp rere'))
expect(name).toBe('Schwarz') expect(name).toBe('Schwarz')
expect(['Pink Eyed White (PEW)', 'Hermelin', 'Himalaya']).not.toContain(name) expect(['REW', 'Hermelin', 'Himalaya']).not.toContain(name)
}) })
it('unknown second marker allele defaults UNMARKED, not Schecke (marker-aware)', () => { it('unknown second marker allele defaults UNMARKED, not Schecke (marker-aware)', () => {
@@ -549,15 +552,16 @@ describe('GEN-4: Dilute prefix, REW, no-bare-Fuchs', () => {
}) })
it('REW: both C alleles reduced (no full C) + pp = REW — all three cases (Julian confirmed)', () => { it('REW: both C alleles reduced (no full C) + pp = REW — all three cases (Julian confirmed)', () => {
// hom cchm/cchm + pp // hom cchm/cchm + pp — A/D/E/G-independent (REW-2: Julian: "egal ob AA oder aa")
expect(name('AA cchmcchm DD EE GG pp spsp rere')).toBe('REW') // CP-Gold expect(name('AA cchmcchm DD EE GG pp spsp rere')).toBe('REW') // CP-Gold (A-)
expect(name('aa cchmcchm DD EE GG pp spsp rere')).toBe('REW') // aa: REW-2 verification
expect(name('AA cchmcchm DD ee GG pp spsp rere')).toBe('REW') // CP-Goldfuchs expect(name('AA cchmcchm DD ee GG pp spsp rere')).toBe('REW') // CP-Goldfuchs
expect(name('AA cchmcchm DD EE gg pp spsp rere')).toBe('REW') // CP-Elfenbein expect(name('AA cchmcchm DD EE gg pp spsp rere')).toBe('REW') // CP-Elfenbein
expect(name('AA cchmcchm DD ee gg pp spsp rere')).toBe('REW') // CP-Apricot expect(name('AA cchmcchm DD ee gg pp spsp rere')).toBe('REW') // CP-Apricot
expect(name('AA cchmcchm dd EE GG pp spsp rere')).toBe('REW') // CP-dd Gold expect(name('AA cchmcchm dd EE GG pp spsp rere')).toBe('REW') // CP-dd Gold
// het cchm/ch + pp (Julian: also REW) // het cchm/ch + pp (Julian: also REW)
expect(name('AA cchmch DD EE GG pp spsp rere')).toBe('REW') expect(name('AA cchmch DD EE GG pp spsp rere')).toBe('REW')
// ch/ch + pp (Julian: also REW — subsumes PEW) // ch/ch + pp (Julian: also REW; PEW renamed to REW in catalog per GEN-4c)
expect(name('AA chch DD EE GG pp spsp rere')).toBe('REW') expect(name('AA chch DD EE GG pp spsp rere')).toBe('REW')
expect(name('aa chch DD EE GG pp spsp rere')).toBe('REW') expect(name('aa chch DD EE GG pp spsp rere')).toBe('REW')
// Counterproof: full C present → NOT REW (residual pigment) // Counterproof: full C present → NOT REW (residual pigment)
@@ -593,15 +597,15 @@ describe('GEN-3h: breeder bracket-notation display + E-locus e-before-ef order',
it('ef displays as e[f], cchm as c[chm], ch as c[h]', () => { it('ef displays as e[f], cchm as c[chm], ch as c[h]', () => {
// Fuchsschimmel: E=[ef,ef] hom // Fuchsschimmel: E=[ef,ef] hom
expect(toDisplayString(fromDisplayString('AA CC DD efef GG PP spsp rere'))).toBe( expect(toDisplayString(fromDisplayString('AA CC DD efef GG PP spsp rere'))).toBe(
'AA CC DD e[f]e[f] GG PP spsp rere', 'AA CC DD e[f]e[f] GG PP spsp',
) )
// C-locus het: cchm + ch // C-locus het: cchm + ch
expect(toDisplayString(fromDisplayString('aa cchmch DD EE GG PP spsp rere'))).toBe( expect(toDisplayString(fromDisplayString('aa cchmch DD EE GG PP spsp rere'))).toBe(
'aa c[chm]c[h] DD EE GG PP spsp rere', 'aa c[chm]c[h] DD EE GG PP spsp',
) )
// C-locus hom cchm // C-locus hom cchm
expect(toDisplayString(fromDisplayString('aa cchmcchm DD EE GG PP spsp rere'))).toBe( expect(toDisplayString(fromDisplayString('aa cchmcchm DD EE GG PP spsp rere'))).toBe(
'aa c[chm]c[chm] DD EE GG PP spsp rere', 'aa c[chm]c[chm] DD EE GG PP spsp',
) )
}) })
@@ -611,24 +615,24 @@ describe('GEN-3h: breeder bracket-notation display + E-locus e-before-ef order',
// Display must swap to [e, ef] per breeder convention. // Display must swap to [e, ef] per breeder convention.
const g = fromDisplayString('aa CC DD eef Gg Pp spsp rere') const g = fromDisplayString('aa CC DD eef Gg Pp spsp rere')
expect(g.E).toEqual(['ef', 'e']) // storage order unchanged expect(g.E).toEqual(['ef', 'e']) // storage order unchanged
expect(toDisplayString(g)).toBe('aa CC DD ee[f] Gg Pp spsp rere') expect(toDisplayString(g)).toBe('aa CC DD ee[f] Gg Pp spsp')
}) })
it('E+e stays Ee (E dominant over e, no swap needed)', () => { it('E+e stays Ee (E dominant over e, no swap needed)', () => {
expect(toDisplayString(fromDisplayString('aa CC DD Ee GG PP spsp rere'))).toBe( expect(toDisplayString(fromDisplayString('aa CC DD Ee GG PP spsp rere'))).toBe(
'aa CC DD Ee GG PP spsp rere', 'aa CC DD Ee GG PP spsp',
) )
}) })
it('E+ef displays Ee[f] (E dominant stays first, ef renders as e[f])', () => { it('E+ef displays Ee[f] (E dominant stays first, ef renders as e[f])', () => {
expect(toDisplayString(fromDisplayString('aa CC DD Eef GG PP spsp rere'))).toBe( expect(toDisplayString(fromDisplayString('aa CC DD Eef GG PP spsp rere'))).toBe(
'aa CC DD Ee[f] GG PP spsp rere', 'aa CC DD Ee[f] GG PP spsp',
) )
}) })
// ── Julian oracle fixtures (HUMANQUESTION D3/D4) ───────────────────── // ── Julian oracle fixtures (HUMANQUESTION D3/D4) ─────────────────────
it('Tier C: oracle display string round-trips exactly (aa C- D- ee[f] Gg Pp spsp rere)', () => { it('Tier C: oracle display string round-trips exactly (aa C- D- ee[f] Gg Pp spsp)', () => {
const display = 'aa C- D- ee[f] Gg Pp spsp rere' const display = 'aa C- D- ee[f] Gg Pp spsp'
const g = fromDisplayString(display) const g = fromDisplayString(display)
expect(g.E).toEqual(['ef', 'e']) expect(g.E).toEqual(['ef', 'e'])
expect(g.C).toEqual(['C', '?']) expect(g.C).toEqual(['C', '?'])
@@ -636,16 +640,16 @@ describe('GEN-3h: breeder bracket-notation display + E-locus e-before-ef order',
// Farbschlag scope is outside GEN-3h; god confirmed colour is correct as-is. // Farbschlag scope is outside GEN-3h; god confirmed colour is correct as-is.
}) })
it('Zuleika oracle: aa c[chm]c[h] DD Ee Gg PP spsp rere', () => { it('Zuleika oracle: aa c[chm]c[h] DD Ee Gg PP spsp', () => {
const display = 'aa c[chm]c[h] DD Ee Gg PP spsp rere' const display = 'aa c[chm]c[h] DD Ee Gg PP spsp'
const g = fromDisplayString(display) const g = fromDisplayString(display)
expect(g.C).toEqual(['cchm', 'ch']) expect(g.C).toEqual(['cchm', 'ch'])
expect(g.E).toEqual(['E', 'e']) expect(g.E).toEqual(['E', 'e'])
expect(toDisplayString(g)).toBe(display) expect(toDisplayString(g)).toBe(display)
}) })
it('Milka oracle: aa Cc[h] dd EE Gg P- Spsp rere', () => { it('Milka oracle: aa Cc[h] dd EE Gg P- Spsp', () => {
const display = 'aa Cc[h] dd EE Gg P- Spsp rere' const display = 'aa Cc[h] dd EE Gg P- Spsp'
const g = fromDisplayString(display) const g = fromDisplayString(display)
expect(g.C).toEqual(['C', 'ch']) expect(g.C).toEqual(['C', 'ch'])
expect(g.D).toEqual(['d', 'd']) expect(g.D).toEqual(['d', 'd'])
@@ -668,7 +672,7 @@ describe('GEN-3h: breeder bracket-notation display + E-locus e-before-ef order',
it('e[-] standalone: parses as [e,?], displays e-', () => { it('e[-] standalone: parses as [e,?], displays e-', () => {
const g = fromDisplayString('aa CC DD e[-] GG PP spsp rere') const g = fromDisplayString('aa CC DD e[-] GG PP spsp rere')
expect(g.E).toEqual(['e', '?']) expect(g.E).toEqual(['e', '?'])
expect(toDisplayString(g)).toBe('aa CC DD e- GG PP spsp rere') expect(toDisplayString(g)).toBe('aa CC DD e- GG PP spsp')
}) })
it('CR-1a: Silvain oracle ee[-] parses without crash → [e,?], displays e-', () => { it('CR-1a: Silvain oracle ee[-] parses without crash → [e,?], displays e-', () => {
@@ -680,6 +684,6 @@ describe('GEN-3h: breeder bracket-notation display + E-locus e-before-ef order',
expect(g.C).toEqual(['cchm', 'cchm']) expect(g.C).toEqual(['cchm', 'cchm'])
expect(g.D).toEqual(['D', 'd']) expect(g.D).toEqual(['D', 'd'])
expect(g.Sp).toEqual(['Sp', 'sp']) expect(g.Sp).toEqual(['Sp', 'sp'])
expect(toDisplayString(g)).toBe('aa c[chm]c[chm] Dd e- Gg Pp Spsp rere') expect(toDisplayString(g)).toBe('aa c[chm]c[chm] Dd e- Gg Pp Spsp')
}) })
}) })

View File

@@ -51,7 +51,7 @@ export const BASE_COLORS: readonly FarbschlagEntry[] = [
// GEN-3a: 'Schwarzschimmel' REMOVED (breeder C5: no such variety; efef base is // GEN-3a: 'Schwarzschimmel' REMOVED (breeder C5: no such variety; efef base is
// Orangeschimmel — see the GEN-2 block below). This was an authorized exception // Orangeschimmel — see the GEN-2 block below). This was an authorized exception
// to the frozen-name rule; the ColorVariety seed drops it too. // to the frozen-name rule; the ColorVariety seed drops it too.
{ name: 'Pink Eyed White (PEW)', english: 'Pink Eyed White', tokens: { C: 'ch', P: 'p' }, image: 'rotaugen-weiss-pew-d-sep-e-sep.jpg' }, { name: 'REW', english: 'Pink Eyed White', tokens: { C: 'ch', P: 'p' }, image: 'rotaugen-weiss-pew-d-sep-e-sep.jpg' },
{ name: 'Hermelin', english: 'Dark Tailed White', tokens: { A: 'a', C: 'ch', D: 'D', P: 'P' }, image: 'hermelin.jpeg' }, { name: 'Hermelin', english: 'Dark Tailed White', tokens: { A: 'a', C: 'ch', D: 'D', P: 'P' }, image: 'hermelin.jpeg' },
{ name: 'Himalaya', english: 'Himalayan', tokens: { A: 'A', C: 'ch', D: 'D', P: 'P' }, image: 'himalaya.jpg' }, { name: 'Himalaya', english: 'Himalayan', tokens: { A: 'A', C: 'ch', D: 'D', P: 'P' }, image: 'himalaya.jpg' },
{ name: 'Zobel', english: 'Sable', tokens: { A: 'a', C: 'cchm', D: 'D', E: 'E', G: 'g', P: 'P' }, image: 'zobel.jpeg' }, { name: 'Zobel', english: 'Sable', tokens: { A: 'a', C: 'cchm', D: 'D', E: 'E', G: 'g', P: 'P' }, image: 'zobel.jpeg' },
@@ -268,7 +268,7 @@ export function farbschlagFor(g: Genotype): FarbschlagMatch {
// AND pink-eyed (pp) = REW (Rotaugenweiß), A/D/E/G-independent. // AND pink-eyed (pp) = REW (Rotaugenweiß), A/D/E/G-independent.
// cchm/cchm + pp → REW (CP varieties with pink-eye) // cchm/cchm + pp → REW (CP varieties with pink-eye)
// cchm/ch + pp → REW (het colourpoint + pink-eye) // cchm/ch + pp → REW (het colourpoint + pink-eye)
// ch/ch + pp → REW (this also subsumes the frozen 'Pink Eyed White (PEW)' entry) // ch/ch + pp → REW (Julian confirmed PEW=REW; the 'REW' catalog entry matches here)
// Counterproof: at least one full 'C' + pp → NOT REW (residual pigment remains). // Counterproof: at least one full 'C' + pp → NOT REW (residual pigment remains).
const [c0, c1] = resolvedPair(g, 'C') const [c0, c1] = resolvedPair(g, 'C')
const [p0, p1] = resolvedPair(g, 'P') const [p0, p1] = resolvedPair(g, 'P')

View File

@@ -1,6 +1,6 @@
[ [
{ {
"name": "Pink Eyed White (PEW)", "name": "REW",
"english": "Pink Eyed White", "english": "Pink Eyed White",
"canonicalGenotype": "AA chch DD EE GG pp spsp rere", "canonicalGenotype": "AA chch DD EE GG pp spsp rere",
"sortOrder": 0, "sortOrder": 0,

View File

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

View File

@@ -101,18 +101,21 @@ function displayPair(locus: LocusKey, pair: AllelePair): AllelePair {
} }
/** /**
* Compact display string, e.g. "Aa CC Dd EE GG Pp spsp rere". * Compact display string, e.g. "Aa CC Dd EE GG Pp spsp".
* The Sls locus is OMITTED when wild-type (sl/sl) so legacy 8-locus strings and * The Re locus is OMITTED when wild-type (re/re) — Julian: only show Rex when a
* the colour catalog stay byte-identical; it only appears for WP/Sls carriers * Rex allele is present (Rere/ReRe). Matches the 7-locus notation used by the
* (e.g. "… spsp rere Slsl"). Round-trips: a missing Sls re-parses to sl/sl. * breeder. The Sls locus is likewise omitted when wild-type (sl/sl) so legacy
* GEN-3c: unknown alleles are STORED as '?' but DISPLAYED as '-' (breeder * 8-locus strings and the colour catalog stay byte-identical; it only appears for
* convention) — e.g. ['C','?'] renders "C-". * WP/Sls carriers (e.g. "… spsp Slsl"). Round-trips: missing Re → re/re; missing
* GEN-3h: sub-alleles use breeder bracket notation (e[f], c[chm], c[h]); * Sls → sl/sl. GEN-3c: unknown alleles stored as '?' displayed as '-' (breeder
* E-locus display order is E > e > e[f] (e before e[f] in het pairs). * convention). GEN-3h: bracket notation (e[f], c[chm], c[h]); E-locus display
* order E > e > e[f].
*/ */
export function toDisplayString(g: Genotype): string { export function toDisplayString(g: Genotype): string {
return LOCUS_ORDER.filter( return LOCUS_ORDER.filter(
(locus) => locus !== 'Sls' || !(g.Sls[0] === 'sl' && g.Sls[1] === 'sl'), (locus) =>
!(locus === 'Re' && g.Re[0] === 're' && g.Re[1] === 're') &&
!(locus === 'Sls' && g.Sls[0] === 'sl' && g.Sls[1] === 'sl'),
) )
.map((locus) => { .map((locus) => {
const [a, b] = displayPair(locus, g[locus]) const [a, b] = displayPair(locus, g[locus])

View File

@@ -22,12 +22,14 @@ import Tree from 'react-d3-tree'
import type { CustomNodeElementProps, Point, RawNodeDatum } from 'react-d3-tree' import type { CustomNodeElementProps, Point, RawNodeDatum } from 'react-d3-tree'
import { de } from '../strings/de' import { de } from '../strings/de'
import { ApiError } from '../api/client' import { ApiError } from '../api/client'
import { listLitters } from '../api/litters'
import { listColorVarieties } from '../api/lookups' import { listColorVarieties } from '../api/lookups'
import { getInbreedingCoefficient } from '../api/pedigree' import { getInbreedingCoefficient } from '../api/pedigree'
import type { Gender, Gerbil } from '../api/types' import type { Gender, Gerbil, Litter } from '../api/types'
import { useApi } from '../hooks/useApi' import { useApi } from '../hooks/useApi'
import { formatDate, genderLabel } from '../format/labels' import { formatDate, genderLabel } from '../format/labels'
import { UNKNOWN_FARBSCHLAG, fromDisplayString, genotypeToFarbschlag } from '../genetics' import GerbilIcon from '../components/GerbilIcon'
import { UNKNOWN_FARBSCHLAG, fromDisplayString, genotypeToFarbschlag, toDisplayString } from '../genetics'
import { import {
DEFAULT_GENERATIONS, DEFAULT_GENERATIONS,
ancestorsAt, ancestorsAt,
@@ -156,6 +158,13 @@ export default function StammbaumPage() {
? `${(inbreeding.data * 100).toLocaleString('de-DE', { maximumFractionDigits: 1 })} %` ? `${(inbreeding.data * 100).toLocaleString('de-DE', { maximumFractionDigits: 1 })} %`
: t.inbreeding.unavailable : t.inbreeding.unavailable
/* ── Würfe des Wurzeltiers (STAMMBAUM-LITTERS): aktualisiert bei Umwurzeln ── */
const rootLitters = useApi(
() => listLitters({ filter: `fatherId=${id}|motherId=${id}`, orderBy: 'date desc', pageSize: 50 }),
[id],
)
const rootLitterItems = rootLitters.data?.items ?? []
/* ── react-d3-tree-Daten ── */ /* ── react-d3-tree-Daten ── */
const nodesByPath = useMemo(() => (root ? collectNodes(root) : null), [root]) const nodesByPath = useMemo(() => (root ? collectNodes(root) : null), [root])
const datum = useMemo(() => (root ? toRawNodeDatum(root, t.unknown) : null), [root, t]) const datum = useMemo(() => (root ? toRawNodeDatum(root, t.unknown) : null), [root, t])
@@ -315,27 +324,36 @@ export default function StammbaumPage() {
</button> </button>
</div> </div>
<div className="stammbaum-canvas" ref={canvasRef}> <div className="stammbaum-layout">
{view && ( {rootLitterItems.length > 0 && (
<Tree <LittersPanel litters={rootLitterItems} t={t} />
key={id}
data={datum}
renderCustomNodeElement={renderNode}
orientation="horizontal"
pathFunc="step"
translate={view.translate}
zoom={view.zoom}
scaleExtent={{ min: ZOOM_MIN, max: ZOOM_MAX }}
zoomable
draggable
collapsible={false}
nodeSize={{ x: NODE_X, y: NODE_Y }}
separation={{ siblings: 1, nonSiblings: 1 }}
onUpdate={handleTreeUpdate}
/>
)} )}
<div className="stammbaum-canvas" ref={canvasRef}>
{view && (
<Tree
key={id}
data={datum}
renderCustomNodeElement={renderNode}
orientation="horizontal"
pathFunc="step"
translate={view.translate}
zoom={view.zoom}
scaleExtent={{ min: ZOOM_MIN, max: ZOOM_MAX }}
zoomable
draggable
collapsible={false}
nodeSize={{ x: NODE_X, y: NODE_Y }}
separation={{ siblings: 1, nonSiblings: 1 }}
onUpdate={handleTreeUpdate}
/>
)}
</div>
</div> </div>
<p className="stammbaum-hint">{t.tapHint}</p> <ul className="stammbaum-hints">
<li>{t.tapHint}</li>
<li>{t.hintName}</li>
<li>{t.hintExpand}</li>
</ul>
</section> </section>
{/* Druckansicht: am Bildschirm unsichtbar, ersetzt beim Drucken alles. */} {/* Druckansicht: am Bildschirm unsichtbar, ersetzt beim Drucken alles. */}
@@ -362,7 +380,7 @@ function PedigreeCard({
const t = de.pages.stammbaum const t = de.pages.stammbaum
const g = node.gerbil const g = node.gerbil
const chip = farbschlag ? chipColorFor(farbschlag) : null const chip = farbschlag ? chipColorFor(farbschlag) : null
const year = g.dateOfBirth ? g.dateOfBirth.slice(0, 4) : null const dob = g.dateOfBirth ? formatDate(g.dateOfBirth) : null
return ( return (
<div <div
className={isRoot ? 'pedigree-card pedigree-card--root' : 'pedigree-card'} className={isRoot ? 'pedigree-card pedigree-card--root' : 'pedigree-card'}
@@ -372,7 +390,7 @@ function PedigreeCard({
> >
{/* Foto-Platzhalter — echte Fotos kommen mit FEAT-6. */} {/* Foto-Platzhalter — echte Fotos kommen mit FEAT-6. */}
<div className="pedigree-card__photo" aria-hidden="true"> <div className="pedigree-card__photo" aria-hidden="true">
🐭 <GerbilIcon size="1.6rem" />
</div> </div>
<div className="pedigree-card__body"> <div className="pedigree-card__body">
<div className="pedigree-card__name"> <div className="pedigree-card__name">
@@ -389,11 +407,12 @@ function PedigreeCard({
<span <span
className="pedigree-chip" className="pedigree-chip"
style={chip ? { background: chip.bg, color: chip.fg } : undefined} style={chip ? { background: chip.bg, color: chip.fg } : undefined}
title={farbschlag}
> >
{farbschlag} {g.genotype || farbschlag}
</span> </span>
)} )}
{year && <span className="pedigree-card__year">* {year}</span>} {dob && <span className="pedigree-card__year">* {dob}</span>}
</div> </div>
{node.expandable && ( {node.expandable && (
<button <button
@@ -413,6 +432,36 @@ function PedigreeCard({
) )
} }
/* ── Würfe-Panel (STAMMBAUM-LITTERS) ─────────────────────────────── */
function LittersPanel({
litters,
t,
}: {
litters: Litter[]
t: { littersTitle: string; littersJunge: string }
}) {
return (
<aside className="stammbaum-litters-panel" aria-label={t.littersTitle}>
<div className="stammbaum-litters-panel__title">{t.littersTitle}</div>
<ul className="stammbaum-litters-panel__list">
{litters.map((l) => (
<li key={l.id}>
<Link to={`/wuerfe/${l.id}`} className="stammbaum-litters-panel__link">
<span className="stammbaum-litters-panel__name">{l.name}</span>
{l.totalBorn != null && (
<span className="stammbaum-litters-panel__born">
{l.totalBorn} {t.littersJunge}
</span>
)}
</Link>
</li>
))}
</ul>
</aside>
)
}
function SexIcon({ gender }: { gender: Gender }) { function SexIcon({ gender }: { gender: Gender }) {
const symbol = gender === 'male' ? '♂' : gender === 'female' ? '♀' : '?' const symbol = gender === 'male' ? '♂' : gender === 'female' ? '♀' : '?'
return ( return (
@@ -508,7 +557,11 @@ function PrintCell({
</div> </div>
)} )}
{farbschlag && <div className="stammbaum-print__sub">{farbschlag}</div>} {farbschlag && <div className="stammbaum-print__sub">{farbschlag}</div>}
{g.genotype && gen <= 2 && <div className="stammbaum-print__geno">{g.genotype}</div>} {g.genotype && gen <= 2 && (
<div className="stammbaum-print__geno">
{toDisplayString(fromDisplayString(g.genotype))}
</div>
)}
</div> </div>
) )
} }

View File

@@ -128,7 +128,6 @@ export default function WurfDetailPage() {
</ul> </ul>
)} )}
<h3>{t.detail.expectedColors}</h3>
{expected ? ( {expected ? (
<BreedingResultView result={expected} title={t.detail.expectedColors} /> <BreedingResultView result={expected} title={t.detail.expectedColors} />
) : ( ) : (

View File

@@ -34,15 +34,143 @@
color: var(--color-text); color: var(--color-text);
} }
.stammbaum-hint { .stammbaum-hints {
font-size: 0.8rem; list-style: none;
color: var(--color-text-muted); padding: 0;
margin: 0.4rem 0 0; margin: 0.4rem 0 0;
display: flex;
flex-wrap: wrap;
gap: 0.1rem 1rem;
font-size: 0.78rem;
color: var(--color-text-muted);
}
/* ── Würfe-Panel + Layout (STAMMBAUM-LITTERS) ────────────────── */
.stammbaum-layout {
display: flex;
align-items: stretch;
gap: 0;
}
/* Desktop: Würfe-Panel links vom Baum. */
.stammbaum-litters-panel {
flex: none;
width: 148px;
display: flex;
flex-direction: column;
justify-content: center;
gap: 0.35rem;
padding: 0.5rem 0.75rem 0.5rem 0;
border-right: 1px solid var(--color-border);
margin-right: 0;
}
.stammbaum-litters-panel__title {
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--color-text-muted);
}
.stammbaum-litters-panel__list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.3rem;
overflow-y: auto;
max-height: calc(clamp(18rem, 62dvh, 46rem) - 3rem);
}
.stammbaum-litters-panel__link {
display: flex;
flex-direction: column;
gap: 0.1rem;
padding: 0.3rem 0.45rem;
border-radius: 0.4rem;
text-decoration: none;
color: inherit;
background: var(--color-surface);
border: 1px solid var(--color-border);
font-size: 0.8rem;
font-family: system-ui, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
}
.stammbaum-litters-panel__link:hover {
background: var(--color-accent-soft);
border-color: var(--color-accent);
color: var(--color-accent);
}
.stammbaum-litters-panel__name {
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.stammbaum-litters-panel__born {
color: var(--color-text-muted);
font-size: 0.72rem;
}
/* Mobil (≤520px): Panel als kompakter horizontaler Streifen ÜBER dem Baum. */
@media (max-width: 520px) {
.stammbaum-layout {
flex-direction: column;
}
.stammbaum-litters-panel {
width: auto;
flex-direction: row;
align-items: center;
justify-content: flex-start;
gap: 0.5rem;
padding: 0.4rem 0.5rem;
border-right: none;
border-bottom: 1px solid var(--color-border);
overflow-x: auto;
}
.stammbaum-litters-panel__title {
flex: none;
white-space: nowrap;
}
.stammbaum-litters-panel__list {
flex-direction: row;
flex-wrap: nowrap;
max-height: none;
overflow-x: auto;
overflow-y: hidden;
gap: 0.4rem;
}
.stammbaum-litters-panel__link {
flex-direction: row;
align-items: center;
gap: 0.35rem;
white-space: nowrap;
}
.stammbaum-litters-panel__born {
color: var(--color-text-muted);
}
/* Canvas behält explizite Höhe in column-Richtung. */
.stammbaum-canvas {
flex: 0 0 auto;
}
} }
/* ── Zeichenfläche ────────────────────────────────────────────── */ /* ── Zeichenfläche ────────────────────────────────────────────── */
.stammbaum-canvas { .stammbaum-canvas {
flex: 1 1 auto;
min-width: 0;
height: clamp(18rem, 62dvh, 46rem); height: clamp(18rem, 62dvh, 46rem);
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
border-radius: 0.6rem; border-radius: 0.6rem;
@@ -157,14 +285,15 @@
.pedigree-chip { .pedigree-chip {
align-self: flex-start; align-self: flex-start;
max-width: 100%; max-width: 100%;
font-size: 0.68rem; font-size: 0.62rem;
padding: 0.1rem 0.45rem; padding: 0.1rem 0.45rem;
border-radius: 1rem; border-radius: 1rem;
background: var(--color-accent-soft); background: var(--color-accent-soft);
color: var(--color-accent); color: var(--color-accent);
white-space: nowrap; white-space: normal;
word-break: break-word;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; line-height: 1.3;
} }
.pedigree-card__year { .pedigree-card__year {
@@ -175,20 +304,27 @@
.pedigree-card__expand { .pedigree-card__expand {
flex: none; flex: none;
margin-left: auto; margin-left: auto;
width: 32px; width: 40px;
height: 32px; height: 40px;
padding: 0;
border-radius: 50%; border-radius: 50%;
border: 1px solid var(--color-accent); border: none;
background: var(--color-accent-soft);
color: var(--color-accent);
font-size: 1.05rem;
line-height: 1;
cursor: pointer;
}
.pedigree-card__expand:hover {
background: var(--color-accent); background: var(--color-accent);
color: #fff; color: #fff;
font-size: 1.3rem;
line-height: 1;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 1px 5px rgb(0 0 0 / 28%);
}
.pedigree-card__expand:hover,
.pedigree-card__expand:focus-visible {
opacity: 0.82;
outline: 2px solid var(--color-accent);
outline-offset: 2px;
} }
/* ── Druckansicht (Ahnentafel als CSS-Grid, Hunde-Zertifikat-Optik) ── /* ── Druckansicht (Ahnentafel als CSS-Grid, Hunde-Zertifikat-Optik) ──

View File

@@ -13,6 +13,9 @@ export interface ChipColor {
const CHIP_COLORS: Record<string, ChipColor> = { const CHIP_COLORS: Record<string, ChipColor> = {
// Colourpoint / C-Serie // Colourpoint / C-Serie
// GEN-4c: PEW renamed to REW in catalog (Julian: PEW=REW). Both kept here for
// backward-compat during DB migration (old animals may still carry the old name).
'REW': { bg: '#f7f3ec', fg: '#8a7d6b' },
'Pink Eyed White (PEW)': { bg: '#f7f3ec', fg: '#8a7d6b' }, 'Pink Eyed White (PEW)': { bg: '#f7f3ec', fg: '#8a7d6b' },
Hermelin: { bg: '#f3ede2', fg: '#8a7d6b' }, Hermelin: { bg: '#f3ede2', fg: '#8a7d6b' },
Himalaya: { bg: '#efe6d8', fg: '#8a7d6b' }, Himalaya: { bg: '#efe6d8', fg: '#8a7d6b' },

View File

@@ -355,6 +355,12 @@ export const de = {
/** Auf Karten am Rand: weitere Vorfahren nachladen. */ /** Auf Karten am Rand: weitere Vorfahren nachladen. */
expand: 'Vorfahren laden', expand: 'Vorfahren laden',
tapHint: 'Tippe auf ein Tier, um dessen Stammbaum anzuzeigen.', tapHint: 'Tippe auf ein Tier, um dessen Stammbaum anzuzeigen.',
/** Mini-Legende unter dem Baum (STAMMBAUM-EXPAND). */
hintName: 'Namenslink: Tierakte öffnen',
hintExpand: ': weitere Vorfahren nachladen',
/** Würfe-Panel links (STAMMBAUM-LITTERS). */
littersTitle: 'Würfe',
littersJunge: 'Junge',
zoomIn: 'Vergrößern', zoomIn: 'Vergrößern',
zoomOut: 'Verkleinern', zoomOut: 'Verkleinern',
zoomFit: 'Ansicht einpassen', zoomFit: 'Ansicht einpassen',

View File

@@ -102,9 +102,9 @@
{ {
"name": "Skarlett von den Kleinen Chaoten", "name": "Skarlett von den Kleinen Chaoten",
"dob": "14.07.2013", "dob": "14.07.2013",
"decision": "death date = 17.04.2016 (the '2018' variant was wrong — it had leaked as '/ +2018' into the genotype field; parse-leak already fixed in IMPORT-POLISH)", "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": "17.04.2016", "dateOfDeath": "01.01.2017",
"source": "Julian 2026-06-07 — HUMANQUESTION D6" "source": "Julian 2026-06-07 — HUMANQUESTION D7 (final Skarlett resolution)"
}, },
{ {
"name": "Kazu von den Kleinen Chaoten", "name": "Kazu von den Kleinen Chaoten",
@@ -119,6 +119,100 @@
"decision": "death date = 12.12.2019 (confirmed; the 14.01.2020 variant was wrong)", "decision": "death date = 12.12.2019 (confirmed; the 14.01.2020 variant was wrong)",
"dateOfDeath": "12.12.2019", "dateOfDeath": "12.12.2019",
"source": "Julian 2026-06-07 — HUMANQUESTION D5/D6 (letzter D6-Konflikt)" "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)"
} }
] ]
} }