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 ( ) } function SoundToggle() { const [muted, setM] = useState(isMuted()) const toggle = () => { const next = !muted; setMuted(next); setM(next) } return ( ) } /* ── 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 ( {[1, 0.8, 0.6, 0.4].map((lvl, idx) => ( ))} {skills.map((_, i) => { const p = point(i, 1) return })} {skills.map((s, i) => { const p = point(i, animate ? s.value : 0) return })} {skills.map((s, i) => { const p = point(i, 1.3) return ( {s.label} ) })} ) } /* ── 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 (
{cols.map((col, ci) => (
{col.map((cell) => ( ))}
))}
) } /* ── 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 (
{days.map((d) => (
0 ? '' : 'empty'} ${d.isToday ? 'today' : ''}`} style={{ height: `${Math.round((d.ep / max) * 100)}%` }} />
{d.name}
))}
) } /* ── 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 (
{/* ── Header ── */}
{initials}
{level}

{greeting}, {displayName}

lernt {langLabel}

{streak > 0 && (

🔥 {streak} Tag{streak !== 1 ? 'e' : ''} Streak

)}
{/* ── Tagesziel ── */}
= 1 ? 'var(--success)' : 'var(--gold)'}> {Math.round(goalPct * 100)}%

TAGESZIEL

{todayEp} / {goal} EP heute

{goalPct >= 1 ? 'Geschafft – stark! 🎉' : `Noch ${goal - todayEp} EP bis zum Tagesziel`}

{/* ── Fortschritt (Level/EP) – führt mit Momentum statt nackter Zahl ── */}

DEIN FORTSCHRITT

Level {level} {Math.round(xpPct)} % bis Level {level + 1}
noch {epToNext.toLocaleString('de')} EP {points.toLocaleString('de')} EP gesamt · {langLabel}
{capability &&

{capability}

}
{/* ── Wochen-Aktivität ── */} {stats && (

DIESE WOCHE

{weekCompare.delta != null && (

= 0 ? 'up' : 'down'}`}> {weekCompare.thisW} EP · {weekCompare.delta >= 0 ? '▲' : '▼'} {Math.abs(weekCompare.delta)} % {weekCompare.delta >= 0 ? 'mehr' : 'weniger'} als letzte Woche

)}
)} {/* ── Kategorien (Punkte je Thema) ── */} {stats && (

KATEGORIEN

{categories.length ? (
{categories.map((c, i) => { const color = CAT_COLORS[i % CAT_COLORS.length] return (
{c.label || 'Allgemein'} · {categoryTier(c.points).label} {c.points} P
) })}
) : (

Sammle Punkte — deine Themen erscheinen hier.

)}
)} {/* ── Erfolge ── */} {achievements.length > 0 && (

ERFOLGE · {achievements.filter(a => a.unlocked).length}/{achievements.length}

{achievements.map(a => (
{a.unlocked ? a.icon : '🔒'} {a.label}
))}
)} {/* ── Streak-Kalender ── */} {stats && (

AKTIVITÄT · 12 WOCHEN

weniger mehr
)} {/* ── Eckdaten ── */} {totals && (
{totals.pairs_practiced} Karten geübt
{accuracyPct}% Genauigkeit
{streak} Tage Streak
)} {/* ── Skills ── */}

FÄHIGKEITEN

{hasSkillData ? (
) : (

Leg los — deine Stärken erscheinen, sobald du Karten löst.

)}
{!stats && (

Dein Lernverlauf wird ab jetzt aufgezeichnet — komm morgen wieder! 🌱

)}
) }