Compare commits
4 Commits
feature/we
...
feature/ux
| Author | SHA1 | Date | |
|---|---|---|---|
| 6eb82da90c | |||
| 0a1e63bec6 | |||
| 5b30407257 | |||
| 13eb17b453 |
@@ -11,21 +11,17 @@ public class CmsTests : IClassFixture<ApiFactory>
|
|||||||
public CmsTests(ApiFactory factory) => _client = factory.CreateClient();
|
public CmsTests(ApiFactory factory) => _client = factory.CreateClient();
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Snapshot_has_site_nav_and_seeded_pages()
|
public async Task Snapshot_has_site_nav_and_six_seeded_pages()
|
||||||
{
|
{
|
||||||
var doc = JsonDocument.Parse(await _client.GetStringAsync("/api/site-snapshot"));
|
var doc = JsonDocument.Parse(await _client.GetStringAsync("/api/site-snapshot"));
|
||||||
var root = doc.RootElement;
|
var root = doc.RootElement;
|
||||||
|
|
||||||
Assert.Equal("de", root.GetProperty("site").GetProperty("defaultLocale").GetString());
|
Assert.Equal("de", root.GetProperty("site").GetProperty("defaultLocale").GetString());
|
||||||
var nav = root.GetProperty("site").GetProperty("navOrder").EnumerateArray().Select(x => x.GetString()).ToList();
|
var nav = root.GetProperty("site").GetProperty("navOrder").EnumerateArray().Select(x => x.GetString()).ToList();
|
||||||
// main nav = 6 core pages (Impressum/Datenschutz are footer-only, not in navOrder)
|
|
||||||
Assert.Equal(new[] { "start", "ueber-die-zucht", "abgabetiere", "abgabebedingungen", "farben-genetik", "kontakt" }, nav);
|
Assert.Equal(new[] { "start", "ueber-die-zucht", "abgabetiere", "abgabebedingungen", "farben-genetik", "kontakt" }, nav);
|
||||||
|
|
||||||
var pages = root.GetProperty("pages").EnumerateArray().ToList();
|
var pages = root.GetProperty("pages").EnumerateArray().ToList();
|
||||||
// 8 pages: 6 core + impressum + datenschutz (footer-only legal pages)
|
Assert.Equal(6, pages.Count);
|
||||||
Assert.Equal(8, pages.Count);
|
|
||||||
Assert.Contains(pages, p => p.GetProperty("slug").GetString() == "impressum");
|
|
||||||
Assert.Contains(pages, p => p.GetProperty("slug").GetString() == "datenschutz");
|
|
||||||
// abgabetiere page carries an AbgabetiereList block with a resolved (possibly empty) animals array
|
// abgabetiere page carries an AbgabetiereList block with a resolved (possibly empty) animals array
|
||||||
var abg = pages.Single(p => p.GetProperty("slug").GetString() == "abgabetiere");
|
var abg = pages.Single(p => p.GetProperty("slug").GetString() == "abgabetiere");
|
||||||
var listBlock = abg.GetProperty("blocks").EnumerateArray()
|
var listBlock = abg.GetProperty("blocks").EnumerateArray()
|
||||||
|
|||||||
@@ -507,6 +507,44 @@ namespace GerbilManager.Tests
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UndatedLitters_counted_as_WithoutDate_not_Created()
|
||||||
|
{
|
||||||
|
// COUNTER-BUG regression: litters with no parseable date must go into WithoutDate,
|
||||||
|
// NOT Created. On re-import, Created must be 0 (not 31-phantom-phantom-phantom...).
|
||||||
|
var dir = Path.Combine(Path.GetTempPath(), "undated-" + Guid.NewGuid().ToString("N"));
|
||||||
|
Directory.CreateDirectory(dir);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// One dated litter, one undated litter (blank date field)
|
||||||
|
File.WriteAllText(Path.Combine(dir, "litters.json"), """
|
||||||
|
[
|
||||||
|
{"id":"L-dated","litterId":"A","date":"01.02.2020","damName":"Mutter","sireName":"Vater","totalBorn":3,"zuchtnummer":"","note":""},
|
||||||
|
{"id":"L-undated","litterId":"B","date":"","damName":"Mutter","sireName":"Vater","totalBorn":0,"zuchtnummer":"","note":""}
|
||||||
|
]
|
||||||
|
""");
|
||||||
|
File.WriteAllText(Path.Combine(dir, "animals.json"), "[]");
|
||||||
|
|
||||||
|
using var db = NewDb();
|
||||||
|
|
||||||
|
// First run
|
||||||
|
var r1 = await new ImportService(db, dir, dir).RunAsync(execute: true);
|
||||||
|
Assert.Equal(2, r1.Litters.InSource);
|
||||||
|
Assert.Equal(1, r1.Litters.Created); // only the dated one
|
||||||
|
Assert.Equal(0, r1.Litters.AlreadyImported);
|
||||||
|
Assert.Equal(1, r1.Litters.WithoutDate); // the undated one
|
||||||
|
Assert.Equal(1, await db.Litters.CountAsync()); // only 1 persisted
|
||||||
|
|
||||||
|
// Second run (re-import): dated litter is now existing, undated still WithoutDate
|
||||||
|
var r2 = await new ImportService(db, dir, dir).RunAsync(execute: true);
|
||||||
|
Assert.Equal(0, r2.Litters.Created); // no phantom "created"
|
||||||
|
Assert.Equal(1, r2.Litters.AlreadyImported);
|
||||||
|
Assert.Equal(1, r2.Litters.WithoutDate);
|
||||||
|
Assert.Equal(1, await db.Litters.CountAsync()); // still only 1 row
|
||||||
|
}
|
||||||
|
finally { try { Directory.Delete(dir, recursive: true); } catch { } }
|
||||||
|
}
|
||||||
|
|
||||||
[Theory]
|
[Theory]
|
||||||
[InlineData("01.02.2020", 2020, 2, 1)]
|
[InlineData("01.02.2020", 2020, 2, 1)]
|
||||||
[InlineData("5.3.21", 2021, 3, 5)]
|
[InlineData("5.3.21", 2021, 3, 5)]
|
||||||
|
|||||||
@@ -69,11 +69,10 @@ public class SiteRendererTests
|
|||||||
["data"] = new JsonObject { ["name"] = name, ["email"] = email, ["phone"] = phone },
|
["data"] = new JsonObject { ["name"] = name, ["email"] = email, ["phone"] = phone },
|
||||||
};
|
};
|
||||||
|
|
||||||
private static JsonObject AbgabetiereBlock(string intro,
|
private static JsonObject AbgabetiereBlock(string intro, params (string name, string farbe, string? group, string? photo)[] animals)
|
||||||
params (string name, string farbe, string? group, string? photo, string? gender, string? dob, string? note)[] animals)
|
|
||||||
{
|
{
|
||||||
var animalArr = new JsonArray();
|
var animalArr = new JsonArray();
|
||||||
foreach (var (name, farbe, group, photo, gender, dob, note) in animals)
|
foreach (var (name, farbe, group, photo) in animals)
|
||||||
{
|
{
|
||||||
var photos = new JsonArray();
|
var photos = new JsonArray();
|
||||||
if (photo is not null) photos.Add(photo);
|
if (photo is not null) photos.Add(photo);
|
||||||
@@ -82,9 +81,6 @@ public class SiteRendererTests
|
|||||||
["name"] = name,
|
["name"] = name,
|
||||||
["farbschlag"] = farbe,
|
["farbschlag"] = farbe,
|
||||||
["group"] = group,
|
["group"] = group,
|
||||||
["gender"] = gender,
|
|
||||||
["dateOfBirth"] = dob,
|
|
||||||
["characterNote"] = note,
|
|
||||||
["photos"] = photos,
|
["photos"] = photos,
|
||||||
["aiSaleText"] = (JsonNode?)null,
|
["aiSaleText"] = (JsonNode?)null,
|
||||||
});
|
});
|
||||||
@@ -96,7 +92,6 @@ public class SiteRendererTests
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// ── Tests ────────────────────────────────────────────────────────────────
|
// ── Tests ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -216,52 +211,16 @@ public class SiteRendererTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void AbgabetiereList_renders_group_card_with_name_farbschlag_and_photo()
|
public void AbgabetiereList_renders_animal_cards_with_name_and_farbschlag()
|
||||||
{
|
{
|
||||||
var snap = MakeSnapshot(("abgabetiere", "Abgabetiere",
|
var snap = MakeSnapshot(("abgabetiere", "Abgabetiere",
|
||||||
Blocks(AbgabetiereBlock("Aktuelle Tiere:", ("Krümel", "CP-Agouti", "Großbecken", "/photos/files/abc.jpg", null, null, null)))));
|
Blocks(AbgabetiereBlock("Aktuelle Tiere:", ("Krümel", "CP-Agouti", "Großbecken", "/photos/files/abc.jpg")))));
|
||||||
var html = SiteRenderer.Render(snap)["abgabetiere/index.html"];
|
var html = SiteRenderer.Render(snap)["abgabetiere/index.html"];
|
||||||
Assert.Contains("Krümel", html);
|
Assert.Contains("Krümel", html);
|
||||||
Assert.Contains("CP-Agouti", html);
|
Assert.Contains("CP-Agouti", html);
|
||||||
Assert.Contains("Großbecken", html);
|
Assert.Contains("Großbecken", html);
|
||||||
Assert.Contains("/photos/files/abc.jpg", html);
|
Assert.Contains("/photos/files/abc.jpg", html);
|
||||||
Assert.Contains("cms-group-card", html);
|
Assert.Contains("cms-animal-card", html);
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void AbgabetiereList_group_card_shows_gender_label_and_available_badge()
|
|
||||||
{
|
|
||||||
var snap = MakeSnapshot(("abgabetiere", "Abgabetiere",
|
|
||||||
Blocks(AbgabetiereBlock("",
|
|
||||||
("Frieda", "Agouti", "Becken 1", null, "female", null, null),
|
|
||||||
("Rosa", "PEW", "Becken 1", null, "female", null, null)))));
|
|
||||||
var html = SiteRenderer.Render(snap)["abgabetiere/index.html"];
|
|
||||||
Assert.Contains("Weibchen", html);
|
|
||||||
Assert.Contains("Verfügbar", html);
|
|
||||||
Assert.Contains("cms-badge--free", html);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void AbgabetiereList_shows_dateOfBirth_and_characterNote()
|
|
||||||
{
|
|
||||||
var snap = MakeSnapshot(("abgabetiere", "Abgabetiere",
|
|
||||||
Blocks(AbgabetiereBlock("",
|
|
||||||
("Pünktchen", "Siamesisch", null, null, "female", "2024-03-15", "Sehr neugierig und verspielt.")))));
|
|
||||||
var html = SiteRenderer.Render(snap)["abgabetiere/index.html"];
|
|
||||||
Assert.Contains("März 2024", html);
|
|
||||||
Assert.Contains("Sehr neugierig und verspielt.", html);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void AbgabetiereList_solo_animal_without_group_gets_its_own_card()
|
|
||||||
{
|
|
||||||
var snap = MakeSnapshot(("abgabetiere", "Abgabetiere",
|
|
||||||
Blocks(AbgabetiereBlock("",
|
|
||||||
("Einzelkind", "Zobel", null, null, "male", null, null)))));
|
|
||||||
var html = SiteRenderer.Render(snap)["abgabetiere/index.html"];
|
|
||||||
Assert.Contains("Einzelkind", html);
|
|
||||||
Assert.Contains("Männchen", html);
|
|
||||||
Assert.Contains("cms-group-card", html);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -324,44 +283,6 @@ public class SiteRendererTests
|
|||||||
Assert.Equal(7, files.Count);
|
Assert.Equal(7, files.Count);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Render_impressum_and_datenschutz_pages_are_rendered()
|
|
||||||
{
|
|
||||||
var snap = MakeSnapshot(
|
|
||||||
("start", "Startseite", Blocks()),
|
|
||||||
("impressum", "Impressum", Blocks(RichTextBlock("§ 5 TMG Platzhalter"))),
|
|
||||||
("datenschutz", "Datenschutz", Blocks(RichTextBlock("Datenschutz Platzhalter"))));
|
|
||||||
var files = SiteRenderer.Render(snap);
|
|
||||||
Assert.True(files.ContainsKey("impressum/index.html"));
|
|
||||||
Assert.True(files.ContainsKey("datenschutz/index.html"));
|
|
||||||
Assert.Contains("§ 5 TMG Platzhalter", files["impressum/index.html"]);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Footer_contains_impressum_and_datenschutz_links()
|
|
||||||
{
|
|
||||||
var snap = MakeSnapshot(
|
|
||||||
("start", "Startseite", Blocks()),
|
|
||||||
("kontakt", "Kontakt", Blocks()));
|
|
||||||
var startHtml = SiteRenderer.Render(snap)["index.html"];
|
|
||||||
Assert.Contains("impressum/index.html", startHtml);
|
|
||||||
Assert.Contains("datenschutz/index.html", startHtml);
|
|
||||||
// links in footer, not nav
|
|
||||||
Assert.Contains("site-footer-link", startHtml);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Footer_links_use_correct_relative_path_from_subpage()
|
|
||||||
{
|
|
||||||
var snap = MakeSnapshot(
|
|
||||||
("start", "Startseite", Blocks()),
|
|
||||||
("kontakt", "Kontakt", Blocks()));
|
|
||||||
var kontaktHtml = SiteRenderer.Render(snap)["kontakt/index.html"];
|
|
||||||
// subpage needs ../ prefix for footer links
|
|
||||||
Assert.Contains("../impressum/index.html", kontaktHtml);
|
|
||||||
Assert.Contains("../datenschutz/index.html", kontaktHtml);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Markdown tests ───────────────────────────────────────────────────────
|
// ── Markdown tests ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
[Theory]
|
[Theory]
|
||||||
|
|||||||
@@ -193,15 +193,6 @@ public class ApplicationContext : DbContext
|
|||||||
(6, "kontakt", "Kontakt"),
|
(6, "kontakt", "Kontakt"),
|
||||||
};
|
};
|
||||||
|
|
||||||
// WEB-6a: footer-only legal pages (not in main nav)
|
|
||||||
(int n, string Slug, string Title, string BodyMd)[] legalPages =
|
|
||||||
{
|
|
||||||
(7, "impressum", "Impressum",
|
|
||||||
"**Angaben gemäß § 5 TMG**\\n\\nSeitenbetreiber: [Name und vollständige Adresse eintragen]\\n\\nE-Mail: [E-Mail-Adresse eintragen]\\n\\n---\\n\\n*Diese Seite wird vom Seitenbetreiber noch vervollständigt.*"),
|
|
||||||
(8, "datenschutz", "Datenschutz",
|
|
||||||
"**Datenschutzerklärung**\\n\\nDiese Webseite dient der Vorstellung unserer Rennmauszucht. Es werden keine personenbezogenen Daten gespeichert oder weitergegeben.\\n\\nBei datenschutzbezogenen Fragen: [E-Mail-Adresse eintragen]\\n\\n---\\n\\n*Diese Seite wird vom Seitenbetreiber noch vervollständigt.*"),
|
|
||||||
};
|
|
||||||
|
|
||||||
var pageRows = new List<Page>();
|
var pageRows = new List<Page>();
|
||||||
var blockRows = new List<Block>();
|
var blockRows = new List<Block>();
|
||||||
foreach (var p in pages)
|
foreach (var p in pages)
|
||||||
@@ -220,12 +211,6 @@ public class ApplicationContext : DbContext
|
|||||||
Id = Bid(10), PageId = Pid(3), Order = 1, Type = BlockType.AbgabetiereList,
|
Id = Bid(10), PageId = Pid(3), Order = 1, Type = BlockType.AbgabetiereList,
|
||||||
Data = "{\"mode\":\"auto\",\"intro\":\"\"}",
|
Data = "{\"mode\":\"auto\",\"intro\":\"\"}",
|
||||||
});
|
});
|
||||||
foreach (var lp in legalPages)
|
|
||||||
{
|
|
||||||
pageRows.Add(new Page { Id = Pid(lp.n), Slug = lp.Slug, Title = lp.Title, Status = PageStatus.Published });
|
|
||||||
blockRows.Add(new Block { Id = Bid(lp.n), PageId = Pid(lp.n), Order = 0, Type = BlockType.Heading, Data = $"{{\"text\":\"{lp.Title}\",\"level\":1}}" });
|
|
||||||
blockRows.Add(new Block { Id = Bid(lp.n * 10), PageId = Pid(lp.n), Order = 1, Type = BlockType.RichText, Data = $"{{\"markdown\":\"{lp.BodyMd}\"}}" });
|
|
||||||
}
|
|
||||||
|
|
||||||
modelBuilder.Entity<Page>().HasData(pageRows);
|
modelBuilder.Entity<Page>().HasData(pageRows);
|
||||||
modelBuilder.Entity<Block>().HasData(blockRows);
|
modelBuilder.Entity<Block>().HasData(blockRows);
|
||||||
|
|||||||
@@ -131,8 +131,7 @@ namespace GerbilManagerWebAPI.Cms
|
|||||||
private static string RenderAbgabetiereList(JsonObject d)
|
private static string RenderAbgabetiereList(JsonObject d)
|
||||||
{
|
{
|
||||||
var intro = Str(d["intro"]);
|
var intro = Str(d["intro"]);
|
||||||
var animals = (d["animals"] as JsonArray ?? [])
|
var animals = d["animals"] as JsonArray ?? [];
|
||||||
.OfType<JsonObject>().ToList();
|
|
||||||
var sb = new StringBuilder("\n<section class=\"cms-abgabe\">\n");
|
var sb = new StringBuilder("\n<section class=\"cms-abgabe\">\n");
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(intro))
|
if (!string.IsNullOrWhiteSpace(intro))
|
||||||
@@ -141,80 +140,39 @@ namespace GerbilManagerWebAPI.Cms
|
|||||||
if (animals.Count == 0)
|
if (animals.Count == 0)
|
||||||
{
|
{
|
||||||
sb.Append(" <p class=\"cms-abgabe-empty\">Zurzeit stehen keine Tiere zur Abgabe bereit.</p>\n");
|
sb.Append(" <p class=\"cms-abgabe-empty\">Zurzeit stehen keine Tiere zur Abgabe bereit.</p>\n");
|
||||||
sb.Append("</section>\n");
|
|
||||||
return sb.ToString();
|
|
||||||
}
|
}
|
||||||
|
else
|
||||||
// Group by Becken name; null group → solo card keyed by animal name
|
|
||||||
var groups = animals
|
|
||||||
.GroupBy(a => Str(a["group"]) ?? ("__solo__" + (Str(a["name"]) ?? "")))
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
sb.Append(" <div class=\"cms-group-list\">\n");
|
|
||||||
foreach (var grp in groups)
|
|
||||||
{
|
{
|
||||||
var isSolo = grp.Key.StartsWith("__solo__", StringComparison.Ordinal);
|
sb.Append(" <div class=\"cms-animal-grid\">\n");
|
||||||
var groupName = isSolo ? null : grp.Key;
|
foreach (var node in animals)
|
||||||
var memberList = grp.ToList();
|
|
||||||
|
|
||||||
// Determine group gender label
|
|
||||||
var genders = memberList.Select(a => Str(a["gender"]) ?? "unknown").Distinct().ToList();
|
|
||||||
string genderLabel = genders.Count == 1
|
|
||||||
? genders[0] switch { "female" => "Weibchen", "male" => "Männchen", _ => "" }
|
|
||||||
: genders.Any(g => g != "unknown") ? "Gemischt" : "";
|
|
||||||
|
|
||||||
sb.Append(" <article class=\"cms-group-card\">\n");
|
|
||||||
sb.Append(" <div class=\"cms-group-header\">\n");
|
|
||||||
if (!string.IsNullOrEmpty(groupName))
|
|
||||||
sb.Append($" <h3 class=\"cms-group-name\">{H(groupName)}</h3>\n");
|
|
||||||
var metaParts = new List<string>();
|
|
||||||
if (!string.IsNullOrEmpty(genderLabel)) metaParts.Add(H(genderLabel));
|
|
||||||
metaParts.Add("<span class=\"cms-badge cms-badge--free\">Verfügbar</span>");
|
|
||||||
sb.Append($" <span class=\"cms-group-meta\">{string.Join(" · ", metaParts)}</span>\n");
|
|
||||||
sb.Append(" </div>\n");
|
|
||||||
|
|
||||||
// Gallery: first photo of each animal in the group
|
|
||||||
var gallery = memberList
|
|
||||||
.Select(a => (
|
|
||||||
url: (a["photos"] as JsonArray ?? []).FirstOrDefault()?.GetValue<string>(),
|
|
||||||
name: Str(a["name"]) ?? ""))
|
|
||||||
.Where(x => !string.IsNullOrEmpty(x.url))
|
|
||||||
.ToList();
|
|
||||||
if (gallery.Count > 0)
|
|
||||||
{
|
|
||||||
sb.Append(" <div class=\"cms-group-gallery\">\n");
|
|
||||||
foreach (var (url, name) in gallery)
|
|
||||||
sb.Append($" <img src=\"{H(url)}\" alt=\"{H(name)}\" loading=\"lazy\" class=\"cms-group-photo\">\n");
|
|
||||||
sb.Append(" </div>\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Per-animal detail rows
|
|
||||||
sb.Append(" <ul class=\"cms-animal-list\">\n");
|
|
||||||
foreach (var a in memberList)
|
|
||||||
{
|
{
|
||||||
|
if (node is not JsonObject a) continue;
|
||||||
var name = H(Str(a["name"]) ?? "");
|
var name = H(Str(a["name"]) ?? "");
|
||||||
var farbe = Str(a["farbschlag"]);
|
var farbe = H(Str(a["farbschlag"]) ?? "");
|
||||||
var dob = Str(a["dateOfBirth"]);
|
var group = Str(a["group"]);
|
||||||
var note = Str(a["characterNote"]);
|
|
||||||
var saleText = Str(a["aiSaleText"]);
|
var saleText = Str(a["aiSaleText"]);
|
||||||
|
var photos = a["photos"] as JsonArray ?? [];
|
||||||
|
|
||||||
sb.Append(" <li class=\"cms-animal-row\">\n");
|
sb.Append(" <article class=\"cms-animal-card\">\n");
|
||||||
sb.Append($" <span class=\"cms-animal-name\">{name}</span>\n");
|
|
||||||
var details = new List<string>();
|
// Profile photo (first photo)
|
||||||
if (!string.IsNullOrEmpty(farbe)) details.Add(H(farbe));
|
var firstPhoto = photos.FirstOrDefault()?.GetValue<string>();
|
||||||
if (!string.IsNullOrEmpty(dob) && DateOnly.TryParse(dob, out var dob2))
|
if (!string.IsNullOrEmpty(firstPhoto))
|
||||||
details.Add($"* {dob2.ToString("MMMM yyyy", System.Globalization.CultureInfo.GetCultureInfo("de-DE"))}");
|
sb.Append($" <img class=\"cms-animal-photo\" src=\"{H(firstPhoto)}\" alt=\"{name}\" loading=\"lazy\">\n");
|
||||||
if (details.Count > 0)
|
|
||||||
sb.Append($" <span class=\"cms-animal-detail\">{string.Join(" · ", details)}</span>\n");
|
sb.Append(" <div class=\"cms-animal-info\">\n");
|
||||||
var personalityText = note ?? saleText;
|
sb.Append($" <h3 class=\"cms-animal-name\">{name}</h3>\n");
|
||||||
if (!string.IsNullOrWhiteSpace(personalityText))
|
if (!string.IsNullOrEmpty(farbe))
|
||||||
sb.Append($" <span class=\"cms-animal-note\">{H(personalityText)}</span>\n");
|
sb.Append($" <p class=\"cms-animal-farbe\">{farbe}</p>\n");
|
||||||
sb.Append(" </li>\n");
|
if (!string.IsNullOrWhiteSpace(group))
|
||||||
|
sb.Append($" <p class=\"cms-animal-group\">Gruppe: {H(group)}</p>\n");
|
||||||
|
if (!string.IsNullOrWhiteSpace(saleText))
|
||||||
|
sb.Append($" <p class=\"cms-animal-text\">{H(saleText)}</p>\n");
|
||||||
|
sb.Append(" </div>\n");
|
||||||
|
sb.Append(" </article>\n");
|
||||||
}
|
}
|
||||||
sb.Append(" </ul>\n");
|
sb.Append(" </div>\n");
|
||||||
sb.Append(" </article>\n");
|
|
||||||
}
|
}
|
||||||
sb.Append(" </div>\n");
|
|
||||||
|
|
||||||
sb.Append("</section>\n");
|
sb.Append("</section>\n");
|
||||||
return sb.ToString();
|
return sb.ToString();
|
||||||
@@ -254,10 +212,6 @@ namespace GerbilManagerWebAPI.Cms
|
|||||||
</main>
|
</main>
|
||||||
<footer class="site-footer">
|
<footer class="site-footer">
|
||||||
<p>© {H(SiteName)} — Mongolische Rennmäuse</p>
|
<p>© {H(SiteName)} — Mongolische Rennmäuse</p>
|
||||||
<nav class="site-footer-nav" aria-label="Rechtliches">
|
|
||||||
<a href="{navBase}impressum/index.html" class="site-footer-link">Impressum</a>
|
|
||||||
<a href="{navBase}datenschutz/index.html" class="site-footer-link">Datenschutz</a>
|
|
||||||
</nav>
|
|
||||||
</footer>
|
</footer>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -274,8 +228,6 @@ namespace GerbilManagerWebAPI.Cms
|
|||||||
["abgabebedingungen"] = "Abgabebedingungen",
|
["abgabebedingungen"] = "Abgabebedingungen",
|
||||||
["farben-genetik"] = "Farben & Genetik",
|
["farben-genetik"] = "Farben & Genetik",
|
||||||
["kontakt"] = "Kontakt",
|
["kontakt"] = "Kontakt",
|
||||||
["impressum"] = "Impressum",
|
|
||||||
["datenschutz"] = "Datenschutz",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
var sb = new StringBuilder();
|
var sb = new StringBuilder();
|
||||||
@@ -384,256 +336,121 @@ namespace GerbilManagerWebAPI.Cms
|
|||||||
internal static string SiteCss() => """
|
internal static string SiteCss() => """
|
||||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
:root {
|
:root {
|
||||||
--color-bg: #fdf7f0;
|
--color-bg: #fdf8f3;
|
||||||
--color-surface: #fff;
|
--color-surface: #fff;
|
||||||
--color-surface-warm: #fef9f4;
|
--color-text: #2c2c2c;
|
||||||
--color-text: #2a2118;
|
--color-muted: #6b6b6b;
|
||||||
--color-muted: #7a6d62;
|
--color-accent: #c0392b;
|
||||||
--color-accent: #b5331a;
|
--color-border: #e0d8d0;
|
||||||
--color-accent-hover: #8f2815;
|
--font-body: system-ui, sans-serif;
|
||||||
--color-accent-soft: #fdf0ed;
|
--max-w: 860px;
|
||||||
--color-border: #e8ddd4;
|
|
||||||
--color-badge-free: #2d7a4a;
|
|
||||||
--color-badge-free-bg: #e6f4ec;
|
|
||||||
--font-body: Georgia, "Times New Roman", serif;
|
|
||||||
--font-ui: system-ui, -apple-system, sans-serif;
|
|
||||||
--max-w: 900px;
|
|
||||||
--radius: 10px;
|
|
||||||
}
|
}
|
||||||
body {
|
body {
|
||||||
font-family: var(--font-body);
|
font-family: var(--font-body);
|
||||||
background: var(--color-bg);
|
background: var(--color-bg);
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
line-height: 1.7;
|
line-height: 1.65;
|
||||||
font-size: 1rem;
|
|
||||||
}
|
}
|
||||||
a { color: var(--color-accent); text-decoration: none; }
|
a { color: var(--color-accent); text-decoration: none; }
|
||||||
a:hover { text-decoration: underline; color: var(--color-accent-hover); }
|
a:hover { text-decoration: underline; }
|
||||||
img { max-width: 100%; height: auto; display: block; }
|
img { max-width: 100%; height: auto; display: block; }
|
||||||
p { margin-bottom: .75rem; }
|
|
||||||
|
|
||||||
/* ── Header ── */
|
/* ── Header ── */
|
||||||
.site-header {
|
.site-header {
|
||||||
background: var(--color-surface);
|
background: var(--color-surface);
|
||||||
border-bottom: 2px solid var(--color-border);
|
border-bottom: 1px solid var(--color-border);
|
||||||
padding: 0 1rem;
|
padding: .75rem 1rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: stretch;
|
flex-wrap: wrap;
|
||||||
gap: 0;
|
align-items: center;
|
||||||
min-height: 56px;
|
gap: .5rem 1.5rem;
|
||||||
}
|
}
|
||||||
.site-logo {
|
.site-logo {
|
||||||
font-family: var(--font-body);
|
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
font-size: 1.15rem;
|
font-size: 1.1rem;
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
padding-right: 1.5rem;
|
|
||||||
border-right: 1px solid var(--color-border);
|
|
||||||
text-decoration: none;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
}
|
||||||
.site-logo:hover { color: var(--color-accent); text-decoration: none; }
|
.site-nav { display: flex; flex-wrap: wrap; gap: .25rem .75rem; }
|
||||||
.site-nav {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0;
|
|
||||||
overflow-x: auto;
|
|
||||||
-webkit-overflow-scrolling: touch;
|
|
||||||
scrollbar-width: none;
|
|
||||||
padding-left: .5rem;
|
|
||||||
}
|
|
||||||
.site-nav::-webkit-scrollbar { display: none; }
|
|
||||||
.site-nav-link {
|
.site-nav-link {
|
||||||
font-family: var(--font-ui);
|
font-size: .9rem;
|
||||||
font-size: .875rem;
|
|
||||||
color: var(--color-muted);
|
color: var(--color-muted);
|
||||||
padding: .5rem .65rem;
|
padding: .2rem .4rem;
|
||||||
border-radius: 6px;
|
border-radius: 4px;
|
||||||
white-space: nowrap;
|
|
||||||
transition: color .15s, background .15s;
|
|
||||||
}
|
}
|
||||||
.site-nav-link:hover { color: var(--color-accent); background: var(--color-accent-soft); text-decoration: none; }
|
.site-nav-link:hover, .site-nav-link[aria-current="page"] {
|
||||||
.site-nav-link[aria-current="page"] {
|
|
||||||
color: var(--color-accent);
|
color: var(--color-accent);
|
||||||
background: var(--color-accent-soft);
|
background: #fef0ee;
|
||||||
font-weight: 600;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Main ── */
|
/* ── Main ── */
|
||||||
.site-main {
|
.site-main {
|
||||||
max-width: var(--max-w);
|
max-width: var(--max-w);
|
||||||
margin: 2.5rem auto;
|
margin: 2rem auto;
|
||||||
padding: 0 1rem 4rem;
|
padding: 0 1rem 3rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Footer ── */
|
/* ── Footer ── */
|
||||||
.site-footer {
|
.site-footer {
|
||||||
background: var(--color-surface);
|
border-top: 1px solid var(--color-border);
|
||||||
border-top: 2px solid var(--color-border);
|
padding: 1.5rem 1rem;
|
||||||
padding: 1.75rem 1rem;
|
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: .85rem;
|
font-size: .85rem;
|
||||||
color: var(--color-muted);
|
color: var(--color-muted);
|
||||||
}
|
}
|
||||||
.site-footer-nav { margin-top: .6rem; display: flex; justify-content: center; gap: 1.25rem; }
|
|
||||||
.site-footer-link { color: var(--color-muted); font-size: .8rem; }
|
|
||||||
.site-footer-link:hover { color: var(--color-accent); }
|
|
||||||
|
|
||||||
/* ── CMS blocks ── */
|
/* ── CMS blocks ── */
|
||||||
.cms-heading { margin: 2rem 0 .6rem; line-height: 1.3; }
|
.cms-heading { margin: 1.5rem 0 .5rem; }
|
||||||
h1.cms-heading { font-size: 2rem; margin-top: 0; }
|
h1.cms-heading { font-size: 1.8rem; }
|
||||||
h2.cms-heading { font-size: 1.5rem; }
|
h2.cms-heading { font-size: 1.4rem; }
|
||||||
h3.cms-heading { font-size: 1.2rem; }
|
|
||||||
.cms-richtext { margin: 1rem 0; }
|
.cms-richtext { margin: 1rem 0; }
|
||||||
.cms-richtext p { margin-bottom: .75rem; }
|
.cms-richtext p { margin-bottom: .75rem; }
|
||||||
.cms-richtext ul, .cms-richtext ol { margin: .5rem 0 .75rem 1.5rem; }
|
.cms-richtext ul, .cms-richtext ol { margin: .5rem 0 .75rem 1.5rem; }
|
||||||
.cms-richtext li { margin-bottom: .35rem; }
|
.cms-richtext li { margin-bottom: .3rem; }
|
||||||
.cms-richtext hr { border: none; border-top: 1px solid var(--color-border); margin: 1.5rem 0; }
|
.cms-image { margin: 1.5rem 0; }
|
||||||
.cms-image { margin: 1.75rem 0; }
|
.cms-image img { border-radius: 6px; }
|
||||||
.cms-image img { border-radius: var(--radius); box-shadow: 0 2px 8px rgba(0,0,0,.08); }
|
|
||||||
.cms-gallery {
|
.cms-gallery {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||||
gap: .75rem;
|
gap: .75rem;
|
||||||
margin: 1.75rem 0;
|
margin: 1.5rem 0;
|
||||||
}
|
}
|
||||||
.cms-gallery-item img { border-radius: 6px; aspect-ratio: 1; object-fit: cover; }
|
.cms-gallery-item img { border-radius: 4px; aspect-ratio: 1; object-fit: cover; }
|
||||||
.cms-contact {
|
.cms-contact {
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-family: var(--font-ui);
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: .5rem;
|
gap: .4rem;
|
||||||
margin: 1.25rem 0;
|
margin: 1rem 0;
|
||||||
background: var(--color-surface-warm);
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
border-radius: var(--radius);
|
|
||||||
padding: 1.25rem;
|
|
||||||
}
|
}
|
||||||
.cms-contact-row { display: flex; gap: .6rem; align-items: flex-start; }
|
.cms-contact-row { display: flex; gap: .5rem; align-items: flex-start; }
|
||||||
|
|
||||||
/* ── Abgabetiere — group cards ── */
|
/* ── Abgabetiere ── */
|
||||||
.cms-abgabe { margin: 1.25rem 0; }
|
.cms-abgabe { margin: 1rem 0; }
|
||||||
.cms-abgabe-intro { margin-bottom: 1.5rem; font-size: 1.05rem; }
|
.cms-abgabe-intro { margin-bottom: 1.25rem; font-size: 1.05rem; }
|
||||||
.cms-abgabe-empty { color: var(--color-muted); font-style: italic; }
|
.cms-abgabe-empty { color: var(--color-muted); font-style: italic; }
|
||||||
|
.cms-animal-grid {
|
||||||
.cms-group-list { display: flex; flex-direction: column; gap: 2rem; }
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
||||||
.cms-group-card {
|
gap: 1.25rem;
|
||||||
|
}
|
||||||
|
.cms-animal-card {
|
||||||
background: var(--color-surface);
|
background: var(--color-surface);
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--color-border);
|
||||||
border-radius: var(--radius);
|
border-radius: 8px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
box-shadow: 0 2px 6px rgba(0,0,0,.06);
|
|
||||||
}
|
|
||||||
.cms-group-header {
|
|
||||||
padding: 1rem 1.25rem .75rem;
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
align-items: center;
|
|
||||||
gap: .5rem .75rem;
|
|
||||||
border-bottom: 1px solid var(--color-border);
|
|
||||||
background: var(--color-surface-warm);
|
|
||||||
}
|
|
||||||
.cms-group-name {
|
|
||||||
font-family: var(--font-body);
|
|
||||||
font-size: 1.2rem;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--color-text);
|
|
||||||
}
|
|
||||||
.cms-group-meta {
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: .875rem;
|
|
||||||
color: var(--color-muted);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: .5rem;
|
|
||||||
}
|
}
|
||||||
|
.cms-animal-photo { width: 100%; aspect-ratio: 4/3; object-fit: cover; }
|
||||||
|
.cms-animal-info { padding: .875rem; }
|
||||||
|
.cms-animal-name { font-size: 1.1rem; font-weight: 600; margin-bottom: .25rem; }
|
||||||
|
.cms-animal-farbe { font-size: .9rem; color: var(--color-muted); margin-bottom: .25rem; }
|
||||||
|
.cms-animal-group { font-size: .85rem; color: var(--color-muted); }
|
||||||
|
.cms-animal-text { font-size: .9rem; margin-top: .5rem; }
|
||||||
|
|
||||||
/* availability badge */
|
@media (max-width: 480px) {
|
||||||
.cms-badge {
|
h1.cms-heading { font-size: 1.4rem; }
|
||||||
display: inline-block;
|
.cms-animal-grid { grid-template-columns: 1fr; }
|
||||||
font-size: .75rem;
|
|
||||||
font-weight: 600;
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
padding: .2rem .55rem;
|
|
||||||
border-radius: 20px;
|
|
||||||
letter-spacing: .02em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
.cms-badge--free {
|
|
||||||
background: var(--color-badge-free-bg);
|
|
||||||
color: var(--color-badge-free);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* group photo strip */
|
|
||||||
.cms-group-gallery {
|
|
||||||
display: flex;
|
|
||||||
gap: .375rem;
|
|
||||||
overflow-x: auto;
|
|
||||||
-webkit-overflow-scrolling: touch;
|
|
||||||
scrollbar-width: none;
|
|
||||||
padding: .75rem 1.25rem;
|
|
||||||
background: #f5ede4;
|
|
||||||
}
|
|
||||||
.cms-group-gallery::-webkit-scrollbar { display: none; }
|
|
||||||
.cms-group-photo {
|
|
||||||
width: 140px;
|
|
||||||
height: 140px;
|
|
||||||
object-fit: cover;
|
|
||||||
border-radius: 6px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* animal detail list */
|
|
||||||
.cms-animal-list {
|
|
||||||
list-style: none;
|
|
||||||
padding: .75rem 1.25rem 1.25rem;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: .875rem;
|
|
||||||
}
|
|
||||||
.cms-animal-row {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: .15rem;
|
|
||||||
padding-bottom: .875rem;
|
|
||||||
border-bottom: 1px solid var(--color-border);
|
|
||||||
}
|
|
||||||
.cms-animal-row:last-child { border-bottom: none; padding-bottom: 0; }
|
|
||||||
.cms-animal-name {
|
|
||||||
font-family: var(--font-body);
|
|
||||||
font-size: 1.05rem;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--color-text);
|
|
||||||
}
|
|
||||||
.cms-animal-detail {
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: .875rem;
|
|
||||||
color: var(--color-muted);
|
|
||||||
}
|
|
||||||
.cms-animal-note {
|
|
||||||
font-style: italic;
|
|
||||||
font-size: .925rem;
|
|
||||||
color: var(--color-text);
|
|
||||||
margin-top: .1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Responsive ── */
|
|
||||||
@media (max-width: 600px) {
|
|
||||||
.site-header { padding: 0 .75rem; min-height: 50px; }
|
|
||||||
.site-logo { font-size: 1rem; padding-right: 1rem; }
|
|
||||||
h1.cms-heading { font-size: 1.5rem; }
|
|
||||||
.site-main { margin-top: 1.5rem; }
|
|
||||||
.cms-group-photo { width: 110px; height: 110px; }
|
|
||||||
}
|
|
||||||
@media (min-width: 700px) {
|
|
||||||
.cms-animal-list { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 1rem; }
|
|
||||||
.cms-animal-row { border-bottom: none; padding-bottom: 0; border: 1px solid var(--color-border); border-radius: 8px; padding: .75rem; }
|
|
||||||
}
|
}
|
||||||
""";
|
""";
|
||||||
|
|
||||||
|
|||||||
@@ -82,9 +82,6 @@ namespace GerbilManagerWebAPI.Cms
|
|||||||
{
|
{
|
||||||
g.Id,
|
g.Id,
|
||||||
g.Name,
|
g.Name,
|
||||||
g.Gender,
|
|
||||||
g.DateOfBirth,
|
|
||||||
g.CharacterNote,
|
|
||||||
Farbschlag = g.ColorVariety != null ? g.ColorVariety.Name : null,
|
Farbschlag = g.ColorVariety != null ? g.ColorVariety.Name : null,
|
||||||
// group = Becken (enclosure) name — matches how the Abgabe composer
|
// group = Becken (enclosure) name — matches how the Abgabe composer
|
||||||
// groups ForSale animals by enclosure-mates (god ruling). null = single.
|
// groups ForSale animals by enclosure-mates (god ruling). null = single.
|
||||||
@@ -112,9 +109,6 @@ namespace GerbilManagerWebAPI.Cms
|
|||||||
arr.Add(new JsonObject
|
arr.Add(new JsonObject
|
||||||
{
|
{
|
||||||
["name"] = a.Name,
|
["name"] = a.Name,
|
||||||
["gender"] = a.Gender.ToString(),
|
|
||||||
["dateOfBirth"] = a.DateOfBirth?.ToString("yyyy-MM-dd"),
|
|
||||||
["characterNote"] = a.CharacterNote,
|
|
||||||
["farbschlag"] = a.Farbschlag,
|
["farbschlag"] = a.Farbschlag,
|
||||||
["group"] = a.Group,
|
["group"] = a.Group,
|
||||||
["photos"] = photoArr,
|
["photos"] = photoArr,
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ namespace GerbilManagerWebAPI.Import
|
|||||||
|
|
||||||
public sealed record LitterSummary(int InSource, int Created, int AlreadyImported,
|
public sealed record LitterSummary(int InSource, int Created, int AlreadyImported,
|
||||||
int DerivedFromChart = 0, int DerivedSkipped = 0, int ParentFksDropped = 0,
|
int DerivedFromChart = 0, int DerivedSkipped = 0, int ParentFksDropped = 0,
|
||||||
int ParentFksBackfilled = 0);
|
int ParentFksBackfilled = 0, int WithoutDate = 0);
|
||||||
|
|
||||||
public sealed record AnimalSummary(
|
public sealed record AnimalSummary(
|
||||||
int InSource,
|
int InSource,
|
||||||
|
|||||||
@@ -94,11 +94,17 @@ namespace GerbilManagerWebAPI.Import
|
|||||||
var damNames = litters.Select(l => Normalize(StripZucht(l.DamName))).Where(s => s.Length > 0).ToHashSet();
|
var damNames = litters.Select(l => Normalize(StripZucht(l.DamName))).Where(s => s.Length > 0).ToHashSet();
|
||||||
|
|
||||||
// ---- litters: create map source.id -> Litter (for high-confidence animal links) ----
|
// ---- litters: create map source.id -> Litter (for high-confidence animal links) ----
|
||||||
int littersCreated = 0, littersExisting = 0;
|
// COUNTER-BUG FIX: undated litters (31 in the Wurfchronik) have no parseable date,
|
||||||
|
// so their existingLitterKeySet key was always "" → they were always counted as
|
||||||
|
// "created" even though the execute block skipped them (date is DateOnly d = false).
|
||||||
|
// Fix: skip undated litters early — they can never be created or linked to animals.
|
||||||
|
int littersCreated = 0, littersExisting = 0, littersWithoutDate = 0;
|
||||||
var litterIdMap = new Dictionary<string, Guid>(); // source litter id -> Litter.Id
|
var litterIdMap = new Dictionary<string, Guid>(); // source litter id -> Litter.Id
|
||||||
foreach (var sl in litters)
|
foreach (var sl in litters)
|
||||||
{
|
{
|
||||||
var date = ParseDate(sl.Date);
|
var date = ParseDate(sl.Date);
|
||||||
|
if (date is null) { littersWithoutDate++; continue; } // undated: skip entirely
|
||||||
|
|
||||||
var name = $"Wurf {sl.LitterId}".Trim();
|
var name = $"Wurf {sl.LitterId}".Trim();
|
||||||
var key = $"{name}|{date:yyyy-MM-dd}";
|
var key = $"{name}|{date:yyyy-MM-dd}";
|
||||||
if (existingLitterKeySet.Contains(key)) { littersExisting++; continue; }
|
if (existingLitterKeySet.Contains(key)) { littersExisting++; continue; }
|
||||||
@@ -106,19 +112,19 @@ namespace GerbilManagerWebAPI.Import
|
|||||||
var id = Guid.NewGuid();
|
var id = Guid.NewGuid();
|
||||||
litterIdMap[sl.Id] = id;
|
litterIdMap[sl.Id] = id;
|
||||||
littersCreated++;
|
littersCreated++;
|
||||||
if (execute && date is DateOnly d)
|
if (execute)
|
||||||
{
|
{
|
||||||
_db.Litters.Add(new Litter
|
_db.Litters.Add(new Litter
|
||||||
{
|
{
|
||||||
Id = id,
|
Id = id,
|
||||||
Name = name,
|
Name = name,
|
||||||
Date = d,
|
Date = date.Value,
|
||||||
TotalBorn = sl.TotalBorn,
|
TotalBorn = sl.TotalBorn,
|
||||||
Notes = string.IsNullOrWhiteSpace(sl.Note) ? null : sl.Note,
|
Notes = string.IsNullOrWhiteSpace(sl.Note) ? null : sl.Note,
|
||||||
PairingCode = string.IsNullOrWhiteSpace(sl.Zuchtnummer) ? null : sl.Zuchtnummer,
|
PairingCode = string.IsNullOrWhiteSpace(sl.Zuchtnummer) ? null : sl.Zuchtnummer,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (samples.Count < 8 && date is not null)
|
if (samples.Count < 8)
|
||||||
samples.Add($"Wurf: {name} ({sl.Date}) — {sl.DamName} × {sl.SireName}");
|
samples.Add($"Wurf: {name} ({sl.Date}) — {sl.DamName} × {sl.SireName}");
|
||||||
}
|
}
|
||||||
if (execute) await _db.SaveChangesAsync();
|
if (execute) await _db.SaveChangesAsync();
|
||||||
@@ -454,6 +460,8 @@ namespace GerbilManagerWebAPI.Import
|
|||||||
if (execute && parentFksBackfilled > 0) await _db.SaveChangesAsync();
|
if (execute && parentFksBackfilled > 0) await _db.SaveChangesAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (littersWithoutDate > 0)
|
||||||
|
notes.Add($"Würfe ohne Datum: {littersWithoutDate} Wurfchronik-Einträge ohne parsbares Geburtsdatum übersprungen (weder erstellt noch verknüpft).");
|
||||||
notes.Add("Quarantäne (kein Import): Konflikte + Stubs ohne Geburtsdatum + unsichere Wurf-Zuordnungen — warten auf die Prüfung durch die Züchterin.");
|
notes.Add("Quarantäne (kein Import): Konflikte + Stubs ohne Geburtsdatum + unsichere Wurf-Zuordnungen — warten auf die Prüfung durch die Züchterin.");
|
||||||
if (parentLinksAdded > 0)
|
if (parentLinksAdded > 0)
|
||||||
notes.Add($"Stammbaum-Diagramm: {parentLinksAdded} Tiere über Eltern-Verknüpfung einem (abgeleiteten) Wurf zugeordnet ({derivedLitters} abgeleitete Würfe).");
|
notes.Add($"Stammbaum-Diagramm: {parentLinksAdded} Tiere über Eltern-Verknüpfung einem (abgeleiteten) Wurf zugeordnet ({derivedLitters} abgeleitete Würfe).");
|
||||||
@@ -468,7 +476,7 @@ namespace GerbilManagerWebAPI.Import
|
|||||||
|
|
||||||
return new ImportReport(
|
return new ImportReport(
|
||||||
Executed: execute,
|
Executed: execute,
|
||||||
Litters: new LitterSummary(litters.Count, littersCreated, littersExisting, derivedLitters, derivedLittersSkipped, litterParentFksDropped, parentFksBackfilled),
|
Litters: new LitterSummary(litters.Count, littersCreated, littersExisting, derivedLitters, derivedLittersSkipped, litterParentFksDropped, parentFksBackfilled, littersWithoutDate),
|
||||||
Animals: new AnimalSummary(
|
Animals: new AnimalSummary(
|
||||||
animals.Count, animalsCreated, linked, fbMatched, fbUnmatched, animalsExisting,
|
animals.Count, animalsCreated, linked, fbMatched, fbUnmatched, animalsExisting,
|
||||||
new QuarantineSummary(conflicts, stubs, dateOnly, ambiguous, conflicts + stubs),
|
new QuarantineSummary(conflicts, stubs, dateOnly, ambiguous, conflicts + stubs),
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,71 +0,0 @@
|
|||||||
using System;
|
|
||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
|
||||||
|
|
||||||
#nullable disable
|
|
||||||
|
|
||||||
#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional
|
|
||||||
|
|
||||||
namespace GerbilManagerWebAPI.Migrations
|
|
||||||
{
|
|
||||||
/// <inheritdoc />
|
|
||||||
public partial class AddImpressumDatenschutzPages : Migration
|
|
||||||
{
|
|
||||||
/// <inheritdoc />
|
|
||||||
protected override void Up(MigrationBuilder migrationBuilder)
|
|
||||||
{
|
|
||||||
migrationBuilder.InsertData(
|
|
||||||
table: "Pages",
|
|
||||||
columns: new[] { "Id", "SeoDescription", "Slug", "Status", "Title" },
|
|
||||||
values: new object[,]
|
|
||||||
{
|
|
||||||
{ new Guid("51720001-0000-0000-0000-000000000007"), null, "impressum", "Published", "Impressum" },
|
|
||||||
{ new Guid("51720001-0000-0000-0000-000000000008"), null, "datenschutz", "Published", "Datenschutz" }
|
|
||||||
});
|
|
||||||
|
|
||||||
migrationBuilder.InsertData(
|
|
||||||
table: "Blocks",
|
|
||||||
columns: new[] { "Id", "Data", "Order", "PageId", "Type" },
|
|
||||||
values: new object[,]
|
|
||||||
{
|
|
||||||
{ new Guid("51720002-0000-0000-0000-000000000007"), "{\"text\":\"Impressum\",\"level\":1}", 0, new Guid("51720001-0000-0000-0000-000000000007"), "Heading" },
|
|
||||||
{ new Guid("51720002-0000-0000-0000-000000000008"), "{\"text\":\"Datenschutz\",\"level\":1}", 0, new Guid("51720001-0000-0000-0000-000000000008"), "Heading" },
|
|
||||||
{ new Guid("51720002-0000-0000-0000-000000000070"), "{\"markdown\":\"**Angaben gemäß § 5 TMG**\\n\\nSeitenbetreiber: [Name und vollständige Adresse eintragen]\\n\\nE-Mail: [E-Mail-Adresse eintragen]\\n\\n---\\n\\n*Diese Seite wird vom Seitenbetreiber noch vervollständigt.*\"}", 1, new Guid("51720001-0000-0000-0000-000000000007"), "RichText" },
|
|
||||||
{ new Guid("51720002-0000-0000-0000-000000000080"), "{\"markdown\":\"**Datenschutzerklärung**\\n\\nDiese Webseite dient der Vorstellung unserer Rennmauszucht. Es werden keine personenbezogenen Daten gespeichert oder weitergegeben.\\n\\nBei datenschutzbezogenen Fragen: [E-Mail-Adresse eintragen]\\n\\n---\\n\\n*Diese Seite wird vom Seitenbetreiber noch vervollständigt.*\"}", 1, new Guid("51720001-0000-0000-0000-000000000008"), "RichText" }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
protected override void Down(MigrationBuilder migrationBuilder)
|
|
||||||
{
|
|
||||||
migrationBuilder.DeleteData(
|
|
||||||
table: "Blocks",
|
|
||||||
keyColumn: "Id",
|
|
||||||
keyValue: new Guid("51720002-0000-0000-0000-000000000007"));
|
|
||||||
|
|
||||||
migrationBuilder.DeleteData(
|
|
||||||
table: "Blocks",
|
|
||||||
keyColumn: "Id",
|
|
||||||
keyValue: new Guid("51720002-0000-0000-0000-000000000008"));
|
|
||||||
|
|
||||||
migrationBuilder.DeleteData(
|
|
||||||
table: "Blocks",
|
|
||||||
keyColumn: "Id",
|
|
||||||
keyValue: new Guid("51720002-0000-0000-0000-000000000070"));
|
|
||||||
|
|
||||||
migrationBuilder.DeleteData(
|
|
||||||
table: "Blocks",
|
|
||||||
keyColumn: "Id",
|
|
||||||
keyValue: new Guid("51720002-0000-0000-0000-000000000080"));
|
|
||||||
|
|
||||||
migrationBuilder.DeleteData(
|
|
||||||
table: "Pages",
|
|
||||||
keyColumn: "Id",
|
|
||||||
keyValue: new Guid("51720001-0000-0000-0000-000000000007"));
|
|
||||||
|
|
||||||
migrationBuilder.DeleteData(
|
|
||||||
table: "Pages",
|
|
||||||
keyColumn: "Id",
|
|
||||||
keyValue: new Guid("51720001-0000-0000-0000-000000000008"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -103,38 +103,6 @@ namespace GerbilManagerWebAPI.Migrations
|
|||||||
Order = 1,
|
Order = 1,
|
||||||
PageId = new Guid("51720001-0000-0000-0000-000000000003"),
|
PageId = new Guid("51720001-0000-0000-0000-000000000003"),
|
||||||
Type = "AbgabetiereList"
|
Type = "AbgabetiereList"
|
||||||
},
|
|
||||||
new
|
|
||||||
{
|
|
||||||
Id = new Guid("51720002-0000-0000-0000-000000000007"),
|
|
||||||
Data = "{\"text\":\"Impressum\",\"level\":1}",
|
|
||||||
Order = 0,
|
|
||||||
PageId = new Guid("51720001-0000-0000-0000-000000000007"),
|
|
||||||
Type = "Heading"
|
|
||||||
},
|
|
||||||
new
|
|
||||||
{
|
|
||||||
Id = new Guid("51720002-0000-0000-0000-000000000070"),
|
|
||||||
Data = "{\"markdown\":\"**Angaben gemäß § 5 TMG**\\n\\nSeitenbetreiber: [Name und vollständige Adresse eintragen]\\n\\nE-Mail: [E-Mail-Adresse eintragen]\\n\\n---\\n\\n*Diese Seite wird vom Seitenbetreiber noch vervollständigt.*\"}",
|
|
||||||
Order = 1,
|
|
||||||
PageId = new Guid("51720001-0000-0000-0000-000000000007"),
|
|
||||||
Type = "RichText"
|
|
||||||
},
|
|
||||||
new
|
|
||||||
{
|
|
||||||
Id = new Guid("51720002-0000-0000-0000-000000000008"),
|
|
||||||
Data = "{\"text\":\"Datenschutz\",\"level\":1}",
|
|
||||||
Order = 0,
|
|
||||||
PageId = new Guid("51720001-0000-0000-0000-000000000008"),
|
|
||||||
Type = "Heading"
|
|
||||||
},
|
|
||||||
new
|
|
||||||
{
|
|
||||||
Id = new Guid("51720002-0000-0000-0000-000000000080"),
|
|
||||||
Data = "{\"markdown\":\"**Datenschutzerklärung**\\n\\nDiese Webseite dient der Vorstellung unserer Rennmauszucht. Es werden keine personenbezogenen Daten gespeichert oder weitergegeben.\\n\\nBei datenschutzbezogenen Fragen: [E-Mail-Adresse eintragen]\\n\\n---\\n\\n*Diese Seite wird vom Seitenbetreiber noch vervollständigt.*\"}",
|
|
||||||
Order = 1,
|
|
||||||
PageId = new Guid("51720001-0000-0000-0000-000000000008"),
|
|
||||||
Type = "RichText"
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1021,20 +989,6 @@ namespace GerbilManagerWebAPI.Migrations
|
|||||||
Slug = "kontakt",
|
Slug = "kontakt",
|
||||||
Status = "Published",
|
Status = "Published",
|
||||||
Title = "Kontakt"
|
Title = "Kontakt"
|
||||||
},
|
|
||||||
new
|
|
||||||
{
|
|
||||||
Id = new Guid("51720001-0000-0000-0000-000000000007"),
|
|
||||||
Slug = "impressum",
|
|
||||||
Status = "Published",
|
|
||||||
Title = "Impressum"
|
|
||||||
},
|
|
||||||
new
|
|
||||||
{
|
|
||||||
Id = new Guid("51720001-0000-0000-0000-000000000008"),
|
|
||||||
Slug = "datenschutz",
|
|
||||||
Status = "Published",
|
|
||||||
Title = "Datenschutz"
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ gelisteten Konflikt-Tiere + Tiere mit Sonder-Kürzeln warten in Quarantäne —
|
|||||||
| ~~C3~~ | ✅ **BEANTWORTET** (2026-06-06): Ja, automatisch zusammenführen — **aber nur wenn auch der Zuchtname gleich ist** (Name + Geburtsdatum + Zuchtname = dasselbe Tier). Wird in die Dedup-Regel eingebaut. (Hinweis: der Extraktor fand bisher 0 Fälle mit gleichem Name+Datum aber verschiedenem Zuchtnamen, also ändert sich an den bestehenden Zusammenführungen nichts — die Regel ist die Absicherung.) | — erledigt |
|
| ~~C3~~ | ✅ **BEANTWORTET** (2026-06-06): Ja, automatisch zusammenführen — **aber nur wenn auch der Zuchtname gleich ist** (Name + Geburtsdatum + Zuchtname = dasselbe Tier). Wird in die Dedup-Regel eingebaut. (Hinweis: der Extraktor fand bisher 0 Fälle mit gleichem Name+Datum aber verschiedenem Zuchtnamen, also ändert sich an den bestehenden Zusammenführungen nichts — die Regel ist die Absicherung.) | — erledigt |
|
||||||
| ~~C4~~ | ✅ **BEANTWORTET** (2026-06-06): Ja, **Wurfchronik Teil 2 existiert** — wird gerade überarbeitet, kommt später. Der Importer ist pro Datei wiederholbar (idempotent), also einfach die Datei schicken, sobald fertig → Michael importiert sie nach (keine Doppelungen). | ⏳ Datei folgt, wenn überarbeitet |
|
| ~~C4~~ | ✅ **BEANTWORTET** (2026-06-06): Ja, **Wurfchronik Teil 2 existiert** — wird gerade überarbeitet, kommt später. Der Importer ist pro Datei wiederholbar (idempotent), also einfach die Datei schicken, sobald fertig → Michael importiert sie nach (keine Doppelungen). | ⏳ Datei folgt, wenn überarbeitet |
|
||||||
| ~~C5~~ | ✅ **BEANTWORTET** (2026-06-06): **Es gibt KEIN „Schwarzschimmel"** — das war ein Fehler in unserem Katalog. Die korrekten Schimmelarten: `efef` → **Orangeschimmel** · `efef pp` → **Rotaugenschimmel** · `efef gg` → **Silberschimmel** · Kombis z. B. `c[chm]c[chm] efef` → **CP-Orangeschimmel**. Michael korrigiert den Katalog (Schwarzschimmel raus, efef = Orangeschimmel). | — erledigt |
|
| ~~C5~~ | ✅ **BEANTWORTET** (2026-06-06): **Es gibt KEIN „Schwarzschimmel"** — das war ein Fehler in unserem Katalog. Die korrekten Schimmelarten: `efef` → **Orangeschimmel** · `efef pp` → **Rotaugenschimmel** · `efef gg` → **Silberschimmel** · Kombis z. B. `c[chm]c[chm] efef` → **CP-Orangeschimmel**. Michael korrigiert den Katalog (Schwarzschimmel raus, efef = Orangeschimmel). | — erledigt |
|
||||||
| C6 | **Die 32 Konflikt-Tiere prüfen** → siehe Abschnitt **D**. | diese 32 Tiere werden erst danach geladen |
|
| C6 | **FAST ERLEDIGT** (Stand 06.06. nachmittags): von den ursprünglich 32 Konflikt-Tieren sind **27 geklärt + geladen** (deine D1–D5-Antworten + Beibehalten-Regel + „genauer gewinnt"-Regel). **Offen sind nur noch die 5 Tiere in D6**: Hanami (Sterbedatum), Big Ben (PP↔Pp), Vance Jr. (Spsp↔spsp), Kazu (3 Loci), Skarlett (Sterbedatum). | nur diese 5 warten noch auf den Import |
|
||||||
| C7 | *(optional)* Was hat dir bei **Renner Pro** gefehlt? Lieblings-Auswertungen? | mögliche neue Funktionen |
|
| C7 | *(optional)* Was hat dir bei **Renner Pro** gefehlt? Lieblings-Auswertungen? | mögliche neue Funktionen |
|
||||||
| ~~C8~~ | ✅ **BEANTWORTET** (2026-06-06): **Himalaya gibt es** — Himalaya = **`A- c[h]c[h]`** (agouti), Hermelin = **`aa c[h]c[h]`** (nicht-agouti). Beide bleiben im Katalog; die Engine unterscheidet bereits korrekt nach A-/aa. — erledigt |
|
| ~~C8~~ | ✅ **BEANTWORTET** (2026-06-06): **Himalaya gibt es** — Himalaya = **`A- c[h]c[h]`** (agouti), Hermelin = **`aa c[h]c[h]`** (nicht-agouti). Beide bleiben im Katalog; die Engine unterscheidet bereits korrekt nach A-/aa. — erledigt |
|
||||||
| ~~C9~~ | ✅ **BEANTWORTET** (2026-06-06): **„CP-Fuchs" ist ein Sammelbegriff** — bei diesen Tieren ist unklar, ob es CP-Polarfuchs, CP-Algierfuchs, CP-Kohlfuchs oder CP-Blaufuchs ist (Tiere sind schneeweiß mit schwarzen Augen; Verpaarungen haben die Gene nicht verraten). Bekannt ist nur: **„CP-Fuchs" = `c[chm]c[chm]`**, **„CP-Fuchs hell" = `c[chm]c[h]`**. **Generelle Regel: das Wort „hell" im Farbschlag-Namen bedeutet immer, dass ein `c[h]` im Gencode steckt** (also `c[chm]c[h]`); ohne „hell" = `c[chm]c[chm]`. Die „-Hell"-Vermutung war richtig ✓; Engine-Update beauftragt (GEN-3g): bei unbekannten Unterscheidungs-Loci bleibt der Sammelbegriff „CP-Fuchs" korrekt. — erledigt |
|
| ~~C9~~ | ✅ **BEANTWORTET** (2026-06-06): **„CP-Fuchs" ist ein Sammelbegriff** — bei diesen Tieren ist unklar, ob es CP-Polarfuchs, CP-Algierfuchs, CP-Kohlfuchs oder CP-Blaufuchs ist (Tiere sind schneeweiß mit schwarzen Augen; Verpaarungen haben die Gene nicht verraten). Bekannt ist nur: **„CP-Fuchs" = `c[chm]c[chm]`**, **„CP-Fuchs hell" = `c[chm]c[h]`**. **Generelle Regel: das Wort „hell" im Farbschlag-Namen bedeutet immer, dass ein `c[h]` im Gencode steckt** (also `c[chm]c[h]`); ohne „hell" = `c[chm]c[chm]`. Die „-Hell"-Vermutung war richtig ✓; Engine-Update beauftragt (GEN-3g): bei unbekannten Unterscheidungs-Loci bleibt der Sammelbegriff „CP-Fuchs" korrekt. — erledigt |
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
* AiKeyMissing-Hinweis), Senden (inkl. MailNotConfigured-Hinweis).
|
* AiKeyMissing-Hinweis), Senden (inkl. MailNotConfigured-Hinweis).
|
||||||
* Mock-gebunden (Seed-Anfragen + Fehlerpfad-Flags) → skipUnlessMock.
|
* Mock-gebunden (Seed-Anfragen + Fehlerpfad-Flags) → skipUnlessMock.
|
||||||
*/
|
*/
|
||||||
import { acceptNextDialog, de, expect, gotoSection, skipUnlessMock, test } from './fixtures'
|
import { acceptNextDialog, de, expect, gotoSection, openFilterPanel, skipUnlessMock, test } from './fixtures'
|
||||||
|
|
||||||
const ta = de.pages.anfragen
|
const ta = de.pages.anfragen
|
||||||
const td = ta.detail
|
const td = ta.detail
|
||||||
@@ -23,6 +23,8 @@ test.describe('Anfragen', () => {
|
|||||||
// Status-Badge auf der Karte
|
// Status-Badge auf der Karte
|
||||||
await expect(cards.nth(0)).toContainText(ta.statusLabels.New)
|
await expect(cards.nth(0)).toContainText(ta.statusLabels.New)
|
||||||
|
|
||||||
|
// UX-MOBILE-1: Status-Select liegt im Filter-Drawer — auf Mobil erst öffnen.
|
||||||
|
await openFilterPanel(page)
|
||||||
// Filter: nur Beantwortet
|
// Filter: nur Beantwortet
|
||||||
await page.getByLabel(td.statusLabel).selectOption('Answered')
|
await page.getByLabel(td.statusLabel).selectOption('Answered')
|
||||||
await expect(cards).toHaveCount(1)
|
await expect(cards).toHaveCount(1)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/** BESTAND-FILTER: die Tiere-Liste zeigt standardmäßig nur den eigenen Bestand. */
|
/** BESTAND-FILTER: die Tiere-Liste zeigt standardmäßig nur den eigenen Bestand. */
|
||||||
import { de, expect, gotoSection, skipUnlessMock, test } from './fixtures'
|
import { de, expect, gotoSection, openFilterPanel, skipUnlessMock, test } from './fixtures'
|
||||||
|
|
||||||
const t = de.pages.gerbils
|
const t = de.pages.gerbils
|
||||||
|
|
||||||
@@ -14,11 +14,13 @@ test('Tiere-Liste blendet externe Ahnen standardmäßig aus', async ({ page }) =
|
|||||||
await expect(page.locator('.gerbil-row', { hasText: 'Max' })).toHaveCount(0)
|
await expect(page.locator('.gerbil-row', { hasText: 'Max' })).toHaveCount(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('Toggle „Externe Ahnen einblenden“ zeigt externe Tiere mit Extern-Markierung', async ({ page }) => {
|
test('Toggle „Externe Ahnen einblenden” zeigt externe Tiere mit Extern-Markierung', async ({ page }) => {
|
||||||
skipUnlessMock()
|
skipUnlessMock()
|
||||||
await gotoSection(page, de.nav.gerbils)
|
await gotoSection(page, de.nav.gerbils)
|
||||||
await expect(page.locator('.gerbil-row', { hasText: 'Krümel' })).toBeVisible()
|
await expect(page.locator('.gerbil-row', { hasText: 'Krümel' })).toBeVisible()
|
||||||
|
|
||||||
|
// UX-MOBILE-1: Checkbox liegt im Filter-Drawer — auf Mobil erst öffnen.
|
||||||
|
await openFilterPanel(page)
|
||||||
await page.getByRole('checkbox', { name: t.filters.showExternal }).check()
|
await page.getByRole('checkbox', { name: t.filters.showExternal }).check()
|
||||||
|
|
||||||
const maxRow = page.locator('.gerbil-row', { hasText: 'Max' })
|
const maxRow = page.locator('.gerbil-row', { hasText: 'Max' })
|
||||||
|
|||||||
85
gerbil-manager-web/e2e/filter-panel.spec.ts
Normal file
85
gerbil-manager-web/e2e/filter-panel.spec.ts
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
/**
|
||||||
|
* UX-MOBILE-1: FilterPanel — einklappbare Filter auf Smartphone, inline auf Desktop.
|
||||||
|
*
|
||||||
|
* Telefon (390px): Filter-Button sichtbar, Drawer eingeklappt; Tippen öffnet/schliesst.
|
||||||
|
* Desktop (1280px): Alle Controls direkt sichtbar, kein Toggle-Button.
|
||||||
|
*/
|
||||||
|
import { de, expect, gotoSection, skipUnlessMock, test } from './fixtures'
|
||||||
|
|
||||||
|
const t = de.pages.gerbils
|
||||||
|
|
||||||
|
test.describe('FilterPanel – Rennmäuse-Liste', () => {
|
||||||
|
test('Phone: Filter-Drawer standardmäßig eingeklappt, Toggle-Button sichtbar', async ({
|
||||||
|
page,
|
||||||
|
}, testInfo) => {
|
||||||
|
skipUnlessMock()
|
||||||
|
if (testInfo.project.name !== 'phone') return
|
||||||
|
|
||||||
|
await gotoSection(page, de.nav.gerbils)
|
||||||
|
await expect(page.getByRole('heading', { name: t.title, exact: true })).toBeVisible()
|
||||||
|
|
||||||
|
// Toggle-Button sichtbar.
|
||||||
|
const toggle = page.locator('.filter-panel__toggle')
|
||||||
|
await expect(toggle).toBeVisible()
|
||||||
|
|
||||||
|
// Status-Beschriftung im Drawer ist noch verborgen.
|
||||||
|
await expect(page.getByText(t.filters.status, { exact: true }).first()).not.toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Phone: Toggle öffnet und schließt den Filter-Drawer', async ({
|
||||||
|
page,
|
||||||
|
}, testInfo) => {
|
||||||
|
skipUnlessMock()
|
||||||
|
if (testInfo.project.name !== 'phone') return
|
||||||
|
|
||||||
|
await gotoSection(page, de.nav.gerbils)
|
||||||
|
const toggle = page.locator('.filter-panel__toggle')
|
||||||
|
|
||||||
|
// Öffnen → Status-Feld wird sichtbar.
|
||||||
|
await toggle.click()
|
||||||
|
await expect(page.getByText(t.filters.status, { exact: true }).first()).toBeVisible()
|
||||||
|
|
||||||
|
// Schließen → wieder verborgen.
|
||||||
|
await toggle.click()
|
||||||
|
await expect(page.getByText(t.filters.status, { exact: true }).first()).not.toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Phone: Badge zählt aktive Filter korrekt', async ({
|
||||||
|
page,
|
||||||
|
}, testInfo) => {
|
||||||
|
skipUnlessMock()
|
||||||
|
if (testInfo.project.name !== 'phone') return
|
||||||
|
|
||||||
|
await gotoSection(page, de.nav.gerbils)
|
||||||
|
const toggle = page.locator('.filter-panel__toggle')
|
||||||
|
|
||||||
|
// Initial: Status='Active' ist Default → Badge zeigt kein „(N)".
|
||||||
|
await expect(toggle).toHaveText('Filter')
|
||||||
|
|
||||||
|
// Filter-Drawer öffnen und Geschlecht setzen → 1 aktiver Filter.
|
||||||
|
await toggle.click()
|
||||||
|
await page.locator('.filter-panel__drawer select').nth(1).selectOption('male')
|
||||||
|
await expect(toggle).toHaveText('Filter (1)')
|
||||||
|
|
||||||
|
// Zurücksetzen → Badge weg.
|
||||||
|
await page.getByRole('button', { name: de.filterPanel.resetButton }).click()
|
||||||
|
await expect(toggle).toHaveText('Filter')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Desktop: Toggle-Button nicht sichtbar, alle Controls inline', async ({
|
||||||
|
page,
|
||||||
|
}, testInfo) => {
|
||||||
|
skipUnlessMock()
|
||||||
|
if (testInfo.project.name !== 'desktop') return
|
||||||
|
|
||||||
|
await gotoSection(page, de.nav.gerbils)
|
||||||
|
await expect(page.getByRole('heading', { name: t.title, exact: true })).toBeVisible()
|
||||||
|
|
||||||
|
// Kein Toggle-Button auf Desktop (display:none via Media Query).
|
||||||
|
const toggle = page.locator('.filter-panel__toggle')
|
||||||
|
await expect(toggle).toBeHidden()
|
||||||
|
|
||||||
|
// Status-Beschriftung direkt sichtbar.
|
||||||
|
await expect(page.getByText(t.filters.status, { exact: true }).first()).toBeVisible()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -63,6 +63,18 @@ export function acceptNextDialog(page: Page) {
|
|||||||
page.once('dialog', (d) => void d.accept())
|
page.once('dialog', (d) => void d.accept())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UX-MOBILE-1: Filter-Drawer öffnen, falls der Toggle-Button sichtbar ist
|
||||||
|
* (= Smartphone-Ansicht). Auf Desktop-Ansicht ist er per CSS versteckt, dann
|
||||||
|
* kein Klick nötig — Controls sind direkt sichtbar.
|
||||||
|
*/
|
||||||
|
export async function openFilterPanel(page: Page) {
|
||||||
|
const toggle = page.locator('.filter-panel__toggle')
|
||||||
|
if (await toggle.isVisible()) {
|
||||||
|
await toggle.click()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Eindeutiger Name für LIVE-taugliche Create-Flows. */
|
/** Eindeutiger Name für LIVE-taugliche Create-Flows. */
|
||||||
export const uniqueName = (prefix: string) =>
|
export const uniqueName = (prefix: string) =>
|
||||||
`${prefix} E2E ${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`
|
`${prefix} E2E ${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/** QA-1: Tiere (Rennmäuse) — Liste/Suche, CRUD, Detail-Tabs (FEAT-1 + FEAT-6). */
|
/** QA-1: Tiere (Rennmäuse) — Liste/Suche, CRUD, Detail-Tabs (FEAT-1 + FEAT-6). */
|
||||||
import { de, expect, gotoSection, skipUnlessMock, test, uniqueName } from './fixtures'
|
import { de, expect, gotoSection, openFilterPanel, skipUnlessMock, test, uniqueName } from './fixtures'
|
||||||
|
|
||||||
const t = de.pages.gerbils
|
const t = de.pages.gerbils
|
||||||
const tabs = de.pages.tierTabs
|
const tabs = de.pages.tierTabs
|
||||||
@@ -23,6 +23,8 @@ test('Herkunft-Filter (originBreeder) zeigt nur Tiere der gewählten Zucht (SEAR
|
|||||||
await expect(page.getByRole('link', { name: /Krümel/ })).toBeVisible()
|
await expect(page.getByRole('link', { name: /Krümel/ })).toBeVisible()
|
||||||
await expect(page.getByRole('link', { name: /Fridolin/ })).toBeVisible()
|
await expect(page.getByRole('link', { name: /Fridolin/ })).toBeVisible()
|
||||||
|
|
||||||
|
// UX-MOBILE-1: Herkunft-Select liegt im Filter-Drawer — auf Mobil erst öffnen.
|
||||||
|
await openFilterPanel(page)
|
||||||
// Herkunft (originBreeder) auf die Seed-Zucht 'Clan-Kleine-Chaoten' (nur Krümel).
|
// Herkunft (originBreeder) auf die Seed-Zucht 'Clan-Kleine-Chaoten' (nur Krümel).
|
||||||
await page
|
await page
|
||||||
.locator('label.field', { has: page.locator(`span:text-is("${t.fields.origin}")`) })
|
.locator('label.field', { has: page.locator(`span:text-is("${t.fields.origin}")`) })
|
||||||
|
|||||||
52
gerbil-manager-web/src/components/FilterPanel.tsx
Normal file
52
gerbil-manager-web/src/components/FilterPanel.tsx
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { useState, type ReactNode } from 'react'
|
||||||
|
import { de } from '../strings/de'
|
||||||
|
import './filterPanel.css'
|
||||||
|
|
||||||
|
interface FilterPanelProps {
|
||||||
|
/** Always visible on mobile (typically the search text input). Optional. */
|
||||||
|
searchField?: ReactNode
|
||||||
|
/** Collapsible filters (hidden behind toggle on mobile; inline on desktop). */
|
||||||
|
children: ReactNode
|
||||||
|
/** Number of currently active (non-default) filter values. Shown as badge. */
|
||||||
|
activeCount: number
|
||||||
|
/** Called when the reset button is clicked. */
|
||||||
|
onReset: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UX-MOBILE-1: wraps a set of filter controls so they collapse on mobile.
|
||||||
|
* Render inside the existing `.filters` div (or replace it entirely).
|
||||||
|
*
|
||||||
|
* Desktop (>=768px): renders all children inline, identical to today.
|
||||||
|
* Mobile (<768px): shows searchField + a "Filter (N)" toggle; tapping reveals
|
||||||
|
* the rest of the controls in a column drawer + a reset button.
|
||||||
|
*/
|
||||||
|
export function FilterPanel({ searchField, children, activeCount, onReset }: FilterPanelProps) {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const t = de.filterPanel
|
||||||
|
|
||||||
|
const label = activeCount > 0 ? `${t.toggleButton} (${activeCount})` : t.toggleButton
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`filters filter-panel${open ? ' filter-panel--open' : ''}`}>
|
||||||
|
{searchField}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn--ghost filter-panel__toggle"
|
||||||
|
onClick={() => setOpen((v) => !v)}
|
||||||
|
aria-expanded={open}
|
||||||
|
aria-label={label}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
<div className="filter-panel__drawer">
|
||||||
|
{children}
|
||||||
|
{activeCount > 0 && (
|
||||||
|
<button type="button" className="btn btn--ghost filter-panel__reset-btn" onClick={onReset}>
|
||||||
|
{t.resetButton}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
53
gerbil-manager-web/src/components/filterPanel.css
Normal file
53
gerbil-manager-web/src/components/filterPanel.css
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
/*
|
||||||
|
* UX-MOBILE-1: FilterPanel — collapsible filter drawer on mobile.
|
||||||
|
*
|
||||||
|
* Desktop (>=768px): toggle hidden, drawer shows as display:contents so its
|
||||||
|
* children participate directly in the parent .filters flex row.
|
||||||
|
* Mobile (<768px): searchField inline, then toggle button. Tap opens a full-
|
||||||
|
* width drawer (flex column) with the remaining filters + reset button.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* ── Mobile default ── */
|
||||||
|
.filter-panel__toggle {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-panel__drawer {
|
||||||
|
display: none;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
width: 100%;
|
||||||
|
padding-top: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-panel--open .filter-panel__drawer {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Desktop ── */
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.filter-panel__toggle {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-panel__drawer {
|
||||||
|
/* Let children participate directly in the parent flex row. */
|
||||||
|
display: contents;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Reset button sits in the flex row on desktop when active filters exist. */
|
||||||
|
.filter-panel__reset-btn {
|
||||||
|
align-self: flex-end;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Mobile reset button ── */
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.filter-panel__reset-btn {
|
||||||
|
align-self: flex-start;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
} from '../api/requests'
|
} from '../api/requests'
|
||||||
import { useApi, useMutation } from '../hooks/useApi'
|
import { useApi, useMutation } from '../hooks/useApi'
|
||||||
import { formatDateTime } from '../format/labels'
|
import { formatDateTime } from '../format/labels'
|
||||||
|
import { FilterPanel } from '../components/FilterPanel'
|
||||||
import './anfragen.css'
|
import './anfragen.css'
|
||||||
|
|
||||||
const PAGE_SIZE = 20
|
const PAGE_SIZE = 20
|
||||||
@@ -75,7 +76,10 @@ export default function AnfragenPage() {
|
|||||||
{sync.error && <div className="alert alert--error">{sync.error}</div>}
|
{sync.error && <div className="alert alert--error">{sync.error}</div>}
|
||||||
|
|
||||||
{/* Status-Filter */}
|
{/* Status-Filter */}
|
||||||
<div className="filters">
|
<FilterPanel
|
||||||
|
activeCount={status !== '' ? 1 : 0}
|
||||||
|
onReset={() => { setStatus(''); setPage(1) }}
|
||||||
|
>
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>{t.detail.statusLabel}</span>
|
<span>{t.detail.statusLabel}</span>
|
||||||
<select
|
<select
|
||||||
@@ -93,7 +97,7 @@ export default function AnfragenPage() {
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</FilterPanel>
|
||||||
|
|
||||||
{requests.loading && <p className="muted">{de.common.loading}</p>}
|
{requests.loading && <p className="muted">{de.common.loading}</p>}
|
||||||
{requests.error && (
|
{requests.error && (
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { andFilter, condition, type GridifyQuery } from '../api/gridify'
|
|||||||
import { GENDERS, GERBIL_STATUSES, type Gender, type GerbilStatus } from '../api/types'
|
import { GENDERS, GERBIL_STATUSES, type Gender, type GerbilStatus } from '../api/types'
|
||||||
import { useApi, useMutation } from '../hooks/useApi'
|
import { useApi, useMutation } from '../hooks/useApi'
|
||||||
import { formatDate, genderLabel, statusLabel } from '../format/labels'
|
import { formatDate, genderLabel, statusLabel } from '../format/labels'
|
||||||
|
import { FilterPanel } from '../components/FilterPanel'
|
||||||
import './gerbils.css'
|
import './gerbils.css'
|
||||||
|
|
||||||
const PAGE_SIZE = 20
|
const PAGE_SIZE = 20
|
||||||
@@ -82,6 +83,14 @@ export default function GerbilsPage() {
|
|||||||
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
|
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
|
||||||
const items = gerbils.data?.items ?? []
|
const items = gerbils.data?.items ?? []
|
||||||
|
|
||||||
|
// UX-MOBILE-1: count non-default filter values for the badge.
|
||||||
|
const activeFilterCount =
|
||||||
|
(status !== 'Active' ? 1 : 0) +
|
||||||
|
(gender !== '' ? 1 : 0) +
|
||||||
|
(colorVarietyId !== '' ? 1 : 0) +
|
||||||
|
(originBreeder !== '' ? 1 : 0) +
|
||||||
|
(showExternal ? 1 : 0)
|
||||||
|
|
||||||
// Multi-select bulk "Zur Abgabe stellen".
|
// Multi-select bulk "Zur Abgabe stellen".
|
||||||
const [selected, setSelected] = useState<Set<string>>(new Set())
|
const [selected, setSelected] = useState<Set<string>>(new Set())
|
||||||
const toggleSelect = (id: string) =>
|
const toggleSelect = (id: string) =>
|
||||||
@@ -118,15 +127,20 @@ export default function GerbilsPage() {
|
|||||||
</Link>
|
</Link>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="filters">
|
<FilterPanel
|
||||||
<input
|
searchField={
|
||||||
type="search"
|
<input
|
||||||
className="input"
|
type="search"
|
||||||
placeholder={t.searchPlaceholder}
|
className="input"
|
||||||
value={search}
|
placeholder={t.searchPlaceholder}
|
||||||
onChange={(e) => onFilterChange(setSearch)(e.target.value)}
|
value={search}
|
||||||
aria-label={t.fields.name}
|
onChange={(e) => onFilterChange(setSearch)(e.target.value)}
|
||||||
/>
|
aria-label={t.fields.name}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
activeCount={activeFilterCount}
|
||||||
|
onReset={resetFilters}
|
||||||
|
>
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>{t.filters.status}</span>
|
<span>{t.filters.status}</span>
|
||||||
<select
|
<select
|
||||||
@@ -200,10 +214,7 @@ export default function GerbilsPage() {
|
|||||||
onChange={(e) => onFilterChange(setShowExternal)(e.target.checked)}
|
onChange={(e) => onFilterChange(setShowExternal)(e.target.checked)}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<button type="button" className="btn" onClick={resetFilters}>
|
</FilterPanel>
|
||||||
{t.filters.reset}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{gerbils.loading && <p className="muted">{de.common.loading}</p>}
|
{gerbils.loading && <p className="muted">{de.common.loading}</p>}
|
||||||
{gerbils.error && (
|
{gerbils.error && (
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { andFilter, condition, type GridifyQuery } from '../api/gridify'
|
|||||||
import type { Litter } from '../api/types'
|
import type { Litter } from '../api/types'
|
||||||
import { useApi } from '../hooks/useApi'
|
import { useApi } from '../hooks/useApi'
|
||||||
import { formatDate } from '../format/labels'
|
import { formatDate } from '../format/labels'
|
||||||
|
import { FilterPanel } from '../components/FilterPanel'
|
||||||
|
|
||||||
const PAGE_SIZE = 20
|
const PAGE_SIZE = 20
|
||||||
|
|
||||||
@@ -111,7 +112,10 @@ export default function WuerfeListPage() {
|
|||||||
|
|
||||||
{tab === 'litters' && (
|
{tab === 'litters' && (
|
||||||
<>
|
<>
|
||||||
<div className="filters">
|
<FilterPanel
|
||||||
|
activeCount={year !== '' ? 1 : 0}
|
||||||
|
onReset={() => { setYear(''); setSort('dateDesc'); setPage(1) }}
|
||||||
|
>
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>{t.filterYear}</span>
|
<span>{t.filterYear}</span>
|
||||||
<select
|
<select
|
||||||
@@ -136,7 +140,7 @@ export default function WuerfeListPage() {
|
|||||||
<option value="dateAsc">{t.sort.dateAsc}</option>
|
<option value="dateAsc">{t.sort.dateAsc}</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</FilterPanel>
|
||||||
|
|
||||||
{litters.loading && <p className="muted">{de.common.loading}</p>}
|
{litters.loading && <p className="muted">{de.common.loading}</p>}
|
||||||
{litters.error && (
|
{litters.error && (
|
||||||
|
|||||||
@@ -807,6 +807,12 @@ export const de = {
|
|||||||
{ key: 'schreckhaft', label: 'schreckhaft' },
|
{ key: 'schreckhaft', label: 'schreckhaft' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
// ── UX-MOBILE-1 (Kevin): FilterPanel — einklappbare Filter auf Mobil ──
|
||||||
|
filterPanel: {
|
||||||
|
toggleButton: 'Filter',
|
||||||
|
resetButton: 'Filter zurücksetzen',
|
||||||
|
closeButton: 'Schließen',
|
||||||
|
},
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
export type Strings = typeof de
|
export type Strings = typeof de
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ _Automatisch erzeugt von `tools/import/extract.py` — **noch nichts in die Date
|
|||||||
- Nach Zusammenführung (eindeutige Tiere): **621**
|
- Nach Zusammenführung (eindeutige Tiere): **621**
|
||||||
- davon mit Geburtsdatum: 326
|
- davon mit Geburtsdatum: 326
|
||||||
- in mehreren Dateien gefunden (Dubletten zusammengeführt): 158
|
- in mehreren Dateien gefunden (Dubletten zusammengeführt): 158
|
||||||
- Konflikte zur Klärung: **8**
|
- Konflikte zur Klärung: **5**
|
||||||
- Mehrdeutige / unvollständige Einträge (ohne Name+Datum): **310**
|
- Mehrdeutige / unvollständige Einträge (ohne Name+Datum): **310**
|
||||||
- Fotos zugeordnet: **137**
|
- Fotos zugeordnet: **137**
|
||||||
- Würfe aus der Wurfchronik: **752**
|
- Würfe aus der Wurfchronik: **752**
|
||||||
@@ -25,12 +25,9 @@ Gleiches Tier (Name+Datum), aber widersprüchliche Angaben in verschiedenen Date
|
|||||||
|
|
||||||
| Tier | Geburtsdatum | abweichende Genotypen | abweichende Farbschläge | Sterbedaten | Dateien |
|
| Tier | Geburtsdatum | abweichende Genotypen | abweichende Farbschläge | Sterbedaten | Dateien |
|
||||||
|---|---|---|---|---|---|
|
|---|---|---|---|---|---|
|
||||||
| Ella | 10.06.2019 | Aa C D- ee[f] GG P- spsp // Aa Cc[chm] D- ee[f] UwUw P- spsp | Algierfuchsschimmel, hell | 03.02.2023 | Stammbaum von Akio Kids, Stammbaum von CP-Fuchs, CP-Sa Sp von Unity, Stammbaum von Valentino Firehearts Kids |
|
|
||||||
| Kazu von den Kleinen Chaoten | 23.04.2013 | Aa Cc[chm] DD e[f]e[f] Gg P Spsp // Aa Cc[chm] DD ee[f] UwUw PP Spsp | — | 03.09.2017 | Stammbaum von Akio Kids, Stammbaum von Vance |
|
| Kazu von den Kleinen Chaoten | 23.04.2013 | Aa Cc[chm] DD e[f]e[f] Gg P Spsp // Aa Cc[chm] DD ee[f] UwUw PP Spsp | — | 03.09.2017 | Stammbaum von Akio Kids, Stammbaum von Vance |
|
||||||
| Enya von den Kleinen Chaoten | 01.11.2017 | Aa c[chm]c[chm] D- ee[-] G- P- spsp // Aa c[chm]c[chm] D- ee[-] Uwuw[d] P- spsp | — | — | Stammbaum von Alberto Kids, Stammbaum von Fire Kids, Stammbaum von Kohlief, Goldfuchsef Sp von Chrissi, Stammbaum von Stella Kids |
|
|
||||||
| Little Runner's Big Ben | 03.02.2020 | Aa Cc[chm] DD Ee Gg PP Spsp // Aa Cc[chm] DD Ee Gg Pp Spsp | — | 14.10.2023 | Stammbaum von CP-Fuchs, CP-Sa Sp von Unity, Stammbaum von Fire Kids, Stammbaum von Goldfuchs Sp (Pikachu) Kids, Stammbaum von Kohlief, Goldfuchsef Sp von Chrissi, Stammbaum von Valentino Firehearts Kids, Stammbaum von Watarus Kids |
|
| Little Runner's Big Ben | 03.02.2020 | Aa Cc[chm] DD Ee Gg PP Spsp // Aa Cc[chm] DD Ee Gg Pp Spsp | — | 14.10.2023 | Stammbaum von CP-Fuchs, CP-Sa Sp von Unity, Stammbaum von Fire Kids, Stammbaum von Goldfuchs Sp (Pikachu) Kids, Stammbaum von Kohlief, Goldfuchsef Sp von Chrissi, Stammbaum von Valentino Firehearts Kids, Stammbaum von Watarus Kids |
|
||||||
| Vance Jr. von den Kleinen Chaoten | 10.04.2022 | aa Cc[hm] Dd Ee gg P- Spsp // aa Cc[hm] Dd Ee gg P- spsp | Kohlfuchs, hell | — | Stammbaum von Fire Kids, Stammbaum von Stella Kids |
|
| Vance Jr. von den Kleinen Chaoten | 10.04.2022 | aa Cc[hm] Dd Ee gg P- Spsp // aa Cc[hm] Dd Ee gg P- spsp | Kohlfuchs, hell | — | Stammbaum von Fire Kids, Stammbaum von Stella Kids |
|
||||||
| Zac gen. Action von den Kleinen Chaoten | 25.12.2020 | aa C- D- Ee G- Pp Spsp [DP] // aa CC D- Ee G- Pp Spsp [DP] | — | 31.01.2025 | Stammbaum von Goldfuchs Sp (Pikachu) Kids, Stammbaum von Kohlief, Goldfuchsef Sp von Chrissi, Stammbaum von Watarus Kids |
|
|
||||||
| Hanami von den Kleinen Chaoten | 10.09.2015 | aa Cc[chm] D- Ee gg P- spsp | — | 12.12.2019 // 14.01.2020 | Stammbaum von Kentucky, Stammbaum von Stella Kids |
|
| Hanami von den Kleinen Chaoten | 10.09.2015 | aa Cc[chm] D- Ee gg P- spsp | — | 12.12.2019 // 14.01.2020 | Stammbaum von Kentucky, Stammbaum von Stella Kids |
|
||||||
| Skarlett v.d. Kleinen Chaoten | 14.07.2013 | / +2018, Aa Cc[chm] DD ee uw[d]uw[d] PP spsp // Aa Cc[chm] DD ee uw[d]uw[d] PP spsp | — | 17.04.2016 // 2018 | Stammbaum von Vance |
|
| Skarlett v.d. Kleinen Chaoten | 14.07.2013 | / +2018, Aa Cc[chm] DD ee uw[d]uw[d] PP spsp // Aa Cc[chm] DD ee uw[d]uw[d] PP spsp | — | 17.04.2016 // 2018 | Stammbaum von Vance |
|
||||||
|
|
||||||
@@ -129,7 +126,7 @@ Diese Tokens stehen weiter in `rawGenotype`/`unmappedTokens` — Entscheidung (M
|
|||||||
|
|
||||||
| Token | Vorkommen | Bedeutung (Vermutung) |
|
| Token | Vorkommen | Bedeutung (Vermutung) |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `/+` | 7 | ? |
|
| `/+` | 6 | ? |
|
||||||
| `-g` | 2 | ? |
|
| `-g` | 2 | ? |
|
||||||
| `C(C)` | 2 | Schreibweise (C trägt c) |
|
| `C(C)` | 2 | Schreibweise (C trägt c) |
|
||||||
| `Cc[]` | 1 | ? |
|
| `Cc[]` | 1 | ? |
|
||||||
|
|||||||
Reference in New Issue
Block a user