Compare commits

...

15 Commits

Author SHA1 Message Date
2befe53df9 feat(stammbaum): Desktop-Ansicht passt den ganzen Baum ein + höhere Zeichenfläche
All checks were successful
CI / Backend Tests (.NET) (push) Successful in 1m3s
CI / Frontend Tests (Node/Vite) (push) Successful in 9m35s
CI / Docker Build & Push (push) Successful in 1m30s
Auf dem Desktop wurde der Baum bei festem Zoom 1 rechts abgeschnitten (man sah
nur 1–2 Generationen, der Rest lag außerhalb) und viel Fläche blieb leer. Die
Standard-Ansicht passt jetzt den gesamten geladenen Baum ein — gedeckelt auf
Zoom 1, damit Karten bei kleinen Bäumen nicht vergrößert/unscharf werden. Mobil
bleibt es bei „Wurzel links + Pan" (sonst würden die Karten winzig). Zeichen-
fläche von 62dvh auf 72dvh erhöht (füllt die Desktop-Höhe besser).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 16:26:50 +02:00
cf672c0933 feat(provenance): Datenherkunft als chronologischer, datei-attribuierter Verlauf
Die Herkunft liest sich jetzt wie eine History: pro Feld wird genannt, aus
welcher Quelldatei der Wert kam, plus Merge-/Entscheidungs-/Wurfchronik-Schritte.
Beispiel: „In ‚X.xlsx' gefunden." → „Geburtsdatum (…) aus ‚X.xlsx'." → „Auch in
‚Y.docx' gefunden → zusammengeführt." → „Eltern über Position erkannt (Quelle …)."

Import: build_entity_provenance() um history[] erweitert; ein field_source-
Tracking im Gerbil-Dedup hält fest, welcher Datensatz/welche Datei jedes Feld
(DOB/Genotyp/Geschlecht/Farbschlag/Sterbedatum) lieferte — auch über Empty-Fill
und Mehrheitsentscheid hinweg. Kontakte und Würfe erhalten analoge, datei-
attribuierte Verläufe. Kein DB-Migration nötig (history liegt im Provenance-JSON).

Frontend: ProvenanceDialog rendert den Verlauf als nummerierte Timeline.
Tests: history wird erzeugt, nennt Dateien, überlebt Ingest (dotnet 212,
vitest 129, playwright 17, python merge/extract grün).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 16:21:24 +02:00
a31fea17fa feat(stammbaum): Kontextmenü per Long-Press auf Touch-Geräten öffnen
Das Rechtsklick-Kontextmenü (ID kopieren / Fehler melden) war auf Mobil nicht
erreichbar (onContextMenu feuert auf Touch nicht zuverlässig). Jetzt öffnet ein
langer Druck (~500 ms ohne Verschieben) auf eine Ahnenkarte dasselbe Menü; der
darauf folgende synthetische Klick (Umwurzeln) wird unterdrückt. Bewegung >10 px
bricht den Long-Press ab (kollidiert nicht mit Pan/Zoom). Legenden-Hinweis
ergänzt. e2e: Long-Press öffnet das Menü (desktop; phone-Projekt übersprungen,
da synthetischer Touch dort instabil).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 16:09:47 +02:00
438c816820 feat: Fehler-melden + Datenherkunft auf Kontakte & Würfe erweitern
- Kontakt-Detailseite: „Fehler melden"- und „Datenherkunft"-Button.
- Wurf-Ansicht: „Datenherkunft"-Button (Feedback war bereits vorhanden).
- Feedback-Entity um loses, nullable ContactId erweitert (kein FK → übersteht
  Ingest-Wipe); Migration AddFeedbackContactId.
- Contact.Provenance + Litter.Provenance (nullable text); Migration
  AddContactLitterProvenance; im Ingest gemappt und in den DTOs zurückgegeben.
- Import: build_entity_provenance() generalisiert; Kontakte (sourceFiles,
  Züchter/Abnehmer-Hinweise) und Würfe (Wurfchronik vs. Diagramm-rekonstruiert,
  Geschwister-Merge) erhalten Herkunftsdaten in resolved_import.json.
- Frontend: ProvenanceDialog generalisiert (EntityProvenance + entityLabel).

Tests erweitert (Ingest-Round-trip Kontakt/Wurf, contact-scoped Feedback
übersteht Wipe). dotnet(212)/vitest(129)/playwright(36)/tsc/eslint grün.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 16:02:11 +02:00
9c98c37d2a feat(rennmausakte): Datenherkunft/Nachverfolgung pro Rennmaus
Neuer „Datenherkunft"-Button in der Rennmausakte öffnet einen Dialog, der zeigt,
aus welchen Quellen der Eintrag erzeugt wurde: Quelldateien (Stammbäume +
Wurfchronik), Anzahl zusammengeführter Datensätze, Eltern-Herleitung
(Methode/Konfidenz) und Hinweise (z. B. „per manueller Entscheidung zugeordnet",
„Konflikt gelöst", „aus N Datensätzen zusammengeführt").

Import: build_provenance() in merge_and_resolve.py sammelt die Herkunft über alle
deduplizierten Datensätze und schreibt sie als Provenance-JSON je Tier in
resolved_import.json. Backend: Gerbil.Provenance (nullable text) + Migration
AddGerbilProvenance, gemappt im Ingest und im GerbilDto zurückgegeben. Frontend:
ProvenanceDialog + Typen + Strings.

Tests: Ingest-Round-trip (vorhanden/abwesend), e2e provenance.spec.ts.
dotnet(212)/vitest(129)/playwright/tsc/eslint grün.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 15:45:15 +02:00
b9e5b031c4 feat(feedback): Fehler-melden-Dialog + ID-kopieren (Stammbaum/Akte/Wurf)
Rechtsklick auf eine Maus im Stammbaum öffnet ein Kontextmenü mit „ID kopieren"
(Clipboard + Toast) und „Fehler melden". Zusätzlich „Fehler melden"-Buttons in
der Rennmausakte und der Wurf-Ansicht. Der Dialog erfasst eine Beschreibung und
sendet sie samt Debug-Kontext (Ansicht, Tier-/Wurf-ID + Name, URL, Zeitstempel,
User-Agent) an POST /feedback; gespeichert in einer neuen Feedback-Tabelle.

Backend: Feedback-Entity (lose nullable GerbilId/LitterId ohne FK), Endpoints
POST/GET /feedback, EF-Migration AddFeedback. Die Tabelle wird vom Import-Ingest
NICHT geleert — Feedback überlebt Re-Ingests (Test deckt das ab).

Tests: FeedbackEndpointTests (persistiert, 400 bei leer, übersteht Ingest-Wipe);
e2e feedback.spec.ts. tsc/eslint/vitest(129)/playwright(6)/dotnet(212) grün.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 15:30:42 +02:00
1845d37476 fix(import): Eltern-Ref-Auswahl geschlechts-bewusst machen (Regression behoben)
Die in 03ad9e6 eingeführte DOB-Bevorzugung war zu grob: ein datierter, aber
geschlechts-falscher Ref (z. B. weibliche „Danielle") gewann den Vater-Slot
gegen einen undatieren männlichen/unbekannten (z. B. „Hagrid Rubeus") — wodurch
Mollys korrekte Mutter Arya Stark in der Rollen-Normalisierung verloren ging.

pick_parent_ref wertet jetzt zuerst das (aus den Tierdaten ermittelte) Geschlecht
für die Rolle: ein klar falsches Geschlecht wird stark abgewertet, erst danach
zählt plausibles DOB > kein DOB > unplausibles DOB. Fixt Molly (Hagrid × Arya)
UND behält Solice (Lui × Molly = Geschwister von Silvain). Keine Abdeckungs-
Regression (0 Selbst-Verpaarungen / 0 Gender-Fehler / 0 Alter-Verletzungen).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 15:11:53 +02:00
2868aad369 feat(stammbaum): gleiches Geburtsdatum der Eltern als Geschwister-Indiz werten
Ein identisches Geburtsdatum bei Vater und Mutter ist ein starkes Indiz für eine
Vollgeschwister-Verpaarung — ein gemeinsamer Wurf ist praktisch der einzige
Grund dafür, und die Eltern-Würfe sind in den importierten Daten nicht immer
verknüpft (gleiche litterId fehlt dann).

Die Geschwister-Erkennung (Diagramm-Zusammenführung in build.ts + Akte-Chip in
GerbilDetailPage) greift jetzt bei gleicher litterId ODER gleichem, gesetztem
Geburtsdatum beider Eltern (distinkte Tiere vorausgesetzt).

Tests: gleiches DOB bei fehlender/verschiedener litterId → erkannt; unterschied-
liches DOB → nicht erkannt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 15:02:19 +02:00
03ad9e6e81 fix(import): Eltern-Alter-Sanity-Check + plausiblere Eltern-Auswahl aus Chart-Refs
Eine Rennmaus lebt max ~6 Jahre, kann also nicht Elternteil eines Tiers sein,
das nach ihrem Tod (oder vor ihrer Geburt) geboren wurde. Bisher fehlte diese
Prüfung: z. B. war Jayjay (*2013) als Vater von Solice (*2022) eingetragen, und
Solice/Silvain (Vollgeschwister, beide *27.03.2022) landeten in getrennten
Würfen mit verschiedenen Eltern.

- parent_age_plausible(): Elternteil muss vor dem Kind UND innerhalb der
  Lebensspanne (≤6 J.) geboren sein. Genutzt im Namens-Resolver (Kandidaten-
  filter) und als finaler Sanity-Pass, der unmögliche FatherId/MotherId verwirft
  und im Log auflistet.
- pick_parent_ref(): bei mehreren widersprüchlichen Chart-parentRefs wird nicht
  mehr blind der erste genommen, sondern ein alters-plausibler bevorzugt und das
  Tier der jeweils anderen Elternrolle gemieden (Vollgeschwister-Charts nennen
  denselben Namen in beiden Slots).

Ergebnis: Solice = Lui × Molly = Geschwister von Silvain (gemeinsamer Wurf);
0 Selbst-Verpaarungen, 0 Gender-Rollen-Fehler, 0 Eltern-Alter-Verletzungen über
alle 977 Würfe. Tests in test_merge_resolve.py ergänzt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 14:59:34 +02:00
18efab6996 fix(import): Foto-Generations-Shift beim Stammbaum-Import beheben
Fotos liegen in den Stammbaum-xlsx je nach Datei links, rechts oder auf der
Namenszelle. Die alte Seiten-Erkennung hing an festen Spalten (col 1 / col 4):
Blätter ohne solche Anker wurden als Rechts-Layout fehlgedeutet, wodurch jedes
Foto eine Generation zum Probanden hin verrutschte. Folge: Kazuya trug das Foto
seines Vaters (Wilbur), Wilbur das seines Großvaters (Elay), Elay hatte keins.

Fix (extract._attach_photos): beide Interpretationen (links/rechts) durchrechnen
und die wählen, die jedes Foto am nächsten an die Namensspalte seines Tiers legt
(minimale horizontale Fehlausrichtung). Diagnose über alle 42 Dateien: keine ist
echt rechts-seitig; ~21 waren fehlklassifiziert. Selbstjustierend statt fester
Spalten-Annahme.

Regressionstest in test_extract.py: Kazuya ohne Foto, Wilbur/Elay mit ihren
eigenen (gated auf vorhandene Quelldatei).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 14:49:38 +02:00
070a896072 feat(rennmausakte): Hinweis-Chip für Geschwisterverpaarung in der Tierakte
Stammt ein Tier aus einer Vollgeschwister-Verpaarung (Vater und Mutter teilen
dieselbe litterId), zeigt die Rennmausakte jetzt einen Chip
"⚭ Geschwisterverpaarung" in der Fakten-Zeile — neben Geschlecht, Geburtsdatum
und Farbschlag, in Akzentfarbe hervorgehoben (zuchtrelevant).

e2e-Test: Chip erscheint für 'Inzucht Kind' (Eltern aus demselben Wurf),
nicht für 'Krümel' (Eltern aus verschiedenen Würfen).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 14:32:36 +02:00
3715e32303 fix(stammbaum): Geschwister-Verpaarung erkennen und im Diagramm zusammenführen
Vollgeschwister-Verpaarung (Vater & Mutter aus demselben Wurf) ist in der
Gerbil-Zucht häufig. Bisher zeigten Diagramm und Import sie nicht korrekt.

Import (merge_and_resolve.py):
- Wurf-Dedup gehärtet: asymmetrischer Merge nur bei kompatiblen Eltern-Namen;
  bei Date=None nur mit positivem Namens-Match (verhindert blindes Verschmelzen
  unverwandter datumloser Stubs). Falsch-Merges 112 -> 70.
- Parent-Resolver robust: Gender ist Präferenz statt hartem Filter, sodass
  vertauschte Eltern (z. B. weibliches Tier in der Vater-Position) trotzdem
  auflösen. Aufgelöste Eltern 200/206 -> 240/260.
- Neue Rollen-Normalisierung: weist jede Maus rollenrichtig nach Geschlecht zu,
  entfernt Selbst-Verpaarungen und unmögliche Doppelrollen.
  Selbst-Verpaarungen 16 -> 0, Gender-Rollen-Fehler 30 -> 0.
- Reine Helfer (litter_compatible/assign_parent_roles/names_*) auf Modulebene
  extrahiert und in test_merge_resolve.py (26 Tests) abgesichert.

Frontend (pedigree):
- buildAnimal markiert den Mutter-Knoten, wenn beide Eltern dieselbe litterId
  teilen (Vollgeschwister). toRawNodeDatum führt dessen Vorfahren-Ast zu einem
  Verweis-Knoten zusammen ("Geschwister von <Vater>, Eltern siehe oben"); die
  Vaterlinie zeigt die gemeinsamen Großeltern einmal.
- Druck-Ahnentafel bleibt vollständig (ignoriert die Marke).
- Build-Tests (+3) und e2e-Test (Inzucht-Kind-Fixture) ergänzt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 14:02:45 +02:00
cb2ee03a14 fix: Naho Eltern + Foto-Layout-Erkennung + Eltern-Fallback-Lookup
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 1m2s
CI / Frontend Tests (Node/Vite) (push) Successful in 9m34s
CI / Docker Build & Push (push) Failing after 9s
2026-06-21 22:55:50 +02:00
41ff973f9a feat(import): merge animals with different DOBs if their parent names match
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 1m2s
CI / Frontend Tests (Node/Vite) (push) Successful in 9m35s
CI / Docker Build & Push (push) Failing after 12s
2026-06-21 22:23:59 +02:00
d2993418b6 fix(import): remap Kazuya DOB and correct parents using Stammbaum von Kazuya.xlsx
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 1m4s
CI / Docker Build & Push (push) Has been cancelled
CI / Frontend Tests (Node/Vite) (push) Has been cancelled
2026-06-21 22:18:04 +02:00
53 changed files with 9775 additions and 111 deletions

View File

@@ -0,0 +1,181 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using GerbilManagerWebAPI.Import;
using GerbilManagerWebAPI.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
namespace GerbilManager.Tests;
/// <summary>
/// FEEDBACK: the "Fehler melden" report sink.
/// - POST /feedback persists a row (with captured debug context) and GET /feedback returns it.
/// - Validation: an empty message is rejected with 400.
/// - CRITICAL: feedback rows survive the import re-ingest wipe (loose, FK-free GerbilId/LitterId).
/// </summary>
public class FeedbackEndpointTests : IClassFixture<ApiFactory>
{
private readonly ApiFactory _factory;
public FeedbackEndpointTests(ApiFactory factory) => _factory = factory;
[Fact]
public async Task Post_feedback_persists_row_with_debug_context()
{
var client = _factory.CreateClient();
var gerbilId = Guid.NewGuid();
var resp = await client.PostAsJsonAsync("/feedback", new
{
message = "Der Stammbaum zeigt den falschen Vater.",
context = "stammbaum",
gerbilId,
litterId = (Guid?)null,
entityName = "Krümel",
url = "http://localhost:5173/rennmaeuse/kruemel/stammbaum",
clientTimestamp = "2026-06-22T12:00:00Z",
});
Assert.Equal(HttpStatusCode.Created, resp.StatusCode);
var created = JsonDocument.Parse(await resp.Content.ReadAsStringAsync()).RootElement;
Assert.False(string.IsNullOrEmpty(created.GetProperty("id").GetString()));
Assert.Equal("stammbaum", created.GetProperty("context").GetString());
Assert.Equal("Krümel", created.GetProperty("entityName").GetString());
Assert.Equal(gerbilId.ToString(), created.GetProperty("gerbilId").GetString());
// GET returns it (newest first)
var listed = JsonDocument.Parse(await client.GetStringAsync("/feedback")).RootElement;
Assert.Contains(listed.EnumerateArray(),
f => f.GetProperty("entityName").GetString() == "Krümel"
&& f.GetProperty("message").GetString() == "Der Stammbaum zeigt den falschen Vater.");
}
[Fact]
public async Task Post_feedback_rejects_empty_message()
{
var client = _factory.CreateClient();
var resp = await client.PostAsJsonAsync("/feedback", new { message = " ", context = "gerbil-detail" });
Assert.Equal(HttpStatusCode.BadRequest, resp.StatusCode);
}
[Fact]
public async Task Feedback_survives_ingest_wipe()
{
// Fresh in-memory DB seeded with a resolved import file (mirrors IngestResolvedServiceTests).
var dir = Path.Combine(Path.GetTempPath(), "feedback-ingest-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
try
{
var contactId = Guid.NewGuid();
var fatherId = Guid.NewGuid();
var motherId = Guid.NewGuid();
var litterId = Guid.NewGuid();
var data = new
{
Contacts = new[]
{
new { Id = contactId, Name = "Test Breeder", Email = "t@e.de", Phone = "", Address = "", Notes = (string?)null, IsBreeder = true, IsReceiver = false, NameSuffix = (string?)null, Provenance = (string?)null }
},
Litters = new[]
{
new { Id = litterId, Name = "Wurf A", Date = "2026-01-01", TotalBorn = 5, DeathsWithin8Weeks = 0, FatherId = fatherId, MotherId = motherId, ExpectedGoHomeDate = (string?)null, Notes = "", PairingCode = "PC01", ExternalRef = "ext-litter-1", LitterLetter = "A" }
},
Gerbils = new[]
{
Animal(fatherId, "Papa", "male", contactId),
Animal(motherId, "Mama", "female", contactId),
},
GerbilPhotos = Array.Empty<object>(),
};
File.WriteAllText(Path.Combine(dir, "resolved_import.json"), JsonSerializer.Serialize(data));
var opts = new DbContextOptionsBuilder<ApplicationContext>()
.UseInMemoryDatabase("feedback-ingest-" + Guid.NewGuid().ToString("N"))
.Options;
using var db = new ApplicationContext(opts);
db.Database.EnsureCreated();
// A feedback report referencing the gerbil + litter that the wipe will delete.
var feedbackId = Guid.NewGuid();
db.Feedback.Add(new Feedback
{
Id = feedbackId,
Message = "Bitte prüfen.",
Context = "gerbil-detail",
GerbilId = fatherId,
LitterId = litterId,
EntityName = "Papa",
Url = "http://localhost/rennmaeuse/papa",
CreatedAt = DateTimeOffset.UtcNow,
});
// A contact-scoped feedback report — the ContactId is a loose (FK-free) id,
// so it must survive the contact-table wipe just like gerbil/litter ids.
var contactFeedbackId = Guid.NewGuid();
db.Feedback.Add(new Feedback
{
Id = contactFeedbackId,
Message = "Adresse stimmt nicht.",
Context = "contact-detail",
ContactId = contactId,
EntityName = "Test Breeder",
Url = "http://localhost/kontakte/test-breeder",
CreatedAt = DateTimeOffset.UtcNow,
});
await db.SaveChangesAsync();
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?> { { "Import:SourcePath", dir } })
.Build();
// Run the ingest wipe + reload.
var result = await new IngestResolvedService(db, config, null!).RunAsync();
Assert.Contains("Ingestion successful!", result);
// Gerbils/litters/contacts were wiped & re-created, but feedback is untouched.
var survivor = await db.Feedback.SingleAsync(f => f.Id == feedbackId);
Assert.Equal(fatherId, survivor.GerbilId); // loose id preserved even though the gerbil row was deleted/recreated
Assert.Equal(litterId, survivor.LitterId);
Assert.Equal("Papa", survivor.EntityName);
// The contact-scoped report also survives the contacts wipe (loose ContactId).
var contactSurvivor = await db.Feedback.SingleAsync(f => f.Id == contactFeedbackId);
Assert.Equal(contactId, contactSurvivor.ContactId);
Assert.Equal("contact-detail", contactSurvivor.Context);
Assert.Equal("Test Breeder", contactSurvivor.EntityName);
Assert.Equal(2, await db.Feedback.CountAsync());
}
finally
{
try { Directory.Delete(dir, recursive: true); } catch { /* best effort */ }
}
}
private static object Animal(Guid id, string name, string gender, Guid contactId) => new
{
Id = id,
Name = name,
Gender = gender,
Status = "Breeding",
LitterId = (Guid?)null,
OriginContactId = contactId,
ReceiverContactId = (Guid?)null,
EnclosureId = (Guid?)null,
ColorVarietyId = new Guid("00000000-0000-0000-0000-000000000006"),
DateOfBirth = "2025-01-01",
DateOfDeath = (string?)null,
CauseOfDeath = (string?)null,
GoHomeDate = (string?)null,
Genotype = "aa CC DD EE GG PP spsp rere",
Notes = "",
ImportSource = "docx-export",
ExternalRef = "ext-" + name,
RawImportData = "{}",
OriginBreeder = "Test Zucht",
NameSearch = name.ToLowerInvariant(),
CharacterTraits = Array.Empty<string>(),
CharacterNote = (string?)null,
IsDeaf = false,
IsResident = true,
};
}

View File

@@ -36,7 +36,8 @@ namespace GerbilManager.Tests
Email = "test@example.com",
Homepage = "",
Phone = "",
Address = ""
Address = "",
Provenance = (string?)"{\"sourceFiles\":[\"Stammbaum\"],\"mergedRecordCount\":1,\"notes\":[\"als Züchter erkannt\"]}"
}
},
Litters = new[]
@@ -54,7 +55,8 @@ namespace GerbilManager.Tests
Notes = "Test litter notes",
PairingCode = "PC01",
ExternalRef = "ext-litter-1",
LitterLetter = "A"
LitterLetter = "A",
Provenance = (string?)"{\"sourceFiles\":[\"Wurfchronik-Detail.docx\"],\"mergedRecordCount\":1,\"fromWurfchronik\":true,\"notes\":[\"aus Wurfchronik\"]}"
}
},
Gerbils = new[]
@@ -84,7 +86,8 @@ namespace GerbilManager.Tests
CharacterTraits = new string[] {},
CharacterNote = (string?)null,
IsDeaf = false,
IsResident = true
IsResident = true,
Provenance = (string?)"{\"sourceFiles\":[\"Stammbaum von Papa.xlsx\",\"Wurfchronik-Detail.docx\"],\"mergedRecordCount\":2,\"fromWurfchronik\":true,\"notes\":[\"aus 2 Datensätzen zusammengeführt\"],\"history\":[\"In \\u201eStammbaum von Papa.xlsx\\u201c gefunden.\",\"Genotyp aus \\u201eWurfchronik-Detail.docx\\u201c.\"]}"
},
new
{
@@ -111,7 +114,8 @@ namespace GerbilManager.Tests
CharacterTraits = new string[] {},
CharacterNote = (string?)null,
IsDeaf = false,
IsResident = true
IsResident = true,
Provenance = (string?)null
}
},
GerbilPhotos = new[]
@@ -176,6 +180,25 @@ namespace GerbilManager.Tests
Assert.Equal(mother.Id, litter.MotherId);
Assert.Equal(father.Id, photo.GerbilId);
Assert.Equal(father.OriginContactId, mother.OriginContactId);
// Provenance round-trips verbatim through ingest (and stays null when absent).
Assert.NotNull(father.Provenance);
Assert.Contains("Stammbaum von Papa.xlsx", father.Provenance);
Assert.Contains("mergedRecordCount", father.Provenance);
// The chronological, file-attributed history survives ingest verbatim
// (stored as opaque JSON on the text column — no schema for it).
Assert.Contains("history", father.Provenance);
Assert.Contains("Genotyp aus", father.Provenance);
Assert.Null(mother.Provenance);
// Contact + litter provenance also round-trips through the ingest.
var contact = await db.Contacts.SingleAsync();
Assert.NotNull(contact.Provenance);
Assert.Contains("als Züchter erkannt", contact.Provenance);
Assert.NotNull(litter.Provenance);
Assert.Contains("aus Wurfchronik", litter.Provenance);
Assert.Contains("fromWurfchronik", litter.Provenance);
}
}
}

