- getAchievements() im API-Client - unlocked_achievements aus saveProgress als Overlay-Typ 'achievement' - ERFOLGE-Sektion im Profil (freigeschaltet/gesperrt), degradiert lautlos ohne Endpoint Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
385 lines
17 KiB
JavaScript
385 lines
17 KiB
JavaScript
import { useEffect, useState, useMemo } from 'react'
|
||
import './Profil.css'
|
||
import { useAuth } from '../context/AuthContext'
|
||
import { getProfilData, getStats, getLanguageOptions, langById, getAchievements } 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']
|
||
|
||
function LogoutButton() {
|
||
const { logout } = useAuth()
|
||
return (
|
||
<button onClick={logout} title="Abmelden" className="profil-logout">
|
||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/>
|
||
<polyline points="16 17 21 12 16 7"/>
|
||
<line x1="21" y1="12" x2="9" y2="12"/>
|
||
</svg>
|
||
</button>
|
||
)
|
||
}
|
||
|
||
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
|
||
const angle = (i) => (Math.PI * 2 * i) / n - Math.PI / 2
|
||
const point = (i, ratio) => ({ x: cx + r * ratio * Math.cos(angle(i)), y: cy + r * ratio * Math.sin(angle(i)) })
|
||
const gridPoly = (ratio) => skills.map((_, i) => `${point(i, ratio).x},${point(i, ratio).y}`).join(' ')
|
||
const dataPoly = skills.map((s, i) => `${point(i, animate ? s.value : 0).x},${point(i, animate ? s.value : 0).y}`).join(' ')
|
||
const labelAnchor = (i) => { const x = Math.cos(angle(i)); return x > 0.1 ? 'start' : x < -0.1 ? 'end' : 'middle' }
|
||
const labelOffset = (i) => { const y = Math.sin(angle(i)); return y > 0.1 ? 10 : y < -0.1 ? -4 : 4 }
|
||
|
||
return (
|
||
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} overflow="visible">
|
||
{[1, 0.8, 0.6, 0.4].map((lvl, idx) => (
|
||
<polygon key={lvl} points={gridPoly(lvl)} fill="none" stroke="#D4B896" strokeWidth={idx === 0 ? 1 : 0.7} />
|
||
))}
|
||
{skills.map((_, i) => {
|
||
const p = point(i, 1)
|
||
return <line key={i} x1={cx} y1={cy} x2={p.x} y2={p.y} stroke="#D4B896" strokeWidth="0.7" />
|
||
})}
|
||
<polygon points={dataPoly} fill="#C4A85A" fillOpacity="0.4" stroke="#7A5C3A" strokeWidth="1.5" strokeLinejoin="round"
|
||
style={{ transition: animate ? 'all 0.7s ease-out' : 'none' }} />
|
||
{skills.map((s, i) => {
|
||
const p = point(i, animate ? s.value : 0)
|
||
return <circle key={i} cx={p.x} cy={p.y} r="4" fill="#7A5C3A"
|
||
style={{ transition: animate ? `all 0.7s ease-out ${i * 0.06}s` : 'none' }} />
|
||
})}
|
||
{skills.map((s, i) => {
|
||
const p = point(i, 1.3)
|
||
return (
|
||
<text key={i} x={p.x} y={p.y + labelOffset(i)} textAnchor={labelAnchor(i)} dominantBaseline="middle"
|
||
fontSize="11" fontWeight="700" fill="#4A3728" fontFamily="var(--font-ui)">{s.label}</text>
|
||
)
|
||
})}
|
||
</svg>
|
||
)
|
||
}
|
||
|
||
/* ── Streak-Heatmap (letzte 12 Wochen) ───────────────────────── */
|
||
function StreakHeatmap({ daily }) {
|
||
const byDate = new Map(daily.map(d => [d.date, d.ep]))
|
||
const WEEKS = 12, DAYS = WEEKS * 7
|
||
const today = new Date()
|
||
// Start so, dass die letzte Spalte mit heute endet; auf Wochenraster (Mo–So) ausrichten
|
||
const cells = []
|
||
for (let i = DAYS - 1; i >= 0; i--) {
|
||
const d = new Date(today)
|
||
d.setDate(today.getDate() - i)
|
||
const key = d.toISOString().slice(0, 10)
|
||
const ep = byDate.get(key) || 0
|
||
const level = ep === 0 ? 0 : ep < 10 ? 1 : ep < 25 ? 2 : ep < 50 ? 3 : 4
|
||
cells.push({ key, ep, level })
|
||
}
|
||
// In Spalten zu je 7 Tagen gruppieren
|
||
const cols = []
|
||
for (let c = 0; c < WEEKS; c++) cols.push(cells.slice(c * 7, c * 7 + 7))
|
||
|
||
return (
|
||
<div className="heatmap">
|
||
{cols.map((col, ci) => (
|
||
<div key={ci} className="heatmap-col">
|
||
{col.map((cell) => (
|
||
<span key={cell.key} className={`heatmap-cell lvl-${cell.level}`}
|
||
title={`${cell.key}: ${cell.ep} EP`} />
|
||
))}
|
||
</div>
|
||
))}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
/* ── Wochen-Graph (letzte 7 Tage) ────────────────────────────── */
|
||
function WeekBars({ daily, goal }) {
|
||
const byDate = new Map(daily.map(d => [d.date, d.ep]))
|
||
const today = new Date()
|
||
const days = []
|
||
const NAMES = ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa']
|
||
for (let i = 6; i >= 0; i--) {
|
||
const d = new Date(today)
|
||
d.setDate(today.getDate() - i)
|
||
const key = d.toISOString().slice(0, 10)
|
||
days.push({ key, ep: byDate.get(key) || 0, name: NAMES[d.getDay()], isToday: i === 0 })
|
||
}
|
||
const max = Math.max(goal || 0, ...days.map(d => d.ep), 1)
|
||
return (
|
||
<div className="weekbars">
|
||
{days.map((d) => (
|
||
<div key={d.key} className="weekbar-col">
|
||
<div className="weekbar-track">
|
||
<div className={`weekbar-fill ${d.ep > 0 ? '' : 'empty'} ${d.isToday ? 'today' : ''}`}
|
||
style={{ height: `${Math.round((d.ep / max) * 100)}%` }} />
|
||
</div>
|
||
<span className={`weekbar-label ${d.isToday ? 'today' : ''}`}>{d.name}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
/* ── Main ────────────────────────────────────────────────────── */
|
||
export default function Profil() {
|
||
const { user, token } = useAuth()
|
||
const [radarReady, setRadarReady] = useState(false)
|
||
const [profil, setProfil] = useState(null)
|
||
const [stats, setStats] = useState(null)
|
||
const [langs, setLangs] = useState([])
|
||
const [achievements, setAchievements] = useState([])
|
||
|
||
useEffect(() => {
|
||
const t = setTimeout(() => setRadarReady(true), 120)
|
||
return () => clearTimeout(t)
|
||
}, [])
|
||
|
||
useEffect(() => {
|
||
async function load() {
|
||
try {
|
||
const [p, langs] = await Promise.all([getProfilData(token), getLanguageOptions()])
|
||
setProfil(p); setLangs(langs)
|
||
} catch { /* Fallback unten */ }
|
||
// Stats getrennt – degradiert lautlos, falls /auth/stats noch nicht deployed ist
|
||
try { setStats(await getStats(token)) } catch { /* kein Tracking verfügbar */ }
|
||
// Erfolge – degradiert lautlos, falls /auth/achievements noch nicht deployed ist
|
||
try { setAchievements(await getAchievements(token)) } catch { /* keine Erfolge verfügbar */ }
|
||
}
|
||
load()
|
||
}, [token])
|
||
|
||
const displayName = profil?.username || user?.username || '…'
|
||
const initials = displayName.slice(0, 2).toUpperCase()
|
||
const greeting = profil?.language_target_greeting || 'Hallo'
|
||
const points = profil?.total_ep ?? user?.total_ep ?? 0
|
||
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')
|
||
const streak = profil?.streak_days ?? user?.streak_days ?? 0
|
||
|
||
const today = stats?.today
|
||
const goal = today?.daily_goal_ep || 30
|
||
const todayEp = today?.ep || 0
|
||
const goalPct = Math.min(todayEp / goal, 1)
|
||
const daily = stats?.daily || []
|
||
const totals = stats?.totals
|
||
const skills = stats?.skills || []
|
||
const hasSkillData = skills.some(s => s.seen > 0)
|
||
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 ── */}
|
||
<div className="profil-header">
|
||
<div className="avatar-wrap">
|
||
<div className="avatar-ring">
|
||
<div className="avatar-inner"><div className="avatar">{initials}</div></div>
|
||
</div>
|
||
<div className="avatar-level-badge">
|
||
<svg viewBox="0 0 48 54" width="28" height="32">
|
||
<defs>
|
||
<linearGradient id="hexGold2" x1="0%" y1="0%" x2="100%" y2="100%">
|
||
<stop offset="0%" stopColor="#C4A882" /><stop offset="50%" stopColor="#7A5C3A" /><stop offset="100%" stopColor="#4A3728" />
|
||
</linearGradient>
|
||
</defs>
|
||
<polygon points="24,2 46,14 46,40 24,52 2,40 2,14" fill="url(#hexGold2)" stroke="rgba(255,220,100,0.2)" strokeWidth="1" />
|
||
<text x="24" y="30" textAnchor="middle" dominantBaseline="middle" fontSize="16" fontWeight="800" fill="#F5EFE6" fontFamily="inherit">{level}</text>
|
||
</svg>
|
||
</div>
|
||
</div>
|
||
<div className="profil-info">
|
||
<h2 className="profil-name">{greeting}, {displayName}</h2>
|
||
<p className="profil-learning">lernt {langLabel}</p>
|
||
{streak > 0 && (
|
||
<p className="profil-streak">🔥 {streak} Tag{streak !== 1 ? 'e' : ''} Streak</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* ── Tagesziel ── */}
|
||
<div className="card goal-card">
|
||
<ProgressRing value={goalPct} size={72} stroke={8}
|
||
color={goalPct >= 1 ? 'var(--success)' : 'var(--gold)'}>
|
||
<span className="goal-ring-label">{Math.round(goalPct * 100)}%</span>
|
||
</ProgressRing>
|
||
<div className="goal-text">
|
||
<p className="card-title">TAGESZIEL</p>
|
||
<p className="goal-value">{todayEp} <small>/ {goal} EP heute</small></p>
|
||
<p className="goal-hint">
|
||
{goalPct >= 1 ? 'Geschafft – stark! 🎉' : `Noch ${goal - todayEp} EP bis zum Tagesziel`}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
{/* ── 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="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-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>
|
||
)}
|
||
|
||
{/* ── Kategorien (Punkte je Thema) ── */}
|
||
{stats && (
|
||
<div className="card">
|
||
<p className="card-title">KATEGORIEN</p>
|
||
{categories.length ? (
|
||
<div className="cat-list">
|
||
{categories.map((c, i) => {
|
||
const color = CAT_COLORS[i % CAT_COLORS.length]
|
||
return (
|
||
<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 className="cat-tier"> · {categoryTier(c.points).label}</span>
|
||
</span>
|
||
<span className="cat-points">{c.points} P</span>
|
||
</div>
|
||
<div className="cat-bar">
|
||
<div className="cat-bar-fill"
|
||
style={{ width: `${Math.round((c.points / maxCatPoints) * 100)}%`, background: color }} />
|
||
</div>
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
) : (
|
||
<p className="skills-empty">Sammle Punkte — deine Themen erscheinen hier.</p>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* ── Erfolge ── */}
|
||
{achievements.length > 0 && (
|
||
<div className="card">
|
||
<p className="card-title">ERFOLGE · {achievements.filter(a => a.unlocked).length}/{achievements.length}</p>
|
||
<div className="ach-grid">
|
||
{achievements.map(a => (
|
||
<div key={a.key} className={`ach-tile ${a.unlocked ? 'on' : 'off'}`} title={a.label}>
|
||
<span className="ach-icon">{a.unlocked ? a.icon : '🔒'}</span>
|
||
<span className="ach-label">{a.label}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* ── Streak-Kalender ── */}
|
||
{stats && (
|
||
<div className="card">
|
||
<p className="card-title">AKTIVITÄT · 12 WOCHEN</p>
|
||
<StreakHeatmap daily={daily} />
|
||
<div className="heatmap-legend">
|
||
<span>weniger</span>
|
||
<span className="heatmap-cell lvl-0" /><span className="heatmap-cell lvl-1" />
|
||
<span className="heatmap-cell lvl-2" /><span className="heatmap-cell lvl-3" />
|
||
<span className="heatmap-cell lvl-4" />
|
||
<span>mehr</span>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* ── Eckdaten ── */}
|
||
{totals && (
|
||
<div className="stat-grid">
|
||
<div className="stat-tile">
|
||
<span className="stat-num">{totals.pairs_practiced}</span>
|
||
<span className="stat-cap">Karten geübt</span>
|
||
</div>
|
||
<div className="stat-tile">
|
||
<span className="stat-num">{accuracyPct}%</span>
|
||
<span className="stat-cap">Genauigkeit</span>
|
||
</div>
|
||
<div className="stat-tile">
|
||
<span className="stat-num">{streak}</span>
|
||
<span className="stat-cap">Tage Streak</span>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* ── Skills ── */}
|
||
<div className="card">
|
||
<p className="card-title">FÄHIGKEITEN</p>
|
||
{hasSkillData ? (
|
||
<div className="radar-wrap"><RadarChart skills={skills} animate={radarReady} /></div>
|
||
) : (
|
||
<p className="skills-empty">Leg los — deine Stärken erscheinen, sobald du Karten löst.</p>
|
||
)}
|
||
</div>
|
||
|
||
{!stats && (
|
||
<p className="tracking-hint">Dein Lernverlauf wird ab jetzt aufgezeichnet — komm morgen wieder! 🌱</p>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|