Compare commits
8 Commits
ee7f0aeab4
...
feature/an
| Author | SHA1 | Date | |
|---|---|---|---|
| d5f4be76be | |||
| 92dd614269 | |||
| 097a04cbd8 | |||
| b7ebfd3057 | |||
| d268bbc122 | |||
| a638cf2c52 | |||
| efbf24ca39 | |||
| 22e033b26a |
@@ -52,7 +52,11 @@ jobs:
|
||||
# 'tool update' ist idempotent (installiert wenn fehlend, sonst aktualisiert) —
|
||||
# auf dem SELF-HOSTED Runner persistieren --global Tools, daher schlug 'tool install'
|
||||
# ab dem 2. Lauf mit 'already installed' fehl und kippte den Backend-Job.
|
||||
run: dotnet tool update --global dotnet-ef --version 10.0.*
|
||||
# GITHUB_PATH-Zeile: global tools landen in ~/.dotnet/tools, das auf dem Runner-Container
|
||||
# nicht im PATH ist — einmalig eintragen, damit alle Folge-Steps 'dotnet ef' finden.
|
||||
run: |
|
||||
dotnet tool update --global dotnet-ef --version 10.0.*
|
||||
echo "$HOME/.dotnet/tools" >> $GITHUB_PATH
|
||||
|
||||
- name: DB-3 EF Migrations Drift-Check
|
||||
# Fails CI if the EF model diverges from the snapshot (i.e. a code change touched
|
||||
|
||||
208
GerbilManager.Tests/NameSuggestionTests.cs
Normal file
208
GerbilManager.Tests/NameSuggestionTests.cs
Normal file
@@ -0,0 +1,208 @@
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using GerbilManagerWebAPI.Names;
|
||||
using GerbilManagerWebAPI.SaleAd;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace GerbilManager.Tests
|
||||
{
|
||||
/// <summary>
|
||||
/// FEAT-NAMEGEN: NameSuggestionService — prompt assembly, JSON parse (incl. Markdown
|
||||
/// fence strip), 503-not-configured path, upstream-error path.
|
||||
/// </summary>
|
||||
public class NameSuggestionTests
|
||||
{
|
||||
// ── Prompt assembly ───────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void SystemPrompt_verlangt_reines_JSON_ohne_Erklärungen()
|
||||
{
|
||||
var prompt = NameSuggestionService.BuildSystemPrompt();
|
||||
Assert.Contains("reinen JSON-Array", prompt);
|
||||
Assert.Contains("KEINE Markdown-Code-Blöcke", prompt);
|
||||
Assert.Contains("name", prompt);
|
||||
Assert.Contains("meaning", prompt);
|
||||
Assert.Contains("origin", prompt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UserPrompt_enthält_Anzahl_und_Anfangsbuchstaben()
|
||||
{
|
||||
var prompt = NameSuggestionService.BuildUserPrompt("A", "female", "norn,mythg", 6);
|
||||
Assert.Contains("6", prompt);
|
||||
Assert.Contains("\"A\"", prompt);
|
||||
Assert.Contains("weibliche", prompt);
|
||||
Assert.Contains("norn,mythg", prompt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UserPrompt_ohne_optionale_Parameter_ist_gültig()
|
||||
{
|
||||
var prompt = NameSuggestionService.BuildUserPrompt(null, null, null, 5);
|
||||
Assert.Contains("5", prompt);
|
||||
Assert.DoesNotContain("Buchstaben", prompt);
|
||||
Assert.DoesNotContain("weibliche", prompt);
|
||||
Assert.DoesNotContain("männliche", prompt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UserPrompt_gender_any_wird_nicht_im_Prompt_erwähnt()
|
||||
{
|
||||
var prompt = NameSuggestionService.BuildUserPrompt(null, "any", null, 3);
|
||||
Assert.DoesNotContain("weibliche", prompt);
|
||||
Assert.DoesNotContain("männliche", prompt);
|
||||
}
|
||||
|
||||
// ── JSON parsing ──────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void ParseSuggestions_verarbeitet_reines_JSON()
|
||||
{
|
||||
var json = """[{"name":"Astrid","meaning":"göttliche Stärke","origin":"Altnordisch"}]""";
|
||||
var result = NameSuggestionService.ParseSuggestions(json);
|
||||
Assert.NotNull(result);
|
||||
Assert.Single(result);
|
||||
Assert.Equal("Astrid", result[0].Name);
|
||||
Assert.Equal("göttliche Stärke", result[0].Meaning);
|
||||
Assert.Equal("Altnordisch", result[0].Origin);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseSuggestions_strippt_json_Markdown_Fence()
|
||||
{
|
||||
var fenced = "```json\n[{\"name\":\"Aiko\",\"meaning\":\"kleine Geliebte\",\"origin\":\"Japanisch\"}]\n```";
|
||||
var result = NameSuggestionService.ParseSuggestions(fenced);
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("Aiko", result![0].Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseSuggestions_strippt_generische_Markdown_Fence()
|
||||
{
|
||||
var fenced = "```\n[{\"name\":\"Luna\",\"meaning\":\"Mond\",\"origin\":\"Lateinisch\"}]\n```";
|
||||
var result = NameSuggestionService.ParseSuggestions(fenced);
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("Luna", result![0].Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseSuggestions_toleriert_umgebenden_Text_vor_Array()
|
||||
{
|
||||
var messy = "Hier sind die Namen:\n[{\"name\":\"Sol\",\"meaning\":\"Sonne\",\"origin\":\"Nordisch\"}]\nHoffnungslos.";
|
||||
var result = NameSuggestionService.ParseSuggestions(messy);
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("Sol", result![0].Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseSuggestions_gibt_null_zurück_bei_ungültigem_JSON()
|
||||
{
|
||||
Assert.Null(NameSuggestionService.ParseSuggestions("kein json"));
|
||||
Assert.Null(NameSuggestionService.ParseSuggestions("{\"name\":\"X\"}"));
|
||||
Assert.Null(NameSuggestionService.ParseSuggestions(""));
|
||||
}
|
||||
|
||||
// ── 503: nicht konfiguriert ───────────────────────────────────────────
|
||||
|
||||
[Theory]
|
||||
[InlineData(null, "key", "model")]
|
||||
[InlineData("https://api.example.com/v1", null, "model")]
|
||||
[InlineData("https://api.example.com/v1", "key", null)]
|
||||
[InlineData(null, null, null)]
|
||||
public async Task SuggestAsync_gibt_NotConfigured_wenn_AI_Key_fehlt(
|
||||
string? baseUrl, string? apiKey, string? model)
|
||||
{
|
||||
var service = CreateService(baseUrl, apiKey, model,
|
||||
new StubHandler(_ => throw new InvalidOperationException("darf nicht aufgerufen werden")));
|
||||
|
||||
var result = await service.SuggestAsync(null, null, null, 5);
|
||||
|
||||
Assert.Equal(NameSuggestionStatus.NotConfigured, result.Status);
|
||||
Assert.Null(result.Suggestions);
|
||||
}
|
||||
|
||||
// ── Gemini-Antwort wird geparst ───────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task SuggestAsync_parst_valide_JSON_Antwort()
|
||||
{
|
||||
var payload = """[{"name":"Astrid","meaning":"göttliche Stärke","origin":"Altnordisch"},{"name":"Aiko","meaning":"kleine Geliebte","origin":"Japanisch"}]""";
|
||||
var handler = new StubHandler(_ => Canned(payload));
|
||||
var service = CreateService("https://generativelanguage.googleapis.com/v1beta/openai", "k", "gemini-2.0-flash", handler);
|
||||
|
||||
var result = await service.SuggestAsync("A", "female", "norn,japa", 2);
|
||||
|
||||
Assert.Equal(NameSuggestionStatus.Ok, result.Status);
|
||||
Assert.NotNull(result.Suggestions);
|
||||
Assert.Equal(2, result.Suggestions!.Count);
|
||||
Assert.Equal("Astrid", result.Suggestions[0].Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SuggestAsync_parst_JSON_in_Markdown_Fence()
|
||||
{
|
||||
var fenced = "```json\n[{\"name\":\"Luna\",\"meaning\":\"Mond\",\"origin\":\"Lateinisch\"}]\n```";
|
||||
var handler = new StubHandler(_ => Canned(fenced));
|
||||
var service = CreateService("https://api.example.com/v1", "k", "m", handler);
|
||||
|
||||
var result = await service.SuggestAsync(null, null, null, 1);
|
||||
|
||||
Assert.Equal(NameSuggestionStatus.Ok, result.Status);
|
||||
Assert.Equal("Luna", result.Suggestions![0].Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SuggestAsync_gibt_UpstreamError_bei_ungültigem_JSON()
|
||||
{
|
||||
var handler = new StubHandler(_ => Canned("das ist kein json"));
|
||||
var service = CreateService("https://api.example.com/v1", "k", "m", handler);
|
||||
|
||||
var result = await service.SuggestAsync(null, null, null, 3);
|
||||
|
||||
Assert.Equal(NameSuggestionStatus.UpstreamError, result.Status);
|
||||
Assert.Null(result.Suggestions);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SuggestAsync_gibt_UpstreamError_bei_HTTP_Fehler()
|
||||
{
|
||||
var handler = new StubHandler(_ => new HttpResponseMessage(HttpStatusCode.TooManyRequests));
|
||||
var service = CreateService("https://api.example.com/v1", "k", "m", handler);
|
||||
|
||||
var result = await service.SuggestAsync(null, null, null, 3);
|
||||
|
||||
Assert.Equal(NameSuggestionStatus.UpstreamError, result.Status);
|
||||
}
|
||||
|
||||
// ── Helfer ────────────────────────────────────────────────────────────
|
||||
|
||||
private static NameSuggestionService CreateService(
|
||||
string? baseUrl, string? apiKey, string? model, StubHandler handler)
|
||||
{
|
||||
var options = Options.Create(new AiOptions { BaseUrl = baseUrl, ApiKey = apiKey, Model = model });
|
||||
return new NameSuggestionService(new HttpClient(handler), options);
|
||||
}
|
||||
|
||||
private static HttpResponseMessage Canned(string content)
|
||||
{
|
||||
var completion = new
|
||||
{
|
||||
choices = new[] { new { message = new { role = "assistant", content } } },
|
||||
};
|
||||
return new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(JsonSerializer.Serialize(completion),
|
||||
Encoding.UTF8, "application/json"),
|
||||
};
|
||||
}
|
||||
|
||||
private sealed class StubHandler(Func<HttpRequestMessage, HttpResponseMessage> respond)
|
||||
: HttpMessageHandler
|
||||
{
|
||||
protected override Task<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
=> Task.FromResult(respond(request));
|
||||
}
|
||||
}
|
||||
}
|
||||
38
GerbilManagerWebAPI/Endpoints/NamesEndpoints.cs
Normal file
38
GerbilManagerWebAPI/Endpoints/NamesEndpoints.cs
Normal file
@@ -0,0 +1,38 @@
|
||||
using GerbilManagerWebAPI.Names;
|
||||
|
||||
namespace GerbilManagerWebAPI.Endpoints
|
||||
{
|
||||
/// <summary>
|
||||
/// FEAT-NAMEGEN: GET /names/suggest — meaningful gerbil name suggestions via Gemini.
|
||||
///
|
||||
/// 503 {code:"NamesKeyMissing"} while the AI section is unconfigured — the
|
||||
/// frontend maps exactly this code to its German disabled-state hint.
|
||||
/// </summary>
|
||||
public static class NamesEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapNamesEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
app.MapGet("/names/suggest", async (
|
||||
string? letter,
|
||||
string? gender,
|
||||
string? usages,
|
||||
int count,
|
||||
NameSuggestionService service,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
var result = await service.SuggestAsync(letter, gender, usages, count, ct);
|
||||
return result.Status switch
|
||||
{
|
||||
NameSuggestionStatus.Ok => Results.Ok(result.Suggestions),
|
||||
NameSuggestionStatus.NotConfigured => Results.Json(
|
||||
new { code = "NamesKeyMissing", message = result.Error ?? "" }, statusCode: 503),
|
||||
_ => Results.Json(
|
||||
new { code = "NamesUpstreamError", message = result.Error ?? "" }, statusCode: 502),
|
||||
};
|
||||
})
|
||||
.WithTags("Names");
|
||||
|
||||
return app;
|
||||
}
|
||||
}
|
||||
}
|
||||
1402
GerbilManagerWebAPI/Migrations/20260606185411_AddLitterLetter.Designer.cs
generated
Normal file
1402
GerbilManagerWebAPI/Migrations/20260606185411_AddLitterLetter.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,28 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace GerbilManagerWebAPI.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddLitterLetter : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "LitterLetter",
|
||||
table: "Litters",
|
||||
type: "text",
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "LitterLetter",
|
||||
table: "Litters");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -905,6 +905,9 @@ namespace GerbilManagerWebAPI.Migrations
|
||||
b.Property<Guid?>("FatherId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("LitterLetter")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<Guid?>("MotherId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
|
||||
@@ -30,5 +30,9 @@ namespace GerbilManagerWebAPI.Models
|
||||
/// nulls allowed for manually-entered litters). Primary idempotency key for re-imports;
|
||||
/// Name+Date is the fallback for litters created before this column existed.</summary>
|
||||
public string? ExternalRef { get; set; }
|
||||
|
||||
/// <summary>FEAT-NAMEGEN: Wurfbuchstabe (A, B, C … AA, AB …) — alle Welpen dieses
|
||||
/// Wurfs erhalten Namen mit diesem Anfangsbuchstaben (gängige Zuchtkonvention).</summary>
|
||||
public string? LitterLetter { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
5
GerbilManagerWebAPI/Names/NameSuggestion.cs
Normal file
5
GerbilManagerWebAPI/Names/NameSuggestion.cs
Normal file
@@ -0,0 +1,5 @@
|
||||
namespace GerbilManagerWebAPI.Names
|
||||
{
|
||||
/// <summary>FEAT-NAMEGEN: a single name suggestion returned by GET /names/suggest.</summary>
|
||||
public sealed record NameSuggestion(string Name, string Meaning, string Origin);
|
||||
}
|
||||
107
GerbilManagerWebAPI/Names/NameSuggestionService.cs
Normal file
107
GerbilManagerWebAPI/Names/NameSuggestionService.cs
Normal file
@@ -0,0 +1,107 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using GerbilManagerWebAPI.Ai;
|
||||
using GerbilManagerWebAPI.SaleAd;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace GerbilManagerWebAPI.Names
|
||||
{
|
||||
/// <summary>
|
||||
/// FEAT-NAMEGEN: generates meaningful gerbil name suggestions via Gemini
|
||||
/// (the same OpenAiChatClient used by sale-ads and reply-drafts).
|
||||
/// Returns NotConfigured when the AI section is missing — callers map to 503.
|
||||
/// </summary>
|
||||
public sealed class NameSuggestionService(HttpClient http, IOptions<AiOptions> options)
|
||||
{
|
||||
private readonly OpenAiChatClient _client = new(http, options);
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOpts = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
};
|
||||
|
||||
public async Task<NameSuggestionResult> SuggestAsync(
|
||||
string? letter, string? gender, string? usages, int count,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var aiResult = await _client.CompleteAsync(
|
||||
BuildSystemPrompt(),
|
||||
BuildUserPrompt(letter, gender, usages, count),
|
||||
ct);
|
||||
|
||||
if (aiResult.Status == AiCallStatus.NotConfigured)
|
||||
return new NameSuggestionResult(NameSuggestionStatus.NotConfigured, null, aiResult.Error);
|
||||
if (aiResult.Status != AiCallStatus.Ok || aiResult.Text is null)
|
||||
return new NameSuggestionResult(NameSuggestionStatus.UpstreamError, null, aiResult.Error);
|
||||
|
||||
var suggestions = ParseSuggestions(aiResult.Text);
|
||||
return suggestions is null
|
||||
? new NameSuggestionResult(NameSuggestionStatus.UpstreamError, null, "Ungültiges JSON in KI-Antwort.")
|
||||
: new NameSuggestionResult(NameSuggestionStatus.Ok, suggestions, null);
|
||||
}
|
||||
|
||||
internal static string BuildSystemPrompt() =>
|
||||
"Du bist ein Helfer für Rennmaus-Züchter. " +
|
||||
"Antworte IMMER mit einem reinen JSON-Array — KEINE Markdown-Code-Blöcke, " +
|
||||
"KEINE Erklärungen, KEIN Text außerhalb des Arrays. " +
|
||||
"Jedes Element hat genau die Felder: name, meaning, origin (alle Strings, alle auf Deutsch).";
|
||||
|
||||
internal static string BuildUserPrompt(string? letter, string? gender, string? usages, int count)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.Append($"Schlage {count} Rennmaus-Namen vor");
|
||||
if (!string.IsNullOrWhiteSpace(letter))
|
||||
sb.Append($" die mit dem Buchstaben \"{letter.ToUpperInvariant()}\" beginnen");
|
||||
if (!string.IsNullOrWhiteSpace(gender) &&
|
||||
!gender.Equals("any", StringComparison.OrdinalIgnoreCase))
|
||||
sb.Append($", passend für {(gender.Equals("female", StringComparison.OrdinalIgnoreCase) ? "weibliche" : "männliche")} Tiere");
|
||||
if (!string.IsNullOrWhiteSpace(usages))
|
||||
sb.Append($", aus den Kulturkreisen: {usages}");
|
||||
sb.Append(". Jeder Name muss eine echte etymologische Bedeutung und Herkunft haben ");
|
||||
sb.Append("(keine erfundenen oder zufälligen Namen). ");
|
||||
sb.Append($"Antworte mit genau {count} Elementen als reines JSON-Array: ");
|
||||
sb.Append("[{\"name\":\"...\",\"meaning\":\"...\",\"origin\":\"...\"}]");
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Strips optional markdown fences (```json ... ```) Gemini sometimes wraps around
|
||||
/// its JSON output, then deserialises the array.
|
||||
/// </summary>
|
||||
internal static List<NameSuggestion>? ParseSuggestions(string raw)
|
||||
{
|
||||
var text = raw.Trim();
|
||||
|
||||
// Strip ```json ... ``` or ``` ... ``` fences.
|
||||
if (text.StartsWith("```", StringComparison.Ordinal))
|
||||
{
|
||||
var firstNewline = text.IndexOf('\n');
|
||||
if (firstNewline >= 0) text = text[(firstNewline + 1)..];
|
||||
if (text.EndsWith("```", StringComparison.Ordinal))
|
||||
text = text[..^3].TrimEnd();
|
||||
}
|
||||
|
||||
// Find the JSON array bounds defensively.
|
||||
var start = text.IndexOf('[');
|
||||
var end = text.LastIndexOf(']');
|
||||
if (start < 0 || end <= start) return null;
|
||||
text = text[start..(end + 1)];
|
||||
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<List<NameSuggestion>>(text, JsonOpts);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public enum NameSuggestionStatus { Ok, NotConfigured, UpstreamError }
|
||||
|
||||
public sealed record NameSuggestionResult(
|
||||
NameSuggestionStatus Status,
|
||||
List<NameSuggestion>? Suggestions,
|
||||
string? Error);
|
||||
}
|
||||
@@ -45,6 +45,9 @@ builder.Services.AddHttpClient<GerbilManagerWebAPI.SaleAd.SaleAdService>(
|
||||
// INBOX-2: KI-Antwortentwurf (gleiche AI-Sektion, gleicher Wire-Client).
|
||||
builder.Services.AddHttpClient<GerbilManagerWebAPI.Inbox.DraftReplyService>(
|
||||
http => http.Timeout = TimeSpan.FromSeconds(60));
|
||||
// FEAT-NAMEGEN: Name suggestions via Gemini (same AI section, same wire client).
|
||||
builder.Services.AddHttpClient<GerbilManagerWebAPI.Names.NameSuggestionService>(
|
||||
http => http.Timeout = TimeSpan.FromSeconds(60));
|
||||
|
||||
// INBOX-0: Gmail inbox. App Password encrypted at rest via Data Protection.
|
||||
// AR-3: persist the key ring so encrypted passwords survive image redeployments.
|
||||
@@ -100,6 +103,7 @@ app.MapSettingsEndpoints();
|
||||
app.MapExportEndpoints();
|
||||
app.MapCmsEndpoints();
|
||||
app.MapRequestEndpoints();
|
||||
app.MapNamesEndpoints();
|
||||
|
||||
app.Run();
|
||||
|
||||
|
||||
@@ -128,7 +128,7 @@ Bitte je Tier sagen, **welcher Wert stimmt** (die Quellen widersprechen sich bei
|
||||
| Hanami v.d. K.C. (*10.09.2015) | Sterbedatum: **12.12.2019** ↔ **14.01.2020** (= D5) |
|
||||
| ~~Little Runner's Big Ben (*03.02.2020)~~ | ✅ **Pp** (P-Locus) — Julian 2026-06-07 → in conflict-decisions.json |
|
||||
| ~~Vance Jr. v.d. K.C. (*10.04.2022)~~ | ✅ **spsp** (ungescheckt) — Julian 2026-06-07 → in conflict-decisions.json |
|
||||
| Kazu v.d. K.C. (*23.04.2013) | E-Locus: **e[f]e[f]** ↔ **ee[f]** · G-Locus: **Gg** ↔ **GG** · P-Locus: **P?** ↔ **PP** |
|
||||
| ~~Kazu v.d. K.C. (*23.04.2013)~~ | ✅ **ee[f] · GG · PP** — Julian 2026-06-07 → in conflict-decisions.json |
|
||||
| ~~Skarlett v.d. K.C. (*14.07.2013)~~ | ✅ **Sterbedatum 17.04.2016** — Julian 2026-06-07 → in conflict-decisions.json |
|
||||
|
||||
*(Enya, Ella und Zac fehlen hier bewusst: deren Abweichung ist nur „unbekannt ↔ genau angegeben" — löst der Importer automatisch mit der „genauer gewinnt"-Regel.)*
|
||||
|
||||
@@ -361,6 +361,26 @@ export async function installMockApi(page: Page): Promise<MockDb> {
|
||||
return json(route, 201, contract)
|
||||
}
|
||||
|
||||
// FEAT-NAMEGEN: /names/suggest
|
||||
if (path === '/names/suggest' && method === 'GET') {
|
||||
if (!db.namesConfigured) {
|
||||
return json(route, 503, { code: 'NamesKeyMissing', message: 'Kein API-Key konfiguriert' })
|
||||
}
|
||||
const letter = url.searchParams.get('letter')?.toUpperCase()
|
||||
const allSuggestions = [
|
||||
{ name: 'Fenrir', meaning: 'Wolf aus der Nordischen Mythologie', origin: 'Nordisch' },
|
||||
{ name: 'Freya', meaning: 'Göttin der Liebe und Fruchtbarkeit', origin: 'Nordisch' },
|
||||
{ name: 'Artemis', meaning: 'Göttin der Jagd und des Mondlichts', origin: 'Griech. Mythologie' },
|
||||
{ name: 'Kira', meaning: 'Strahlendes Licht', origin: 'Japanisch' },
|
||||
{ name: 'Luna', meaning: 'Mondgöttin', origin: 'Griech. Mythologie' },
|
||||
{ name: 'Baldur', meaning: 'Gott des Lichts und der Reinheit', origin: 'Nordisch' },
|
||||
]
|
||||
const result = letter
|
||||
? allSuggestions.filter((s) => s.name.startsWith(letter))
|
||||
: allSuggestions
|
||||
return json(route, 200, result)
|
||||
}
|
||||
|
||||
// Generische Kollektionen: /<resource> und /<resource>/<id>
|
||||
m = path.match(/^\/([a-z-]+)(?:\/([^/]+))?$/)
|
||||
const col = m ? collections[m[1]] : undefined
|
||||
|
||||
@@ -74,6 +74,8 @@ export interface MockDb {
|
||||
// ABGABE: Verträge + KI-Inserat-Flag
|
||||
contracts: MockContract[]
|
||||
saleAdConfigured: boolean
|
||||
// FEAT-NAMEGEN: Namensvorschläge — false = 503 NamesKeyMissing simulieren
|
||||
namesConfigured: boolean
|
||||
}
|
||||
|
||||
function gerbil(
|
||||
@@ -292,5 +294,6 @@ export function seedDb(): MockDb {
|
||||
mailConfigured: true,
|
||||
contracts: [],
|
||||
saleAdConfigured: true,
|
||||
namesConfigured: true,
|
||||
}
|
||||
}
|
||||
|
||||
59
gerbil-manager-web/e2e/namegen.spec.ts
Normal file
59
gerbil-manager-web/e2e/namegen.spec.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
/** FEAT-NAMEGEN: UC-1 — 'Name vorschlagen'-Panel auf der GerbilFormPage. */
|
||||
import { de, expect, skipUnlessMock, test } from './fixtures'
|
||||
|
||||
const t = de.namegen
|
||||
const tf = de.pages.gerbils.form
|
||||
|
||||
test('Name-vorschlagen-Panel öffnet sich und zeigt Vorschläge (FEAT-NAMEGEN)', async ({ page }) => {
|
||||
skipUnlessMock()
|
||||
await page.goto('/rennmaeuse/neu')
|
||||
await expect(page.getByRole('heading', { name: tf.createTitle })).toBeVisible()
|
||||
|
||||
// Panel öffnen
|
||||
await page.getByRole('button', { name: t.button }).click()
|
||||
await expect(page.getByText(t.panelTitle)).toBeVisible()
|
||||
|
||||
// Vorschläge laden
|
||||
await page.getByRole('button', { name: t.loadButton }).click()
|
||||
// Fenrir ist im Mock immer dabei (kein Buchstabe-Filter)
|
||||
await expect(page.getByRole('button', { name: 'Fenrir' })).toBeVisible()
|
||||
await expect(page.getByText('Wolf aus der Nordischen Mythologie')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Klick auf Vorschlag befüllt Namensfeld und schließt Panel (FEAT-NAMEGEN)', async ({ page }) => {
|
||||
skipUnlessMock()
|
||||
await page.goto('/rennmaeuse/neu')
|
||||
await page.getByRole('button', { name: t.button }).click()
|
||||
await page.getByRole('button', { name: t.loadButton }).click()
|
||||
await expect(page.getByRole('button', { name: 'Fenrir' })).toBeVisible()
|
||||
|
||||
// Klick auf Vorschlag 'Fenrir'
|
||||
await page.getByRole('button', { name: 'Fenrir' }).click()
|
||||
|
||||
// Panel geschlossen, Name-Feld befüllt
|
||||
await expect(page.getByText(t.panelTitle)).toBeHidden()
|
||||
await expect(page.getByLabel(`${de.pages.gerbils.fields.name} *`)).toHaveValue('Fenrir')
|
||||
})
|
||||
|
||||
test('Buchstabe-Filter schränkt Vorschläge ein (FEAT-NAMEGEN)', async ({ page }) => {
|
||||
skipUnlessMock()
|
||||
await page.goto('/rennmaeuse/neu')
|
||||
await page.getByRole('button', { name: t.button }).click()
|
||||
await page.getByLabel(t.letterLabel).fill('F')
|
||||
await page.getByRole('button', { name: t.loadButton }).click()
|
||||
|
||||
// Mock gibt nur Namen mit F zurück: Fenrir + Freya
|
||||
await expect(page.getByRole('button', { name: 'Fenrir' })).toBeVisible()
|
||||
await expect(page.getByRole('button', { name: 'Freya' })).toBeVisible()
|
||||
// Artemis (A) nicht sichtbar
|
||||
await expect(page.getByRole('button', { name: 'Artemis' })).toBeHidden()
|
||||
})
|
||||
|
||||
test('503 NamesKeyMissing zeigt freundlichen Hinweis (FEAT-NAMEGEN)', async ({ page, mockDb }) => {
|
||||
skipUnlessMock()
|
||||
if (mockDb) mockDb.namesConfigured = false
|
||||
await page.goto('/rennmaeuse/neu')
|
||||
await page.getByRole('button', { name: t.button }).click()
|
||||
await page.getByRole('button', { name: t.loadButton }).click()
|
||||
await expect(page.getByText(t.keyMissing)).toBeVisible()
|
||||
})
|
||||
@@ -172,3 +172,27 @@ test('Tier bearbeiten — isDeaf Tristate round-trip (FORM-FIELDS-1)', async ({
|
||||
await page.goto('/rennmaeuse/kruemel/bearbeiten')
|
||||
await expect(page.getByLabel(t.fields.isDeaf)).toHaveValue('true')
|
||||
})
|
||||
|
||||
test('Detailseite: Würfe als Elternteil zeigt verlinkten Wurf (ANIMAL-LITTERS)', async ({ page }) => {
|
||||
skipUnlessMock()
|
||||
// Fridolin ist fatherId von 'w-kruemel' (Wurf K)
|
||||
await page.goto('/rennmaeuse/fridolin')
|
||||
await expect(page.getByRole('heading', { name: 'Fridolin' })).toBeVisible()
|
||||
await expect(page.getByText(t.detail.parentLittersTitle)).toBeVisible()
|
||||
// Wurf K als Link sichtbar
|
||||
const litterLink = page.getByRole('link', { name: /Wurf K/ })
|
||||
await expect(litterLink).toBeVisible()
|
||||
// Vater-Badge sichtbar
|
||||
await expect(page.getByText(t.detail.parentLittersRoleVater)).toBeVisible()
|
||||
// Navigation zum Wurf funktioniert
|
||||
await litterLink.first().click()
|
||||
await expect(page.getByRole('heading', { name: 'Wurf K' })).toBeVisible()
|
||||
})
|
||||
|
||||
test('Detailseite: Keine Würfe als Elternteil zeigt Leer-Zustand (ANIMAL-LITTERS)', async ({ page }) => {
|
||||
skipUnlessMock()
|
||||
// Krümel hat keine Würfe als Elternteil (nur als Kind von w-kruemel)
|
||||
await page.goto('/rennmaeuse/kruemel')
|
||||
await expect(page.getByText(t.detail.parentLittersTitle)).toBeVisible()
|
||||
await expect(page.getByText(t.detail.parentLittersEmpty)).toBeVisible()
|
||||
})
|
||||
|
||||
73
gerbil-manager-web/src/api/__tests__/names.test.ts
Normal file
73
gerbil-manager-web/src/api/__tests__/names.test.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
/** FEAT-NAMEGEN: Tests für buildSuggestPath + NAMEGEN_USAGES. */
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { buildSuggestPath, NAMEGEN_USAGES } from '../names'
|
||||
|
||||
describe('buildSuggestPath', () => {
|
||||
it('includes uppercased letter', () => {
|
||||
const path = buildSuggestPath({ letter: 'f', gender: 'female', usages: ['norn'] })
|
||||
expect(path).toContain('letter=F')
|
||||
})
|
||||
|
||||
it('omits letter when blank', () => {
|
||||
const path = buildSuggestPath({ letter: '', usages: ['norn'] })
|
||||
expect(path).not.toContain('letter=')
|
||||
})
|
||||
|
||||
it('omits letter when only whitespace', () => {
|
||||
const path = buildSuggestPath({ letter: ' ', usages: ['norn'] })
|
||||
expect(path).not.toContain('letter=')
|
||||
})
|
||||
|
||||
it('includes gender', () => {
|
||||
const path = buildSuggestPath({ gender: 'male', usages: ['norn'] })
|
||||
expect(path).toContain('gender=male')
|
||||
})
|
||||
|
||||
it('omits gender for empty string', () => {
|
||||
const path = buildSuggestPath({ gender: '', usages: ['norn'] })
|
||||
expect(path).not.toContain('gender=')
|
||||
})
|
||||
|
||||
it('joins multiple usages without encoding commas', () => {
|
||||
const path = buildSuggestPath({ usages: ['norn', 'mythg'] })
|
||||
expect(path).toContain('usages=norn,mythg')
|
||||
})
|
||||
|
||||
it('omits usages when array is empty', () => {
|
||||
const path = buildSuggestPath({ usages: [] })
|
||||
expect(path).not.toContain('usages=')
|
||||
})
|
||||
|
||||
it('defaults count to 6', () => {
|
||||
const path = buildSuggestPath({ usages: ['norn'] })
|
||||
expect(path).toContain('count=6')
|
||||
})
|
||||
|
||||
it('uses provided count', () => {
|
||||
const path = buildSuggestPath({ usages: ['norn'], count: 8 })
|
||||
expect(path).toContain('count=8')
|
||||
})
|
||||
|
||||
it('starts with /names/suggest', () => {
|
||||
const path = buildSuggestPath({ usages: ['norn'] })
|
||||
expect(path).toMatch(/^\/names\/suggest\?/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('NAMEGEN_USAGES', () => {
|
||||
it('contains all 5 expected culture codes', () => {
|
||||
const codes = NAMEGEN_USAGES.map((u) => u.code)
|
||||
expect(codes).toContain('norn')
|
||||
expect(codes).toContain('japa')
|
||||
expect(codes).toContain('mythg')
|
||||
expect(codes).toContain('ger')
|
||||
expect(codes).toContain('arb')
|
||||
expect(codes).toHaveLength(5)
|
||||
})
|
||||
|
||||
it('every usage has a non-empty label', () => {
|
||||
for (const u of NAMEGEN_USAGES) {
|
||||
expect(u.label.length).toBeGreaterThan(0)
|
||||
}
|
||||
})
|
||||
})
|
||||
40
gerbil-manager-web/src/api/names.ts
Normal file
40
gerbil-manager-web/src/api/names.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
/** FEAT-NAMEGEN: Namensvorschläge — Schnittstelle zum Backend /names/suggest. */
|
||||
import { api } from './client'
|
||||
|
||||
export interface NameSuggestion {
|
||||
name: string
|
||||
meaning: string
|
||||
origin: string
|
||||
}
|
||||
|
||||
export const NAMEGEN_USAGES = [
|
||||
{ code: 'norn', label: 'Nordisch' },
|
||||
{ code: 'japa', label: 'Japanisch' },
|
||||
{ code: 'mythg', label: 'Griech. Mythologie' },
|
||||
{ code: 'ger', label: 'Deutsch' },
|
||||
{ code: 'arb', label: 'Arabisch' },
|
||||
] as const
|
||||
|
||||
export type NamegenUsageCode = (typeof NAMEGEN_USAGES)[number]['code']
|
||||
|
||||
export interface SuggestNamesParams {
|
||||
letter?: string
|
||||
gender?: string
|
||||
usages: NamegenUsageCode[]
|
||||
count?: number
|
||||
}
|
||||
|
||||
/** Exported for unit tests — builds the query path without a network call. */
|
||||
export function buildSuggestPath(params: SuggestNamesParams): string {
|
||||
const parts: string[] = []
|
||||
const letter = params.letter?.trim().toUpperCase()
|
||||
if (letter) parts.push(`letter=${encodeURIComponent(letter)}`)
|
||||
if (params.gender && params.gender !== '') parts.push(`gender=${encodeURIComponent(params.gender)}`)
|
||||
if (params.usages.length > 0) parts.push(`usages=${params.usages.join(',')}`)
|
||||
parts.push(`count=${params.count ?? 6}`)
|
||||
return `/names/suggest?${parts.join('&')}`
|
||||
}
|
||||
|
||||
export function suggestNames(params: SuggestNamesParams): Promise<NameSuggestion[]> {
|
||||
return api.get<NameSuggestion[]>(buildSuggestPath(params))
|
||||
}
|
||||
114
gerbil-manager-web/src/components/NameSuggestPanel.css
Normal file
114
gerbil-manager-web/src/components/NameSuggestPanel.css
Normal file
@@ -0,0 +1,114 @@
|
||||
.namegen-name-row {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.namegen-name-row .input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.namegen-panel {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 0.5rem;
|
||||
padding: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
background: var(--color-bg, #fff);
|
||||
}
|
||||
|
||||
.namegen-panel__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.75rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.namegen-panel__filters {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.namegen-panel__letter {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.namegen-panel__letter-input {
|
||||
width: 5rem;
|
||||
}
|
||||
|
||||
.namegen-panel__usages {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.namegen-usages-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem 1rem;
|
||||
}
|
||||
|
||||
.namegen-usages-grid label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.namegen-suggestions {
|
||||
list-style: none;
|
||||
margin: 0.75rem 0 0;
|
||||
padding: 0;
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.namegen-suggestion {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.namegen-suggestion:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.namegen-suggestion__pick {
|
||||
font-weight: 600;
|
||||
color: var(--color-accent, #2563eb);
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
font-size: inherit;
|
||||
font-family: inherit;
|
||||
min-width: 6rem;
|
||||
}
|
||||
|
||||
.namegen-suggestion__pick:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.namegen-suggestion__meaning {
|
||||
flex: 1;
|
||||
font-size: 0.88rem;
|
||||
color: var(--color-text-muted);
|
||||
min-width: 8rem;
|
||||
}
|
||||
|
||||
.namegen-suggestion__origin {
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
132
gerbil-manager-web/src/components/NameSuggestPanel.tsx
Normal file
132
gerbil-manager-web/src/components/NameSuggestPanel.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
import { useState } from 'react'
|
||||
import { de } from '../strings/de'
|
||||
import { ApiError, errorCode } from '../api/client'
|
||||
import { NAMEGEN_USAGES, suggestNames, type NamegenUsageCode, type NameSuggestion } from '../api/names'
|
||||
import './NameSuggestPanel.css'
|
||||
|
||||
interface NameSuggestPanelProps {
|
||||
gender: string
|
||||
onPick: (name: string) => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const ALL_CODES = NAMEGEN_USAGES.map((u) => u.code) as NamegenUsageCode[]
|
||||
|
||||
export default function NameSuggestPanel({ gender, onPick, onClose }: NameSuggestPanelProps) {
|
||||
const t = de.namegen
|
||||
const [letter, setLetter] = useState('')
|
||||
const [usages, setUsages] = useState<Set<NamegenUsageCode>>(new Set(ALL_CODES))
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [keyMissing, setKeyMissing] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [suggestions, setSuggestions] = useState<NameSuggestion[]>([])
|
||||
const [fetched, setFetched] = useState(false)
|
||||
|
||||
function toggleUsage(code: NamegenUsageCode) {
|
||||
setUsages((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(code)) next.delete(code)
|
||||
else next.add(code)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
async function load() {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
setKeyMissing(false)
|
||||
setSuggestions([])
|
||||
try {
|
||||
const result = await suggestNames({
|
||||
letter: letter.trim() || undefined,
|
||||
gender: gender || undefined,
|
||||
usages: [...usages] as NamegenUsageCode[],
|
||||
count: 6,
|
||||
})
|
||||
setSuggestions(result)
|
||||
setFetched(true)
|
||||
} catch (err) {
|
||||
if (errorCode(err) === 'NamesKeyMissing') {
|
||||
setKeyMissing(true)
|
||||
} else {
|
||||
setError(err instanceof ApiError ? err.message : de.api.errors.unknown)
|
||||
}
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="namegen-panel" role="region" aria-label={t.panelTitle}>
|
||||
<div className="namegen-panel__header">
|
||||
<span>{t.panelTitle}</span>
|
||||
<button type="button" className="btn" onClick={onClose} aria-label={t.close}>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="namegen-panel__filters">
|
||||
<div className="namegen-panel__letter">
|
||||
<label htmlFor="namegen-letter">{t.letterLabel}</label>
|
||||
<input
|
||||
id="namegen-letter"
|
||||
className="input namegen-panel__letter-input"
|
||||
value={letter}
|
||||
onChange={(e) => setLetter(e.target.value)}
|
||||
placeholder={t.letterPlaceholder}
|
||||
maxLength={1}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="namegen-panel__usages">
|
||||
<span>{t.usagesLabel}</span>
|
||||
<div className="namegen-usages-grid">
|
||||
{NAMEGEN_USAGES.map(({ code, label }) => (
|
||||
<label key={code}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={usages.has(code)}
|
||||
onChange={() => toggleUsage(code)}
|
||||
/>
|
||||
{label}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--primary"
|
||||
onClick={load}
|
||||
disabled={loading || usages.size === 0}
|
||||
>
|
||||
{loading ? t.loading : t.loadButton}
|
||||
</button>
|
||||
|
||||
{keyMissing && <p className="muted">{t.keyMissing}</p>}
|
||||
{error && <p className="error-text">{error}</p>}
|
||||
{fetched && !loading && !keyMissing && !error && suggestions.length === 0 && (
|
||||
<p className="muted">{t.empty}</p>
|
||||
)}
|
||||
|
||||
{suggestions.length > 0 && (
|
||||
<ul className="namegen-suggestions" aria-label={t.panelTitle}>
|
||||
{suggestions.map((s, i) => (
|
||||
<li key={`${s.name}-${i}`} className="namegen-suggestion">
|
||||
<button
|
||||
type="button"
|
||||
className="namegen-suggestion__pick"
|
||||
onClick={() => onPick(s.name)}
|
||||
>
|
||||
{s.name}
|
||||
</button>
|
||||
<span className="namegen-suggestion__meaning">{s.meaning}</span>
|
||||
<span className="namegen-suggestion__origin">{s.origin}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { useMemo, useState, type ReactNode } from 'react'
|
||||
import { Link, useParams } from 'react-router-dom'
|
||||
import { de } from '../strings/de'
|
||||
import { getGerbil, updateGerbil } from '../api/gerbils'
|
||||
import { listLitters as listLittersPaged } from '../api/litters'
|
||||
import { listColorVarieties, listContacts, listEnclosures, listLitters } from '../api/lookups'
|
||||
import { useApi, useMutation } from '../hooks/useApi'
|
||||
import { formatDate, genderLabel, statusLabel } from '../format/labels'
|
||||
@@ -55,6 +56,10 @@ export default function GerbilDetailPage() {
|
||||
const enclosures = useApi(() => listEnclosures(), [])
|
||||
const contacts = useApi(() => listContacts(), [])
|
||||
const litters = useApi(() => listLitters(), [])
|
||||
const parentLitters = useApi(
|
||||
() => listLittersPaged({ filter: `fatherId=${id}|motherId=${id}`, orderBy: 'date desc', pageSize: 50 }),
|
||||
[id],
|
||||
)
|
||||
|
||||
const colorName = useMemo(
|
||||
() => new Map((colorVarieties.data ?? []).map((c) => [c.id, c.name])),
|
||||
@@ -237,6 +242,32 @@ export default function GerbilDetailPage() {
|
||||
{saveCharacter.error && <span className="error-text">{saveCharacter.error}</span>}
|
||||
</div>
|
||||
|
||||
<h3>{t.detail.parentLittersTitle}</h3>
|
||||
{parentLitters.loading && <p className="muted">{de.common.loading}</p>}
|
||||
{!parentLitters.loading && (parentLitters.data?.items.length ?? 0) === 0 && (
|
||||
<p className="muted">{t.detail.parentLittersEmpty}</p>
|
||||
)}
|
||||
{(parentLitters.data?.items.length ?? 0) > 0 && (
|
||||
<ul className="card-list">
|
||||
{(parentLitters.data?.items ?? []).map((litter) => (
|
||||
<li key={litter.id}>
|
||||
<Link to={`/wuerfe/${litter.id}`} className="gerbil-card">
|
||||
<span className="gerbil-card__name">{litter.name}</span>
|
||||
<span className="badge badge--active">
|
||||
{litter.fatherId === id ? t.detail.parentLittersRoleVater : t.detail.parentLittersRoleMutter}
|
||||
</span>
|
||||
<span className="gerbil-card__meta">{formatDate(litter.date)}</span>
|
||||
{litter.totalBorn !== null && (
|
||||
<span className="gerbil-card__meta">
|
||||
{litter.totalBorn} {de.pages.litters.countLabel}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<h3 className="visually-hidden">{t.detail.moreData}</h3>
|
||||
<div className="tabs" role="tablist">
|
||||
{(['photos', 'health', 'weight'] as const).map((key) => (
|
||||
|
||||
@@ -8,6 +8,8 @@ import { useApi, useMutation } from '../hooks/useApi'
|
||||
import { genderLabel, statusLabel } from '../format/labels'
|
||||
import { fromDisplayString } from '../genetics'
|
||||
import FarbschlagImage from '../components/FarbschlagImage'
|
||||
import NameSuggestPanel from '../components/NameSuggestPanel'
|
||||
import '../components/NameSuggestPanel.css'
|
||||
|
||||
interface FormState {
|
||||
name: string
|
||||
@@ -111,6 +113,7 @@ export default function GerbilFormPage() {
|
||||
const [form, setForm] = useState<FormState>(EMPTY)
|
||||
const [errors, setErrors] = useState<Partial<Record<keyof FormState, string>>>({})
|
||||
const [initializedFor, setInitializedFor] = useState<string | null>(null)
|
||||
const [showNameSuggest, setShowNameSuggest] = useState(false)
|
||||
|
||||
const existing = useApi(() => (id ? getGerbil(id) : Promise.resolve(null)), [id])
|
||||
const colorVarieties = useApi(() => listColorVarieties(), [])
|
||||
@@ -197,16 +200,33 @@ export default function GerbilFormPage() {
|
||||
<h2>{isEdit ? t.form.editTitle : t.form.createTitle}</h2>
|
||||
|
||||
<form className="form" onSubmit={onSubmit} noValidate>
|
||||
<label className="field">
|
||||
<span>{t.fields.name} *</span>
|
||||
<input
|
||||
className="input"
|
||||
value={form.name}
|
||||
onChange={(e) => set('name', e.target.value)}
|
||||
aria-invalid={Boolean(errors.name)}
|
||||
/>
|
||||
<div className="field">
|
||||
<label htmlFor="gerbil-name">{t.fields.name} *</label>
|
||||
<div className="namegen-name-row">
|
||||
<input
|
||||
id="gerbil-name"
|
||||
className="input"
|
||||
value={form.name}
|
||||
onChange={(e) => set('name', e.target.value)}
|
||||
aria-invalid={Boolean(errors.name)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
onClick={() => setShowNameSuggest((v) => !v)}
|
||||
>
|
||||
{de.namegen.button}
|
||||
</button>
|
||||
</div>
|
||||
{errors.name && <small className="error-text">{errors.name}</small>}
|
||||
</label>
|
||||
</div>
|
||||
{showNameSuggest && (
|
||||
<NameSuggestPanel
|
||||
gender={form.gender}
|
||||
onPick={(name) => { set('name', name); setShowNameSuggest(false) }}
|
||||
onClose={() => setShowNameSuggest(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<label className="field">
|
||||
<span>{t.fields.gender} *</span>
|
||||
|
||||
@@ -105,6 +105,11 @@ export const de = {
|
||||
tabPlaceholder: 'Dieser Bereich entsteht in einem späteren Schritt.',
|
||||
notFound: 'Diese Rennmaus wurde nicht gefunden.',
|
||||
moreData: 'Weitere Daten',
|
||||
// ANIMAL-LITTERS: Würfe als Elternteil
|
||||
parentLittersTitle: 'Würfe als Elternteil',
|
||||
parentLittersEmpty: 'Keine Würfe als Elternteil erfasst.',
|
||||
parentLittersRoleVater: 'Vater',
|
||||
parentLittersRoleMutter: 'Mutter',
|
||||
},
|
||||
// Formular (anlegen/bearbeiten)
|
||||
form: {
|
||||
@@ -813,6 +818,19 @@ export const de = {
|
||||
resetButton: 'Filter zurücksetzen',
|
||||
closeButton: 'Schließen',
|
||||
},
|
||||
// ── FEAT-NAMEGEN: Namensvorschläge (KI-gestützt, UC-1 Einzeltier) ──
|
||||
namegen: {
|
||||
button: 'Name vorschlagen',
|
||||
panelTitle: 'Namensvorschläge',
|
||||
letterLabel: 'Anfangsbuchstabe',
|
||||
letterPlaceholder: 'z. B. A',
|
||||
usagesLabel: 'Herkunftskultur',
|
||||
loadButton: 'Vorschläge laden',
|
||||
loading: 'Lade Vorschläge …',
|
||||
empty: 'Keine Vorschläge — andere Einstellungen versuchen.',
|
||||
keyMissing: 'Namensvorschläge benötigen einen API-Key — bitte in den Einstellungen konfigurieren.',
|
||||
close: 'Schließen',
|
||||
},
|
||||
} as const
|
||||
|
||||
export type Strings = typeof de
|
||||
|
||||
@@ -105,6 +105,13 @@
|
||||
"decision": "death date = 17.04.2016 (the '2018' variant was wrong — it had leaked as '/ +2018' into the genotype field; parse-leak already fixed in IMPORT-POLISH)",
|
||||
"dateOfDeath": "17.04.2016",
|
||||
"source": "Julian 2026-06-07 — HUMANQUESTION D6"
|
||||
},
|
||||
{
|
||||
"name": "Kazu von den Kleinen Chaoten",
|
||||
"dob": "23.04.2013",
|
||||
"decision": "E-locus = ee[f], G-locus = GG (one source wrote UwUw = GG in international notation), P-locus = PP — resolves the 3 contested loci",
|
||||
"genotype": "Aa Cc[chm] DD ee[f] GG PP Spsp",
|
||||
"source": "Julian 2026-06-07 — HUMANQUESTION D6"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user