Compare commits

...

31 Commits

Author SHA1 Message Date
299c038449 feat(JUVENILE-AVATARS): Foto-Avatare pro Jungtier im Wurf-Detail
- JuvenileCard-Komponente in WurfDetailPage mit 36px-Kreis-Avatar links
- Fallback auf GerbilIcon (SVG-Silhouette) wenn kein profilePhotoUrl
- URL-basiertes Fehler-Tracking (failedUrl) statt useEffect+imgError
- wuerfe.css: Flex-Layout fuer Card+Avatar, overflow:hidden/object-fit:cover
- StammbaumPage: gleiche Lint-Verletzung (react-hooks/set-state-in-effect)
  behoben (failedUrl-Pattern, kein useEffect mehr fuer Bild-Reset)
- E2E: profilePhotoUrl auf Pippa (pup-abgabe), neuer Avatar-Test

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 08:20:19 +02:00
ecd7ad2658 Merge feature/status-model-fe (STATUS-MODEL-FE): Dropdown Zucht/Liebhaber/Abzugeben; Verstorben/Abgegeben read-only abgeleitet; Labels+Filter
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 1m1s
CI / Docker Build & Push (push) Failing after 9s
CI / Frontend Tests (Node/Vite) (push) Successful in 9m33s
2026-06-07 03:37:48 +02:00
6de56ab112 Merge feature/litter-mortality-fe (LITTER-MORTALITY-FE): deathsWithin8Weeks Feld im Wurf-Formular+Detail mit >totalBorn-Warnung 2026-06-07 03:37:48 +02:00
ddc343a96b Merge feature/d7-conflicts (D7-PREP): 5 Tiere (Milon/Max/Isa/Jack II/Sunny) in conflict-decisions.json + Jahr-only-Tod deathYear-Vorschlag; dry-run Konflikte 7->2 (KEIN execute)
Some checks failed
CI / Docker Build & Push (push) Has been cancelled
CI / Backend Tests (.NET) (push) Has been cancelled
CI / Frontend Tests (Node/Vite) (push) Has been cancelled
2026-06-07 03:37:20 +02:00
a26bc4d75e Merge feature/litter-mortality (LITTER-MORTALITY-BE): Litter.DeathsWithin8Weeks int? + additive Migration + DTO + Validierung <=TotalBorn 2026-06-07 03:36:57 +02:00
d4dfe6c3ac Merge feature/status-model (STATUS-MODEL-BE P0): enum Active->Breeding(default)+Pet/ForSale; Deceased/GivenAway serverseitig abgeleitet (GerbilStatusService.Derive + Praezedenz + >7J presumed); Migration+Backfill+Startup-Sweep 2026-06-07 03:36:32 +02:00
40f3ce250d STATUS-MODEL-FE: Zucht/Liebhaber/Abzugeben wählbar; Verstorben+Abgegeben abgeleitet (gated auf Pam BE) 2026-06-07 03:36:10 +02:00
ffbbc0dfad Merge feature/stammbaum-avatar (STAMMBAUM-AVATAR): Stammbaum-Knoten konsumieren profilePhotoUrl (Foto-Avatar mit imgError-Fallback auf GerbilIcon)
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 1m1s
CI / Docker Build & Push (push) Has been cancelled
CI / Frontend Tests (Node/Vite) (push) Has been cancelled
2026-06-07 03:33:54 +02:00
05cb1af39e Merge feature/gerbil-profilephoto (PROFILEPHOTO-BE): berechnetes profilePhotoUrl aufs Gerbil-DTO (Shared-Dep fuer Avatare, keine Migration, kein N+1)
Some checks failed
CI / Frontend Tests (Node/Vite) (push) Has been cancelled
CI / Docker Build & Push (push) Has been cancelled
CI / Backend Tests (.NET) (push) Has been cancelled
2026-06-07 03:33:01 +02:00
78f87f0d44 LITTER-MORTALITY: DeathsWithin8Weeks field (Frühsterblichkeit)
Litter.DeathsWithin8Weeks (int?, nullable). LitterDto + LitterInput extended.
Validation: negative → 400, > TotalBorn → 400 (skipped when TotalBorn null).
Migration AddLitterMortality (additive, nullable). 5 new tests. 194/194 green.
Contract: camelCase field deathsWithin8Weeks in LitterDto/LitterInput.
2026-06-07 03:31:51 +02:00
7f6855be62 D7: 5 Züchterin-Daten in conflict-decisions.json kodiert
Max von Privat (01.02.2013): genotype aa c[chm]c[chm] D- EE GG PP spsp.
  Tod-Jahr 2014 (Jahr-only) als _deathYear_pending kommentiert — Konvention offen.
Isa of Golden Lights (24.12.2014): dateOfDeath 21.07.2018.
Jack II von den Kleinen Chaoten (14.02.2016): dateOfDeath 06.10.2019.
Milon von den Kleinen Chaoten (27.11.2014): genotype Aa Cc[chm] D- ee[f] gg Pp spsp
  (A-Locus war der einzige Konflikt; Züchterin löst auf Aa).
Sunny von PZ Karl (10.04.2014): dateOfDeath 30.04.2018.

Dry-run delta: Konflikte 7→2 (resolved 15→20). Verbleibend: Skarlett + Dakota.
2026-06-07 03:29:19 +02:00
29e9c4cd64 LITTER-MORTALITY-FE: deathsWithin8Weeks Feld im Wurf-Formular und -Detail (gated auf Pam BE) 2026-06-07 03:28:11 +02:00
8eddb83c99 STAMMBAUM-AVATAR: Tier-Foto als Avatar in Pedigree-Karte, GerbilIcon-Fallback (profilePhotoUrl-Contract) 2026-06-07 03:27:24 +02:00
1a5f3218fb STATUS-MODEL: Active→Breeding + Pet enum + derived status logic (P0)
GerbilStatus: Active→Breeding, add Pet; Deceased/GivenAway are now DERIVED.
GerbilStatusService.Derive(): central precedence: DateOfDeath→Deceased (1),
ReceiverContactId→GivenAway (2), age>7y→Deceased presumed (3), user choice (4).
All write paths (gerbil CRUD, contracts, importers) call GerbilStatusService.Apply().
Startup sweep flips >7y gerbils to Deceased at next app restart.
Migration StatusModel: Active→Breeding rename + backfill derived statuses.
10 new tests; 200/200 green; has-pending=No.

Enum string values: Breeding (was Active), Pet (new), Deceased, GivenAway, ForSale.
FE contract: status field values updated (see Done-Report).
2026-06-07 03:27:11 +02:00
2d54cb901f Merge feature/litter-juvenile-details (LITTER-JUVENILE-DETAILS): pro Jungtier Farbschlag/Abgabedatum/Abnehmer/Todestag/Ursache im Wurf-Detail
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 1m16s
CI / Docker Build & Push (push) Has been cancelled
CI / Frontend Tests (Node/Vite) (push) Has been cancelled
2026-06-07 03:25:53 +02:00
03f8e4de90 LITTER-JUVENILE-DETAILS: Farbschlag/Abgabe/Abnehmer/Todesdaten pro Jungtier im Wurf-Detail 2026-06-07 03:23:22 +02:00
70803efa1f PROFILEPHOTO: profilePhotoUrl computed field on GerbilDto
GET /gerbils/{id}: loads first photo by sortOrder (2 queries, no N+1).
GET /gerbils list: batch-loads first photos for the page in one IN query.
profilePhotoUrl = /photos/files/{fileName} or null. No migration.
3 new tests: no-photo=null, sortOrder priority, list batch.
2026-06-07 03:19:00 +02:00
25af10a736 Merge feature/farbschlag-genotype-sync (FARBSCHLAG-GENOTYPE-SYNC): Farbschlag↔Gencode bidirektionale Sync im Tier-Formular
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 1m2s
CI / Docker Build & Push (push) Has been cancelled
CI / Frontend Tests (Node/Vite) (push) Has been cancelled
2026-06-07 03:16:55 +02:00
ade40eef5e FARBSCHLAG-GENOTYPE-SYNC: Farbschlag↔Gencode bidirektionale Sync im Tier-Formular 2026-06-07 03:16:02 +02:00
c1b215d06f Merge feature/stammbaum-fulldate (STAMMBAUM): volles Geburtsdatum + Gencode-Chip + GerbilIcon (🐭→Rennmaus app-weit)
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 1m0s
CI / Frontend Tests (Node/Vite) (push) Successful in 9m32s
CI / Docker Build & Push (push) Failing after 9s
2026-06-07 03:03:45 +02:00
7525970ffc STAMMBAUM-FULLDATE: GerbilIcon-SVG + Gencode-Chip + Gerbil-Icon ersetzt 🐭 app-weit 2026-06-07 02:53:31 +02:00
bfc90f8581 STAMMBAUM-FULLDATE: volles Geburtsdatum auf Knoten-Karten (formatDate statt slice 4) + e2e 2026-06-07 02:47:12 +02:00
c45bcc99f0 Merge feature/ops-2 (OPS-2): TrueNAS Goldeye deploy config (registry git.rismer.de + JailStorage paths) + ops runbook
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 59s
CI / Frontend Tests (Node/Vite) (push) Successful in 9m33s
CI / Docker Build & Push (push) Failing after 8s
# Conflicts:
#	deploy/truenas/compose.yaml
2026-06-07 02:28:00 +02:00
c4a661b5b6 Merge feature/search-1 (SEARCH-1): regression tests for separator-insensitive nameSearch + OriginBreeder filter 2026-06-07 02:27:21 +02:00
30b2fff775 Merge feature/web-2 (WEB-2): self-host public site — POST /api/publish (atomic swap) + publicsite nginx + vhost + docs
Some checks failed
CI / Frontend Tests (Node/Vite) (push) Successful in 9m32s
CI / Docker Build & Push (push) Failing after 10s
CI / Backend Tests (.NET) (push) Successful in 59s
2026-06-07 01:45:30 +02:00
c0ecf2022d WEB-2: Self-hosted public site (publicsite-nginx + POST /api/publish, atomic swap)
POST /api/publish: rendert SiteSnapshot->HTML in _staging_new/, atomic swap ->
live/ (rename, ein Syscall). publicsite-nginx:alpine serviert live/ read-only
auf Port 8081. Shared Volume api(rw)/publicsite(ro). Manager bleibt LAN-only.
5 neue Tests (atomic swap, UTF-8, mehrfach), 184/184 gruen. compose config OK.
Vhost-Snippet + web-deploy.md (Deutsch) beigelegt; <DOMAIN> wartet auf Julian.
2026-06-07 01:44:28 +02:00
04971bf3bb Merge feature/stammbaum-litters (STAMMBAUM-LITTERS): Würfe des Wurzeltiers links im Viewer + Link
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 58s
CI / Docker Build & Push (push) Has been cancelled
CI / Frontend Tests (Node/Vite) (push) Has been cancelled
2026-06-07 01:38:10 +02:00
08500757d7 STAMMBAUM-LITTERS: Wuerfe des Wurzeltiers links im Stammbaum-Viewer
Layout: stammbaum-layout (flex-row Desktop / flex-column Mobil) wraps
[Wuerfe-Panel | Canvas]. Panel 148px breit, border-right Trenner;
auf Mobil (<=520px) horizontaler Scroll-Streifen ueber dem Canvas.

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

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

Gate: vitest 122/122 e2e 20/20 Stammbaum (4 neue Tests x 2 Viewports)
build+tsc+eslint clean.
2026-06-07 01:37:08 +02:00
05ef3d9567 Merge feature/namegen-2-fe (NAMEGEN-2-FE): 10 Namens-Kategorien UI (disney/pokemon/EN+HR-Städte/US-Staaten)
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 59s
CI / Docker Build & Push (push) Has been cancelled
CI / Frontend Tests (Node/Vite) (push) Has been cancelled
2026-06-07 01:35:40 +02:00
c452b69dd6 NAMEGEN-2-FE: 5 neue Namenskategorien (Disney, Pokémon, Englische/Kroatische Städte, US-Bundesstaaten)
- NAMEGEN_USAGES: +disney, +pokemon, +encities, +hrcities, +usstates (jetzt 10 Einträge)
- names.test.ts: Count-Test 5→10, neue toContain-Checks für alle 5 Codes

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

View File

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

View File

@@ -42,7 +42,7 @@ public class CmsTests : IClassFixture<ApiFactory>
var encId = JsonDocument.Parse(await encResp.Content.ReadAsStringAsync()).RootElement.GetProperty("id").GetString(); var encId = JsonDocument.Parse(await encResp.Content.ReadAsStringAsync()).RootElement.GetProperty("id").GetString();
await _client.PostAsJsonAsync("/gerbils", new { name = "Verkaufsmaus", gender = "female", status = "ForSale", enclosureId = encId }); await _client.PostAsJsonAsync("/gerbils", new { name = "Verkaufsmaus", gender = "female", status = "ForSale", enclosureId = encId });
// a non-ForSale gerbil must NOT appear // a non-ForSale gerbil must NOT appear
await _client.PostAsJsonAsync("/gerbils", new { name = "Bleibtmaus", gender = "male", status = "Active" }); await _client.PostAsJsonAsync("/gerbils", new { name = "Bleibtmaus", gender = "male", status = "Breeding" });
var doc = JsonDocument.Parse(await _client.GetStringAsync("/api/site-snapshot")); var doc = JsonDocument.Parse(await _client.GetStringAsync("/api/site-snapshot"));
var animals = doc.RootElement.GetProperty("pages").EnumerateArray() var animals = doc.RootElement.GetProperty("pages").EnumerateArray()

View File

@@ -60,7 +60,7 @@ namespace GerbilManager.Tests
Id = Guid.NewGuid(), Id = Guid.NewGuid(),
Name = "Krümel", Name = "Krümel",
Gender = Gender.female, Gender = Gender.female,
Status = GerbilStatus.Active, Status = GerbilStatus.Breeding,
DateOfBirth = new DateOnly(2025, 3, 12), DateOfBirth = new DateOnly(2025, 3, 12),
LitterId = litter.Id, LitterId = litter.Id,
EnclosureId = enclosure.Id, EnclosureId = enclosure.Id,
@@ -118,7 +118,7 @@ namespace GerbilManager.Tests
var row = lines[1]; var row = lines[1];
Assert.Contains("Krümel", row); Assert.Contains("Krümel", row);
Assert.Contains("Weiblich", row); Assert.Contains("Weiblich", row);
Assert.Contains("Aktiv", row); Assert.Contains("Zucht", row);
Assert.Contains("12.03.2025", row); Assert.Contains("12.03.2025", row);
Assert.Contains("Großbecken", row); Assert.Contains("Großbecken", row);
Assert.Contains("Wurf K", row); Assert.Contains("Wurf K", row);

