feat: Fortschritt spürbar machen – Momente, Momentum & Storytelling

- +EP-Float am Button + hochzählendes EP-Badge (EpFloat, useCountUp)
- Level-/Streak-/Tagesziel-Overlay (MilestoneOverlay), getriggert aus der saveProgress-Response
- Combo-Zähler + variables Lob, ermutigendes Fehler-Feedback statt stillem Verschwinden
- Session-Summary mit Story-Zeilen statt End-Sackgasse
- Profil führt mit %-bis-Level + Capability-Satz; Kategorie-Stufen, Wochenvergleich, Sound-Toggle
- Level-Kurve gespiegelt (utils/leveling.js); Level deploy-unabhängig aus EP abgeleitet

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-17 21:43:56 +02:00
parent 9e8af27d51
commit 039d2cbbf4
14 changed files with 665 additions and 36 deletions

View File

@@ -1,8 +1,11 @@
import { useEffect, useState } from 'react'
import { useEffect, useState, useMemo } from 'react'
import './Profil.css'
import { useAuth } from '../context/AuthContext'
import { getProfilData, getStats, getLanguageOptions, langById } from '../api/directus'
import ProgressRing from '../components/ProgressRing'
import { levelInfo } from '../utils/leveling'
import { categoryTier, capabilitySentence } from '../utils/praise'
import { isMuted, setMuted } from '../utils/sound'
// Erdige Palette für die Kategorie-Dots/Balken (harmoniert mit dem Profil-Theme)
const CAT_COLORS = ['#C4A85A', '#7A5C3A', '#3D7055', '#B5732E', '#5B7DB1', '#9C5A8A']
@@ -20,6 +23,24 @@ function LogoutButton() {
)
}
function SoundToggle() {
const [muted, setM] = useState(isMuted())
const toggle = () => { const next = !muted; setMuted(next); setM(next) }
return (
<button onClick={toggle} title={muted ? 'Töne an' : 'Töne aus'} className="profil-sound">
{muted ? (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><line x1="23" y1="9" x2="17" y2="15"/><line x1="17" y1="9" x2="23" y2="15"/>
</svg>
) : (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><path d="M15.54 8.46a5 5 0 0 1 0 7.07"/><path d="M19.07 4.93a10 10 0 0 1 0 14.14"/>
</svg>
)}
</button>
)
}
/* ── Radar Chart (echte Skill-Daten) ─────────────────────────── */
function RadarChart({ skills, animate }) {
const size = 220, cx = 110, cy = 105, r = 70, n = skills.length
@@ -147,9 +168,14 @@ export default function Profil() {
const initials = displayName.slice(0, 2).toUpperCase()
const greeting = profil?.language_target_greeting || 'Hallo'
const points = profil?.total_ep ?? user?.total_ep ?? 0
const level = profil?.level ?? Math.floor(points / 500)
const epIntoLevel = points - level * 500
const epPerLevel = 500
const li = levelInfo(points)
// Level + Progress immer als Set aus EINER Quelle (sonst „Level 0 / 33 % bis Level 1"-
// Mischmasch, solange das Backend die neue Kurve noch nicht deployed hat).
const hasApiLevel = profil?.ep_to_next_level != null
const level = hasApiLevel ? profil.level : li.level
const epIntoLevel = hasApiLevel ? profil.ep_into_level : li.epIntoLevel
const epToNext = hasApiLevel ? profil.ep_to_next_level : li.epToNextLevel
const epPerLevel = Math.max(1, epIntoLevel + epToNext)
const xpPct = Math.min((epIntoLevel / epPerLevel) * 100, 100)
const toLang = profil?.language_target_id ? langById(profil.language_target_id, langs) : null
const langLabel = toLang ? `${toLang.flag} ${toLang.label}` : (profil?.language_target_titel || 'Zielsprache')
@@ -166,9 +192,25 @@ export default function Profil() {
const accuracyPct = totals ? Math.round((totals.accuracy || 0) * 100) : null
const categories = stats?.categories || []
const maxCatPoints = Math.max(1, ...categories.map(c => c.points))
const capability = capabilitySentence(categories)
// Wochenvergleich (soziale/zeitliche Validierung) aus dem Tagesverlauf.
const weekCompare = useMemo(() => {
const startOfToday = new Date(); startOfToday.setHours(0, 0, 0, 0)
let thisW = 0, lastW = 0
for (const d of daily) {
const dt = new Date(d.date); dt.setHours(0, 0, 0, 0)
const diff = Math.round((startOfToday - dt) / 86400000)
if (diff >= 0 && diff < 7) thisW += d.ep || 0
else if (diff >= 7 && diff < 14) lastW += d.ep || 0
}
const delta = lastW > 0 ? Math.round(((thisW - lastW) / lastW) * 100) : null
return { thisW, lastW, delta }
}, [daily])
return (
<div className="profil page-enter">
<SoundToggle />
<LogoutButton />
{/* ── Header ── */}
@@ -213,24 +255,30 @@ export default function Profil() {
</div>
</div>
{/* ── Fortschritt (Level/EP) ── */}
{/* ── Fortschritt (Level/EP) führt mit Momentum statt nackter Zahl ── */}
<div className="card">
<p className="card-title">DEIN FORTSCHRITT</p>
<div className="xp-row">
<span className="lang-label">{langLabel}</span>
<span className="xp-value">{points.toLocaleString('de')} EP gesamt</span>
<span className="level-pill">Level {level}</span>
<span className="xp-value">{Math.round(xpPct)} % bis Level {level + 1}</span>
</div>
<div className="xp-bar"><div className="xp-fill" style={{ width: `${xpPct}%` }} /></div>
<div className="level-row">
<span className="level-pill">Level {level}</span>
<span className="level-hint">{(epPerLevel - epIntoLevel).toLocaleString('de')} EP bis Level {level + 1}</span>
<span className="level-hint">noch {epToNext.toLocaleString('de')} EP</span>
<span className="level-hint">{points.toLocaleString('de')} EP gesamt · {langLabel}</span>
</div>
{capability && <p className="capability-line">{capability}</p>}
</div>
{/* ── Wochen-Aktivität ── */}
{stats && (
<div className="card">
<p className="card-title">DIESE WOCHE</p>
{weekCompare.delta != null && (
<p className={`week-compare ${weekCompare.delta >= 0 ? 'up' : 'down'}`}>
{weekCompare.thisW} EP · {weekCompare.delta >= 0 ? '▲' : '▼'} {Math.abs(weekCompare.delta)} % {weekCompare.delta >= 0 ? 'mehr' : 'weniger'} als letzte Woche
</p>
)}
<WeekBars daily={daily} goal={goal} />
</div>
)}
@@ -247,7 +295,10 @@ export default function Profil() {
<div key={c.id} className="cat-row">
<div className="cat-head">
<span className="cat-dot" style={{ background: color }} />
<span className="cat-label">{c.label || 'Allgemein'}</span>
<span className="cat-label">
{c.label || 'Allgemein'}
<span className="cat-tier"> · {categoryTier(c.points).label}</span>
</span>
<span className="cat-points">{c.points} P</span>
</div>
<div className="cat-bar">