From 5a6326a967ba0491003f0df8ceabf90e1910550c Mon Sep 17 00:00:00 2001 From: Gulum Date: Sat, 6 Jun 2026 00:06:22 +0200 Subject: [PATCH] =?UTF-8?q?FEAT-5:=20Probeverpaarung=20page=20=E2=80=94=20?= =?UTF-8?q?free=20genotype=20entry,=20client-side=20breed(),=20Farbschlag?= =?UTF-8?q?=20cards=20+=20warnings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/components/ParentSelector.tsx | 62 +++++++++++ gerbil-manager-web/src/format/genotypeText.ts | 22 ++++ gerbil-manager-web/src/index.css | 103 ++++++++++++++++++ gerbil-manager-web/src/pages/GenetikPage.tsx | 100 +++++++++++++++++ 4 files changed, 287 insertions(+) create mode 100644 gerbil-manager-web/src/components/ParentSelector.tsx create mode 100644 gerbil-manager-web/src/format/genotypeText.ts diff --git a/gerbil-manager-web/src/components/ParentSelector.tsx b/gerbil-manager-web/src/components/ParentSelector.tsx new file mode 100644 index 0000000..affb38a --- /dev/null +++ b/gerbil-manager-web/src/components/ParentSelector.tsx @@ -0,0 +1,62 @@ +import type { ReactNode } from 'react' +import { isValidGenotype, previewFarbschlag } from '../format/genotypeText' +import { de } from '../strings/de' + +export interface ParentValue { + /** Free-text genotype (GEN-1 compact string; '?' wildcards allowed). */ + genotype: string + /** Name of a picked animal, if the genotype came from one (display only). */ + animalName?: string | null +} + +export interface ParentSelectorProps { + label: string + value: ParentValue + onChange: (value: ParentValue) => void + /** Optional animal-picker UI (FEAT-5 increment 3) rendered above the input. */ + picker?: ReactNode +} + +export default function ParentSelector({ label, value, onChange, picker }: ParentSelectorProps) { + const t = de.pages.genetik + const preview = previewFarbschlag(value.genotype) + const invalid = value.genotype.trim() !== '' && !isValidGenotype(value.genotype) + + return ( +
+ {label} + {picker} + {value.animalName && ( +

+ {value.animalName} + +

+ )} + +
+ ) +} diff --git a/gerbil-manager-web/src/format/genotypeText.ts b/gerbil-manager-web/src/format/genotypeText.ts new file mode 100644 index 0000000..cf59a03 --- /dev/null +++ b/gerbil-manager-web/src/format/genotypeText.ts @@ -0,0 +1,22 @@ +/** Helpers for free-text genotype input (parse/validate/preview via GEN-1). */ +import { fromDisplayString, genotypeToFarbschlag } from '../genetics' + +export function isValidGenotype(genotype: string): boolean { + if (!genotype.trim()) return false + try { + fromDisplayString(genotype) + return true + } catch { + return false + } +} + +/** Resolved Farbschlag for a genotype string, or null if empty/invalid. */ +export function previewFarbschlag(genotype: string): string | null { + if (!genotype.trim()) return null + try { + return genotypeToFarbschlag(fromDisplayString(genotype)) + } catch { + return null + } +} diff --git a/gerbil-manager-web/src/index.css b/gerbil-manager-web/src/index.css index 5d00c61..0836633 100644 --- a/gerbil-manager-web/src/index.css +++ b/gerbil-manager-web/src/index.css @@ -457,3 +457,106 @@ textarea { gap: 0.75rem; margin-top: 0.5rem; } + +/* ── Genetik / Probeverpaarung (FEAT-5) ──────────────────────── */ + +.parents-grid { + display: grid; + grid-template-columns: 1fr; + gap: 1rem; + margin: 1rem 0; +} + +@media (min-width: 768px) { + .parents-grid { + grid-template-columns: 1fr 1fr; + } +} + +.parent-selector { + border: 1px solid var(--color-border); + border-radius: 0.6rem; + padding: 0.75rem 1rem 1rem; + margin: 0; +} + +.parent-selector legend { + font-weight: 600; + padding: 0 0.4rem; +} + +.parent-selector__animal { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; +} + +.link-btn { + background: none; + border: none; + color: var(--color-accent); + cursor: pointer; + font: inherit; + padding: 0.25rem 0; + text-decoration: underline; +} + +.alert--warning { + background: #fdf2dc; + border: 1px solid #e6c878; + color: #7a5a14; +} + +.results { + margin-top: 1rem; +} + +.farbschlag-cards { + list-style: none; + margin: 0.5rem 0 1rem; + padding: 0; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(13rem, 1fr)); + gap: 0.5rem; +} + +.farbschlag-card { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + padding: 0.6rem 0.9rem; + border: 1px solid var(--color-border); + border-radius: 0.6rem; + background: var(--color-surface); +} + +.farbschlag-card__name { + font-weight: 600; +} + +.farbschlag-card__prob { + color: var(--color-accent); + white-space: nowrap; +} + +.genotype-table { + width: 100%; + border-collapse: collapse; + margin-top: 0.75rem; + font-size: 0.9rem; +} + +.genotype-table th, +.genotype-table td { + text-align: left; + padding: 0.4rem 0.5rem; + border-bottom: 1px solid var(--color-border); +} + +.genotype-table code { + background: var(--color-accent-soft); + padding: 0.1rem 0.35rem; + border-radius: 0.3rem; +} diff --git a/gerbil-manager-web/src/pages/GenetikPage.tsx b/gerbil-manager-web/src/pages/GenetikPage.tsx index 2fd586f..9240f8d 100644 --- a/gerbil-manager-web/src/pages/GenetikPage.tsx +++ b/gerbil-manager-web/src/pages/GenetikPage.tsx @@ -1,11 +1,111 @@ +import { useMemo, useState } from 'react' import { de } from '../strings/de' +import { breed, fromDisplayString, type BreedingResult } from '../genetics' +import ParentSelector, { type ParentValue } from '../components/ParentSelector' +import { isValidGenotype } from '../format/genotypeText' + +const EMPTY_PARENT: ParentValue = { genotype: '', animalName: null } + +function warningText(code: string): string { + const map = de.genetics.warnings as Record + return map[code] ?? code +} export default function GenetikPage() { const t = de.pages.genetik + const [father, setFather] = useState(EMPTY_PARENT) + const [mother, setMother] = useState(EMPTY_PARENT) + const [showGenotypes, setShowGenotypes] = useState(false) + + const bothValid = isValidGenotype(father.genotype) && isValidGenotype(mother.genotype) + + const result: BreedingResult | null = useMemo(() => { + if (!bothValid) return null + // breed() is pure & client-side; ParentSelector already validated both, so + // fromDisplayString won't throw. + return breed(fromDisplayString(father.genotype), fromDisplayString(mother.genotype)) + }, [bothValid, father.genotype, mother.genotype]) + return (

{t.title}

{t.subtitle}

+ +
+ + +
+ + {!bothValid &&

{t.needBoth}

} + + {result && ( +
+ {result.warnings.length > 0 && ( +
+

{t.warningsTitle}

+ {result.warnings.map((w) => ( +
+ {warningText(w.code)} +
+ ))} +
+ )} + +

{t.resultsTitle}

+ {result.byFarbschlag.length === 0 ? ( +

{t.noOffspring}

+ ) : ( + <> +

{t.byFarbschlag}

+
    + {result.byFarbschlag.map((f) => ( +
  • + {f.farbschlag} + + {f.probability.percent} + ({f.probability.text}) + +
  • + ))} +
+ + + + {showGenotypes && ( + + + + + + + + + + {result.offspring.map((o) => ( + + + + + + ))} + +
{t.genotypeLabel}{t.genotypePreview}{t.probability}
+ {o.genotype} + {o.farbschlag} + {o.probability.percent} ({o.probability.text}) +
+ )} + + )} +
+ )}
) }