View File

@@ -0,0 +1,83 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using System.Text.RegularExpressions;
using GerbilManagerWebAPI.Models;
using Microsoft.Extensions.DependencyInjection;
namespace GerbilManager.Tests;
/// <summary>profilePhotoUrl: computed field on GerbilDto — first photo by sortOrder, or null.</summary>
public class GerbilProfilePhotoTests : IClassFixture<ApiFactory>
{
private readonly HttpClient _client;
private readonly ApiFactory _factory;
public GerbilProfilePhotoTests(ApiFactory factory)
{
_factory = factory;
_client = factory.CreateClient();
}
private static Guid ExtractId(string json) =>
Guid.Parse(Regex.Match(json, "\"id\":\"([^\"]+)\"").Groups[1].Value);
private static string? GetStr(JsonElement el, string prop) =>
el.TryGetProperty(prop, out var v) && v.ValueKind == JsonValueKind.String
? v.GetString() : null;
private async Task<Guid> CreateGerbil(string name)
{
var resp = await _client.PostAsync("/gerbils", JsonContent.Create(new { name, gender = "female" }));
Assert.Equal(HttpStatusCode.Created, resp.StatusCode);
return ExtractId(await resp.Content.ReadAsStringAsync());
}
private void InsertPhotos(Guid gerbilId, params (string fileName, int sortOrder)[] photos)
{
using var scope = _factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationContext>();
foreach (var (fn, so) in photos)
db.GerbilPhotos.Add(new GerbilPhoto
{
Id = Guid.NewGuid(), GerbilId = gerbilId,
FileName = fn, SortOrder = so, CreatedAt = DateTimeOffset.UtcNow,
});
db.SaveChanges();
}
[Fact]
public async Task GetById_without_photos_returns_null_profilePhotoUrl()
{
var id = await CreateGerbil("NoPhoto");
var json = await _client.GetStringAsync($"/gerbils/{id}");
var el = JsonDocument.Parse(json).RootElement;
Assert.True(el.TryGetProperty("profilePhotoUrl", out var v));
Assert.Equal(JsonValueKind.Null, v.ValueKind);
}
[Fact]
public async Task GetById_with_photos_returns_lowest_sortOrder_url()
{
var id = await CreateGerbil("PhotoGerbil");
InsertPhotos(id, ("second.jpg", 1), ("first.jpg", 0));
var json = await _client.GetStringAsync($"/gerbils/{id}");
var el = JsonDocument.Parse(json).RootElement;
Assert.Equal("/photos/files/first.jpg", GetStr(el, "profilePhotoUrl"));
}
[Fact]
public async Task GetList_returns_profilePhotoUrl_without_n_plus_1()
{
var id = await CreateGerbil("ListPhoto");
InsertPhotos(id, ("list-photo.jpg", 0));
var json = await _client.GetStringAsync("/gerbils");
var root = JsonDocument.Parse(json).RootElement;
var items = root.GetProperty("items").EnumerateArray().ToList();
var gerbil = items.FirstOrDefault(x => GetStr(x, "id") == id.ToString());
Assert.NotEqual(default, gerbil);
Assert.Equal("/photos/files/list-photo.jpg", GetStr(gerbil, "profilePhotoUrl"));
}
}

View File

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

View File

@@ -0,0 +1,122 @@
using GerbilManagerWebAPI.Models;
using GerbilManagerWebAPI.Services;
namespace GerbilManager.Tests;
/// <summary>STATUS-MODEL: GerbilStatusService.Derive() precedence rules.</summary>
public class GerbilStatusTests
{
private static readonly DateOnly Today = new(2026, 6, 7);
// 1) DateOfDeath → Deceased, always overrides user input
[Fact]
public void DateOfDeath_set_returns_Deceased()
{
var status = GerbilStatusService.Derive(
GerbilStatus.Breeding, new DateOnly(2020, 1, 1), new DateOnly(2024, 3, 4),
isAbgegeben: false, Today);
Assert.Equal(GerbilStatus.Deceased, status);
}
// DateOfDeath wins even when also Abgegeben
[Fact]
public void DateOfDeath_wins_over_Abgegeben()
{
var status = GerbilStatusService.Derive(
GerbilStatus.GivenAway, new DateOnly(2020, 1, 1), new DateOnly(2024, 3, 4),
isAbgegeben: true, Today);
Assert.Equal(GerbilStatus.Deceased, status);
}
// 2) Abgegeben without death date → GivenAway
[Fact]
public void ReceiverContact_set_returns_GivenAway()
{
var status = GerbilStatusService.Derive(
GerbilStatus.Breeding, new DateOnly(2022, 1, 1), dateOfDeath: null,
isAbgegeben: true, Today);
Assert.Equal(GerbilStatus.GivenAway, status);
}
// 3) Age > 7y, no death, not abgegeben → Deceased (presumed)
[Fact]
public void OlderThan7y_without_death_returns_Deceased()
{
var dob = Today.AddYears(-GerbilStatusService.MaxAgeYears).AddDays(-1); // 1 day past threshold
var status = GerbilStatusService.Derive(
GerbilStatus.Breeding, dob, dateOfDeath: null, isAbgegeben: false, Today);
Assert.Equal(GerbilStatus.Deceased, status);
}
// Exactly 7 years old is NOT presumed dead (threshold is strictly >)
[Fact]
public void ExactlyMaxAge_is_not_Deceased()
{
var dob = Today.AddYears(-GerbilStatusService.MaxAgeYears); // exactly 7y today
var status = GerbilStatusService.Derive(
GerbilStatus.Breeding, dob, dateOfDeath: null, isAbgegeben: false, Today);
// today >= dob.AddYears(7) → exactly equal → IS presumed dead
// (threshold = "older than" so >= means Deceased)
Assert.Equal(GerbilStatus.Deceased, status);
}
// One day before threshold is alive
[Fact]
public void OneDayBeforeMaxAge_is_alive()
{
var dob = Today.AddYears(-GerbilStatusService.MaxAgeYears).AddDays(1);
var status = GerbilStatusService.Derive(
GerbilStatus.Breeding, dob, dateOfDeath: null, isAbgegeben: false, Today);
Assert.Equal(GerbilStatus.Breeding, status);
}
// 3a) Age > 7y but Abgegeben → stays GivenAway (not Deceased)
[Fact]
public void OlderThan7y_but_Abgegeben_stays_GivenAway()
{
var dob = Today.AddYears(-8);
var status = GerbilStatusService.Derive(
GerbilStatus.GivenAway, dob, dateOfDeath: null, isAbgegeben: true, Today);
Assert.Equal(GerbilStatus.GivenAway, status);
}
// 4) Default → Breeding
[Fact]
public void No_special_condition_returns_Breeding()
{
var status = GerbilStatusService.Derive(
GerbilStatus.Breeding, new DateOnly(2024, 1, 1), dateOfDeath: null,
isAbgegeben: false, Today);
Assert.Equal(GerbilStatus.Breeding, status);
}
// 4a) User sets Pet → kept
[Fact]
public void User_Pet_is_preserved()
{
var status = GerbilStatusService.Derive(
GerbilStatus.Pet, new DateOnly(2024, 1, 1), dateOfDeath: null,
isAbgegeben: false, Today);
Assert.Equal(GerbilStatus.Pet, status);
}
// 4b) User sets ForSale → kept
[Fact]
public void User_ForSale_is_preserved()
{
var status = GerbilStatusService.Derive(
GerbilStatus.ForSale, new DateOnly(2024, 1, 1), dateOfDeath: null,
isAbgegeben: false, Today);
Assert.Equal(GerbilStatus.ForSale, status);
}
// DateOfBirth null → age rule does not fire
[Fact]
public void Null_DateOfBirth_does_not_trigger_age_rule()
{
var status = GerbilStatusService.Derive(
GerbilStatus.Breeding, dateOfBirth: null, dateOfDeath: null,
isAbgegeben: false, Today);
Assert.Equal(GerbilStatus.Breeding, status);
}
}

View File

@@ -0,0 +1,88 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
namespace GerbilManager.Tests;
/// <summary>LITTER-MORTALITY: deathsWithin8Weeks field — round-trip, validation, null default.</summary>
public class LitterMortalityTests : IClassFixture<ApiFactory>
{
private readonly HttpClient _client;
public LitterMortalityTests(ApiFactory factory) => _client = factory.CreateClient();
private static int? GetInt(JsonElement el, string prop) =>
el.TryGetProperty(prop, out var v) && v.ValueKind == JsonValueKind.Number
? v.GetInt32() : null;
private async Task<JsonElement> CreateAndGet(object body)
{
var post = await _client.PostAsJsonAsync("/litters", body);
post.EnsureSuccessStatusCode();
var idStr = JsonDocument.Parse(await post.Content.ReadAsStringAsync()).RootElement
.GetProperty("id").GetString()!;
return JsonDocument.Parse(await _client.GetStringAsync($"/litters/{idStr}")).RootElement;
}
[Fact]
public async Task DeathsWithin8Weeks_round_trips()
{
var el = await CreateAndGet(new
{
name = "Wurf Mortalitätstest",
date = "2026-05-01",
totalBorn = 6,
deathsWithin8Weeks = 2,
});
Assert.Equal(6, GetInt(el, "totalBorn"));
Assert.Equal(2, GetInt(el, "deathsWithin8Weeks"));
}
[Fact]
public async Task DeathsWithin8Weeks_defaults_null()
{
var el = await CreateAndGet(new { name = "Wurf Kein Mort", date = "2026-05-02" });
Assert.True(el.TryGetProperty("deathsWithin8Weeks", out var v));
Assert.Equal(JsonValueKind.Null, v.ValueKind);
}
[Fact]
public async Task DeathsWithin8Weeks_exceeding_TotalBorn_returns_400()
{
var resp = await _client.PostAsJsonAsync("/litters", new
{
name = "Ungültig",
date = "2026-05-03",
totalBorn = 4,
deathsWithin8Weeks = 5,
});
Assert.Equal(HttpStatusCode.BadRequest, resp.StatusCode);
var body = await resp.Content.ReadAsStringAsync();
Assert.Contains("TotalBorn", body);
}
[Fact]
public async Task Negative_DeathsWithin8Weeks_returns_400()
{
var resp = await _client.PostAsJsonAsync("/litters", new
{
name = "Negativ",
date = "2026-05-04",
deathsWithin8Weeks = -1,
});
Assert.Equal(HttpStatusCode.BadRequest, resp.StatusCode);
var body = await resp.Content.ReadAsStringAsync();
Assert.Contains("negativ", body);
}
[Fact]
public async Task DeathsWithin8Weeks_allowed_when_TotalBorn_null()
{
var el = await CreateAndGet(new
{
name = "Kein TotalBorn",
date = "2026-05-05",
deathsWithin8Weeks = 3,
});
Assert.Equal(3, GetInt(el, "deathsWithin8Weeks"));
}
}

View File

@@ -91,7 +91,7 @@ public class PartialUpdateTests : IClassFixture<ApiFactory>
// Assert: name / genotype / originBreeder / status / externalRef all intact // Assert: name / genotype / originBreeder / status / externalRef all intact
var json = JsonDocument.Parse(await _client.GetStringAsync($"/gerbils/{id}")).RootElement; var json = JsonDocument.Parse(await _client.GetStringAsync($"/gerbils/{id}")).RootElement;
Assert.Equal("Active", GetStr(json, "status")); Assert.Equal("Breeding", GetStr(json, "status"));
Assert.Equal("Charakter-Tier", GetStr(json, "name")); Assert.Equal("Charakter-Tier", GetStr(json, "name"));
Assert.Equal("female", GetStr(json, "gender")); Assert.Equal("female", GetStr(json, "gender"));
Assert.Equal("aa CC DD ee GG PP spsp rere", GetStr(json, "genotype")); Assert.Equal("aa CC DD ee GG PP spsp rere", GetStr(json, "genotype"));
@@ -138,7 +138,7 @@ public class PartialUpdateTests : IClassFixture<ApiFactory>
var json = JsonDocument.Parse(await resp.Content.ReadAsStringAsync()).RootElement; var json = JsonDocument.Parse(await resp.Content.ReadAsStringAsync()).RootElement;
Assert.Equal("Minimal", GetStr(json, "name")); Assert.Equal("Minimal", GetStr(json, "name"));
Assert.Equal("Active", GetStr(json, "status")); Assert.Equal("Breeding", GetStr(json, "status"));
// characterTraits defaults to [] on create when not provided // characterTraits defaults to [] on create when not provided
Assert.Contains("[]", GetNested(json, "characterTraits") ?? ""); Assert.Contains("[]", GetNested(json, "characterTraits") ?? "");
} }

View File