View File

@@ -24,6 +24,7 @@ public class ApplicationContext : DbContext
public DbSet<Media> Media => Set<Media>();
public DbSet<Request> Requests => Set<Request>();
public DbSet<MailSettings> MailSettings => Set<MailSettings>();
public DbSet<Feedback> Feedback => Set<Feedback>();
// Keep Gerbil.NameSearch in sync on every save (separator-insensitive search key),
// so it can never drift from Name regardless of which code path mutates the entity.
@@ -202,6 +203,15 @@ public class ApplicationContext : DbContext
modelBuilder.Entity<MailSettings>()
.HasData(new MailSettings { Id = GerbilManagerWebAPI.Models.MailSettings.SingletonId });
// FEEDBACK: deliberately relationship-free. GerbilId/LitterId are plain nullable
// Guid columns (no navigation properties → EF creates NO foreign key), so the
// import re-ingest wipe of Gerbils/Litters never cascades into — or breaks —
// feedback rows. They survive re-ingest, which is the whole point.
modelBuilder.Entity<Feedback>(e =>
{
e.HasIndex(f => f.CreatedAt);
});
// DB-4: German collation on remaining searched/sorted text columns (Npgsql-only).
if (isNpgsql)
{

View File

@@ -32,7 +32,8 @@ namespace GerbilManagerWebAPI.Dtos
bool? IsDeaf,
bool IsResident,
string? ProfilePhotoUrl,
bool IsCastrated);
bool IsCastrated,
string? Provenance);
public record LitterDto(
Guid Id,
@@ -43,9 +44,10 @@ namespace GerbilManagerWebAPI.Dtos
Guid? FatherId,
Guid? MotherId,
DateOnly? ExpectedGoHomeDate,
string? Notes);
string? Notes,
string? Provenance);
public record ContactDto(Guid Id, string Name, string? Email, string? Phone, string? Address, string? Notes, bool IsBreeder, bool IsReceiver, string? NameSuffix);
public record ContactDto(Guid Id, string Name, string? Email, string? Phone, string? Address, string? Notes, bool IsBreeder, bool IsReceiver, string? NameSuffix, string? Provenance);
public record EnclosureDto(Guid Id, string Name, string? Notes);

View File

@@ -0,0 +1,27 @@
namespace GerbilManagerWebAPI.Dtos
{
/// <summary>FEEDBACK: payload for POST /feedback (the "Fehler melden" dialog).</summary>
public record FeedbackInput(
string Message,
string Context,
Guid? GerbilId,
Guid? LitterId,
Guid? ContactId,
string? EntityName,
string? Url,
DateTimeOffset? ClientTimestamp);
/// <summary>FEEDBACK: response DTO for a stored report.</summary>
public record FeedbackDto(
Guid Id,
string Message,
string Context,
Guid? GerbilId,
Guid? LitterId,
Guid? ContactId,
string? EntityName,
string? Url,
DateTimeOffset? ClientTimestamp,
string? UserAgent,
DateTimeOffset CreatedAt);
}

View File

@@ -55,6 +55,6 @@ namespace GerbilManagerWebAPI.Endpoints
return app;
}
private static ContactDto ToDto(Contact c) => new(c.Id, c.Name, c.Email, c.Phone, c.Address, c.Notes, c.IsBreeder, c.IsReceiver, c.NameSuffix);
private static ContactDto ToDto(Contact c) => new(c.Id, c.Name, c.Email, c.Phone, c.Address, c.Notes, c.IsBreeder, c.IsReceiver, c.NameSuffix, c.Provenance);
}
}

View File

@@ -0,0 +1,63 @@
using GerbilManagerWebAPI.Dtos;
using GerbilManagerWebAPI.Models;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.EntityFrameworkCore;
namespace GerbilManagerWebAPI.Endpoints
{
/// <summary>
/// FEEDBACK: the "Fehler melden" report sink.
/// POST /feedback -> persist a user bug report (with captured debug context), returns 201.
/// GET /feedback -> list reports, newest first (for later review).
/// Feedback is decoupled from gerbils/litters (loose nullable Guid columns, no FK), so
/// rows survive the import re-ingest wipe.
/// </summary>
public static class FeedbackEndpoints
{
public static IEndpointRouteBuilder MapFeedbackEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/feedback").WithTags("Feedback");
group.MapPost("/", async Task<Results<Created<FeedbackDto>, BadRequest<string>>> (
FeedbackInput input, ApplicationContext db, HttpContext http) =>
{
if (string.IsNullOrWhiteSpace(input.Message))
return TypedResults.BadRequest("Message darf nicht leer sein.");
var entity = new Feedback
{
Id = Guid.NewGuid(),
Message = input.Message.Trim(),
Context = string.IsNullOrWhiteSpace(input.Context) ? "unknown" : input.Context.Trim(),
GerbilId = input.GerbilId,
LitterId = input.LitterId,
ContactId = input.ContactId,
EntityName = input.EntityName,
Url = input.Url,
ClientTimestamp = input.ClientTimestamp,
UserAgent = http.Request.Headers.UserAgent.ToString() is { Length: > 0 } ua ? ua : null,
CreatedAt = DateTimeOffset.UtcNow,
};
db.Feedback.Add(entity);
await db.SaveChangesAsync();
return TypedResults.Created($"/feedback/{entity.Id}", ToDto(entity));
});
group.MapGet("/", async (ApplicationContext db) =>
{
// Order in memory: SQLite (test host) cannot ORDER BY a DateTimeOffset column.
var rows = await db.Feedback.AsNoTracking().ToListAsync();
return TypedResults.Ok(rows
.OrderByDescending(f => f.CreatedAt)
.Select(ToDto)
.ToList());
});
return app;
}
private static FeedbackDto ToDto(Feedback f) =>
new(f.Id, f.Message, f.Context, f.GerbilId, f.LitterId, f.ContactId, f.EntityName, f.Url,
f.ClientTimestamp, f.UserAgent, f.CreatedAt);
}
}

View File

@@ -137,6 +137,7 @@ namespace GerbilManagerWebAPI.Endpoints
g.Id, g.Name, g.Gender, g.Status, g.LitterId, g.OriginContactId, g.ReceiverContactId,
g.EnclosureId, g.ColorVarietyId, g.DateOfBirth, g.DateOfDeath, g.CauseOfDeath,
g.GoHomeDate, g.Genotype, g.SpottingType, g.Notes, g.ImportSource, g.ExternalRef, g.OriginBreeder,
g.CharacterTraits, g.CharacterNote, g.IsDeaf, g.IsResident, profilePhotoUrl, g.IsCastrated);
g.CharacterTraits, g.CharacterNote, g.IsDeaf, g.IsResident, profilePhotoUrl, g.IsCastrated,
g.Provenance);
}
}

View File

@@ -98,7 +98,7 @@ namespace GerbilManagerWebAPI.Endpoints
private static LitterDto ToDto(Litter l) => new(
l.Id, l.Name, l.Date, l.TotalBorn, l.DeathsWithin8Weeks,
l.FatherId, l.MotherId, l.ExpectedGoHomeDate, l.Notes);
l.FatherId, l.MotherId, l.ExpectedGoHomeDate, l.Notes, l.Provenance);
}
/// <summary>400 body for a father×mother gender mismatch; frontend localises by Code.</summary>

View File

@@ -103,6 +103,8 @@ namespace GerbilManagerWebAPI.Import
existingContact.Notes = c.Notes;
existingContact.IsBreeder = c.IsBreeder;
existingContact.IsReceiver = c.IsReceiver;
existingContact.NameSuffix = c.NameSuffix;
existingContact.Provenance = c.Provenance;
contactsUpdated++;
}
else if (addedContactIds.Add(c.Id))
@@ -130,7 +132,8 @@ namespace GerbilManagerWebAPI.Import
Notes = l.Notes,
PairingCode = l.PairingCode,
ExternalRef = l.ExternalRef,
LitterLetter = l.LitterLetter
LitterLetter = l.LitterLetter,
Provenance = l.Provenance
});
}
_db.Litters.AddRange(littersToInsert);
@@ -160,6 +163,7 @@ namespace GerbilManagerWebAPI.Import
ImportSource = g.ImportSource,
ExternalRef = g.ExternalRef,
RawImportData = g.RawImportData,
Provenance = g.Provenance,
OriginBreeder = g.OriginBreeder,
NameSearch = g.NameSearch,
CharacterTraits = g.CharacterTraits,

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,47 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace GerbilManagerWebAPI.Migrations
{
/// <inheritdoc />
public partial class AddFeedback : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Feedback",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
Message = table.Column<string>(type: "text", nullable: false),
Context = table.Column<string>(type: "text", nullable: false),
GerbilId = table.Column<Guid>(type: "uuid", nullable: true),
LitterId = table.Column<Guid>(type: "uuid", nullable: true),
EntityName = table.Column<string>(type: "text", nullable: true),
Url = table.Column<string>(type: "text", nullable: true),
ClientTimestamp = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
UserAgent = table.Column<string>(type: "text", nullable: true),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Feedback", x => x.Id);
});
migrationBuilder.CreateIndex(
name: "IX_Feedback_CreatedAt",
table: "Feedback",
column: "CreatedAt");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "Feedback");
}
}
}

File diff suppressed because it is too large Load Diff

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,29 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace GerbilManagerWebAPI.Migrations
{
/// <inheritdoc />
public partial class AddFeedbackContactId : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<Guid>(
name: "ContactId",
table: "Feedback",
type: "uuid",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "ContactId",
table: "Feedback");
}
}
}

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -741,6 +741,9 @@ namespace GerbilManagerWebAPI.Migrations
b.Property<string>("Phone")
.HasColumnType("text");
b.Property<string>("Provenance")
.HasColumnType("text");
b.HasKey("Id");
b.ToTable("Contacts");
@@ -793,6 +796,51 @@ namespace GerbilManagerWebAPI.Migrations
b.ToTable("EnclosurePhotos");
});
modelBuilder.Entity("GerbilManagerWebAPI.Models.Feedback", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset?>("ClientTimestamp")
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("ContactId")
.HasColumnType("uuid");
b.Property<string>("Context")
.IsRequired()
.HasColumnType("text");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("EntityName")
.HasColumnType("text");
b.Property<Guid?>("GerbilId")
.HasColumnType("uuid");
b.Property<Guid?>("LitterId")
.HasColumnType("uuid");
b.Property<string>("Message")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Url")
.HasColumnType("text");
b.Property<string>("UserAgent")
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("CreatedAt");
b.ToTable("Feedback");
});
modelBuilder.Entity("GerbilManagerWebAPI.Models.Gerbil", b =>
{
b.Property<Guid>("Id")
@@ -870,6 +918,9 @@ namespace GerbilManagerWebAPI.Migrations
b.Property<Guid?>("OriginContactId")
.HasColumnType("uuid");
b.Property<string>("Provenance")
.HasColumnType("text");
b.Property<string>("RawImportData")
.HasColumnType("text");
@@ -1001,6 +1052,9 @@ namespace GerbilManagerWebAPI.Migrations
b.Property<string>("PairingCode")
.HasColumnType("text");
b.Property<string>("Provenance")
.HasColumnType("text");
b.Property<int?>("TotalBorn")
.HasColumnType("integer");

View File

@@ -24,5 +24,11 @@ namespace GerbilManagerWebAPI.Models
/// „von den Wüstenwinden“. Für Tiere fremder Züchter pflegbar.
/// </summary>
public string? NameSuffix { get; set; }
/// <summary>Data-provenance / traceability for the import: a JSON object describing
/// WHICH source information produced this contact (sourceFiles, mergedRecordCount,
/// notes). Written by the Python merge_and_resolve step and surfaced read-only
/// ("Datenherkunft"). Null = manually-added contact / no import data.</summary>
public string? Provenance { get; set; }
}
}

View File

@@ -0,0 +1,47 @@
using System.ComponentModel.DataAnnotations;
namespace GerbilManagerWebAPI.Models
{
/// <summary>
/// FEEDBACK: a user-submitted "Fehler melden" report. Decoupled from the rest of the
/// model on purpose — GerbilId/LitterId are plain nullable Guid columns (NOT enforced
/// foreign keys), so the import re-ingest wipe (IngestResolvedService) can delete
/// gerbils/litters without deleting or breaking feedback rows. The captured EntityName
/// keeps the report human-readable even after the referenced animal is gone.
/// </summary>
public class Feedback
{
[Key]
public Guid Id { get; set; }
/// <summary>The user's free-text description of the problem.</summary>
public required string Message { get; set; }
/// <summary>Which view the report came from: stammbaum | gerbil-detail | litter-detail.</summary>
public required string Context { get; set; }
/// <summary>Loose reference (no FK) to the gerbil the report is about, if any.</summary>
public Guid? GerbilId { get; set; }
/// <summary>Loose reference (no FK) to the litter the report is about, if any.</summary>
public Guid? LitterId { get; set; }
/// <summary>Loose reference (no FK) to the contact the report is about, if any.</summary>
public Guid? ContactId { get; set; }
/// <summary>Captured name of the referenced animal/litter (survives an ingest wipe).</summary>
public string? EntityName { get; set; }
/// <summary>The client URL/route the report was filed from.</summary>
public string? Url { get; set; }
/// <summary>Client-supplied timestamp (when the user submitted, in their browser).</summary>
public DateTimeOffset? ClientTimestamp { get; set; }
/// <summary>Optional browser user-agent for diagnostics.</summary>
public string? UserAgent { get; set; }
/// <summary>Server-side creation time.</summary>
public DateTimeOffset CreatedAt { get; set; }
}
}

