Files
app-hejyou/src/pages/Profil.jsx
admin 98543979db feat: Erfolge im Feed-Overlay + Profil-Sektion
- 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>
2026-06-17 21:54:02 +02:00

385 lines
17 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 (MoSo) 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>
)
}