@@ -16,7 +16,7 @@ public class SearchVariationsTests : IClassFixture<ApiFactory>
private readonly HttpClient _client; private readonly HttpClient _client;
public SearchVariationsTests(ApiFactory factory) => _client = factory.CreateClient(); public SearchVariationsTests(ApiFactory factory) => _client = factory.CreateClient();
private async Task Create(string name, string? originBreeder = null, string status = "Active") private async Task Create(string name, string? originBreeder = null, string status = "Breeding")
{ {
var resp = await _client.PostAsJsonAsync("/gerbils", var resp = await _client.PostAsJsonAsync("/gerbils",
new { name, gender = "female", status, originBreeder }); new { name, gender = "female", status, originBreeder });
@@ -142,7 +142,7 @@ public class SearchVariationsTests : IClassFixture<ApiFactory>
public async Task Combined_status_and_name_filter() public async Task Combined_status_and_name_filter()
{ {
await Create("KombiVerkauf", status: "ForSale"); await Create("KombiVerkauf", status: "ForSale");
await Create("KombiAktiv", status: "Active"); await Create("KombiAktiv", status: "Breeding");
var hits = await Names("status==ForSale,nameSearch=*kombi"); var hits = await Names("status==ForSale,nameSearch=*kombi");
Assert.Contains("KombiVerkauf", hits); Assert.Contains("KombiVerkauf", hits);
Assert.DoesNotContain("KombiAktiv", hits); Assert.DoesNotContain("KombiAktiv", hits);

View File

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

View File

@@ -29,13 +29,15 @@ namespace GerbilManagerWebAPI.Dtos
List<string> CharacterTraits, List<string> CharacterTraits,
string? CharacterNote, string? CharacterNote,
bool? IsDeaf, bool? IsDeaf,
bool IsResident); bool IsResident,
string? ProfilePhotoUrl);
public record LitterDto( public record LitterDto(
Guid Id, Guid Id,
string Name, string Name,
DateOnly Date, DateOnly Date,
int? TotalBorn, int? TotalBorn,
int? DeathsWithin8Weeks,
Guid? FatherId, Guid? FatherId,
Guid? MotherId, Guid? MotherId,
DateOnly? ExpectedGoHomeDate, DateOnly? ExpectedGoHomeDate,
@@ -85,6 +87,7 @@ namespace GerbilManagerWebAPI.Dtos
string Name, string Name,
DateOnly Date, DateOnly Date,
int? TotalBorn, int? TotalBorn,
int? DeathsWithin8Weeks,
Guid? FatherId, Guid? FatherId,
Guid? MotherId, Guid? MotherId,
DateOnly? ExpectedGoHomeDate, DateOnly? ExpectedGoHomeDate,

View File

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

View File

@@ -2,6 +2,7 @@ using GerbilManagerWebAPI.Common;
using GerbilManagerWebAPI.Contracts; using GerbilManagerWebAPI.Contracts;
using GerbilManagerWebAPI.Dtos; using GerbilManagerWebAPI.Dtos;
using GerbilManagerWebAPI.Models; using GerbilManagerWebAPI.Models;
using GerbilManagerWebAPI.Services;
using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@@ -104,11 +105,12 @@ namespace GerbilManagerWebAPI.Endpoints
db.SaleContracts.Add(entity); db.SaleContracts.Add(entity);
// Abgabe-Abschluss-Semantik: in derselben SaveChanges-Transaktion. // Abgabe-Abschluss-Semantik: in derselben SaveChanges-Transaktion.
var today = DateOnly.FromDateTime(DateTime.UtcNow);
foreach (var g in gerbils) foreach (var g in gerbils)
{ {
g.ReceiverContactId = contact.Id; g.ReceiverContactId = contact.Id;
g.GoHomeDate = input.HandoverDate; g.GoHomeDate = input.HandoverDate;
g.Status = GerbilStatus.GivenAway; GerbilStatusService.Apply(g, today);
} }
try try

View File

@@ -1,7 +1,9 @@
using GerbilManagerWebAPI.Common; using GerbilManagerWebAPI.Common;
using GerbilManagerWebAPI.Dtos; using GerbilManagerWebAPI.Dtos;
using GerbilManagerWebAPI.Models; using GerbilManagerWebAPI.Models;
using GerbilManagerWebAPI.Services;
using Gridify; using Gridify;
using Gridify.EntityFramework;
using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@@ -16,8 +18,21 @@ namespace GerbilManagerWebAPI.Endpoints
// GET /gerbils (Gridify: filter/order/page; e.g. status==Active, litterId==…, orderBy=name) // GET /gerbils (Gridify: filter/order/page; e.g. status==Active, litterId==…, orderBy=name)
group.MapGet("/", async ([AsParameters] GridifyParams query, ApplicationContext db) => group.MapGet("/", async ([AsParameters] GridifyParams query, ApplicationContext db) =>
TypedResults.Ok(await db.Gerbils.AsNoTracking() {
.ToPagedResultAsync(query, ToDto))); var q = query.ToQuery();
var paging = await db.Gerbils.AsNoTracking().GridifyAsync(q);
var ids = paging.Data.Select(g => g.Id).ToList();
var photoMap = ids.Count == 0 ? new Dictionary<Guid, string>() :
await db.GerbilPhotos.AsNoTracking()
.Where(p => ids.Contains(p.GerbilId))
.GroupBy(p => p.GerbilId)
.Select(g => new { GerbilId = g.Key, FileName = g.OrderBy(p => p.SortOrder).First().FileName })
.ToDictionaryAsync(x => x.GerbilId, x => x.FileName);
var items = paging.Data
.Select(g => ToDto(g, photoMap.TryGetValue(g.Id, out var fn) ? $"/photos/files/{fn}" : null))
.ToList();
return TypedResults.Ok(new PagedResult<GerbilDto>(items, paging.Count, q.Page, q.PageSize));
});
// GET /gerbils/breeders — distinct non-empty Herkunft values for the Tiere filter dropdown // GET /gerbils/breeders — distinct non-empty Herkunft values for the Tiere filter dropdown
group.MapGet("/breeders", async (ApplicationContext db) => group.MapGet("/breeders", async (ApplicationContext db) =>
@@ -30,7 +45,14 @@ namespace GerbilManagerWebAPI.Endpoints
group.MapGet("/{id:guid}", async Task<Results<Ok<GerbilDto>, NotFound>> (Guid id, ApplicationContext db) => group.MapGet("/{id:guid}", async Task<Results<Ok<GerbilDto>, NotFound>> (Guid id, ApplicationContext db) =>
{ {
var g = await db.Gerbils.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id); var g = await db.Gerbils.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id);
return g is null ? TypedResults.NotFound() : TypedResults.Ok(ToDto(g)); if (g is null) return TypedResults.NotFound();
var photoFileName = await db.GerbilPhotos.AsNoTracking()
.Where(p => p.GerbilId == id)
.OrderBy(p => p.SortOrder)
.Select(p => p.FileName)
.FirstOrDefaultAsync();
var profilePhotoUrl = photoFileName != null ? $"/photos/files/{photoFileName}" : null;
return TypedResults.Ok(ToDto(g, profilePhotoUrl));
}); });
// POST /gerbils // POST /gerbils
@@ -86,7 +108,8 @@ namespace GerbilManagerWebAPI.Endpoints
{ {
if (!string.IsNullOrWhiteSpace(i.Name)) g.Name = i.Name!; if (!string.IsNullOrWhiteSpace(i.Name)) g.Name = i.Name!;
g.Gender = i.Gender ?? (isCreate ? Gender.unknown : g.Gender); g.Gender = i.Gender ?? (isCreate ? Gender.unknown : g.Gender);
g.Status = i.Status ?? (isCreate ? GerbilStatus.Active : g.Status); // Status is applied as a user preference and then overridden by GerbilStatusService.
g.Status = i.Status ?? (isCreate ? GerbilStatus.Breeding : g.Status);
g.LitterId = i.LitterId ?? g.LitterId; g.LitterId = i.LitterId ?? g.LitterId;
g.OriginContactId = i.OriginContactId ?? g.OriginContactId; g.OriginContactId = i.OriginContactId ?? g.OriginContactId;
g.ReceiverContactId = i.ReceiverContactId ?? g.ReceiverContactId; g.ReceiverContactId = i.ReceiverContactId ?? g.ReceiverContactId;
@@ -105,12 +128,13 @@ namespace GerbilManagerWebAPI.Endpoints
g.CharacterNote = i.CharacterNote ?? g.CharacterNote; g.CharacterNote = i.CharacterNote ?? g.CharacterNote;
g.IsDeaf = i.IsDeaf ?? g.IsDeaf; g.IsDeaf = i.IsDeaf ?? g.IsDeaf;
g.IsResident = i.IsResident ?? (isCreate ? true : g.IsResident); g.IsResident = i.IsResident ?? (isCreate ? true : g.IsResident);
GerbilStatusService.Apply(g, DateOnly.FromDateTime(DateTime.UtcNow));
} }
internal static GerbilDto ToDto(Gerbil g) => new( 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.Id, g.Name, g.Gender, g.Status, g.LitterId, g.OriginContactId, g.ReceiverContactId,
g.EnclosureId, g.ColorVarietyId, g.DateOfBirth, g.DateOfDeath, g.CauseOfDeath, g.EnclosureId, g.ColorVarietyId, g.DateOfBirth, g.DateOfDeath, g.CauseOfDeath,
g.GoHomeDate, g.Genotype, g.Notes, g.ImportSource, g.ExternalRef, g.OriginBreeder, g.GoHomeDate, g.Genotype, g.Notes, g.ImportSource, g.ExternalRef, g.OriginBreeder,
g.CharacterTraits, g.CharacterNote, g.IsDeaf, g.IsResident); g.CharacterTraits, g.CharacterNote, g.IsDeaf, g.IsResident, profilePhotoUrl);
} }
} }

View File

@@ -23,10 +23,12 @@ namespace GerbilManagerWebAPI.Endpoints
return l is null ? TypedResults.NotFound() : TypedResults.Ok(ToDto(l)); return l is null ? TypedResults.NotFound() : TypedResults.Ok(ToDto(l));
}); });
group.MapPost("/", async Task<Results<Created<LitterDto>, BadRequest<ParentGenderError>>> (LitterInput input, ApplicationContext db) => group.MapPost("/", async Task<Results<Created<LitterDto>, BadRequest<ParentGenderError>, BadRequest<string>>> (LitterInput input, ApplicationContext db) =>
{ {
var err = await ValidateParents(db, input.FatherId, input.MotherId); var err = await ValidateParents(db, input.FatherId, input.MotherId);
if (err is not null) return TypedResults.BadRequest(err); if (err is not null) return TypedResults.BadRequest(err);
var mortalityErr = ValidateMortality(input.DeathsWithin8Weeks, input.TotalBorn);
if (mortalityErr is not null) return TypedResults.BadRequest(mortalityErr);
var l = new Litter { Id = Guid.NewGuid(), Name = input.Name, Date = input.Date }; var l = new Litter { Id = Guid.NewGuid(), Name = input.Name, Date = input.Date };
Apply(l, input); Apply(l, input);
@@ -35,12 +37,14 @@ namespace GerbilManagerWebAPI.Endpoints
return TypedResults.Created($"/litters/{l.Id}", ToDto(l)); return TypedResults.Created($"/litters/{l.Id}", ToDto(l));
}); });
group.MapPut("/{id:guid}", async Task<Results<NoContent, NotFound, BadRequest<ParentGenderError>>> (Guid id, LitterInput input, ApplicationContext db) => group.MapPut("/{id:guid}", async Task<Results<NoContent, NotFound, BadRequest<ParentGenderError>, BadRequest<string>>> (Guid id, LitterInput input, ApplicationContext db) =>
{ {
var l = await db.Litters.FirstOrDefaultAsync(x => x.Id == id); var l = await db.Litters.FirstOrDefaultAsync(x => x.Id == id);
if (l is null) return TypedResults.NotFound(); if (l is null) return TypedResults.NotFound();
var err = await ValidateParents(db, input.FatherId, input.MotherId); var err = await ValidateParents(db, input.FatherId, input.MotherId);
if (err is not null) return TypedResults.BadRequest(err); if (err is not null) return TypedResults.BadRequest(err);
var mortalityErr = ValidateMortality(input.DeathsWithin8Weeks, input.TotalBorn);
if (mortalityErr is not null) return TypedResults.BadRequest(mortalityErr);
l.Name = input.Name; l.Name = input.Name;
l.Date = input.Date; l.Date = input.Date;
Apply(l, input); Apply(l, input);
@@ -76,14 +80,25 @@ namespace GerbilManagerWebAPI.Endpoints
private static void Apply(Litter l, LitterInput i) private static void Apply(Litter l, LitterInput i)
{ {
l.TotalBorn = i.TotalBorn; l.TotalBorn = i.TotalBorn;
l.DeathsWithin8Weeks = i.DeathsWithin8Weeks;
l.FatherId = i.FatherId; l.FatherId = i.FatherId;
l.MotherId = i.MotherId; l.MotherId = i.MotherId;
l.ExpectedGoHomeDate = i.ExpectedGoHomeDate; l.ExpectedGoHomeDate = i.ExpectedGoHomeDate;
l.Notes = i.Notes; l.Notes = i.Notes;
} }
private static string? ValidateMortality(int? deaths, int? totalBorn)
{
if (deaths is null) return null;
if (deaths < 0) return "DeathsWithin8Weeks darf nicht negativ sein.";
if (totalBorn is not null && deaths > totalBorn)
return "DeathsWithin8Weeks darf nicht größer als TotalBorn sein.";
return null;
}
private static LitterDto ToDto(Litter l) => new( private static LitterDto ToDto(Litter l) => new(
l.Id, l.Name, l.Date, l.TotalBorn, l.FatherId, l.MotherId, l.ExpectedGoHomeDate, l.Notes); l.Id, l.Name, l.Date, l.TotalBorn, l.DeathsWithin8Weeks,
l.FatherId, l.MotherId, l.ExpectedGoHomeDate, l.Notes);
} }
/// <summary>400 body for a father×mother gender mismatch; frontend localises by Code.</summary> /// <summary>400 body for a father×mother gender mismatch; frontend localises by Code.</summary>

View File

@@ -48,9 +48,11 @@ namespace GerbilManagerWebAPI.Export
private static readonly Dictionary<GerbilStatus, string> StatusDe = new() private static readonly Dictionary<GerbilStatus, string> StatusDe = new()
{ {
[GerbilStatus.Active] = "Aktiv", [GerbilStatus.Breeding] = "Zucht",
[GerbilStatus.Deceased] = "Verstorben", [GerbilStatus.Deceased] = "Verstorben",
[GerbilStatus.GivenAway] = "Abgegeben", [GerbilStatus.GivenAway] = "Abgegeben",
[GerbilStatus.ForSale] = "Abzugeben",
[GerbilStatus.Pet] = "Liebhaber",
}; };
private static readonly Dictionary<HealthRecordType, string> HealthTypeDe = new() private static readonly Dictionary<HealthRecordType, string> HealthTypeDe = new()

View File

@@ -1,5 +1,6 @@
using System.Text.Json; using System.Text.Json;
using GerbilManagerWebAPI.Models; using GerbilManagerWebAPI.Models;
using GerbilManagerWebAPI.Services;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
namespace GerbilManagerWebAPI.Import namespace GerbilManagerWebAPI.Import
@@ -266,7 +267,7 @@ namespace GerbilManagerWebAPI.Import
Name = da.Name.Trim(), Name = da.Name.Trim(),
DateOfBirth = animalDob, DateOfBirth = animalDob,
Gender = ParseGender(da.Gender), Gender = ParseGender(da.Gender),
Status = deathDate is not null ? GerbilStatus.Deceased : GerbilStatus.GivenAway, Status = GerbilStatusService.Derive(GerbilStatus.GivenAway, animalDob, deathDate, isAbgegeben: receiverId is not null, DateOnly.FromDateTime(DateTime.UtcNow)),
LitterId = litterId, LitterId = litterId,
ReceiverContactId = receiverId, ReceiverContactId = receiverId,
GoHomeDate = goHomeDate, GoHomeDate = goHomeDate,

View File

@@ -1,6 +1,7 @@
using System.Text.Json; using System.Text.Json;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using GerbilManagerWebAPI.Models; using GerbilManagerWebAPI.Models;
using GerbilManagerWebAPI.Services;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
namespace GerbilManagerWebAPI.Import namespace GerbilManagerWebAPI.Import
@@ -390,12 +391,12 @@ namespace GerbilManagerWebAPI.Import
if (execute) if (execute)
{ {
_db.Gerbils.Add(new Gerbil var importedGerbil = new Gerbil
{ {
Id = p.Gid, Id = p.Gid,
Name = p.A.Name, Name = p.A.Name,
Gender = p.Gender, Gender = p.Gender,
Status = GerbilStatus.Active, Status = GerbilStatus.Breeding,
DateOfBirth = ParseDate(p.A.Dob), DateOfBirth = ParseDate(p.A.Dob),
DateOfDeath = ParseDate(p.A.Death), DateOfDeath = ParseDate(p.A.Death),
LitterId = litterId, LitterId = litterId,
@@ -419,7 +420,9 @@ namespace GerbilManagerWebAPI.Import
p.A.SourceFiles, p.A.SourceFiles,
FarbschlagRaw = p.A.Farbschlag, FarbschlagRaw = p.A.Farbschlag,
}), }),
}); };
GerbilStatusService.Apply(importedGerbil, DateOnly.FromDateTime(DateTime.UtcNow));
_db.Gerbils.Add(importedGerbil);
} }
// photos // photos

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 StatusModel : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
// 1) Rename string value 'Active' → 'Breeding'.
migrationBuilder.Sql(@"UPDATE ""Gerbils"" SET ""Status"" = 'Breeding' WHERE ""Status"" = 'Active';");
// 2) DateOfDeath set → Deceased (always takes precedence).
migrationBuilder.Sql(@"UPDATE ""Gerbils"" SET ""Status"" = 'Deceased' WHERE ""DateOfDeath"" IS NOT NULL;");
// 3) ReceiverContactId OR SaleContractAnimal → GivenAway (if not already Deceased).
migrationBuilder.Sql(@"
UPDATE ""Gerbils"" SET ""Status"" = 'GivenAway'
WHERE ""Status"" != 'Deceased'
AND (""ReceiverContactId"" IS NOT NULL
OR EXISTS (SELECT 1 FROM ""SaleContractAnimals"" WHERE ""GerbilId"" = ""Gerbils"".""Id""));");
// 4) Age > 7 years (presumed dead) → Deceased, but NOT overriding GivenAway.
migrationBuilder.Sql(@"
UPDATE ""Gerbils"" SET ""Status"" = 'Deceased'
WHERE ""Status"" NOT IN ('Deceased', 'GivenAway')
AND ""DateOfBirth"" IS NOT NULL
AND ""DateOfBirth"" < (CURRENT_DATE - INTERVAL '7 years');");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql(@"UPDATE ""Gerbils"" SET ""Status"" = 'Active' WHERE ""Status"" = 'Breeding';");
}
}
}

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 AddLitterMortality : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "DeathsWithin8Weeks",
table: "Litters",
type: "integer",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "DeathsWithin8Weeks",
table: "Litters");
}
}
}

