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:
62
gerbil-manager-web/src/components/ParentSelector.tsx
Normal file
62
gerbil-manager-web/src/components/ParentSelector.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
22
gerbil-manager-web/src/format/genotypeText.ts
Normal file
22
gerbil-manager-web/src/format/genotypeText.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user