Compare commits

...

9 Commits

Author SHA1 Message Date
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
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
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
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
19 changed files with 749 additions and 129 deletions

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,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

@@ -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

@@ -21,6 +21,11 @@ KEYS_PATH=/mnt/SSD/gerbil/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

@@ -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
@@ -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
@@ -122,3 +140,10 @@ volumes:
type: none type: none
o: bind o: bind
device: "${BACKUPS_PATH:-/mnt/gerbil/backups}" device: "${BACKUPS_PATH:-/mnt/gerbil/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;
# }
# }

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

@@ -110,3 +110,30 @@ test('-Knopf ist sichtbar und lädt weitere Vorfahren nach (STAMMBAUM-EXPAND)
await expandBtn.click({ force: true }) await expandBtn.click({ force: true })
await expect(page.getByRole('link', { name: 'Max' })).toBeVisible({ timeout: 8000 }) 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()
})

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

@@ -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)
}) })
@@ -223,8 +223,9 @@ describe('Farbschlag catalog', () => {
expect(CATALOG[0]).toMatchObject({ name: 'REW', 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', () => {
@@ -285,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')
}) })
@@ -297,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',
) )
}) })
@@ -330,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', () => {
@@ -363,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',
) )
}) })
}) })
@@ -590,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',
) )
}) })
@@ -608,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', '?'])
@@ -633,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'])
@@ -665,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-', () => {
@@ -677,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

@@ -2,425 +2,425 @@
{ {
"name": "REW", "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,13 @@ 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 { UNKNOWN_FARBSCHLAG, fromDisplayString, genotypeToFarbschlag, toDisplayString } from '../genetics'
import { import {
DEFAULT_GENERATIONS, DEFAULT_GENERATIONS,
ancestorsAt, ancestorsAt,
@@ -156,6 +157,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,6 +323,10 @@ export default function StammbaumPage() {
</button> </button>
</div> </div>
<div className="stammbaum-layout">
{rootLitterItems.length > 0 && (
<LittersPanel litters={rootLitterItems} t={t} />
)}
<div className="stammbaum-canvas" ref={canvasRef}> <div className="stammbaum-canvas" ref={canvasRef}>
{view && ( {view && (
<Tree <Tree
@@ -335,6 +347,7 @@ export default function StammbaumPage() {
/> />
)} )}
</div> </div>
</div>
<ul className="stammbaum-hints"> <ul className="stammbaum-hints">
<li>{t.tapHint}</li> <li>{t.tapHint}</li>
<li>{t.hintName}</li> <li>{t.hintName}</li>
@@ -417,6 +430,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 (
@@ -512,7 +555,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

@@ -45,9 +45,132 @@
color: var(--color-text-muted); 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;

View File

@@ -358,6 +358,9 @@ export const de = {
/** Mini-Legende unter dem Baum (STAMMBAUM-EXPAND). */ /** Mini-Legende unter dem Baum (STAMMBAUM-EXPAND). */
hintName: 'Namenslink: Tierakte öffnen', hintName: 'Namenslink: Tierakte öffnen',
hintExpand: ': weitere Vorfahren nachladen', 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',