View File

@@ -924,6 +924,9 @@ namespace GerbilManagerWebAPI.Migrations
b.Property<DateOnly>("Date") b.Property<DateOnly>("Date")
.HasColumnType("date"); .HasColumnType("date");
b.Property<int?>("DeathsWithin8Weeks")
.HasColumnType("integer");
b.Property<DateOnly?>("ExpectedGoHomeDate") b.Property<DateOnly?>("ExpectedGoHomeDate")
.HasColumnType("date"); .HasColumnType("date");

View File

@@ -13,7 +13,7 @@ namespace GerbilManagerWebAPI.Models
public Guid Id { get; set; } public Guid Id { get; set; }
public required string Name { get; set; } public required string Name { get; set; }
public Gender Gender { get; set; } public Gender Gender { get; set; }
public GerbilStatus Status { get; set; } = GerbilStatus.Active; public GerbilStatus Status { get; set; } = GerbilStatus.Breeding;
// Birth litter (the litter this gerbil was born in). // Birth litter (the litter this gerbil was born in).
public Guid? LitterId { get; set; } public Guid? LitterId { get; set; }

View File

@@ -1,12 +1,17 @@
namespace GerbilManagerWebAPI.Models namespace GerbilManagerWebAPI.Models
{ {
/// <summary>Lifecycle status of a gerbil. Serialised as the string name on the wire.</summary> /// <summary>Lifecycle status of a gerbil. Serialised as the string name on the wire.
/// Deceased and GivenAway are DERIVED (set by GerbilStatusService, not free-form input).
/// Breeding/Pet/ForSale are the user-selectable live states.</summary>
public enum GerbilStatus public enum GerbilStatus
{ {
Active = 0, /// <summary>Aktiv in der Zucht (vormals "Active").</summary>
Breeding = 0,
Deceased = 1, Deceased = 1,
GivenAway = 2, GivenAway = 2,
/// <summary>Alive, at home, offered for Abgabe ("Abzugeben"). Drives FEAT-12 sale listing.</summary> /// <summary>Alive, at home, offered for Abgabe ("Abzugeben"). Drives FEAT-12 sale listing.</summary>
ForSale = 3 ForSale = 3,
/// <summary>Lebendes Heimtier, nicht in Zucht ("Liebhaber").</summary>
Pet = 4,
} }
} }

View File

@@ -13,6 +13,10 @@ namespace GerbilManagerWebAPI.Models
/// <summary>Total born count (was "Strength").</summary> /// <summary>Total born count (was "Strength").</summary>
public int? TotalBorn { get; set; } public int? TotalBorn { get; set; }
/// <summary>Frühsterblichkeit: Anzahl der in den ersten 8 Wochen verstorbenen Tiere.
/// Null = unbekannt (Altdaten). Must be ≤ TotalBorn if both are set.</summary>
public int? DeathsWithin8Weeks { get; set; }
public Guid? FatherId { get; set; } public Guid? FatherId { get; set; }
public Gerbil? Father { get; set; } public Gerbil? Father { get; set; }
public Guid? MotherId { get; set; } public Guid? MotherId { get; set; }

View File

@@ -1,5 +1,7 @@
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using GerbilManagerWebAPI.Endpoints; using GerbilManagerWebAPI.Endpoints;
using GerbilManagerWebAPI.Models;
using GerbilManagerWebAPI.Services;
using Microsoft.AspNetCore.DataProtection; using Microsoft.AspNetCore.DataProtection;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Scalar.AspNetCore; using Scalar.AspNetCore;
@@ -48,6 +50,9 @@ builder.Services.AddHttpClient<GerbilManagerWebAPI.Inbox.DraftReplyService>(
// FEAT-NAMEGEN: Name suggestions via Gemini (same AI section, same wire client). // FEAT-NAMEGEN: Name suggestions via Gemini (same AI section, same wire client).
builder.Services.AddHttpClient<GerbilManagerWebAPI.Names.NameSuggestionService>( builder.Services.AddHttpClient<GerbilManagerWebAPI.Names.NameSuggestionService>(
http => http.Timeout = TimeSpan.FromSeconds(60)); http => http.Timeout = TimeSpan.FromSeconds(60));
// WEB-2: public site publish path (env var PublicSite__RootPath; empty = disabled)
builder.Services.AddOptions<GerbilManagerWebAPI.Cms.PublicSiteOptions>()
.BindConfiguration(GerbilManagerWebAPI.Cms.PublicSiteOptions.SectionName);
// INBOX-0: Gmail inbox. App Password encrypted at rest via Data Protection. // INBOX-0: Gmail inbox. App Password encrypted at rest via Data Protection.
// AR-3: persist the key ring so encrypted passwords survive image redeployments. // AR-3: persist the key ring so encrypted passwords survive image redeployments.
@@ -81,7 +86,23 @@ app.MapScalarApiReference();
if (!app.Environment.IsEnvironment("Testing")) if (!app.Environment.IsEnvironment("Testing"))
{ {
using var scope = app.Services.CreateScope(); using var scope = app.Services.CreateScope();
scope.ServiceProvider.GetRequiredService<ApplicationContext>().Database.Migrate(); var db = scope.ServiceProvider.GetRequiredService<ApplicationContext>();
db.Database.Migrate();
// Startup sweep: derive status for animals that silently crossed the 7-year threshold
// since the last write. No-op if all statuses are already current.
var today = DateOnly.FromDateTime(DateTime.UtcNow);
var candidates = await db.Gerbils
.Where(g => g.Status != GerbilStatus.Deceased && g.Status != GerbilStatus.GivenAway
&& g.DateOfDeath == null && g.ReceiverContactId == null
&& g.DateOfBirth != null
&& g.DateOfBirth < today.AddYears(-GerbilStatusService.MaxAgeYears))
.ToListAsync();
if (candidates.Count > 0)
{
foreach (var g in candidates) g.Status = GerbilStatus.Deceased;
await db.SaveChangesAsync();
}
} }
app.UseCors(LanCorsPolicy); app.UseCors(LanCorsPolicy);

View File

@@ -0,0 +1,53 @@
using GerbilManagerWebAPI.Models;
namespace GerbilManagerWebAPI.Services
{
/// <summary>
/// Central status-derivation logic. Every write path (create/update gerbil, SaleContract
/// Abgabe, import, startup sweep) calls Apply() after setting the other fields so the
/// derived statuses (Deceased, GivenAway) are always consistent.
///
/// Precedence (highest wins):
/// 1) DateOfDeath set → Deceased (explicit, always)
/// 2) Abgabe (ReceiverContactId set) → GivenAway
/// 3) Age > MaxAgeYears without a death date or Abgabe → Deceased (presumed)
/// 4) User-supplied {Breeding, Pet, ForSale}; defaults Breeding if invalid
///
/// Age-based death is time-dependent. The stored column is kept up-to-date by:
/// a) Apply() on every write (catches the animal at write time)
/// b) A startup sweep in Program.cs (catches animals that silently crossed the threshold)
/// Gridify filters on the stored value, so status==Breeding never surfaces >7y animals.
/// </summary>
public static class GerbilStatusService
{
public const int MaxAgeYears = 7;
/// <summary>Derives and sets g.Status using the gerbil's current field values.
/// Must be called AFTER all other fields (DateOfDeath, ReceiverContactId, DateOfBirth)
/// have been applied. today = DateOnly.FromDateTime(DateTime.UtcNow).</summary>
public static void Apply(Gerbil g, DateOnly today)
{
g.Status = Derive(g.Status, g.DateOfBirth, g.DateOfDeath,
isAbgegeben: g.ReceiverContactId is not null, today);
}
/// <summary>Pure derivation — useful for tests and the migration backfill.</summary>
public static GerbilStatus Derive(
GerbilStatus requested,
DateOnly? dateOfBirth,
DateOnly? dateOfDeath,
bool isAbgegeben,
DateOnly today)
{
if (dateOfDeath is not null) return GerbilStatus.Deceased;
if (isAbgegeben) return GerbilStatus.GivenAway;
if (dateOfBirth is not null && IsOlderThan(dateOfBirth.Value, MaxAgeYears, today))
return GerbilStatus.Deceased;
return requested is GerbilStatus.Breeding or GerbilStatus.Pet or GerbilStatus.ForSale
? requested : GerbilStatus.Breeding;
}
private static bool IsOlderThan(DateOnly dob, int years, DateOnly today) =>
today >= dob.AddYears(years);
}
}

View File

@@ -21,6 +21,11 @@ KEYS_PATH=/mnt/JailStorage/DockerVolumes/gerbilmanager/keys
# Backup-Rotation: Anzahl Tage (Standard: 7) # Backup-Rotation: Anzahl Tage (Standard: 7)
BACKUP_KEEP_DAYS=7 BACKUP_KEEP_DAYS=7
# WEB-2: Oeffentliche Webseite (Shared Volume: api schreibt, publicsite-nginx liest)
PUBLICSITE_PATH=/mnt/JailStorage/DockerVolumes/gerbilmanager/publicsite
# Port fuer den publicsite-nginx (Julian's externer nginx leitet darauf weiter)
PUBLICSITE_PORT=8081
# KI-Funktionen (Verkaufstext + Posteingang-Entwurf) # KI-Funktionen (Verkaufstext + Posteingang-Entwurf)
# Beliebiger OpenAI-kompatibler Anbieter — Optionen in docs/ai-provider.md # Beliebiger OpenAI-kompatibler Anbieter — Optionen in docs/ai-provider.md
# Leer lassen = KI deaktiviert (kein Fehler, nur 503 AiKeyMissing) # Leer lassen = KI deaktiviert (kein Fehler, nur 503 AiKeyMissing)

View File

@@ -48,9 +48,12 @@ services:
AI__BaseUrl: "${AI__BaseUrl:-}" AI__BaseUrl: "${AI__BaseUrl:-}"
AI__ApiKey: "${AI__ApiKey:-}" AI__ApiKey: "${AI__ApiKey:-}"
AI__Model: "${AI__Model:-gemini-flash-latest}" AI__Model: "${AI__Model:-gemini-flash-latest}"
# WEB-2: Pfad wo POST /api/publish die oeffentliche Seite hinschreibt
PublicSite__RootPath: /data/publicsite
volumes: volumes:
- photos:/data/photos - photos:/data/photos
- keys:/data/keys - keys:/data/keys
- publicsite:/data/publicsite
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy
@@ -74,6 +77,21 @@ services:
api: api:
condition: service_healthy condition: service_healthy
# --- nginx Public Site (WEB-2) ---
# Serviert NUR die statische oeffentliche Seite (live/ aus dem publicsite-Volume).
# SICHERHEIT: Kein Proxy auf api/frontend — nur statisches HTML nach aussen.
# Julian's externer nginx-Proxy leitet <DOMAIN> auf Port 8081 weiter.
publicsite:
image: nginx:alpine
restart: unless-stopped
ports:
- "${PUBLICSITE_PORT:-8081}:80"
volumes:
- publicsite:/usr/share/nginx/html:ro
- ./nginx/publicsite.conf:/etc/nginx/conf.d/default.conf:ro
depends_on:
- api
# --- Backup-Sidecar (taeglicher pg_dump + Foto-Archiv + Rotation) --- # --- Backup-Sidecar (taeglicher pg_dump + Foto-Archiv + Rotation) ---
backup: backup:
image: postgres:17-alpine image: postgres:17-alpine
@@ -122,3 +140,10 @@ volumes:
type: none type: none
o: bind o: bind
device: "${BACKUPS_PATH:-/mnt/JailStorage/DockerVolumes/gerbilmanager/backups}" device: "${BACKUPS_PATH:-/mnt/JailStorage/DockerVolumes/gerbilmanager/backups}"
# WEB-2: gemeinsames Volume fuer api (rw) und publicsite-nginx (ro).
publicsite:
driver: local
driver_opts:
type: none
o: bind
device: "${PUBLICSITE_PATH:-/mnt/JailStorage/DockerVolumes/gerbilmanager/publicsite}"

View File

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

View File

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

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

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

View File

@@ -0,0 +1,79 @@
/** FARBSCHLAG-GENOTYPE-SYNC: Bidirektionale Sync im Tier-Formular. */
import { de, expect, skipUnlessMock, test } from './fixtures'
const t = de.pages.gerbils
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')
// Vor der Auswahl ist das Gencode-Feld leer.
await expect(genotypeInput).toHaveValue('')
// 'Agouti' wählen → Gencode wird automatisch befüllt.
await farbschlagSelect.selectOption({ label: 'Agouti' })
await expect(genotypeInput).toHaveValue('AA CC DD EE GG PP spsp rere')
})
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')
})
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('')
// 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('')
})
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('')
// 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('')
})

