FEAT-5: Probeverpaarung page — free genotype entry, client-side breed(), Farbschlag cards + warnings

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-06 00:06:22 +02:00
parent f7ba1fa70e
commit 5a6326a967
4 changed files with 287 additions and 0 deletions

View File

@@ -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 (
<fieldset className="parent-selector">
<legend>{label}</legend>
{picker}
{value.animalName && (
<p className="muted parent-selector__animal">
{value.animalName}
<button
type="button"
className="link-btn"
onClick={() => onChange({ genotype: '', animalName: null })}
>
{t.clearAnimal}
</button>
</p>
)}
<label className="field">
<span>{t.genotypeLabel}</span>
<input
className="input"
value={value.genotype}
placeholder="Aa CC Dd EE GG Pp Spsp rere"
onChange={(e) => onChange({ genotype: e.target.value, animalName: null })}
aria-invalid={invalid}
/>
{invalid ? (
<small className="error-text">{t.genotypeInvalid}</small>
) : preview ? (
<small className="muted">
{t.genotypePreview}: <strong>{preview}</strong>
</small>
) : (
<small className="muted">{t.genotypeHint}</small>
)}
</label>
</fieldset>
)
}

View File

@@ -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
}
}

View File

@@ -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;
}

View File

@@ -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<string, string>
return map[code] ?? code
}
export default function GenetikPage() {
const t = de.pages.genetik
const [father, setFather] = useState<ParentValue>(EMPTY_PARENT)
const [mother, setMother] = useState<ParentValue>(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 (
<section className="page">
<h2>{t.title}</h2>
<p className="muted">{t.subtitle}</p>
<div className="parents-grid">
<ParentSelector label={t.father} value={father} onChange={setFather} />
<ParentSelector label={t.mother} value={mother} onChange={setMother} />
</div>
{!bothValid && <p className="muted">{t.needBoth}</p>}
{result && (
<div className="results">
{result.warnings.length > 0 && (
<div className="warnings">
<h3>{t.warningsTitle}</h3>
{result.warnings.map((w) => (
<div key={w.code} className="alert alert--warning">
<span>{warningText(w.code)}</span>
</div>
))}
</div>
)}
<h3>{t.resultsTitle}</h3>
{result.byFarbschlag.length === 0 ? (
<p className="muted">{t.noOffspring}</p>
) : (
<>
<h4 className="muted">{t.byFarbschlag}</h4>
<ul className="farbschlag-cards">
{result.byFarbschlag.map((f) => (
<li key={f.farbschlag} className="farbschlag-card">
<span className="farbschlag-card__name">{f.farbschlag}</span>
<span className="farbschlag-card__prob">
{f.probability.percent}
<small> ({f.probability.text})</small>
</span>
</li>
))}
</ul>
<button
type="button"
className="link-btn"
onClick={() => setShowGenotypes((s) => !s)}
aria-expanded={showGenotypes}
>
{showGenotypes ? t.hideGenotypes : t.showGenotypes}
</button>
{showGenotypes && (
<table className="genotype-table">
<thead>
<tr>
<th>{t.genotypeLabel}</th>
<th>{t.genotypePreview}</th>
<th>{t.probability}</th>
</tr>
</thead>
<tbody>
{result.offspring.map((o) => (
<tr key={o.genotype}>
<td>
<code>{o.genotype}</code>
</td>
<td>{o.farbschlag}</td>
<td>
{o.probability.percent} <small>({o.probability.text})</small>
</td>
</tr>
))}
</tbody>
</table>
)}
</>
)}
</div>
)}
</section>
)
}