Compare commits

...

24 Commits

Author SHA1 Message Date
bddbaf59d3 chore(import): document import workflow and apply Round 2 contact deduplications
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 1m4s
CI / Frontend Tests (Node/Vite) (push) Failing after 4m53s
CI / Docker Build & Push (push) Has been skipped
2026-06-13 09:27:13 +02:00
3021149ddd chore(import): apply user contact deduplication decisions 2026-06-13 09:22:04 +02:00
46f987422b FIX: Combine Birgitta Büskens and Birgitta Müller-Büskens contacts 2026-06-13 08:35:15 +02:00
5d2316d670 FIX: Combine Barbara R / Barbara Riegler contacts 2026-06-13 08:22:54 +02:00
c6968b9968 FEAT: Merge Anja Sch. into Anja Schaumburg, and keep infinite scroll layout pages 2026-06-13 08:14:31 +02:00
f3f4534392 FEAT: Normalize Anke B./Anke Busch contact, and save other infinite scroll layout updates 2026-06-13 08:11:17 +02:00
d189da954f FEAT: Wipe Contacts table on ingest to prevent duplicates, and commit infinite scroll contacts list 2026-06-13 08:06:56 +02:00
5b32c09076 FIX: Keep Ag(o)uti('s sind) net(t) as a valid breeder contact 2026-06-13 07:59:52 +02:00
ea760fba06 FEAT: Apply contact normalization and misclassification discarding mappings during import 2026-06-13 01:45:45 +02:00
396d8f05d8 FEAT: Implement deceased/givenaway enclosure visibility rules, preserve external clan name, and hide receiver fields for deceased gerbils 2026-06-13 01:40:19 +02:00
5732398e60 feat(rennmaeuse): Tiere-Liste neu im Rennmaus-Filter-Design
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 1m2s
CI / Frontend Tests (Node/Vite) (push) Failing after 4m54s
CI / Docker Build & Push (push) Has been skipped
Filter-UX aus dem Design:
- Quick-Chip-Kategorien (Status/Geschlecht/Farbschlag/Herkunft) öffnen ein
  Bottom-Sheet mit Mehrfachauswahl (suchbar bei Farbschlag/Herkunft); aktive
  Filter als entfernbare Chips; Sortierung + "Externe Ahnen" als gleich große
  Chips (Sortierung öffnet dasselbe Drawer).
- Neue zweizeilige Tierkarte (Name + Geschlechts-Kreis + Status-Badge, Meta-Zeile),
  cardIn/Chip/Sheet-Animationen, Titel blendet beim Scrollen aus.

App-weit:
- Design-Palette (Creme/Braun/Terrakotta), Hanken-Grotesk-Schrift und gemeinsamer
  --shadow-Token; obere "Rennmaus-Manager"-Titelleiste entfernt (mobil bleibt die
  untere Aktionsleiste); fixe Shell-Höhe, nur der Hauptbereich scrollt.

Funktion:
- Infinite Scroll via useInfiniteList (seitenweise) + IntersectionObserver-Sentinel.
- Namensgenerator: Option männlich/weiblich/egal.
- Mehrfach-OR-Filter über gridify orGroup; e2e an die neue UI angepasst.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 01:05:44 +02:00
0185446bfe feat(migration): implement offline-resolved database-ready ingestion, fix circular FKs, duplicate contacts and parser scanning bugs 2026-06-08 21:57:01 +02:00
04d189bd2f FEAT: genotype parsing resilience and displayGenotypeSafe format helper 2026-06-08 21:19:11 +02:00
7d8166c555 Merge feature/gerbil-icon-redesign: GerbilIcon SVG neu gezeichnet
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 1m1s
CI / Frontend Tests (Node/Vite) (push) Successful in 9m33s
CI / Docker Build & Push (push) Failing after 9s
Julian fand die alte Silhouette bescheiden. Neue Rennmaus-Silhouette
(sitzende Pose, layered ears, Pfoten, geschwungener Schwanz + Tuft).
Komponenten-API unveraendert (Props/viewBox/role/aria-label/style) ->
Consumer + e2e Selektoren intakt. QA: tsc+vite build green, vitest 122/122.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 00:35:18 +02:00
d5c7b77e7e FEAT: redesign GerbilIcon with a cute alert sitting silhouette and dynamic tufted tail 2026-06-08 00:33:40 +02:00
bff25b4683 FEAT-8d: add wipe-import-data.sql utility
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 1m2s
CI / Frontend Tests (Node/Vite) (push) Successful in 9m33s
CI / Docker Build & Push (push) Failing after 9s
Utility script to clean up import data from the database after testing
/import/dry-run. Useful for re-running the import process during development
and QA. Deletes all animals and litters created by the import (identified
by ImportSource='docx-export' and ImportSource='derived-litter'), resets
sequences, and leaves the database ready for fresh import execution.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 21:58:11 +02:00
aa1d22cec7 FEAT-8d: add node_modules/ to .gitignore
Never commit node_modules — always installed locally via npm install.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 21:58:10 +02:00
f7e97ad7a2 FEAT-8d: fix StatusModel migration table name SaleContractAnimals->SaleContractAnimal
During import execution, the migration 20260607012436_StatusModel failed because it
referenced a non-existent table "SaleContractAnimals" (plural). The actual table name
is "SaleContractAnimal" (singular). Fixed the reference in the SQL raw string to use
the correct table name so the migration can apply cleanly on fresh deployments.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 21:58:10 +02:00
7d552d33cd D7-PREP: Batch-Merge feature/d7-conflicts -> main (all 7 D7 conflict animals resolved)
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 1m0s
CI / Frontend Tests (Node/Vite) (push) Successful in 9m32s
CI / Docker Build & Push (push) Failing after 9s
Merged commits:
- 5ce35af (Skarlett): dateOfDeath 01.01.2017 (year-only, born 14.07.2013)
- 62b0759 (Max): dateOfDeath 01.01.2014 (year-only, born 01.02.2013) + genotype
- 4d7333b (Dakota + Banjo): Dakota genotype + Banjo E-locus correction
- Base file (Isa/Jack II/Sunny): Already correct, verified

All D7 conflict decisions now on main. Feature branch can be retained or deleted per god preference.
Status: Ready for FEAT-8d re-import (currently on HOLD pending Julian GO).

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-06-07 17:32:00 +02:00
4d7333b714 D7-PREP: Dakota of sweet little mouse + Banjo of Fiomi (E-locus correction)
Dakota of sweet little mouse (DOB 30.01.2015):
- Genotype: Aa CC Dd Ee Gg pp Spsp
- A-Locus=Aa, P-Locus=pp, Sp-Locus=Spsp confirmed by Julian

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

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

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-06-07 17:26:10 +02:00
5ce35af813 D7-PREP: Skarlett death date corrected to 01.01.2017 (year-only convention, birth 2013 + 4y)
Julian's final answer: 'She became 4 years old, no exact date.' Birth: 14.07.2013.
Calculated: 2013 + 4 = 2017. Applied year-only death convention: 01.01.2017.
Previous entry (17.04.2016) from HUMANQUESTION D6 was incorrect.
2026-06-07 16:40:55 +02:00
23b6dad303 chore: retire HUMANQUESTION.md -> HUMANQUESTION.md.migrated
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 1m0s
CI / Frontend Tests (Node/Vite) (push) Successful in 9m33s
CI / Docker Build & Push (push) Failing after 9s
Human-feedback workflow moved into the hive task system (humanQA arrays on blocked cards in tasks.json). File is no longer the source of truth and must not be written to again. Open asks migrated to HQ-A3-GMAIL / HQ-A4-HOSTNAME / HQ-A5B2-REGISTRY / HQ-C7-RENNER-WISHES, plus D7-PREP (5 open conflict animals) and FEAT-8d.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 10:17:37 +02:00
e9eef5cce6 Merge JUVENILE-AVATARS (feature/juvenile-avatars @299c038) — Kelly
Some checks failed
CI / Frontend Tests (Node/Vite) (push) Successful in 9m32s
CI / Docker Build & Push (push) Failing after 9s
CI / Backend Tests (.NET) (push) Successful in 1m3s
Foto-Avatare pro Jungtier im Wurf-Detail (36px Kreis-Avatar + GerbilIcon-Fallback),
reuse profilePhotoUrl + failedUrl-Fehler-Tracking. StammbaumPage auf gleiches
failedUrl-Muster umgestellt (set-state-in-effect Lint-Fix). FE-only, keine Migration.
god-QA: vitest 122/122, build+tsc clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 08:24:49 +02:00
80 changed files with 10155 additions and 671 deletions

3
.gitignore vendored
View File

@@ -137,3 +137,6 @@ GerbilManagerWebAPI/photo-storage/
# AR-3: Data Protection key ring (dev-only ephemeral keys) — never commit
GerbilManagerWebAPI/.data-protection-keys/
# node_modules — never commit, installed locally per npm install
node_modules/

View File

@@ -122,7 +122,7 @@ namespace GerbilManager.Tests
Assert.Equal(GerbilStatus.GivenAway, gerbil.Status);
Assert.Equal(litter.Id, gerbil.LitterId); // date-only link worked
Assert.Equal(new DateOnly(2023, 7, 1), gerbil.GoHomeDate);
Assert.Equal("Zucht der Kleinen Chaoten", gerbil.OriginBreeder);
Assert.Equal("Zucht der kleinen Chaoten", gerbil.OriginBreeder);
Assert.False(gerbil.IsResident);
Assert.Equal("docx", gerbil.ImportSource);
Assert.StartsWith("docx-", gerbil.ExternalRef);

View File

@@ -0,0 +1,181 @@
using GerbilManagerWebAPI.Import;
using GerbilManagerWebAPI.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using System.Text.Json;
namespace GerbilManager.Tests
{
public class IngestResolvedServiceTests : IDisposable
{
private readonly string _dir;
private readonly string _resolvedJsonPath;
public IngestResolvedServiceTests()
{
_dir = Path.Combine(Path.GetTempPath(), "ingest-resolved-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(_dir);
_resolvedJsonPath = Path.Combine(_dir, "resolved_import.json");
var contactId = Guid.NewGuid();
var fatherId = Guid.NewGuid();
var motherId = Guid.NewGuid();
var litterId = Guid.NewGuid();
var photoId = Guid.NewGuid();
var data = new
{
Contacts = new[]
{
new
{
Id = contactId,
Name = "Test Breeder",
ZuchtName = "Test Zucht",
City = "Test City",
Email = "test@example.com",
Homepage = "",
Phone = "",
Address = ""
}
},
Litters = new[]
{
new
{
Id = litterId,
Name = "Wurf A",
Date = "2026-01-01",
TotalBorn = 5,
DeathsWithin8Weeks = 0,
FatherId = fatherId,
MotherId = motherId,
ExpectedGoHomeDate = (string?)null,
Notes = "Test litter notes",
PairingCode = "PC01",
ExternalRef = "ext-litter-1",
LitterLetter = "A"
}
},
Gerbils = new[]
{
new
{
Id = fatherId,
Name = "Papa",
Gender = "male",
Status = "Breeding",
LitterId = (Guid?)null,
OriginContactId = contactId,
ReceiverContactId = (Guid?)null,
EnclosureId = (Guid?)null,
ColorVarietyId = new Guid("00000000-0000-0000-0000-000000000006"), // Schwarz
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-papa",
RawImportData = "{}",
OriginBreeder = "Test Zucht",
NameSearch = "papa",
CharacterTraits = new string[] {},
CharacterNote = (string?)null,
IsDeaf = false,
IsResident = true
},
new
{
Id = motherId,
Name = "Mama",
Gender = "female",
Status = "Breeding",
LitterId = (Guid?)null,
OriginContactId = contactId,
ReceiverContactId = (Guid?)null,
EnclosureId = (Guid?)null,
ColorVarietyId = new Guid("00000000-0000-0000-0000-000000000006"), // Schwarz
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-mama",
RawImportData = "{}",
OriginBreeder = "Test Zucht",
NameSearch = "mama",
CharacterTraits = new string[] {},
CharacterNote = (string?)null,
IsDeaf = false,
IsResident = true
}
},
GerbilPhotos = new[]
{
new
{
Id = photoId,
GerbilId = fatherId,
FileName = "photo1.jpg",
SortOrder = 0
}
}
};
File.WriteAllText(_resolvedJsonPath, JsonSerializer.Serialize(data));
}
public void Dispose()
{
try { Directory.Delete(_dir, recursive: true); } catch { }
}
private ApplicationContext NewDb()
{
var opts = new DbContextOptionsBuilder<ApplicationContext>()
.UseInMemoryDatabase("ingest-resolved-" + Guid.NewGuid().ToString("N"))
.Options;
var db = new ApplicationContext(opts);
db.Database.EnsureCreated();
return db;
}
[Fact]
public async Task IngestResolved_loads_data_and_resolves_all_relations()
{
using var db = NewDb();
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
{ "Import:SourcePath", _dir }
})
.Build();
var service = new IngestResolvedService(db, config, null!);
var result = await service.RunAsync();
Assert.Contains("Ingestion successful!", result);
// Verify counts
Assert.Equal(1, await db.Contacts.CountAsync());
Assert.Equal(1, await db.Litters.CountAsync());
Assert.Equal(2, await db.Gerbils.CountAsync());
Assert.Equal(1, await db.GerbilPhotos.CountAsync());
// Verify relations
var litter = await db.Litters.SingleAsync();
var father = await db.Gerbils.SingleAsync(g => g.Name == "Papa");
var mother = await db.Gerbils.SingleAsync(g => g.Name == "Mama");
var photo = await db.GerbilPhotos.SingleAsync();
Assert.Equal(father.Id, litter.FatherId);
Assert.Equal(mother.Id, litter.MotherId);
Assert.Equal(father.Id, photo.GerbilId);
Assert.Equal(father.OriginContactId, mother.OriginContactId);
}
}
}

View File

@@ -36,7 +36,7 @@ public class PartialUpdateTests : IClassFixture<ApiFactory>
name,
gender = "female",
genotype = "aa CC DD ee GG PP spsp rere",
originBreeder = "Zucht der Kleinen Chaoten",
originBreeder = "Zucht der kleinen Chaoten",
isDeaf = false,
characterTraits = new[] { "neugierig", "zutraulich" },
characterNote = "Liebling der Familie",
@@ -67,7 +67,7 @@ public class PartialUpdateTests : IClassFixture<ApiFactory>
Assert.Equal("ForSale-Tier", GetStr(json, "name"));
Assert.Equal("female", GetStr(json, "gender"));
Assert.Equal("aa CC DD ee GG PP spsp rere", GetStr(json, "genotype"));
Assert.Equal("Zucht der Kleinen Chaoten", GetStr(json, "originBreeder"));
Assert.Equal("Zucht der kleinen Chaoten", GetStr(json, "originBreeder"));
Assert.Equal("Eine Notiz", GetStr(json, "notes"));
Assert.NotNull(GetStr(json, "externalRef")); // externalRef set during create, not cleared by partial PUT
Assert.Contains("neugierig", GetNested(json, "characterTraits") ?? "");
@@ -95,7 +95,7 @@ public class PartialUpdateTests : IClassFixture<ApiFactory>
Assert.Equal("Charakter-Tier", GetStr(json, "name"));
Assert.Equal("female", GetStr(json, "gender"));
Assert.Equal("aa CC DD ee GG PP spsp rere", GetStr(json, "genotype"));
Assert.Equal("Zucht der Kleinen Chaoten", GetStr(json, "originBreeder"));
Assert.Equal("Zucht der kleinen Chaoten", GetStr(json, "originBreeder"));
Assert.Equal("Eine Notiz", GetStr(json, "notes"));
Assert.NotNull(GetStr(json, "externalRef")); // externalRef set during create, not cleared by partial PUT
Assert.Contains("handzahm", GetNested(json, "characterTraits") ?? "");
@@ -123,7 +123,7 @@ public class PartialUpdateTests : IClassFixture<ApiFactory>
Assert.Equal("EditForm-Tier (umbenannt)", GetStr(json, "name"));
Assert.Equal("Aktualisierte Notiz", GetStr(json, "notes"));
Assert.Equal("Zucht der Kleinen Chaoten", GetStr(json, "originBreeder"));
Assert.Equal("Zucht der kleinen Chaoten", GetStr(json, "originBreeder"));
Assert.NotNull(GetStr(json, "externalRef")); // externalRef set during create, not cleared by partial PUT
Assert.Contains("neugierig", GetNested(json, "characterTraits") ?? "");
Assert.Equal("Liebling der Familie", GetStr(json, "characterNote"));

View File

@@ -22,6 +22,7 @@ namespace GerbilManagerWebAPI.Dtos
string? CauseOfDeath,
DateOnly? GoHomeDate,
string? Genotype,
string? SpottingType,
string? Notes,
string? ImportSource,
string? ExternalRef,
@@ -30,7 +31,8 @@ namespace GerbilManagerWebAPI.Dtos
string? CharacterNote,
bool? IsDeaf,
bool IsResident,
string? ProfilePhotoUrl);
string? ProfilePhotoUrl,
bool IsCastrated);
public record LitterDto(
Guid Id,
@@ -43,7 +45,7 @@ namespace GerbilManagerWebAPI.Dtos
DateOnly? ExpectedGoHomeDate,
string? Notes);
public record ContactDto(Guid Id, string Name, string? Email, string? Phone, string? Address, string? Notes);
public record ContactDto(Guid Id, string Name, string? Email, string? Phone, string? Address, string? Notes, bool IsBreeder, bool IsReceiver);
public record EnclosureDto(Guid Id, string Name, string? Notes);
@@ -74,6 +76,7 @@ namespace GerbilManagerWebAPI.Dtos
string? CauseOfDeath,
DateOnly? GoHomeDate,
string? Genotype,
string? SpottingType,
string? Notes,
string? ImportSource,
string? ExternalRef,
@@ -81,7 +84,8 @@ namespace GerbilManagerWebAPI.Dtos
List<string>? CharacterTraits,
string? CharacterNote,
bool? IsDeaf,
bool? IsResident);
bool? IsResident,
bool? IsCastrated);
public record LitterInput(
string Name,
@@ -93,7 +97,7 @@ namespace GerbilManagerWebAPI.Dtos
DateOnly? ExpectedGoHomeDate,
string? Notes);
public record ContactInput(string Name, string? Email, string? Phone, string? Address, string? Notes);
public record ContactInput(string Name, string? Email, string? Phone, string? Address, string? Notes, bool IsBreeder, bool IsReceiver);
public record EnclosureInput(string Name, string? Notes);

View File

@@ -24,7 +24,7 @@ namespace GerbilManagerWebAPI.Endpoints
group.MapPost("/", async (ContactInput input, ApplicationContext db) =>
{
var c = new Contact { Id = Guid.NewGuid(), Name = input.Name, Email = input.Email, Phone = input.Phone, Address = input.Address, Notes = input.Notes };
var c = new Contact { Id = Guid.NewGuid(), Name = input.Name, Email = input.Email, Phone = input.Phone, Address = input.Address, Notes = input.Notes, IsBreeder = input.IsBreeder, IsReceiver = input.IsReceiver };
db.Contacts.Add(c);
await db.SaveChangesAsync();
return TypedResults.Created($"/contacts/{c.Id}", ToDto(c));
@@ -35,6 +35,7 @@ namespace GerbilManagerWebAPI.Endpoints
var c = await db.Contacts.FirstOrDefaultAsync(x => x.Id == id);
if (c is null) return TypedResults.NotFound();
c.Name = input.Name; c.Email = input.Email; c.Phone = input.Phone; c.Address = input.Address; c.Notes = input.Notes;
c.IsBreeder = input.IsBreeder; c.IsReceiver = input.IsReceiver;
await db.SaveChangesAsync();
return TypedResults.NoContent();
});
@@ -54,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);
private static ContactDto ToDto(Contact c) => new(c.Id, c.Name, c.Email, c.Phone, c.Address, c.Notes, c.IsBreeder, c.IsReceiver);
}
}

View File

@@ -120,6 +120,7 @@ namespace GerbilManagerWebAPI.Endpoints
g.CauseOfDeath = i.CauseOfDeath ?? g.CauseOfDeath;
g.GoHomeDate = i.GoHomeDate ?? g.GoHomeDate;
g.Genotype = i.Genotype ?? g.Genotype;
g.SpottingType = i.SpottingType ?? g.SpottingType;
g.Notes = i.Notes ?? g.Notes;
g.ImportSource = i.ImportSource ?? g.ImportSource;
g.ExternalRef = i.ExternalRef ?? g.ExternalRef;
@@ -128,13 +129,14 @@ namespace GerbilManagerWebAPI.Endpoints
g.CharacterNote = i.CharacterNote ?? g.CharacterNote;
g.IsDeaf = i.IsDeaf ?? g.IsDeaf;
g.IsResident = i.IsResident ?? (isCreate ? true : g.IsResident);
g.IsCastrated = i.IsCastrated ?? (isCreate ? false : g.IsCastrated);
GerbilStatusService.Apply(g, DateOnly.FromDateTime(DateTime.UtcNow));
}
internal static GerbilDto ToDto(Gerbil g, string? profilePhotoUrl = null) => new(
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.Notes, g.ImportSource, g.ExternalRef, g.OriginBreeder,
g.CharacterTraits, g.CharacterNote, g.IsDeaf, g.IsResident, profilePhotoUrl);
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);
}
}

View File

@@ -29,6 +29,13 @@ namespace GerbilManagerWebAPI.Endpoints
return TypedResults.Ok(report);
});
group.MapPost("/ingest-resolved", async Task<Ok<string>> (
ApplicationContext db, IConfiguration config, IWebHostEnvironment env) =>
{
var result = await new IngestResolvedService(db, config, env).RunAsync();
return TypedResults.Ok(result);
});
return app;
}
}

View File

@@ -55,10 +55,21 @@ namespace GerbilManagerWebAPI.Import
var docxLitters = Load<List<DocxLitter>>("docx_litters.json") ?? new();
var docxAnimals = Load<List<DocxAnimal>>("docx_animals.json") ?? new();
var pdfLitters = Load<List<DocxLitter>>("pdf_litters.json") ?? new();
var pdfAnimals = Load<List<DocxAnimal>>("pdf_animals.json") ?? new();
int docxLitCount = docxLitters.Count;
int docxAnimCount = docxAnimals.Count;
int pdfLitCount = pdfLitters.Count;
int pdfAnimCount = pdfAnimals.Count;
docxLitters.AddRange(pdfLitters);
docxAnimals.AddRange(pdfAnimals);
if (docxLitters.Count == 0 && docxAnimals.Count == 0)
{
notes.Add($"Keine Quelldaten in {_sourceDir} (docx_litters.json/docx_animals.json). " +
"extract_docx.py zuerst ausführen.");
notes.Add($"Keine Quelldaten in {_sourceDir} (docx_litters.json/docx_animals.json oder pdf_litters.json/pdf_animals.json). " +
"extract_docx.py zuerst ausführen oder PDF-VLM-Daten bereitstellen.");
return new ImportDocxReport(false, 0, 0, 0, 0, 0, 0, 0, notes);
}
@@ -274,7 +285,7 @@ namespace GerbilManagerWebAPI.Import
DateOfDeath = deathDate,
CauseOfDeath = string.IsNullOrWhiteSpace(da.DeathCause) ? null : da.DeathCause.Trim(),
ColorVarietyId = cvId == default ? null : cvId,
OriginBreeder = "Zucht der Kleinen Chaoten",
OriginBreeder = "Zucht der kleinen Chaoten",
IsResident = false,
ImportSource = "docx",
ExternalRef = externalRef,
@@ -313,7 +324,7 @@ namespace GerbilManagerWebAPI.Import
});
}
notes.Add($"Quelle: {docxLitters.Count} Würfe, {docxAnimals.Count} Tier-Zeilen aus der docx.");
notes.Add($"Quelle: {docxLitCount} Würfe / {docxAnimCount} Tiere aus DOCX. {pdfLitCount} Würfe / {pdfAnimCount} Tiere aus PDF.");
notes.Add($"Neu angelegt: {animalsCreated} Jungtiere (abgegeben, nicht in Stammbäumen).");
notes.Add($"Litter-Links: {litterLinked} Tiere einem Wurf zugeordnet (DOB-Match ±5 Tage, eindeutig).");
notes.Add($"Abnehmer: {ownerLinked} bestehende Kontakte verknüpft, {ownerCreated} neue Kontakte angelegt.");

View File