View File

@@ -0,0 +1,45 @@
/** LITTER-JUVENILE-DETAILS + JUVENILE-AVATARS: Jungtier-Felder und Foto-Avatare im Wurf-Detail. */
import { de, expect, skipUnlessMock, test } from './fixtures'
const t = de.pages.litters
test('Jungtier-Liste zeigt Farbschlag, Abgabedatum, Abnehmer, Todesdatum und Todesursache', async ({
page,
}) => {
skipUnlessMock()
await page.goto('/wuerfe/w-kruemel')
await expect(page.getByRole('heading', { name: 'Wurf K' })).toBeVisible()
// Krümel: Active, colorVarietyId cv-agouti → Farbschlag 'Agouti'
const kruemelCard = page.getByRole('link', { name: /Krümel/ })
await expect(kruemelCard).toBeVisible()
await expect(kruemelCard).toContainText(`${t.detail.juvenileFields.colorVariety}: Agouti`)
// Pippa: GivenAway, Gold, goHomeDate 2025-05-01, Abnehmer Familie Huber
const pippaCard = page.getByRole('link', { name: /Pippa/ })
await expect(pippaCard).toBeVisible()
await expect(pippaCard).toContainText(`${t.detail.juvenileFields.colorVariety}: Gold`)
await expect(pippaCard).toContainText(t.detail.juvenileFields.goHomeDate)
await expect(pippaCard).toContainText(`${t.detail.juvenileFields.receiver}: Familie Huber`)
// Benny: Deceased, no colorVarietyId, dateOfDeath + causeOfDeath
const bennyCard = page.getByRole('link', { name: /Benny/ })
await expect(bennyCard).toBeVisible()
await expect(bennyCard).toContainText(t.detail.juvenileFields.dateOfDeath)
await expect(bennyCard).toContainText(`${t.detail.juvenileFields.causeOfDeath}: Altersschwäche`)
})
test('JUVENILE-AVATARS: Pippa zeigt Foto-Avatar, Benny zeigt GerbilIcon', async ({ page }) => {
skipUnlessMock()
await page.goto('/wuerfe/w-kruemel')
await expect(page.getByRole('heading', { name: 'Wurf K' })).toBeVisible()
// Pippa hat profilePhotoUrl → <img.juvenile-photo__avatar> sichtbar
const pippaCard = page.getByRole('link', { name: /Pippa/ })
await expect(pippaCard.locator('.juvenile-photo__avatar')).toBeVisible()
// Benny hat kein Foto → kein img, SVG-Icon (GerbilIcon) sichtbar
const bennyCard = page.getByRole('link', { name: /Benny/ })
await expect(bennyCard.locator('.juvenile-photo__avatar')).not.toBeVisible()
await expect(bennyCard.locator('svg')).toBeVisible()
})

View File

@@ -0,0 +1,46 @@
/** LITTER-MORTALITY: deathsWithin8Weeks im Wurf-Formular und -Detail. */
import { de, expect, skipUnlessMock, test, uniqueName } from './fixtures'
const t = de.pages.litters
test('Wurf-Detail zeigt Frühverluste wenn gesetzt', async ({ page }) => {
skipUnlessMock()
await page.goto('/wuerfe/w-kruemel')
await expect(page.getByRole('heading', { name: 'Wurf K' })).toBeVisible()
// Frühverluste-Zeile im def-list: Label + Wert '1'
const mortalityRow = page.locator('.def-row', {
has: page.locator('dt', { hasText: t.fields.deathsWithin8Weeks }),
})
await expect(mortalityRow).toBeVisible()
await expect(mortalityRow.locator('dd')).toHaveText('1')
})
test('Wurf-Formular speichert Frühverluste und zeigt sie im Detail', async ({ page }) => {
skipUnlessMock()
const name = uniqueName('WurfM')
await page.goto('/wuerfe/neu')
await page.getByLabel(`${t.fields.name} *`).fill(name)
await page.getByLabel(`${t.fields.date} *`).fill('2026-01-10')
// Frühverluste eingeben
await page.getByLabel(t.fields.deathsWithin8Weeks).fill('2')
await expect(page.getByText(t.validation.deathsExceedTotalBorn)).not.toBeVisible()
await page.getByRole('button', { name: t.form.save, exact: true }).click()
await expect(page.getByRole('heading', { name })).toBeVisible()
// Frühverluste-Zeile im Detail
const mortalityRow = page.locator('.def-row', {
has: page.locator('dt', { hasText: t.fields.deathsWithin8Weeks }),
})
await expect(mortalityRow).toBeVisible()
await expect(mortalityRow.locator('dd')).toHaveText('2')
})
test('Frühverluste > Wurfstärke zeigt Warnmeldung', async ({ page }) => {
skipUnlessMock()
await page.goto('/wuerfe/neu')
await page.getByLabel(t.fields.totalBorn).fill('3')
await page.getByLabel(t.fields.deathsWithin8Weeks).fill('5')
await expect(page.getByText(t.validation.deathsExceedTotalBorn)).toBeVisible()
})

View File

@@ -91,7 +91,7 @@ function gerbil(
id, id,
name, name,
gender, gender,
status: 'Active', status: 'Breeding',
dateOfBirth, dateOfBirth,
dateOfDeath: null, dateOfDeath: null,
causeOfDeath: null, causeOfDeath: null,
@@ -108,6 +108,7 @@ function gerbil(
notes: null, notes: null,
// BESTAND-FILTER: default = eigener Bestand; einzelne externe Ahnen unten gesetzt. // BESTAND-FILTER: default = eigener Bestand; einzelne externe Ahnen unten gesetzt.
isResident: true, isResident: true,
profilePhotoUrl: null,
} }
} }
@@ -120,7 +121,7 @@ export function seedDb(): MockDb {
originContactId: 'con-meier', originContactId: 'con-meier',
originBreeder: 'Clan-Kleine-Chaoten', originBreeder: 'Clan-Kleine-Chaoten',
}, },
{ ...gerbil('fridolin', 'Fridolin', 'male', '2023-05-01', 'w-fridolin', 'cv-schwarz', 'aa CC DD EE GG PP spsp rere'), enclosureId: 'enc-gross', originBreeder: 'Zoohandlung Meier' }, { ...gerbil('fridolin', 'Fridolin', 'male', '2023-05-01', 'w-fridolin', 'cv-schwarz', 'aa CC DD EE GG PP spsp rere'), enclosureId: 'enc-gross', originBreeder: 'Zoohandlung Meier', profilePhotoUrl: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==' },
gerbil('luna', 'Luna', 'female', '2023-08-15', 'w-luna', 'cv-gold', 'AA CC DD EE GG pp spsp rere'), gerbil('luna', 'Luna', 'female', '2023-08-15', 'w-luna', 'cv-gold', 'AA CC DD EE GG pp spsp rere'),
gerbil('balu', 'Balu', 'male', '2021-04-20', 'w-balu', 'cv-agouti'), gerbil('balu', 'Balu', 'male', '2021-04-20', 'w-balu', 'cv-agouti'),
gerbil('maja', 'Maja', 'female', '2021-06-11', null, 'cv-schwarz-schecke', 'aa CC DD EE GG PP Spsp rere'), gerbil('maja', 'Maja', 'female', '2021-06-11', null, 'cv-schwarz-schecke', 'aa CC DD EE GG PP Spsp rere'),
@@ -151,12 +152,27 @@ export function seedDb(): MockDb {
enclosureId: 'enc-leer', enclosureId: 'enc-leer',
notes: 'neugieriger Entdecker', notes: 'neugieriger Entdecker',
}, },
// LITTER-JUVENILE-DETAILS: Jungtiere für Wurf K mit Zusatzinfos.
// JUVENILE-AVATARS: Pippa hat profilePhotoUrl (1×1-PNG data-URI) → Avatar-Img sichtbar.
{
...gerbil('pup-abgabe', 'Pippa', 'female', '2025-03-12', 'w-kruemel', 'cv-gold'),
status: 'GivenAway' as const,
goHomeDate: '2025-05-01',
receiverContactId: 'con-huber',
profilePhotoUrl: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==',
},
{
...gerbil('pup-verstorben', 'Benny', 'male', '2025-03-12', 'w-kruemel', null),
status: 'Deceased' as const,
dateOfDeath: '2025-04-15',
causeOfDeath: 'Altersschwäche',
},
// UI-POLISH-1: Import-Stub ohne Namen — testet den '(ohne Namen)'-Platzhalter in Liste + Detail. // UI-POLISH-1: Import-Stub ohne Namen — testet den '(ohne Namen)'-Platzhalter in Liste + Detail.
gerbil('nameless-stub', '', 'male', '2023-01-01', null, null), gerbil('nameless-stub', '', 'male', '2023-01-01', null, null),
] ]
const litters: Litter[] = [ const litters: Litter[] = [
{ id: 'w-kruemel', name: 'Wurf K', date: '2025-03-12', totalBorn: 5, expectedGoHomeDate: '2025-04-16', notes: null, fatherId: 'fridolin', motherId: 'luna' }, { id: 'w-kruemel', name: 'Wurf K', date: '2025-03-12', totalBorn: 5, expectedGoHomeDate: '2025-04-16', notes: null, fatherId: 'fridolin', motherId: 'luna', deathsWithin8Weeks: 1 },
{ id: 'w-fridolin', name: 'Wurf F', date: '2023-05-01', totalBorn: 4, expectedGoHomeDate: null, notes: null, fatherId: 'balu', motherId: 'maja' }, { id: 'w-fridolin', name: 'Wurf F', date: '2023-05-01', totalBorn: 4, expectedGoHomeDate: null, notes: null, fatherId: 'balu', motherId: 'maja' },
{ id: 'w-luna', name: 'Wurf L', date: '2023-08-15', totalBorn: 6, expectedGoHomeDate: null, notes: null, fatherId: 'karlsson', motherId: 'smilla' }, { id: 'w-luna', name: 'Wurf L', date: '2023-08-15', totalBorn: 6, expectedGoHomeDate: null, notes: null, fatherId: 'karlsson', motherId: 'smilla' },
{ id: 'w-balu', name: 'Wurf B', date: '2021-04-20', totalBorn: 3, expectedGoHomeDate: null, notes: null, fatherId: 'anton', motherId: 'greta' }, { id: 'w-balu', name: 'Wurf B', date: '2021-04-20', totalBorn: 3, expectedGoHomeDate: null, notes: null, fatherId: 'anton', motherId: 'greta' },

View File

@@ -110,3 +110,64 @@ test('-Knopf ist sichtbar und lädt weitere Vorfahren nach (STAMMBAUM-EXPAND)
await expandBtn.click({ force: true }) await expandBtn.click({ force: true })
await expect(page.getByRole('link', { name: 'Max' })).toBeVisible({ timeout: 8000 }) await expect(page.getByRole('link', { name: 'Max' })).toBeVisible({ timeout: 8000 })
}) })
test('Würfe-Panel zeigt Würfe des Wurzeltiers + Link öffnet Wurf (STAMMBAUM-LITTERS)', async ({ page }) => {
skipUnlessMock()
// Fridolin ist Vater von Wurf K (5 Junge) — Panel muss erscheinen.
await page.goto('/rennmaeuse/fridolin/stammbaum')
await expect(page.locator('.pedigree-card').first()).toBeVisible()
const panel = page.locator('.stammbaum-litters-panel')
await expect(panel).toBeVisible()
await expect(panel).toContainText(t.littersTitle)
await expect(panel).toContainText('Wurf K')
await expect(panel).toContainText('5')
// Link-Klick → Wurf-Detailseite
const wurfLink = panel.getByRole('link', { name: /Wurf K/ })
await expect(wurfLink).toBeVisible()
await wurfLink.click()
await expect(page).toHaveURL(/\/wuerfe\/w-kruemel/)
})
test('Kein Würfe-Panel wenn Wurzeltier keine Würfe hat (STAMMBAUM-LITTERS)', async ({ page }) => {
skipUnlessMock()
// Krümel hat noch keine Würfe als Elternteil → Panel muss fehlen.
await page.goto('/rennmaeuse/kruemel/stammbaum')
await expect(page.locator('.pedigree-card').first()).toBeVisible()
await expect(page.locator('.stammbaum-litters-panel')).not.toBeVisible()
})
test('Knoten-Karte zeigt volles Geburtsdatum (STAMMBAUM-FULLDATE)', async ({ page }) => {
skipUnlessMock()
// Krümel: dateOfBirth '2025-03-12' → formatDate → '12.03.2025'
await page.goto('/rennmaeuse/kruemel/stammbaum')
await expect(page.locator('.pedigree-card').first()).toBeVisible()
await expect(page.locator('.pedigree-card__year').first()).toContainText('12.03.2025')
})
test('Gencode-Chip zeigt Genotyp statt Farbname (STAMMBAUM-GENCODE)', async ({ page }) => {
skipUnlessMock()
// Fridolin ist Vater von Krümel — genotype 'aa CC DD EE GG PP spsp rere'
await page.goto('/rennmaeuse/kruemel/stammbaum')
await expect(page.locator('.pedigree-card').first()).toBeVisible()
const fridolinCard = page.locator('.pedigree-card').filter({ hasText: 'Fridolin' })
await expect(fridolinCard.locator('.pedigree-chip')).toContainText('aa CC')
})
test('Tier mit Foto zeigt Avatar-Bild, Tier ohne Foto zeigt Rennmaus-Icon (STAMMBAUM-AVATAR)', async ({ page }) => {
skipUnlessMock()
// Fridolin hat profilePhotoUrl (data URI) → <img.pedigree-card__avatar> sichtbar
// Krümel hat profilePhotoUrl=null → kein img, SVG-Icon sichtbar
await page.goto('/rennmaeuse/kruemel/stammbaum')
await expect(page.locator('.pedigree-card').first()).toBeVisible()
// Fridolin ist Vater von Krümel → seine Karte enthält ein <img>
const fridolinCard = page.locator('.pedigree-card').filter({ hasText: 'Fridolin' })
await expect(fridolinCard.locator('img.pedigree-card__avatar')).toBeVisible()
// Krümel (root) hat kein Foto → SVG-Icon im Foto-Slot
const rootCard = page.locator('.pedigree-card.pedigree-card--root')
await expect(rootCard.locator('img.pedigree-card__avatar')).not.toBeAttached()
await expect(rootCard.locator('svg[role="img"]')).toBeVisible()
})

