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 (
)
}
/* ── 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 ── */}
{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! 🌱
)}
)
}