@@ -571,7 +571,7 @@ namespace GerbilManagerWebAPI.Import
// HERKUNFT BACKFILL (fill-NULL-only, safe): sweep all resident animals whose
// OriginBreeder is null and fill it with a derived value or 'Zucht der Kleinen Chaoten'.
// NEVER overwrites a non-null OriginBreeder (Julian: "alle Schreibweisen unterstützen").
const string DefaultClanBreeder = "Zucht der Kleinen Chaoten";
const string DefaultClanBreeder = "Zucht der kleinen Chaoten";
int herkunftBackfilled = 0;
{
var needsHerkunft = await _db.Gerbils
@@ -615,9 +615,9 @@ namespace GerbilManagerWebAPI.Import
notes.Add($"FK-Integrität: {litterParentFksDropped} Eltern-Verknüpfung(en) verworfen (Elternteil nicht ladbar), {derivedLittersSkipped} abgeleitete Würfe übersprungen (kein ladbares Elternteil). Bei 0/0 ist /import/execute FK-sicher.");
if (parentFksBackfilled > 0)
notes.Add($"Parent-FK-Backfill: {parentFksBackfilled} bereits importierte Würfe haben jetzt eine Eltern-Verknüpfung (Elternteil war zuvor in Quarantäne, jetzt geladen).");
notes.Add($"Bestand/Herkunft: {residentTotal} im Bestand (Clan Kleine Chaoten), {externalTotal} externe Ahnen ({flippedByParentRule} davon über die Eltern-Regel als Bestand erkannt).");
notes.Add($"Bestand/Herkunft: {residentTotal} im Bestand (Zucht der kleinen Chaoten), {externalTotal} externe Ahnen ({flippedByParentRule} davon über die Eltern-Regel als Bestand erkannt).");
if (herkunftBackfilled > 0)
notes.Add($"Herkunft-Backfill: {herkunftBackfilled} Bestand-Tier(e) mit leerem Herkunft-Feld befüllt (Zucht der Kleinen Chaoten oder von Elternteil abgeleitet). Niemals überschrieben.");
notes.Add($"Herkunft-Backfill: {herkunftBackfilled} Bestand-Tier(e) mit leerem Herkunft-Feld befüllt (Zucht der kleinen Chaoten oder von Elternteil abgeleitet). Niemals überschrieben.");
if (farbschlagWouldRebackfill > 0)
notes.Add($"Farbschlag-Hinweis (kein Overwrite): {farbschlagWouldRebackfill} bereits importierte Tier(e) haben einen veralteten Farbschlag, den der aktuelle Extraktor korrigieren würde — Overwrite-Mechanismus ausstehend (god/Julian Freigabe).");
int conflictsResolvedByDecision = loadable.Count(a => a.ResolvedByDecision);

View File

@@ -0,0 +1,255 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using GerbilManagerWebAPI.Models;
using Microsoft.EntityFrameworkCore;
namespace GerbilManagerWebAPI.Import
{
public sealed class IngestResolvedService
{
private readonly ApplicationContext _db;
private readonly string _resolvedJsonPath;
private readonly string _sourceDir;
private readonly string _photoRoot;
public IngestResolvedService(ApplicationContext db, IConfiguration config, IWebHostEnvironment? env)
{
_db = db;
var contentRoot = env?.ContentRootPath ?? Directory.GetCurrentDirectory();
_sourceDir = config["Import:SourcePath"]
?? Path.GetFullPath(Path.Combine(contentRoot, "..", "tools", "import", "output"));
_resolvedJsonPath = Path.Combine(_sourceDir, "resolved_import.json");
_photoRoot = config["Photos:RootPath"]
?? Path.Combine(contentRoot, "photo-storage");
}
public async Task<string> RunAsync()
{
if (!File.Exists(_resolvedJsonPath))
{
return $"Error: Resolved import file not found at {_resolvedJsonPath}. Run resolve_import.py first.";
}
string jsonContent = await File.ReadAllTextAsync(_resolvedJsonPath);
var jsonOptions = new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true
};
jsonOptions.Converters.Add(new System.Text.Json.Serialization.JsonStringEnumConverter());
var data = JsonSerializer.Deserialize<ResolvedImportData>(jsonContent, jsonOptions);
if (data == null)
{
return "Error: Failed to deserialize resolved_import.json.";
}
// 1. Provider-agnostic clean wipe of dependent tables to prevent duplicate keys
if (_db.Database.ProviderName == "Microsoft.EntityFrameworkCore.InMemory")
{
// Break circular references first
foreach (var l in _db.Litters)
{
l.FatherId = null;
l.MotherId = null;
}
foreach (var g in _db.Gerbils)
{
g.LitterId = null;
}
await _db.SaveChangesAsync();
_db.GerbilPhotos.RemoveRange(_db.GerbilPhotos);
_db.WeightRecords.RemoveRange(_db.WeightRecords);
_db.HealthRecords.RemoveRange(_db.HealthRecords);
_db.Gerbils.RemoveRange(_db.Gerbils);
_db.Litters.RemoveRange(_db.Litters);
_db.Contacts.RemoveRange(_db.Contacts);
await _db.SaveChangesAsync();
}
else
{
// Break circular references first using ExecuteUpdateAsync
await _db.Litters.ExecuteUpdateAsync(s => s
.SetProperty(l => l.FatherId, (Guid?)null)
.SetProperty(l => l.MotherId, (Guid?)null));
await _db.Gerbils.ExecuteUpdateAsync(s => s
.SetProperty(g => g.LitterId, (Guid?)null));
await _db.GerbilPhotos.ExecuteDeleteAsync();
await _db.WeightRecords.ExecuteDeleteAsync();
await _db.HealthRecords.ExecuteDeleteAsync();
await _db.Gerbils.ExecuteDeleteAsync();
await _db.Litters.ExecuteDeleteAsync();
await _db.Contacts.ExecuteDeleteAsync();
}
// 2. Import Contacts (Add new ones, and update roles/details of existing ones)
var existingContacts = await _db.Contacts.ToDictionaryAsync(c => c.Id);
var addedContactIds = new HashSet<Guid>();
int contactsAdded = 0;
int contactsUpdated = 0;
foreach (var c in data.Contacts)
{
if (existingContacts.TryGetValue(c.Id, out var existingContact))
{
existingContact.Name = c.Name;
existingContact.Email = c.Email;
existingContact.Phone = c.Phone;
existingContact.Address = c.Address;
existingContact.Notes = c.Notes;
existingContact.IsBreeder = c.IsBreeder;
existingContact.IsReceiver = c.IsReceiver;
contactsUpdated++;
}
else if (addedContactIds.Add(c.Id))
{
_db.Contacts.Add(c);
contactsAdded++;
}
}
await _db.SaveChangesAsync();
// 3. Pass 1: Insert all Litters with parent references set to null to avoid FK errors
var littersToInsert = new List<Litter>();
foreach (var l in data.Litters)
{
littersToInsert.Add(new Litter
{
Id = l.Id,
Name = l.Name,
Date = l.Date,
TotalBorn = l.TotalBorn,
DeathsWithin8Weeks = l.DeathsWithin8Weeks,
FatherId = null, // Set in Pass 2
MotherId = null, // Set in Pass 2
ExpectedGoHomeDate = l.ExpectedGoHomeDate,
Notes = l.Notes,
PairingCode = l.PairingCode,
ExternalRef = l.ExternalRef,
LitterLetter = l.LitterLetter
});
}
_db.Litters.AddRange(littersToInsert);
await _db.SaveChangesAsync();
// 4. Pass 1: Insert all Gerbils with LitterId set to null to avoid FK errors
var gerbilsToInsert = new List<Gerbil>();
foreach (var g in data.Gerbils)
{
gerbilsToInsert.Add(new Gerbil
{
Id = g.Id,
Name = g.Name,
Gender = g.Gender,
Status = g.Status,
LitterId = null, // Set in Pass 2
OriginContactId = g.OriginContactId,
ReceiverContactId = g.ReceiverContactId,
EnclosureId = g.EnclosureId,
ColorVarietyId = g.ColorVarietyId,
DateOfBirth = g.DateOfBirth,
DateOfDeath = g.DateOfDeath,
CauseOfDeath = g.CauseOfDeath,
GoHomeDate = g.GoHomeDate,
Genotype = g.Genotype,
Notes = g.Notes,
ImportSource = g.ImportSource,
ExternalRef = g.ExternalRef,
RawImportData = g.RawImportData,
OriginBreeder = g.OriginBreeder,
NameSearch = g.NameSearch,
CharacterTraits = g.CharacterTraits,
CharacterNote = g.CharacterNote,
IsDeaf = g.IsDeaf,
IsResident = g.IsResident
});
}
_db.Gerbils.AddRange(gerbilsToInsert);
await _db.SaveChangesAsync();
// 5. Pass 2: Set the actual foreign key relationships
// Update Litters with their parent animal IDs
var littersInDb = await _db.Litters.ToDictionaryAsync(l => l.Id);
foreach (var l in data.Litters)
{
if (littersInDb.TryGetValue(l.Id, out var dbLitter))
{
dbLitter.FatherId = l.FatherId;
dbLitter.MotherId = l.MotherId;
}
}
// Update Gerbils with their actual LitterId
var gerbilsInDb = await _db.Gerbils.ToDictionaryAsync(g => g.Id);
foreach (var g in data.Gerbils)
{
if (gerbilsInDb.TryGetValue(g.Id, out var dbGerbil))
{
dbGerbil.LitterId = g.LitterId;
}
}
await _db.SaveChangesAsync();
// 6. Insert all Photos & copy physical files
Directory.CreateDirectory(_photoRoot);
foreach (var p in data.GerbilPhotos)
{
if (!string.IsNullOrEmpty(p.SourcePath))
{
var relPath = p.SourcePath.Replace('/', Path.DirectorySeparatorChar).Replace('\\', Path.DirectorySeparatorChar);
var src = Path.Combine(_sourceDir, relPath);
if (File.Exists(src))
{
var dest = Path.Combine(_photoRoot, p.FileName);
try
{
File.Copy(src, dest, overwrite: true);
}
catch (Exception ex)
{
Console.WriteLine($"Warning: Failed to copy photo {src} to {dest}: {ex.Message}");
}
}
else
{
Console.WriteLine($"Warning: Source photo file not found at {src}");
}
}
_db.GerbilPhotos.Add(new GerbilPhoto
{
Id = p.Id,
GerbilId = p.GerbilId,
FileName = p.FileName,
Caption = p.Caption,
SortOrder = p.SortOrder,
CreatedAt = DateTimeOffset.UtcNow
});
}
await _db.SaveChangesAsync();
return $"Ingestion successful! Imported {contactsAdded} contacts ({contactsUpdated} updated), {data.Litters.Count} litters, {data.Gerbils.Count} gerbils, and {data.GerbilPhotos.Count} photos.";
}
}
public class ResolvedImportData
{
public List<Contact> Contacts { get; set; } = new();
public List<Litter> Litters { get; set; } = new();
public List<Gerbil> Gerbils { get; set; } = new();
public List<ResolvedPhoto> GerbilPhotos { get; set; } = new();
}
public class ResolvedPhoto
{
public Guid Id { get; set; }
public Guid GerbilId { get; set; }
public required string FileName { get; set; }
public string? Caption { get; set; }
public int SortOrder { get; set; }
[JsonPropertyName("_source_path")]
public string? SourcePath { get; set; }
}
}

View File

@@ -21,7 +21,7 @@ namespace GerbilManagerWebAPI.Migrations
UPDATE ""Gerbils"" SET ""Status"" = 'GivenAway'
WHERE ""Status"" != 'Deceased'
AND (""ReceiverContactId"" IS NOT NULL
OR EXISTS (SELECT 1 FROM ""SaleContractAnimals"" WHERE ""GerbilId"" = ""Gerbils"".""Id""));");
OR EXISTS (SELECT 1 FROM ""SaleContractAnimal"" WHERE ""GerbilId"" = ""Gerbils"".""Id""));");
// 4) Age > 7 years (presumed dead) → Deceased, but NOT overriding GivenAway.
migrationBuilder.Sql(@"

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 AddSpottingType : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "SpottingType",
table: "Gerbils",
type: "text",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "SpottingType",
table: "Gerbils");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,40 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace GerbilManagerWebAPI.Migrations
{
/// <inheritdoc />
public partial class AddContactRoles : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "IsBreeder",
table: "Contacts",
type: "boolean",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "IsReceiver",
table: "Contacts",
type: "boolean",
nullable: false,
defaultValue: false);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "IsBreeder",
table: "Contacts");
migrationBuilder.DropColumn(
name: "IsReceiver",
table: "Contacts");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace GerbilManagerWebAPI.Migrations
{
/// <inheritdoc />
public partial class AddIsCastrated : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "IsCastrated",
table: "Gerbils",
type: "boolean",
nullable: false,
defaultValue: false);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "IsCastrated",
table: "Gerbils");
}
}
}

View File

@@ -716,6 +716,12 @@ namespace GerbilManagerWebAPI.Migrations
b.Property<string>("Email")
.HasColumnType("text");
b.Property<bool>("IsBreeder")
.HasColumnType("boolean");
b.Property<bool>("IsReceiver")
.HasColumnType("boolean");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text")
@@ -794,6 +800,9 @@ namespace GerbilManagerWebAPI.Migrations
b.Property<string>("ImportSource")
.HasColumnType("text");
b.Property<bool>("IsCastrated")
.HasColumnType("boolean");
b.Property<bool?>("IsDeaf")
.HasColumnType("boolean");
@@ -830,6 +839,9 @@ namespace GerbilManagerWebAPI.Migrations
b.Property<Guid?>("ReceiverContactId")
.HasColumnType("uuid");
b.Property<string>("SpottingType")
.HasColumnType("text");
b.Property<string>("Status")
.IsRequired()
.HasColumnType("text");

View File

@@ -15,5 +15,8 @@ namespace GerbilManagerWebAPI.Models
public string? Phone { get; set; }
public string? Address { get; set; }
public string? Notes { get; set; }
public bool IsBreeder { get; set; }
public bool IsReceiver { get; set; }
}
}

View File

@@ -41,6 +41,8 @@ namespace GerbilManagerWebAPI.Models
/// <summary>Compact genotype string (frozen GEN-1 contract). Null = not genotyped.</summary>
public string? Genotype { get; set; }
public string? SpottingType { get; set; }
public string? Notes { get; set; }
// Provenance (for the FEAT-8 spreadsheet import).
@@ -80,6 +82,7 @@ namespace GerbilManagerWebAPI.Models
/// Defaults true (manually-added animals are own stock); the FEAT-8 import classifies
/// imported animals. See hive/agents/god/OWNERSHIP-residency.md. Gridify-filterable.</summary>
public bool IsResident { get; set; } = true;
public bool IsCastrated { get; set; }
}
/// <summary>Shared normalisation for the separator-insensitive name search.</summary>

View File

