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 { ALL_TRAITS } from '../format/traits'
|
||||
import { ALL_TRAITS, TRAIT_CATEGORIES } from '../format/traits'
|
||||
import './charakterbogen.css'
|
||||
|
||||
export interface CharakterbogenProps {
|
||||
@@ -10,12 +10,6 @@ export interface CharakterbogenProps {
|
||||
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({
|
||||
traits,
|
||||
note,
|
||||
@@ -29,26 +23,32 @@ export default function Charakterbogen({
|
||||
const next = new Set(selected)
|
||||
if (next.has(key)) next.delete(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))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="charakterbogen">
|
||||
<ul className="trait-grid">
|
||||
{ALL_TRAITS.map((tr) => (
|
||||
<li key={tr.key}>
|
||||
<label className="trait-chip">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.has(tr.key)}
|
||||
onChange={() => toggle(tr.key)}
|
||||
/>
|
||||
<span>{tr.label}</span>
|
||||
</label>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{TRAIT_CATEGORIES.map((cat) => (
|
||||
<section key={cat.category} className="trait-category">
|
||||
<h4 className="trait-category__heading">{cat.category}</h4>
|
||||
<ul className="trait-grid">
|
||||
{cat.traits.map((tr) => (
|
||||
<li key={tr.key}>
|
||||
<label className={`trait-chip${tr.warn ? ' trait-chip--warn' : ''}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.has(tr.key)}
|
||||
onChange={() => toggle(tr.key)}
|
||||
/>
|
||||
<span>{tr.label}</span>
|
||||
{tr.warn && <span className="trait-warn-badge">{t.warnLabel}</span>}
|
||||
</label>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
))}
|
||||
<label className="field">
|
||||
<span>{t.noteLabel}</span>
|
||||
<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 {
|
||||
list-style: none;
|
||||
margin: 0 0 0.75rem;
|
||||
margin: 0 0 0.5rem;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
@@ -39,3 +52,16 @@
|
||||
min-height: 0;
|
||||
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'
|
||||
|
||||
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 {
|
||||
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[] {
|
||||
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',
|
||||
},
|
||||
// ── FEAT-14 (Kevin): Charakterbogen — Eigenschaften + Notiz, speist den KI-Verkaufstext ──
|
||||
// Traits: stabile KEYS (gespeichert) + deutsche LABELS (UI + KI-Prompt).
|
||||
// Erweitern = eine Zeile in der Liste; Julian verfeinert die Auswahl.
|
||||
// ── FEAT-14 / CHARAKTERBOGEN-2: Charakterbogen — Eigenschaften + Notiz, speist KI-Verkaufstext ──
|
||||
// Traits: stabile KEYS (gespeichert, NIEMALS umbenennen) + deutsche LABELS (UI + KI-Prompt).
|
||||
// Neue Traits = neuen Eintrag hinzufügen; warn:true = Warnsignal (visuell abgesetzt).
|
||||
character: {
|
||||
sectionTitle: 'Charakter & Eigenschaften',
|
||||
noteLabel: 'Notizen zum Charakter',
|
||||
@@ -794,22 +794,53 @@ export const de = {
|
||||
save: 'Charakter speichern',
|
||||
saved: 'Charakter gespeichert.',
|
||||
none: 'Noch keine Eigenschaften ausgewählt.',
|
||||
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: 'anfaengergeeignet', label: 'anfängergeeignet' },
|
||||
{ key: 'futterfreudig', label: 'futterfreudig' },
|
||||
{ key: 'buddelt', label: 'buddelt gern' },
|
||||
{ key: 'klettert', label: 'klettert gern' },
|
||||
{ key: 'laufrad', label: 'läuft gern im Laufrad' },
|
||||
{ key: 'vertraeglich', label: 'gut verträglich' },
|
||||
{ key: 'schreckhaft', label: 'schreckhaft' },
|
||||
warnLabel: '⚠ Warnsignal',
|
||||
traitCategories: [
|
||||
{
|
||||
category: 'Sozialverhalten',
|
||||
traits: [
|
||||
{ key: 'dominant', label: 'dominant (Leittier)' },
|
||||
{ key: 'rangniedrig', label: 'rangniedrig / unterwürfig' },
|
||||
{ key: 'sozialkompetent', label: 'sozialkompetent / gut sozialisiert' },
|
||||
{ key: 'schwer-vergesellschaftbar', label: 'schwer vergesellschaftbar', warn: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
category: 'Eignung & Umgang',
|
||||
traits: [
|
||||
{ key: 'anfaengergeeignet', label: 'anfängergeeignet' },
|
||||
{ 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 ──
|
||||
|
||||
Reference in New Issue
Block a user