View File

@@ -49,6 +49,13 @@ namespace GerbilManagerWebAPI.Models
public string? ImportSource { get; set; }
public string? ExternalRef { get; set; }
/// <summary>Data-provenance / traceability for the import: a JSON object describing
/// WHICH source information produced this entry (sourceFiles, mergedRecordCount,
/// fromWurfchronik, parentMethod/parentConfidence, notes). Written by the Python
/// merge_and_resolve step and surfaced read-only in the Rennmausakte
/// ("Nachverfolgungsinformationen"). Null = manually-added animal / no import data.</summary>
public string? Provenance { get; set; }
/// <summary>Raw import payload preserved verbatim (rawGenotype + unmappedTokens like
/// the Uw locus / WFNZ markers) so nothing from the spreadsheets is lost. JSON text.</summary>
public string? RawImportData { get; set; }

View File

@@ -38,5 +38,12 @@ namespace GerbilManagerWebAPI.Models
/// <summary>FEAT-NAMEGEN: Wurfbuchstabe (A, B, C … AA, AB …) — alle Welpen dieses
/// Wurfs erhalten Namen mit diesem Anfangsbuchstaben (gängige Zuchtkonvention).</summary>
public string? LitterLetter { get; set; }
/// <summary>Data-provenance / traceability for the import: a JSON object describing
/// WHICH source information produced this litter (sourceFiles, mergedRecordCount,
/// fromWurfchronik, notes — e.g. "aus Wurfchronik", "aus Stammbaum-Diagramm rekonstruiert").
/// Written by the Python merge_and_resolve step and surfaced read-only ("Datenherkunft").
/// Null = manually-added litter / no import data.</summary>
public string? Provenance { get; set; }
}
}

View File

@@ -126,6 +126,7 @@ app.MapExportEndpoints();
app.MapCmsEndpoints();
app.MapRequestEndpoints();
app.MapNamesEndpoints();
app.MapFeedbackEndpoints();
app.Run();

View File

@@ -0,0 +1,132 @@
/** FEEDBACK: "Fehler melden" Dialog + Stammbaum-Rechtsklick ("ID kopieren" / "Fehler melden"). */
import { de, expect, skipUnlessMock, test } from './fixtures'
const f = de.feedback
const st = de.pages.stammbaum
test('Tierakte: "Fehler melden"-Button öffnet den Dialog, Senden zeigt Erfolg', async ({ page, mockDb }) => {
skipUnlessMock()
await page.goto('/rennmaeuse/kruemel')
// Button in den Aktionen öffnet den Dialog.
await page.getByRole('button', { name: f.button }).click()
const dialog = page.getByRole('dialog', { name: f.dialogTitle })
await expect(dialog).toBeVisible()
// Debug-Kontext ist sichtbar (automatisch erfasst).
await expect(dialog).toContainText(f.debugTitle)
await expect(dialog).toContainText(f.contexts['gerbil-detail'])
// Beschreibung eintragen + senden.
await dialog.getByLabel(f.label).fill('Der Farbschlag stimmt nicht.')
await dialog.getByRole('button', { name: f.submit }).click()
// Erfolgs-Toast + Dialog schließt.
await expect(page.getByText(f.success)).toBeVisible()
await expect(dialog).not.toBeVisible()
// Der Bericht wurde mit Debug-Kontext gespeichert.
expect(mockDb).not.toBeNull()
const reports = mockDb!.feedback
expect(reports.length).toBe(1)
expect(reports[0]).toMatchObject({
message: 'Der Farbschlag stimmt nicht.',
context: 'gerbil-detail',
gerbilId: 'kruemel',
})
expect(reports[0].url).toContain('/rennmaeuse/kruemel')
expect(typeof reports[0].clientTimestamp).toBe('string')
})
test('Wurf-Ansicht: "Fehler melden"-Button öffnet den Dialog und sendet mit Wurf-Kontext', async ({ page, mockDb }) => {
skipUnlessMock()
await page.goto('/wuerfe/w-kruemel')
await page.getByRole('button', { name: f.button }).click()
const dialog = page.getByRole('dialog', { name: f.dialogTitle })
await expect(dialog).toBeVisible()
await expect(dialog).toContainText(f.contexts['litter-detail'])
await dialog.getByLabel(f.label).fill('Die Wurfstärke ist falsch.')
await dialog.getByRole('button', { name: f.submit }).click()
await expect(page.getByText(f.success)).toBeVisible()
const reports = mockDb!.feedback
expect(reports.length).toBe(1)
expect(reports[0]).toMatchObject({ context: 'litter-detail', litterId: 'w-kruemel' })
})
test('Kontakt-Detail: "Fehler melden"-Button sendet mit Kontakt-Kontext', async ({ page, mockDb }) => {
skipUnlessMock()
await page.goto('/kontakte/con-meier')
await page.getByRole('button', { name: f.button }).click()
const dialog = page.getByRole('dialog', { name: f.dialogTitle })
await expect(dialog).toBeVisible()
await expect(dialog).toContainText(f.contexts['contact-detail'])
await dialog.getByLabel(f.label).fill('Die Adresse stimmt nicht.')
await dialog.getByRole('button', { name: f.submit }).click()
await expect(page.getByText(f.success)).toBeVisible()
const reports = mockDb!.feedback
expect(reports.length).toBe(1)
expect(reports[0]).toMatchObject({ context: 'contact-detail', contactId: 'con-meier' })
})
test('Stammbaum: Rechtsklick auf eine Karte zeigt "ID kopieren" + "Fehler melden"', async ({ page, mockDb }) => {
skipUnlessMock()
await page.goto('/rennmaeuse/kruemel/stammbaum')
await expect(page.locator('.pedigree-card').first()).toBeVisible()
// Rechtsklick auf die Wurzelkarte (Krümel).
await page.locator('.pedigree-card--root').click({ button: 'right' })
const menu = page.locator('.stammbaum-context-menu')
await expect(menu).toBeVisible()
await expect(menu.getByRole('menuitem', { name: st.contextMenu.copyId })).toBeVisible()
await expect(menu.getByRole('menuitem', { name: st.contextMenu.reportError })).toBeVisible()
// "Fehler melden" öffnet den Dialog mit Stammbaum-Kontext.
await menu.getByRole('menuitem', { name: st.contextMenu.reportError }).click()
const dialog = page.getByRole('dialog', { name: f.dialogTitle })
await expect(dialog).toBeVisible()
await expect(dialog).toContainText(f.contexts.stammbaum)
await dialog.getByLabel(f.label).fill('Stammbaum-Bug.')
await dialog.getByRole('button', { name: f.submit }).click()
await expect(page.getByText(f.success)).toBeVisible()
const reports = mockDb!.feedback
expect(reports.length).toBe(1)
expect(reports[0]).toMatchObject({ context: 'stammbaum', gerbilId: 'kruemel' })
})
test('Stammbaum: langer Druck (Long-Press) öffnet dasselbe Kontextmenü', async ({ page }, testInfo) => {
skipUnlessMock()
// Der Long-Press-Code-Pfad (onTouchStart → Timer → Menü) ist projekt-unabhängig
// identisch; im Touch-emulierten phone-Projekt ist das Auslösen eines
// synthetischen TouchEvents unzuverlässig, daher hier auf das desktop-Projekt
// beschränkt (validiert dieselbe Logik).
testInfo.skip(testInfo.project.name === 'phone', 'synthetischer Long-Press im phone-Projekt instabil')
await page.goto('/rennmaeuse/kruemel/stammbaum')
const card = page.locator('.pedigree-card--root')
await expect(card).toBeVisible()
// Long-Press simulieren: echtes touchstart (mit Touch-Objekt) auslösen; der
// Timer im PedigreeCard öffnet nach ~500 ms das Menü.
const dispatched = await card.evaluate((el) => {
if (typeof Touch === 'undefined' || typeof TouchEvent === 'undefined') return false
const r = el.getBoundingClientRect()
const t = new Touch({ identifier: 1, target: el, clientX: r.left + 10, clientY: r.top + 10 })
el.dispatchEvent(
new TouchEvent('touchstart', { bubbles: true, cancelable: true, touches: [t], targetTouches: [t], changedTouches: [t] }),
)
return true
})
test.skip(!dispatched, 'TouchEvent in diesem Projekt nicht verfügbar')
await expect(page.locator('.stammbaum-context-menu')).toBeVisible({ timeout: 2000 })
await expect(
page.locator('.stammbaum-context-menu').getByRole('menuitem', { name: st.contextMenu.copyId }),
).toBeVisible()
})

View File

@@ -397,6 +397,25 @@ export async function installMockApi(page: Page): Promise<MockDb> {
return json(route, 200, result)
}
// FEEDBACK: "Fehler melden" — POST persistiert, GET listet (neueste zuerst).
if (path === '/feedback') {
if (method === 'POST') {
const body = request.postDataJSON() as Row
const created = {
id: newId('feedback'),
...body,
userAgent: request.headers()['user-agent'] ?? null,
createdAt: new Date().toISOString(),
}
db.feedback.push(created)
return json(route, 201, created)
}
if (method === 'GET') {
return json(route, 200, [...db.feedback].reverse())
}
return json(route, 405)
}
// Generische Kollektionen: /<resource> und /<resource>/<id>
m = path.match(/^\/([a-z-]+)(?:\/([^/]+))?$/)
const col = m ? collections[m[1]] : undefined

View File

