CHARAKTERBOGEN-2 (E1): Trait-Katalog in 4 Kategorien + 10 neue Traits + Warnsignale
- de.ts: traits-Array -> traitCategories (4 Kategorien: Sozialverhalten / Eignung & Umgang / Hobbys & Eigenarten / Wesen & Temperament); alle 15 bestehenden Keys erhalten (stored auf Live-Tieren); 10 neue Keys additiv; warn:true auf schwer-vergesellschaftbar + territorial. - format/traits.ts: TraitEntry/TraitCategory-Interface, TRAIT_CATEGORIES-Export, ALL_TRAITS via flatMap, neuer isWarnTrait()-Helper. - Charakterbogen.tsx: grouped-Rendering nach Kategorie (section + h4); warn-Chips mit trait-chip--warn-Klasse + trait-warn-badge. - charakterbogen.css: .trait-category__heading + .trait-chip--warn (amber) + .trait-warn-badge. - format/__tests__/traits.test.ts: 14 neue Vitest-Tests (Kategorien, Keys, warn, traitLabel/traitLabels). - Gate: vitest 122/122, tsc clean, build clean, eslint clean.
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
import { de } from '../strings/de'
|
import { de } from '../strings/de'
|
||||||
import { ALL_TRAITS } from '../format/traits'
|
import { ALL_TRAITS, TRAIT_CATEGORIES } from '../format/traits'
|
||||||
import './charakterbogen.css'
|
import './charakterbogen.css'
|
||||||
|
|
||||||
export interface CharakterbogenProps {
|
export interface CharakterbogenProps {
|
||||||
@@ -10,12 +10,6 @@ export interface CharakterbogenProps {
|
|||||||
onNoteChange: (note: string) => void
|
onNoteChange: (note: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* FEAT-14: character sheet — a checkbox grid of traits + a free note.
|
|
||||||
* Controlled & reusable: rendered on the animal detail page (persisted) and in
|
|
||||||
* the Abgabe listing composer (feeds the AI sale-text). German labels from
|
|
||||||
* de.character.traits; the stored value is the trait KEY.
|
|
||||||
*/
|
|
||||||
export default function Charakterbogen({
|
export default function Charakterbogen({
|
||||||
traits,
|
traits,
|
||||||
note,
|
note,
|
||||||
@@ -29,26 +23,32 @@ export default function Charakterbogen({
|
|||||||
const next = new Set(selected)
|
const next = new Set(selected)
|
||||||
if (next.has(key)) next.delete(key)
|
if (next.has(key)) next.delete(key)
|
||||||
else next.add(key)
|
else next.add(key)
|
||||||
// Preserve the vocabulary order for stable output.
|
// Preserve vocabulary (category) order for stable output.
|
||||||
onTraitsChange(ALL_TRAITS.filter((tr) => next.has(tr.key)).map((tr) => tr.key))
|
onTraitsChange(ALL_TRAITS.filter((tr) => next.has(tr.key)).map((tr) => tr.key))
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="charakterbogen">
|
<div className="charakterbogen">
|
||||||
<ul className="trait-grid">
|
{TRAIT_CATEGORIES.map((cat) => (
|
||||||
{ALL_TRAITS.map((tr) => (
|
<section key={cat.category} className="trait-category">
|
||||||
<li key={tr.key}>
|
<h4 className="trait-category__heading">{cat.category}</h4>
|
||||||
<label className="trait-chip">
|
<ul className="trait-grid">
|
||||||
<input
|
{cat.traits.map((tr) => (
|
||||||
type="checkbox"
|
<li key={tr.key}>
|
||||||
checked={selected.has(tr.key)}
|
<label className={`trait-chip${tr.warn ? ' trait-chip--warn' : ''}`}>
|
||||||
onChange={() => toggle(tr.key)}
|
<input
|
||||||
/>
|
type="checkbox"
|
||||||
<span>{tr.label}</span>
|
checked={selected.has(tr.key)}
|
||||||
</label>
|
onChange={() => toggle(tr.key)}
|
||||||
</li>
|
/>
|
||||||
))}
|
<span>{tr.label}</span>
|
||||||
</ul>
|
{tr.warn && <span className="trait-warn-badge">{t.warnLabel}</span>}
|
||||||
|
</label>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>{t.noteLabel}</span>
|
<span>{t.noteLabel}</span>
|
||||||
<textarea
|
<textarea
|
||||||
|
|||||||
@@ -1,8 +1,21 @@
|
|||||||
/* FEAT-14 Charakterbogen — trait checkbox grid (mobile-first). */
|
/* CHARAKTERBOGEN-2 — categorised trait grid with warn-signal styling (mobile-first). */
|
||||||
|
|
||||||
|
.trait-category {
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trait-category__heading {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-muted, #666);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
margin: 0.75rem 0 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
.charakterbogen .trait-grid {
|
.charakterbogen .trait-grid {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
margin: 0 0 0.75rem;
|
margin: 0 0 0.5rem;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
@@ -39,3 +52,16 @@
|
|||||||
min-height: 0;
|
min-height: 0;
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.trait-chip--warn {
|
||||||
|
border-color: #d97706;
|
||||||
|
background: #fff7ed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trait-warn-badge {
|
||||||
|
margin-left: auto;
|
||||||
|
font-size: 0.68rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #d97706;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|||||||
91
gerbil-manager-web/src/format/__tests__/traits.test.ts
Normal file
91
gerbil-manager-web/src/format/__tests__/traits.test.ts
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { ALL_TRAITS, TRAIT_CATEGORIES, traitLabel, traitLabels, isWarnTrait } from '../traits'
|
||||||
|
|
||||||
|
describe('trait catalog', () => {
|
||||||
|
it('has 4 categories', () => {
|
||||||
|
expect(TRAIT_CATEGORIES).toHaveLength(4)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('category names match spec', () => {
|
||||||
|
const names = TRAIT_CATEGORIES.map((c) => c.category)
|
||||||
|
expect(names).toEqual([
|
||||||
|
'Sozialverhalten',
|
||||||
|
'Eignung & Umgang',
|
||||||
|
'Hobbys & Eigenarten',
|
||||||
|
'Wesen & Temperament',
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ALL_TRAITS flattens all categories', () => {
|
||||||
|
const total = TRAIT_CATEGORIES.reduce((sum, c) => sum + c.traits.length, 0)
|
||||||
|
expect(ALL_TRAITS).toHaveLength(total)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('all keys are unique', () => {
|
||||||
|
const keys = ALL_TRAITS.map((t) => t.key)
|
||||||
|
expect(new Set(keys).size).toBe(keys.length)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('existing 15 keys are still present (stored on live animals)', () => {
|
||||||
|
const legacy = [
|
||||||
|
'zutraulich', 'handzahm', 'neugierig', 'aufgeschlossen', 'ruhig', 'lebhaft',
|
||||||
|
'verschmust', 'eigenstaendig', 'anfaengergeeignet', 'futterfreudig',
|
||||||
|
'buddelt', 'klettert', 'laufrad', 'vertraeglich', 'schreckhaft',
|
||||||
|
]
|
||||||
|
const allKeys = new Set(ALL_TRAITS.map((t) => t.key))
|
||||||
|
for (const key of legacy) {
|
||||||
|
expect(allKeys.has(key), `missing legacy key: ${key}`).toBe(true)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('new keys are present', () => {
|
||||||
|
const newKeys = [
|
||||||
|
'dominant', 'rangniedrig', 'sozialkompetent', 'schwer-vergesellschaftbar',
|
||||||
|
'erfahrene-halter', 'beobachtungstier', 'familiengeeignet',
|
||||||
|
'schredder', 'nestbauer', 'territorial',
|
||||||
|
]
|
||||||
|
const allKeys = new Set(ALL_TRAITS.map((t) => t.key))
|
||||||
|
for (const key of newKeys) {
|
||||||
|
expect(allKeys.has(key), `missing new key: ${key}`).toBe(true)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('warn traits are schwer-vergesellschaftbar and territorial', () => {
|
||||||
|
expect(isWarnTrait('schwer-vergesellschaftbar')).toBe(true)
|
||||||
|
expect(isWarnTrait('territorial')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('non-warn traits return false from isWarnTrait', () => {
|
||||||
|
expect(isWarnTrait('zutraulich')).toBe(false)
|
||||||
|
expect(isWarnTrait('dominant')).toBe(false)
|
||||||
|
expect(isWarnTrait('beobachtungstier')).toBe(false)
|
||||||
|
expect(isWarnTrait('schreckhaft')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('unknown key returns false from isWarnTrait', () => {
|
||||||
|
expect(isWarnTrait('not-a-real-key')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('traitLabel returns German label for known key', () => {
|
||||||
|
expect(traitLabel('zutraulich')).toBe('zutraulich')
|
||||||
|
expect(traitLabel('dominant')).toBe('dominant (Leittier)')
|
||||||
|
expect(traitLabel('schwer-vergesellschaftbar')).toBe('schwer vergesellschaftbar')
|
||||||
|
expect(traitLabel('territorial')).toBe('territorial')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('traitLabel returns the key itself for unknown key', () => {
|
||||||
|
expect(traitLabel('not-a-real-key')).toBe('not-a-real-key')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('traitLabels maps a list of keys to labels', () => {
|
||||||
|
expect(traitLabels(['zutraulich', 'dominant'])).toEqual([
|
||||||
|
'zutraulich',
|
||||||
|
'dominant (Leittier)',
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('traitLabels handles null and undefined gracefully', () => {
|
||||||
|
expect(traitLabels(null)).toEqual([])
|
||||||
|
expect(traitLabels(undefined)).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,9 +1,23 @@
|
|||||||
/** FEAT-14: map character trait KEYS (stored) <-> German LABELS (de.character.traits). */
|
/** CHARAKTERBOGEN-2: map character trait KEYS (stored) <-> German LABELS (de.character.traitCategories). */
|
||||||
import { de } from '../strings/de'
|
import { de } from '../strings/de'
|
||||||
|
|
||||||
export const ALL_TRAITS = de.character.traits
|
export interface TraitEntry {
|
||||||
|
key: string
|
||||||
|
label: string
|
||||||
|
warn?: true
|
||||||
|
}
|
||||||
|
|
||||||
const LABEL_BY_KEY = new Map<string, string>(de.character.traits.map((t) => [t.key, t.label]))
|
export interface TraitCategory {
|
||||||
|
category: string
|
||||||
|
traits: readonly TraitEntry[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TRAIT_CATEGORIES: ReadonlyArray<TraitCategory> =
|
||||||
|
de.character.traitCategories as unknown as ReadonlyArray<TraitCategory>
|
||||||
|
|
||||||
|
export const ALL_TRAITS: readonly TraitEntry[] = TRAIT_CATEGORIES.flatMap((c) => c.traits)
|
||||||
|
|
||||||
|
const LABEL_BY_KEY = new Map<string, string>(ALL_TRAITS.map((t) => [t.key, t.label]))
|
||||||
|
|
||||||
export function traitLabel(key: string): string {
|
export function traitLabel(key: string): string {
|
||||||
return LABEL_BY_KEY.get(key) ?? key
|
return LABEL_BY_KEY.get(key) ?? key
|
||||||
@@ -13,3 +27,7 @@ export function traitLabel(key: string): string {
|
|||||||
export function traitLabels(keys: readonly string[] | null | undefined): string[] {
|
export function traitLabels(keys: readonly string[] | null | undefined): string[] {
|
||||||
return (keys ?? []).map(traitLabel)
|
return (keys ?? []).map(traitLabel)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isWarnTrait(key: string): boolean {
|
||||||
|
return ALL_TRAITS.some((t) => t.key === key && t.warn === true)
|
||||||
|
}
|
||||||
|
|||||||
@@ -784,9 +784,9 @@ export const de = {
|
|||||||
},
|
},
|
||||||
unknownFarbschlag: 'Unbekannter Farbschlag',
|
unknownFarbschlag: 'Unbekannter Farbschlag',
|
||||||
},
|
},
|
||||||
// ── FEAT-14 (Kevin): Charakterbogen — Eigenschaften + Notiz, speist den KI-Verkaufstext ──
|
// ── FEAT-14 / CHARAKTERBOGEN-2: Charakterbogen — Eigenschaften + Notiz, speist KI-Verkaufstext ──
|
||||||
// Traits: stabile KEYS (gespeichert) + deutsche LABELS (UI + KI-Prompt).
|
// Traits: stabile KEYS (gespeichert, NIEMALS umbenennen) + deutsche LABELS (UI + KI-Prompt).
|
||||||
// Erweitern = eine Zeile in der Liste; Julian verfeinert die Auswahl.
|
// Neue Traits = neuen Eintrag hinzufügen; warn:true = Warnsignal (visuell abgesetzt).
|
||||||
character: {
|
character: {
|
||||||
sectionTitle: 'Charakter & Eigenschaften',
|
sectionTitle: 'Charakter & Eigenschaften',
|
||||||
noteLabel: 'Notizen zum Charakter',
|
noteLabel: 'Notizen zum Charakter',
|
||||||
@@ -794,22 +794,53 @@ export const de = {
|
|||||||
save: 'Charakter speichern',
|
save: 'Charakter speichern',
|
||||||
saved: 'Charakter gespeichert.',
|
saved: 'Charakter gespeichert.',
|
||||||
none: 'Noch keine Eigenschaften ausgewählt.',
|
none: 'Noch keine Eigenschaften ausgewählt.',
|
||||||
traits: [
|
warnLabel: '⚠ Warnsignal',
|
||||||
{ key: 'zutraulich', label: 'zutraulich' },
|
traitCategories: [
|
||||||
{ key: 'handzahm', label: 'handzahm' },
|
{
|
||||||
{ key: 'neugierig', label: 'neugierig' },
|
category: 'Sozialverhalten',
|
||||||
{ key: 'aufgeschlossen', label: 'aufgeschlossen' },
|
traits: [
|
||||||
{ key: 'ruhig', label: 'ruhig / ausgeglichen' },
|
{ key: 'dominant', label: 'dominant (Leittier)' },
|
||||||
{ key: 'lebhaft', label: 'lebhaft / aktiv' },
|
{ key: 'rangniedrig', label: 'rangniedrig / unterwürfig' },
|
||||||
{ key: 'verschmust', label: 'verschmust' },
|
{ key: 'sozialkompetent', label: 'sozialkompetent / gut sozialisiert' },
|
||||||
{ key: 'eigenstaendig', label: 'eigenständig' },
|
{ key: 'schwer-vergesellschaftbar', label: 'schwer vergesellschaftbar', warn: true },
|
||||||
{ key: 'anfaengergeeignet', label: 'anfängergeeignet' },
|
],
|
||||||
{ key: 'futterfreudig', label: 'futterfreudig' },
|
},
|
||||||
{ key: 'buddelt', label: 'buddelt gern' },
|
{
|
||||||
{ key: 'klettert', label: 'klettert gern' },
|
category: 'Eignung & Umgang',
|
||||||
{ key: 'laufrad', label: 'läuft gern im Laufrad' },
|
traits: [
|
||||||
{ key: 'vertraeglich', label: 'gut verträglich' },
|
{ key: 'anfaengergeeignet', label: 'anfängergeeignet' },
|
||||||
{ key: 'schreckhaft', label: 'schreckhaft' },
|
{ key: 'erfahrene-halter', label: 'für erfahrene Halter' },
|
||||||
|
{ key: 'beobachtungstier', label: 'reines Beobachtungstier' },
|
||||||
|
{ key: 'familiengeeignet', label: 'familiengeeignet / stressresistent' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: 'Hobbys & Eigenarten',
|
||||||
|
traits: [
|
||||||
|
{ key: 'futterfreudig', label: 'futterfreudig' },
|
||||||
|
{ key: 'buddelt', label: 'buddelt gern' },
|
||||||
|
{ key: 'klettert', label: 'klettert gern' },
|
||||||
|
{ key: 'laufrad', label: 'läuft gern im Laufrad' },
|
||||||
|
{ key: 'schredder', label: 'Schredder-Meister' },
|
||||||
|
{ key: 'nestbauer', label: 'Nestbauer / Architekt' },
|
||||||
|
{ key: 'territorial', label: 'territorial', warn: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: 'Wesen & Temperament',
|
||||||
|
traits: [
|
||||||
|
{ key: 'zutraulich', label: 'zutraulich' },
|
||||||
|
{ key: 'handzahm', label: 'handzahm' },
|
||||||
|
{ key: 'neugierig', label: 'neugierig' },
|
||||||
|
{ key: 'aufgeschlossen', label: 'aufgeschlossen' },
|
||||||
|
{ key: 'ruhig', label: 'ruhig / ausgeglichen' },
|
||||||
|
{ key: 'lebhaft', label: 'lebhaft / aktiv' },
|
||||||
|
{ key: 'verschmust', label: 'verschmust' },
|
||||||
|
{ key: 'eigenstaendig', label: 'eigenständig' },
|
||||||
|
{ key: 'vertraeglich', label: 'gut verträglich' },
|
||||||
|
{ key: 'schreckhaft', label: 'schreckhaft' },
|
||||||
|
],
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
// ── UX-MOBILE-1 (Kevin): FilterPanel — einklappbare Filter auf Mobil ──
|
// ── UX-MOBILE-1 (Kevin): FilterPanel — einklappbare Filter auf Mobil ──
|
||||||
|
|||||||
Reference in New Issue
Block a user