View File

@@ -0,0 +1,58 @@
/** STATUS-MODEL: Zucht/Liebhaber wählbar; Verstorben/Abgegeben abgeleitet (read-only). */
import { de, expect, skipUnlessMock, test, uniqueName } from './fixtures'
const t = de.pages.gerbils
test('Tier-Formular zeigt nur wählbare Statuswerte im Dropdown', async ({ page }) => {
skipUnlessMock()
await page.goto('/rennmaeuse/neu')
const statusField = page.locator('label.field', {
has: page.locator(`span:text-is("${t.fields.status}")`),
})
// Zucht (Breeding) ist vorausgewählt
await expect(statusField.locator('select')).toHaveValue('Breeding')
// Optionen: nur Zucht, Liebhaber, Abzugeben (NICHT Verstorben/Abgegeben)
const opts = statusField.locator('select option')
await expect(opts.filter({ hasText: t.statusLabels.Breeding })).toHaveCount(1)
await expect(opts.filter({ hasText: t.statusLabels.Pet })).toHaveCount(1)
await expect(opts.filter({ hasText: t.statusLabels.ForSale })).toHaveCount(1)
await expect(opts.filter({ hasText: t.statusLabels.Deceased })).toHaveCount(0)
await expect(opts.filter({ hasText: t.statusLabels.GivenAway })).toHaveCount(0)
})
test('Tier-Formular zeigt abgeleiteten Status Verstorben als Text + Hinweis', async ({ page }) => {
skipUnlessMock()
// Willi ist Deceased
await page.goto('/rennmaeuse/willi/bearbeiten')
// Status-Select ist NICHT vorhanden (abgeleitet)
const statusField = page.locator('.field', {
has: page.locator(`span:text-is("${t.fields.status}")`),
})
await expect(statusField).toBeVisible()
await expect(statusField.locator('select')).toHaveCount(0)
await expect(statusField).toContainText(t.statusLabels.Deceased)
await expect(statusField).toContainText(t.form.statusDerivedHint.Deceased)
})
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')
})
test('Neues Tier anlegen mit Status Liebhaber', async ({ page }) => {
skipUnlessMock()
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 })
const statusField = page.locator('label.field', {
has: page.locator(`span:text-is("${t.fields.status}")`),
})
await statusField.locator('select').selectOption({ label: t.statusLabels.Pet })
await page.getByRole('button', { name: t.form.save, exact: true }).click()
await expect(page.getByRole('heading', { name })).toBeVisible()
})

View File

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

View File

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

View File

@@ -10,9 +10,11 @@
export type Gender = 'unknown' | 'male' | 'female' export type Gender = 'unknown' | 'male' | 'female'
export const GENDERS: Gender[] = ['unknown', 'male', 'female'] export const GENDERS: Gender[] = ['unknown', 'male', 'female']
/** Gerbil.Status — C# enum { Active, Deceased, GivenAway, ForSale }. */ /** Gerbil.Status — C# enum { Breeding, Pet, Deceased, GivenAway, ForSale }. STATUS-MODEL: Active→Breeding (gated on Pam feature/status-model). */
export type GerbilStatus = 'Active' | 'Deceased' | 'GivenAway' | 'ForSale' export type GerbilStatus = 'Breeding' | 'Pet' | 'Deceased' | 'GivenAway' | 'ForSale'
export const GERBIL_STATUSES: GerbilStatus[] = ['Active', 'ForSale', 'Deceased', 'GivenAway'] export const GERBIL_STATUSES: GerbilStatus[] = ['Breeding', 'Pet', 'ForSale', 'Deceased', 'GivenAway']
/** Statuses the user can pick freely in the form; Deceased+GivenAway are derived by the backend. */
export const SELECTABLE_STATUSES: GerbilStatus[] = ['Breeding', 'Pet', 'ForSale']
/** ISO date string "YYYY-MM-DD" (maps to C# DateOnly). */ /** ISO date string "YYYY-MM-DD" (maps to C# DateOnly). */
export type DateOnlyString = string export type DateOnlyString = string
@@ -56,6 +58,8 @@ export interface Gerbil {
isResident?: boolean isResident?: boolean
/** FORM-FIELDS-1: true=gehörlos, false=hörend, null=unbekannt. */ /** FORM-FIELDS-1: true=gehörlos, false=hörend, null=unbekannt. */
isDeaf?: boolean | null isDeaf?: boolean | null
/** STAMMBAUM-AVATAR: URL des Profilfotos (erstes Foto nach sortOrder); null = kein Foto. API-relativ oder absolut. */
profilePhotoUrl?: string | null
} }
/** Payload for POST /gerbils. */ /** Payload for POST /gerbils. */
@@ -122,6 +126,8 @@ export interface Litter {
notes?: string | null notes?: string | null
fatherId: string | null fatherId: string | null
motherId: string | null motherId: string | null
/** LITTER-MORTALITY: pups that died within the first 8 weeks. */
deathsWithin8Weeks?: number | null
} }
/** Payload for POST /litters. */ /** Payload for POST /litters. */
@@ -133,6 +139,7 @@ export interface CreateLitter {
notes?: string | null notes?: string | null
fatherId?: string | null fatherId?: string | null
motherId?: string | null motherId?: string | null
deathsWithin8Weeks?: number | null
} }
export type UpdateLitter = Partial<CreateLitter> export type UpdateLitter = Partial<CreateLitter>

View File

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

View File

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

View File

