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:
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user