@@ -64,7 +64,7 @@ _Quelldatei = die Stammbaum-Datei(en) in `C:\Users\gulum\dev\Sttammbäume\`, in
**B) Gencode-Konflikt** (+ ggf. Sterbedatum):
| Tier | Konflikt — was stimmt? | Quelldatei(en) |
|---|---|---|
| Milon v.d. K.C. (*27.11.2014) | A-Locus: **Aa** ↔ **aa** | Stammbaum von **South Dakota**.xlsx · von **Tennessee**.xlsx |
| ~~Milon v.d. K.C. (*27.11.2014)~~ ✅ | A-Locus **Aa** (erledigt — Julian 2026-06-07: Aa gewinnt gg. aa) | — |
| ~~Percy of little runners (*16.12.2017)~~ ✅ | P-Locus **Pp** (erledigt) | — |
| ~~Iwana of little runners (*02.10.2018)~~ ✅ | P-Locus **Pp** (erledigt) | — |
| ~~Sokrates v.d. K.C. (*14.12.2015)~~ ✅ | **D-** + Sterbedatum **20.05.2019** (erledigt) | — |

126
README.md
View File

@@ -1,31 +1,95 @@
# GerbilManager
- `GerbilManagerWebAPI/` — ASP.NET Core backend (Swagger unter `/swagger`)
- `gerbil-manager-web/` — React-Frontend (Vite + TypeScript), siehe `gerbil-manager-web/README.md`
# Requirements
Install ef:
dotnet tool install --global dotnet-ef
# Used cmd´s
## Start containers
docker compose up
## Create the initial database
dotnet ef database update
## Create an migration script
dotnet ef migrations add <name>
## Docker
### force compose rebuild
docker-compose build --no-cache
## Frontend (gerbil-manager-web)
cd gerbil-manager-web
npm install
npm run dev
TODO:
- Backup docker volume database
- run "dotnet ef database update" if no database exist.
# GerbilManager
A manager application for gerbil breeding data featuring a .NET Aspire backend and a React-based frontend.
- `GerbilManagerWebAPI/` — ASP.NET Core backend (Swagger under `/swagger`)
- `gerbil-manager-web/` — React Frontend (Vite + TypeScript), see `gerbil-manager-web/README.md`
- `tools/import/` — Python import pipeline scripts and database schemas
## Requirements
To manage database migrations, install the EF Core CLI tool globally:
```bash
dotnet tool install --global dotnet-ef
```
## Running the Application
To run the full stack (PostgreSQL database, WebAPI, and Frontend Vite app) in one command:
```bash
dotnet run --project GerbilManager.AppHost
```
---
## 📥 Data Import & Normalization Pipeline
The application features a Python-based import pipeline that compiles gerbil breeding records, lineage, photos, and contact information from source files (markdown logs, Excel spreadsheets, Word documents) into a unified JSON format, and ingests them into the PostgreSQL database.
### 1. Structure of the Pipeline
* **Source Data**: Located at `C:\Users\gulum\dev\Wurfchronik_Bilder` (contains markdown files, `.xlsx` pedigree trees, and detail logs).
* **Compilation**: [merge_and_resolve.py](file:///C:/Users/gulum/dev/GerbilManager/tools/import/merge_and_resolve.py) processes the source files, extracts breeding details, resolves lineage links, maps colors/genotypes, and normalizes contact names.
* **Normalization Mapping**: `merge_and_resolve.py` contains a `norm_map` dictionary of contact name normalizations. This maps various spelling variations and abbreviations to a single canonical name.
### 2. How to Run the Import
#### Automatic Way (All-in-One)
The PowerShell script in `tools/import` boots the Aspire stack, waits for the API to become ready, and automatically triggers the ingest:
```powershell
powershell -ExecutionPolicy Bypass -File tools/import/run_app_and_import.ps1
```
#### Manual Way
1. **Recompile the import data**:
Run the compiler script from the root directory to parse raw sources and output a fresh `resolved_import.json`:
```bash
python tools/import/merge_and_resolve.py
```
2. **Ingest into the running database**:
Ensure the application is running, then send an HTTP `POST` request to the API:
```bash
Invoke-RestMethod -Uri "http://localhost:5179/import/ingest-resolved" -Method Post
```
---
## 👥 Contact Deduplication Workflow
When the database is wiped and re-imported, you can detect and resolve duplicate contacts (e.g. spelling mistakes, initials vs full names) using the deduplication checklist workflow.
### 1. Search for Duplicate Contacts
You can run duplicate checks using two different similarity parameter models:
* **Round 1 (Clever matching on first 4 characters)**:
```bash
python tools/import/find_duplicates_v5.py
```
Creates: `tools/import/output/potential_duplicates_decision.md`
* **Round 2 (Broader matching on initials, matching last names, phones, or emails)**:
```bash
python tools/import/find_duplicates_round2.py
```
Creates: `tools/import/output/potential_duplicates_decision_round2.md`
### 2. Fill out the Checklist
Open the generated markdown checklist file and edit the checkboxes:
* **Combine/Merge**: Put an `x` in the checkbox `[x]` of the name you want to choose as the **canonical name**. Leave the checkboxes empty `[ ]` for any names you want to **merge into the canonical name**.
* **Standalone**: Put an `o` in the checkbox `[o]` of any name that should remain a separate, standalone contact.
* **New Name**: Put an `x` next to `Custom name:` and type your new desired name inside the backticks.
* **Keep all separate**: Leave all checkboxes in a Choice empty `[ ]`.
*Example:*
```markdown
- [x] Max Mustermann <-- canonical name
- [ ] Max M. <-- merges into Max Mustermann
- [o] Moritz Mustermann <-- remains a separate standalone contact
```
### 3. Apply the Decisions
Once you have saved your decisions in the checklist file, run the parser:
```bash
python tools/import/parse_decisions.py
```
This automatically parses your selections, resolves the merges, and updates the `norm_map` inside `merge_and_resolve.py` so they are **permanently saved** in the codebase.
Run the compilation pipeline again to produce the final deduplicated import JSON:
```bash
python tools/import/merge_and_resolve.py
```

View File

@@ -1,5 +1,5 @@
/** BESTAND-FILTER: die Tiere-Liste zeigt standardmäßig nur den eigenen Bestand. */
import { de, expect, gotoSection, openFilterPanel, skipUnlessMock, test } from './fixtures'
import { de, expect, gotoSection, skipUnlessMock, test } from './fixtures'
const t = de.pages.gerbils
@@ -19,9 +19,8 @@ test('Toggle „Externe Ahnen einblenden” zeigt externe Tiere mit Extern-Marki
await gotoSection(page, de.nav.gerbils)
await expect(page.locator('.gerbil-row', { hasText: 'Krümel' })).toBeVisible()
// UX-MOBILE-1: Checkbox liegt im Filter-Drawer — auf Mobil erst öffnen.
await openFilterPanel(page)
await page.getByRole('checkbox', { name: t.filters.showExternal }).check()
// DESIGN (Rennmaus Filter): „Externe Ahnen" ist ein Toggle-Chip (Button), kein Checkbox.
await page.getByRole('button', { name: t.filters.showExternalShort }).click()
const maxRow = page.locator('.gerbil-row', { hasText: 'Max' })
await expect(maxRow).toBeVisible()

View File

@@ -7,9 +7,6 @@ test('Farbschlag-Auswahl füllt Gencode automatisch aus', async ({ page }) => {
skipUnlessMock()
await page.goto('/rennmaeuse/neu')
const farbschlagSelect = page
.locator('label.field', { has: page.locator(`span:text-is("${t.fields.colorVariety}")`) })
.locator('select')
const genotypeInput = page
.locator('label.field', { has: page.locator(`span:text-is("${t.fields.genotype}")`) })
.locator('input')
@@ -18,7 +15,8 @@ test('Farbschlag-Auswahl füllt Gencode automatisch aus', async ({ page }) => {
await expect(genotypeInput).toHaveValue('')
// 'Agouti' wählen → Gencode wird automatisch befüllt.
await farbschlagSelect.selectOption({ label: 'Agouti' })
await page.locator('.color-picker-trigger').click()
await page.locator('.color-picker-option', { hasText: 'Agouti' }).click()
await expect(genotypeInput).toHaveValue('AA CC DD EE GG PP spsp rere')
})
@@ -26,54 +24,47 @@ test('Gencode-Eingabe aktualisiert die Farbschlag-Auswahl', async ({ page }) =>
skipUnlessMock()
await page.goto('/rennmaeuse/neu')
const farbschlagSelect = page
.locator('label.field', { has: page.locator(`span:text-is("${t.fields.colorVariety}")`) })
.locator('select')
const genotypeInput = page
.locator('label.field', { has: page.locator(`span:text-is("${t.fields.genotype}")`) })
.locator('input')
// Gencode für Schwarz eintippen → Farbschlag-Select springt auf 'Schwarz'.
await genotypeInput.fill('aa CC DD EE GG PP spsp rere')
await expect(farbschlagSelect).toHaveValue('cv-schwarz')
await expect(page.locator('.color-picker-trigger')).toHaveText(/Schwarz/)
})
test('Gencode ohne passende Farbschlag-Auswahl leert das Dropdown', async ({ page }) => {
skipUnlessMock()
await page.goto('/rennmaeuse/neu')
const farbschlagSelect = page
.locator('label.field', { has: page.locator(`span:text-is("${t.fields.colorVariety}")`) })
.locator('select')
const genotypeInput = page
.locator('label.field', { has: page.locator(`span:text-is("${t.fields.genotype}")`) })
.locator('input')
// Zuerst eine bekannte Farbe wählen, damit das Select belegt ist.
await farbschlagSelect.selectOption({ label: 'Agouti' })
await expect(farbschlagSelect).not.toHaveValue('')
await page.locator('.color-picker-trigger').click()
await page.locator('.color-picker-option', { hasText: 'Agouti' }).click()
await expect(page.locator('.color-picker-trigger')).toHaveText(/Agouti/)
// Marder-Genotyp: bekannte Farbe im Engine, aber NICHT im Mock-Dropdown
// → Select wird auf '' (kein Eintrag) zurückgesetzt.
await genotypeInput.fill('aa cchmcchm DD EE GG PP spsp rere')
await expect(farbschlagSelect).toHaveValue('')
await expect(page.locator('.color-picker-trigger')).toHaveText(/— keine Angabe —/)
})
test('Unbekannter Genotyp leert das Farbschlag-Dropdown (Unbekannt-Fall)', async ({ page }) => {
skipUnlessMock()
await page.goto('/rennmaeuse/neu')
const farbschlagSelect = page
.locator('label.field', { has: page.locator(`span:text-is("${t.fields.colorVariety}")`) })
.locator('select')
const genotypeInput = page
.locator('label.field', { has: page.locator(`span:text-is("${t.fields.genotype}")`) })
.locator('input')
await farbschlagSelect.selectOption({ label: 'Blau' })
await expect(farbschlagSelect).not.toHaveValue('')
await page.locator('.color-picker-trigger').click()
await page.locator('.color-picker-option', { hasText: 'Blau' }).click()
await expect(page.locator('.color-picker-trigger')).toHaveText(/Blau/)
// aa CC dd EE gg pp → Unbekannter Farbschlag (nicht im Katalog) → Select leert sich.
await genotypeInput.fill('aa CC dd EE gg pp spsp rere')
await expect(farbschlagSelect).toHaveValue('')
await expect(page.locator('.color-picker-trigger')).toHaveText(/— keine Angabe —/)
})

View File

@@ -1,85 +1,54 @@
/**
* UX-MOBILE-1: FilterPanel — einklappbare Filter auf Smartphone, inline auf Desktop.
* DESIGN (Rennmaus Filter.html): Quick-Chips-Filter der Rennmäuse-Liste.
*
* Telefon (390px): Filter-Button sichtbar, Drawer eingeklappt; Tippen öffnet/schliesst.
* Desktop (1280px): Alle Controls direkt sichtbar, kein Toggle-Button.
* Eine Pillen-Reihe (Status / Geschlecht / Farbschlag / Herkunft) öffnet je ein
* Bottom-Sheet mit Mehrfachauswahl; gewählte Werte erscheinen als entfernbare
* Chips, „Alle löschen" setzt zurück. Mobile-first; identisches Verhalten auf Desktop.
*/
import { de, expect, gotoSection, skipUnlessMock, test } from './fixtures'
import { de, expect, gotoSection, selectListFilter, skipUnlessMock, test } from './fixtures'
const t = de.pages.gerbils
test.describe('FilterPanel Rennmäuse-Liste', () => {
test('Phone: Filter-Drawer standardmäßig eingeklappt, Toggle-Button sichtbar', async ({
page,
}, testInfo) => {
test.describe('Quick-Chips-Filter Rennmäuse-Liste', () => {
test('Kategorie-Pillen sind sichtbar', async ({ page }) => {
skipUnlessMock()
if (testInfo.project.name !== 'phone') return
await gotoSection(page, de.nav.gerbils)
await expect(page.getByRole('heading', { name: t.title, exact: true })).toBeVisible()
// Toggle-Button sichtbar.
const toggle = page.locator('.filter-panel__toggle')
await expect(toggle).toBeVisible()
// Status-Beschriftung im Drawer ist noch verborgen.
await expect(page.getByText(t.filters.status, { exact: true }).first()).not.toBeVisible()
const quickrow = page.locator('.rmf-quickrow')
await expect(quickrow.getByRole('button', { name: t.filters.status })).toBeVisible()
await expect(quickrow.getByRole('button', { name: t.filters.gender })).toBeVisible()
await expect(quickrow.getByRole('button', { name: t.filters.colorVariety })).toBeVisible()
await expect(quickrow.getByRole('button', { name: t.fields.origin })).toBeVisible()
})
test('Phone: Toggle öffnet und schließt den Filter-Drawer', async ({
test('Mehrfachauswahl erzeugt entfernbare Chips, „Alle löschen" setzt zurück', async ({
page,
}, testInfo) => {
}) => {
skipUnlessMock()
if (testInfo.project.name !== 'phone') return
await gotoSection(page, de.nav.gerbils)
const toggle = page.locator('.filter-panel__toggle')
// Öffnen → Status-Feld wird sichtbar.
await toggle.click()
await expect(page.getByText(t.filters.status, { exact: true }).first()).toBeVisible()
// Default: Zucht-Chip ist aktiv.
await expect(page.locator('.rmf-chip-val', { hasText: t.statusLabels.Breeding })).toBeVisible()
// Schließen → wieder verborgen.
await toggle.click()
await expect(page.getByText(t.filters.status, { exact: true }).first()).not.toBeVisible()
// Geschlecht = männlich über das Sheet wählen → zweiter Chip erscheint.
await selectListFilter(page, t.filters.gender, t.genderLabels.male)
await expect(page.locator('.rmf-chip-val', { hasText: t.genderLabels.male })).toBeVisible()
// „Alle löschen" entfernt sämtliche Chips.
await page.getByRole('button', { name: de.filterBar.clearAll }).click()
await expect(page.locator('.rmf-chip')).toHaveCount(0)
})
test('Phone: Badge zählt aktive Filter korrekt', async ({
page,
}, testInfo) => {
test('Einzelner Chip lässt sich per Klick entfernen', async ({ page }) => {
skipUnlessMock()
if (testInfo.project.name !== 'phone') return
await gotoSection(page, de.nav.gerbils)
const toggle = page.locator('.filter-panel__toggle')
// Initial: Status='Active' ist Default → Badge zeigt kein „(N)".
await expect(toggle).toHaveText('Filter')
// Filter-Drawer öffnen und Geschlecht setzen → 1 aktiver Filter.
await toggle.click()
await page.locator('.filter-panel__drawer select').nth(1).selectOption('male')
await expect(toggle).toHaveText('Filter (1)')
// Zurücksetzen → Badge weg.
await page.getByRole('button', { name: de.filterPanel.resetButton }).click()
await expect(toggle).toHaveText('Filter')
})
test('Desktop: Toggle-Button nicht sichtbar, alle Controls inline', async ({
page,
}, testInfo) => {
skipUnlessMock()
if (testInfo.project.name !== 'desktop') return
await gotoSection(page, de.nav.gerbils)
await expect(page.getByRole('heading', { name: t.title, exact: true })).toBeVisible()
// Kein Toggle-Button auf Desktop (display:none via Media Query).
const toggle = page.locator('.filter-panel__toggle')
await expect(toggle).toBeHidden()
// Status-Beschriftung direkt sichtbar.
await expect(page.getByText(t.filters.status, { exact: true }).first()).toBeVisible()
const breedingChip = page.locator('.rmf-chip', {
has: page.locator('.rmf-chip-val', { hasText: t.statusLabels.Breeding }),
})
await expect(breedingChip).toBeVisible()
await breedingChip.click()
await expect(breedingChip).toHaveCount(0)
})
})

View File

@@ -86,6 +86,18 @@ export async function openFilterPanel(page: Page) {
}
}
/**
* DESIGN (Rennmaus Filter): wählt einen Wert im Quick-Chips-Filter der Rennmäuse-Liste.
* Öffnet das Bottom-Sheet der Kategorie, hakt die Option an und schließt mit „Fertig".
*/
export async function selectListFilter(page: Page, categoryLabel: string, optionLabel: string) {
// Pille der Kategorie in der Quick-Chips-Reihe öffnen (nicht den aktiven Chip treffen).
await page.locator('.rmf-quickrow').getByRole('button', { name: categoryLabel }).click()
const sheet = page.getByRole('dialog', { name: categoryLabel })
await sheet.getByRole('button', { name: optionLabel, exact: true }).click()
await sheet.getByRole('button', { name: de.filterBar.done }).click()
}
/** Eindeutiger Name für LIVE-taugliche Create-Flows. */
export const uniqueName = (prefix: string) =>
`${prefix} E2E ${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`

View File

@@ -45,12 +45,20 @@ function matchesFilter(row: Row, filter: string | null): boolean {
function applyOrderBy(rows: Row[], orderBy: string | null): Row[] {
if (!orderBy) return rows
const [field, dir] = orderBy.split(/\s+/)
const sign = dir?.toLowerCase() === 'desc' ? -1 : 1
// Mirror Gridify's comma-separated multi-key ordering (e.g. "name,id"): each key
// is "<field> [desc]"; later keys act as tiebreakers.
const keys = orderBy.split(',').map((part) => {
const [field, dir] = part.trim().split(/\s+/)
return { field, sign: dir?.toLowerCase() === 'desc' ? -1 : 1 }
})
return [...rows].sort((a, b) => {
const av = String(a[field] ?? '')
const bv = String(b[field] ?? '')
return av < bv ? -sign : av > bv ? sign : 0
for (const { field, sign } of keys) {
const av = String(a[field] ?? '')
const bv = String(b[field] ?? '')
if (av < bv) return -sign
if (av > bv) return sign
}
return 0
})
}

View File

@@ -136,8 +136,10 @@ export function seedDb(): MockDb {
{ ...gerbil('max', 'Max', 'male', '2015-08-08', null, 'cv-agouti'), isResident: false, originBreeder: 'Zoohandlung Meier' },
// Statistik: Verstorbene + Abgegebene für Verluste/Bestandskurve
{ ...gerbil('willi', 'Willi', 'male', '2019-09-09', null, 'cv-schwarz'), status: 'Deceased', dateOfDeath: '2022-04-04' },
{ ...gerbil('rosa', 'Rosa', 'female', '2020-02-02', null, 'cv-gold'), status: 'Deceased', dateOfDeath: '2023-08-15' },
{ ...gerbil('pauli', 'Pauli', 'male', '2023-05-01', null, 'cv-himalaya'), status: 'GivenAway', goHomeDate: '2023-07-15', receiverContactId: 'con-huber' },
{ ...gerbil('rosa', 'Rosa', 'female', '2020-02-02', null, 'cv-gold'), status: 'Deceased', dateOfDeath: '2023-08-15' },
{ ...gerbil('pauli-breeder', 'Pauli Breeder', 'male', '2023-05-01', null, 'cv-himalaya'), status: 'GivenAway', goHomeDate: '2023-07-15', receiverContactId: 'con-meier' },
{ ...gerbil('pauli-neither', 'Pauli Neither', 'male', '2023-05-01', null, 'cv-himalaya'), status: 'GivenAway', goHomeDate: '2023-07-15', receiverContactId: 'con-neither' },
// ABGABE: zwei Tiere zur Abgabe im Quarantänebecken (enc-leer ist leer, kein Konflikt
// mit dem Becken-Belegungstest der Großbecken-2-Tiere-Assertion).
{
@@ -189,9 +191,10 @@ 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 },
{ id: 'con-huber', name: 'Familie Huber', email: null, phone: '0151 2345678', address: null, notes: null },
{ id: 'con-frei', name: 'Züchterin Frei', email: null, phone: null, address: null, notes: 'unverknüpft' },
{ 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-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 },
]
const colorVarieties = [

View File

@@ -3,7 +3,7 @@ import { de, expect, gotoSection, test } from './fixtures'
test('alle Bereiche sind über die Navigation erreichbar', async ({ page }) => {
await page.goto('/')
await expect(page.getByRole('heading', { name: de.app.title })).toBeVisible()
// DESIGN: the top "Rennmaus-Manager" title bar was removed — the page heading remains.
await expect(page.getByRole('heading', { name: de.pages.gerbils.title, exact: true })).toBeVisible()
await gotoSection(page, de.nav.gerbils)

View File

@@ -37,10 +37,8 @@ test('Tier-Formular zeigt abgeleiteten Status Verstorben als Text + Hinweis', as
test('Tiere-Liste zeigt Zucht als Standard-Status-Filter', async ({ page }) => {
skipUnlessMock()
await page.goto('/rennmaeuse')
const statusSelect = page
.locator('label.field', { has: page.locator(`span:text-is("${t.filters.status}")`) })
.locator('select')
await expect(statusSelect).toHaveValue('Breeding')
// DESIGN (Rennmaus Filter): der Default-Status erscheint als aktiver, entfernbarer Chip.
await expect(page.locator('.rmf-chip-val', { hasText: t.statusLabels.Breeding })).toBeVisible()
})
test('Neues Tier anlegen mit Status Liebhaber', async ({ page }) => {
@@ -48,7 +46,7 @@ test('Neues Tier anlegen mit Status Liebhaber', async ({ page }) => {
const name = uniqueName('Tier')
await page.goto('/rennmaeuse/neu')
await page.getByLabel(`${t.fields.name} *`).fill(name)
await page.getByLabel(`${t.fields.gender} *`).selectOption({ label: t.genderLabels.male })
await page.getByRole('button', { name: t.genderLabels.male, exact: false }).click()
const statusField = page.locator('label.field', {
has: page.locator(`span:text-is("${t.fields.status}")`),
})
@@ -56,3 +54,41 @@ test('Neues Tier anlegen mit Status Liebhaber', async ({ page }) => {
await page.getByRole('button', { name: t.form.save, exact: true }).click()
await expect(page.getByRole('heading', { name })).toBeVisible()
})
test('Gehege-Feld Sichtbarkeit bei verstorbenen/abgegebenen Tieren', async ({ page }) => {
skipUnlessMock()
// 1. Krümel ist Breeding, hat Gehege 'Großbecken'
await page.goto('/rennmaeuse/kruemel')
await expect(page.locator('dt', { hasText: t.fields.enclosure })).toBeVisible()
await expect(page.locator('dd', { hasText: 'Großbecken' })).toBeVisible()
// 2. Willi ist Deceased (Verstorben) -> Gehege ausgeblendet
await page.goto('/rennmaeuse/willi')
await expect(page.locator('dt', { hasText: t.fields.enclosure })).toBeHidden()
// 3. Pauli ist GivenAway (Abgegeben), Abnehmer ist Familie Huber (isReceiver=true) -> Gehege eingeblendet
await page.goto('/rennmaeuse/pauli')
await expect(page.locator('dt', { hasText: t.fields.enclosure })).toBeVisible()
// 4. Pauli Breeder ist GivenAway, Abnehmer ist Zoohandlung Meier (isBreeder=true) -> Gehege eingeblendet
await page.goto('/rennmaeuse/pauli-breeder')
await expect(page.locator('dt', { hasText: t.fields.enclosure })).toBeVisible()
// 5. Pauli Neither ist GivenAway, Abnehmer ist Weder Noch (weder Züchter noch Abnehmer) -> Gehege ausgeblendet
await page.goto('/rennmaeuse/pauli-neither')
await expect(page.locator('dt', { hasText: t.fields.enclosure })).toBeHidden()
})
test('Verstorbene Tiere: Abnehmer und Inserat-Button ausgeblendet', async ({ page }) => {
skipUnlessMock()
// Willi ist verstorben -> "Abnehmer"-Zeile ausgeblendet und "Zur Abgabe stellen"-Button ausgeblendet
await page.goto('/rennmaeuse/willi')
await expect(page.locator('dt', { hasText: t.fields.receiver })).toBeHidden()
await expect(page.getByRole('button', { name: de.pages.abgabe.markAction })).toBeHidden()
// Krümel lebt -> "Zur Abgabe stellen"-Button vorhanden
await page.goto('/rennmaeuse/kruemel')
await expect(page.getByRole('button', { name: de.pages.abgabe.markAction })).toBeVisible()
})

View File

@@ -1,5 +1,5 @@
/** QA-1: Tiere (Rennmäuse) — Liste/Suche, CRUD, Detail-Tabs (FEAT-1 + FEAT-6). */
import { de, expect, gotoSection, openFilterPanel, skipUnlessMock, test, uniqueName } from './fixtures'
import { de, expect, gotoSection, selectListFilter, skipUnlessMock, test, uniqueName } from './fixtures'
const t = de.pages.gerbils
const tabs = de.pages.tierTabs
@@ -23,13 +23,9 @@ test('Herkunft-Filter (originBreeder) zeigt nur Tiere der gewählten Zucht (SEAR
await expect(page.getByRole('link', { name: /Krümel/ })).toBeVisible()
await expect(page.getByRole('link', { name: /Fridolin/ })).toBeVisible()
// UX-MOBILE-1: Herkunft-Select liegt im Filter-Drawer — auf Mobil erst öffnen.
await openFilterPanel(page)
// Herkunft (originBreeder) auf die Seed-Zucht 'Clan-Kleine-Chaoten' (nur Krümel).
await page
.locator('label.field', { has: page.locator(`span:text-is("${t.fields.origin}")`) })
.locator('select')
.selectOption({ label: 'Clan-Kleine-Chaoten' })
// DESIGN (Rennmaus Filter): Herkunft (originBreeder) über das Quick-Chips-Sheet
// auf die Seed-Zucht 'Clan-Kleine-Chaoten' (nur Krümel) filtern.
await selectListFilter(page, t.fields.origin, 'Clan-Kleine-Chaoten')
await expect(page.getByRole('link', { name: /Krümel/ })).toBeVisible()
await expect(page.getByRole('link', { name: /Fridolin/ })).toBeHidden()
@@ -56,7 +52,7 @@ test('Neue Rennmaus anlegen — Validierung + Erfolg', async ({ page }) => {
const name = uniqueName('Testmaus')
await page.getByLabel(`${t.fields.name} *`).fill(name)
await page.getByLabel(`${t.fields.gender} *`).selectOption({ label: t.genderLabels.female })
await page.getByRole('button', { name: t.genderLabels.female, exact: false }).click()
await page.getByRole('button', { name: t.form.save, exact: true }).click()
// Erfolg: Detailseite des neuen Tieres
@@ -178,7 +174,7 @@ test('Detailseite: Würfe als Elternteil zeigt verlinkten Wurf (ANIMAL-LITTERS)'
// Fridolin ist fatherId von 'w-kruemel' (Wurf K)
await page.goto('/rennmaeuse/fridolin')
await expect(page.getByRole('heading', { name: 'Fridolin' })).toBeVisible()
await expect(page.getByText(t.detail.parentLittersTitle)).toBeVisible()
await expect(page.getByRole('heading', { name: t.detail.parentLittersTitle })).toBeVisible()
// Wurf K als Link sichtbar
const litterLink = page.getByRole('link', { name: /Wurf K/ })
await expect(litterLink).toBeVisible()
@@ -193,6 +189,6 @@ test('Detailseite: Keine Würfe als Elternteil zeigt Leer-Zustand (ANIMAL-LITTER
skipUnlessMock()
// Krümel hat keine Würfe als Elternteil (nur als Kind von w-kruemel)
await page.goto('/rennmaeuse/kruemel')
await expect(page.getByText(t.detail.parentLittersTitle)).toBeVisible()
await expect(page.getByRole('heading', { name: t.detail.parentLittersTitle })).toBeVisible()
await expect(page.getByText(t.detail.parentLittersEmpty)).toBeVisible()
})

View File

@@ -47,7 +47,7 @@ test('Jungtier erfassen — Formular ist mit Wurf + Geburtsdatum vorbelegt', asy
const name = uniqueName('Jungtier')
await page.getByLabel(`${tg.fields.name} *`).fill(name)
await page.getByLabel(`${tg.fields.gender} *`).selectOption({ label: tg.genderLabels.male })
await page.getByRole('button', { name: tg.genderLabels.male, exact: false }).click()
await page.getByRole('button', { name: tg.form.save, exact: true }).click()
await expect(page.getByRole('heading', { name })).toBeVisible()

View File

@@ -5,6 +5,13 @@
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="Verwaltung für Rennmäuse, Würfe und Züchter" />
<!-- DESIGN (Rennmaus Filter): Hanken Grotesk als App-Schrift. -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Hanken+Grotesk:wght@400;500;600;700;800&display=swap"
rel="stylesheet"
/>
<title>Rennmaus-Manager</title>
</head>
<body>

View File

@@ -18,6 +18,7 @@ import NotFoundPage from './pages/NotFoundPage'
import StammbaumPage from './pages/StammbaumPage'
import StatistikPage from './pages/StatistikPage'
import HilfePage from './pages/HilfePage'
import FarbkatalogPage from './pages/FarbkatalogPage'
import VertraegeListPage from './pages/VertraegeListPage'
import VertragWizardPage from './pages/VertragWizardPage'
import EinstellungenPage from './pages/EinstellungenPage'
@@ -70,6 +71,7 @@ export default function App() {
<Route path="abgabe" element={<AbgabePage />} />
<Route path="statistik" element={<StatistikPage />} />
<Route path="hilfe" element={<HilfePage />} />
<Route path="farbkatalog" element={<FarbkatalogPage />} />
{/* FEAT-13: Abgabeverträge + Einstellungen (Zuchtprofil) */}
<Route path="vertraege">
<Route index element={<VertraegeListPage />} />

View File

@@ -10,6 +10,8 @@ export interface CreateContact {
phone?: string | null
address?: string | null
notes?: string | null
isBreeder?: boolean
isReceiver?: boolean
}
/** Payload for PUT /contacts/{id}. */

View File

@@ -61,6 +61,23 @@ export function andFilter(...conditions: Array<string | null | undefined | false
return conditions.filter((c): c is string => Boolean(c)).join(',')
}
/**
* Build an OR-group of conditions for one field, e.g. `(status=Breeding|status=ForSale)`.
* Used for multi-select filters: several allowed values within one category are OR'd,
* while categories are AND'd together by {@link andFilter}.
* Returns '' for an empty value list (so andFilter drops it); a single value needs no parens.
*/
export function orGroup(
field: string,
values: ReadonlyArray<string | number | boolean>,
op: GridifyOp = '==',
): string {
const parts = values.map((value) => condition({ field, op, value }))
if (parts.length === 0) return ''
if (parts.length === 1) return parts[0]
return `(${parts.join('|')})`
}
/** Assemble the URL query string ("?...") from a GridifyQuery. */
export function toQueryString(q: GridifyQuery): string {
const params = new URLSearchParams()

View File

@@ -22,13 +22,15 @@ export async function listEnclosures(): Promise<Enclosure[]> {
}
export async function listContacts(): Promise<Contact[]> {
const paged = await api.get<Paged<Contact>>(`${resources.contacts}${ALL}`)
const paged = await api.get<Paged<Contact>>(
`${resources.contacts}${toQueryString({ page: 1, pageSize: 10000, orderBy: 'name' })}`
)
return paged.items
}
export async function listLitters(): Promise<Litter[]> {
const paged = await api.get<Paged<Litter>>(
`${resources.litters}${toQueryString({ page: 1, pageSize: 1000, orderBy: 'date desc' })}`,
`${resources.litters}${toQueryString({ page: 1, pageSize: 10000, orderBy: 'date desc' })}`,
)
return paged.items
}

View File

@@ -60,6 +60,8 @@ export interface Gerbil {
isDeaf?: boolean | null
/** STAMMBAUM-AVATAR: URL des Profilfotos (erstes Foto nach sortOrder); null = kein Foto. API-relativ oder absolut. */
profilePhotoUrl?: string | null
spottingType?: string | null
isCastrated?: boolean
}
/** Payload for POST /gerbils. */
@@ -83,6 +85,8 @@ export interface CreateGerbil {
characterNote?: string | null
isResident?: boolean
isDeaf?: boolean | null
spottingType?: string | null
isCastrated?: boolean
}
/** Payload for PUT /gerbils/{id} (all optional / partial update). */
@@ -108,6 +112,8 @@ export interface Contact {
phone: string | null
address: string | null
notes: string | null
isBreeder: boolean
isReceiver: boolean
}
export interface Litter {

View File

@@ -31,6 +31,7 @@ const SECONDARY: NavItem[] = [
{ to: '/einstellungen', label: de.nav.settings, icon: '⚙️' },
// WEB-0b: CMS-Verwaltung der öffentlichen Webseite
{ to: '/webseite', label: de.nav.website, icon: '🌐' },
{ to: '/farbkatalog', label: de.nav.colorCatalog, icon: '🎨' },
{ to: '/hilfe', label: de.nav.help, icon: '❓' },
]
@@ -47,11 +48,6 @@ export default function AppShell() {
return (
<div className="app-shell">
<header className="app-header">
<span className="app-logo" aria-hidden="true"><GerbilIcon size="1.5rem" /></span>
<h1 className="app-title">{de.app.title}</h1>
</header>
<nav className="app-nav" aria-label={de.nav.mainNavigation}>
<div className="nav-primary">
{PRIMARY.map((item) => (

View File

@@ -0,0 +1,156 @@
.color-picker-container {
position: relative;
width: 100%;
}
.color-picker-trigger {
font: inherit;
padding: 0.5rem 0.6rem;
border: 1px solid var(--color-border);
border-radius: 0.5rem;
background: var(--color-surface);
color: var(--color-text);
min-height: 44px;
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
cursor: pointer;
text-align: left;
transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
.color-picker-trigger:hover {
border-color: var(--color-accent);
}
.color-picker-trigger:focus {
outline: none;
border-color: var(--color-accent);
box-shadow: 0 0 0 2px var(--color-accent-soft);
}
.color-picker-trigger-content {
display: flex;
align-items: center;
gap: 0.5rem;
}
.color-picker-trigger-name {
font-weight: 500;
}
.color-picker-placeholder {
color: var(--color-text-muted);
}
.color-picker-caret {
color: var(--color-text-muted);
font-size: 0.8rem;
transition: transform 0.15s ease;
}
.color-picker-container[aria-expanded="true"] .color-picker-caret {
transform: rotate(180deg);
}
.color-picker-dropdown {
position: absolute;
top: 100%;
left: 0;
right: 0;
z-index: 1000;
margin-top: 0.25rem;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 0.5rem;
box-shadow: 0 4px 16px rgba(59, 48, 38, 0.12);
display: flex;
flex-direction: column;
max-height: 320px;
overflow: hidden;
}
.color-picker-search-wrapper {
padding: 0.5rem;
background: var(--color-bg);
border-bottom: 1px solid var(--color-border);
}
.color-picker-search {
width: 100%;
padding: 0.4rem 0.6rem;
border: 1px solid var(--color-border);
border-radius: 0.375rem;
background: var(--color-surface);
color: var(--color-text);
font: inherit;
font-size: 0.9rem;
min-height: 36px;
}
.color-picker-search:focus {
outline: none;
border-color: var(--color-accent);
}
.color-picker-options {
overflow-y: auto;
padding: 0.25rem;
display: flex;
flex-direction: column;
gap: 1px;
}
.color-picker-option {
display: flex;
align-items: center;
gap: 0.75rem;
width: 100%;
padding: 0.4rem 0.6rem;
border: none;
background: transparent;
color: var(--color-text);
font: inherit;
font-size: 0.95rem;
cursor: pointer;
border-radius: 0.375rem;
text-align: left;
min-height: 40px;
transition: background-color 0.15s ease, color 0.15s ease;
}
.color-picker-option:hover {
background: var(--color-accent-soft);
}
.color-picker-option--selected {
background: var(--color-accent-soft);
color: var(--color-accent);
font-weight: 600;
}
.color-picker-option-image-wrapper {
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.color-picker-option-image-placeholder {
width: 24px;
height: 24px;
border-radius: 0.25rem;
background: var(--color-bg);
border: 1px solid var(--color-border);
flex-shrink: 0;
}
.color-picker-no-results {
padding: 1rem;
text-align: center;
color: var(--color-text-muted);
font-size: 0.9rem;
}

View File

@@ -0,0 +1,116 @@
import { useState, useEffect, useRef } from 'react'
import { de } from '../strings/de'
import { type ColorVariety } from '../api/types'
import FarbschlagImage from './FarbschlagImage'
import './ColorVarietyPicker.css'
interface ColorVarietyPickerProps {
value: string
onChange: (id: string) => void
colorVarieties: ColorVariety[]
}
export default function ColorVarietyPicker({ value, onChange, colorVarieties }: ColorVarietyPickerProps) {
const [isOpen, setIsOpen] = useState(false)
const [search, setSearch] = useState('')
const containerRef = useRef<HTMLDivElement>(null)
// Close dropdown when clicking outside of it
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
setIsOpen(false)
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [])
// Find currently selected variety
const selectedVariety = colorVarieties.find((cv) => cv.id === value)
// Filter varieties by search query
const filtered = colorVarieties.filter((cv) =>
cv.name.toLowerCase().includes(search.toLowerCase())
)
const handleSelect = (id: string) => {
onChange(id)
setIsOpen(false)
setSearch('')
}
return (
<div className="color-picker-container" ref={containerRef}>
<button
type="button"
className="color-picker-trigger"
onClick={() => setIsOpen((prev) => !prev)}
aria-haspopup="listbox"
aria-expanded={isOpen}
>
<span className="color-picker-trigger-content">
{selectedVariety ? (
<>
<FarbschlagImage name={selectedVariety.name} size={24} />
<span className="color-picker-trigger-name">{selectedVariety.name}</span>
</>
) : (
<span className="color-picker-placeholder">{de.pages.gerbils.form.none}</span>
)}
</span>
<span className="color-picker-caret"></span>
</button>
{isOpen && (
<div className="color-picker-dropdown">
<div className="color-picker-search-wrapper">
<input
type="text"
className="color-picker-search"
placeholder="Farbschlag suchen..."
value={search}
onChange={(e) => setSearch(e.target.value)}
autoFocus
/>
</div>
<div className="color-picker-options" role="listbox">
<button
type="button"
className={`color-picker-option ${!value ? 'color-picker-option--selected' : ''}`}
onClick={() => handleSelect('')}
role="option"
aria-selected={!value}
>
<div className="color-picker-option-image-placeholder" />
<span className="color-picker-option-name">{de.pages.gerbils.form.none}</span>
</button>
{filtered.map((cv) => {
const isSelected = cv.id === value
return (
<button
key={cv.id}
type="button"
className={`color-picker-option ${isSelected ? 'color-picker-option--selected' : ''}`}
onClick={() => handleSelect(cv.id)}
role="option"
aria-selected={isSelected}
>
<div className="color-picker-option-image-wrapper">
<FarbschlagImage name={cv.name} size={24} />
</div>
<span className="color-picker-option-name">{cv.name}</span>
</button>
)
})}
{filtered.length === 0 && (
<div className="color-picker-no-results">
Keine Farbschläge gefunden
</div>
)}
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,271 @@
/**
* DESIGN (Rennmaus Filter.html): mobile-first filter UX —
* 1. a horizontal "Quick-Chips" pill row (one pill per category, with a count badge),
* 2. tapping a pill opens a bottom-sheet of multi-select options (✓ checkboxes,
* searchable for long lists like Farbschlag / Herkunft),
* 3. active selections appear ABOVE the list as removable chips (so they scroll
* away with the list), with "Alle löschen".
*
* The user landed on the Quick-Chips concept, dezent style, brown accent. Colors
* come from the app's existing tokens. `GerbilFilterBar` is the pill row + sheet;
* `ActiveFilterChips` renders the removable chips and is placed above the list.
*/
import { useEffect, useMemo, useState, type CSSProperties, type ReactNode } from 'react'
import { createPortal } from 'react-dom'
import { de } from '../strings/de'
import './gerbilFilterBar.css'
export interface FilterOption {
value: string
label: string
/** Optional swatch dot (used for status colors). */
color?: string
/** Optional leading symbol (used for gender ♂ ♀ ?). */
symbol?: string
}
export interface FilterCategory {
/** Matches the Gridify field / the key into the selected map. */
key: string
label: string
/** Renders a search box above the option list (for long lists). */
searchable?: boolean
options: FilterOption[]
}
function countFor(selected: Record<string, string[]>, key: string): number {
return (selected[key] ?? []).length
}
interface GerbilFilterBarProps {
categories: FilterCategory[]
selected: Record<string, string[]>
onToggle: (key: string, value: string) => void
}
/** The Quick-Chips pill row + the category bottom-sheet. */
export function GerbilFilterBar({ categories, selected, onToggle }: GerbilFilterBarProps) {
const t = de.filterBar
const [activeKey, setActiveKey] = useState<string | null>(null)
const activeCat = categories.find((c) => c.key === activeKey) ?? null
return (
<div className="rmf">
<div className="rmf-quickrow" role="group" aria-label={t.title}>
{categories.map((cat) => {
const n = countFor(selected, cat.key)
return (
<button
key={cat.key}
type="button"
className={'rmf-pill' + (n > 0 ? ' is-on' : '')}
onClick={() => setActiveKey(cat.key)}
aria-haspopup="dialog"
>
{cat.label}
{n > 0 && <span className="rmf-pill-count">{n}</span>}
<span className="rmf-pill-chev" aria-hidden="true"></span>
</button>
)
})}
</div>
{activeCat && (
<FilterSheet
cat={activeCat}
selected={selected[activeCat.key] ?? []}
onToggle={onToggle}
onClose={() => setActiveKey(null)}
/>
)}
</div>
)
}
interface ActiveFilterChipsProps {
categories: FilterCategory[]
selected: Record<string, string[]>
onRemove: (key: string, value: string) => void
onClearAll: () => void
/** Optional per-chip accent colour (e.g. status colours); falls back to the app accent. */
chipColor?: (key: string, value: string) => string | undefined
}
/**
* Removable chips for the active selections, placed above the list so they scroll
* away as you browse. Wraps onto multiple lines — nothing is hidden.
*/
export function ActiveFilterChips({
categories,
selected,
onRemove,
onClearAll,
chipColor,
}: ActiveFilterChipsProps) {
const t = de.filterBar
const chips = categories.flatMap((cat) => {
const labelByValue = new Map(cat.options.map((o) => [o.value, o.label]))
return (selected[cat.key] ?? []).map((value) => ({
key: cat.key,
value,
catLabel: cat.label,
label: labelByValue.get(value) ?? value,
}))
})
if (chips.length === 0) return null
return (
<div className="rmf-chips">
{chips.map((c) => {
const accent = chipColor?.(c.key, c.value)
return (
<button
key={c.key + c.value}
type="button"
className="rmf-chip"
style={accent ? ({ '--chip': accent } as CSSProperties) : undefined}
onClick={() => onRemove(c.key, c.value)}
aria-label={`${c.catLabel}: ${c.label}${t.remove}`}
>
<span className="rmf-chip-cat">{c.catLabel}</span>
<span className="rmf-chip-val">{c.label}</span>
<span className="rmf-chip-x" aria-hidden="true">×</span>
</button>
)
})}
{chips.length > 1 && (
<button type="button" className="rmf-chip rmf-chip-clear" onClick={onClearAll}>
{t.clearAll}
</button>
)}
</div>
)
}
interface BottomSheetProps {
title: string
onClose: () => void
children: ReactNode
}
/**
* Reusable bottom-sheet: dimmed scrim + slide-up panel with grip, title and a
* "Fertig" button. Locks body scroll and closes on Escape / scrim tap. Used for
* the category filters AND the sort selector (same drawer mechanic).
*/
export function BottomSheet({ title, onClose, children }: BottomSheetProps) {
const t = de.filterBar
useEffect(() => {
const prev = document.body.style.overflow
document.body.style.overflow = 'hidden'
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
}
window.addEventListener('keydown', onKey)
return () => {
document.body.style.overflow = prev
window.removeEventListener('keydown', onKey)
}
}, [onClose])
// Portal to <body> so the sheet escapes any sticky/stacking-context ancestor
// (e.g. the sticky header) and renders above the bottom action bar.
return createPortal(
<div className="rmf-scrim" onClick={onClose} role="presentation">
<div
className="rmf-sheet"
role="dialog"
aria-modal="true"
aria-label={title}
onClick={(e) => e.stopPropagation()}
>
<div className="rmf-grip" aria-hidden="true" />
<div className="rmf-sheet-head">
<span className="rmf-sheet-title">{title}</span>
<button type="button" className="rmf-sheet-done" onClick={onClose}>
{t.done}
</button>
</div>
<div className="rmf-sheet-body">{children}</div>
</div>
</div>,
document.body,
)
}
interface FilterSheetProps {
cat: FilterCategory
selected: string[]
onToggle: (key: string, value: string) => void
onClose: () => void
}
function FilterSheet({ cat, selected, onToggle, onClose }: FilterSheetProps) {
return (
<BottomSheet title={cat.label} onClose={onClose}>
<OptionList cat={cat} selected={selected} onToggle={onToggle} />
</BottomSheet>
)
}
interface OptionListProps {
cat: FilterCategory
selected: string[]
onToggle: (key: string, value: string) => void
}
function OptionList({ cat, selected, onToggle }: OptionListProps) {
const t = de.filterBar
const [q, setQ] = useState('')
const options = useMemo(() => {
if (!cat.searchable) return cat.options
const term = q.trim().toLowerCase()
if (!term) return cat.options
return cat.options.filter((o) => o.label.toLowerCase().includes(term))
}, [cat, q])
return (
<div className="rmf-picker">
{cat.searchable && (
<input
className="rmf-search"
placeholder={t.searchPlaceholder}
value={q}
onChange={(e) => setQ(e.target.value)}
autoFocus
aria-label={t.searchPlaceholder}
/>
)}
<div className={'rmf-optlist' + (cat.searchable ? ' rmf-optlist--scroll' : '')}>
{options.length === 0 && <div className="rmf-empty">{t.noMatch}</div>}
{options.map((o) => {
const on = selected.includes(o.value)
return (
<button
key={o.value}
type="button"
className={'rmf-opt' + (on ? ' is-on' : '')}
onClick={() => onToggle(cat.key, o.value)}
aria-pressed={on}
>
<span className="rmf-opt-box" aria-hidden="true">
{on ? '✓' : ''}
</span>
{o.color && <span className="rmf-opt-dot" style={{ background: o.color }} />}
{o.symbol && (
<span className="rmf-opt-sym" aria-hidden="true">
{o.symbol}
</span>
)}
<span className="rmf-opt-label">{o.label}</span>
</button>
)
})}
</div>
</div>
)
}

View File

@@ -21,15 +21,17 @@ export default function GerbilIcon({ size = '1em', style, className }: Props) {
aria-label="Rennmaus"
>
{/* body — sitting upright */}
<path d="M40 14c6 0 11 5 11 12 0 4-2 7-2 11 0 10-6 16-15 16-8 0-14-6-14-15 0-13 9-24 20-24z" />
{/* ear */}
<circle cx="46" cy="15" r="5" />
{/* hind paw */}
<ellipse cx="24" cy="52" rx="9" ry="3.5" />
{/* long curved tail with tuft */}
<path d="M16 51c-7 1-13-2-15-9-1-4 0-8 3-10 1 3 1 6 3 8 3 3 7 4 11 4z" />
<path d="M 34 23 C 38 20, 44 21, 46 25 C 47 27, 45 30, 41 31 C 38 32, 40 38, 40 42 C 40 48, 36 51, 31 52 C 25 52, 21 47, 21 40 C 21 31, 27 25, 34 23 Z" />
{/* ears */}
<path d="M 36 22 Q 33 13 39 11 Q 42 13 39 21 Z" />
<path d="M 32 23 Q 30 15 35 13 Q 37 15 35 22 Z" opacity="0.6" />
{/* front paw */}
<path d="M30 45c3 0 5 1 5 4-3 1-6 1-7-2 0-1 1-2 2-2z" />
<path d="M 41 33 Q 46 33 46 36 Q 44 38 41 35 Z" />
{/* hind paw */}
<path d="M 21 49 C 19 53, 23 55, 30 55 C 33 55, 34 52, 30 50 Z" />
{/* tail and tuft */}
<path d="M 21 47 C 14 51, 9 46, 8 41 C 7 35, 11 27, 16 22 C 17 21, 18 22, 17 23 C 13 28, 10 35, 11 40 C 12 44, 15 47, 21 44 Z" />
<path d="M 16 22 C 16 16, 13 11, 16 7 C 20 3, 22 10, 19 16 C 18 19, 17 21, 16 22 Z" />
</svg>
)
}

View File

@@ -43,6 +43,39 @@
width: 5rem;
}
.namegen-panel__gender {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.namegen-gender-group {
display: inline-flex;
border: 1px solid var(--color-border);
border-radius: 0.4rem;
overflow: hidden;
}
.namegen-gender-btn {
background: none;
border: none;
border-left: 1px solid var(--color-border);
padding: 0.35rem 0.7rem;
font: inherit;
font-size: 0.9rem;
cursor: pointer;
color: var(--color-text);
}
.namegen-gender-btn:first-child {
border-left: none;
}
.namegen-gender-btn.is-active {
background: var(--color-accent, #2563eb);
color: #fff;
}
.namegen-panel__usages {
display: flex;
flex-direction: column;

View File

@@ -12,9 +12,17 @@ interface NameSuggestPanelProps {
const ALL_CODES = NAMEGEN_USAGES.map((u) => u.code) as NamegenUsageCode[]
type GenderSelection = 'any' | 'female' | 'male'
/** Map the gerbil form's gender onto the generator's three-way choice. */
function seedGender(gender: string): GenderSelection {
return gender === 'female' || gender === 'male' ? gender : 'any'
}
export default function NameSuggestPanel({ gender, onPick, onClose }: NameSuggestPanelProps) {
const t = de.namegen
const [letter, setLetter] = useState('')
const [genderSel, setGenderSel] = useState<GenderSelection>(() => seedGender(gender))
const [usages, setUsages] = useState<Set<NamegenUsageCode>>(new Set(ALL_CODES))
const [loading, setLoading] = useState(false)
const [keyMissing, setKeyMissing] = useState(false)
@@ -39,7 +47,7 @@ export default function NameSuggestPanel({ gender, onPick, onClose }: NameSugges
try {
const result = await suggestNames({
letter: letter.trim() || undefined,
gender: gender || undefined,
gender: genderSel === 'any' ? undefined : genderSel,
usages: [...usages] as NamegenUsageCode[],
count: 6,
})
@@ -78,6 +86,28 @@ export default function NameSuggestPanel({ gender, onPick, onClose }: NameSugges
/>
</div>
<div className="namegen-panel__gender">
<span>{t.genderLabel}</span>
<div className="namegen-gender-group" role="radiogroup" aria-label={t.genderLabel}>
{([
['any', t.genderAny],
['female', t.genderFemale],
['male', t.genderMale],
] as const).map(([value, label]) => (
<button
key={value}
type="button"
role="radio"
aria-checked={genderSel === value}
className={`namegen-gender-btn ${genderSel === value ? 'is-active' : ''}`}
onClick={() => setGenderSel(value)}
>
{label}
</button>
))}
</div>
</div>
<div className="namegen-panel__usages">
<span>{t.usagesLabel}</span>
<div className="namegen-usages-grid">

View File

@@ -65,3 +65,54 @@
color: #d97706;
white-space: nowrap;
}
/* Collapsible details styling for the Charakter & Eigenschaften section */
.charakter-details {
border: 1px solid var(--color-border, #ddd);
border-radius: 8px;
background: var(--color-surface, #fff);
margin-top: 1.5rem;
margin-bottom: 1.5rem;
overflow: hidden;
}
.charakter-summary {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.25rem;
cursor: pointer;
list-style: none;
user-select: none;
background: var(--color-accent-soft, #f9f9f9);
transition: background 0.2s ease;
}
.charakter-summary::-webkit-details-marker {
display: none;
}
.charakter-summary:hover {
background: var(--color-hover, #f5f5f5);
}
.charakter-summary h3 {
margin: 0;
font-size: 1.25rem;
color: var(--color-text, #333);
}
.charakter-chevron {
font-size: 1.1rem;
transition: transform 0.2s ease;
flex-shrink: 0;
}
.charakter-details[open] > .charakter-summary .charakter-chevron {
transform: rotate(180deg);
}
.charakter-body {
padding: 1.25rem;
border-top: 1px solid var(--color-border, #ddd);
}

View File

@@ -0,0 +1,427 @@
/* DESIGN (Rennmaus Filter.html): Quick-Chips + Bottom-Sheet + entfernbare Chips.
Mobile-first. Palette + font sizes are taken 1:1 from the design (the app tokens
are now the design's creme/brown/terracotta), so this matches the prototype. */
.rmf {
--rmf-ease: cubic-bezier(0.22, 1, 0.36, 1);
margin: 0.5rem 0 0.25rem;
}
/* ---------- Quick-Chips pill row ---------- */
.rmf-quickrow {
display: flex;
gap: 0.5rem;
overflow-x: auto;
padding: 2px 0 4px;
scrollbar-width: none;
-webkit-overflow-scrolling: touch;
}
.rmf-quickrow::-webkit-scrollbar {
display: none;
}
.rmf-pill {
flex-shrink: 0;
cursor: pointer;
font: inherit;
background: #fff;
border: 1px solid var(--color-border);
color: var(--color-text);
padding: 9px 13px;
border-radius: 999px;
font-size: 14px;
font-weight: 600;
display: inline-flex;
align-items: center;
gap: 0.375rem;
box-shadow: var(--shadow);
transition: transform 0.12s var(--rmf-ease), border-color 0.15s, color 0.15s, background 0.15s;
}
.rmf-pill:active {
transform: scale(0.94);
}
.rmf-pill.is-on {
border-color: var(--color-accent);
color: var(--color-accent);
background: var(--color-accent-soft);
}
.rmf-pill-count {
background: var(--color-accent);
color: #fff;
font-size: 11px;
font-weight: 800;
min-width: 18px;
height: 18px;
padding: 0 5px;
border-radius: 9px;
display: inline-flex;
align-items: center;
justify-content: center;
}
.rmf-pill-chev {
font-size: 11px;
color: var(--color-text-faint);
}
/* ---------- Active filter chips ---------- */
.rmf-chips {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 0.625rem;
}
.rmf-chip {
--chip: var(--color-accent);
cursor: pointer;
font: inherit;
display: inline-flex;
align-items: center;
gap: 0.375rem;
background: color-mix(in srgb, var(--chip) 12%, #fff);
border: 1px solid color-mix(in srgb, var(--chip) 32%, transparent);
color: color-mix(in srgb, var(--chip) 78%, #2b2723);
padding: 6px 8px 6px 11px;
border-radius: 999px;
font-size: 13px;
font-weight: 600;
white-space: nowrap;
animation: rmfChipIn 0.22s var(--rmf-ease);
transition: transform 0.1s;
}
.rmf-chip:active {
transform: scale(0.94);
}
.rmf-chip-cat {
font-size: 11px;
font-weight: 700;
opacity: 0.6;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.rmf-chip-val {
font-weight: 700;
}
.rmf-chip-x {
font-size: 15px;
line-height: 1;
width: 17px;
height: 17px;
display: inline-flex;
align-items: center;
justify-content: center;
background: color-mix(in srgb, var(--chip) 22%, transparent);
border-radius: 50%;
}
.rmf-chip-clear {
background: transparent;
border: 1px solid var(--color-border);
color: var(--color-text-muted);
}
.rmf-chip-clear .rmf-chip-x {
display: none;
}
@keyframes rmfChipIn {
from {
opacity: 0;
transform: scale(0.8);
}
to {
opacity: 1;
transform: scale(1);
}
}
/* ---------- Bottom-Sheet ---------- */
.rmf-scrim {
position: fixed;
inset: 0;
z-index: 60;
background: rgba(43, 33, 25, 0.42);
display: flex;
align-items: flex-end;
justify-content: center;
animation: rmfScrim 0.2s ease;
}
@keyframes rmfScrim {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.rmf-sheet {
width: 100%;
max-width: 460px;
background: var(--color-bg);
border-radius: 22px 22px 0 0;
max-height: 82vh;
display: flex;
flex-direction: column;
box-shadow: 0 -8px 40px rgba(43, 33, 25, 0.25);
animation: rmfSheetUp 0.32s var(--rmf-ease);
}
@keyframes rmfSheetUp {
from {
transform: translateY(100%);
}
to {
transform: translateY(0);
}
}
.rmf-grip {
width: 40px;
height: 4px;
border-radius: 2px;
background: var(--color-text-faint);
opacity: 0.5;
margin: 9px auto 4px;
}
.rmf-sheet-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.375rem 1rem 0.75rem;
border-bottom: 1px solid var(--color-border);
}
.rmf-sheet-title {
font-size: 18px;
font-weight: 800;
color: var(--color-text);
}
.rmf-sheet-done {
background: var(--color-accent);
color: #fff;
border: none;
font: inherit;
font-weight: 700;
font-size: 14px;
padding: 0.5rem 1rem;
border-radius: 999px;
cursor: pointer;
}
.rmf-sheet-body {
overflow-y: auto;
padding: 0.5rem 1rem 1.5rem;
-webkit-overflow-scrolling: touch;
}
/* ---------- Option list (multi-select checkboxes) ---------- */
.rmf-picker {
display: flex;
flex-direction: column;
}
.rmf-search {
width: 100%;
border: 1px solid var(--color-border);
background: #fff;
border-radius: 14px;
padding: 0.75rem 0.875rem;
font: inherit;
font-size: 1rem;
color: var(--color-text);
outline: none;
margin: 0.25rem 0 0.375rem;
}
.rmf-search:focus {
border-color: var(--color-accent);
}
.rmf-optlist {
display: flex;
flex-direction: column;
gap: 2px;
padding-top: 4px;
}
.rmf-optlist--scroll {
max-height: 46vh;
overflow-y: auto;
}
.rmf-opt {
display: flex;
align-items: center;
gap: 0.7rem;
background: none;
border: none;
font: inherit;
padding: 0.75rem 0.375rem;
cursor: pointer;
color: var(--color-text);
font-size: 1rem;
font-weight: 500;
text-align: left;
width: 100%;
border-radius: 10px;
transition: background 0.12s;
}
.rmf-opt:hover {
background: var(--color-accent-soft);
}
.rmf-opt.is-on {
font-weight: 700;
}
.rmf-opt-box {
flex-shrink: 0;
width: 24px;
height: 24px;
border-radius: 7px;
border: 1.5px solid var(--color-border);
background: #fff;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 14px;
color: #fff;
font-weight: 800;
transition: background 0.14s, border-color 0.14s;
}
.rmf-opt.is-on .rmf-opt-box {
background: var(--color-accent);
border-color: var(--color-accent);
}
.rmf-opt-dot {
width: 12px;
height: 12px;
border-radius: 50%;
flex-shrink: 0;
}
.rmf-opt-sym {
width: 22px;
text-align: center;
font-size: 16px;
color: var(--color-text-muted);
}
.rmf-opt-label {
flex: 1;
}
.rmf-empty {
padding: 1.1rem 0.375rem;
color: var(--color-text-muted);
font-size: 15px;
}
/* ---------- Search bar ---------- */
.rmf-searchbar {
margin-top: 0.75rem;
position: relative;
display: flex;
align-items: center;
background: #fff;
border: 1px solid var(--color-border);
border-radius: 14px;
box-shadow: var(--shadow);
}
.rmf-search-ico {
padding-left: 0.875rem;
font-size: 19px;
color: var(--color-text-faint);
}
.rmf-searchfield {
flex: 1;
border: none;
background: transparent;
outline: none;
font: inherit;
font-size: 16px;
color: var(--color-text);
padding: 0.875rem 0.75rem;
min-width: 0;
}
.rmf-searchfield::placeholder {
color: var(--color-text-faint);
}
.rmf-search-clear {
border: none;
background: var(--color-accent-soft);
color: var(--color-text-muted);
width: 24px;
height: 24px;
border-radius: 50%;
margin-right: 0.625rem;
font-size: 1rem;
line-height: 1;
cursor: pointer;
flex-shrink: 0;
}
/* ---------- Bar: Externe-Ahnen toggle + Sortieren — two equal-width chips ---------- */
.rm-bar {
display: flex;
align-items: stretch;
gap: 0.5rem;
margin-top: 0.625rem;
}
/* Shared pill: both chips are the SAME size (flex: 1 → equal width). */
.rmf-bar-chip {
flex: 1 1 0;
min-width: 0;
cursor: pointer;
font: inherit;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
background: #fff;
border: 1px solid var(--color-border);
color: var(--color-text-muted);
padding: 8px 12px;
border-radius: 999px;
font-size: 13px;
font-weight: 600;
white-space: nowrap;
box-shadow: var(--shadow);
transition: background 0.15s, border-color 0.15s, color 0.15s, transform 0.1s;
}
.rmf-bar-chip:active {
transform: scale(0.97);
}
/* Externe-Ahnen active state. */
.rmf-bar-chip.is-on {
background: var(--color-accent-soft);
border-color: color-mix(in srgb, var(--color-accent) 32%, transparent);
color: color-mix(in srgb, var(--color-accent) 78%, #2b2723);
}
.rmf-bar-chip-label {
overflow: hidden;
text-overflow: ellipsis;
}
.rmf-bar-chip-ico {
color: var(--color-accent);
font-weight: 800;
}
.rmf-bar-chip-chev {
font-size: 11px;
color: var(--color-text-faint);
}
/* Toggle icon circle on the Externe-Ahnen chip (+ → ✓). */
.rmf-chip-toggle-ico {
font-size: 14px;
line-height: 1;
width: 17px;
height: 17px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 50%;
font-weight: 800;
background: color-mix(in srgb, var(--color-accent) 14%, transparent);
color: var(--color-accent);
transition: background 0.15s, color 0.15s;
}
.rmf-bar-chip.is-on .rmf-chip-toggle-ico {
background: var(--color-accent);
color: #fff;
}
/* Desktop: the sheet becomes a centered modal rather than a bottom drawer. */
@media (min-width: 768px) {
.rmf-scrim {
align-items: center;
}
.rmf-sheet {
border-radius: 18px;
max-width: 420px;
max-height: 70vh;
animation: rmfChipIn 0.2s var(--rmf-ease);
}
}

View File

@@ -16,6 +16,7 @@ import {
fromJSON,
wildType,
extractGenotypeFlags,
displayGenotypeSafe,
} from '../genotype'
import { combineLocus } from '../punnett'
import { LOCI, type LocusKey } from '../loci'
@@ -686,4 +687,48 @@ describe('GEN-3h: breeder bracket-notation display + E-locus e-before-ef order',
expect(g.Sp).toEqual(['Sp', 'sp'])
expect(toDisplayString(g)).toBe('aa c[chm]c[chm] Dd e- Gg Pp Spsp')
})
// ── GENOTYPE-PARSE-CRASH / displayGenotypeSafe resilience ───────────────
it('handles blank c locus and c[-] resiliently without crashing', () => {
// Vandana's genotype
const rawVandana = 'Aa Cc Dd eef Gg P? spsp ??'
const g = fromDisplayString(rawVandana)
expect(g.C).toEqual(['C', '?'])
expect(toDisplayString(g)).toBe('Aa C- Dd ee[f] Gg P- spsp')
// cchmc allele
const g2 = fromDisplayString('Aa cchmc Dd ee Gg Pp spsp')
expect(g2.C).toEqual(['cchm', '?'])
expect(toDisplayString(g2)).toBe('Aa c[chm]- Dd ee Gg Pp spsp')
// c[-] and blank c standalone
const g3 = fromDisplayString('Aa c[-] Dd ee Gg Pp spsp')
expect(g3.C).toEqual(['C', 'C']) // default to wild-type since c[-] was skipped
expect(toDisplayString(g3)).toBe('Aa CC Dd ee Gg Pp spsp')
const g4 = fromDisplayString('Aa c Dd ee Gg Pp spsp')
expect(g4.C).toEqual(['C', 'C']) // default to wild-type since c was skipped
expect(toDisplayString(g4)).toBe('Aa CC Dd ee Gg Pp spsp')
})
it('displayGenotypeSafe formats parsed genotypes and handles fallback safely', () => {
// 1. Parseable with unknown alleles, replacing ? with -
expect(displayGenotypeSafe('Aa C? Dd')).toBe('Aa C- Dd EE GG PP spsp')
// 2. Parseable with trailing ?? (stripped completely)
expect(displayGenotypeSafe('Aa CC Dd EE GG Pp spsp ??')).toBe('Aa CC Dd EE GG Pp spsp')
expect(displayGenotypeSafe('Aa Cc Dd eef Gg P? spsp ??')).toBe('Aa C- Dd ee[f] Gg P- spsp')
expect(displayGenotypeSafe('Aa CC Dd EE GG Pp spsp Slsl ??')).toBe('Aa CC Dd EE GG Pp spsp Slsl')
// 3. Unparseable fallback: removes ?? / -- / [-], replaces remaining ? with -
expect(displayGenotypeSafe('Aa Cc XX ??')).toBe('Aa Cc XX')
expect(displayGenotypeSafe('Aa Cc XX --')).toBe('Aa Cc XX')
expect(displayGenotypeSafe('Aa Cc XX [-]')).toBe('Aa Cc XX')
expect(displayGenotypeSafe('Aa Cc? XX')).toBe('Aa Cc- XX')
// 4. Handles null/undefined/empty
expect(displayGenotypeSafe(null)).toBe('')
expect(displayGenotypeSafe(undefined)).toBe('')
expect(displayGenotypeSafe('')).toBe('')
})
})

View File

@@ -192,6 +192,11 @@ function normalizeToken(tok: string): string | null {
// the generic [-]→? rule below (which makes the bracket-dash a wildcard,
// leaving the leading allele intact for splitToken).
t = t.replace(/(?<=[A-Za-z])e\[-\]/g, '?')
t = t.replace(/(?<=[A-Za-z])c\[-\]/g, '?')
t = t.replace(/(?<=[A-Za-z])c$/g, '?')
t = t.replace(/^c\[-\]$/g, '??')
t = t.replace(/^c\?$/g, '??')
t = t.replace(/^c$/g, '??')
t = t.replace(/\[-\]/g, '?') // bare/standalone bracket-unknown → wildcard
// GEN-3c: plain dash is the breeder's UNKNOWN marker on input; store internally as '?'.
t = t.replace(/-/g, '?')
@@ -257,6 +262,25 @@ export function fromDisplayString(input: string): Genotype {
return makeGenotype({ ...base, ...acc })
}
export function displayGenotypeSafe(raw: string | null | undefined): string {
if (!raw) return ''
try {
const parsed = fromDisplayString(raw)
return toDisplayString(parsed)
} catch {
// Fallback path:
return raw
.trim()
.split(/\s+/)
.filter((tok) => {
const norm = normalizeToken(tok)
return norm !== null && norm !== '?' && norm !== '??'
})
.map((tok) => tok.replace(/\?/g, '-'))
.join(' ')
}
}
export function hasUnknown(g: Genotype): boolean {
return LOCUS_ORDER.some((l) => g[l][0] === WILDCARD || g[l][1] === WILDCARD)
}

View File

@@ -20,6 +20,7 @@ export {
toJSON,
fromJSON,
hasUnknown,
displayGenotypeSafe,
WILDCARD,
} from './genotype'
export type { Genotype, AllelePair } from './genotype'

View File

@@ -21,7 +21,7 @@ export interface AsyncState<T> {
reload: () => void
}
function messageFor(err: unknown): string {
export function messageFor(err: unknown): string {
if (err instanceof ApiError) return err.message
if (err instanceof Error && err.message) return err.message
return de.api.errors.unknown

View File

@@ -0,0 +1,151 @@
/**
* Server-paginated infinite list (no react-query).
*
* `loader(page)` fetches ONE page; pages are accumulated into `items`. The list
* never loads everything up front — `loadMore()` (wired to a scroll sentinel)
* advances exactly one page at a time until `hasMore` is false.
*
* Changing `resetKey` (e.g. the filter/sort/search signature) clears the list and
* reloads from page 1. `reload()` re-fetches page 1 on demand (e.g. retry).
*
* Design mirrors useApi (and satisfies the strict react-hooks rules):
* - the request signature lives in `req`; the query reset on `resetKey` change is
* an in-render state adjustment (not a setState-in-effect), so no cascade.
* - `loading` / `loadingMore` are DERIVED from resolved-vs-current request keys,
* never set synchronously inside the fetch effect.
* - the loader is read through a ref so the effect keys purely on the request id.
*/
import { useCallback, useEffect, useRef, useState } from 'react'
import { messageFor } from './useApi'
import type { Paged } from '../api/types'
export interface InfiniteListState<T> {
items: T[]
total: number
/** Initial / reset load (page 1) in flight. */
loading: boolean
/** A subsequent page is being appended. */
loadingMore: boolean
error: string | null
hasMore: boolean
/** Advance one page; the scroll sentinel calls this. */
loadMore: () => void
/** Re-fetch from page 1 (retry). */
reload: () => void
}
interface Req {
key: string
page: number
nonce: number
}
interface Resolved<T> {
reqId: string
items: T[]
total: number
error: string | null
}
export function useInfiniteList<T>(
loader: (page: number) => Promise<Paged<T>>,
resetKey: string,
): InfiniteListState<T> {
const loaderRef = useRef(loader)
useEffect(() => {
loaderRef.current = loader
})
const [req, setReq] = useState<Req>({ key: resetKey, page: 1, nonce: 0 })
// Adjust state during render when the query signature changes — the recommended
// pattern for deriving state from changed inputs (no setState-in-effect cascade).
if (req.key !== resetKey) {
setReq({ key: resetKey, page: 1, nonce: 0 })
}
const queryId = `${req.nonce}:${req.key}`
const reqId = `${queryId}#${req.page}`
const [resolved, setResolved] = useState<Resolved<T>>({
reqId: '',
items: [],
total: 0,
error: null,
})
// Tracks an in-flight request synchronously so loadMore() can't advance (and
// re-fetch/append) the same page twice if the scroll sentinel fires repeatedly.
const inFlightRef = useRef(false)
useEffect(() => {
let cancelled = false
const page = req.page
const id = `${req.nonce}:${req.key}#${page}`
inFlightRef.current = true
loaderRef
.current(page)
.then((res) => {
if (cancelled) return
setResolved((prev) => ({
reqId: id,
// page 1 replaces (fresh query); later pages append.
items: page === 1 ? res.items : [...prev.items, ...res.items],
total: res.totalCount,
error: null,
}))
})
.catch((err) => {
if (!cancelled) setResolved((prev) => ({ ...prev, reqId: id, error: messageFor(err) }))
})
.finally(() => {
if (!cancelled) inFlightRef.current = false
})
return () => {
cancelled = true
}
}, [req]) // req is a fresh object per change (key/page/nonce) → one fetch per change
// Resolved data only counts if it belongs to the current query signature.
const resolvedQueryId = resolved.reqId.slice(0, resolved.reqId.lastIndexOf('#'))
const sameQuery = resolved.reqId !== '' && resolvedQueryId === queryId
const inFlight = resolved.reqId !== reqId
const items = sameQuery ? resolved.items : []
const total = sameQuery ? resolved.total : 0
const loading = !sameQuery || (inFlight && req.page === 1)
const loadingMore = sameQuery && inFlight && req.page > 1
const error = inFlight ? null : sameQuery ? resolved.error : null
const hasMore = sameQuery && !inFlight && items.length < total
const loadMore = useCallback(() => {
if (inFlightRef.current) return
setReq((r) => ({ ...r, page: r.page + 1 }))
}, [])
const reload = useCallback(() => setReq((r) => ({ key: r.key, page: 1, nonce: r.nonce + 1 })), [])
return { items, total, loading, loadingMore, error, hasMore, loadMore, reload }
}
/**
* Wires an IntersectionObserver to a sentinel element that triggers loadMore() as
* it scrolls into view. Render the returned ref on a small element shown only while
* `list.hasMore`, e.g. `{list.hasMore && <div ref={sentinelRef} />}`. Re-subscribes
* when the load state changes so it keeps paging while the sentinel stays in view.
*/
export function useInfiniteSentinel<T>(list: InfiniteListState<T>) {
const ref = useRef<HTMLDivElement | null>(null)
const { hasMore, loading, loadingMore, loadMore } = list
useEffect(() => {
const el = ref.current
if (!el) return
const io = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && hasMore && !loading && !loadingMore) loadMore()
},
{ rootMargin: '300px 0px' },
)
io.observe(el)
return () => io.disconnect()
}, [hasMore, loading, loadingMore, loadMore])
return ref
}