@@ -76,6 +76,8 @@ export interface MockDb {
saleAdConfigured: boolean
// FEAT-NAMEGEN: Namensvorschläge — false = 503 NamesKeyMissing simulieren
namesConfigured: boolean
// FEEDBACK: "Fehler melden" — gesammelte Berichte (POST /feedback)
feedback: Record<string, unknown>[]
}
function gerbil(
@@ -120,6 +122,23 @@ export function seedDb(): MockDb {
enclosureId: 'enc-gross',
originContactId: 'con-meier',
originBreeder: 'Clan-Kleine-Chaoten',
// NACHVERFOLGUNG: Datenherkunft (vom Import erzeugter JSON-String).
provenance: JSON.stringify({
sourceFiles: ['Stammbaum von Krümel.xlsx', 'Wurfchronik-Detail.docx'],
mergedRecordCount: 2,
fromWurfchronik: true,
parentMethod: 'chart-position',
parentConfidence: 'medium',
notes: ['aus 2 Datensätzen zusammengeführt'],
history: [
'In „Stammbaum von Krümel.xlsx“ gefunden.',
'Geburtsdatum (12.03.2025) aus „Stammbaum von Krümel.xlsx“.',
'Genotyp aus „Stammbaum von Krümel.xlsx“.',
'Auch in „Wurfchronik-Detail.docx“ gefunden → Datensätze zusammengeführt.',
'Eltern über Position im Stammbaum erkannt (Quelle: „Stammbaum von Krümel.xlsx“).',
'Angaben aus der Wurfchronik übernommen.',
],
}),
},
{ ...gerbil('fridolin', 'Fridolin', 'male', '2023-05-01', 'w-fridolin', 'cv-schwarz', 'aa CC DD EE GG PP spsp rere'), enclosureId: 'enc-gross', originBreeder: 'Zoohandlung Meier', profilePhotoUrl: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==' },
gerbil('luna', 'Luna', 'female', '2023-08-15', 'w-luna', 'cv-gold', 'AA CC DD EE GG pp spsp rere'),
@@ -171,10 +190,34 @@ export function seedDb(): MockDb {
},
// UI-POLISH-1: Import-Stub ohne Namen — testet den '(ohne Namen)'-Platzhalter in Liste + Detail.
gerbil('nameless-stub', '', 'male', '2023-01-01', null, null),
// SIBLING-PAIRING: Vollgeschwister-Verpaarung (in der Zucht häufig). Beide
// Eltern von 'inzucht-kind' stammen aus DEMSELBEN Wurf 'w-zwillinge' → das
// Diagramm führt ihren Vorfahren-Ast zu einem Verweis-Knoten zusammen.
gerbil('zwilling-bock', 'Zwilling Bock', 'male', '2020-05-01', 'w-zwillinge', 'cv-agouti'),
gerbil('zwilling-maus', 'Zwilling Maus', 'female', '2020-05-01', 'w-zwillinge', 'cv-schwarz'),
{ ...gerbil('opa-w', 'Opa W', 'male', '2018-01-01', null, 'cv-agouti'), isResident: false },
{ ...gerbil('oma-u', 'Oma U', 'female', '2018-02-01', null, 'cv-gold'), isResident: false },
gerbil('inzucht-kind', 'Inzucht Kind', 'female', '2021-06-01', 'w-inzucht', 'cv-agouti'),
]
const litters: Litter[] = [
{ id: 'w-kruemel', name: 'Wurf K', date: '2025-03-12', totalBorn: 5, expectedGoHomeDate: '2025-04-16', notes: null, fatherId: 'fridolin', motherId: 'luna', deathsWithin8Weeks: 1 },
{ id: 'w-zwillinge', name: 'Wurf Z', date: '2020-05-01', totalBorn: 4, expectedGoHomeDate: null, notes: null, fatherId: 'opa-w', motherId: 'oma-u' },
{ id: 'w-inzucht', name: 'Wurf I', date: '2021-06-01', totalBorn: 3, expectedGoHomeDate: null, notes: null, fatherId: 'zwilling-bock', motherId: 'zwilling-maus' },
{
id: 'w-kruemel', name: 'Wurf K', date: '2025-03-12', totalBorn: 5, expectedGoHomeDate: '2025-04-16', notes: null, fatherId: 'fridolin', motherId: 'luna', deathsWithin8Weeks: 1,
// NACHVERFOLGUNG: Datenherkunft des Wurfs (vom Import erzeugter JSON-String).
provenance: JSON.stringify({
sourceFiles: ['Wurfchronik Teil 1_page_0009.md', 'Wurfchronik Teil 1_page_0028.md'],
mergedRecordCount: 2,
fromWurfchronik: true,
notes: ['aus Wurfchronik', 'aus 2 Datensätzen zusammengeführt', 'Geschwister-Würfe zusammengeführt'],
history: [
'Wurf aus Wurfchronik „Wurfchronik Teil 1_page_0009.md“.',
'Auch in „Wurfchronik Teil 1_page_0028.md“ gefunden → Datensätze zusammengeführt.',
'Geschwister-Würfe zusammengeführt.',
],
}),
},
{ id: 'w-fridolin', name: 'Wurf F', date: '2023-05-01', totalBorn: 4, expectedGoHomeDate: null, notes: null, fatherId: 'balu', motherId: 'maja' },
{ id: 'w-luna', name: 'Wurf L', date: '2023-08-15', totalBorn: 6, expectedGoHomeDate: null, notes: null, fatherId: 'karlsson', motherId: 'smilla' },
{ id: 'w-balu', name: 'Wurf B', date: '2021-04-20', totalBorn: 3, expectedGoHomeDate: null, notes: null, fatherId: 'anton', motherId: 'greta' },
@@ -191,7 +234,21 @@ export function seedDb(): MockDb {
// FEAT-13: contactInfo (Freitext) wurde durch strukturierte Felder ersetzt.
const contacts: Contact[] = [
{ id: 'con-meier', name: 'Zoohandlung Meier', email: 'meier@example.de', phone: null, address: 'Hauptstraße 1, 12345 Musterstadt', notes: null, isBreeder: true, isReceiver: true },
{
id: 'con-meier', name: 'Zoohandlung Meier', email: 'meier@example.de', phone: null, address: 'Hauptstraße 1, 12345 Musterstadt', notes: null, isBreeder: true, isReceiver: true,
// NACHVERFOLGUNG: Datenherkunft des Kontakts (vom Import erzeugter JSON-String).
provenance: JSON.stringify({
sourceFiles: ['Wurfchronik Teil 1_page_0001.md', 'Stammbaum von Krümel.xlsx'],
mergedRecordCount: 2,
fromWurfchronik: true,
notes: ['aus 2 Datensätzen zusammengeführt', 'als Züchter erkannt', 'als Abnehmer erkannt'],
history: [
'In „Stammbaum von Krümel.xlsx“ als Züchter erkannt.',
'Auch in „Wurfchronik Teil 1_page_0001.md“ gefunden → Datensätze zusammengeführt.',
'Sowohl als Züchter als auch als Abnehmer geführt.',
],
}),
},
{ id: 'con-huber', name: 'Familie Huber', email: null, phone: '0151 2345678', address: null, notes: null, isBreeder: false, isReceiver: true },
{ id: 'con-frei', name: 'Züchterin Frei', email: null, phone: null, address: null, notes: 'unverknüpft', isBreeder: true, isReceiver: false },
{ id: 'con-neither', name: 'Weder Noch', email: null, phone: null, address: null, notes: 'weder züchter noch abnehmer', isBreeder: false, isReceiver: false },
@@ -314,5 +371,6 @@ export function seedDb(): MockDb {
contracts: [],
saleAdConfigured: true,
namesConfigured: true,
feedback: [],
}
}

View File

@@ -0,0 +1,101 @@
/** NACHVERFOLGUNG: "Datenherkunft"-Button in der Tierakte öffnet den
* Nachverfolgungs-Dialog mit Quelldateien, Datensatzanzahl und Hinweisen. */
import { de, expect, skipUnlessMock, test } from './fixtures'
const p = de.provenance
test('Tierakte: "Datenherkunft"-Button öffnet den Nachverfolgungs-Dialog', async ({ page }) => {
skipUnlessMock()
await page.goto('/rennmaeuse/kruemel')
// Button in den Aktionen öffnet den Dialog.
await page.getByRole('button', { name: p.button }).click()
const dialog = page.getByRole('dialog', { name: p.dialogTitle })
await expect(dialog).toBeVisible()
// VERLAUF: chronologischer, dateibezogener Verlauf als Primärinhalt.
await expect(dialog).toContainText(p.historyTitle)
await expect(dialog).toContainText('In „Stammbaum von Krümel.xlsx“ gefunden.')
await expect(dialog).toContainText('Geburtsdatum (12.03.2025) aus „Stammbaum von Krümel.xlsx“.')
await expect(dialog).toContainText(
'Auch in „Wurfchronik-Detail.docx“ gefunden → Datensätze zusammengeführt.',
)
// Quelldateien werden angezeigt.
await expect(dialog).toContainText(p.sourceFilesTitle)
await expect(dialog).toContainText('Stammbaum von Krümel.xlsx')
await expect(dialog).toContainText('Wurfchronik-Detail.docx')
// Zusammenführungs-Info + Wurfchronik-Hinweis.
await expect(dialog).toContainText(p.mergedCount(2))
await expect(dialog).toContainText(p.fromWurfchronik)
// Eltern-Herkunft (übersetzte Methode/Konfidenz).
await expect(dialog).toContainText(p.methods['chart-position'])
await expect(dialog).toContainText(p.confidences.medium)
// Hinweis aus dem Import.
await expect(dialog).toContainText('aus 2 Datensätzen zusammengeführt')
// Schließen (Footer-Button — Header-Button trägt dasselbe aria-label).
await dialog.locator('.provenance__actions').getByRole('button', { name: p.close }).click()
await expect(dialog).not.toBeVisible()
})
test('Tierakte: Tier ohne Importdaten zeigt "Keine Herkunftsdaten"', async ({ page }) => {
skipUnlessMock()
// Luna hat keine provenance im Mock → Leer-Zustand.
await page.goto('/rennmaeuse/luna')
await page.getByRole('button', { name: p.button }).click()
const dialog = page.getByRole('dialog', { name: p.dialogTitle })
await expect(dialog).toBeVisible()
await expect(dialog).toContainText(p.empty)
})
test('Kontakt: "Datenherkunft"-Button öffnet den Nachverfolgungs-Dialog', async ({ page }) => {
skipUnlessMock()
await page.goto('/kontakte/con-meier')
await page.getByRole('button', { name: p.button }).click()
const dialog = page.getByRole('dialog', { name: p.dialogTitle })
await expect(dialog).toBeVisible()
// VERLAUF: dateibezogene Schritte des Kontakts.
await expect(dialog).toContainText(p.historyTitle)
await expect(dialog).toContainText('In „Stammbaum von Krümel.xlsx“ als Züchter erkannt.')
// Quelldateien + Zusammenführung + Kontakt-Rolle-Hinweise.
await expect(dialog).toContainText(p.sourceFilesTitle)
await expect(dialog).toContainText('Wurfchronik Teil 1_page_0001.md')
await expect(dialog).toContainText(p.mergedCount(2))
await expect(dialog).toContainText(p.fromWurfchronik)
await expect(dialog).toContainText('als Züchter erkannt')
await expect(dialog).toContainText('als Abnehmer erkannt')
await dialog.locator('.provenance__actions').getByRole('button', { name: p.close }).click()
await expect(dialog).not.toBeVisible()
})
test('Wurf: "Datenherkunft"-Button öffnet den Nachverfolgungs-Dialog', async ({ page }) => {
skipUnlessMock()
await page.goto('/wuerfe/w-kruemel')
await page.getByRole('button', { name: p.button }).click()
const dialog = page.getByRole('dialog', { name: p.dialogTitle })
await expect(dialog).toBeVisible()
// VERLAUF: dateibezogene Schritte des Wurfs.
await expect(dialog).toContainText(p.historyTitle)
await expect(dialog).toContainText('Wurf aus Wurfchronik „Wurfchronik Teil 1_page_0009.md“.')
await expect(dialog).toContainText(p.sourceFilesTitle)
await expect(dialog).toContainText('Wurfchronik Teil 1_page_0009.md')
await expect(dialog).toContainText(p.mergedCount(2))
await expect(dialog).toContainText(p.fromWurfchronik)
await expect(dialog).toContainText('aus Wurfchronik')
await expect(dialog).toContainText('Geschwister-Würfe zusammengeführt')
await dialog.locator('.provenance__actions').getByRole('button', { name: p.close }).click()
await expect(dialog).not.toBeVisible()
})

View File

@@ -111,6 +111,33 @@ test('-Knopf ist sichtbar und lädt weitere Vorfahren nach (STAMMBAUM-EXPAND)
await expect(page.getByRole('link', { name: 'Max' })).toBeVisible({ timeout: 8000 })
})
test('Geschwister-Verpaarung führt den Vorfahren-Ast zu einem Verweis-Knoten zusammen (SIBLING-PAIRING)', async ({ page }) => {
skipUnlessMock()
// 'Inzucht Kind': Eltern Zwilling Bock (♂) + Zwilling Maus (♀) stammen aus
// demselben Wurf 'w-zwillinge' (Opa W × Oma U) → Vollgeschwister.
await page.goto('/rennmaeuse/inzucht-kind/stammbaum')
await expect(page.locator('.pedigree-card').first()).toBeVisible()
const fit = page.getByRole('button', { name: t.zoomFit })
if (await fit.isVisible()) await fit.click()
await page.waitForTimeout(600)
// Vaterlinie zeigt die echten Großeltern (einmal). Der Vater-Knoten trägt den
// Namen als Link (die Verweis-Karte nur als Span — daher Link-Rolle eindeutig).
await expect(page.getByRole('link', { name: 'Zwilling Bock' })).toBeVisible()
await expect(page.locator('.pedigree-card').filter({ hasText: 'Opa W' })).toBeVisible()
await expect(page.locator('.pedigree-card').filter({ hasText: 'Oma U' })).toBeVisible()
// Mutter-Ast ist zu EINEM Verweis-Knoten zusammengeführt (Verweis auf den Vater).
const ref = page.locator('.pedigree-card--sibling')
await expect(ref).toBeVisible()
await expect(ref).toContainText(t.siblingPairing.refLabel('Zwilling Bock'))
await expect(ref).toContainText(t.siblingPairing.refHint)
// Mini-Legende ergänzt den Geschwister-Hinweis.
await expect(page.locator('.stammbaum-hints')).toContainText(t.hintSiblings)
})
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.

View File

@@ -104,6 +104,20 @@ test('Detailseite: Wurf-Feld ist ein Link zur Wurf-Detailseite (WURF-LINK)', asy
await expect(page.getByRole('heading', { name: 'Wurf K' })).toBeVisible()
})
test('Detailseite zeigt Geschwisterverpaarungs-Hinweis nur wenn die Eltern Vollgeschwister sind (SIBLING-PAIRING)', async ({ page }) => {
skipUnlessMock()
// 'Inzucht Kind': Eltern Zwilling Bock + Zwilling Maus aus demselben Wurf
// 'w-zwillinge' → Vollgeschwister → Hinweis sichtbar.
await page.goto('/rennmaeuse/inzucht-kind')
await expect(page.getByRole('heading', { name: 'Inzucht Kind' })).toBeVisible()
await expect(page.locator('.ak-fact--sibling')).toContainText(t.detail.siblingPairing)
// 'Krümel': Eltern Fridolin × Luna aus verschiedenen Würfen → kein Hinweis.
await page.goto('/rennmaeuse/kruemel')
await expect(page.getByRole('heading', { name: 'Krümel' })).toBeVisible()
await expect(page.locator('.ak-fact--sibling')).toHaveCount(0)
})
test('Detailseite: Herkunft zeigt originBreeder als Text wenn kein Kontakt (WURF-LINK-Addendum)', async ({ page }) => {
skipUnlessMock()
// Fridolin hat originContactId=null + originBreeder='Zoohandlung Meier' → plain text, kein Link

View File

@@ -0,0 +1,37 @@
/** FEEDBACK: API client for the "Fehler melden" report sink (POST /feedback). */
import { api } from './client'
const RESOURCE = '/feedback'
/** Which view a report was filed from (matches the backend Context contract). */
export type FeedbackContext = 'stammbaum' | 'gerbil-detail' | 'litter-detail' | 'contact-detail'
/** Payload for POST /feedback. Debug fields are captured automatically by the caller. */
export interface FeedbackInput {
message: string
context: FeedbackContext
gerbilId?: string | null
litterId?: string | null
contactId?: string | null
entityName?: string | null
url?: string | null
clientTimestamp?: string | null
}
export interface Feedback {
id: string
message: string
context: string
gerbilId: string | null
litterId: string | null
contactId: string | null
entityName: string | null
url: string | null
clientTimestamp: string | null
userAgent: string | null
createdAt: string
}
export function submitFeedback(body: FeedbackInput): Promise<Feedback> {
return api.post<Feedback>(RESOURCE, body)
}

View File

@@ -62,8 +62,48 @@ export interface Gerbil {
profilePhotoUrl?: string | null
spottingType?: string | null
isCastrated?: boolean
/**
* NACHVERFOLGUNG: Datenherkunft des Import-Eintrags als JSON-String (vom
* Python-Merge erzeugt; siehe {@link GerbilProvenance} für die geparste Form).
* null = manuell angelegtes Tier / keine Importdaten.
*/
provenance?: string | null
}
/**
* NACHVERFOLGUNG: geparste Datenherkunft eines importierten Tiers (JSON aus
* {@link Gerbil.provenance}). Beschreibt, WELCHE Quellinformationen den
* Datenbankeintrag erzeugt haben.
*/
export interface GerbilProvenance {
/** Alle Quelldateien (Stammbäume / Wurfchronik), die beigetragen haben. */
sourceFiles: string[]
/** Anzahl der Rohdatensätze, die zu diesem Tier zusammengeführt wurden. */
mergedRecordCount: number
/** Hat ein Wurfchronik-Treffer beigetragen? */
fromWurfchronik: boolean
/** Wie die Eltern abgeleitet wurden (z. B. "chart-position", "decision"). */
parentMethod?: string
/** Konfidenz der Eltern-Ableitung (z. B. "low" | "medium" | "high"). */
parentConfidence?: string
/** Menschlich lesbare Herkunftshinweise (deutsch). */
notes: string[]
/**
* Chronologischer, dateibezogener Verlauf (deutsch): liest sich wie ein
* Protokoll, WELCHE Quelldatei WELCHE Angabe beigetragen hat (z. B.
* „Geburtsdatum (27.03.2022) aus Stammbaum von X.xlsx'."). Primärinhalt des
* Datenherkunft-Dialogs.
*/
history: string[]
}
/**
* NACHVERFOLGUNG: geparste Datenherkunft eines beliebigen importierten Eintrags
* (Tier, Kontakt oder Wurf). {@link GerbilProvenance} ist die Tier-Spezialisierung
* mit zusätzlichen Eltern-Feldern; Kontakte/Würfe nutzen dieselbe Grundform.
*/
export type EntityProvenance = GerbilProvenance
/** Payload for POST /gerbils. */
export interface CreateGerbil {
name: string
@@ -116,6 +156,11 @@ export interface Contact {
isReceiver: boolean
/** Namens-Anhängsel dieser Zucht (für Tiere fremder Züchter). */
nameSuffix: string | null
/**
* NACHVERFOLGUNG: Datenherkunft des Import-Eintrags als JSON-String (vom
* Python-Merge erzeugt; siehe {@link EntityProvenance}). null = manuell angelegt.
*/
provenance?: string | null
}
export interface Litter {
@@ -136,6 +181,11 @@ export interface Litter {
motherId: string | null
/** LITTER-MORTALITY: pups that died within the first 8 weeks. */
deathsWithin8Weeks?: number | null
/**
* NACHVERFOLGUNG: Datenherkunft des Import-Eintrags als JSON-String (vom
* Python-Merge erzeugt; siehe {@link EntityProvenance}). null = manuell angelegt.
*/
provenance?: string | null
}
/** Payload for POST /litters. */

View File

@@ -0,0 +1,173 @@
/**
* NACHVERFOLGUNG: "Nachverfolgungsinformationen" — read-only modal showing the
* data provenance of an imported gerbil (which source files contributed, how many
* raw records were merged, how the parents were derived, and human-readable notes).
*
* The provenance arrives as a JSON string on the Gerbil DTO (produced by the Python
* merge step). We parse it here defensively; anything unparseable / absent is shown
* as "Keine Herkunftsdaten vorhanden." Mirrors the ReportErrorDialog modal pattern.
*/
import { useEffect } from 'react'
import { de } from '../strings/de'
import type { EntityProvenance } from '../api/types'
import './provenanceDialog.css'
interface ProvenanceDialogProps {
open: boolean
onClose: () => void
/** Raw provenance JSON string from the entity (Gerbil/Contact/Litter; null = no import data). */
provenance?: string | null
/** Optional entity label (e.g. "dieses Tiers", "dieses Kontakts") for the intro line. */
entityLabel?: string
}
/** Parse the JSON provenance string defensively; null on absence / parse error. */
function parseProvenance(raw?: string | null): EntityProvenance | null {
if (!raw || !raw.trim()) return null
try {
const p = JSON.parse(raw) as Partial<EntityProvenance>
return {
sourceFiles: Array.isArray(p.sourceFiles) ? p.sourceFiles : [],
mergedRecordCount: typeof p.mergedRecordCount === 'number' ? p.mergedRecordCount : 0,
fromWurfchronik: Boolean(p.fromWurfchronik),
parentMethod: p.parentMethod,
parentConfidence: p.parentConfidence,
notes: Array.isArray(p.notes) ? p.notes : [],
history: Array.isArray(p.history) ? p.history : [],
}
} catch {
return null
}
}
export default function ProvenanceDialog({ open, onClose, provenance, entityLabel }: ProvenanceDialogProps) {
// Close on Escape (only while mounted).
useEffect(() => {
if (!open) return
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [open, onClose])
if (!open) return null
const t = de.provenance
const p = parseProvenance(provenance)
const method = p?.parentMethod ? (t.methods[p.parentMethod] ?? p.parentMethod) : null
const confidence = p?.parentConfidence
? (t.confidences[p.parentConfidence] ?? p.parentConfidence)
: null
return (
<div className="provenance__backdrop" onClick={onClose} role="presentation">
<div
className="provenance__dialog"
role="dialog"
aria-modal="true"
aria-labelledby="provenance-title"
onClick={(e) => e.stopPropagation()}
>
<div className="provenance__header">
<h2 id="provenance-title" className="provenance__title">
{t.dialogTitle}
</h2>
<button
type="button"
className="provenance__close"
onClick={onClose}
aria-label={t.close}
>
</button>
</div>
{!p ? (
<p className="provenance__empty">{t.empty}</p>
) : (
<>
<p className="provenance__intro">{entityLabel ? t.introFor(entityLabel) : t.intro}</p>
{p.history.length > 0 && (
<section className="provenance__section">
<div className="provenance__section-title">{t.historyTitle}</div>
<ol className="provenance__history">
{p.history.map((step, i) => (
<li key={`${i}-${step}`} className="provenance__history-step">
{step}
</li>
))}
</ol>
</section>
)}
<section className="provenance__section">
<div className="provenance__section-title">
{t.mergedTitle}
</div>
<p className="provenance__merged">{t.mergedCount(p.mergedRecordCount)}</p>
{p.fromWurfchronik && (
<p className="provenance__wurfchronik">📖 {t.fromWurfchronik}</p>
)}
</section>
<section className="provenance__section">
<div className="provenance__section-title">
{t.sourceFilesTitle} {t.sourceFilesCount(p.sourceFiles.length)}
</div>
{p.sourceFiles.length === 0 ? (
<p className="provenance__empty">{t.empty}</p>
) : (
<ul className="provenance__files">
{p.sourceFiles.map((f) => (
<li key={f} className="provenance__file">
{f}
</li>
))}
</ul>
)}
</section>
{(method || confidence) && (
<section className="provenance__section">
<div className="provenance__section-title">{t.parentsTitle}</div>
<dl className="provenance__kvlist">
{method && (
<div className="provenance__kv">
<dt>{t.parentMethodLabel}</dt>
<dd>{method}</dd>
</div>
)}
{confidence && (
<div className="provenance__kv">
<dt>{t.parentConfidenceLabel}</dt>
<dd>{confidence}</dd>
</div>
)}
</dl>
</section>
)}
{p.notes.length > 0 && (
<section className="provenance__section">
<div className="provenance__section-title">{t.notesTitle}</div>
<ul className="provenance__notes">
{p.notes.map((n) => (
<li key={n}>{n}</li>
))}
</ul>
</section>
)}
</>
)}
<div className="provenance__actions">
<button type="button" className="btn btn--primary" onClick={onClose}>
{t.close}
</button>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,173 @@
/**
* FEEDBACK: "Fehler melden" — a small accessible modal with a textarea + submit.
*
* The caller passes the debug context (which view, the gerbil/litter id + name); the
* dialog captures the current URL and a client timestamp itself and POSTs everything
* to /feedback. Success/error is surfaced via the existing toast system.
*/
import { useEffect, useRef, useState } from 'react'
import { de } from '../strings/de'
import { ApiError } from '../api/client'
import { submitFeedback, type FeedbackContext } from '../api/feedback'
import { useToast } from './toast'
import './reportErrorDialog.css'
export interface ReportErrorContext {
context: FeedbackContext
gerbilId?: string | null
litterId?: string | null
contactId?: string | null
entityName?: string | null
}
interface ReportErrorDialogProps {
open: boolean
onClose: () => void
context: ReportErrorContext
}
/**
* Thin wrapper: mounts the dialog body only while open, so its form state starts
* fresh on every open (no reset-in-effect needed).
*/
export default function ReportErrorDialog({ open, onClose, context }: ReportErrorDialogProps) {
if (!open) return null
return <ReportErrorDialogBody onClose={onClose} context={context} />
}
function ReportErrorDialogBody({
onClose,
context,
}: {
onClose: () => void
context: ReportErrorContext
}) {
const t = de.feedback
const toast = useToast()
const [message, setMessage] = useState('')
const [submitting, setSubmitting] = useState(false)
const textareaRef = useRef<HTMLTextAreaElement | null>(null)
// Focus the textarea once on mount.
useEffect(() => {
const id = window.setTimeout(() => textareaRef.current?.focus(), 0)
return () => window.clearTimeout(id)
}, [])
// Close on Escape.
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [onClose])
const contextLabel = t.contexts[context.context]
async function handleSubmit() {
const trimmed = message.trim()
if (!trimmed) {
toast.error(t.emptyMessage)
textareaRef.current?.focus()
return
}
setSubmitting(true)
try {
await submitFeedback({
message: trimmed,
context: context.context,
gerbilId: context.gerbilId ?? null,
litterId: context.litterId ?? null,
contactId: context.contactId ?? null,
entityName: context.entityName ?? null,
url: window.location.href,
clientTimestamp: new Date().toISOString(),
})
toast.success(t.success)
onClose()
} catch (err) {
toast.error(err instanceof ApiError ? err.message : t.error)
setSubmitting(false)
}
}
return (
<div
className="report-error__backdrop"
onClick={onClose}
role="presentation"
>
<div
className="report-error__dialog"
role="dialog"
aria-modal="true"
aria-labelledby="report-error-title"
onClick={(e) => e.stopPropagation()}
>
<div className="report-error__header">
<h2 id="report-error-title" className="report-error__title">
{t.dialogTitle}
</h2>
<button
type="button"
className="report-error__close"
onClick={onClose}
aria-label={t.close}
>
</button>
</div>
<p className="report-error__intro">{t.intro}</p>
<label className="report-error__label" htmlFor="report-error-message">
{t.label}
</label>
<textarea
id="report-error-message"
ref={textareaRef}
className="report-error__textarea"
value={message}
onChange={(e) => setMessage(e.target.value)}
placeholder={t.placeholder}
rows={5}
/>
<div className="report-error__debug">
<div className="report-error__debug-title">{t.debugTitle}</div>
<dl className="report-error__debug-list">
<div className="report-error__debug-row">
<dt>{t.debugFields.context}</dt>
<dd>{contextLabel}</dd>
</div>
{context.entityName && (
<div className="report-error__debug-row">
<dt>{t.debugFields.entity}</dt>
<dd>{context.entityName}</dd>
</div>
)}
<div className="report-error__debug-row">
<dt>{t.debugFields.url}</dt>
<dd className="report-error__debug-url">{window.location.href}</dd>
</div>
</dl>
</div>
<div className="report-error__actions">
<button type="button" className="btn" onClick={onClose} disabled={submitting}>
{t.cancel}
</button>
<button
type="button"
className="btn btn--primary"
onClick={handleSubmit}
disabled={submitting}
>
{submitting ? t.submitting : t.submit}
</button>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,171 @@
/* NACHVERFOLGUNG: "Nachverfolgungsinformationen" modal dialog. Mirrors the
reportErrorDialog modal pattern. */
.provenance__backdrop {
position: fixed;
inset: 0;
z-index: 200;
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
background: rgba(43, 33, 25, 0.45);
}
.provenance__dialog {
width: 100%;
max-width: 32rem;
max-height: calc(100vh - 2rem);
overflow-y: auto;
background: var(--color-bg, #fff);
color: var(--color-text);
border-radius: 14px;
box-shadow: 0 12px 40px rgba(43, 33, 25, 0.32);
padding: 1.25rem;
animation: provenanceIn 0.18s cubic-bezier(0.22, 1, 0.36, 1);
}
@keyframes provenanceIn {
from {
opacity: 0;
transform: translateY(12px);
}
to {
opacity: 1;
transform: none;
}
}
.provenance__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.provenance__title {
margin: 0;
font-size: 1.15rem;
}
.provenance__close {
background: none;
border: none;
font: inherit;
font-size: 1.1rem;
line-height: 1;
cursor: pointer;
color: var(--color-text-muted, #666);
padding: 0.25rem 0.4rem;
border-radius: 6px;
}
.provenance__close:hover {
background: var(--color-border, #eee);
}
.provenance__intro {
margin: 0 0 0.85rem;
font-size: 0.9rem;
color: var(--color-text-muted, #666);
}
.provenance__empty {
margin: 0.4rem 0;
font-size: 0.9rem;
color: var(--color-text-muted, #888);
font-style: italic;
}
.provenance__section {
margin-top: 0.9rem;
padding: 0.6rem 0.75rem;
border: 1px solid var(--color-border, #e5e5e5);
border-radius: 8px;
background: var(--color-surface-2, rgba(0, 0, 0, 0.03));
}
.provenance__section-title {
font-size: 0.78rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.03em;
color: var(--color-text-muted, #888);
margin-bottom: 0.4rem;
}
.provenance__merged {
margin: 0;
font-size: 0.9rem;
}
.provenance__wurfchronik {
margin: 0.4rem 0 0;
font-size: 0.85rem;
color: var(--color-text-muted, #666);
}
.provenance__files {
margin: 0;
padding-left: 1.1rem;
display: grid;
gap: 0.2rem;
}
.provenance__file {
font-size: 0.85rem;
word-break: break-word;
}
.provenance__kvlist {
margin: 0;
display: grid;
gap: 0.25rem;
}
.provenance__kv {
display: flex;
gap: 0.5rem;
font-size: 0.85rem;
}
.provenance__kv dt {
flex: 0 0 6rem;
font-weight: 600;
color: var(--color-text-muted, #777);
}
.provenance__kv dd {
margin: 0;
min-width: 0;
word-break: break-word;
}
.provenance__notes {
margin: 0;
padding-left: 1.1rem;
display: grid;
gap: 0.2rem;
font-size: 0.85rem;
}
/* Chronologischer Verlauf: liest sich wie ein Protokoll der Datenherkunft. */
.provenance__history {
margin: 0;
padding-left: 1.25rem;
display: grid;
gap: 0.35rem;
}
.provenance__history-step {
font-size: 0.88rem;
line-height: 1.35;
word-break: break-word;
}
.provenance__actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
margin-top: 1.1rem;
}

View File

@@ -0,0 +1,149 @@
/* FEEDBACK: "Fehler melden" modal dialog. */
.report-error__backdrop {
position: fixed;
inset: 0;
z-index: 200;
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
background: rgba(43, 33, 25, 0.45);
}
.report-error__dialog {
width: 100%;
max-width: 32rem;
max-height: calc(100vh - 2rem);
overflow-y: auto;
background: var(--color-bg, #fff);
color: var(--color-text);
border-radius: 14px;
box-shadow: 0 12px 40px rgba(43, 33, 25, 0.32);
padding: 1.25rem;
animation: reportErrorIn 0.18s cubic-bezier(0.22, 1, 0.36, 1);
}
@keyframes reportErrorIn {
from {
opacity: 0;
transform: translateY(12px);
}
to {
opacity: 1;
transform: none;
}
}
.report-error__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.report-error__title {
margin: 0;
font-size: 1.15rem;
}
.report-error__close {
background: none;
border: none;
font: inherit;
font-size: 1.1rem;
line-height: 1;
cursor: pointer;
color: var(--color-text-muted, #666);
padding: 0.25rem 0.4rem;
border-radius: 6px;
}
.report-error__close:hover {
background: var(--color-border, #eee);
}
.report-error__intro {
margin: 0 0 0.85rem;
font-size: 0.9rem;
color: var(--color-text-muted, #666);
}
.report-error__label {
display: block;
font-weight: 600;
font-size: 0.9rem;
margin-bottom: 0.3rem;
}
.report-error__textarea {
width: 100%;
box-sizing: border-box;
font: inherit;
font-size: 0.95rem;
padding: 0.6rem 0.7rem;
border: 1px solid var(--color-border, #ccc);
border-radius: 8px;
resize: vertical;
min-height: 5.5rem;
background: var(--color-bg, #fff);
color: var(--color-text);
}
.report-error__textarea:focus {
outline: 2px solid var(--color-accent, #2563eb);
outline-offset: 1px;
}
.report-error__debug {
margin-top: 0.9rem;
padding: 0.6rem 0.75rem;
border: 1px solid var(--color-border, #e5e5e5);
border-radius: 8px;
background: var(--color-surface-2, rgba(0, 0, 0, 0.03));
}
.report-error__debug-title {
font-size: 0.78rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.03em;
color: var(--color-text-muted, #888);
margin-bottom: 0.4rem;
}
.report-error__debug-list {
margin: 0;
display: grid;
gap: 0.25rem;
}
.report-error__debug-row {
display: flex;
gap: 0.5rem;
font-size: 0.85rem;
}
.report-error__debug-row dt {
flex: 0 0 5.5rem;
font-weight: 600;
color: var(--color-text-muted, #777);
}
.report-error__debug-row dd {
margin: 0;
min-width: 0;
word-break: break-word;
}
.report-error__debug-url {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 0.8rem;
}
.report-error__actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
margin-top: 1.1rem;
}

View File

@@ -14,6 +14,8 @@ import GerbilHealthTab from '../components/GerbilHealthTab'
import GerbilPhotosTab from '../components/GerbilPhotosTab'
import GerbilProfilePhoto from '../components/GerbilProfilePhoto'
import GerbilWeightTab from '../components/GerbilWeightTab'
import ProvenanceDialog from '../components/ProvenanceDialog'
import ReportErrorDialog from '../components/ReportErrorDialog'
import { useGerbilName } from '../components/breederSuffix'
import { useToast } from '../components/toast'
import './rennmausakte.css'
@@ -57,6 +59,8 @@ export default function GerbilDetailPage() {
const toast = useToast()
const { id = '' } = useParams()
const [tab, setTab] = useState<DetailTab>('photos')
const [reportOpen, setReportOpen] = useState(false)
const [provenanceOpen, setProvenanceOpen] = useState(false)
const gerbil = useApi(() => getGerbil(id), [id])
const forSale = useMutation(() => updateGerbil(id, { status: 'ForSale' }))
@@ -137,6 +141,17 @@ export default function GerbilDetailPage() {
const lookup = (map: Map<string, string>, key: string | null) => (key ? (map.get(key) ?? '—') : '—')
const storedColorName = g.colorVarietyId ? (colorName.get(g.colorVarietyId) ?? null) : null
// Geschwisterverpaarung: Vater und Mutter dieses Tiers sind Vollgeschwister —
// erkennbar an gemeinsamer litterId ODER gleichem Geburtsdatum (starkes Indiz:
// ein identisches Datum entsteht praktisch nur durch einen gemeinsamen Wurf,
// und die Eltern-Würfe sind in den Daten nicht immer verknüpft).
const isSiblingPairing =
!!father.data &&
!!mother.data &&
father.data.id !== mother.data.id &&
((!!father.data.litterId && father.data.litterId === mother.data.litterId) ||
(!!father.data.dateOfBirth && father.data.dateOfBirth === mother.data.dateOfBirth))
const parentLink = (
pid: string | null,
p: Parameters<typeof gerbilName>[0] | null,
@@ -204,6 +219,12 @@ export default function GerbilDetailPage() {
{storedColorName}
</span>
)}
{isSiblingPairing && (
<span className="ak-fact ak-fact--sibling" title={t.detail.siblingPairingTitle}>
<span aria-hidden="true"></span>
{t.detail.siblingPairing}
</span>
)}
</div>
<div className="ak-actions">
@@ -239,6 +260,12 @@ export default function GerbilDetailPage() {
{de.pages.vertraege.wizard.title}
</Link>
)}
<button type="button" className="ak-btn" onClick={() => setProvenanceOpen(true)}>
{de.provenance.button}
</button>
<button type="button" className="ak-btn" onClick={() => setReportOpen(true)}>
{de.feedback.button}
</button>
<Link to="/rennmaeuse" className="ak-btn">
{t.detail.back}
</Link>
@@ -454,6 +481,18 @@ export default function GerbilDetailPage() {
</div>
</section>
</div>
<ReportErrorDialog
open={reportOpen}
onClose={() => setReportOpen(false)}
context={{ context: 'gerbil-detail', gerbilId: g.id, entityName: g.name || de.pages.gerbils.nameless }}
/>
<ProvenanceDialog
open={provenanceOpen}
onClose={() => setProvenanceOpen(false)}
provenance={g.provenance}
/>
</section>
)
}

View File

@@ -12,12 +12,16 @@ import { listGerbils } from '../api/gerbils'
import { listColorVarieties } from '../api/lookups'
import { condition } from '../api/gridify'
import { useApi, useMutation } from '../hooks/useApi'
import ProvenanceDialog from '../components/ProvenanceDialog'
import ReportErrorDialog from '../components/ReportErrorDialog'
export default function KontaktDetailPage() {
const t = de.pages.kontakte
const { id = '' } = useParams()
const navigate = useNavigate()
const [deleteError, setDeleteError] = useState<string | null>(null)
const [reportOpen, setReportOpen] = useState(false)
const [provenanceOpen, setProvenanceOpen] = useState(false)
const contact = useApi(() => getContact(id), [id])
// Verknüpfte Tiere: Kontakt ist Herkunft ODER Abnehmer (Gridify-OR via |).
@@ -79,6 +83,12 @@ export default function KontaktDetailPage() {
<Link to={`/kontakte/${c.id}/bearbeiten`} className="btn btn--primary">
{t.detail.edit}
</Link>
<button type="button" className="btn" onClick={() => setProvenanceOpen(true)}>
{de.provenance.button}
</button>
<button type="button" className="btn" onClick={() => setReportOpen(true)}>
{de.feedback.button}
</button>
<button
type="button"
className="btn btn--danger"
@@ -161,6 +171,19 @@ export default function KontaktDetailPage() {
))}
</ul>
)}
<ReportErrorDialog
open={reportOpen}
onClose={() => setReportOpen(false)}
context={{ context: 'contact-detail', contactId: c.id, entityName: c.name }}
/>
<ProvenanceDialog
open={provenanceOpen}
onClose={() => setProvenanceOpen(false)}
provenance={c.provenance}
entityLabel={de.provenance.entityLabels.contact}
/>
</section>
)
}

View File

@@ -16,7 +16,7 @@
* - Druck/PDF: separate CSS-Grid-Ahnentafel (.stammbaum-print), per
* @media print sichtbar; Browser „Als PDF speichern“ ist der Export.
*/
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type MouseEvent as ReactMouseEvent, type TouchEvent as ReactTouchEvent } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom'
import Tree from 'react-d3-tree'
import type { CustomNodeElementProps, Point, RawNodeDatum } from 'react-d3-tree'
@@ -29,6 +29,8 @@ import type { Gender, Gerbil, Litter } from '../api/types'
import { useApi } from '../hooks/useApi'
import { formatDate, genderLabel } from '../format/labels'
import GerbilIcon from '../components/GerbilIcon'
import ReportErrorDialog, { type ReportErrorContext } from '../components/ReportErrorDialog'
import { useToast } from '../components/toast'
import { UNKNOWN_FARBSCHLAG, fromDisplayString, genotypeToFarbschlag, displayGenotypeSafe } from '../genetics'
import {
DEFAULT_GENERATIONS,
@@ -90,6 +92,73 @@ export default function StammbaumPage() {
const t = de.pages.stammbaum
const { id = '' } = useParams()
const navigate = useNavigate()
const toast = useToast()
/* ── Rechtsklick-Kontextmenü auf einer Ahnenkarte ── */
const [contextMenu, setContextMenu] = useState<{
x: number
y: number
gerbil: Gerbil
} | null>(null)
const [reportContext, setReportContext] = useState<ReportErrorContext | null>(null)
const openContextMenuAt = useCallback((x: number, y: number, gerbil: Gerbil) => {
setContextMenu({ x, y, gerbil })
}, [])
// Rechtsklick (Desktop). Auf Touch-Geräten gibt es keinen Rechtsklick — dort
// öffnet ein langer Druck (Long-Press) dasselbe Menü, siehe PedigreeCard.
const openContextMenu = useCallback(
(e: ReactMouseEvent, gerbil: Gerbil) => {
e.preventDefault()
openContextMenuAt(e.clientX, e.clientY, gerbil)
},
[openContextMenuAt],
)
const closeContextMenu = useCallback(() => setContextMenu(null), [])
const copyId = useCallback(
async (gerbilId: string) => {
closeContextMenu()
try {
await navigator.clipboard.writeText(gerbilId)
toast.success(de.feedback.idCopied)
} catch {
toast.error(de.feedback.idCopyFailed)
}
},
[closeContextMenu, toast],
)
const openReport = useCallback(
(gerbil: Gerbil) => {
closeContextMenu()
setReportContext({
context: 'stammbaum',
gerbilId: gerbil.id,
entityName: gerbil.name || de.pages.gerbils.nameless,
})
},
[closeContextMenu],
)
/* Menü schließt bei Klick/Scroll/Escape außerhalb. */
useEffect(() => {
if (!contextMenu) return
const onAway = () => closeContextMenu()
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') closeContextMenu()
}
window.addEventListener('click', onAway)
window.addEventListener('scroll', onAway, true)
window.addEventListener('keydown', onKey)
return () => {
window.removeEventListener('click', onAway)
window.removeEventListener('scroll', onAway, true)
window.removeEventListener('keydown', onKey)
}
}, [contextMenu, closeContextMenu])
/* ── Daten: Ahnenbaum (Cache überlebt das Umwurzeln) ── */
const [source] = useState(createApiPedigreeSource)
@@ -168,6 +237,13 @@ export default function StammbaumPage() {
/* ── react-d3-tree-Daten ── */
const nodesByPath = useMemo(() => (root ? collectNodes(root) : null), [root])
const datum = useMemo(() => (root ? toRawNodeDatum(root, t.unknown) : null), [root, t])
const hasSiblingPairing = useMemo(
() =>
nodesByPath
? [...nodesByPath.values()].some((n) => n.kind === 'animal' && n.siblingPairing != null)
: false,
[nodesByPath],
)
/* ── Zeichenfläche vermessen (Erst-Zentrierung + Einpassen) ── */
const canvasRef = useRef<HTMLDivElement | null>(null)
@@ -183,16 +259,27 @@ export default function StammbaumPage() {
return () => observer.disconnect()
}, [hasRoot])
/* ── Ansicht (Zoom/Position): Wurzelkarte links, vertikal zentriert —
so sind die Eltern auch auf dem Smartphone sofort sichtbar. ── */
/* ── Ansicht (Zoom/Position) ──
Desktop: den gesamten geladenen Baum einpassen, aber nie über 1
vergrößern (Karten bleiben lesbar). So sind alle geladenen Generationen
sofort sichtbar und die Fläche wird genutzt, statt rechte Generationen
abzuschneiden. Mobil: Wurzel links, vertikal zentriert (Pan), damit die
Karten auf dem kleinen Schirm nicht winzig werden. ── */
const defaultView = useMemo<View | null>(() => {
if (!size) return null
const zoom = size.w < 520 ? 0.8 : 1
const isPhone = size.w < 520
let zoom = isPhone ? 0.8 : 1
if (!isPhone && datum) {
const extent = datumExtent(datum)
const width = extent.depth * NODE_X + CARD_W + 32
const height = extent.leaves * NODE_Y + 32
zoom = clamp(Math.min(1, size.w / width, size.h / height), ZOOM_MIN, ZOOM_MAX)
}
return {
zoom,
translate: { x: (CARD_W / 2) * zoom + 24, y: size.h / 2 },
}
}, [size])
}, [size, datum])
const [viewOverride, setViewOverride] = useState<(View & { forId: string }) | null>(null)
const view = viewOverride && viewOverride.forId === id ? viewOverride : defaultView
@@ -246,6 +333,28 @@ export default function StammbaumPage() {
const renderNode = useCallback(
({ nodeDatum }: CustomNodeElementProps) => {
const path = String(nodeDatum.attributes?.path ?? '')
// Verweis-Knoten der Geschwister-Verpaarung: rein aus den Attributen
// gerendert (kein Eintrag in nodesByPath).
if (nodeDatum.attributes?.kind === 'sibling-ref') {
const refName = String(nodeDatum.attributes.refName ?? '') || de.pages.gerbils.nameless
return (
<g>
<foreignObject width={CARD_W} height={CARD_H} x={-CARD_W / 2} y={-CARD_H / 2}>
<div className="pedigree-card pedigree-card--sibling">
<span className="pedigree-card--sibling__icon" aria-hidden="true">
</span>
<div className="pedigree-card--sibling__body">
<span className="pedigree-card--sibling__label">
{t.siblingPairing.refLabel(refName)}
</span>
<span className="pedigree-card--sibling__hint">{t.siblingPairing.refHint}</span>
</div>
</div>
</foreignObject>
</g>
)
}
const node = nodesByPath?.get(path)
return (
<g>
@@ -259,13 +368,15 @@ export default function StammbaumPage() {
farbschlag={farbschlagOf(node.gerbil)}
onOpen={() => navigate(`/rennmaeuse/${node.gerbil.id}/stammbaum`)}
onExpand={() => handleExpand(path)}
onContextMenu={(e) => openContextMenu(e, node.gerbil)}
onLongPress={(x, y) => openContextMenuAt(x, y, node.gerbil)}
/>
)}
</foreignObject>
</g>
)
},
[nodesByPath, farbschlagOf, navigate, handleExpand, t],
[nodesByPath, farbschlagOf, navigate, handleExpand, openContextMenu, openContextMenuAt, t],
)
/* ── Zustände: Laden / Fehler ── */
@@ -353,11 +464,49 @@ export default function StammbaumPage() {
<li>{t.tapHint}</li>
<li>{t.hintName}</li>
<li>{t.hintExpand}</li>
<li>{t.hintContext}</li>
{hasSiblingPairing && <li>{t.hintSiblings}</li>}
</ul>
</section>
{/* Druckansicht: am Bildschirm unsichtbar, ersetzt beim Drucken alles. */}
<PrintPedigree root={root} farbschlagOf={farbschlagOf} inbreedingText={inbreedingText} />
{contextMenu && (
<ul
className="stammbaum-context-menu"
role="menu"
style={{ left: contextMenu.x, top: contextMenu.y }}
onClick={(e) => e.stopPropagation()}
>
<li role="none">
<button
type="button"
role="menuitem"
className="stammbaum-context-menu__item"
onClick={() => copyId(contextMenu.gerbil.id)}
>
{t.contextMenu.copyId}
</button>
</li>
<li role="none">
<button
type="button"
role="menuitem"
className="stammbaum-context-menu__item"
onClick={() => openReport(contextMenu.gerbil)}
>
{t.contextMenu.reportError}
</button>
</li>
</ul>
)}
<ReportErrorDialog
open={reportContext !== null}
onClose={() => setReportContext(null)}
context={reportContext ?? { context: 'stammbaum' }}
/>
</>
)
}
@@ -370,12 +519,16 @@ function PedigreeCard({
farbschlag,
onOpen,
onExpand,
onContextMenu,
onLongPress,
}: {
node: AnimalNode
isRoot: boolean
farbschlag: string | null
onOpen: () => void
onExpand: () => void
onContextMenu: (e: ReactMouseEvent) => void
onLongPress: (x: number, y: number) => void
}) {
const t = de.pages.stammbaum
const g = node.gerbil
@@ -388,10 +541,57 @@ function PedigreeCard({
: `${API_BASE_URL}${g.profilePhotoUrl}`
: null
const photoUrl = rawUrl !== failedUrl ? rawUrl : null
/* Long-Press (Touch) = Rechtsklick-Äquivalent: ~500 ms halten ohne zu
verschieben öffnet das Kontextmenü. Der danach folgende Klick (Umwurzeln)
wird unterdrückt. */
const pressTimer = useRef<number | null>(null)
const pressStart = useRef<{ x: number; y: number } | null>(null)
const longPressed = useRef(false)
const cancelPress = () => {
if (pressTimer.current != null) {
clearTimeout(pressTimer.current)
pressTimer.current = null
}
}
const onTouchStart = (e: ReactTouchEvent) => {
const tch = e.touches[0]
if (!tch) return
pressStart.current = { x: tch.clientX, y: tch.clientY }
longPressed.current = false
cancelPress()
pressTimer.current = window.setTimeout(() => {
pressTimer.current = null
longPressed.current = true
onLongPress(pressStart.current!.x, pressStart.current!.y)
}, 500)
}
const onTouchMove = (e: ReactTouchEvent) => {
const tch = e.touches[0]
if (!tch || !pressStart.current) return
if (Math.abs(tch.clientX - pressStart.current.x) > 10 || Math.abs(tch.clientY - pressStart.current.y) > 10) {
cancelPress()
}
}
const onTouchEnd = (e: ReactTouchEvent) => {
cancelPress()
if (longPressed.current) {
// Synthetischen Klick verhindern (sonst würde umgewurzelt / Menü sofort
// wieder geschlossen).
e.preventDefault()
longPressed.current = false
}
}
return (
<div
className={isRoot ? 'pedigree-card pedigree-card--root' : 'pedigree-card'}
onClick={isRoot ? undefined : onOpen}
onContextMenu={onContextMenu}
onTouchStart={onTouchStart}
onTouchMove={onTouchMove}
onTouchEnd={onTouchEnd}
onTouchCancel={cancelPress}
role={isRoot ? undefined : 'button'}
title={isRoot ? undefined : t.tapHint}
>
@@ -429,7 +629,7 @@ function PedigreeCard({
)}
{dob && <span className="pedigree-card__year">* {dob}</span>}
</div>
{node.expandable && (
{node.expandable && !node.siblingPairing && (
<button
type="button"
className="pedigree-card__expand"

View File

@@ -13,6 +13,8 @@ import { isValidGenotype } from '../format/genotypeText'
import { breed, fromDisplayString, genotypeToFarbschlag, UNKNOWN_FARBSCHLAG, type BreedingResult } from '../genetics'
import BreedingResultView from '../components/BreedingResultView'
import GerbilIcon from '../components/GerbilIcon'
import ProvenanceDialog from '../components/ProvenanceDialog'
import ReportErrorDialog from '../components/ReportErrorDialog'
import { useGerbilName } from '../components/breederSuffix'
import './wuerfe.css'
@@ -64,6 +66,8 @@ export default function WurfDetailPage() {
const t = de.pages.litters
const gerbilName = useGerbilName()
const { id = '' } = useParams()
const [reportOpen, setReportOpen] = useState(false)
const [provenanceOpen, setProvenanceOpen] = useState(false)
const litter = useApi(() => getLitter(id), [id])
const fatherId = litter.data?.fatherId ?? null
@@ -151,6 +155,12 @@ export default function WurfDetailPage() {
<Link to={`/wuerfe/${l.id}/bearbeiten`} className="btn btn--primary">
{t.detail.edit}
</Link>
<button type="button" className="btn" onClick={() => setProvenanceOpen(true)}>
{de.provenance.button}
</button>
<button type="button" className="btn" onClick={() => setReportOpen(true)}>
{de.feedback.button}
</button>
<Link to="/wuerfe" className="btn">
{t.detail.back}
</Link>
@@ -224,6 +234,19 @@ export default function WurfDetailPage() {
) : (
<p className="muted">{t.detail.needParentsGenotype}</p>
)}
<ReportErrorDialog
open={reportOpen}
onClose={() => setReportOpen(false)}
context={{ context: 'litter-detail', litterId: l.id, entityName: l.name }}
/>
<ProvenanceDialog
open={provenanceOpen}
onClose={() => setProvenanceOpen(false)}
provenance={l.provenance}
entityLabel={de.provenance.entityLabels.litter}
/>
</section>
)
}

View File

@@ -139,6 +139,12 @@
font-weight: 600;
color: var(--color-text);
}
/* Geschwisterverpaarung: hervorgehoben, da zuchtrelevant (Inzucht). */
.ak-fact--sibling {
background: color-mix(in srgb, var(--color-accent) 12%, var(--color-surface));
border-color: var(--color-accent);
color: var(--color-accent);
}
/* ---------- Actions ---------- */
.ak-actions {

View File

@@ -171,7 +171,7 @@
.stammbaum-canvas {
flex: 1 1 auto;
min-width: 0;
height: clamp(18rem, 62dvh, 46rem);
height: clamp(18rem, 72dvh, 58rem);
border: 1px solid var(--color-border);
border-radius: 0.6rem;
background:
@@ -231,6 +231,44 @@
cursor: default;
}
/* Verweis-Knoten der Geschwister-Verpaarung: Vater & Mutter aus demselben
Wurf — der Vorfahren-Ast verweist auf die Vaterlinie statt ihn zu doppeln. */
.pedigree-card--sibling {
border-style: dashed;
border-color: var(--color-accent);
background: var(--color-accent-soft);
cursor: default;
}
.pedigree-card--sibling__icon {
flex: none;
font-size: 1.4rem;
line-height: 1;
color: var(--color-accent);
}
.pedigree-card--sibling__body {
display: flex;
flex-direction: column;
gap: 0.15rem;
min-width: 0;
}
.pedigree-card--sibling__label {
font-size: 0.82rem;
font-weight: 600;
color: var(--color-text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.pedigree-card--sibling__hint {
font-size: 0.72rem;
font-style: italic;
color: var(--color-text-muted);
}
.pedigree-card__photo {
flex: none;
width: 42px;
@@ -450,3 +488,38 @@
font-size: 0.8em;
color: #555;
}
/* FEEDBACK: Rechtsklick-Kontextmenü auf einer Ahnenkarte. */
.stammbaum-context-menu {
position: fixed;
z-index: 150;
margin: 0;
padding: 0.25rem;
list-style: none;
min-width: 11rem;
background: var(--color-bg, #fff);
border: 1px solid var(--color-border, #ddd);
border-radius: 8px;
box-shadow: 0 8px 28px rgba(43, 33, 25, 0.22);
}
.stammbaum-context-menu__item {
display: block;
width: 100%;
text-align: left;
background: none;
border: none;
font: inherit;
font-size: 0.9rem;
color: var(--color-text);
padding: 0.5rem 0.7rem;
border-radius: 5px;
cursor: pointer;
}
.stammbaum-context-menu__item:hover,
.stammbaum-context-menu__item:focus-visible {
background: var(--color-accent, #2563eb);
color: #fff;
outline: none;
}

View File

@@ -183,6 +183,74 @@ describe('buildPedigree', () => {
expect(aAgain.expandable).toBe(false)
})
it('markiert Geschwister-Verpaarung: Vater & Mutter aus demselben Wurf', async () => {
// Vater und Mutter sind Vollgeschwister (gleiche litterId 'w-grand').
const source = makeSource(
[
makeGerbil('kind', 'Kind', 'w-kind'),
makeGerbil('vater', 'Vater', 'w-grand'),
makeGerbil('mutter', 'Mutter', 'w-grand'),
makeGerbil('opa', 'Opa', null),
makeGerbil('oma', 'Oma', null),
],
[makeLitter('w-kind', 'vater', 'mutter'), makeLitter('w-grand', 'opa', 'oma')],
)
const root = await buildPedigree(source, 'kind')
const vater = animal(root!.father)
const mutter = animal(root!.mother)
// Mutter trägt den Verweis auf die Vaterlinie; Vater selbst nicht.
expect(mutter.siblingPairing).toEqual({ siblingName: 'Vater', siblingPath: 'v' })
expect(vater.siblingPairing).toBeUndefined()
// Echte Vorfahren bleiben im Modell (Druck-Ahnentafel nutzt sie weiter).
expect(animal(mutter.father).gerbil.name).toBe('Opa')
expect(animal(mutter.mother).gerbil.name).toBe('Oma')
})
it('erkennt Geschwister-Verpaarung am gleichen Geburtsdatum (litterId fehlt/verschieden)', async () => {
// Vater und Mutter haben verschiedene litterId, aber dasselbe Geburtsdatum
// (starkes Indiz: gemeinsamer Wurf, der in den Daten nicht verknüpft ist).
const source = makeSource(
[
makeGerbil('kind', 'Kind', 'w-kind'),
{ ...makeGerbil('vater', 'Vater', 'w-v'), dateOfBirth: '2018-09-22' },
{ ...makeGerbil('mutter', 'Mutter', 'w-m'), dateOfBirth: '2018-09-22' },
],
[makeLitter('w-kind', 'vater', 'mutter')],
)
const root = await buildPedigree(source, 'kind')
expect(animal(root!.mother).siblingPairing).toEqual({ siblingName: 'Vater', siblingPath: 'v' })
})
it('gleiches Geburtsdatum nur bei BEIDEN Eltern (unterschiedliches DOB → keine Verpaarung)', async () => {
const source = makeSource(
[
makeGerbil('kind', 'Kind', 'w-kind'),
{ ...makeGerbil('vater', 'Vater', 'w-v'), dateOfBirth: '2018-09-22' },
{ ...makeGerbil('mutter', 'Mutter', 'w-m'), dateOfBirth: '2019-05-15' },
],
[makeLitter('w-kind', 'vater', 'mutter')],
)
const root = await buildPedigree(source, 'kind')
expect(animal(root!.mother).siblingPairing).toBeUndefined()
})
it('verlangt für Geschwister-Verpaarung dieselbe litterId (Halbgeschwister zählen nicht)', async () => {
// Gemeinsamer Vater (Opa), aber verschiedene Würfe → keine Verpaarung-Marke.
const source = makeSource(
[
makeGerbil('kind', 'Kind', 'w1'),
makeGerbil('vater', 'Vater', 'w2'),
makeGerbil('mutter', 'Mutter', 'w3'),
makeGerbil('opa', 'Opa', null),
],
[makeLitter('w1', 'vater', 'mutter'), makeLitter('w2', 'opa', null), makeLitter('w3', 'opa', null)],
)
const root = await buildPedigree(source, 'kind')
expect(animal(root!.mother).siblingPairing).toBeUndefined()
})
it('dasselbe Tier darf an mehreren Positionen stehen (Inzucht ist kein Zyklus)', async () => {
// Vater und Mutter haben denselben Vater (Opa) — Ahnenschwund, kein Zyklus.
const source = makeSource(
@@ -260,6 +328,36 @@ describe('toRawNodeDatum', () => {
expect(datum.children![0].children).toBeUndefined()
})
it('führt den Vorfahren-Ast einer Geschwister-Verpaarung zu einem Verweis zusammen', async () => {
const source = makeSource(
[
makeGerbil('kind', 'Kind', 'w-kind'),
makeGerbil('vater', 'Vater', 'w-grand'),
makeGerbil('mutter', 'Mutter', 'w-grand'),
makeGerbil('opa', 'Opa', null),
makeGerbil('oma', 'Oma', null),
],
[makeLitter('w-kind', 'vater', 'mutter'), makeLitter('w-grand', 'opa', 'oma')],
)
const root = await buildPedigree(source, 'kind')
const datum = toRawNodeDatum(root!, 'unbekannt')
// Vater behält seinen vollen Vorfahren-Ast …
const vater = datum.children![0]
expect(vater.name).toBe('Vater')
expect(vater.children!.map((c) => c.name)).toEqual(['Opa', 'Oma'])
// … Mutter wird zu EINEM Verweis-Knoten zusammengeführt.
const mutter = datum.children![1]
expect(mutter.name).toBe('Mutter')
expect(mutter.children).toHaveLength(1)
expect(mutter.children![0].attributes).toMatchObject({
kind: 'sibling-ref',
refName: 'Vater',
refPath: 'v',
})
})
it('rendert Platzhalter mit dem übergebenen Label', async () => {
const root = await buildPedigree(familySource(), 'mutter')
const datum = toRawNodeDatum(root!, 'unbekannt')

View File

@@ -70,6 +70,29 @@ async function buildAnimal(
parentNode(source, litter?.fatherId, path + 'v', generationsLeft - 1, nextAncestors),
parentNode(source, litter?.motherId, path + 'm', generationsLeft - 1, nextAncestors),
])
// Geschwister-Verpaarung: Vater und Mutter sind Vollgeschwister, wenn sie aus
// DEMSELBEN Wurf stammen (gleiche litterId) ODER am selben Tag geboren sind
// (gleiches Geburtsdatum ist ein starkes Indiz — Würfe sind praktisch der
// einzige Grund für ein identisches Datum, und die Daten verknüpfen die
// Eltern-Würfe nicht immer). Wir markieren dann die Mutter, damit das Diagramm
// ihren Ast zu einem Verweis auf die Vaterlinie zusammenführt. Die echten
// Vorfahren bleiben im Modell erhalten (Druck-Ahnentafel nutzt sie weiter).
if (
father.kind === 'animal' &&
mother.kind === 'animal' &&
father.gerbil.id !== mother.gerbil.id &&
((father.gerbil.litterId && father.gerbil.litterId === mother.gerbil.litterId) ||
(father.gerbil.dateOfBirth != null &&
father.gerbil.dateOfBirth === mother.gerbil.dateOfBirth))
) {
const markedMother: AnimalNode = {
...mother,
siblingPairing: { siblingName: father.gerbil.name, siblingPath: father.path },
}
return { kind: 'animal', path, gerbil, father, mother: markedMother, expandable: false }
}
return { kind: 'animal', path, gerbil, father, mother, expandable: false }
}
@@ -143,6 +166,24 @@ export function toRawNodeDatum(node: PedigreeNode, unknownLabel: string): RawNod
name: node.gerbil.name,
attributes: { path: node.path, kind: 'animal', expandable: node.expandable },
}
// Geschwister-Verpaarung: Vorfahren-Ast zu EINEM Verweis-Knoten
// zusammenführen (gemeinsame Vorfahren stehen bereits an der Vaterlinie).
if (node.siblingPairing) {
datum.children = [
{
name: node.siblingPairing.siblingName,
attributes: {
// Synthetischer Pfad (nicht in collectNodes) — die Karte rendert
// ausschließlich aus diesen Attributen.
path: node.path + '§',
kind: 'sibling-ref',
refName: node.siblingPairing.siblingName,
refPath: node.siblingPairing.siblingPath,
},
},
]
return datum
}
if (node.father && node.mother) {
datum.children = [
toRawNodeDatum(node.father, unknownLabel),

View File

@@ -38,6 +38,15 @@ export interface AnimalNode {
readonly mother?: PedigreeNode
/** True: Tier hat einen Wurf, dessen Eltern noch nachgeladen werden können. */
readonly expandable: boolean
/**
* Geschwister-Verpaarung: gesetzt, wenn dieses Tier und sein Geschwister
* (an `siblingPath`) aus DEMSELBEN Wurf stammen und beide Eltern des
* Wurzeltiers sind (Vollgeschwister-Verpaarung — in der Zucht häufig).
* Im interaktiven Diagramm wird der Vorfahren-Ast dieses Knotens dann zu
* einem Verweis kollabiert (gemeinsame Vorfahren siehe `siblingPath`); die
* echten Vorfahren bleiben im Modell (Druck-Ahnentafel zeigt sie weiterhin).
*/
readonly siblingPairing?: { readonly siblingName: string; readonly siblingPath: PedigreePath }
}
/**

View File

@@ -122,6 +122,10 @@ export const de = {
parentLittersEmpty: 'Keine Würfe als Elternteil erfasst.',
parentLittersRoleVater: 'Vater',
parentLittersRoleMutter: 'Mutter',
// Hinweis: Eltern dieses Tiers sind Vollgeschwister (gleicher Wurf).
siblingPairing: 'Geschwisterverpaarung',
siblingPairingTitle:
'Die Eltern dieses Tiers stammen aus demselben Wurf (Vollgeschwister).',
},
// Formular (anlegen/bearbeiten)
form: {
@@ -411,6 +415,13 @@ export const de = {
/** Mini-Legende unter dem Baum (STAMMBAUM-EXPAND). */
hintName: 'Namenslink: Tierakte öffnen',
hintExpand: ': weitere Vorfahren nachladen',
hintContext: 'Rechtsklick (oder Karte lange gedrückt halten): ID kopieren / Fehler melden',
hintSiblings: '⟲: Eltern sind Geschwister — gemeinsame Vorfahren siehe Vaterlinie',
/** Verweis-Knoten bei Geschwister-Verpaarung (Vater & Mutter aus demselben Wurf). */
siblingPairing: {
refLabel: (name: string) => `Geschwister von ${name}`,
refHint: 'Eltern siehe oben',
},
/** Würfe-Panel links (STAMMBAUM-LITTERS). */
littersTitle: 'Würfe',
littersJunge: 'Junge',
@@ -428,6 +439,11 @@ export const de = {
createdOn: 'Erstellt am',
born: 'geb.',
},
/** FEEDBACK: Rechtsklick-Kontextmenü auf einer Stammbaum-Karte. */
contextMenu: {
copyId: 'ID kopieren',
reportError: 'Fehler melden',
},
},
// ── FEAT-7 (Kelly): Statistik & Berichte ──
statistik: {
@@ -832,6 +848,79 @@ export const de = {
unknown: 'Ein unbekannter Fehler ist aufgetreten.',
},
},
// ── FEEDBACK: "Fehler melden" Dialog + "ID kopieren" Aktion ──
feedback: {
/** Button-Beschriftung (Tierakte, Wurf-Ansicht). */
button: 'Fehler melden',
dialogTitle: 'Fehler melden',
intro: 'Beschreibe kurz, was nicht stimmt. Technische Angaben werden automatisch mitgesendet.',
label: 'Beschreibung',
placeholder: 'Was funktioniert nicht oder ist falsch dargestellt?',
/** Überschrift über den automatisch erfassten Debug-Angaben. */
debugTitle: 'Automatisch erfasst',
debugFields: {
context: 'Ansicht',
entity: 'Datensatz',
url: 'Adresse',
},
contexts: {
stammbaum: 'Stammbaum',
'gerbil-detail': 'Rennmausakte',
'litter-detail': 'Wurf',
'contact-detail': 'Kontakt',
},
submit: 'Senden',
submitting: 'Wird gesendet …',
cancel: 'Abbrechen',
close: 'Schließen',
success: 'Danke! Dein Fehlerbericht wurde gesendet.',
error: 'Fehlerbericht konnte nicht gesendet werden.',
emptyMessage: 'Bitte beschreibe den Fehler.',
/** Toast nach erfolgreichem "ID kopieren". */
idCopied: 'ID kopiert',
idCopyFailed: 'ID konnte nicht kopiert werden.',
},
/** NACHVERFOLGUNG: Datenherkunft eines importierten Tiers (Rennmausakte). */
provenance: {
/** Button-Beschriftung in den Akten-Aktionen. */
button: 'Datenherkunft',
dialogTitle: 'Nachverfolgungsinformationen',
intro: 'Woher stammen die Daten dieses Eintrags? Diese Angaben werden beim Import automatisch erfasst.',
/** Wie {@link intro}, aber mit Entitätsbezeichnung (z. B. „dieses Kontakts"). */
introFor: (label: string) =>
`Woher stammen die Daten ${label}? Diese Angaben werden beim Import automatisch erfasst.`,
/** Entitätsbezeichnungen für introFor (Genitiv). */
entityLabels: {
contact: 'dieses Kontakts',
litter: 'dieses Wurfs',
},
/** Überschrift des chronologischen, dateibezogenen Verlaufs (Primärinhalt). */
historyTitle: 'Verlauf',
/** Überschriften / Feldbeschriftungen. */
sourceFilesTitle: 'Quelldateien',
sourceFilesCount: (n: number) => (n === 1 ? 'aus 1 Quelle' : `aus ${n} Quellen`),
mergedTitle: 'Datensätze',
mergedCount: (n: number) =>
n === 1 ? 'aus 1 Datensatz' : `zusammengeführt aus ${n} Datensätzen`,
fromWurfchronik: 'Enthält Angaben aus der Wurfchronik',
parentsTitle: 'Eltern-Herkunft',
parentMethodLabel: 'Methode',
parentConfidenceLabel: 'Konfidenz',
/** Übersetzungen für die technischen Methoden-/Konfidenz-Werte. */
methods: {
'chart-position': 'Position im Stammbaum',
decision: 'Manuelle Entscheidung',
} as Record<string, string>,
confidences: {
low: 'niedrig',
medium: 'mittel',
high: 'hoch',
} as Record<string, string>,
notesTitle: 'Hinweise',
/** Wenn ein Tier keine Importdaten hat (manuell angelegt). */
empty: 'Keine Herkunftsdaten vorhanden.',
close: 'Schließen',
},
common: {
loading: 'Lädt …',
retry: 'Erneut versuchen',

View File

@@ -242,6 +242,29 @@
"decision": "Genotype is aa CC D- Ee Gg pp Spsp [DP] (C locus is CC, full color, duplicate colourpoint record resolved).",
"genotype": "aa CC D- Ee Gg pp Spsp [DP]",
"source": "Julian 2026-06-12"
},
{
"name": "Kazuya von den Kleinen Chaoten",
"dob": "22.08.2018",
"decision": "duplicate with wrong birthdate and parents — same animal as Kazuya *14.07.2019; merge into it and correct parents to Wilbur + Naho",
"correctDob": "14.07.2019",
"source": "Stammbaum von Kazuya.xlsx"
},
{
"name": "Kazuya von den Kleinen Chaoten",
"dob": "14.07.2019",
"decision": "father = Wilbur von den Kleinen Chaoten, mother = Naho von den Kleinen Chaoten (from Stammbaum von Kazuya.xlsx)",
"father": "Wilbur von den Kleinen Chaoten",
"mother": "Naho von den Kleinen Chaoten",
"source": "Stammbaum von Kazuya.xlsx"
},
{
"name": "Naho von den Kleinen Chaoten",
"dob": "20.07.2017",
"decision": "father = Osamu von den Kleinen Chaoten, mother = Montana v.d. Kleinen Chaoten (from Stammbaum von Kazuya.xlsx)",
"father": "Osamu von den Kleinen Chaoten",
"mother": "Montana v.d. Kleinen Chaoten",
"source": "Stammbaum von Kazuya.xlsx"
}
]
}

View File

@@ -340,18 +340,58 @@ def _attach_photos(z, sheets, animals, fname):
anchors = [a for a in xu.image_anchors(z)]
if not anchors:
return
by_gen = {}
for a in animals:
by_gen.setdefault(a["_gen"], []).append(a)
media_dir = os.path.join(OUT, "photos")
col_offset = 0 if any(a["_col"] == 2 for a in animals) else 3
for i, (sp, col, row, media) in enumerate(anchors):
g = gen_of(col, col_offset)
cands = by_gen.get(g, [])
if not cands:
# fall back to nearest animal by row across all gens
cands = animals
target = min(cands, key=lambda a: abs((a["_row"] - row) - 10)) if cands else None
# A photo sits one column either side of (or on) its animal's name cell;
# the generation is read from the photo's column. Whether photos are LEFT or
# RIGHT of the name varies, and the old fixed-column heuristic (col 1 / col 4)
# misclassified sheets that have neither, shifting every photo one generation
# toward the proband (e.g. „Stammbaum von Kazuya“ / „Picus Son“: Kazuya wore
# his father's photo, the father his grandfather's). Instead, try BOTH
# interpretations and keep the one that places photos closest to their
# assigned animal's name column (minimal horizontal misalignment).
def get_anchor_gen(colnum, left_style):
effective_col = colnum - col_offset
if left_style:
if effective_col <= 3: return 0
if effective_col <= 6: return 1
if effective_col <= 9: return 2
if effective_col <= 12: return 3
if effective_col <= 15: return 4
return 5
else:
if effective_col <= 4: return 0
if effective_col <= 7: return 1
if effective_col <= 10: return 2
if effective_col <= 13: return 3
if effective_col <= 16: return 4
return 5
def targets_for(left_style):
out = []
for (sp, col, row, media) in anchors:
cands = by_gen.get(get_anchor_gen(col, left_style), []) or animals
out.append(min(cands, key=lambda a: abs((a["_row"] - row) - 10)) if cands else None)
return out
def mean_col_offset(targets):
vals = [abs(col - t["_col"]) for (sp, col, row, m), t in zip(anchors, targets) if t]
return sum(vals) / len(vals) if vals else 0.0
left_targets = targets_for(True)
right_targets = targets_for(False)
targets = (
left_targets
if mean_col_offset(left_targets) <= mean_col_offset(right_targets)
else right_targets
)
for (sp, col, row, media), target in zip(anchors, targets):
if not target:
continue
ext = os.path.splitext(media)[1] or ".img"
@@ -362,7 +402,8 @@ def _attach_photos(z, sheets, animals, fname):
try:
with z.open(media) as src, open(os.path.join(OUT, rel), "wb") as dst:
shutil.copyfileobj(src, dst)
target["photos"].append(rel)
if rel not in target["photos"]:
target["photos"].append(rel)
except KeyError:
pass
@@ -612,6 +653,49 @@ def dedup(animals):
"files": sorted(set(f for a in grp for f in a["sourceFiles"])),
})
# 2. Merge groups that have different DOBs but same call-name (and Zucht) and matching parents
def get_parent_keys(a):
parent_refs = a.get("parentRefs", [])
f_name = next((p["name"] for p in parent_refs if p.get("roleGuess") == "father"), "")
m_name = next((p["name"] for p in parent_refs if p.get("roleGuess") == "mother"), "")
return norm_name(f_name), norm_name(m_name)
def groups_parents_match(g1, g2):
for a1 in g1:
for a2 in g2:
f1, m1 = get_parent_keys(a1)
f2, m2 = get_parent_keys(a2)
if f1 and f2 and f1 == f2 and m1 and m2 and m1 == m2:
return True
return False
i = 0
while i < len(final_groups):
g1 = final_groups[i]
call1, _ = split_name_zucht(g1[0]["name"])
n_call1 = norm_name(call1)
zucht1 = g1[0].get("_zucht", "")
j = i + 1
merged_any = False
while j < len(final_groups):
g2 = final_groups[j]
call2, _ = split_name_zucht(g2[0]["name"])
n_call2 = norm_name(call2)
zucht2 = g2[0].get("_zucht", "")
if n_call1 == n_call2:
if zucht1 == zucht2 or not zucht1 or not zucht2:
if groups_parents_match(g1, g2):
g1.extend(g2)
final_groups.pop(j)
merged_any = True
continue
j += 1
if merged_any:
continue
i += 1
merged = []
conflicts = []
for grp in final_groups:
@@ -652,11 +736,25 @@ def dedup(animals):
return sum(1 for pair in gd["mapped8locus"].values() for a in pair if a != "?")
best = max((a["genotype"] for a in grp),
key=lambda gd: (len(gd["mapped8locus"]), _specificity(gd), len(gd["rawGenotype"])))
# Find best DOB (proband first)
best_dob = ""
for a in grp:
if a.get("_gen") == 0 and a.get("dob"):
best_dob = a["dob"]
break
if not best_dob:
for a in grp:
if a.get("dob"):
best_dob = a["dob"]
break
chosen_dob = norm_dob(best_dob or base["dob"])
out = {
"id": slug(base["name"], base["dob"]),
"id": slug(base["name"], chosen_dob),
"name": base["name"],
"nameVariants": sorted(v for v in variants if v),
"dob": norm_dob(base["dob"]),
"dob": chosen_dob,
"death": sorted(deaths)[0] if deaths else "",
# box-colour sex (blue=male, white=female): majority across mentions, else None.
"gender": Counter(genders).most_common(1)[0][0] if genders else None,
@@ -999,6 +1097,25 @@ def apply_conflict_decisions(merged, conflicts, path):
a["farbschlagVariants"] = [d["farbschlag"]]
if d.get("dateOfDeath"): # D5 death-date resolutions
a["death"] = norm_dob(d["dateOfDeath"])
if "father" in d or "mother" in d:
new_refs = []
if d.get("father"):
new_refs.append({
"name": d["father"],
"dob": "",
"roleGuess": "father",
"method": "decision",
"confidence": "high"
})
if d.get("mother"):
new_refs.append({
"name": d["mother"],
"dob": "",
"roleGuess": "mother",
"method": "decision",
"confidence": "high"
})
a["parentRefs"] = new_refs
if a.get("conflict"):
a["conflict"] = False
conflicts[:] = [c for c in conflicts if c.get("id") != a["id"]]

File diff suppressed because it is too large Load Diff

View File

@@ -4,15 +4,15 @@ _Automatisch erzeugt von `tools/import/extract.py` — **noch nichts in die Date
## Überblick
- Rohe Tier-Einträge aus den Stammbäumen: **2449**
- Nach Zusammenführung (eindeutige Tiere): **1006**
- davon mit Geburtsdatum: 681
- in mehreren Dateien gefunden (Dubletten zusammengeführt): 460
- Konflikte zur Klärung: **2**
- Mehrdeutige / unvollständige Einträge (ohne Name+Datum): **342**
- Fotos zugeordnet: **416**
- Rohe Tier-Einträge aus den Stammbäumen: **2548**
- Nach Zusammenführung (eindeutige Tiere): **1044**
- davon mit Geburtsdatum: 685
- in mehreren Dateien gefunden (Dubletten zusammengeführt): 476
- Konflikte zur Klärung: **7**
- Mehrdeutige / unvollständige Einträge (ohne Name+Datum): **380**
- Fotos zugeordnet: **435**
- Würfe aus der Wurfchronik: **752**
- Tiere mit Wurf verknüpft: **271** (davon über Geburtsdatum **und** Eltern: 166, nur über Geburtsdatum: 105; mehrdeutig: 16)
- Tiere mit Wurf verknüpft: **270** (davon über Geburtsdatum **und** Eltern: 168, nur über Geburtsdatum: 102; mehrdeutig: 17)
- Würfe mit Datenqualitäts-Hinweisen: 113 (+ 138 Zeilen mit abweichendem Spaltenschema)
## Zusammenführungs-Schlüssel
@@ -34,12 +34,17 @@ Gleiches Tier (Name+Datum), aber widersprüchliche Angaben in verschiedenen Date
| Tier | Geburtsdatum | abweichende Genotypen | abweichende Farbschläge | Sterbedaten | Dateien |
|---|---|---|---|---|---|
| Osamu | 10.12.2015 | AA CC DD ee gg P- spsp // AA CC DD ee gg PP spsp // AA CC DD ee uw[d]uw[d] PP spsp | — | 01.10.2020 // 18.12.2020 | Stammbaum von Danako, Stammbaum von Ella, Stammbaum von Jin, Stammbaum von Kazuya, Stammbaum von Kentucky, Stammbaum von Martin, Stammbaum von Rainny, Stammbaum von Ren, Stammbaum von South Dakota, Stammbaum von Stella Kids, Stammbaum von Tennessee, Stammbaum von Zenon von Elea |
| Ella | 10.06.2019 | Aa C D- ee[f] GG P- spsp // Aa Cc[chm] D- ee[f] GG P- spsp // Aa Cc[chm] D- ee[f] UwUw P- spsp | Algierfuchsschimmel, hell | 03.02.2023 // 31.01.2023 | Stammbaum von Akio Kids, Stammbaum von CP-Fuchs, CP-Sa Sp von Unity, Stammbaum von Ella, Stammbaum von Picus Son, Stammbaum von Valentino Firehearts Kids |
| Lui von den Kleinen Chaoten | 16.04.2021 | Aa Cc[chm] DD ee[f] Gg PP Spsp // Aa c[chm]c[chm] D- Ee[f] Gg PP spsp | — | 15.12.2020 | Stammbaum von Alberto Kids, Stammbaum von Picus Son |
| Osamu | 10.12.2015 | AA CC DD ee gg P- spsp // AA CC DD ee gg PP spsp // AA CC DD ee uw[d]uw[d] PP spsp | — | 01.10.2020 // 18.12.2020 | Stammbaum von Danako, Stammbaum von Ella, Stammbaum von Jin, Stammbaum von Kazuya, Stammbaum von Kentucky, Stammbaum von Martin, Stammbaum von Picus Son, Stammbaum von Rainny, Stammbaum von Ren, Stammbaum von South Dakota, Stammbaum von Stella Kids, Stammbaum von Tennessee, Stammbaum von Zenon von Elea |
| Quebec v.d. Kleinen Chaoten | 21.06.2014 | aa Cc[chm] dd Ee Gg Pp spsp // aa Cc[chm] dd Ee Uwuw[d] Pp spsp // aa Cc[chm] dd Ee gg Pp spsp | — | 21.10.2016 | Stammbaum von Ella, Stammbaum von Jin, Stammbaum von Kazuya, Stammbaum von Martin, Stammbaum von Picus Son, Stammbaum von Rainny, Stammbaum von Ren, Stammbaum von South Dakota, Stammbaum von Tennessee, Stammbaum von Zenon von Elea |
| | 20.04.2024 | Aa C- dd Ee Gg P- Spsp | Dilute Agouti Kragenschecke // Dilute Kohlfuchs Kragenschecke DP | — | Stammbaum von Fire Kids, Stammbaum von Stella Kids |
| Hanami | 10.09.2015 | aa Cc[chm] D- Ee gg P- spsp // aa Cc[chm] D- Ee uw[d]uw[d] P- spsp | — | 02.01.2020 // 12.12.2019 // 14.01.2020 | Stammbaum von Hana, Stammbaum von Kentucky, Stammbaum von Rainny, Stammbaum von Ren, Stammbaum von Stella Kids, Stammbaum von Vance, Stammbaum von Zac (Vance.Dorie) |
| | 13.08.2025 | Aa C- D- ee[f] G(G) pp Spsp // aa Cc[chm] D- ee[f] Gg Pp Spsp | Goldfuchsschimmel Kragenschecke // Kohlfuchsschimmel, hell | — | Stammbaum von Kohlief, Goldfuchsef Sp von Chrissi, Stammbaum von Watarus Kids |
## Mehrdeutige / unvollständige Einträge
342 Einträge ohne sichere Name+Datum-Kombination (z. B. `Name1 & Name2`-Paarzellen der tiefsten Generation, oder Zellen ohne Datum). Diese werden NICHT automatisch zusammengeführt.
380 Einträge ohne sichere Name+Datum-Kombination (z. B. `Name1 & Name2`-Paarzellen der tiefsten Generation, oder Zellen ohne Datum). Diese werden NICHT automatisch zusammengeführt.
- Oskar v.d. bunten Fellnase · Stammbaum von Akio Kids.xlsx
- Raya v.d. bunten Fellnasen · Stammbaum von Akio Kids.xlsx
@@ -84,7 +89,7 @@ Gleiches Tier (Name+Datum), aber widersprüchliche Angaben in verschiedenen Date
## Wahrscheinliche Zuordnungen unvollständiger Einträge
91 namenlose/datenlose Einträge tragen denselben Namen wie ein vollständiges Tier — vermutlich dasselbe Tier (zur Bestätigung):
98 namenlose/datenlose Einträge tragen denselben Namen wie ein vollständiges Tier — vermutlich dasselbe Tier (zur Bestätigung):
- „Tai of Lennylengo“ → Tai of Lennylengo (*01.10.2011)
- „Arrow PZ Niederlande“ → Arrow PZ Niederlande (*20.05.2014)
@@ -95,7 +100,6 @@ Gleiches Tier (Name+Datum), aber widersprüchliche Angaben in verschiedenen Date
- „Oscar of Black Forest“ → Oscar of Black Forest (*12.06.2019)
- „Hagrid Rubeus of Black Forest“ → Hagrid Rubeus of Black Forest (*18.07.2019)
- „Lilo of LennyLengo“ → Lilo of LennyLengo (*04.11.2018)
- „Kazuya“ → Kazuya (*14.07.2019)
- „Harumi“ → Harumi (*21.02.2015)
- „Pan“ → Pan (*09.12.2014)
- „Gin“ → Gin (*05.02.2015)
@@ -146,6 +150,7 @@ Gleiches Tier (Name+Datum), aber widersprüchliche Angaben in verschiedenen Date
- „Zenon von Elea“ → Zenon von Elea (*06.10.2019)
- „Nisha of Black Forest“ → Nisha of Black Forest (*28.03.2018)
- „Zenon von Elea“ → Zenon von Elea (*06.10.2019)
- „Nisha of Black Forest“ → Nisha of Black Forest (*28.03.2018)
## Nicht ins 8-Loci-Modell abgebildete Tokens (verbatim erhalten)
@@ -163,7 +168,6 @@ Diese Tokens stehen weiter in `rawGenotype`/`unmappedTokens` — Entscheidung (M
| `!` | 2 | ? |
| `C(C)` | 2 | Schreibweise (C trägt c) |
| `(KW` | 2 | ? |
| `Cc[]` | 1 | ? |
| `[WFNZ-Maroon]` | 1 | ? |
| `/+April'2017` | 1 | ? |
| `[meliert]-[DP]` | 1 | ? |
@@ -173,11 +177,12 @@ Diese Tokens stehen weiter in `rawGenotype`/`unmappedTokens` — Entscheidung (M
| `/+Dezember'2014` | 1 | ? |
| `(Ansatz)` | 1 | ? |
| `-psp` | 1 | ? |
| `G(G)` | 1 | ? |
| `!Niereninsuffizienz!` | 1 | ? |
| `+09.07.2017` | 1 | ? |
| `+2018` | 1 | ? |
| `AAA` | 1 | ? |
| `chmchm` | 1 | Schreibweise (c[chm]c[chm]) |
| `c[chm]chm]` | 1 | ? |
## Wurfchronik — Datenqualitäts-Hinweise

View File

@@ -111,10 +111,15 @@ def main():
# Index animals for fast lookup by slug ID and by normalized name+dob
animal_by_id = {a["id"]: a for a in animals}
animal_by_name_dob = {}
animal_by_name = {}
for a in animals:
key = (ex.norm_name(a["name"]), ex.norm_dob(a["dob"]))
if key[0] and key[1]:
animal_by_name_dob.setdefault(key, []).append(a)
nk = ex.norm_name(a["name"])
if nk:
animal_by_name.setdefault(nk, []).append(a)
# 2. Extract and resolve unique Contacts (Breeders & Zuchten)
print("Extracting unique contacts...")
@@ -163,8 +168,36 @@ def main():
l_parents = litter_parent_map.setdefault(l_id, [None, None]) # [father, mother]
for p_ref in a.get("parentRefs", []):
p_key = (ex.norm_name(p_ref["name"]), ex.norm_dob(p_ref["dob"]))
p_dob = ex.norm_dob(p_ref.get("dob", ""))
p_key = (ex.norm_name(p_ref["name"]), p_dob)
p_candidates = animal_by_name_dob.get(p_key, [])
if not p_candidates:
# Fallback to name-only lookup when parent DOB is empty/not found
p_norm = ex.norm_name(p_ref["name"])
candidates = animal_by_name.get(p_norm, [])
if candidates:
offspring_dob_str = parse_date_only(a["dob"])
if offspring_dob_str:
try:
o_dob = datetime.strptime(offspring_dob_str, "%Y-%m-%d")
valid_candidates = []
for cand in candidates:
cand_dob_str = parse_date_only(cand["dob"])
if cand_dob_str:
try:
c_dob = datetime.strptime(cand_dob_str, "%Y-%m-%d")
if c_dob < o_dob:
valid_candidates.append(cand)
except ValueError:
valid_candidates.append(cand)
else:
valid_candidates.append(cand)
if valid_candidates:
p_candidates = [valid_candidates[0]]
except ValueError:
p_candidates = [candidates[0]]
else:
p_candidates = [candidates[0]]
if p_candidates:
p_guid = animal_guid_map[p_candidates[0]["id"]]
if p_ref["roleGuess"] == "father":

View File

@@ -396,6 +396,27 @@ if os.path.exists(danako_path):
else:
print("\nWarning: Danako stammbaum file not found, skipping integration checks.")
# --- Stammbaum von Kazuya: photo generation-shift regression (PHOTO-LEFT-STYLE) ---
# This sheet has neither col-1 nor col-4 image anchors, so the old fixed-column
# heuristic misread it as right-style and shifted every photo one generation
# toward the proband: Kazuya wore his father's (Wilbur's) photo, Wilbur wore the
# grandfather's (Elay's). The fix picks the layout that places photos closest to
# their animal's name column → photos land on the correct generation.
kazuya_path = r"C:\Users\gulum\dev\Sttammbäume\Stammbaum von Kazuya.xlsx"
if os.path.exists(kazuya_path):
print(f"\nFound Kazuya stammbaum, running photo generation-shift validation...")
kz = {a["name"]: a for a in e.extract_stammbaum(kazuya_path)}
if "Kazuya" in kz:
check("Kazuya (proband) has NO photo of his own", kz["Kazuya"]["photos"] == [])
if "Wilbur" in kz:
check("Wilbur (father) gets his own photo (image7), not the grandfather's",
kz["Wilbur"]["photos"] == ["photos/wilbur-19032017/image7.jpeg"])
if "Elay" in kz:
check("Elay (grandfather) gets his own photo (image2)",
kz["Elay"]["photos"] == ["photos/elay-16032016/image2.jpeg"])
else:
print("\nWarning: Kazuya stammbaum file not found, skipping photo-shift checks.")
if failed:
print(f"\n{failed} test(s) FAILED")
sys.exit(1)

View File

@@ -0,0 +1,283 @@
"""Zero-dep tests for merge_and_resolve.py litter-dedup & parent-role logic.
Run: python test_merge_resolve.py (exit 0 = all pass)
Covers the sibling-pairing data fix:
- litter_compatible(): same-date / compatible-parent dedup, incl. the dateless
guard that stops unrelated nameless stubs from blind-merging.
- assign_parent_roles(): gender-correct role assignment, self-pairing removal,
no two same-role parents — the fix for the 16 self-pairings / 30 gender-role
errors that the old simple swap missed.
"""
import sys
import merge_and_resolve as m
def check(name, cond):
if not cond:
print(f"FAIL: {name}")
check.failed += 1
else:
print(f"ok: {name}")
check.failed = 0
def litter(date, fid=None, mid=None, fname=None, mname=None):
return {
"Date": date,
"FatherId": fid,
"MotherId": mid,
"_father_name": fname,
"_mother_name": mname,
}
# ── litter_compatible: both sides fully parented ──
check(
"same date + same parents → compatible",
m.litter_compatible(litter("2018-09-22", "F", "M"), litter("2018-09-22", "F", "M")),
)
check(
"same date + different parents → NOT compatible",
not m.litter_compatible(litter("2018-09-22", "F", "M"), litter("2018-09-22", "X", "Y")),
)
check(
"different date → NOT compatible",
not m.litter_compatible(litter("2018-09-22", "F", "M"), litter("2019-01-01", "F", "M")),
)
# ── asymmetric (one resolved, one not), real date ──
check(
"asymmetric same real date, names agree → merge",
m.litter_compatible(
litter("2018-09-22", "F", "M", "Wonderman", "Unique"),
litter("2018-09-22", None, None, "Wonderman", "Unique"),
),
)
check(
"asymmetric same real date, conflicting names → NO merge",
not m.litter_compatible(
litter("2018-09-22", "F", "M", "Wonderman", "Unique"),
litter("2018-09-22", None, None, "Someone", "Else"),
),
)
check(
"asymmetric real date, parented side nameless stub → merge (no conflict)",
m.litter_compatible(
litter("2018-09-22", "F", "M", "Wonderman", "Unique"),
litter("2018-09-22", None, None, None, None),
),
)
# ── dateless guard: the bug that wrongly merged unrelated stubs ──
check(
"dateless asymmetric, NO name evidence → do NOT merge (was the bug)",
not m.litter_compatible(
litter(None, "F", "M", "Akina", "Arrow"),
litter(None, None, None, None, None),
),
)
check(
"dateless asymmetric WITH positive name match → merge",
m.litter_compatible(
litter(None, "F", "M", "Akina", "Arrow"),
litter(None, None, None, "Akina", None),
),
)
check(
"dateless asymmetric, contradicting names → do NOT merge",
not m.litter_compatible(
litter(None, "F", "M", "Akina", "Arrow"),
litter(None, None, None, "Mismatch", None),
),
)
# ── neither side parented → never blind-merge ──
check(
"neither parented, same date → NOT compatible",
not m.litter_compatible(litter("2018-09-22"), litter("2018-09-22")),
)
# ── assign_parent_roles ──
GENDER = {"bock": "male", "bock2": "male", "maus": "female", "maus2": "female", "u": "unknown", "u2": "unknown"}
gof = lambda gid: GENDER.get(gid)
check("correct roles stay put", m.assign_parent_roles("bock", "maus", gof) == ("bock", "maus"))
check("reversed roles get swapped", m.assign_parent_roles("maus", "bock", gof) == ("bock", "maus"))
check(
"self-pairing collapses to gender-correct single role (male→father)",
m.assign_parent_roles("bock", "bock", gof) == ("bock", None),
)
check(
"self-pairing collapses to gender-correct single role (female→mother)",
m.assign_parent_roles("maus", "maus", gof) == (None, "maus"),
)
check(
"female in father slot, empty mother → moved to mother",
m.assign_parent_roles("maus", None, gof) == (None, "maus"),
)
check(
"male in mother slot, empty father → moved to father",
m.assign_parent_roles(None, "bock", gof) == ("bock", None),
)
check(
"two males → keep one father, drop impossible second",
m.assign_parent_roles("bock", "bock2", gof) == ("bock", None),
)
check(
"two females → keep one mother, drop impossible second",
m.assign_parent_roles("maus", "maus2", gof) == (None, "maus"),
)
check(
"male + unknown → unknown fills mother",
m.assign_parent_roles("bock", "u", gof) == ("bock", "u"),
)
check(
"female + unknown → unknown fills father",
m.assign_parent_roles("u", "maus", gof) == ("u", "maus"),
)
check("both empty → both None", m.assign_parent_roles(None, None, gof) == (None, None))
check(
"single unknown parent kept as father",
m.assign_parent_roles("u", None, gof) == ("u", None),
)
# ── name helpers ──
check("names_no_conflict: one side empty", m.names_no_conflict(litter(None, fname="A"), litter(None)))
check(
"names_no_conflict: contradiction detected",
not m.names_no_conflict(litter(None, fname="A"), litter(None, fname="B")),
)
check("names_overlap: matching father name", m.names_overlap(litter(None, fname="A"), litter(None, fname="A")))
check("names_overlap: nothing in common", not m.names_overlap(litter(None, fname="A"), litter(None, mname="B")))
# ── parent_age_plausible: born before child, within ~6y lifespan ──
check("age: parent 1y before child → plausible", m.parent_age_plausible("16.04.2021", "27.03.2022"))
check("age: parent born AFTER child → implausible", not m.parent_age_plausible("2023-01-01", "2022-03-27"))
check("age: parent born SAME day → implausible", not m.parent_age_plausible("2022-03-27", "2022-03-27"))
check("age: 9 years older (Jayjay→Solice) → implausible", not m.parent_age_plausible("19.06.2013", "27.03.2022"))
check("age: exactly ~5y older → plausible", m.parent_age_plausible("01.06.2017", "01.05.2022"))
check("age: 7 years older → implausible", not m.parent_age_plausible("25.10.2018", "13.08.2025"))
check("age: unknown parent dob → plausible (can't disprove)", m.parent_age_plausible(None, "2022-03-27"))
check("age: unknown litter date → plausible", m.parent_age_plausible("2021-04-16", None))
# ── pick_parent_ref: prefer age-plausible ref, avoid duplicating the other role ──
def pref(name, role, dob=None):
return {"name": name, "roleGuess": role, "dob": dob}
# Solice case: first father ref (Jayjay, no DOB) loses to the dated, plausible Lui.
solice_refs = [
pref("Jayjay", "father"),
pref("Lui von den Kleinen Chaoten", "mother", "16.04.2021"),
pref("Lui von den Kleinen Chaoten", "father", "16.04.2021"),
pref("Molly of Black Forest", "mother", "13.09.2021"),
]
f = m.pick_parent_ref(solice_refs, "father", "27.03.2022")
check("pick: father = plausible-dated Lui, not first-listed Jayjay",
f and f["name"] == "Lui von den Kleinen Chaoten")
mo = m.pick_parent_ref(solice_refs, "mother", "27.03.2022", avoid_name=f["name"])
check("pick: mother = Molly (Lui avoided as it is the father)",
mo and mo["name"] == "Molly of Black Forest")
check("pick: a dated-but-impossible ref loses to a plausible one",
m.pick_parent_ref([pref("Old", "father", "2010-01-01"), pref("Dad", "father", "2021-01-01")],
"father", "2022-03-27")["name"] == "Dad")
check("pick: no ref for role → None",
m.pick_parent_ref([pref("X", "mother", "2021-01-01")], "father", "2022-03-27") is None)
check("pick: single ref is returned",
m.pick_parent_ref([pref("Solo", "father")], "father", "2022-03-27")["name"] == "Solo")
# Gender-aware: a dated FEMALE ref must not win the father slot over an undated
# male/unknown one (Molly regression: Hagrid (unknown, no DOB) vs Danielle
# (female, dated) → father must be Hagrid, not Danielle).
molly_refs = [
pref("Hagrid Rubeus of Black Forest", "father"),
pref("Arya Stark von den Kleinen Chaoten", "mother", "30.06.2020"),
pref("Danielle von den Kleinen Chaoten", "father", "04.03.2020"),
pref("Hagrid Rubeus of Black Forest", "mother", "18.07.2019"),
]
gender = {
m.normalize_name("Hagrid Rubeus of Black Forest"): None, # unknown
m.normalize_name("Danielle von den Kleinen Chaoten"): "female",
m.normalize_name("Arya Stark von den Kleinen Chaoten"): "female",
}
gof = lambda name: gender.get(m.normalize_name(name))
fr = m.pick_parent_ref(molly_refs, "father", "13.09.2021", gender_of=gof)
check("pick(gender): father = unknown-sex Hagrid, not dated female Danielle",
fr and fr["name"] == "Hagrid Rubeus of Black Forest")
mr = m.pick_parent_ref(molly_refs, "mother", "13.09.2021", avoid_name=fr["name"], gender_of=gof)
check("pick(gender): mother = Arya (female)", mr and mr["name"].startswith("Arya"))
# ── Provenance history: chronological, file-attributed German log ──
import json as _json
def _hist(prov_json):
return _json.loads(prov_json)["history"]
# build_entity_provenance carries history through verbatim.
_prov = _json.loads(
m.build_entity_provenance(["A.xlsx"], 1, notes=["x"], history=["line one"])
)
check("build_entity_provenance includes history key", _prov.get("history") == ["line one"])
check("build_entity_provenance defaults history to []",
_json.loads(m.build_entity_provenance(["A.xlsx"], 1)).get("history") == [])
# Single-record gerbil history: names the file and the per-field facts.
g_single = {
"Name": "Picus", "DateOfBirth": "2022-03-27", "Gender": "male",
"Genotype": "aa", "ColorVarietyId": None, "DateOfDeath": None,
"ImportSource": "Stammbaum von Picus Son.xlsx",
"_filename": "Stammbaum von Picus Son.xlsx", "parentRefs": [],
}
h = m._build_gerbil_history([g_single], g_single, {})
check("history: first line names the source file",
h[0] == "In „Stammbaum von Picus Son.xlsx“ gefunden.")
check("history: dob line names file + formatted date",
"Geburtsdatum (27.03.2022) aus „Stammbaum von Picus Son.xlsx“." in h)
check("history: gender line is German + file-attributed",
"Geschlecht (männlich) aus „Stammbaum von Picus Son.xlsx“." in h)
check("history: genotype line file-attributed",
"Genotyp aus „Stammbaum von Picus Son.xlsx“." in h)
# Merged gerbil: a field sourced from a DIFFERENT file is attributed to THAT file.
g_best = {
"Name": "Solice", "DateOfBirth": "2022-03-27", "Gender": "male",
"Genotype": None, "ColorVarietyId": None, "DateOfDeath": None,
"ImportSource": "Stammbaum von Picus Son.xlsx",
"_filename": "Stammbaum von Picus Son.xlsx", "parentRefs": [],
}
g_other = {
"Name": "Solice", "DateOfBirth": "2022-03-27", "Gender": "male",
"Genotype": "aa", "ColorVarietyId": None, "DateOfDeath": None,
"ImportSource": "Wurfchronik-Detail.docx",
"_filename": "Wurfchronik-Detail.docx", "parentRefs": [],
}
# Genotype was filled from g_other → its line must name the docx file.
g_best["Genotype"] = "aa"
fs = {"DateOfBirth": g_best, "Gender": g_best, "Genotype": g_other}
h2 = m._build_gerbil_history([g_best, g_other], g_best, fs)
check("history(merge): genotype attributed to the file that supplied it",
"Genotyp aus „Wurfchronik-Detail.docx“." in h2)
check("history(merge): dob attributed to primary file",
"Geburtsdatum (27.03.2022) aus „Stammbaum von Picus Son.xlsx“." in h2)
check("history(merge): merge line names the absorbed file",
"Auch in „Wurfchronik-Detail.docx“ gefunden → Datensätze zusammengeführt." in h2)
check("history(merge): Wurfchronik line present",
"Angaben aus der Wurfchronik übernommen." in h2)
# Date formatting helper.
check("_de_date: ISO → DD.MM.YYYY", m._de_date("2022-03-27") == "27.03.2022")
check("_de_date: passes through non-ISO", m._de_date("unbekannt") == "unbekannt")
if check.failed:
print(f"\n{check.failed} test(s) FAILED")
sys.exit(1)
print("\nAll merge_and_resolve tests passed.")