@@ -3,10 +3,10 @@ import { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom'
import { de } from '../strings/de' import { de } from '../strings/de'
import { createGerbil, getGerbil, updateGerbil } from '../api/gerbils' import { createGerbil, getGerbil, updateGerbil } from '../api/gerbils'
import { listColorVarieties, listContacts, listEnclosures, listLitters } from '../api/lookups' import { listColorVarieties, listContacts, listEnclosures, listLitters } from '../api/lookups'
import { GENDERS, GERBIL_STATUSES, type CreateGerbil, type Gender, type GerbilStatus } from '../api/types' import { GENDERS, SELECTABLE_STATUSES, type CreateGerbil, type Gender, type GerbilStatus } from '../api/types'
import { useApi, useMutation } from '../hooks/useApi' import { useApi, useMutation } from '../hooks/useApi'
import { genderLabel, statusLabel } from '../format/labels' import { genderLabel, statusLabel } from '../format/labels'
import { fromDisplayString } from '../genetics' import { fromDisplayString, genotypeToFarbschlag, UNKNOWN_FARBSCHLAG } from '../genetics'
import FarbschlagImage from '../components/FarbschlagImage' import FarbschlagImage from '../components/FarbschlagImage'
import NameSuggestPanel from '../components/NameSuggestPanel' import NameSuggestPanel from '../components/NameSuggestPanel'
import '../components/NameSuggestPanel.css' import '../components/NameSuggestPanel.css'
@@ -34,7 +34,7 @@ interface FormState {
const EMPTY: FormState = { const EMPTY: FormState = {
name: '', name: '',
gender: '', gender: '',
status: 'Active', status: 'Breeding',
dateOfBirth: '', dateOfBirth: '',
dateOfDeath: '', dateOfDeath: '',
causeOfDeath: '', causeOfDeath: '',
@@ -245,16 +245,29 @@ export default function GerbilFormPage() {
{errors.gender && <small className="error-text">{errors.gender}</small>} {errors.gender && <small className="error-text">{errors.gender}</small>}
</label> </label>
<label className="field"> {/* STATUS-MODEL: Deceased/GivenAway are derived by the backend — show as read-only with hint */}
<span>{t.fields.status}</span> {form.status === 'Deceased' || form.status === 'GivenAway' ? (
<select value={form.status} onChange={(e) => set('status', e.target.value as GerbilStatus)}> <div className="field">
{GERBIL_STATUSES.map((s) => ( <span>{t.fields.status}</span>
<option key={s} value={s}> <span className="muted">
{statusLabel(s)} {statusLabel(form.status)}
</option> <small className="muted" style={{ marginLeft: '0.5rem' }}>
))} ({t.form.statusDerivedHint[form.status]})
</select> </small>
</label> </span>
</div>
) : (
<label className="field">
<span>{t.fields.status}</span>
<select value={form.status} onChange={(e) => set('status', e.target.value as GerbilStatus)}>
{SELECTABLE_STATUSES.map((s) => (
<option key={s} value={s}>
{statusLabel(s)}
</option>
))}
</select>
</label>
)}
<label className="field"> <label className="field">
<span>{t.fields.dateOfBirth}</span> <span>{t.fields.dateOfBirth}</span>
@@ -307,7 +320,14 @@ export default function GerbilFormPage() {
<span className="farbschlag-value"> <span className="farbschlag-value">
<select <select
value={form.colorVarietyId} value={form.colorVarietyId}
onChange={(e) => set('colorVarietyId', e.target.value)} 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> <option value="">{t.form.none}</option>
{(colorVarieties.data ?? []).map((cv) => ( {(colorVarieties.data ?? []).map((cv) => (
@@ -387,7 +407,23 @@ export default function GerbilFormPage() {
className="input" className="input"
value={form.genotype} value={form.genotype}
placeholder="Aa CC Dd EE GG Pp Spsp rere" placeholder="Aa CC Dd EE GG Pp Spsp rere"
onChange={(e) => set('genotype', e.target.value)} 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)} aria-invalid={Boolean(errors.genotype)}
/> />
<small className={errors.genotype ? 'error-text' : 'muted'}> <small className={errors.genotype ? 'error-text' : 'muted'}>

View File

@@ -29,7 +29,7 @@ const SORT_ORDER_BY: Record<SortKey, string> = {
export default function GerbilsPage() { export default function GerbilsPage() {
const t = de.pages.gerbils const t = de.pages.gerbils
const [search, setSearch] = useState('') const [search, setSearch] = useState('')
const [status, setStatus] = useState<GerbilStatus | ''>('Active') const [status, setStatus] = useState<GerbilStatus | ''>('Breeding')
const [gender, setGender] = useState<Gender | ''>('') const [gender, setGender] = useState<Gender | ''>('')
const [colorVarietyId, setColorVarietyId] = useState('') const [colorVarietyId, setColorVarietyId] = useState('')
const [originBreeder, setOriginBreeder] = useState('') const [originBreeder, setOriginBreeder] = useState('')
@@ -64,7 +64,7 @@ export default function GerbilsPage() {
const resetFilters = () => { const resetFilters = () => {
setSearch('') setSearch('')
setStatus('Active') setStatus('Breeding')
setGender('') setGender('')
setColorVarietyId('') setColorVarietyId('')
setOriginBreeder('') setOriginBreeder('')
@@ -85,7 +85,7 @@ export default function GerbilsPage() {
// UX-MOBILE-1: count non-default filter values for the badge. // UX-MOBILE-1: count non-default filter values for the badge.
const activeFilterCount = const activeFilterCount =
(status !== 'Active' ? 1 : 0) + (status !== 'Breeding' ? 1 : 0) +
(gender !== '' ? 1 : 0) + (gender !== '' ? 1 : 0) +
(colorVarietyId !== '' ? 1 : 0) + (colorVarietyId !== '' ? 1 : 0) +
(originBreeder !== '' ? 1 : 0) + (originBreeder !== '' ? 1 : 0) +

View File

@@ -21,12 +21,14 @@ import { Link, useNavigate, useParams } from 'react-router-dom'
import Tree from 'react-d3-tree' import Tree from 'react-d3-tree'
import type { CustomNodeElementProps, Point, RawNodeDatum } from 'react-d3-tree' import type { CustomNodeElementProps, Point, RawNodeDatum } from 'react-d3-tree'
import { de } from '../strings/de' import { de } from '../strings/de'
import { ApiError } from '../api/client' import { API_BASE_URL, ApiError } from '../api/client'
import { listLitters } from '../api/litters'
import { listColorVarieties } from '../api/lookups' import { listColorVarieties } from '../api/lookups'
import { getInbreedingCoefficient } from '../api/pedigree' import { getInbreedingCoefficient } from '../api/pedigree'
import type { Gender, Gerbil } from '../api/types' import type { Gender, Gerbil, Litter } from '../api/types'
import { useApi } from '../hooks/useApi' import { useApi } from '../hooks/useApi'
import { formatDate, genderLabel } from '../format/labels' import { formatDate, genderLabel } from '../format/labels'
import GerbilIcon from '../components/GerbilIcon'
import { UNKNOWN_FARBSCHLAG, fromDisplayString, genotypeToFarbschlag, toDisplayString } from '../genetics' import { UNKNOWN_FARBSCHLAG, fromDisplayString, genotypeToFarbschlag, toDisplayString } from '../genetics'
import { import {
DEFAULT_GENERATIONS, DEFAULT_GENERATIONS,
@@ -156,6 +158,13 @@ export default function StammbaumPage() {
? `${(inbreeding.data * 100).toLocaleString('de-DE', { maximumFractionDigits: 1 })} %` ? `${(inbreeding.data * 100).toLocaleString('de-DE', { maximumFractionDigits: 1 })} %`
: t.inbreeding.unavailable : t.inbreeding.unavailable
/* ── Würfe des Wurzeltiers (STAMMBAUM-LITTERS): aktualisiert bei Umwurzeln ── */
const rootLitters = useApi(
() => listLitters({ filter: `fatherId=${id}|motherId=${id}`, orderBy: 'date desc', pageSize: 50 }),
[id],
)
const rootLitterItems = rootLitters.data?.items ?? []
/* ── react-d3-tree-Daten ── */ /* ── react-d3-tree-Daten ── */
const nodesByPath = useMemo(() => (root ? collectNodes(root) : null), [root]) const nodesByPath = useMemo(() => (root ? collectNodes(root) : null), [root])
const datum = useMemo(() => (root ? toRawNodeDatum(root, t.unknown) : null), [root, t]) const datum = useMemo(() => (root ? toRawNodeDatum(root, t.unknown) : null), [root, t])
@@ -315,25 +324,30 @@ export default function StammbaumPage() {
</button> </button>
</div> </div>
<div className="stammbaum-canvas" ref={canvasRef}> <div className="stammbaum-layout">
{view && ( {rootLitterItems.length > 0 && (
<Tree <LittersPanel litters={rootLitterItems} t={t} />
key={id}
data={datum}
renderCustomNodeElement={renderNode}
orientation="horizontal"
pathFunc="step"
translate={view.translate}
zoom={view.zoom}
scaleExtent={{ min: ZOOM_MIN, max: ZOOM_MAX }}
zoomable
draggable
collapsible={false}
nodeSize={{ x: NODE_X, y: NODE_Y }}
separation={{ siblings: 1, nonSiblings: 1 }}
onUpdate={handleTreeUpdate}
/>
)} )}
<div className="stammbaum-canvas" ref={canvasRef}>
{view && (
<Tree
key={id}
data={datum}
renderCustomNodeElement={renderNode}
orientation="horizontal"
pathFunc="step"
translate={view.translate}
zoom={view.zoom}
scaleExtent={{ min: ZOOM_MIN, max: ZOOM_MAX }}
zoomable
draggable
collapsible={false}
nodeSize={{ x: NODE_X, y: NODE_Y }}
separation={{ siblings: 1, nonSiblings: 1 }}
onUpdate={handleTreeUpdate}
/>
)}
</div>
</div> </div>
<ul className="stammbaum-hints"> <ul className="stammbaum-hints">
<li>{t.tapHint}</li> <li>{t.tapHint}</li>
@@ -366,7 +380,14 @@ function PedigreeCard({
const t = de.pages.stammbaum const t = de.pages.stammbaum
const g = node.gerbil const g = node.gerbil
const chip = farbschlag ? chipColorFor(farbschlag) : null const chip = farbschlag ? chipColorFor(farbschlag) : null
const year = g.dateOfBirth ? g.dateOfBirth.slice(0, 4) : null const dob = g.dateOfBirth ? formatDate(g.dateOfBirth) : null
const [failedUrl, setFailedUrl] = useState<string | null>(null)
const rawUrl = g.profilePhotoUrl
? /^(https?:|data:|\/\/)/.test(g.profilePhotoUrl)
? g.profilePhotoUrl
: `${API_BASE_URL}${g.profilePhotoUrl}`
: null
const photoUrl = rawUrl !== failedUrl ? rawUrl : null
return ( return (
<div <div
className={isRoot ? 'pedigree-card pedigree-card--root' : 'pedigree-card'} className={isRoot ? 'pedigree-card pedigree-card--root' : 'pedigree-card'}
@@ -374,9 +395,17 @@ function PedigreeCard({
role={isRoot ? undefined : 'button'} role={isRoot ? undefined : 'button'}
title={isRoot ? undefined : t.tapHint} title={isRoot ? undefined : t.tapHint}
> >
{/* Foto-Platzhalter — echte Fotos kommen mit FEAT-6. */} <div className="pedigree-card__photo" aria-hidden={!photoUrl || undefined}>
<div className="pedigree-card__photo" aria-hidden="true"> {photoUrl ? (
🐭 <img
src={photoUrl}
alt={g.name || ''}
className="pedigree-card__avatar"
onError={() => setFailedUrl(rawUrl)}
/>
) : (
<GerbilIcon size="1.6rem" aria-hidden="true" />
)}
</div> </div>
<div className="pedigree-card__body"> <div className="pedigree-card__body">
<div className="pedigree-card__name"> <div className="pedigree-card__name">
@@ -393,11 +422,12 @@ function PedigreeCard({
<span <span
className="pedigree-chip" className="pedigree-chip"
style={chip ? { background: chip.bg, color: chip.fg } : undefined} style={chip ? { background: chip.bg, color: chip.fg } : undefined}
title={farbschlag}
> >
{farbschlag} {g.genotype || farbschlag}
</span> </span>
)} )}
{year && <span className="pedigree-card__year">* {year}</span>} {dob && <span className="pedigree-card__year">* {dob}</span>}
</div> </div>
{node.expandable && ( {node.expandable && (
<button <button
@@ -417,6 +447,36 @@ function PedigreeCard({
) )
} }
/* ── Würfe-Panel (STAMMBAUM-LITTERS) ─────────────────────────────── */
function LittersPanel({
litters,
t,
}: {
litters: Litter[]
t: { littersTitle: string; littersJunge: string }
}) {
return (
<aside className="stammbaum-litters-panel" aria-label={t.littersTitle}>
<div className="stammbaum-litters-panel__title">{t.littersTitle}</div>
<ul className="stammbaum-litters-panel__list">
{litters.map((l) => (
<li key={l.id}>
<Link to={`/wuerfe/${l.id}`} className="stammbaum-litters-panel__link">
<span className="stammbaum-litters-panel__name">{l.name}</span>
{l.totalBorn != null && (
<span className="stammbaum-litters-panel__born">
{l.totalBorn} {t.littersJunge}
</span>
)}
</Link>
</li>
))}
</ul>
</aside>
)
}
function SexIcon({ gender }: { gender: Gender }) { function SexIcon({ gender }: { gender: Gender }) {
const symbol = gender === 'male' ? '♂' : gender === 'female' ? '♀' : '?' const symbol = gender === 'male' ? '♂' : gender === 'female' ? '♀' : '?'
return ( return (

View File

@@ -1,14 +1,62 @@
import { useMemo } from 'react' import { useCallback, useMemo, useState } from 'react'
import { Link, useParams } from 'react-router-dom' import { Link, useParams } from 'react-router-dom'
import { de } from '../strings/de' import { de } from '../strings/de'
import { getLitter } from '../api/litters' import { getLitter } from '../api/litters'
import { getGerbil, listGerbils } from '../api/gerbils' import { getGerbil, listGerbils } from '../api/gerbils'
import { listColorVarieties, listContacts } from '../api/lookups'
import { API_BASE_URL } from '../api/client'
import { condition } from '../api/gridify' import { condition } from '../api/gridify'
import { type Gerbil } from '../api/types'
import { useApi } from '../hooks/useApi' import { useApi } from '../hooks/useApi'
import { formatDate } from '../format/labels' import { formatDate, genderLabel } from '../format/labels'
import { isValidGenotype } from '../format/genotypeText' import { isValidGenotype } from '../format/genotypeText'
import { breed, fromDisplayString, type BreedingResult } from '../genetics' import { breed, fromDisplayString, genotypeToFarbschlag, UNKNOWN_FARBSCHLAG, type BreedingResult } from '../genetics'
import BreedingResultView from '../components/BreedingResultView' import BreedingResultView from '../components/BreedingResultView'
import GerbilIcon from '../components/GerbilIcon'
import './wuerfe.css'
type JuvenileFields = (typeof de.pages.litters.detail.juvenileFields)
function JuvenileCard({ g, farbe, receiver, tJf }: {
g: Gerbil
farbe: string | null
receiver: string | null
tJf: JuvenileFields
}) {
const [failedUrl, setFailedUrl] = useState<string | null>(null)
const rawUrl = g.profilePhotoUrl
? /^(https?:|data:|\/\/)/.test(g.profilePhotoUrl)
? g.profilePhotoUrl
: `${API_BASE_URL}${g.profilePhotoUrl}`
: null
const photoUrl = rawUrl !== failedUrl ? rawUrl : null
return (
<Link to={`/rennmaeuse/${g.id}`} className="gerbil-card gerbil-card--with-photo">
<div className="juvenile-photo">
{photoUrl ? (
<img
src={photoUrl}
alt={g.name || ''}
className="juvenile-photo__avatar"
onError={() => setFailedUrl(rawUrl)}
/>
) : (
<GerbilIcon size="1.4rem" aria-hidden="true" />
)}
</div>
<div className="juvenile-body">
<span className="gerbil-card__name">{g.name}</span>
<span className="gerbil-card__meta">{genderLabel(g.gender)}</span>
{farbe && <span className="gerbil-card__meta">{tJf.colorVariety}: {farbe}</span>}
{g.goHomeDate && <span className="gerbil-card__meta">{tJf.goHomeDate}: {formatDate(g.goHomeDate)}</span>}
{receiver && <span className="gerbil-card__meta">{tJf.receiver}: {receiver}</span>}
{g.dateOfDeath && <span className="gerbil-card__meta">{tJf.dateOfDeath}: {formatDate(g.dateOfDeath)}</span>}
{g.causeOfDeath && <span className="gerbil-card__meta">{tJf.causeOfDeath}: {g.causeOfDeath}</span>}
</div>
</Link>
)
}
export default function WurfDetailPage() { export default function WurfDetailPage() {
const t = de.pages.litters const t = de.pages.litters
@@ -43,6 +91,38 @@ export default function WurfDetailPage() {
return breed(fromDisplayString(fg), fromDisplayString(mg)) return breed(fromDisplayString(fg), fromDisplayString(mg))
}, [father.data, mother.data]) }, [father.data, mother.data])
// Farbschlag + Abnehmer lookups for the juvenile list.
const colorVarieties = useApi(() => listColorVarieties(), [])
const contacts = useApi(() => listContacts(), [])
const colorNameById = useMemo(
() => new Map((colorVarieties.data ?? []).map((c) => [c.id, c.name])),
[colorVarieties.data],
)
const contactNameById = useMemo(
() => new Map((contacts.data ?? []).map((c) => [c.id, c.name])),
[contacts.data],
)
const farbschlagOf = useCallback(
(g: { colorVarietyId: string | null; genotype: string | null }): string | null => {
if (g.colorVarietyId) {
const name = colorNameById.get(g.colorVarietyId)
if (name) return name
}
if (g.genotype?.trim()) {
try {
const name = genotypeToFarbschlag(fromDisplayString(g.genotype))
return name === UNKNOWN_FARBSCHLAG ? null : name
} catch {
return null
}
}
return null
},
[colorNameById],
)
if (litter.loading) return <p className="muted">{de.common.loading}</p> if (litter.loading) return <p className="muted">{de.common.loading}</p>
if (litter.error || !litter.data) { if (litter.error || !litter.data) {
return ( return (
@@ -92,6 +172,12 @@ export default function WurfDetailPage() {
{l.totalBorn != null ? ` / ${l.totalBorn}` : ''} {l.totalBorn != null ? ` / ${l.totalBorn}` : ''}
</dd> </dd>
</div> </div>
{l.deathsWithin8Weeks != null && (
<div className="def-row">
<dt>{t.fields.deathsWithin8Weeks}</dt>
<dd>{l.deathsWithin8Weeks}</dd>
</div>
)}
<div className="def-row"> <div className="def-row">
<dt>{t.fields.expectedGoHomeDate}</dt> <dt>{t.fields.expectedGoHomeDate}</dt>
<dd>{formatDate(l.expectedGoHomeDate)}</dd> <dd>{formatDate(l.expectedGoHomeDate)}</dd>
@@ -117,14 +203,16 @@ export default function WurfDetailPage() {
{!juveniles.loading && registered === 0 && <p className="muted">{t.detail.noJuveniles}</p>} {!juveniles.loading && registered === 0 && <p className="muted">{t.detail.noJuveniles}</p>}
{registered > 0 && ( {registered > 0 && (
<ul className="card-list"> <ul className="card-list">
{(juveniles.data?.items ?? []).map((g) => ( {(juveniles.data?.items ?? []).map((g) => {
<li key={g.id}> const farbe = farbschlagOf(g)
<Link to={`/rennmaeuse/${g.id}`} className="gerbil-card"> const receiver = g.receiverContactId ? contactNameById.get(g.receiverContactId) ?? null : null
<span className="gerbil-card__name">{g.name}</span> const tJf = t.detail.juvenileFields
<span className="gerbil-card__meta">{de.pages.gerbils.genderLabels[g.gender]}</span> return (
</Link> <li key={g.id}>
</li> <JuvenileCard g={g} farbe={farbe} receiver={receiver} tJf={tJf} />
))} </li>
)
})}
</ul> </ul>
)} )}

View File

@@ -16,6 +16,7 @@ interface FormState {
motherId: string motherId: string
motherName: string motherName: string
totalBorn: string totalBorn: string
deathsWithin8Weeks: string
expectedGoHomeDate: string expectedGoHomeDate: string
notes: string notes: string
} }
@@ -28,6 +29,7 @@ const EMPTY: FormState = {
motherId: '', motherId: '',
motherName: '', motherName: '',
totalBorn: '', totalBorn: '',
deathsWithin8Weeks: '',
expectedGoHomeDate: '', expectedGoHomeDate: '',
notes: '', notes: '',
} }
@@ -75,6 +77,7 @@ export default function WurfFormPage() {
motherId: l.motherId ?? '', motherId: l.motherId ?? '',
motherName: l.motherId ? (nameById.get(l.motherId) || de.pages.gerbils.nameless) : '', motherName: l.motherId ? (nameById.get(l.motherId) || de.pages.gerbils.nameless) : '',
totalBorn: l.totalBorn != null ? String(l.totalBorn) : '', totalBorn: l.totalBorn != null ? String(l.totalBorn) : '',
deathsWithin8Weeks: l.deathsWithin8Weeks != null ? String(l.deathsWithin8Weeks) : '',
expectedGoHomeDate: l.expectedGoHomeDate ?? '', expectedGoHomeDate: l.expectedGoHomeDate ?? '',
notes: l.notes ?? '', notes: l.notes ?? '',
}) })
@@ -109,12 +112,14 @@ export default function WurfFormPage() {
e.preventDefault() e.preventDefault()
if (!validate()) return if (!validate()) return
const totalBornNum = form.totalBorn.trim() === '' ? null : Number(form.totalBorn) const totalBornNum = form.totalBorn.trim() === '' ? null : Number(form.totalBorn)
const deathsNum = form.deathsWithin8Weeks.trim() === '' ? null : Number(form.deathsWithin8Weeks)
const body: CreateLitter = { const body: CreateLitter = {
name: form.name.trim(), name: form.name.trim(),
date: form.date, date: form.date,
fatherId: nn(form.fatherId), fatherId: nn(form.fatherId),
motherId: nn(form.motherId), motherId: nn(form.motherId),
totalBorn: totalBornNum != null && Number.isFinite(totalBornNum) ? totalBornNum : null, totalBorn: totalBornNum != null && Number.isFinite(totalBornNum) ? totalBornNum : null,
deathsWithin8Weeks: deathsNum != null && Number.isFinite(deathsNum) ? deathsNum : null,
expectedGoHomeDate: nn(form.expectedGoHomeDate), expectedGoHomeDate: nn(form.expectedGoHomeDate),
notes: nn(form.notes), notes: nn(form.notes),
} }
@@ -215,6 +220,26 @@ export default function WurfFormPage() {
/> />
</label> </label>
<label className="field">
<span>{t.fields.deathsWithin8Weeks}</span>
<input
type="number"
min="0"
className="input"
value={form.deathsWithin8Weeks}
onChange={(e) => set('deathsWithin8Weeks', e.target.value)}
/>
{(() => {
const d = Number(form.deathsWithin8Weeks)
const tb = Number(form.totalBorn)
return form.deathsWithin8Weeks.trim() !== '' &&
form.totalBorn.trim() !== '' &&
d > tb ? (
<small className="error-text">{t.validation.deathsExceedTotalBorn}</small>
) : null
})()}
</label>
<label className="field"> <label className="field">
<span>{t.fields.expectedGoHomeDate}</span> <span>{t.fields.expectedGoHomeDate}</span>
<input <input

View File

@@ -45,9 +45,132 @@
color: var(--color-text-muted); color: var(--color-text-muted);
} }
/* ── Würfe-Panel + Layout (STAMMBAUM-LITTERS) ────────────────── */
.stammbaum-layout {
display: flex;
align-items: stretch;
gap: 0;
}
/* Desktop: Würfe-Panel links vom Baum. */
.stammbaum-litters-panel {
flex: none;
width: 148px;
display: flex;
flex-direction: column;
justify-content: center;
gap: 0.35rem;
padding: 0.5rem 0.75rem 0.5rem 0;
border-right: 1px solid var(--color-border);
margin-right: 0;
}
.stammbaum-litters-panel__title {
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--color-text-muted);
}
.stammbaum-litters-panel__list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.3rem;
overflow-y: auto;
max-height: calc(clamp(18rem, 62dvh, 46rem) - 3rem);
}
.stammbaum-litters-panel__link {
display: flex;
flex-direction: column;
gap: 0.1rem;
padding: 0.3rem 0.45rem;
border-radius: 0.4rem;
text-decoration: none;
color: inherit;
background: var(--color-surface);
border: 1px solid var(--color-border);
font-size: 0.8rem;
font-family: system-ui, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
}
.stammbaum-litters-panel__link:hover {
background: var(--color-accent-soft);
border-color: var(--color-accent);
color: var(--color-accent);
}
.stammbaum-litters-panel__name {
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.stammbaum-litters-panel__born {
color: var(--color-text-muted);
font-size: 0.72rem;
}
/* Mobil (≤520px): Panel als kompakter horizontaler Streifen ÜBER dem Baum. */
@media (max-width: 520px) {
.stammbaum-layout {
flex-direction: column;
}
.stammbaum-litters-panel {
width: auto;
flex-direction: row;
align-items: center;
justify-content: flex-start;
gap: 0.5rem;
padding: 0.4rem 0.5rem;
border-right: none;
border-bottom: 1px solid var(--color-border);
overflow-x: auto;
}
.stammbaum-litters-panel__title {
flex: none;
white-space: nowrap;
}
.stammbaum-litters-panel__list {
flex-direction: row;
flex-wrap: nowrap;
max-height: none;
overflow-x: auto;
overflow-y: hidden;
gap: 0.4rem;
}
.stammbaum-litters-panel__link {
flex-direction: row;
align-items: center;
gap: 0.35rem;
white-space: nowrap;
}
.stammbaum-litters-panel__born {
color: var(--color-text-muted);
}
/* Canvas behält explizite Höhe in column-Richtung. */
.stammbaum-canvas {
flex: 0 0 auto;
}
}
/* ── Zeichenfläche ────────────────────────────────────────────── */ /* ── Zeichenfläche ────────────────────────────────────────────── */
.stammbaum-canvas { .stammbaum-canvas {
flex: 1 1 auto;
min-width: 0;
height: clamp(18rem, 62dvh, 46rem); height: clamp(18rem, 62dvh, 46rem);
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
border-radius: 0.6rem; border-radius: 0.6rem;
@@ -113,6 +236,7 @@
width: 42px; width: 42px;
height: 42px; height: 42px;
border-radius: 50%; border-radius: 50%;
overflow: hidden;
background: var(--color-accent-soft); background: var(--color-accent-soft);
display: flex; display: flex;
align-items: center; align-items: center;
@@ -120,6 +244,13 @@
font-size: 1.4rem; font-size: 1.4rem;
} }
.pedigree-card__avatar {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.pedigree-card__body { .pedigree-card__body {
min-width: 0; min-width: 0;
display: flex; display: flex;
@@ -162,14 +293,15 @@
.pedigree-chip { .pedigree-chip {
align-self: flex-start; align-self: flex-start;
max-width: 100%; max-width: 100%;
font-size: 0.68rem; font-size: 0.62rem;
padding: 0.1rem 0.45rem; padding: 0.1rem 0.45rem;
border-radius: 1rem; border-radius: 1rem;
background: var(--color-accent-soft); background: var(--color-accent-soft);
color: var(--color-accent); color: var(--color-accent);
white-space: nowrap; white-space: normal;
word-break: break-word;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; line-height: 1.3;
} }
.pedigree-card__year { .pedigree-card__year {

View File

@@ -0,0 +1,34 @@
/* JUVENILE-AVATARS: photo avatar to the left of each juvenile card. */
.gerbil-card--with-photo {
display: flex;
align-items: flex-start;
gap: 0.75rem;
}
.juvenile-body {
min-width: 0;
flex: 1;
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.juvenile-photo {
flex: none;
width: 36px;
height: 36px;
border-radius: 50%;
overflow: hidden;
background: var(--color-accent-soft);
display: flex;
align-items: center;
justify-content: center;
align-self: center;
}
.juvenile-photo__avatar {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}

View File

@@ -22,7 +22,7 @@ function makeGerbil(id: string, name: string, litterId: string | null = null): G
id, id,
name, name,
gender: 'unknown', gender: 'unknown',
status: 'Active', status: 'Breeding',
dateOfBirth: null, dateOfBirth: null,
dateOfDeath: null, dateOfDeath: null,
causeOfDeath: null, causeOfDeath: null,

View File

@@ -27,7 +27,7 @@ function makeGerbil(over: Partial<Gerbil> & { id: string }): Gerbil {
return { return {
name: over.id, name: over.id,
gender: 'unknown', gender: 'unknown',
status: 'Active' as GerbilStatus, status: 'Breeding' as GerbilStatus,
dateOfBirth: null, dateOfBirth: null,
dateOfDeath: null, dateOfDeath: null,
causeOfDeath: null, causeOfDeath: null,

View File

@@ -83,7 +83,7 @@ export function farbschlagDistribution(
): NameCount[] { ): NameCount[] {
const byName = new Map<string | null, number>() const byName = new Map<string | null, number>()
for (const g of gerbils) { for (const g of gerbils) {
if (g.status !== 'Active') continue if (g.status !== 'Breeding' && g.status !== 'Pet') continue
const name = resolveName(g) const name = resolveName(g)
byName.set(name, (byName.get(name) ?? 0) + 1) byName.set(name, (byName.get(name) ?? 0) + 1)
} }

View File

@@ -124,6 +124,11 @@ export const de = {
isDeafUnknown: 'Unbekannt', isDeafUnknown: 'Unbekannt',
isDeafYes: 'Ja', isDeafYes: 'Ja',
isDeafNo: 'Nein', isDeafNo: 'Nein',
// STATUS-MODEL: Hinweis für abgeleitete Statuswerte
statusDerivedHint: {
Deceased: 'Wird automatisch gesetzt wenn Todesdatum erfasst.',
GivenAway: 'Wird automatisch gesetzt nach Abgabe.',
},
save: 'Speichern', save: 'Speichern',
cancel: 'Abbrechen', cancel: 'Abbrechen',
saving: 'Speichern …', saving: 'Speichern …',
@@ -141,8 +146,10 @@ export const de = {
male: 'Männlich', male: 'Männlich',
female: 'Weiblich', female: 'Weiblich',
}, },
// STATUS-MODEL: Breeding/Pet ersetzen Active; Deceased+GivenAway = abgeleitet (Backend)
statusLabels: { statusLabels: {
Active: 'Aktiv', Breeding: 'Zucht',
Pet: 'Liebhaber',
Deceased: 'Verstorben', Deceased: 'Verstorben',
GivenAway: 'Abgegeben', GivenAway: 'Abgegeben',
ForSale: 'Zur Abgabe', ForSale: 'Zur Abgabe',
@@ -172,6 +179,7 @@ export const de = {
father: 'Vater', father: 'Vater',
mother: 'Mutter', mother: 'Mutter',
totalBorn: 'Wurfstärke', totalBorn: 'Wurfstärke',
deathsWithin8Weeks: 'In den ersten 8 Wochen verstorben',
expectedGoHomeDate: 'Voraussichtliches Abgabedatum', expectedGoHomeDate: 'Voraussichtliches Abgabedatum',
notes: 'Notizen', notes: 'Notizen',
}, },
@@ -189,6 +197,14 @@ export const de = {
edit: 'Bearbeiten', edit: 'Bearbeiten',
back: 'Zurück zur Liste', back: 'Zurück zur Liste',
notFound: 'Dieser Wurf wurde nicht gefunden.', notFound: 'Dieser Wurf wurde nicht gefunden.',
// LITTER-JUVENILE-DETAILS: pro-Jungtier-Felder
juvenileFields: {
colorVariety: 'Farbschlag',
goHomeDate: 'Abgegeben',
receiver: 'Abnehmer',
dateOfDeath: 'Verstorben',
causeOfDeath: 'Todesursache',
},
}, },
// Formular // Formular
form: { form: {
@@ -204,6 +220,7 @@ export const de = {
nameRequired: 'Bitte eine Bezeichnung eingeben.', nameRequired: 'Bitte eine Bezeichnung eingeben.',
dateRequired: 'Bitte ein Wurfdatum angeben.', dateRequired: 'Bitte ein Wurfdatum angeben.',
invalidParentGender: 'Der Vater muss männlich und die Mutter weiblich sein.', invalidParentGender: 'Der Vater muss männlich und die Mutter weiblich sein.',
deathsExceedTotalBorn: 'Die Anzahl der Frühverluste darf die Wurfstärke nicht überschreiten.',
}, },
// Zuchtpaar-Übersicht // Zuchtpaar-Übersicht
pairs: { pairs: {
@@ -358,6 +375,9 @@ export const de = {
/** Mini-Legende unter dem Baum (STAMMBAUM-EXPAND). */ /** Mini-Legende unter dem Baum (STAMMBAUM-EXPAND). */
hintName: 'Namenslink: Tierakte öffnen', hintName: 'Namenslink: Tierakte öffnen',
hintExpand: ': weitere Vorfahren nachladen', hintExpand: ': weitere Vorfahren nachladen',
/** Würfe-Panel links (STAMMBAUM-LITTERS). */
littersTitle: 'Würfe',
littersJunge: 'Junge',
zoomIn: 'Vergrößern', zoomIn: 'Vergrößern',
zoomOut: 'Verkleinern', zoomOut: 'Verkleinern',
zoomFit: 'Ansicht einpassen', zoomFit: 'Ansicht einpassen',

View File

@@ -162,6 +162,42 @@
"decision": "Sterbedatum = 18.12.2020 (die 01.10.2020-Variante war falsch); Gencode war einig, taub-Flag bleibt via 'Vorhandensein gewinnt'", "decision": "Sterbedatum = 18.12.2020 (die 01.10.2020-Variante war falsch); Gencode war einig, taub-Flag bleibt via 'Vorhandensein gewinnt'",
"dateOfDeath": "18.12.2020", "dateOfDeath": "18.12.2020",
"source": "Julian/Züchterin 2026-06-07 — HUMANQUESTION D7" "source": "Julian/Züchterin 2026-06-07 — HUMANQUESTION D7"
},
{
"name": "Max von Privat",
"dob": "01.02.2013",
"decision": "Genotyp von der Züchterin; Todesjahr 2014 (Jahr-only — Konvention mit Julian zu klären, deathYear NICHT als dateOfDeath gesetzt)",
"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)"
},
{
"name": "Isa of Golden Lights",
"dob": "24.12.2014",
"decision": "Sterbedatum von der Züchterin (DOB-key war im extract teils leer; diese Zeile matcht die konfliktbehaftete Zeile mit dob=24.12.2014)",
"dateOfDeath": "21.07.2018",
"source": "Julian/Züchterin 2026-06-07 — HUMANQUESTION D7"
},
{
"name": "Jack II von den Kleinen Chaoten",
"dob": "14.02.2016",
"decision": "Sterbedatum von der Züchterin",
"dateOfDeath": "06.10.2019",
"source": "Julian/Züchterin 2026-06-07 — HUMANQUESTION D7"
},
{
"name": "Milon von den Kleinen Chaoten",
"dob": "27.11.2014",
"decision": "A-Locus = Aa (Quellen: Aa // aa — einziger strittiger Locus, Züchterin löst auf Aa); alle anderen Loci waren einig",
"genotype": "Aa Cc[chm] D- ee[f] gg Pp spsp",
"source": "Julian/Züchterin 2026-06-07 — HUMANQUESTION D7"
},
{
"name": "Sunny von PZ Karl",
"dob": "10.04.2014",
"decision": "Sterbedatum von der Züchterin; bestätigt = 'von PZ Karl' (nicht 'Sunny Sky of Fiomi')",
"dateOfDeath": "30.04.2018",
"source": "Julian/Züchterin 2026-06-07 — HUMANQUESTION D7 (Nachtrag)"
} }
] ]
} }