View File

@@ -2,15 +2,20 @@
Seitenleiste links ab 768px (Laptop). */
:root {
--color-bg: #faf7f2;
/* DESIGN (Rennmaus Filter): the app palette is the design's creme/brown/terracotta. */
--color-bg: #f6efe2;
--color-surface: #ffffff;
--color-border: #e5ddd0;
--color-text: #3b3026;
--color-text-muted: #8a7d6b;
--color-accent: #b5651d;
--color-accent-soft: #f6e7d7;
--color-border: #e7dcc8;
--color-text: #3d2e23;
--color-text-muted: #8c7f6e;
--color-text-faint: #b4a790;
--color-accent: #a85f2e;
--color-accent-soft: #f3e7da;
/* DESIGN (Rennmaus Filter): the single soft elevation used on cards, pills,
the search bar and buttons. */
--shadow: 0 1px 2px rgba(61, 46, 35, 0.06), 0 2px 8px rgba(61, 46, 35, 0.05);
font-family: system-ui, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
font-family: 'Hanken Grotesk', system-ui, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
line-height: 1.5;
color: var(--color-text);
background-color: var(--color-bg);
@@ -41,46 +46,41 @@ a {
/* --- Grundgerüst (Smartphone) --- */
/* DESIGN: the top "Rennmaus-Manager" title bar is removed; on mobile only the
bottom action bar remains, on desktop the left sidebar.
The shell is a fixed viewport box and ONLY the main area scrolls — so the bottom
nav (mobile) and the sidebar (desktop) stay put, and the page header's
position: sticky pins reliably to the top of the scrolling main area. */
.app-shell {
display: grid;
min-height: 100dvh;
grid-template-rows: auto 1fr auto;
height: 100dvh;
overflow: hidden;
grid-template-rows: 1fr auto;
grid-template-areas:
'header'
'main'
'nav';
}
.app-header {
grid-area: header;
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1rem;
background: var(--color-surface);
border-bottom: 1px solid var(--color-border);
position: sticky;
top: 0;
}
.app-logo {
font-size: 1.5rem;
}
.app-title {
font-size: 1.15rem;
margin: 0;
}
.app-main {
grid-area: main;
padding: 1rem;
/* No top padding: the page's sticky header pins flush to the top with no gap
(pages add their own top spacing via .page). */
padding: 0 1rem 1.5rem;
max-width: 60rem;
width: 100%;
/* UX-MOBILE-2: prevent any wide content (e.g. genotype table) from making
the page body scroll horizontally. The .genotype-table-scroll wrapper
provides the per-table horizontal scroll. */
/* MAIN is the scroll container: min-height:0 lets the 1fr grid row shrink below
its content so it can scroll. overflow-anchor:none stops the browser from
re-adjusting scroll when the sticky title collapses (no scroll "fight"). */
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
overflow-anchor: none;
}
/* Top spacing for page content (replaces the removed .app-main top padding). The
Rennmäuse page opts out — its sticky header provides its own top padding. */
.page {
padding-top: 1rem;
}
/* Tab-Leiste unten */
@@ -92,6 +92,8 @@ a {
border-top: 1px solid var(--color-border);
position: sticky;
bottom: 0;
/* Above the sticky page header + cards so it is never overlapped. */
z-index: 30;
padding-bottom: env(safe-area-inset-bottom);
}
@@ -122,10 +124,8 @@ a {
@media (min-width: 768px) {
.app-shell {
grid-template-columns: 14rem 1fr;
grid-template-rows: auto 1fr;
grid-template-areas:
'header header'
'nav main';
grid-template-rows: 1fr;
grid-template-areas: 'nav main';
}
.app-nav {
@@ -136,6 +136,9 @@ a {
border-right: 1px solid var(--color-border);
padding: 1rem 0.5rem;
position: static;
/* Sidebar stays fixed; scrolls internally only if the link list is taller
than the viewport. */
overflow-y: auto;
}
.nav-link {
@@ -156,7 +159,7 @@ a {
}
.app-main {
padding: 1.5rem 2rem;
padding: 0 2rem 1.5rem;
}
}
@@ -793,10 +796,149 @@ textarea {
}
/* Profilfoto im Detail-Kopf */
.profile-photo {
width: 56px;
height: 56px;
border-radius: 50%;
object-fit: cover;
border: 2px solid var(--color-border);
.profile-header-summary {
display: flex;
align-items: center;
gap: 1.5rem;
}
.profile-header-info {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.profile-header-badges {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
align-items: center;
}
.profile-photo {
width: 200px;
height: 200px;
border-radius: 1.25rem;
object-fit: cover;
border: 4px solid var(--color-border);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.08);
transition: transform 0.25s cubic-bezier(0.4, 0, 0.2, 1), box-shadow 0.25s cubic-bezier(0.4, 0, 0.2, 1);
}
.profile-photo:hover {
transform: translateY(-4px) scale(1.02);
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.12);
}
.form-row-2col {
display: flex;
gap: 1rem;
align-items: flex-end;
margin-bottom: 0.5rem;
}
.form-row-2col > :first-child {
flex: 2;
min-width: 0;
}
.form-row-2col > :last-child {
flex: 1;
min-width: 0;
min-height: 44px;
display: flex;
align-items: center;
}
.form-group-box {
border: 1px solid var(--color-border);
border-radius: 0.6rem;
padding: 1.25rem;
background: var(--color-surface);
box-shadow: inset 0 1px 3px rgba(59, 48, 38, 0.02);
display: flex;
flex-direction: column;
gap: 1rem;
margin: 0.5rem 0 1.25rem 0;
max-width: 32rem; /* match form max-width */
}
.form-group-box__title {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
font-weight: 700;
color: var(--color-accent);
margin-bottom: 0.25rem;
border-bottom: 1px dashed var(--color-border);
padding-bottom: 0.5rem;
}
/* Gender Segmented Control / Radio Buttons */
.gender-selector-group {
display: flex;
gap: 0.5rem;
margin-top: 0.25rem;
max-width: 32rem;
}
.gender-btn {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.75rem 1rem;
border-radius: 0.5rem;
border: 1px solid var(--color-border);
background: var(--color-surface);
color: var(--color-text);
font-family: inherit;
font-size: 0.95rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease-in-out;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.02);
}
.gender-btn:hover {
background: var(--color-bg);
border-color: var(--color-text-muted);
}
.gender-btn__icon {
font-size: 1.15rem;
line-height: 1;
}
/* Active styles */
.gender-btn--male.is-active {
border-color: #3b82f6;
background: #eff6ff;
color: #1d4ed8;
box-shadow: 0 0 0 1px #3b82f6;
}
.gender-btn--male.is-active .gender-btn__icon {
color: #2563eb;
}
.gender-btn--female.is-active {
border-color: #ec4899;
background: #fdf2f8;
color: #be185d;
box-shadow: 0 0 0 1px #ec4899;
}
.gender-btn--female.is-active .gender-btn__icon {
color: #db2777;
}
.gender-btn--unknown.is-active {
border-color: #6b7280;
background: #f3f4f6;
color: #374151;
box-shadow: 0 0 0 1px #6b7280;
}
.gender-btn--unknown.is-active .gender-btn__icon {
color: #4b5563;
}

View File

@@ -13,7 +13,8 @@ import {
syncRequests,
type RequestStatus,
} from '../api/requests'
import { useApi, useMutation } from '../hooks/useApi'
import { useMutation } from '../hooks/useApi'
import { useInfiniteList, useInfiniteSentinel } from '../hooks/useInfiniteList'
import { formatDateTime } from '../format/labels'
import { FilterPanel } from '../components/FilterPanel'
import './anfragen.css'
@@ -31,19 +32,20 @@ export function StatusBadge({ status }: { status: RequestStatus }) {
export default function AnfragenPage() {
const t = de.pages.anfragen
const [status, setStatus] = useState<RequestStatus | ''>('')
const [page, setPage] = useState(1)
const [notice, setNotice] = useState<string | null>(null)
const requests = useApi(
() =>
const requests = useInfiniteList(
(page) =>
listRequests({
page,
pageSize: PAGE_SIZE,
orderBy: 'receivedAt desc',
orderBy: 'receivedAt desc,id',
filter: status === '' ? undefined : `status==${status}`,
}),
[page, status],
`${status}`,
)
const sentinelRef = useInfiniteSentinel(requests)
const { items, total, loading } = requests
const sync = useMutation(() => syncRequests())
async function onSync() {
@@ -63,7 +65,7 @@ export default function AnfragenPage() {
<header className="page-head">
<div>
<h2>{t.title}</h2>
{requests.data && <p className="muted">{t.countText(requests.data.totalCount)}</p>}
{!loading && <p className="muted">{t.countText(total)}</p>}
</div>
<div className="head-actions">
<button type="button" className="btn btn--primary" onClick={onSync} disabled={sync.pending}>
@@ -78,16 +80,13 @@ export default function AnfragenPage() {
{/* Status-Filter */}
<FilterPanel
activeCount={status !== '' ? 1 : 0}
onReset={() => { setStatus(''); setPage(1) }}
onReset={() => setStatus('')}
>
<label className="field">
<span>{t.detail.statusLabel}</span>
<select
value={status}
onChange={(e) => {
setStatus(e.target.value as RequestStatus | '')
setPage(1)
}}
onChange={(e) => setStatus(e.target.value as RequestStatus | '')}
>
<option value="">{t.filterAll}</option>
{REQUEST_STATUSES.map((s) => (
@@ -99,7 +98,7 @@ export default function AnfragenPage() {
</label>
</FilterPanel>
{requests.loading && <p className="muted">{de.common.loading}</p>}
{loading && <p className="muted">{de.common.loading}</p>}
{requests.error && (
<div className="alert alert--error">
<span>{requests.error}</span>
@@ -109,13 +108,13 @@ export default function AnfragenPage() {
</div>
)}
{requests.data && requests.data.items.length === 0 && (
{!loading && items.length === 0 && (
<p className="muted">{status === '' ? t.empty : t.emptyFiltered}</p>
)}
{requests.data && requests.data.items.length > 0 && (
{items.length > 0 && (
<ul className="card-list">
{requests.data.items.map((r) => (
{items.map((r) => (
<li key={r.id}>
<Link to={`/anfragen/${r.id}`} className="gerbil-card anfrage-card">
<span className="gerbil-card__name">{r.fromName ?? r.fromAddress}</span>
@@ -130,24 +129,12 @@ export default function AnfragenPage() {
</ul>
)}
{requests.data && requests.data.totalCount > PAGE_SIZE && (
<nav className="pager" aria-label="Seitennavigation">
<button type="button" className="btn" disabled={page <= 1} onClick={() => setPage(page - 1)}>
{de.common.previous}
</button>
<span>
{de.common.page} {page} {de.common.of}{' '}
{Math.max(1, Math.ceil(requests.data.totalCount / PAGE_SIZE))}
</span>
<button
type="button"
className="btn"
disabled={page >= Math.ceil(requests.data.totalCount / PAGE_SIZE)}
onClick={() => setPage(page + 1)}
>
{de.common.next}
</button>
</nav>
{/* Infinite-scroll sentinel + "loading more" indicator. */}
{requests.hasMore && <div ref={sentinelRef} aria-hidden="true" style={{ height: 1 }} />}
{requests.loadingMore && (
<p className="muted" style={{ textAlign: 'center', padding: '0.75rem 0' }}>
{de.common.loading}
</p>
)}
</section>
)

View File

@@ -1,35 +1,34 @@
/** FEAT-2: Becken-Liste — Gridify-Namenssuche + Paging, Link auf Detailseite. */
/** FEAT-2: Becken-Liste — Gridify-Namenssuche + Infinite Scroll, Link auf Detailseite. */
import { useState } from 'react'
import { Link } from 'react-router-dom'
import { de } from '../strings/de'
import { listEnclosuresPaged } from '../api/enclosures'
import { condition, type GridifyQuery } from '../api/gridify'
import { useApi } from '../hooks/useApi'
import { condition } from '../api/gridify'
import { useInfiniteList, useInfiniteSentinel } from '../hooks/useInfiniteList'
const PAGE_SIZE = 20
export default function BeckenPage() {
const t = de.pages.becken
const [search, setSearch] = useState('')
const [page, setPage] = useState(1)
const filter = search.trim()
? condition({ field: 'name', op: 'contains', value: search.trim() })
: undefined
const query: GridifyQuery = { filter, orderBy: 'name', page, pageSize: PAGE_SIZE }
const enclosures = useApi(() => listEnclosuresPaged(query), [filter, page])
const total = enclosures.data?.totalCount ?? 0
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
const items = enclosures.data?.items ?? []
// Infinite scroll; trailing `id` keeps OFFSET pagination stable (no repeats/omits).
const list = useInfiniteList(
(page) => listEnclosuresPaged({ filter, orderBy: 'name,id', page, pageSize: PAGE_SIZE }),
`${filter ?? ''}`,
)
const sentinelRef = useInfiniteSentinel(list)
const { items, total, loading } = list
return (
<section className="page">
<header className="page-head">
<div>
<h2>{t.title}</h2>
{enclosures.data && (
{!loading && (
<p className="muted">
{total} {t.countLabel}
</p>
@@ -46,27 +45,22 @@ export default function BeckenPage() {
className="input"
placeholder={t.searchPlaceholder}
value={search}
onChange={(e) => {
setSearch(e.target.value)
setPage(1)
}}
onChange={(e) => setSearch(e.target.value)}
aria-label={t.fields.name}
/>
</div>
{enclosures.loading && <p className="muted">{de.common.loading}</p>}
{enclosures.error && (
{loading && <p className="muted">{de.common.loading}</p>}
{list.error && (
<div className="alert alert--error">
<span>{enclosures.error}</span>
<button type="button" className="btn" onClick={enclosures.reload}>
<span>{list.error}</span>
<button type="button" className="btn" onClick={list.reload}>
{de.common.retry}
</button>
</div>
)}
{!enclosures.loading && !enclosures.error && items.length === 0 && (
<p className="muted">{t.empty}</p>
)}
{!loading && !list.error && items.length === 0 && <p className="muted">{t.empty}</p>}
{items.length > 0 && (
<ul className="card-list">
@@ -81,28 +75,12 @@ export default function BeckenPage() {
</ul>
)}
{totalPages > 1 && (
<nav className="pager" aria-label="Seitennavigation">
<button
type="button"
className="btn"
disabled={page <= 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
{de.common.previous}
</button>
<span className="muted">
{de.common.page} {page} {de.common.of} {totalPages}
</span>
<button
type="button"
className="btn"
disabled={page >= totalPages}
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
>
{de.common.next}
</button>
</nav>
{/* Infinite-scroll sentinel + "loading more" indicator. */}
{list.hasMore && <div ref={sentinelRef} aria-hidden="true" style={{ height: 1 }} />}
{list.loadingMore && (
<p className="muted" style={{ textAlign: 'center', padding: '0.75rem 0' }}>
{de.common.loading}
</p>
)}
</section>
)

View File

@@ -0,0 +1,81 @@
import { useState } from 'react'
import { de } from '../strings/de'
import { CATALOG, farbschlagImageUrl } from '../genetics'
import './farbkatalog.css'
export default function FarbkatalogPage() {
const [search, setSearch] = useState('')
// Filter color catalog by query
const filtered = CATALOG.filter((variety) => {
const q = search.toLowerCase()
return (
variety.name.toLowerCase().includes(q) ||
(variety.english && variety.english.toLowerCase().includes(q)) ||
variety.canonicalGenotype.toLowerCase().includes(q)
);
})
return (
<section className="page farbkatalog-page">
<header className="page-head">
<div>
<h2>{de.pages.farbkatalog.title}</h2>
<p className="muted">
{de.pages.farbkatalog.subtitle.replace('{count}', String(CATALOG.length))}
</p>
</div>
</header>
<div className="farbkatalog-search-bar">
<input
type="search"
className="input"
placeholder={de.pages.farbkatalog.searchPlaceholder}
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
<div className="farbkatalog-grid">
{filtered.map((variety) => {
const imgUrl = farbschlagImageUrl(variety.image)
return (
<div key={variety.name} className="farbkatalog-card">
<div className="farbkatalog-card-media">
{imgUrl ? (
<img
src={imgUrl}
alt={variety.name}
className="farbkatalog-card-img"
loading="lazy"
/>
) : (
<div className="farbkatalog-card-media-placeholder">
<span>{variety.name.substring(0, 2).toUpperCase()}</span>
</div>
)}
</div>
<div className="farbkatalog-card-body">
<h4 className="farbkatalog-card-title">{variety.name}</h4>
{variety.english && (
<span className="farbkatalog-card-subtitle">{variety.english}</span>
)}
<div className="farbkatalog-card-genotype">
<code>{variety.canonicalGenotype}</code>
</div>
</div>
</div>
)
})}
{filtered.length === 0 && (
<div className="farbkatalog-empty">
<span className="farbkatalog-empty-icon">🔍</span>
<p>{de.pages.farbkatalog.empty}</p>
</div>
)}
</div>
</section>
)
}

View File

@@ -6,7 +6,7 @@ import { listLitters as listLittersPaged } from '../api/litters'
import { listColorVarieties, listContacts, listEnclosures, listLitters } from '../api/lookups'
import { useApi, useMutation } from '../hooks/useApi'
import { formatDate, genderLabel, statusLabel } from '../format/labels'
import { fromDisplayString, genotypeToFarbschlag, toDisplayString } from '../genetics'
import { fromDisplayString, genotypeToFarbschlag, displayGenotypeSafe } from '../genetics'
import FarbschlagImage from '../components/FarbschlagImage'
import Charakterbogen from '../components/Charakterbogen'
// FEAT-6 (Oscar): Tab-Inhalte + Profilfoto
@@ -22,10 +22,10 @@ function describeGenotype(genotype: string | null): { display: string; farbschla
if (!genotype || !genotype.trim()) return null
try {
const g = fromDisplayString(genotype)
return { display: toDisplayString(g), farbschlag: genotypeToFarbschlag(g) }
return { display: displayGenotypeSafe(genotype), farbschlag: genotypeToFarbschlag(g) }
} catch {
// Unparseable: show the raw value, no derived Farbschlag.
return { display: genotype, farbschlag: de.genetics.unknownFarbschlag }
// Unparseable: show the safely-rendered raw value, no derived Farbschlag.
return { display: displayGenotypeSafe(genotype), farbschlag: de.genetics.unknownFarbschlag }
}
}
@@ -49,6 +49,7 @@ export default function GerbilDetailPage() {
const [charTraits, setCharTraits] = useState<string[]>([])
const [charNote, setCharNote] = useState('')
const [charInit, setCharInit] = useState<string | null>(null)
const [isCharacterExpanded, setIsCharacterExpanded] = useState(true)
const saveCharacter = useMutation(() =>
updateGerbil(id, { characterTraits: charTraits, characterNote: charNote.trim() || null }),
)
@@ -78,6 +79,12 @@ export default function GerbilDetailPage() {
[litters.data],
)
const receiverContact = useMemo(() => {
const receiverId = gerbil.data?.receiverContactId
if (!receiverId || !contacts.data) return null
return contacts.data.find((c) => c.id === receiverId) ?? null
}, [gerbil.data?.receiverContactId, contacts.data])
if (gerbil.loading) return <p className="muted">{de.common.loading}</p>
if (gerbil.error || !gerbil.data) {
return (
@@ -91,6 +98,11 @@ export default function GerbilDetailPage() {
}
const g = gerbil.data
const showEnclosure = g.status !== 'Deceased' && (
g.status !== 'GivenAway' ||
(receiverContact ? (receiverContact.isBreeder || receiverContact.isReceiver) : false)
)
const geno = describeGenotype(g.genotype)
const lookup = (map: Map<string, string>, key: string | null) => (key ? (map.get(key) ?? '—') : '—')
const storedColorName = g.colorVarietyId ? (colorName.get(g.colorVarietyId) ?? null) : null
@@ -100,20 +112,25 @@ export default function GerbilDetailPage() {
setCharInit(g.id)
setCharTraits(g.characterTraits ?? [])
setCharNote(g.characterNote ?? '')
setIsCharacterExpanded(g.status !== 'Deceased' && g.status !== 'GivenAway')
}
return (
<section className="page">
<header className="page-head">
<div>
<div className="profile-header-summary">
<GerbilProfilePhoto gerbilId={g.id} />
<h2>{g.name || de.pages.gerbils.nameless}</h2>
<span className={`badge badge--${g.status.toLowerCase()}`}>{statusLabel(g.status)}</span>
{g.isResident === false && (
<span className="badge badge--external" title={de.pages.gerbils.externalTitle}>
{de.pages.gerbils.externalBadge}
</span>
)}
<div className="profile-header-info">
<h2>{g.name || de.pages.gerbils.nameless}</h2>
<div className="profile-header-badges">
<span className={`badge badge--${g.status.toLowerCase()}`}>{statusLabel(g.status)}</span>
{g.isResident === false && (
<span className="badge badge--external" title={de.pages.gerbils.externalTitle}>
{de.pages.gerbils.externalBadge}
</span>
)}
</div>
</div>
</div>
<div className="head-actions">
<Link to={`/rennmaeuse/${g.id}/bearbeiten`} className="btn btn--primary">
@@ -122,7 +139,7 @@ export default function GerbilDetailPage() {
<Link to={`/genetik?parent=${g.id}`} className="btn">
{t.detail.testMating}
</Link>
{g.status !== 'ForSale' && (
{g.status !== 'ForSale' && g.status !== 'Deceased' && (
<button
type="button"
className="btn"
@@ -152,7 +169,15 @@ export default function GerbilDetailPage() {
<h3>{t.detail.masterData}</h3>
<dl className="def-list">
<Row label={t.fields.gender} value={genderLabel(g.gender)} />
<Row
label={t.fields.gender}
value={
<>
{genderLabel(g.gender)}
{g.isCastrated && <span style={{ marginLeft: '0.5rem', opacity: 0.8 }}>(Kastrat)</span>}
</>
}
/>
<Row label={t.fields.status} value={statusLabel(g.status)} />
<Row label={t.fields.dateOfBirth} value={formatDate(g.dateOfBirth)} />
{g.status === 'Deceased' && (
@@ -177,7 +202,18 @@ export default function GerbilDetailPage() {
)
}
/>
<Row label={t.fields.enclosure} value={lookup(enclosureName, g.enclosureId)} />
{g.spottingType && (
<Row
label={t.fields.spottingType}
value={
de.pages.gerbils.spottingTypes[g.spottingType as keyof typeof de.pages.gerbils.spottingTypes] ||
g.spottingType
}
/>
)}
{showEnclosure && (
<Row label={t.fields.enclosure} value={lookup(enclosureName, g.enclosureId)} />
)}
<Row
label={t.fields.litter}
value={
@@ -194,7 +230,9 @@ export default function GerbilDetailPage() {
: (g.originBreeder || null)
}
/>
<Row label={t.fields.receiver} value={lookup(contactName, g.receiverContactId)} />
{g.status !== 'Deceased' && (
<Row label={t.fields.receiver} value={lookup(contactName, g.receiverContactId)} />
)}
<Row label={t.fields.notes} value={g.notes || null} />
</dl>
@@ -209,7 +247,7 @@ export default function GerbilDetailPage() {
{geno.farbschlag}
{storedColorName &&
geno.farbschlag !== de.genetics.unknownFarbschlag &&
storedColorName !== geno.farbschlag && (
storedColorName !== geno.farbschlag.replace(' Schecke', '').replace(' Rex', '') && (
<small className="mismatch-hint"> {t.detail.farbschlagMismatch}</small>
)}
</>
@@ -220,27 +258,38 @@ export default function GerbilDetailPage() {
<p className="muted">{t.detail.genotypeNotSet}</p>
)}
<h3>{de.character.sectionTitle}</h3>
<Charakterbogen
traits={charTraits}
note={charNote}
onTraitsChange={setCharTraits}
onNoteChange={setCharNote}
/>
<div className="form-actions">
<button
type="button"
className="btn btn--primary"
disabled={saveCharacter.pending}
onClick={async () => {
const r = await saveCharacter.run()
if (r.ok) gerbil.reload()
}}
>
{saveCharacter.pending ? t.form.saving : de.character.save}
</button>
{saveCharacter.error && <span className="error-text">{saveCharacter.error}</span>}
</div>
<details
className="charakter-details"
open={isCharacterExpanded}
onToggle={(e) => setIsCharacterExpanded(e.currentTarget.open)}
>
<summary className="charakter-summary">
<h3>{de.character.sectionTitle}</h3>
<span className="charakter-chevron" aria-hidden="true"></span>
</summary>
<div className="charakter-body">
<Charakterbogen
traits={charTraits}
note={charNote}
onTraitsChange={setCharTraits}
onNoteChange={setCharNote}
/>
<div className="form-actions" style={{ marginTop: '1rem' }}>
<button
type="button"
className="btn btn--primary"
disabled={saveCharacter.pending}
onClick={async () => {
const r = await saveCharacter.run()
if (r.ok) gerbil.reload()
}}
>
{saveCharacter.pending ? t.form.saving : de.character.save}
</button>
{saveCharacter.error && <span className="error-text">{saveCharacter.error}</span>}
</div>
</div>
</details>
<h3>{t.detail.parentLittersTitle}</h3>
{parentLitters.loading && <p className="muted">{de.common.loading}</p>}

View File

@@ -3,12 +3,12 @@ import { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom'
import { de } from '../strings/de'
import { createGerbil, getGerbil, updateGerbil } from '../api/gerbils'
import { listColorVarieties, listContacts, listEnclosures, listLitters } from '../api/lookups'
import { GENDERS, SELECTABLE_STATUSES, type CreateGerbil, type Gender, type GerbilStatus } from '../api/types'
import { SELECTABLE_STATUSES, type CreateGerbil, type Gender, type GerbilStatus } from '../api/types'
import { useApi, useMutation } from '../hooks/useApi'
import { genderLabel, statusLabel } from '../format/labels'
import { fromDisplayString, genotypeToFarbschlag, UNKNOWN_FARBSCHLAG } from '../genetics'
import FarbschlagImage from '../components/FarbschlagImage'
import NameSuggestPanel from '../components/NameSuggestPanel'
import ColorVarietyPicker from '../components/ColorVarietyPicker'
import '../components/NameSuggestPanel.css'
interface FormState {
@@ -29,11 +29,14 @@ interface FormState {
notes: string
isResident: boolean
isDeaf: boolean | null
spottingType: string
isSchecke: boolean
isCastrated: boolean
}
const EMPTY: FormState = {
name: '',
gender: '',
gender: 'unknown',
status: 'Breeding',
dateOfBirth: '',
dateOfDeath: '',
@@ -49,6 +52,9 @@ const EMPTY: FormState = {
notes: '',
isResident: true,
isDeaf: null,
spottingType: '',
isSchecke: false,
isCastrated: false,
}
function formFromGerbil(g: {
@@ -69,7 +75,11 @@ function formFromGerbil(g: {
notes: string | null
isResident?: boolean | null
isDeaf?: boolean | null
spottingType?: string | null
isCastrated?: boolean | null
}): FormState {
const hasSp = g.genotype?.includes('Sp') || false
const hasSpottingType = Boolean(g.spottingType)
return {
name: g.name,
gender: g.gender,
@@ -88,6 +98,9 @@ function formFromGerbil(g: {
notes: g.notes ?? '',
isResident: g.isResident ?? true,
isDeaf: g.isDeaf ?? null,
spottingType: g.spottingType ?? '',
isSchecke: hasSp || hasSpottingType,
isCastrated: g.isCastrated ?? false,
}
}
@@ -154,7 +167,9 @@ export default function GerbilFormPage() {
function validate(): boolean {
const next: Partial<Record<keyof FormState, string>> = {}
if (form.name.trim() === '') next.name = t.validation.nameRequired
if (form.gender === '') next.gender = t.validation.genderRequired
if (form.gender === 'unknown' || form.gender === '') {
next.gender = t.validation.genderRequired
}
if (!isGenotypeValid(form.genotype)) next.genotype = t.validation.genotypeInvalid
if (form.dateOfBirth && form.dateOfDeath && form.dateOfDeath < form.dateOfBirth) {
next.dateOfDeath = t.validation.dateOfDeathBeforeBirth
@@ -184,6 +199,8 @@ export default function GerbilFormPage() {
notes: nn(form.notes),
isResident: form.isResident,
isDeaf: form.isDeaf,
spottingType: nn(form.spottingType),
isCastrated: form.isCastrated,
}
const result = await mutation.run(body)
if (result.ok) navigate(`/rennmaeuse/${result.value.id}`)
@@ -192,9 +209,6 @@ export default function GerbilFormPage() {
if (isEdit && existing.loading) return <p className="muted">{de.common.loading}</p>
const selectedColorName =
(colorVarieties.data ?? []).find((cv) => cv.id === form.colorVarietyId)?.name ?? null
return (
<section className="page">
<h2>{isEdit ? t.form.editTitle : t.form.createTitle}</h2>
@@ -229,22 +243,176 @@ export default function GerbilFormPage() {
)}
<label className="field">
<span>{t.fields.gender} *</span>
<select
value={form.gender}
onChange={(e) => set('gender', e.target.value as Gender | '')}
aria-invalid={Boolean(errors.gender)}
>
<span>{t.fields.origin}</span>
<select value={form.originContactId} onChange={(e) => set('originContactId', e.target.value)}>
<option value="">{t.form.none}</option>
{GENDERS.map((g) => (
<option key={g} value={g}>
{genderLabel(g)}
{(contacts.data ?? []).filter((c) => c.isBreeder || c.id === form.originContactId).map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
{errors.gender && <small className="error-text">{errors.gender}</small>}
</label>
<label className="field">
<span>{t.fields.originBreeder}</span>
<input
className="input"
value={form.originBreeder}
placeholder="z. B. Zucht der Kleinen Chaoten"
onChange={(e) => set('originBreeder', e.target.value)}
/>
</label>
<div className="field">
<span>{t.fields.gender} *</span>
<div className="gender-selector-group">
{(['male', 'female'] as const).map((g) => {
const active = form.gender === g
let icon = '♂'
let colorClass = 'male'
if (g === 'female') {
icon = '♀'
colorClass = 'female'
}
return (
<button
key={g}
type="button"
className={`gender-btn gender-btn--${colorClass} ${active ? 'is-active' : ''}`}
onClick={() => set('gender', active ? 'unknown' : g)}
>
<span className="gender-btn__icon">{icon}</span>
<span className="gender-btn__label">{genderLabel(g)}</span>
</button>
)
})}
</div>
{errors.gender && <small className="error-text">{errors.gender}</small>}
</div>
<label className="field field--check">
<span>{t.fields.isCastrated}</span>
<input
type="checkbox"
checked={form.isCastrated}
onChange={(e) => set('isCastrated', e.target.checked)}
/>
</label>
<label className="field">
<span>{t.fields.dateOfBirth}</span>
<input
type="date"
className="input"
value={form.dateOfBirth}
onChange={(e) => set('dateOfBirth', e.target.value)}
/>
</label>
<div className="form-group-box">
<div className="form-group-box__title">{de.pages.gerbils.form.colorSectionTitle}</div>
<div className="form-row-2col">
<div className="field">
<span>{t.fields.colorVariety}</span>
<ColorVarietyPicker
value={form.colorVarietyId}
colorVarieties={colorVarieties.data ?? []}
onChange={(newId) => {
set('colorVarietyId', newId)
if (newId) {
const variety = (colorVarieties.data ?? []).find((cv) => cv.id === newId)
if (variety?.canonicalGenotype) set('genotype', variety.canonicalGenotype)
}
}}
/>
</div>
<label className="field field--check">
<span>{de.pages.gerbils.form.isScheckeLabel}</span>
<input
type="checkbox"
checked={form.isSchecke}
onChange={(e) => {
const checked = e.target.checked
set('isSchecke', checked)
if (!checked) {
set('spottingType', '')
}
}}
/>
</label>
</div>
{form.isSchecke && (
<label className="field">
<span>{t.fields.spottingType}</span>
<select
value={form.spottingType}
onChange={(e) => set('spottingType', e.target.value)}
>
<option value="">{de.pages.gerbils.spottingTypes.none}</option>
{Object.keys(de.pages.gerbils.spottingTypes)
.filter((key) => key !== 'none')
.map((key) => (
<option key={key} value={key}>
{de.pages.gerbils.spottingTypes[key as keyof typeof de.pages.gerbils.spottingTypes]}
</option>
))}
</select>
</label>
)}
<label className="field">
<span>{t.fields.genotype}</span>
<input
className="input"
value={form.genotype}
placeholder="Aa CC Dd EE GG Pp Spsp rere"
onChange={(e) => {
const val = e.target.value
set('genotype', val)
if (val.includes('Sp')) {
set('isSchecke', true)
}
if (val.trim() !== '' && isGenotypeValid(val)) {
try {
const name = genotypeToFarbschlag(fromDisplayString(val))
if (name === UNKNOWN_FARBSCHLAG) {
set('colorVarietyId', '')
} else {
const match = (colorVarieties.data ?? []).find((cv) => cv.name === name)
set('colorVarietyId', match?.id ?? '')
}
} catch {
// invalid while typing — leave colorVarietyId unchanged
}
}
}}
aria-invalid={Boolean(errors.genotype)}
/>
<small className={errors.genotype ? 'error-text' : 'muted'}>
{errors.genotype ?? t.form.genotypeHint}
</small>
</label>
<label className="field">
<span>{t.fields.isDeaf}</span>
<select
value={form.isDeaf === null ? '' : String(form.isDeaf)}
onChange={(e) => {
const v = e.target.value
set('isDeaf', v === '' ? null : v === 'true')
}}
>
<option value="">{t.form.isDeafUnknown}</option>
<option value="true">{t.form.isDeafYes}</option>
<option value="false">{t.form.isDeafNo}</option>
</select>
</label>
</div>
{/* STATUS-MODEL: Deceased/GivenAway are derived by the backend — show as read-only with hint */}
{form.status === 'Deceased' || form.status === 'GivenAway' ? (
<div className="field">
@@ -269,16 +437,6 @@ export default function GerbilFormPage() {
</label>
)}
<label className="field">
<span>{t.fields.dateOfBirth}</span>
<input
type="date"
className="input"
value={form.dateOfBirth}
onChange={(e) => set('dateOfBirth', e.target.value)}
/>
</label>
{form.status === 'Deceased' && (
<>
<label className="field">
@@ -315,31 +473,6 @@ export default function GerbilFormPage() {
</label>
)}
<label className="field">
<span>{t.fields.colorVariety}</span>
<span className="farbschlag-value">
<select
value={form.colorVarietyId}
onChange={(e) => {
const newId = e.target.value
set('colorVarietyId', newId)
if (newId) {
const variety = (colorVarieties.data ?? []).find((cv) => cv.id === newId)
if (variety?.canonicalGenotype) set('genotype', variety.canonicalGenotype)
}
}}
>
<option value="">{t.form.none}</option>
{(colorVarieties.data ?? []).map((cv) => (
<option key={cv.id} value={cv.id}>
{cv.name}
</option>
))}
</select>
{selectedColorName && <FarbschlagImage name={selectedColorName} size={36} />}
</span>
</label>
<label className="field">
<span>{t.fields.enclosure}</span>
<select value={form.enclosureId} onChange={(e) => set('enclosureId', e.target.value)}>
@@ -364,27 +497,7 @@ export default function GerbilFormPage() {
</select>
</label>
<label className="field">
<span>{t.fields.origin}</span>
<select value={form.originContactId} onChange={(e) => set('originContactId', e.target.value)}>
<option value="">{t.form.none}</option>
{(contacts.data ?? []).map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
</label>
<label className="field">
<span>{t.fields.originBreeder}</span>
<input
className="input"
value={form.originBreeder}
placeholder="z. B. Zucht der Kleinen Chaoten"
onChange={(e) => set('originBreeder', e.target.value)}
/>
</label>
<label className="field">
<span>{t.fields.receiver}</span>
@@ -393,7 +506,7 @@ export default function GerbilFormPage() {
onChange={(e) => set('receiverContactId', e.target.value)}
>
<option value="">{t.form.none}</option>
{(contacts.data ?? []).map((c) => (
{(contacts.data ?? []).filter((c) => c.isReceiver || c.id === form.receiverContactId).map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
@@ -401,55 +514,16 @@ export default function GerbilFormPage() {
</select>
</label>
<label className="field">
<span>{t.fields.genotype}</span>
<input
className="input"
value={form.genotype}
placeholder="Aa CC Dd EE GG Pp Spsp rere"
onChange={(e) => {
const val = e.target.value
set('genotype', val)
if (val.trim() !== '' && isGenotypeValid(val)) {
try {
const name = genotypeToFarbschlag(fromDisplayString(val))
if (name === UNKNOWN_FARBSCHLAG) {
set('colorVarietyId', '')
} else {
const match = (colorVarieties.data ?? []).find((cv) => cv.name === name)
set('colorVarietyId', match?.id ?? '')
}
} catch {
// invalid while typing — leave colorVarietyId unchanged
}
}
}}
aria-invalid={Boolean(errors.genotype)}
/>
<small className={errors.genotype ? 'error-text' : 'muted'}>
{errors.genotype ?? t.form.genotypeHint}
</small>
</label>
<label className="field">
<span>{t.fields.notes}</span>
<textarea value={form.notes} onChange={(e) => set('notes', e.target.value)} />
</label>
<label className="field">
<span>{t.fields.isDeaf}</span>
<select
value={form.isDeaf === null ? '' : String(form.isDeaf)}
onChange={(e) => {
const v = e.target.value
set('isDeaf', v === '' ? null : v === 'true')
}}
>
<option value="">{t.form.isDeafUnknown}</option>
<option value="true">{t.form.isDeafYes}</option>
<option value="false">{t.form.isDeafNo}</option>
</select>
</label>
<label className="field field--check" title={t.form.isResidentHint}>
<span>{t.form.isResidentLabel}</span>

View File

@@ -96,6 +96,17 @@ export default function KontaktDetailPage() {
{deleteError && <div className="alert alert--error">{deleteError}</div>}
<dl className="def-list">
<div className="def-row">
<dt>Rolle</dt>
<dd>
{[
c.isBreeder ? 'Züchter' : null,
c.isReceiver ? 'Abnehmer' : null,
]
.filter(Boolean)
.join(' · ') || '—'}
</dd>
</div>
<div className="def-row">
<dt>{t.fields.email}</dt>
<dd>{c.email ?? '—'}</dd>

View File

@@ -12,9 +12,11 @@ interface FormState {
phone: string
address: string
notes: string
isBreeder: boolean
isReceiver: boolean
}
const EMPTY: FormState = { name: '', email: '', phone: '', address: '', notes: '' }
const EMPTY: FormState = { name: '', email: '', phone: '', address: '', notes: '', isBreeder: false, isReceiver: true }
/** "" -> null, sonst der Wert. */
const nn = (s: string): string | null => (s.trim() === '' ? null : s)
@@ -40,6 +42,8 @@ export default function KontaktFormPage() {
phone: existing.data.phone ?? '',
address: existing.data.address ?? '',
notes: existing.data.notes ?? '',
isBreeder: existing.data.isBreeder,
isReceiver: existing.data.isReceiver,
})
}
@@ -63,6 +67,8 @@ export default function KontaktFormPage() {
phone: nn(form.phone),
address: nn(form.address),
notes: nn(form.notes),
isBreeder: form.isBreeder,
isReceiver: form.isReceiver,
})
if (result.ok) navigate(`/kontakte/${result.value.id}`)
// On failure mutation.error drives the inline alert; run() never throws.
@@ -116,6 +122,24 @@ export default function KontaktFormPage() {
<small className="muted">{t.form.addressHint}</small>
</label>
<label className="field field--check">
<span>{t.fields.isBreeder}</span>
<input
type="checkbox"
checked={form.isBreeder}
onChange={(e) => set('isBreeder', e.target.checked)}
/>
</label>
<label className="field field--check">
<span>{t.fields.isReceiver}</span>
<input
type="checkbox"
checked={form.isReceiver}
onChange={(e) => set('isReceiver', e.target.checked)}
/>
</label>
<label className="field">
<span>{t.fields.notes}</span>
<textarea value={form.notes} onChange={(e) => set('notes', e.target.value)} />

View File

@@ -1,35 +1,35 @@
/** FEAT-2: Kontakte-Liste — Gridify-Namenssuche + Paging, Link auf Detailseite. */
/** FEAT-2: Kontakte-Liste — Gridify-Namenssuche + Infinite Scroll, Link auf Detailseite. */
import { useState } from 'react'
import { Link } from 'react-router-dom'
import { de } from '../strings/de'
import { listContactsPaged } from '../api/contacts'
import { condition, type GridifyQuery } from '../api/gridify'
import { useApi } from '../hooks/useApi'
import { condition } from '../api/gridify'
import { useInfiniteList, useInfiniteSentinel } from '../hooks/useInfiniteList'
const PAGE_SIZE = 20
export default function KontaktePage() {
const t = de.pages.kontakte
const [search, setSearch] = useState('')
const [page, setPage] = useState(1)
const filter = search.trim()
? condition({ field: 'name', op: 'contains', value: search.trim() })
: undefined
const query: GridifyQuery = { filter, orderBy: 'name', page, pageSize: PAGE_SIZE }
const contacts = useApi(() => listContactsPaged(query), [filter, page])
const total = contacts.data?.totalCount ?? 0
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
const items = contacts.data?.items ?? []
// Infinite scroll: one page at a time, accumulated. The trailing `id` makes the
// ordering a total order so OFFSET pagination never repeats/omits a row.
const list = useInfiniteList(
(page) => listContactsPaged({ filter, orderBy: 'name,id', page, pageSize: PAGE_SIZE }),
`${filter ?? ''}`,
)
const sentinelRef = useInfiniteSentinel(list)
const { items, total, hasMore, loading, loadingMore } = list
return (
<section className="page">
<header className="page-head">
<div>
<h2>{t.title}</h2>
{contacts.data && (
{!loading && (
<p className="muted">
{total} {t.countLabel}
</p>
@@ -46,34 +46,61 @@ export default function KontaktePage() {
className="input"
placeholder={t.searchPlaceholder}
value={search}
onChange={(e) => {
setSearch(e.target.value)
setPage(1)
}}
onChange={(e) => setSearch(e.target.value)}
aria-label={t.fields.name}
/>
</div>
{contacts.loading && <p className="muted">{de.common.loading}</p>}
{contacts.error && (
{loading && <p className="muted">{de.common.loading}</p>}
{list.error && (
<div className="alert alert--error">
<span>{contacts.error}</span>
<button type="button" className="btn" onClick={contacts.reload}>
<span>{list.error}</span>
<button type="button" className="btn" onClick={list.reload}>
{de.common.retry}
</button>
</div>
)}
{!contacts.loading && !contacts.error && items.length === 0 && (
<p className="muted">{t.empty}</p>
)}
{!loading && !list.error && items.length === 0 && <p className="muted">{t.empty}</p>}
{items.length > 0 && (
<ul className="card-list">
{items.map((c) => (
<li key={c.id}>
<Link to={`/kontakte/${c.id}`} className="gerbil-card">
<span className="gerbil-card__name">{c.name}</span>
<span className="gerbil-card__name">
{c.name}
{c.isBreeder && (
<span
style={{
marginLeft: '0.5rem',
fontSize: '0.75rem',
padding: '0.1rem 0.4rem',
borderRadius: '4px',
background: '#e0f2fe',
color: '#0369a1',
fontWeight: 'normal',
}}
>
Züchter
</span>
)}
{c.isReceiver && (
<span
style={{
marginLeft: '0.5rem',
fontSize: '0.75rem',
padding: '0.1rem 0.4rem',
borderRadius: '4px',
background: '#fef3c7',
color: '#b45309',
fontWeight: 'normal',
}}
>
Abnehmer
</span>
)}
</span>
<span className="gerbil-card__meta">
{[c.phone, c.email].filter(Boolean).join(' · ')}
</span>
@@ -83,28 +110,12 @@ export default function KontaktePage() {
</ul>
)}
{totalPages > 1 && (
<nav className="pager" aria-label="Seitennavigation">
<button
type="button"
className="btn"
disabled={page <= 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
{de.common.previous}
</button>
<span className="muted">
{de.common.page} {page} {de.common.of} {totalPages}
</span>
<button
type="button"
className="btn"
disabled={page >= totalPages}
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
>
{de.common.next}
</button>
</nav>
{/* Infinite-scroll sentinel + "loading more" indicator. */}
{hasMore && <div ref={sentinelRef} aria-hidden="true" style={{ height: 1 }} />}
{loadingMore && (
<p className="muted" style={{ textAlign: 'center', padding: '0.75rem 0' }}>
{de.common.loading}
</p>
)}
</section>
)

View File

@@ -29,7 +29,7 @@ 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 { UNKNOWN_FARBSCHLAG, fromDisplayString, genotypeToFarbschlag, toDisplayString } from '../genetics'
import { UNKNOWN_FARBSCHLAG, fromDisplayString, genotypeToFarbschlag, displayGenotypeSafe } from '../genetics'
import {
DEFAULT_GENERATIONS,
ancestorsAt,
@@ -424,7 +424,7 @@ function PedigreeCard({
style={chip ? { background: chip.bg, color: chip.fg } : undefined}
title={farbschlag}
>
{g.genotype || farbschlag}
{g.genotype ? displayGenotypeSafe(g.genotype) : farbschlag}
</span>
)}
{dob && <span className="pedigree-card__year">* {dob}</span>}
@@ -574,7 +574,13 @@ function PrintCell({
{farbschlag && <div className="stammbaum-print__sub">{farbschlag}</div>}
{g.genotype && gen <= 2 && (
<div className="stammbaum-print__geno">
{toDisplayString(fromDisplayString(g.genotype))}
{(() => {
try {
return displayGenotypeSafe(g.genotype)
} catch {
return g.genotype.replace(/\?/g, '-')
}
})()}
</div>
)}
</div>

View File

@@ -1,10 +1,11 @@
/** FEAT-13: Abgabeverträge — Liste mit Download/Löschen (/vertraege). */
import { useMemo, useState } from 'react'
import { useMemo } from 'react'
import { Link } from 'react-router-dom'
import { de } from '../strings/de'
import { contractFileUrl, deleteContract, listContracts } from '../api/contracts'
import { listContactsPaged } from '../api/contacts'
import { useApi, useMutation } from '../hooks/useApi'
import { useInfiniteList, useInfiniteSentinel } from '../hooks/useInfiniteList'
import { formatDate } from '../format/labels'
import './vertragWizard.css'
@@ -16,12 +17,14 @@ function formatPrice(price: number): string {
export default function VertraegeListPage() {
const t = de.pages.vertraege
const [page, setPage] = useState(1)
const contracts = useApi(
() => listContracts({ page, pageSize: PAGE_SIZE, orderBy: 'createdAt desc' }),
[page],
const contracts = useInfiniteList(
(page) => listContracts({ page, pageSize: PAGE_SIZE, orderBy: 'createdAt desc,id' }),
'contracts',
)
const sentinelRef = useInfiniteSentinel(contracts)
const { items, total: totalCount, loading } = contracts
const contacts = useApi(() => listContactsPaged({ page: 1, pageSize: 1000, orderBy: 'name' }), [])
const contactName = useMemo(
() => new Map((contacts.data?.items ?? []).map((c) => [c.id, c.name])),
@@ -35,8 +38,8 @@ export default function VertraegeListPage() {
if (result.ok) contracts.reload()
}
if (contracts.loading) return <p className="muted">{de.common.loading}</p>
if (contracts.error || !contracts.data) {
if (loading) return <p className="muted">{de.common.loading}</p>
if (contracts.error) {
return (
<section className="page">
<h2>{t.title}</h2>
@@ -50,9 +53,6 @@ export default function VertraegeListPage() {
)
}
const { items, totalCount } = contracts.data
const totalPages = Math.max(1, Math.ceil(totalCount / PAGE_SIZE))
return (
<section className="page">
<header className="page-head">
@@ -98,23 +98,12 @@ export default function VertraegeListPage() {
</ul>
)}
{totalPages > 1 && (
<nav className="pager" aria-label="Seitennavigation">
<button type="button" className="btn" disabled={page <= 1} onClick={() => setPage(page - 1)}>
{de.common.previous}
</button>
<span>
{de.common.page} {page} {de.common.of} {totalPages}
</span>
<button
type="button"
className="btn"
disabled={page >= totalPages}
onClick={() => setPage(page + 1)}
>
{de.common.next}
</button>
</nav>
{/* Infinite-scroll sentinel + "loading more" indicator. */}
{contracts.hasMore && <div ref={sentinelRef} aria-hidden="true" style={{ height: 1 }} />}
{contracts.loadingMore && (
<p className="muted" style={{ textAlign: 'center', padding: '0.75rem 0' }}>
{de.common.loading}
</p>
)}
</section>
)

View File

@@ -47,7 +47,7 @@ export default function VertragWizardPage() {
const [newContact, setNewContact] = useState({ name: '', email: '', phone: '', address: '' })
const contacts = useApi(() => listContactsPaged({ page: 1, pageSize: 1000, orderBy: 'name' }), [])
const contactItems = useMemo(() => contacts.data?.items ?? [], [contacts.data])
const contactItems = useMemo(() => (contacts.data?.items ?? []).filter((c) => c.isReceiver), [contacts.data])
const filteredContacts = useMemo(() => {
const needle = contactSearch.trim().toLowerCase()
return needle === ''
@@ -62,6 +62,8 @@ export default function VertragWizardPage() {
email: newContact.email.trim() || null,
phone: newContact.phone.trim() || null,
address: newContact.address.trim() || null,
isReceiver: true,
isBreeder: false,
}),
)
async function onCreateContact() {

View File

@@ -3,9 +3,10 @@ import { Link } from 'react-router-dom'
import { de } from '../strings/de'
import { listLitters } from '../api/litters'
import { listGerbils } from '../api/gerbils'
import { andFilter, condition, type GridifyQuery } from '../api/gridify'
import { andFilter, condition, escapeGridifyValue } from '../api/gridify'
import type { Litter } from '../api/types'
import { useApi } from '../hooks/useApi'
import { useInfiniteList, useInfiniteSentinel } from '../hooks/useInfiniteList'
import { formatDate } from '../format/labels'
import { FilterPanel } from '../components/FilterPanel'
@@ -13,11 +14,12 @@ const PAGE_SIZE = 20
type Tab = 'litters' | 'pairs'
type SortKey = 'dateDesc' | 'dateAsc'
const SORT_ORDER_BY: Record<SortKey, string> = { dateDesc: 'date desc', dateAsc: 'date' }
// Trailing `id` → total order, so OFFSET pagination is stable for infinite scroll.
const SORT_ORDER_BY: Record<SortKey, string> = { dateDesc: 'date desc,id', dateAsc: 'date,id' }
function yearOptions(): number[] {
const current = new Date().getFullYear()
return Array.from({ length: 16 }, (_, i) => current - i)
return Array.from({ length: 30 }, (_, i) => current - i)
}
interface Pair {
@@ -47,9 +49,9 @@ function derivePairs(litters: Litter[]): Pair[] {
export default function WuerfeListPage() {
const t = de.pages.litters
const [tab, setTab] = useState<Tab>('litters')
const [search, setSearch] = useState('')
const [year, setYear] = useState('')
const [sort, setSort] = useState<SortKey>('dateDesc')
const [page, setPage] = useState(1)
// Parent names (shared by both tabs).
const gerbils = useApi(() => listGerbils({ page: 1, pageSize: 1000, orderBy: 'name' }), [])
@@ -62,12 +64,16 @@ export default function WuerfeListPage() {
// Litters tab (paged, filtered).
const filter = andFilter(
search.trim() && `(name=*${escapeGridifyValue(search)}/i|notes=*${escapeGridifyValue(search)}/i|litterLetter=*${escapeGridifyValue(search)}/i)`,
year && condition({ field: 'date', op: '>=', value: `${year}-01-01` }),
year && condition({ field: 'date', op: '<=', value: `${year}-12-31` }),
)
const orderBy = SORT_ORDER_BY[sort]
const query: GridifyQuery = { filter: filter || undefined, orderBy, page, pageSize: PAGE_SIZE }
const litters = useApi(() => listLitters(query), [filter, orderBy, page])
const litters = useInfiniteList(
(page) => listLitters({ filter: filter || undefined, orderBy, page, pageSize: PAGE_SIZE }),
`${filter}|${orderBy}`,
)
const littersSentinelRef = useInfiniteSentinel(litters)
// Pairs tab: all litters, grouped client-side.
const allLitters = useApi(
@@ -76,9 +82,17 @@ export default function WuerfeListPage() {
)
const pairs = useMemo(() => derivePairs(allLitters.data?.items ?? []), [allLitters.data])
const total = litters.data?.totalCount ?? 0
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
const items = litters.data?.items ?? []
// Search-enabled pairs list (client-side search by parent name)
const filteredPairs = useMemo(() => {
if (!search.trim()) return pairs
const term = search.toLowerCase()
return pairs.filter((p) =>
parentName(p.fatherId).toLowerCase().includes(term) ||
parentName(p.motherId).toLowerCase().includes(term)
)
}, [pairs, search, nameById])
const items = litters.items
return (
<section className="page">
@@ -113,17 +127,28 @@ export default function WuerfeListPage() {
{tab === 'litters' && (
<>
<FilterPanel
activeCount={year !== '' ? 1 : 0}
onReset={() => { setYear(''); setSort('dateDesc'); setPage(1) }}
searchField={
<input
type="search"
className="input"
placeholder={t.searchPlaceholder}
value={search}
onChange={(e) => setSearch(e.target.value)}
aria-label="Wurf suchen"
/>
}
activeCount={(year !== '' ? 1 : 0) + (search.trim() !== '' ? 1 : 0)}
onReset={() => {
setSearch('')
setYear('')
setSort('dateDesc')
}}
>
<label className="field">
<span>{t.filterYear}</span>
<select
value={year}
onChange={(e) => {
setYear(e.target.value)
setPage(1)
}}
onChange={(e) => setYear(e.target.value)}
>
<option value="">{t.allYears}</option>
{yearOptions().map((y) => (
@@ -174,39 +199,45 @@ export default function WuerfeListPage() {
</ul>
)}
{totalPages > 1 && (
<nav className="pager" aria-label={de.common.pageNav}>
<button
type="button"
className="btn"
disabled={page <= 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
{de.common.previous}
</button>
<span className="muted">
{de.common.page} {page} {de.common.of} {totalPages}
</span>
<button
type="button"
className="btn"
disabled={page >= totalPages}
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
>
{de.common.next}
</button>
</nav>
{/* Infinite-scroll sentinel + "loading more" indicator. */}
{litters.hasMore && (
<div ref={littersSentinelRef} aria-hidden="true" style={{ height: 1 }} />
)}
{litters.loadingMore && (
<p className="muted" style={{ textAlign: 'center', padding: '0.75rem 0' }}>
{de.common.loading}
</p>
)}
</>
)}
{tab === 'pairs' && (
<>
<div style={{ marginBottom: '1.5rem', display: 'flex', gap: '1rem', alignItems: 'center' }}>
<input
type="search"
className="input"
style={{ maxWidth: '320px' }}
placeholder="Elternteil suchen …"
value={search}
onChange={(e) => setSearch(e.target.value)}
aria-label="Elternteil suchen"
/>
{search && (
<button
type="button"
className="btn btn--secondary btn--sm"
onClick={() => setSearch('')}
>
{de.pages.gerbils.filters.reset}
</button>
)}
</div>
{allLitters.loading && <p className="muted">{de.common.loading}</p>}
{!allLitters.loading && pairs.length === 0 && <p className="muted">{t.pairs.empty}</p>}
{pairs.length > 0 && (
{!allLitters.loading && filteredPairs.length === 0 && <p className="muted">{t.pairs.empty}</p>}
{filteredPairs.length > 0 && (
<ul className="card-list">
{pairs.map((p) => (
{filteredPairs.map((p) => (
<li key={`${p.fatherId}|${p.motherId}`} className="gerbil-card">
<span className="gerbil-card__name">
{parentName(p.fatherId)} × {parentName(p.motherId)}

View File

@@ -0,0 +1,142 @@
.farbkatalog-page {
display: flex;
flex-direction: column;
}
.farbkatalog-search-bar {
margin: 1.5rem 0;
max-width: 36rem;
width: 100%;
}
.farbkatalog-search-bar .input {
box-shadow: 0 2px 8px rgba(59, 48, 38, 0.04);
transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
.farbkatalog-search-bar .input:focus {
border-color: var(--color-accent);
box-shadow: 0 0 0 3px var(--color-accent-soft);
}
.farbkatalog-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(14rem, 1fr));
gap: 1rem;
margin-top: 0.5rem;
}
.farbkatalog-card {
display: flex;
flex-direction: column;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 0.75rem;
overflow: hidden;
box-shadow: 0 2px 6px rgba(59, 48, 38, 0.04);
transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease;
}
.farbkatalog-card:hover {
transform: translateY(-3px);
box-shadow: 0 8px 20px rgba(59, 48, 38, 0.08);
border-color: var(--color-accent);
}
.farbkatalog-card-media {
position: relative;
width: 100%;
aspect-ratio: 16/11;
background: var(--color-bg);
overflow: hidden;
border-bottom: 1px solid var(--color-border);
}
.farbkatalog-card-img {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.3s ease;
}
.farbkatalog-card:hover .farbschlag-card-img,
.farbkatalog-card:hover .farbkatalog-card-img {
transform: scale(1.05);
}
.farbkatalog-card-media-placeholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, var(--color-accent-soft) 0%, #ecd0b9 100%);
color: var(--color-accent);
font-weight: 700;
font-size: 1.5rem;
letter-spacing: 0.1em;
}
.farbkatalog-card-body {
padding: 0.85rem 1rem;
display: flex;
flex-direction: column;
flex-grow: 1;
}
.farbkatalog-card-title {
margin: 0;
font-size: 1.05rem;
font-weight: 600;
color: var(--color-text);
line-height: 1.25;
}
.farbkatalog-card-subtitle {
font-size: 0.75rem;
color: var(--color-text-muted);
margin-top: 0.15rem;
display: block;
}
.farbkatalog-card-genotype {
margin-top: auto;
padding-top: 0.75rem;
}
.farbkatalog-card-genotype code {
font-family: monospace;
font-size: 0.72rem;
background: var(--color-bg);
color: var(--color-accent);
padding: 0.2rem 0.4rem;
border-radius: 0.25rem;
display: inline-block;
max-width: 100%;
overflow-x: auto;
}
.farbkatalog-empty {
grid-column: 1 / -1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 3rem 1.5rem;
background: var(--color-surface);
border: 1px dashed var(--color-border);
border-radius: 0.75rem;
text-align: center;
color: var(--color-text-muted);
}
.farbkatalog-empty-icon {
font-size: 2rem;
margin-bottom: 0.75rem;
}
@media (min-width: 768px) {
.farbkatalog-grid {
gap: 1.25rem;
}
}

View File

@@ -1,33 +1,283 @@
/* FEAT-12a multi-select on the Tiere list — page-scoped. */
/* The sticky header supplies the top spacing, so opt out of the global .page top
padding (otherwise the bar wouldn't pin flush to the top). */
.gerbils-page {
padding-top: 0;
}
.gerbil-row {
/* DESIGN (Rennmaus Filter): cards ease in (fade + slight rise) instead of popping
when the result set changes. React keys by id, so only newly-appearing rows
replay this; rows that persist across a filter change are reused and stay put. */
animation: gerbilRowIn 0.28s cubic-bezier(0.22, 1, 0.36, 1) both;
}
/* Light cascade so a fresh result set sweeps in rather than landing all at once.
Capped at the first handful of rows; the rest share the last delay. */
.gerbil-row:nth-child(1) { animation-delay: 0s; }
.gerbil-row:nth-child(2) { animation-delay: 0.03s; }
.gerbil-row:nth-child(3) { animation-delay: 0.06s; }
.gerbil-row:nth-child(4) { animation-delay: 0.09s; }
.gerbil-row:nth-child(5) { animation-delay: 0.12s; }
.gerbil-row:nth-child(6) { animation-delay: 0.15s; }
.gerbil-row:nth-child(n + 7) { animation-delay: 0.18s; }
@keyframes gerbilRowIn {
from {
opacity: 0;
transform: translateY(6px);
}
to {
opacity: 1;
transform: none;
}
}
@media (prefers-reduced-motion: reduce) {
.gerbil-row {
animation: none;
}
}
.rm-noresults {
text-align: center;
padding: 2.5rem 1rem;
display: flex;
align-items: center;
gap: 0.6rem;
}
.gerbil-row__check {
width: 1.25rem;
height: 1.25rem;
min-height: 0;
flex: 0 0 auto;
}
.gerbil-row > .gerbil-card {
flex: 1;
}
.bulk-bar {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
padding: 0.6rem 0.9rem;
margin: 0.5rem 0;
border: 1px solid var(--color-accent);
border-radius: 0.5rem;
}
/* Consistent breathing room below the sticky header: between the header and the
list (no active filters) and between the active chips and the first card. */
.gerbils-page .card-list {
margin-top: 0.625rem;
}
/* Infinite scroll: invisible sentinel observed to trigger the next page,
plus a centered "loading more" line while a page is being appended. */
.rmf-sentinel {
height: 1px;
width: 100%;
}
.rmf-loadmore {
text-align: center;
padding: 0.75rem 0;
}
/* Back-to-top: floating round button, above the mobile action bar. */
.rmf-backtotop {
position: fixed;
right: 1rem;
bottom: calc(4.75rem + env(safe-area-inset-bottom));
z-index: 25;
width: 44px;
height: 44px;
border-radius: 50%;
border: 1px solid color-mix(in srgb, var(--color-accent) 30%, var(--color-border));
background: var(--color-surface);
color: var(--color-accent);
font-size: 1.25rem;
font-weight: 800;
line-height: 1;
cursor: pointer;
box-shadow: 0 2px 6px rgba(61, 46, 35, 0.12), 0 8px 22px rgba(61, 46, 35, 0.12);
display: inline-flex;
align-items: center;
justify-content: center;
animation: gerbilRowIn 0.2s cubic-bezier(0.22, 1, 0.36, 1);
transition: transform 0.12s cubic-bezier(0.22, 1, 0.36, 1), background 0.15s;
}
.rmf-backtotop:hover {
background: var(--color-accent-soft);
}
.rmf-backtotop:active {
transform: scale(0.92);
}
@media (min-width: 768px) {
/* Desktop has a sidebar, not a bottom bar — sit at the normal bottom inset. */
.rmf-backtotop {
bottom: 1.5rem;
}
}
/* ---------- DESIGN (Rennmaus Filter): sticky header that blends out on scroll ----------
The whole header (title + search + filter pills + bar) sticks to the top; the list
scrolls underneath. The title row collapses once a 1px sentinel above the header
leaves the viewport. Negative margins let the bar span the app-main padding so its
background/border reach edge-to-edge while pinned. */
.gerbils-scrolltop {
height: 1px;
}
.gerbils-topbar {
position: sticky;
top: 0;
z-index: 1;
z-index: 10;
background: var(--color-bg);
/* Horizontal negative margins let the bar's background + border-bottom span the
app-main padding edge-to-edge while pinned. The body scrolls, so the app-main
top padding scrolls away and the bar pins flush to the viewport top (no gap). */
margin: 0 -1rem;
padding: 0.5rem 1rem 0.625rem;
border-bottom: 1px solid var(--color-border);
}
@media (min-width: 768px) {
.gerbils-topbar {
margin: 0 -2rem;
padding: 0.75rem 2rem 0.625rem;
}
}
.gerbils-head h2 {
font-size: 26px;
font-weight: 800;
letter-spacing: -0.02em;
color: var(--color-text);
}
.gerbils-head .muted {
font-size: 14px;
margin: 2px 0 0;
}
.gerbils-head {
overflow: hidden;
/* Generous cap so the expanded state never clips when the "Neu" button wraps on
narrow phones; the collapse still animates cleanly from this height to 0. */
max-height: 200px;
transition: max-height 0.28s cubic-bezier(0.22, 1, 0.36, 1), opacity 0.2s ease,
margin 0.28s cubic-bezier(0.22, 1, 0.36, 1);
}
.gerbils-head--condensed {
max-height: 0;
opacity: 0;
margin: 0;
pointer-events: none;
}
@media (prefers-reduced-motion: reduce) {
.gerbils-head {
transition: none;
}
}
/* ---------- DESIGN (Rennmaus Filter): new two-row list card ----------
Name + gender circle + status badge on top, "Farbschlag · Herkunft · geb. Datum"
below. Overrides the former single-row grid card from index.css. */
.gerbil-card {
display: flex;
flex-direction: column;
/* Override the old single-row card's `align-items: center` (index.css) — in a
column flex that centers children horizontally and shoved the name off the left. */
align-items: stretch;
gap: 0.5rem;
padding: 0.875rem 1rem;
border: 1px solid var(--color-border);
border-radius: 18px;
background: var(--color-surface);
box-shadow: var(--shadow);
text-decoration: none;
color: var(--color-text);
transition: border-color 0.15s, box-shadow 0.15s,
transform 0.12s cubic-bezier(0.22, 1, 0.36, 1);
}
.gerbil-card:hover {
background: var(--color-surface);
border-color: color-mix(in srgb, var(--color-accent) 35%, var(--color-border));
box-shadow: 0 2px 6px rgba(61, 46, 35, 0.1), 0 8px 22px rgba(61, 46, 35, 0.08);
}
.gerbil-card:active {
transform: scale(0.992);
}
.gerbil-card__top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.625rem;
}
.gerbil-card__name {
font-size: 18px;
font-weight: 700;
color: var(--color-text);
display: inline-flex;
align-items: center;
}
.gerbil-card__right {
display: flex;
align-items: center;
gap: 0.5rem;
flex-shrink: 0;
}
.gerbil-card__meta {
font-size: 14px;
color: var(--color-text-muted);
font-weight: 500;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.375rem;
}
.gerbil-card__meta-strong {
color: var(--color-text);
font-weight: 600;
}
.gerbil-card__dot {
color: var(--color-text-faint);
}
/* Status badge: design size (12px/700), coloured per status below. */
.gerbil-card .badge {
font-size: 12px;
font-weight: 700;
}
/* Gender circle (♂/♀/?) — colored disc, from the design's GeschlechtIcon. */
.gerbil-sex {
width: 24px;
height: 24px;
border-radius: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 14px;
font-weight: 700;
flex-shrink: 0;
}
.gerbil-sex--male {
color: #5a7da8;
background: color-mix(in srgb, #5a7da8 12%, #fff);
}
.gerbil-sex--female {
color: #c2703d;
background: color-mix(in srgb, #c2703d 12%, #fff);
}
.gerbil-sex--unknown {
color: #9a938a;
background: color-mix(in srgb, #9a938a 14%, #fff);
}
/* Status badge coloured per status — scoped to cards so other pages stay as-is. */
.gerbil-card .badge--breeding {
color: #5e8c5a;
background: color-mix(in srgb, #5e8c5a 12%, #fff);
border: 1px solid color-mix(in srgb, #5e8c5a 30%, transparent);
}
.gerbil-card .badge--pet {
color: #5a7da8;
background: color-mix(in srgb, #5a7da8 12%, #fff);
border: 1px solid color-mix(in srgb, #5a7da8 30%, transparent);
}
.gerbil-card .badge--forsale {
color: #c2703d;
background: color-mix(in srgb, #c2703d 12%, #fff);
border: 1px solid color-mix(in srgb, #c2703d 30%, transparent);
}
.gerbil-card .badge--deceased {
color: #8a847b;
background: color-mix(in srgb, #8a847b 14%, #fff);
border: 1px solid color-mix(in srgb, #8a847b 30%, transparent);
}
.gerbil-card .badge--givenaway {
color: #b08e5c;
background: color-mix(in srgb, #b08e5c 14%, #fff);
border: 1px solid color-mix(in srgb, #b08e5c 32%, transparent);
}

View File

@@ -34,6 +34,7 @@ export const de = {
help: 'Hilfe',
// WEB-0b (Kevin): CMS-Verwaltung der öffentlichen Webseite
website: 'Webseite',
colorCatalog: 'Farbkatalog',
},
pages: {
gerbils: {
@@ -44,6 +45,10 @@ export const de = {
empty: 'Keine Rennmäuse gefunden.',
countLabel: 'Tiere',
searchPlaceholder: 'Name suchen …',
// DESIGN (Rennmaus Filter): „geb." Präfix vor dem Geburtsdatum auf der Karte.
born: 'geb.',
// Zurück-nach-oben-Button (erscheint beim Scrollen der langen Liste).
backToTop: 'Nach oben',
// BESTAND-FILTER: Kennzeichnung externer (nicht zum eigenen Bestand gehörender) Tiere
externalBadge: 'Extern',
externalTitle: 'Externes Tier — gehört nicht zum eigenen Bestand, nur für den Stammbaum erfasst.',
@@ -58,6 +63,8 @@ export const de = {
sortBy: 'Sortieren nach',
// BESTAND-FILTER: eigener Bestand (Clan) vs. externe Ahnen (nur Stammbaum)
showExternal: 'Externe Ahnen einblenden',
// DESIGN (Rennmaus Filter): kurze Variante für den Toggle-Chip.
showExternalShort: 'Externe Ahnen',
showExternalHint: 'Tiere aus fremden Zuchten, die nur für den Stammbaum erfasst sind.',
},
// Sortier-Optionen
@@ -84,7 +91,9 @@ export const de = {
isDeaf: 'Gehörlos',
receiver: 'Abnehmer',
genotype: 'Genotyp',
isCastrated: 'Kastrat',
notes: 'Notizen',
spottingType: 'Scheckungsart',
},
// Detailseite
detail: {
@@ -124,6 +133,8 @@ export const de = {
isDeafUnknown: 'Unbekannt',
isDeafYes: 'Ja',
isDeafNo: 'Nein',
isScheckeLabel: 'Ist eine Schecke',
colorSectionTitle: 'Farbe & Genetik',
// STATUS-MODEL: Hinweis für abgeleitete Statuswerte
statusDerivedHint: {
Deceased: 'Wird automatisch gesetzt wenn Todesdatum erfasst.',
@@ -154,6 +165,17 @@ export const de = {
GivenAway: 'Abgegeben',
ForSale: 'Zur Abgabe',
},
spottingTypes: {
none: '— keine Scheckungsart —',
Punktschecke: 'Punktschecke',
Kragenschecke: 'Kragenschecke',
Ansatzschecke: 'Ansatzschecke',
Starkschecke: 'Starkschecke',
Superschecke: 'Superschecke',
'DP (Dark Patch)': 'DP (Dark Patch)',
WP: 'Minimalschecke (WP)',
wp: 'WP',
},
},
// ── FEAT-3 (Kevin): Zucht — Würfe + Zuchtpaare ──
litters: {
@@ -166,6 +188,7 @@ export const de = {
newButton: 'Neuer Wurf',
empty: 'Keine Würfe gefunden.',
countLabel: 'Würfe',
searchPlaceholder: 'Wurf suchen …',
filterYear: 'Jahr',
allYears: 'Alle Jahre',
sort: {
@@ -235,6 +258,12 @@ export const de = {
title: 'Züchter',
comingSoon: 'Die Züchter-Verwaltung entsteht gerade. Bald kannst du hier Züchter erfassen.',
},
farbkatalog: {
title: 'Farbkatalog',
subtitle: 'Übersicht aller {count} registrierten Farbschläge und deren genetischen Formeln.',
searchPlaceholder: 'Nach Name, englischem Begriff oder Genen suchen (z. B. \'Zobel\', \'dd\', \'ee\', \'pp\')...',
empty: 'Keine Farbschläge gefunden, die Ihrer Suche entsprechen.',
},
notFound: {
title: 'Seite nicht gefunden',
message: 'Die angeforderte Seite existiert nicht.',
@@ -566,6 +595,8 @@ export const de = {
phone: 'Telefon',
address: 'Adresse',
notes: 'Notizen',
isBreeder: 'Ist Züchter (Zucht)',
isReceiver: 'Ist Abnehmer (Käufer)',
},
linked: {
title: 'Verknüpfte Tiere',
@@ -872,12 +903,26 @@ export const de = {
resetButton: 'Filter zurücksetzen',
closeButton: 'Schließen',
},
// ── DESIGN (Rennmaus Filter): Quick-Chips-Filter mit Bottom-Sheet ──
filterBar: {
title: 'Filter',
done: 'Fertig',
clearAll: 'Alle löschen',
remove: 'entfernen',
searchPlaceholder: 'Suchen …',
noMatch: 'Kein Treffer',
searchClear: 'Suche leeren',
},
// ── FEAT-NAMEGEN: Namensvorschläge (KI-gestützt, UC-1 Einzeltier) ──
namegen: {
button: 'Name vorschlagen',
panelTitle: 'Namensvorschläge',
letterLabel: 'Anfangsbuchstabe',
letterPlaceholder: 'z. B. A',
genderLabel: 'Geschlecht',
genderAny: 'Egal',
genderFemale: 'Weiblich',
genderMale: 'Männlich',
usagesLabel: 'Herkunftskultur',
loadButton: 'Vorschläge laden',
loading: 'Lade Vorschläge …',

88
import_todo.md Normal file
View File

@@ -0,0 +1,88 @@
# TODO: Migration to Unified Database-Ready Format
This is the checklist for migrating the GerbilManager Excel datasets (41 Stammbaum pedigree charts + 1 Wurfchronik litter chronicle) one-by-one into a clean, resolved, relational JSON format (`gerbilmanager_resolved.json`) that can be loaded directly into the database without on-the-fly reconciliation.
---
## 🟩 Phase 1: Preparation & Schema Definition
- [x] Define the target unified JSON schema that maps 1-to-1 with the Entity Framework database entities:
- `Contacts` (breeders/buyers)
- `ColorVarieties` (genetics catalog)
- `Litters` (with resolved `MotherId` and `FatherId` Guid relations)
- `Gerbils` (with resolved `LitterId`, `ColorVarietyId`, `OriginContactId`, `ReceiverContactId` Guid relations)
- `GerbilPhotos` (mapped by Guid relation)
- [x] Extract and verify existing `ColorVarieties` and `Contacts` to establish basic lookup dictionaries.
---
## 🟦 Phase 2: Spreadsheet Extraction & Conversion (One-by-One)
Convert and extract each spreadsheet's records. For each chart, extract the animals, birth dates, death dates, genders (from cell background color), and genotypes.
### Litter Chronicle
- [x] `Wurfchronik der Kleine Chaoten Teil1.xlsx` (Litters data base)
### Pedigree Charts (Stammbäume)
- [x] `Stammbaum von Akio Kids.xlsx`
- [x] `Stammbaum von Alberto Kids.xlsx`
- [x] `Stammbaum von CP-Fuchs, CP-Sa Sp von Unity.xlsx`
- [x] `Stammbaum von Chesnut.xlsx`
- [x] `Stammbaum von Danako.xlsx`
- [x] `Stammbaum von Ella.xlsx`
- [x] `Stammbaum von Emi.xlsx`
- [x] `Stammbaum von Fire Kids.xlsx`
- [x] `Stammbaum von Goldfuchs Sp (Pikachu) Kids.xlsx`
- [x] `Stammbaum von Hana.xlsx`
- [x] `Stammbaum von Jeremy.xlsx`
- [x] `Stammbaum von Jiminy of Black Forest.xlsx`
- [x] `Stammbaum von Jin.xlsx`
- [x] `Stammbaum von Kalea.xlsx`
- [x] `Stammbaum von Kazuya.xlsx`
- [x] `Stammbaum von Kentucky.xlsx`
- [x] `Stammbaum von Kohlief, Goldfuchsef Sp von Chrissi.xlsx`
- [x] `Stammbaum von Marlin of Black Forest.xlsx`
- [x] `Stammbaum von Martin.xlsx`
- [x] `Stammbaum von Oscar of Black Forest.xlsx`
- [x] `Stammbaum von Pinto of Fiomi.xlsx`
- [x] `Stammbaum von Quied Soldier of Black Forest.xlsx`
- [x] `Stammbaum von Rainny.xlsx`
- [x] `Stammbaum von Ren.xlsx`
- [x] `Stammbaum von South Dakota.xlsx`
- [x] `Stammbaum von Stella Kids.xlsx`
- [x] `Stammbaum von Tennessee.xlsx`
- [x] `Stammbaum von Unique of Wild Dreams.xlsx`
- [x] `Stammbaum von Unit.xlsx`
- [x] `Stammbaum von Uriana.xlsx`
- [x] `Stammbaum von Valentino Firehearts Kids.xlsx`
- [x] `Stammbaum von Vance.xlsx`
- [x] `Stammbaum von Vestra von den Schlossmäusen.xlsx`
- [x] `Stammbaum von Watarus Kids.xlsx`
- [x] `Stammbaum von Wildfire und Vestras Kids.xlsx`
- [x] `Stammbaum von Xelina.xlsx`
- [x] `Stammbaum von Yuki.xlsx`
- [x] `Stammbaum von Yurikas und Pintos Sohn.xlsx`
- [x] `Stammbaum von Zac (Vance.Dorie).xlsx`
- [x] `Stammbaum von Zenon von Elea.xlsx`
- [x] `Stammbaum von little Heros Sohn of little runners.xlsx`
---
## 🟨 Phase 3: Resolution & Relational Mapping
- [x] Resolve duplicates using the canonical key: `normalize(name) + normalize(dob)` and `Zucht` (breeding line).
- [x] Inject resolutions from `conflict-decisions.json` to automatically resolve the remaining conflict animals (un-quarantining them and applying correct genotypes/dates).
- [x] Parse and map all parent-child relationships positionally from the pedigree charts.
- [x] Map and link all animals to their respective Wurfchronik litters using parentage and DOB.
- [x] Resolve photo attachments to actual extracted media file paths.
- [x] Assign permanent UUIDs (Guids) to all records and replace string names in relationships with the actual UUID foreign keys.
---
## 🟧 Phase 4: Data Export & Validation
- [x] Generate the final `resolved_import.json` containing the relational array collections.
- [x] Run automated validation tests on the JSON file (check for circular dependencies, broken foreign keys, invalid genotypes, and null dates).
---
## 🟥 Phase 5: Loader Implementation & Database Ingestion
- [x] Build a simplified loader utility (C# endpoint or local python script) that takes the resolved JSON and directly runs `INSERT`/`UPDATE` transactions.
- [x] Execute the import onto the target PostgreSQL database.
- [x] Run verification tests (verify total counts: ~956 animals, and sample check pedigree trees).

View File

@@ -102,9 +102,9 @@
{
"name": "Skarlett von den Kleinen Chaoten",
"dob": "14.07.2013",
"decision": "death date = 17.04.2016 (the '2018' variant was wrong — it had leaked as '/ +2018' into the genotype field; parse-leak already fixed in IMPORT-POLISH)",
"dateOfDeath": "17.04.2016",
"source": "Julian 2026-06-07 — HUMANQUESTION D6"
"decision": "birth 2013 + 4 years = death year 2017, no exact date → year-only convention (01.01.2017). Previous entry (17.04.2016) was wrong.",
"dateOfDeath": "01.01.2017",
"source": "Julian 2026-06-07 — HUMANQUESTION D7 (final Skarlett resolution)"
},
{
"name": "Kazu von den Kleinen Chaoten",
@@ -166,10 +166,10 @@
{
"name": "Max von Privat",
"dob": "01.02.2013",
"decision": "Genotyp von der Züchterin; Todesjahr 2014 (Jahr-onlyKonvention mit Julian zu klären, deathYear NICHT als dateOfDeath gesetzt)",
"decision": "Genotyp von der Züchterin bestätigt (D-=D-, P=PP); Todesjahr 2014 (kein genaues Datum → Jahr-only-Konvention 01.01.2014). Reject 4 on-file-Varianten: 04.02.2016 / 04.03.2016 / 2014-raw / 30.12.2015.",
"genotype": "aa c[chm]c[chm] D- EE GG PP spsp",
"_deathYear_pending": 2014,
"source": "Julian/Züchterin 2026-06-07 — HUMANQUESTION D7 (Jahr-only-Tod offen)"
"dateOfDeath": "01.01.2014",
"source": "Julian/Züchterin 2026-06-07 — HUMANQUESTION D7 (resolved)"
},
{
"name": "Isa of Golden Lights",
@@ -198,6 +198,50 @@
"decision": "Sterbedatum von der Züchterin; bestätigt = 'von PZ Karl' (nicht 'Sunny Sky of Fiomi')",
"dateOfDeath": "30.04.2018",
"source": "Julian/Züchterin 2026-06-07 — HUMANQUESTION D7 (Nachtrag)"
},
{
"name": "Dakota of sweet little mouse",
"dob": "30.01.2015",
"decision": "Genotyp von Julian/Züchterin: A-Locus=Aa, P-Locus=pp, Sp-Locus=Spsp (offene Loci); übrige bestätigt.",
"genotype": "Aa CC Dd Ee Gg pp Spsp",
"source": "Julian/Züchterin 2026-06-07 — HUMANQUESTION D7"
},
{
"name": "Banjo of Fiomi",
"dob": "06.07.2015",
"decision": "E-Locus Korrektur: Julian — 'Goldfuchs Starkschecke, nicht Gold Starkschecke' → E-Locus muss ee sein, nicht E-. Extract hatte AA CC DD E- Gg pp Spsp [WP]; korrigiert zu ee (Goldfuchs-Definition). Sohn von Dakota of sweet little mouse.",
"genotype": "AA CC DD ee Gg pp Spsp",
"farbschlag": "Goldfuchs Starkschecke",
"source": "Julian 2026-06-07 — HUMANQUESTION D7 (Sohn-Korrektur, nicht ursprüngliche D7-Liste)"
},
{
"name": "Joghurt von Privat",
"dob": "06.09.2013",
"decision": "Genotype aa Cc[-] D- ee UwUw PP spsp as confirmed by Julian.",
"genotype": "aa Cc[-] D- ee UwUw PP spsp",
"source": "Julian 2026-06-12"
},
{
"name": "Ken'ichi",
"dob": "01.03.2015",
"decision": "Genotype AA CC DD Ee GG PP spsp is correct, farbschlag is Agouti.",
"genotype": "AA CC DD Ee GG PP spsp",
"farbschlag": "Agouti",
"source": "Julian 2026-06-12"
},
{
"name": "Harumi",
"dob": "21.02.2015",
"decision": "Death date is 11.01.2018 (died in 2018).",
"dateOfDeath": "11.01.2018",
"source": "Julian 2026-06-12"
},
{
"name": "Kleiner Warnowrenner Elieus gen. Eragon",
"dob": "18.05.2016",
"decision": "Genotype is aa CC D- Ee Gg pp Spsp [DP] (C locus is CC, full color, duplicate colourpoint record resolved).",
"genotype": "aa CC D- Ee Gg pp Spsp [DP]",
"source": "Julian 2026-06-12"
}
]
}

View File

@@ -35,19 +35,20 @@ DEATH = re.compile(r"\+\s?(\d{1,2}\.\d{1,2}\.(?:\d{4}|\d{2})|\d{4})")
# ---------------------------------------------------------------- helpers ----
def gen_of(colnum):
def gen_of(colnum, offset=0):
"""Map a column number to a generation band (0=proband ... 5=deepest)."""
if colnum <= 6:
return 0 # E band (proband / "Kids")
if colnum <= 9:
return 1 # H band (parents)
if colnum <= 12:
return 2 # K band (grandparents)
if colnum <= 15:
return 3 # N band (great-grandparents)
if colnum <= 17:
return 4 # Q band (gg-grandparents)
return 5 # R/S band (name-pairs)
effective_col = colnum - offset
if effective_col <= 3:
return 0 # Column B (2) -> proband
if effective_col <= 6:
return 1 # Column E (5) -> parents
if effective_col <= 9:
return 2 # Column H (8) -> grandparents
if effective_col <= 12:
return 3 # Column K (11) -> great-grandparents
if effective_col <= 15:
return 4 # Column N (14) -> gg-grandparents
return 5 # Column Q (17) or deeper -> ggg-grandparents
def norm_name(name):
@@ -189,6 +190,9 @@ def extract_stammbaum(path):
cells = xu.read_cells(z, sheets[0], ss)
fillsex = xu.cell_fill_sex(z, sheets[0]) # box colour -> sex (blue=male, white=female)
has_col2 = any(c == 2 for (c, r) in cells)
col_offset = 0 if has_col2 else 3
# group cells by column for block reconstruction
by_col = {}
for (c, r), t in cells.items():
@@ -217,7 +221,7 @@ def extract_stammbaum(path):
# full block: name above, farbschlag/genotype/breeder below
dob, death, geno0 = parse_detail(t)
name = ""
for rr in range(r - 1, r - 4, -1):
for rr in range(r - 3, r):
if (c, rr) in cells and not re.match(r"^\*?\s?\d", cells[(c, rr)]) \
and not gt.looks_like_genotype(cells[(c, rr)]):
name = clean_name(cells[(c, rr)])
@@ -230,7 +234,7 @@ def extract_stammbaum(path):
# WITH a Farbschlag cell; deep bands (gen >= 2, cols K/N/Q...) are 3-cell blocks
# (Name/DOB/Genotype) with NO Farbschlag — colour is derived from the genotype. So in
# deep bands we must NOT grab the next block's name or a stray health note as Farbschlag.
deep_band = gen_of(c) >= 2
deep_band = gen_of(c, col_offset) >= 2
for rr in range(r + 1, r + 4):
cell = cells.get((c, rr))
if not cell:
@@ -269,7 +273,7 @@ def extract_stammbaum(path):
"parentRefs": [],
"photos": [],
"sourceFiles": [fname],
"_gen": gen_of(c),
"_gen": gen_of(c, col_offset),
"_col": c,
"_row": r,
"_file": fname,
@@ -280,7 +284,7 @@ def extract_stammbaum(path):
for (c, r), t in cells.items():
if (c, r) in used:
continue
if " & " in t and not DOB.search(t) and len(t) < 90 and gen_of(c) >= 4:
if " & " in t and not DOB.search(t) and len(t) < 90 and gen_of(c, col_offset) >= 4:
for part in t.split(" & "):
part = clean_name(part)
if part:
@@ -291,7 +295,7 @@ def extract_stammbaum(path):
"genotype": gt.parse(""), "deaf": None, "tags": [],
"breeder": "", "zucht": zraw,
"parentRefs": [], "photos": [], "sourceFiles": [fname],
"_gen": gen_of(c), "_col": c, "_row": r, "_file": fname,
"_gen": gen_of(c, col_offset), "_col": c, "_row": r, "_file": fname,
"_zucht": norm_zucht(zraw),
})
@@ -337,8 +341,9 @@ def _attach_photos(z, sheets, animals, fname):
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)
g = gen_of(col, col_offset)
cands = by_gen.get(g, [])
if not cands:
# fall back to nearest animal by row across all gens
@@ -1026,6 +1031,9 @@ def main():
print(f" {len(got):4d} {os.path.basename(path)}")
raw_animals.extend(got)
# Skip pseudo-animal records (like "DD-Tumor bei Geschwister") that are actually notes
raw_animals = [a for a in raw_animals if "DD-Tumor" not in a["name"]]
litters = []
if os.path.isfile(args.wurfchronik):
litters = extract_wurfchronik(args.wurfchronik)

View File

@@ -68,11 +68,18 @@ def _sls_alleles(token):
def _deaf_value(token):
"""dea/taub -> True (deaf); Dea/hörend -> False (hearing); else None. Case-sensitive for Dea/dea."""
"""dea/taub -> True (deaf); Dea/hörend -> False (hearing); else None."""
t = token.strip("()[]")
if t == "dea" or t.lower() == "taub":
t_lower = t.lower()
if "taub" in t_lower:
return True
if t == "Dea" or t.lower() in ("hörend", "hoerend"):
if "hörend" in t_lower or "hoerend" in t_lower:
return False
if "dea/dea" in t_lower or "dea/*" in t_lower:
return True
if t == "dea":
return True
if t == "Dea":
return False
return None

File diff suppressed because it is too large Load Diff

View File

@@ -4,36 +4,42 @@ _Automatisch erzeugt von `tools/import/extract.py` — **noch nichts in die Date
## Überblick
- Rohe Tier-Einträge aus den Stammbäumen: **950**
- Nach Zusammenführung (eindeutige Tiere): **621**
- davon mit Geburtsdatum: 326
- in mehreren Dateien gefunden (Dubletten zusammengeführt): 158
- Konflikte zur Klärung: **5**
- Mehrdeutige / unvollständige Einträge (ohne Name+Datum): **310**
- Fotos zugeordnet: **137**
- 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: **417**
- Würfe aus der Wurfchronik: **752**
- Tiere mit Wurf verknüpft: **159** (davon über Geburtsdatum **und** Eltern: 110, nur über Geburtsdatum: 49; mehrdeutig: 10)
- Tiere mit Wurf verknüpft: **270** (davon über Geburtsdatum **und** Eltern: 167, nur über Geburtsdatum: 103; mehrdeutig: 17)
- Würfe mit Datenqualitäts-Hinweisen: 113 (+ 138 Zeilen mit abweichendem Spaltenschema)
## Zusammenführungs-Schlüssel
Tiere wurden zusammengeführt über **normalisierter Rufname + Geburtsdatum**, mit der **Zucht als Unterscheidungsmerkmal** (Julians Regel: die `[Klammern]` in der Wurfchronik und das `of/von <Linie>`-Suffix der Stammbäume bezeichnen beide die Zucht und werden zusammengeführt — z. B. `[ZdkC]``von den Kleinen Chaoten`). Namensvarianten (z. B. `v.d.``von den`, `gen.`-Spitznamen) werden als `nameVariants` erhalten.
### Gleicher Name + Geburtsdatum, aber unterschiedliche Zucht (NICHT zusammengeführt — bitte prüfen)
| Tier | Geburtsdatum | Zuchten | Dateien |
|---|---|---|---|
| Blacky | 23.07.2009 | PZ Seligenstadt // Privatzucht Seligenstadt | Stammbaum von Danako, Stammbaum von Hana, Stammbaum von Jin, Stammbaum von Unit, Stammbaum von Uriana, Stammbaum von Wildfire und Vestras Kids |
| Danny | 09.10.2009 | Maintaler PZ // PZ Maintal | Stammbaum von Hana, Stammbaum von Vance |
| Lola | 10.02.2014 | Chalfont Stud, Freddy Braun // Lennylengo | Stammbaum von Jeremy, Stammbaum von Jiminy of Black Forest, Stammbaum von Marlin of Black Forest, Stammbaum von Zac (Vance.Dorie), Stammbaum von Zenon von Elea |
| Debby gen. Eva | 08.08.2012 | KK Chaos // KK Chaos of KK Chaos | Stammbaum von Kalea, Stammbaum von Unit, Stammbaum von Uriana |
## ⚠️ Konflikte (bitte prüfen)
Gleiches Tier (Name+Datum), aber widersprüchliche Angaben in verschiedenen Dateien:
| Tier | Geburtsdatum | abweichende Genotypen | abweichende Farbschläge | Sterbedaten | Dateien |
|---|---|---|---|---|---|
| Kazu von den Kleinen Chaoten | 23.04.2013 | Aa Cc[chm] DD e[f]e[f] Gg P Spsp // Aa Cc[chm] DD ee[f] UwUw PP Spsp | — | 03.09.2017 | Stammbaum von Akio Kids, Stammbaum von Vance |
| Little Runner's Big Ben | 03.02.2020 | Aa Cc[chm] DD Ee Gg PP Spsp // Aa Cc[chm] DD Ee Gg Pp Spsp | — | 14.10.2023 | Stammbaum von CP-Fuchs, CP-Sa Sp von Unity, Stammbaum von Fire Kids, Stammbaum von Goldfuchs Sp (Pikachu) Kids, Stammbaum von Kohlief, Goldfuchsef Sp von Chrissi, Stammbaum von Valentino Firehearts Kids, Stammbaum von Watarus Kids |
| Vance Jr. von den Kleinen Chaoten | 10.04.2022 | aa Cc[hm] Dd Ee gg P- Spsp // aa Cc[hm] Dd Ee gg P- spsp | Kohlfuchs, hell | — | Stammbaum von Fire Kids, Stammbaum von Stella Kids |
| Hanami von den Kleinen Chaoten | 10.09.2015 | aa Cc[chm] D- Ee gg P- spsp | — | 12.12.2019 // 14.01.2020 | Stammbaum von Kentucky, Stammbaum von Stella Kids |
| Skarlett v.d. Kleinen Chaoten | 14.07.2013 | / +2018, Aa Cc[chm] DD ee uw[d]uw[d] PP spsp // Aa Cc[chm] DD ee uw[d]uw[d] PP spsp | — | 17.04.2016 // 2018 | Stammbaum von Vance |
| 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 |
| 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) |
## Mehrdeutige / unvollständige Einträge
310 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.
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.
- Oskar v.d. bunten Fellnase · Stammbaum von Akio Kids.xlsx
- Raya v.d. bunten Fellnasen · Stammbaum von Akio Kids.xlsx
@@ -78,23 +84,55 @@ Gleiches Tier (Name+Datum), aber widersprüchliche Angaben in verschiedenen Date
## Wahrscheinliche Zuordnungen unvollständiger Einträge
39 namenlose/datenlose Einträge tragen denselben Namen wie ein vollständiges Tier — vermutlich dasselbe Tier (zur Bestätigung):
91 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)
- „Isa of Golden Lights“ → Isa of Golden Lights (*24.12.2014)
- „Leila v. Jessica Walldorf“ → Leila v. Jessica Walldorf (*08.04.2014)
- „Osamu“ → Osamu (*10.12.2015)
- „Nala“ → Nala (*06.03.2015)
- „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)
- „Isa of Golden Lights“ → Isa of Golden Lights (*24.12.2014)
- „Mystique of Black Forest“ → Mystique of Black Forest (*12.03.2022)
- „Elay“ → Elay (*16.03.2016)
- „Osamu“ → Osamu (*10.12.2015)
- „Baiko“ → Baiko (*02.06.2019)
- „Hagrid Rubeus of Black Forest“ → Hagrid Rubeus of Black Forest (*18.07.2019)
- „Osamu“ → Osamu (*10.12.2015)
- „Umi“ → Umi (*13.08.2016)
- „Charly of Golden Lights“ → Charly of Golden Lights (*05.04.2016)
- „Ziwa of Golden Lights“ → Ziwa of Golden Lights (*29.04.2016)
- „Harumi“ → Harumi (*21.02.2015)
- „Pan“ → Pan (*09.12.2014)
- „Gin“ → Gin (*05.02.2015)
- „Isa of golden lights“ → Isa of Golden Lights (*24.12.2014)
- „Porter“ → Porter (*23.05.2015)
- „Chelsea von den Kleinen Chaoten“ → Chelsea von den Kleinen Chaoten (*02.04.2021)
- „Pinto of Fiomi“ → Pinto of Fiomi (*28.08.2016)
- „Nala“ → Nala (*06.03.2015)
- „Hanami“ → Hanami (*10.09.2015)
- „Living Force's Idefix“ → Living Force's Idefix (*05.04.2016)
-Scarlett of Samsimar“ → Scarlett of Samsimar (*05.09.2018)
-Osamu“ → Osamu (*10.12.2015)
- „Baiko“ → Baiko (*02.06.2019)
- „Olivia“ → Olivia (*23.07.2019)
- „Baiko“ → Baiko (*02.06.2019)
- „Olivia“ → Olivia (*23.07.2019)
- „Living Force's Idefix“ → Living Force's Idefix (*05.04.2016)
- „Rosie of LennyLengo“ → Rosie of LennyLengo (*15.03.2016)
- „Pan“ → Pan (*09.12.2014)
- „Gin“ → Gin (*05.02.2015)
- „Chiako“ → Chiako (*15.11.2016)
- „Stich von Privatzucht Gießen“ → Stich von Privatzucht Gießen (*01.09.2018)
- „Lilo of LennyLengo“ → Lilo of LennyLengo (*04.11.2018)
- „Ken'ichi“ → Ken'ichi (*01.03.2015)
- „Rumi“ → Rumi (*24.02.2017)
- „Living Force's Idefix“ → Living Force's Idefix (*05.04.2016)
- „Rosie of LennyLengo“ → Rosie of LennyLengo (*15.03.2016)
- „Living Force's Idefix“ → Living Force's Idefix (*05.04.2016)
@@ -103,22 +141,11 @@ Gleiches Tier (Name+Datum), aber widersprüchliche Angaben in verschiedenen Date
- „Little Hero of Black Forest“ → Little Hero of Black Forest (*22.02.2018)
- „Little Runner's Destiny“ → Little Runner's Destiny (*02.03.2019)
- „Chevrolet Camaro of Topolino“ → Chevrolet Camaro of Topolino (*10.04.2018)
- „Danako“ → Danako (*22.08.2018)
- „Marlin of Black Forest“ → Marlin of Black Forest (*28.05.2019)
- „Zenon von Elea“ → Zenon von Elea (*06.10.2019)
- „Nisha of Black Forest“ → Nisha of Black Forest (*28.03.2018)
-Nisha of Black Forest“ → Nisha of Black Forest (*28.03.2018)
- „Dorie of Black Forest“ → Dorie of Black Forest (*28.05.2019)
- „Zadar from Zeko i ptica, Croatia“ → Zadar from Zeko i ptica, Croatia (*12.04.2019)
- „Living Force's Vally“ → Living Force's Vally (*01.11.2014)
- „Pinto of Fiomi“ → Pinto of Fiomi (*28.08.2016)
- „Hanse Renner's Poseidon“ → Hanse Renner's Poseidon (*14.08.2014)
- „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)
- „Chevrolet Camaro of Topolino“ → Chevrolet Camaro of Topolino (*10.04.2018)
- „Lilo of LennyLengo“ → Lilo of LennyLengo (*04.11.2018)
- „Rosie of LennyLengo“ → Rosie of LennyLengo (*15.03.2016)
- „Rosie of LennyLengo“ → Rosie of LennyLengo (*15.03.2016)
- „Little Runner's Destiny“ → Little Runner's Destiny (*02.03.2019)
-Zenon von Elea“ → Zenon von Elea (*06.10.2019)
## Nicht ins 8-Loci-Modell abgebildete Tokens (verbatim erhalten)
@@ -126,22 +153,31 @@ Diese Tokens stehen weiter in `rawGenotype`/`unmappedTokens` — Entscheidung (M
| Token | Vorkommen | Bedeutung (Vermutung) |
|---|---|---|
| `/+` | 6 | ? |
| `/+` | 7 | ? |
| `[meliert]` | 4 | ? |
| `(Kragen)` | 3 | ? |
| `/` | 3 | ? |
| `-g` | 2 | ? |
| `!DD` | 2 | ? |
| `Tumor` | 2 | ? |
| `!` | 2 | ? |
| `C(C)` | 2 | Schreibweise (C trägt c) |
| `(KW` | 2 | ? |
| `Cc[]` | 1 | ? |
| `[WFNZ-Maroon]` | 1 | ? |
| `/+April'2017` | 1 | ? |
| `[meliert]-[DP]` | 1 | ? |
| `!Knickschwanz-Gen!` | 1 | ? |
| `-09.09.2018` | 1 | ? |
| `[DP?]` | 1 | ? |
| `/+Dezember'2014` | 1 | ? |
| `(Ansatz)` | 1 | ? |
| `-psp` | 1 | ? |
| `G(G)` | 1 | ? |
| `/` | 1 | ? |
| `!Niereninsuffizienz!` | 1 | ? |
| `+09.07.2017` | 1 | ? |
| `+2018` | 1 | ? |
| `chmchm` | 1 | Schreibweise (c[chm]c[chm]) |
| `c[chm]chm]` | 1 | ? |
| `Dea/dea]` | 1 | ? |
| `DD-Tumor` | 1 | ? |
| `bei` | 1 | ? |
| `Geschwistern` | 1 | ? |
| `C-D-` | 1 | ? |
| `-DD` | 1 | ? |
| `AAA` | 1 | ? |
## Wurfchronik — Datenqualitäts-Hinweise

View File

@@ -0,0 +1,394 @@
#!/usr/bin/env python3
import os
import json
import uuid
import re
from datetime import datetime
# Import normalization helpers from extract.py
import extract as ex
HERE = os.path.dirname(os.path.abspath(__file__))
OUTPUT_DIR = os.path.join(HERE, "output")
SEEDS_PATH = os.path.join(HERE, "..", "..", "gerbil-manager-web", "src", "genetics", "colorVarietySeed.backend.json")
def generate_guid(key_str):
"""Generate a stable UUID string based on a key."""
return str(uuid.uuid5(uuid.NAMESPACE_DNS, key_str))
def parse_date_only(d):
"""Convert DD.MM.YYYY string to YYYY-MM-DD for JSON serialization."""
if not d:
return None
try:
p = d.split(".")
if len(p) == 3:
day = int(p[0])
month = int(p[1])
year = int(p[2])
if len(p[2]) == 2:
year += 2000
# Ensure valid date
dt = datetime(year, month, day)
return dt.strftime("%Y-%m-%d")
except Exception:
pass
return None
def save_to_csv(data_list, filepath):
import csv
if not data_list:
return
headers = list(data_list[0].keys())
with open(filepath, "w", encoding="utf-8-sig", newline="") as f:
writer = csv.writer(f, delimiter=";")
writer.writerow(headers)
for row in data_list:
values = []
for h in headers:
val = row[h]
if isinstance(val, (list, dict)):
values.append(json.dumps(val, ensure_ascii=False))
elif val is None:
values.append("")
else:
values.append(str(val))
writer.writerow(values)
def main():
print("Loading extracted JSON files...")
animals_file = os.path.join(OUTPUT_DIR, "animals.json")
litters_file = os.path.join(OUTPUT_DIR, "litters.json")
if not os.path.exists(animals_file) or not os.path.exists(litters_file):
print(f"Error: extract.py must be run first to generate animals.json and litters.json in {OUTPUT_DIR}")
return
with open(animals_file, "r", encoding="utf-8") as f:
animals = json.load(f)
with open(litters_file, "r", encoding="utf-8") as f:
litters = json.load(f)
# Load color variety seeds
print("Loading color variety seeds...")
with open(SEEDS_PATH, "r", encoding="utf-8") as f:
seeds = json.load(f)
# Map variety name -> Guid
variety_map = {}
for v in seeds:
# UUID is based on SortOrder + 1
variety_id = f"00000000-0000-0000-0000-{v['sortOrder'] + 1:012d}"
variety_map[v["name"].strip().lower()] = variety_id
# 1. Establish stable Guid maps
animal_guid_map = {a["id"]: generate_guid(f"animal-{a['id']}") for a in animals}
litter_guid_map = {l["id"]: generate_guid(f"litter-{l['id']}") for l in litters}
# 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 = {}
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)
# 2. Extract and resolve unique Contacts (Breeders & Zuchten)
print("Extracting unique contacts...")
contact_names = set()
for a in animals:
if a.get("breeder"):
contact_names.add(a["breeder"].strip())
if a.get("zucht"):
contact_names.add(a["zucht"].strip())
for l in litters:
if l.get("damZucht"):
contact_names.add(l["damZucht"].strip())
if l.get("sireZucht"):
contact_names.add(l["sireZucht"].strip())
contact_guid_map = {}
resolved_contacts = []
for idx, name in enumerate(sorted(contact_names)):
norm = name.strip().lower()
if not norm or norm in contact_guid_map:
continue
c_guid = generate_guid(f"contact-{norm}")
contact_guid_map[norm] = c_guid
resolved_contacts.append({
"Id": c_guid,
"Name": name,
"ZuchtName": name if "zucht" in norm or "clan" in norm or "runner" in norm else "",
"City": "",
"Email": "",
"Homepage": "",
"Phone": "",
"Address": ""
})
# 3. Resolve parent relationships for litters
print("Resolving litter parents...")
litter_parent_map = {} # litter_id -> (father_guid, mother_guid)
# Pre-map parents using offspring parentRefs
for a in animals:
litter_ref = a.get("litterRef")
if not litter_ref or not litter_ref.get("litterId"):
continue
l_id = litter_ref["litterId"]
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_candidates = animal_by_name_dob.get(p_key, [])
if p_candidates:
p_guid = animal_guid_map[p_candidates[0]["id"]]
if p_ref["roleGuess"] == "father":
l_parents[0] = p_guid
elif p_ref["roleGuess"] == "mother":
l_parents[1] = p_guid
# Fallback lookup for parents by name from litter details if offspring has no parentRefs
for l in litters:
l_parents = litter_parent_map.setdefault(l["id"], [None, None])
l_date_str = parse_date_only(l["date"])
if not l_date_str:
continue
l_date = datetime.strptime(l_date_str, "%Y-%m-%d")
# Fallback Father
if not l_parents[0] and l.get("sireName"):
sire_norm = ex.norm_name(l["sireName"])
# Find an animal with this name born before the litter
candidates = []
for (name_norm, dob_norm), grp in animal_by_name_dob.items():
if name_norm == sire_norm:
for cand in grp:
c_dob_str = parse_date_only(cand["dob"])
if c_dob_str:
c_dob = datetime.strptime(c_dob_str, "%Y-%m-%d")
if c_dob < l_date:
candidates.append(cand)
if len(candidates) == 1:
l_parents[0] = animal_guid_map[candidates[0]["id"]]
# Fallback Mother
if not l_parents[1] and l.get("damName"):
dam_norm = ex.norm_name(l["damName"])
candidates = []
for (name_norm, dob_norm), grp in animal_by_name_dob.items():
if name_norm == dam_norm:
for cand in grp:
c_dob_str = parse_date_only(cand["dob"])
if c_dob_str:
c_dob = datetime.strptime(c_dob_str, "%Y-%m-%d")
if c_dob < l_date:
candidates.append(cand)
if len(candidates) == 1:
l_parents[1] = animal_guid_map[candidates[0]["id"]]
# 4. Construct Litters collection
resolved_litters = []
for l in litters:
l_guid = litter_guid_map[l["id"]]
p_father, p_mother = litter_parent_map.get(l["id"], [None, None])
l_date = parse_date_only(l["date"])
if not l_date:
continue # Skip litters without dates
# Inzucht/Frühsterblichkeit mapping
deaths_8w = None
if l.get("diedLater") is not None or l.get("stillborn") is not None:
deaths_8w = (l.get("stillborn") or 0) + (l.get("diedLater") or 0)
resolved_litters.append({
"Id": l_guid,
"Name": f"Wurf {l['litterId']}".strip(),
"Date": l_date,
"TotalBorn": l.get("totalBorn"),
"DeathsWithin8Weeks": deaths_8w,
"FatherId": p_father,
"MotherId": p_mother,
"ExpectedGoHomeDate": None,
"Notes": l.get("note") if l.get("note") else None,
"PairingCode": l.get("zuchtnummer") if l.get("zuchtnummer") else None,
"ExternalRef": l["id"],
"LitterLetter": l["litterId"] if len(l["litterId"]) <= 2 else None
})
# 5. Determine Residency & Status
# C# Residency Rule: IsResident iff Zuchtname matches Clan kennel, OR is parent of resident
resident_guids = set()
# Step A: Direct Zucht name match
for a in animals:
if ex.is_clan_zucht(a.get("zucht")) or ex.is_clan_zucht(a.get("zuchtCanon")):
resident_guids.add(animal_guid_map[a["id"]])
# Step B: Parent of Clan offspring
# Re-run propagation loop a few times to cover multiple generations
for _ in range(5):
for l in litters:
l_guid = litter_guid_map[l["id"]]
# Check if any offspring of this litter is resident
offspring_guids = [animal_guid_map[a["id"]] for a in animals if a.get("litterRef") and a["litterRef"].get("litterId") == l["id"]]
has_resident_offspring = any(og in resident_guids for og in offspring_guids)
if has_resident_offspring:
p_father, p_mother = litter_parent_map.get(l["id"], [None, None])
if p_father:
resident_guids.add(p_father)
if p_mother:
resident_guids.add(p_mother)
# 6. Construct Gerbils collection
resolved_gerbils = []
resolved_photos = []
for a in animals:
a_guid = animal_guid_map[a["id"]]
# Resolve ColorVariety
cv_id = None
fb_key = a["farbschlag"].strip().lower()
if fb_key in variety_map:
cv_id = variety_map[fb_key]
else:
# Try matching variants
for fbv in a.get("farbschlagVariants", []):
fbv_key = fbv.strip().lower()
if fbv_key in variety_map:
cv_id = variety_map[fbv_key]
break
# Resolve Litter
l_id = None
l_ref = a.get("litterRef")
if l_ref and l_ref.get("litterId") and l_ref["litterId"] in litter_guid_map:
l_id = litter_guid_map[l_ref["litterId"]]
# Resolve Contacts
origin_contact_id = None
breeder_norm = a.get("breeder", "").strip().lower()
zucht_norm = a.get("zucht", "").strip().lower()
if breeder_norm in contact_guid_map:
origin_contact_id = contact_guid_map[breeder_norm]
elif zucht_norm in contact_guid_map:
origin_contact_id = contact_guid_map[zucht_norm]
# Dates
dob = parse_date_only(a.get("dob"))
death = parse_date_only(a.get("death"))
# Gender conversion: M -> male, W -> female
gender = "Unknown"
if a.get("gender") == "M":
gender = "male"
elif a.get("gender") == "W":
gender = "female"
# Determine Residency
is_resident = a_guid in resident_guids
# Determine Status
status = "Breeding"
if death:
status = "Deceased"
elif not is_resident:
status = "GivenAway"
else:
# Age presumed deceased (>7 years)
if dob:
dt_dob = datetime.strptime(dob, "%Y-%m-%d")
dt_now = datetime.now()
age_years = (dt_now - dt_dob).days / 365.25
if age_years >= 7.0:
status = "Deceased"
else:
status = "Breeding" # default to breeding for resident stock
# Raw Import payload
raw_import_payload = json.dumps({
"rawGenotype": a["genotype"]["rawGenotype"],
"unmappedTokens": a["genotype"]["unmappedTokens"],
"breederText": a.get("breeder", "")
}, ensure_ascii=False)
resolved_gerbils.append({
"Id": a_guid,
"Name": a["name"],
"Gender": gender,
"Status": status,
"LitterId": l_id,
"OriginContactId": origin_contact_id,
"ReceiverContactId": None,
"EnclosureId": None,
"ColorVarietyId": cv_id,
"DateOfBirth": dob,
"DateOfDeath": death,
"CauseOfDeath": None,
"GoHomeDate": None,
"Genotype": a["genotype"]["rawGenotype"] if a["genotype"]["rawGenotype"] else None,
"Notes": None,
"ImportSource": "docx-export",
"ExternalRef": a["id"],
"RawImportData": raw_import_payload,
"OriginBreeder": a.get("breeder") if a.get("breeder") else (a.get("zucht") if a.get("zucht") else None),
"NameSearch": ex.norm_name(a["name"]),
"CharacterTraits": [],
"CharacterNote": None,
"IsDeaf": a.get("deaf"),
"IsResident": is_resident
})
# Attach Photos
for idx, photo_rel in enumerate(a.get("photos", [])):
photo_guid = generate_guid(f"photo-{photo_rel}")
ext = os.path.splitext(photo_rel)[1] or ".jpeg"
fn_guid = photo_guid.replace("-", "")
resolved_photos.append({
"Id": photo_guid,
"GerbilId": a_guid,
"FileName": f"{fn_guid}{ext}",
"SortOrder": idx,
"_source_path": photo_rel
})
# Set IsBreeder and IsReceiver flags on contacts
breeder_ids = {g["OriginContactId"] for g in resolved_gerbils if g.get("OriginContactId")}
receiver_ids = {g["ReceiverContactId"] for g in resolved_gerbils if g.get("ReceiverContactId")}
for c in resolved_contacts:
c_id = c["Id"]
is_breeder = c_id in breeder_ids
is_receiver = c_id in receiver_ids
if not is_breeder and not is_receiver:
is_receiver = True
c["IsBreeder"] = is_breeder
c["IsReceiver"] = is_receiver
# Save resolved data
resolved_data = {
"Contacts": resolved_contacts,
"Litters": resolved_litters,
"Gerbils": resolved_gerbils,
"GerbilPhotos": resolved_photos
}
resolved_path = os.path.join(OUTPUT_DIR, "resolved_import.json")
with open(resolved_path, "w", encoding="utf-8") as f:
json.dump(resolved_data, f, ensure_ascii=False, indent=2)
# Save resolved CSV files
save_to_csv(resolved_contacts, os.path.join(OUTPUT_DIR, "resolved_contacts.csv"))
save_to_csv(resolved_litters, os.path.join(OUTPUT_DIR, "resolved_litters.csv"))
save_to_csv(resolved_gerbils, os.path.join(OUTPUT_DIR, "resolved_gerbils.csv"))
save_to_csv(resolved_photos, os.path.join(OUTPUT_DIR, "resolved_photos.csv"))
print(f"Success! Saved resolved database-ready records to {resolved_path} (and CSV counterparts):")
print(f" Contacts: {len(resolved_contacts)}")
print(f" Litters: {len(resolved_litters)}")
print(f" Gerbils: {len(resolved_gerbils)}")
print(f" GerbilPhotos: {len(resolved_photos)}")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,65 @@
# run_app_and_import.ps1
# This script starts the .NET Aspire AppHost and automatically runs the database import once the WebAPI is ready.
Write-Host "Starting GerbilManager AppHost (this will boot PostgreSQL and the WebAPI)..." -ForegroundColor Cyan
# Start AppHost in the background
$appProcess = Start-Process dotnet -ArgumentList "run --project GerbilManager.AppHost" -PassThru -NoNewWindow
# Function to cleanup process on exit
function Cleanup {
Write-Host "`nStopping AppHost..." -ForegroundColor Yellow
if ($appProcess -and -not $appProcess.HasExited) {
Stop-Process -Id $appProcess.Id -Force
}
}
Register-EngineEvent -SourceIdentifier PowerShell.Exiting -Action { Cleanup }
try {
# Poll the import endpoint until it becomes responsive (max 60 seconds)
$url = "http://localhost:5179/import/ingest-resolved"
$success = $false
Write-Host "Waiting for WebAPI to become responsive at $url..." -ForegroundColor Cyan
for ($i = 0; $i -lt 30; $i++) {
try {
# Send a test request or check port
$response = Invoke-WebRequest -Uri "http://localhost:5179/openapi/v1.json" -Method Get -TimeoutSec 2 -ErrorAction Stop
if ($response.StatusCode -eq 200) {
$success = $true
break
}
}
catch {
# WebAPI not ready yet
Start-Sleep -Seconds 2
}
}
if (-not $success) {
Write-Host "Error: WebAPI did not become ready within 60 seconds." -ForegroundColor Red
Cleanup
exit 1
}
Write-Host "WebAPI is up! Triggering the database import..." -ForegroundColor Green
# Run the import POST request
try {
$importResponse = Invoke-RestMethod -Uri $url -Method Post -ContentType "application/json"
Write-Host "Import Result: $importResponse" -ForegroundColor Green
Write-Host "`nThe application is now fully running and imported!" -ForegroundColor Cyan
Write-Host "Press Ctrl+C in this terminal to stop the application." -ForegroundColor Yellow
# Keep script running to keep the app process alive
while ($true) {
Start-Sleep -Seconds 1
}
}
catch {
Write-Host "Error during database import: $_" -ForegroundColor Red
}
}
finally {
Cleanup
}

View File

@@ -68,8 +68,8 @@ finally:
except OSError: pass
gen = e.gen_of
check("gen_of: early bands < 2 (E,H)", gen(5) < 2 and gen(8) < 2)
check("gen_of: deep bands >= 2 (K,N,Q)", gen(11) >= 2 and gen(14) >= 2)
check("gen_of: early bands B, E, H", gen(2) == 0 and gen(5) == 1 and gen(8) == 2)
check("gen_of: deep bands K, N, Q", gen(11) == 3 and gen(14) == 4 and gen(17) == 5)
# --- conflict-decisions consumption (HUMANQUESTION D / C6) ---
dec_path = os.path.join(tempfile.gettempdir(), "conflict-decisions-test.json")

View File

@@ -0,0 +1,32 @@
-- WIPE-IMPORT-DATA: truncate only import-derived tables, preserve seeded data.
--
-- Scope (god decision 2026-06-07):
-- WIPE: GerbilPhotos, WeightRecords, HealthRecords, Gerbils, Litters
-- KEEP: ColorVarieties (migration-seeded, import matches against these)
-- BreederSettings, Sites, Pages (CMS), Contacts, Enclosures,
-- SaleContracts, Requests, MailSettings
--
-- Run ONLY after pg_dump backup. FK order: dependents first.
-- Usage (with Aspire Postgres running):
-- docker exec -i <postgres-container> psql -U postgres gerbilmanager < tools/wipe-import-data.sql
-- OR connect via psql and \i this file.
BEGIN;
-- 1. leaf dependents
TRUNCATE TABLE "GerbilPhotos" CASCADE;
TRUNCATE TABLE "WeightRecords" CASCADE;
TRUNCATE TABLE "HealthRecords" CASCADE;
-- 2. main import tables (Gerbils before Litters: Gerbil.LitterId FK)
TRUNCATE TABLE "Gerbils" CASCADE;
TRUNCATE TABLE "Litters" CASCADE;
COMMIT;
-- Verify (expected: 0 rows each)
SELECT 'GerbilPhotos' AS tbl, COUNT(*) FROM "GerbilPhotos"
UNION ALL SELECT 'WeightRecords', COUNT(*) FROM "WeightRecords"
UNION ALL SELECT 'HealthRecords', COUNT(*) FROM "HealthRecords"
UNION ALL SELECT 'Gerbils', COUNT(*) FROM "Gerbils"
UNION ALL SELECT 'Litters', COUNT(*) FROM "Litters";