diff --git a/src/components/EpFloat.jsx b/src/components/EpFloat.jsx
new file mode 100644
index 0000000..defd9a1
--- /dev/null
+++ b/src/components/EpFloat.jsx
@@ -0,0 +1,12 @@
+import { useEffect } from 'react'
+import './Moments.css'
+
+// Kurzes „+N EP", das vom Bestätigen-Button aufschwebt — Belohnung genau dort,
+// wo das Auge ist. Eltern-Element braucht position: relative.
+export default function EpFloat({ points, onDone }) {
+ useEffect(() => {
+ const t = setTimeout(() => onDone?.(), 1000)
+ return () => clearTimeout(t)
+ }, [onDone])
+ return +{points} EP
+}
diff --git a/src/components/MilestoneOverlay.jsx b/src/components/MilestoneOverlay.jsx
new file mode 100644
index 0000000..9912825
--- /dev/null
+++ b/src/components/MilestoneOverlay.jsx
@@ -0,0 +1,35 @@
+import { useEffect } from 'react'
+import confetti from 'canvas-confetti'
+import './Moments.css'
+
+const COLORS = ['#C4A85A', '#7A5C2E', '#3D7055', '#E8C9A8', '#fff']
+
+function celebrate() {
+ const reduce = window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches
+ if (reduce) return
+ confetti({ particleCount: 140, spread: 90, origin: { y: 0.5 }, colors: COLORS, scalar: 1 })
+ setTimeout(() => confetti({ particleCount: 70, spread: 110, origin: { y: 0.45 }, colors: COLORS, scalar: 0.8 }), 220)
+}
+
+// Texte je Milestone-Art. value = Level-Nummer / Streak-Tage / Tagesziel-EP.
+function content({ kind, value }) {
+ if (kind === 'level') return { cls: '', icon: '🏆', title: `Level ${value} erreicht!`, sub: 'Du wächst spürbar — weiter so.' }
+ if (kind === 'streak') return { cls: 'streak', icon: '🔥', title: `${value} Tage am Stück!`, sub: 'Dranbleiben zahlt sich aus.' }
+ if (kind === 'goal') return { cls: 'goal', icon: '🎯', title: 'Tagesziel erreicht!', sub: 'Stark — heute hast du dein Pensum geschafft.' }
+ return { cls: '', icon: '🎉', title: 'Geschafft!', sub: '' }
+}
+
+export default function MilestoneOverlay({ milestone, onClose }) {
+ useEffect(() => { celebrate() }, [milestone])
+ const { cls, icon, title, sub } = content(milestone)
+ return (
+
+
e.stopPropagation()}>
+
{icon}
+
{title}
+ {sub &&
{sub}
}
+
Weiter
+
+
+ )
+}
diff --git a/src/components/Moments.css b/src/components/Moments.css
new file mode 100644
index 0000000..ba3d83e
--- /dev/null
+++ b/src/components/Moments.css
@@ -0,0 +1,183 @@
+/* ── EP-Float: „+3 EP" schwebt vom Button auf ───────────────── */
+.ep-float {
+ position: absolute;
+ left: 50%;
+ bottom: 100%;
+ transform: translateX(-50%);
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ padding: 4px 12px;
+ border-radius: var(--r-pill);
+ background: var(--gold-soft);
+ color: var(--accent-strong);
+ font-family: var(--font-ui);
+ font-weight: 800;
+ font-size: 15px;
+ white-space: nowrap;
+ pointer-events: none;
+ box-shadow: var(--shadow-soft);
+ animation: epFloatUp 1s var(--ease) forwards;
+}
+@keyframes epFloatUp {
+ 0% { opacity: 0; transform: translateX(-50%) translateY(8px) scale(0.8); }
+ 20% { opacity: 1; transform: translateX(-50%) translateY(0) scale(1.05); }
+ 35% { transform: translateX(-50%) translateY(0) scale(1); }
+ 100% { opacity: 0; transform: translateX(-50%) translateY(-46px) scale(0.95); }
+}
+
+/* ── Combo-Pill (oben rechts, neben dem zentrierten EP-Badge) ── */
+.combo-pill {
+ position: fixed;
+ top: calc(env(safe-area-inset-top, 0px) + 14px);
+ right: 14px;
+ z-index: 30;
+ display: inline-flex;
+ align-items: center;
+ gap: 5px;
+ padding: 5px 12px;
+ border-radius: var(--r-pill);
+ background: var(--accent);
+ color: #F5EFE6;
+ font-weight: 800;
+ font-size: 13px;
+ box-shadow: var(--shadow-pop);
+ animation: comboPop 0.32s var(--ease);
+}
+@keyframes comboPop {
+ from { opacity: 0; transform: translateY(-6px) scale(0.85); }
+ to { opacity: 1; transform: translateY(0) scale(1); }
+}
+
+/* ── Milestone-Overlay (Level-Up / Streak / Tagesziel) ──────── */
+.milestone-overlay {
+ position: fixed;
+ inset: 0;
+ z-index: 100;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: var(--sp-5);
+ background: rgba(58, 37, 21, 0.55);
+ backdrop-filter: blur(3px);
+ -webkit-backdrop-filter: blur(3px);
+ animation: msFade 0.25s var(--ease);
+}
+@keyframes msFade { from { opacity: 0; } to { opacity: 1; } }
+
+.milestone-card {
+ width: 100%;
+ max-width: 320px;
+ background: var(--surface);
+ border-radius: var(--r-lg);
+ padding: var(--sp-6) var(--sp-5) var(--sp-5);
+ text-align: center;
+ box-shadow: var(--shadow-card);
+ animation: msPop 0.4s var(--ease);
+}
+@keyframes msPop {
+ from { opacity: 0; transform: translateY(14px) scale(0.92); }
+ to { opacity: 1; transform: none; }
+}
+
+.milestone-badge {
+ width: 84px;
+ height: 84px;
+ margin: 0 auto var(--sp-4);
+ border-radius: 50%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 40px;
+ background: var(--gold-soft);
+ animation: msBadge 0.6s var(--ease) 0.1s both;
+}
+@keyframes msBadge {
+ 0% { transform: scale(0.4) rotate(-12deg); opacity: 0; }
+ 60% { transform: scale(1.12) rotate(4deg); opacity: 1; }
+ 100% { transform: scale(1) rotate(0); }
+}
+.milestone-badge.streak { background: #F6E0CB; }
+.milestone-badge.goal { background: var(--success-soft); }
+
+.milestone-title {
+ font-family: var(--font-display);
+ font-size: 24px;
+ font-weight: 700;
+ color: var(--text-strong);
+ margin: 0 0 6px;
+}
+.milestone-sub {
+ font-size: 14px;
+ color: var(--text-muted);
+ margin: 0 0 var(--sp-5);
+ line-height: 1.5;
+}
+.milestone-btn {
+ width: 100%;
+ padding: 13px;
+ border: none;
+ border-radius: var(--r-md);
+ background: var(--accent);
+ color: #F5EFE6;
+ font-family: var(--font-ui);
+ font-weight: 700;
+ font-size: 15px;
+ cursor: pointer;
+ transition: transform var(--dur-fast) var(--ease);
+}
+.milestone-btn:active { transform: scale(0.98); }
+
+/* ── Session-Summary (ersetzt die End-Sackgasse) ────────────── */
+.session-summary {
+ margin: var(--sp-4) auto;
+ width: calc(100% - 2 * var(--sp-4));
+ max-width: 460px;
+ background: var(--surface);
+ border-radius: var(--r-lg);
+ padding: var(--sp-5) var(--sp-5) var(--sp-4);
+ text-align: center;
+ box-shadow: var(--shadow-card);
+}
+.session-summary .ss-icon {
+ width: 64px; height: 64px;
+ margin: 0 auto var(--sp-3);
+ border-radius: 50%;
+ background: var(--gold-soft);
+ display: flex; align-items: center; justify-content: center;
+ font-size: 30px;
+}
+.session-summary .ss-title {
+ font-family: var(--font-display);
+ font-size: 21px; font-weight: 700;
+ color: var(--text-strong);
+ margin: 0 0 2px;
+}
+.session-summary .ss-sub {
+ font-size: 13px; color: var(--text-muted); margin: 0 0 var(--sp-4);
+}
+.ss-stats {
+ display: flex;
+ gap: var(--sp-2);
+ margin-bottom: var(--sp-4);
+}
+.ss-stat {
+ flex: 1;
+ background: var(--surface-2);
+ border-radius: var(--r-sm);
+ padding: var(--sp-3) var(--sp-2);
+}
+.ss-stat .n { display: block; font-size: 20px; font-weight: 800; color: var(--text-strong); }
+.ss-stat .c { display: block; font-size: 11px; color: var(--text-muted); margin-top: 2px; }
+.ss-story {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ text-align: left;
+ background: var(--accent-soft);
+ border-radius: var(--r-sm);
+ padding: 10px 12px;
+ font-size: 13px;
+ color: var(--text);
+}
+.ss-story .si { font-size: 18px; }
diff --git a/src/components/PairSentenceCard.jsx b/src/components/PairSentenceCard.jsx
index 7c8238f..8b4f03d 100644
--- a/src/components/PairSentenceCard.jsx
+++ b/src/components/PairSentenceCard.jsx
@@ -2,6 +2,7 @@ import { useState, useRef, useMemo } from 'react'
import confetti from 'canvas-confetti'
import usePairAudio from '../hooks/usePairAudio'
import SelectionOverlay from './SelectionOverlay'
+import EpFloat from './EpFloat'
import { buildChipTimings, activeChipIdAt } from '../utils/chipTimings'
import { speak } from '../utils/speak'
import './PairCards.css'
@@ -65,7 +66,9 @@ export default function PairSentenceCard({ card, onComplete }) {
const [showTranslation, setShowTranslation] = useState(false)
const [holding, setHolding] = useState(false)
const [unlocked, setUnlocked] = useState(false)
+ const [showFloat, setShowFloat] = useState(false)
const holdCompleted = useRef(false)
+ const points = card.meta?.points ?? 2
const lang = card.lang || 'de'
const native = lang === 'de' ? 'en' : 'de'
@@ -100,6 +103,7 @@ export default function PairSentenceCard({ card, onComplete }) {
if (done || !unlocked) return
setDone(true)
setActiveChip(null)
+ setShowFloat(true)
triggerConfetti()
setTimeout(() => onComplete('correct'), 900)
}
@@ -232,7 +236,8 @@ export default function PairSentenceCard({ card, onComplete }) {
)}
-
+
+ {showFloat && setShowFloat(false)} />}
praise())
+ const [encourageWord] = useState(() => encourage())
+ const points = card.meta?.points ?? 3
const lang = card.lang || 'de'
const native = lang === 'de' ? 'en' : 'de'
@@ -117,7 +123,7 @@ export default function PairWordCard({ card, onComplete }) {
const ok = noWrongSelected
setIsCorrect(ok)
setConfirmed(true)
- if (ok) triggerConfetti()
+ if (ok) { setShowFloat(true); triggerConfetti() }
setTimeout(() => onComplete(ok ? 'correct' : 'wrong'), 900)
}
@@ -188,12 +194,15 @@ export default function PairWordCard({ card, onComplete }) {
{confirmed && (
-
- {isCorrect
- ? '✓ Richtig!'
- : `✗ Richtig wären: ${options.filter(o => o.correct).map(o => o[lang] || o.de).join(', ')}`
- }
-
+
+ {isCorrect && showFloat &&
setShowFloat(false)} />}
+
+ {isCorrect
+ ? `✓ ${praiseWord}`
+ : `${encourageWord} Richtig wären: ${options.filter(o => o.correct).map(o => o[lang] || o.de).join(', ')}`
+ }
+
+
)}
{!confirmed && (
diff --git a/src/components/PairYesNoCard.jsx b/src/components/PairYesNoCard.jsx
index e5829d1..b1d4be7 100644
--- a/src/components/PairYesNoCard.jsx
+++ b/src/components/PairYesNoCard.jsx
@@ -2,6 +2,8 @@ import { useState, useMemo } from 'react'
import confetti from 'canvas-confetti'
import usePairAudio from '../hooks/usePairAudio'
import SelectionOverlay from './SelectionOverlay'
+import EpFloat from './EpFloat'
+import { praise, encourage } from '../utils/praise'
import { buildChipTimings, activeChipIdAt } from '../utils/chipTimings'
import { speak } from '../utils/speak'
import './PairCards.css'
@@ -47,6 +49,10 @@ function resolveSentence(sentence, placeholders, onChipClick, activeId) {
export default function PairYesNoCard({ card, onComplete }) {
const [result, setResult] = useState(null)
const [activeChip, setActiveChip] = useState(null)
+ const [showFloat, setShowFloat] = useState(false)
+ const [praiseWord] = useState(() => praise())
+ const [encourageWord] = useState(() => encourage())
+ const points = card.meta?.points ?? 2
const lang = card.lang || 'de'
const native = lang === 'de' ? 'en' : 'de'
@@ -88,7 +94,7 @@ export default function PairYesNoCard({ card, onComplete }) {
const isCorrect = answer === correct
const r = isCorrect ? 'correct' : 'wrong'
setResult(r)
- if (isCorrect) triggerConfetti()
+ if (isCorrect) { setShowFloat(true); triggerConfetti() }
setTimeout(() => onComplete(r), 900)
}
@@ -135,9 +141,12 @@ export default function PairYesNoCard({ card, onComplete }) {
{result && (
-
- {result === 'correct' ? '✓ Richtig!' : `✗ Die Antwort war: ${correct ? 'Ja' : 'Nein'}`}
-
+
+ {result === 'correct' && showFloat &&
setShowFloat(false)} />}
+
+ {result === 'correct' ? `✓ ${praiseWord}` : `${encourageWord} Die Antwort war: ${correct ? 'Ja' : 'Nein'}`}
+
+
)}
diff --git a/src/components/SessionSummary.jsx b/src/components/SessionSummary.jsx
new file mode 100644
index 0000000..52f9e27
--- /dev/null
+++ b/src/components/SessionSummary.jsx
@@ -0,0 +1,47 @@
+import './Moments.css'
+import { categoryTier } from '../utils/praise'
+
+// Ersetzt die End-Sackgasse („Super! Alle Karten…") durch einen echten
+// Abschluss-Moment: Zahlen dieser Session + 1–3 Story-Zeilen.
+export default function SessionSummary({ cards, ep, correct, streak, topCategory, onReload }) {
+ const stories = []
+ if (cards > 0) {
+ stories.push({ icon: '✅', text: `${correct} von ${cards} Karten auf Anhieb richtig` })
+ }
+ if (streak > 0) {
+ stories.push({ icon: '🔥', text: `Tag ${streak} in Folge — Streak gehalten` })
+ }
+ if (topCategory?.label) {
+ const tier = categoryTier(topCategory.points)
+ stories.push({ icon: '📚', text: `„${topCategory.label}" — Stufe ${tier.label}` })
+ }
+
+ return (
+
+
🌟
+
Stark gemacht!
+
Session beendet · {ep} EP gesammelt
+
+
+
{cards} Karten
+
{ep} EP heute
+
{cards ? Math.round((correct / cards) * 100) : 0}% richtig
+
+
+ {stories.length > 0 && (
+
+ {stories.map((s, i) => (
+
+ {s.icon}
+ {s.text}
+
+ ))}
+
+ )}
+
+ {onReload && (
+
Nach neuen Karten suchen
+ )}
+
+ )
+}
diff --git a/src/hooks/useCountUp.js b/src/hooks/useCountUp.js
new file mode 100644
index 0000000..81ca7f3
--- /dev/null
+++ b/src/hooks/useCountUp.js
@@ -0,0 +1,32 @@
+import { useEffect, useRef, useState } from 'react'
+
+// Zählt eine Zahl sanft vom alten auf den neuen Wert hoch (EP-Badge etc.),
+// damit Belohnung nicht stumm „umspringt". Respektiert prefers-reduced-motion.
+export default function useCountUp(target, { duration = 600 } = {}) {
+ const [display, setDisplay] = useState(target ?? 0)
+ const fromRef = useRef(target ?? 0)
+ const rafRef = useRef(0)
+
+ useEffect(() => {
+ if (target == null) return
+ const from = fromRef.current
+ const to = target
+ if (from === to) { setDisplay(to); return }
+
+ const reduce = window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches
+ if (reduce) { fromRef.current = to; setDisplay(to); return }
+
+ const start = performance.now()
+ const tick = (now) => {
+ const t = Math.min(1, (now - start) / duration)
+ const eased = 1 - Math.pow(1 - t, 3)
+ setDisplay(Math.round(from + (to - from) * eased))
+ if (t < 1) rafRef.current = requestAnimationFrame(tick)
+ else fromRef.current = to
+ }
+ rafRef.current = requestAnimationFrame(tick)
+ return () => cancelAnimationFrame(rafRef.current)
+ }, [target, duration])
+
+ return display
+}
diff --git a/src/pages/Feed.jsx b/src/pages/Feed.jsx
index 2179789..bbeb4e4 100644
--- a/src/pages/Feed.jsx
+++ b/src/pages/Feed.jsx
@@ -6,10 +6,16 @@ import ProgressRing from '../components/ProgressRing'
import PairSentenceCard from '../components/PairSentenceCard'
import PairYesNoCard from '../components/PairYesNoCard'
import PairWordCard from '../components/PairWordCard'
+import MilestoneOverlay from '../components/MilestoneOverlay'
+import SessionSummary from '../components/SessionSummary'
+import useCountUp from '../hooks/useCountUp'
+import { levelForEp } from '../utils/leveling'
+import { playCorrect, playMilestone } from '../utils/sound'
// Points per answer_type
const POINTS = { text: 2, yes_no: 2, word: 3, question: 3 }
const PAGE_SIZE = 20
+const STREAK_MILESTONES = [3, 7, 14, 30, 50, 100, 200, 365]
function buildCard(pair) {
return {
@@ -29,6 +35,18 @@ export default function Feed() {
const [exhausted, setExhausted] = useState(false)
const [totalEp, setTotalEp] = useState(null)
const [daily, setDaily] = useState(null) // { ep, daily_goal_ep } – wenn /auth/stats verfügbar
+ const [combo, setCombo] = useState(0) // richtige Antworten in Folge (diese Session)
+ const [milestones, setMilestones] = useState([]) // Queue: Level-Up / Streak / Tagesziel
+ const [topCat, setTopCat] = useState(null) // stärkste Kategorie für die Session-Summary
+ const [reloadKey, setReloadKey] = useState(0) // erneutes Laden nach der Summary
+
+ // Session-Zähler (lokal, für die Abschluss-Summary) + zuletzt bekannter Fortschritt,
+ // um Level-Up/Streak-Up im saveProgress-Response zu erkennen.
+ const session = useRef({ cards: 0, correct: 0, ep: 0 })
+ const progress = useRef({ level: 0, streak: 0 })
+
+ // Sanft hochzählender EP-Wert fürs Badge (statt stummem Umspringen).
+ const displayEp = useCountUp(totalEp ?? 0)
// Refs für den Nachlade-Pfad: Re-Entrancy-Schutz + immer aktuelle Kartenliste
// (Closure im IntersectionObserver wäre sonst veraltet).
@@ -51,15 +69,23 @@ export default function Feed() {
})
.catch(err => { console.error('Feed load error', err); setEmpty(true) })
.finally(() => setLoading(false))
- }, [token, lang])
+ }, [token, lang, reloadKey])
useEffect(() => {
getUserProgress(token)
- .then(p => setTotalEp(p.total_ep))
+ .then(p => {
+ setTotalEp(p.total_ep)
+ // Level aus EP über die (mit dem Backend identische) Kurve ableiten, damit Level-Up
+ // unabhängig vom Backend-Deploy korrekt erkannt wird.
+ progress.current = { level: levelForEp(p.total_ep), streak: p.streak_days ?? 0 }
+ })
.catch(() => {})
- // Tagesziel-Fortschritt – degradiert lautlos, falls /auth/stats noch nicht deployed ist
+ // Tagesziel-Fortschritt + stärkste Kategorie – degradiert lautlos, falls /auth/stats fehlt
getStats(token)
- .then(s => { if (s?.today) setDaily(s.today) })
+ .then(s => {
+ if (s?.today) setDaily(s.today)
+ if (s?.categories?.length) setTopCat(s.categories[0])
+ })
.catch(() => {})
}, [token])
@@ -102,18 +128,66 @@ export default function Feed() {
setDone(prev => new Set([...prev, item.meta.pairId]))
const correct = result === 'correct'
const earned = correct ? item.meta.points : 0
- saveProgress({
- pairId: item.meta.pairId,
- correct,
- points: earned,
- userToken: token,
- })
- .then(res => { if (res?.total_ep != null) setTotalEp(res.total_ep) })
+
+ // Session-Zähler + Combo + Sound
+ session.current.cards += 1
+ if (correct) {
+ session.current.correct += 1
+ session.current.ep += earned
+ setCombo(c => c + 1)
+ playCorrect()
+ } else {
+ setCombo(0)
+ }
+
+ // Optimistische Tagesziel-Erkennung als Fallback, falls die API kein
+ // goal_just_reached liefert (älteres Backend).
+ const dayBefore = daily?.ep ?? null
+ const goalEp = daily?.daily_goal_ep ?? null
+ const optimisticGoal = earned > 0 && dayBefore != null && goalEp != null &&
+ dayBefore < goalEp && dayBefore + earned >= goalEp
+
+ saveProgress({ pairId: item.meta.pairId, correct, points: earned, userToken: token })
+ .then(res => {
+ if (res?.total_ep != null) setTotalEp(res.total_ep)
+ if (res?.daily_ep != null && res?.daily_goal_ep != null) {
+ setDaily(d => ({ ...(d || {}), ep: res.daily_ep, daily_goal_ep: res.daily_goal_ep }))
+ }
+
+ const queued = []
+ // Level aus dem aktuellen EP-Stand ableiten (deploy-unabhängig, identische Kurve);
+ // prev = zuletzt bekannter Level vor dieser Karte.
+ const newLevel = res?.total_ep != null ? levelForEp(res.total_ep) : progress.current.level
+ const prevLevel = progress.current.level
+ if (newLevel > prevLevel) queued.push({ kind: 'level', value: newLevel })
+
+ const newStreak = res?.streak_days ?? progress.current.streak
+ const streakUp = res?.streak_increased ?? (newStreak > progress.current.streak)
+ if (streakUp && STREAK_MILESTONES.includes(newStreak)) queued.push({ kind: 'streak', value: newStreak })
+
+ if (res?.goal_just_reached ?? optimisticGoal) {
+ queued.push({ kind: 'goal', value: res?.daily_goal_ep ?? goalEp })
+ }
+
+ progress.current = { level: newLevel, streak: newStreak }
+ if (queued.length) { setMilestones(q => [...q, ...queued]); playMilestone() }
+ })
.catch(err => console.error('saveProgress error', err))
- // Tagesziel optimistisch hochzählen
+
+ // Tagesziel optimistisch hochzählen (wird vom Server-Response ggf. überschrieben)
if (earned > 0) setDaily(d => d ? { ...d, ep: (d.ep || 0) + earned } : d)
}
+ // Nach der Summary erneut nach Karten suchen (Server schließt erledigte Pairs aus).
+ const handleReload = () => {
+ setDone(new Set())
+ setExhausted(false); exhaustedRef.current = false
+ session.current = { cards: 0, correct: 0, ep: 0 }
+ setCombo(0)
+ setLoading(true)
+ setReloadKey(k => k + 1)
+ }
+
const visible = cards.filter(c => !done.has(c.meta.pairId))
if (loading) {
@@ -136,6 +210,15 @@ export default function Feed() {
return (
+ {combo >= 3 && (
+
🔥 {combo} in Folge
+ )}
+ {milestones.length > 0 && (
+
setMilestones(q => q.slice(1))}
+ />
+ )}
{totalEp != null && (
{goalPct >= 1 ? '✓' : '⭐'}
- {totalEp}EP{daily ? ` · ${daily.ep || 0}/${daily.daily_goal_ep} heute` : ''}
+ {displayEp}EP{daily ? ` · ${daily.ep || 0}/${daily.daily_goal_ep} heute` : ''}
)}
@@ -166,7 +249,18 @@ export default function Feed() {
{/* Nachlade-Bereich */}
{!exhausted &&
}
{loadingMore && Lade weitere Karten…
}
- {exhausted && Super! Alle Karten abgeschlossen. 🎉
}
+ {exhausted && (
+ visible.length === 0
+ ?
+ : Super! Alle Karten abgeschlossen. 🎉
+ )}
)
}
diff --git a/src/pages/Profil.css b/src/pages/Profil.css
index e8ad47a..aeda97c 100644
--- a/src/pages/Profil.css
+++ b/src/pages/Profil.css
@@ -110,6 +110,35 @@
.level-pill { background: var(--accent); color: var(--bg); font-size: 11px; font-weight: 800; padding: 3px 11px; border-radius: var(--r-pill); }
.level-hint { font-size: 11px; color: var(--text-muted); }
+/* Capability-Satz: „was du jetzt kannst" statt nur Zahlen */
+.capability-line {
+ margin-top: var(--sp-3);
+ padding-top: var(--sp-3);
+ border-top: 1px solid var(--border-soft);
+ font-size: 13px;
+ font-style: italic;
+ color: var(--text);
+ line-height: 1.5;
+}
+
+/* Wochenvergleich */
+.week-compare { font-size: 12px; font-weight: 700; margin-bottom: var(--sp-3); }
+.week-compare.up { color: var(--success); }
+.week-compare.down { color: var(--text-muted); }
+
+/* Sound-Toggle (spiegelt den Logout-Button, oben links) */
+.profil-sound {
+ position: absolute;
+ top: calc(env(safe-area-inset-top) + 20px);
+ left: 4px;
+ background: none; border: none; cursor: pointer;
+ padding: 6px; border-radius: var(--r-sm);
+ color: var(--text-soft);
+ display: flex; align-items: center; justify-content: center;
+ transition: color var(--dur-fast), background var(--dur-fast);
+}
+.profil-sound:hover { color: var(--accent); background: var(--accent-soft); }
+
/* ── Wochen-Graph ── */
.weekbars { display: flex; align-items: flex-end; gap: var(--sp-2); height: 96px; }
.weekbar-col { flex: 1; display: flex; flex-direction: column; align-items: center; gap: 6px; height: 100%; }
@@ -154,6 +183,7 @@
.cat-head { display: flex; align-items: center; gap: var(--sp-2); }
.cat-dot { width: 9px; height: 9px; border-radius: 50%; flex-shrink: 0; }
.cat-label { flex: 1; font-size: 13px; font-weight: 700; color: var(--text); }
+.cat-tier { font-size: 11px; font-weight: 600; color: var(--text-soft); }
.cat-points { font-size: 12px; font-weight: 800; color: var(--accent); }
.cat-bar { height: 6px; width: 100%; background: var(--surface-2); border-radius: var(--r-pill); overflow: hidden; }
.cat-bar-fill { height: 100%; border-radius: var(--r-pill); transition: width 0.6s var(--ease); }
diff --git a/src/pages/Profil.jsx b/src/pages/Profil.jsx
index fca003e..155502e 100644
--- a/src/pages/Profil.jsx
+++ b/src/pages/Profil.jsx
@@ -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 (
+
+ {muted ? (
+
+
+
+ ) : (
+
+
+
+ )}
+
+ )
+}
+
/* ── 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 (
+
{/* ── Header ── */}
@@ -213,24 +255,30 @@ export default function Profil() {
- {/* ── Fortschritt (Level/EP) ── */}
+ {/* ── Fortschritt (Level/EP) – führt mit Momentum statt nackter Zahl ── */}
DEIN FORTSCHRITT
- {langLabel}
- {points.toLocaleString('de')} EP gesamt
+ Level {level}
+ {Math.round(xpPct)} % bis Level {level + 1}
- Level {level}
- {(epPerLevel - epIntoLevel).toLocaleString('de')} EP 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
+
+ )}
)}
@@ -247,7 +295,10 @@ export default function Profil() {
- {c.label || 'Allgemein'}
+
+ {c.label || 'Allgemein'}
+ · {categoryTier(c.points).label}
+
{c.points} P
diff --git a/src/utils/leveling.js b/src/utils/leveling.js
new file mode 100644
index 0000000..3004498
--- /dev/null
+++ b/src/utils/leveling.js
@@ -0,0 +1,24 @@
+// Spiegelt die Backend-Kurve (snakkimo-API/src/lib/leveling.js).
+// Dient als Fallback, falls die API noch keine level/ep_into_level-Felder liefert,
+// und für die %-Anzeige innerhalb eines Levels.
+export function epForLevel(level) {
+ return level <= 0 ? 0 : 5 * level * (level + 3)
+}
+
+export function levelForEp(ep) {
+ const e = Math.max(0, ep || 0)
+ return Math.floor((-15 + Math.sqrt(225 + 20 * e)) / 10)
+}
+
+export function levelInfo(ep) {
+ const e = Math.max(0, ep || 0)
+ const level = levelForEp(e)
+ const base = epForLevel(level)
+ const next = epForLevel(level + 1)
+ return {
+ level,
+ epIntoLevel: e - base,
+ epToNextLevel: next - e,
+ epForNextLevel: next - base,
+ }
+}
diff --git a/src/utils/praise.js b/src/utils/praise.js
new file mode 100644
index 0000000..3634203
--- /dev/null
+++ b/src/utils/praise.js
@@ -0,0 +1,42 @@
+// Variables Erfolgs-Feedback statt immer „✓ Richtig!" — kleine Abwechslung
+// hält den Belohnungsmoment frisch.
+const PRAISE = ['Stark!', 'Genau!', 'Sitzt!', 'Perfekt!', 'Bravo!', 'Sauber!', 'Weiter so!']
+export function praise() {
+ return PRAISE[Math.floor(Math.random() * PRAISE.length)]
+}
+
+// Ermutigende Einleitung für falsche Antworten — Fehler als Lernschritt rahmen,
+// nicht als Bestrafung.
+const ENCOURAGE = ['Fast!', 'Kein Problem —', 'Gleich hast du\'s!', 'Daraus lernst du:']
+export function encourage() {
+ return ENCOURAGE[Math.floor(Math.random() * ENCOURAGE.length)]
+}
+
+// Kategorie-Meisterungsstufen (MVP, client-seitig). Idealerweise später
+// backend-/content-getrieben mit echten Schwellen je Thema → Plan C3.
+const TIERS = [
+ { min: 0, label: 'Erste Schritte' },
+ { min: 5, label: 'Vertraut' },
+ { min: 12, label: 'Sicher' },
+ { min: 25, label: 'Gemeistert' },
+]
+export function categoryTier(points) {
+ let tier = TIERS[0]
+ for (const t of TIERS) if ((points || 0) >= t.min) tier = t
+ return tier
+}
+
+// Punkte bis zur nächsten Stufe (für „2 P bis Vertraut").
+export function pointsToNextTier(points) {
+ const p = points || 0
+ for (const t of TIERS) if (p < t.min) return { label: t.label, remaining: t.min - p }
+ return null // bereits höchste Stufe
+}
+
+// Story-Satz für die stärkste Kategorie — „was du jetzt kannst", nicht nur Zahlen.
+export function capabilitySentence(categories) {
+ if (!categories?.length) return null
+ const top = categories[0]
+ const tier = categoryTier(top.points)
+ return `Dein stärkstes Thema: „${top.label}" — Stufe ${tier.label}.`
+}
diff --git a/src/utils/sound.js b/src/utils/sound.js
new file mode 100644
index 0000000..d6b4bce
--- /dev/null
+++ b/src/utils/sound.js
@@ -0,0 +1,56 @@
+// Dezente Belohnungs-Sounds via WebAudio (kein Asset-Download nötig).
+// Mute-Pref in localStorage; standardmäßig an.
+const KEY = 'snakkimo_sound'
+let ctx = null
+
+function audioCtx() {
+ if (typeof window === 'undefined') return null
+ if (!ctx) {
+ const C = window.AudioContext || window.webkitAudioContext
+ ctx = C ? new C() : null
+ }
+ return ctx
+}
+
+export function isMuted() {
+ try { return localStorage.getItem(KEY) === 'off' } catch { return false }
+}
+export function setMuted(muted) {
+ try { localStorage.setItem(KEY, muted ? 'off' : 'on') } catch { /* ignore */ }
+}
+
+function tone(freq, startOffset, dur, gain = 0.05) {
+ const ac = audioCtx()
+ if (!ac) return
+ const osc = ac.createOscillator()
+ const g = ac.createGain()
+ osc.type = 'sine'
+ osc.frequency.value = freq
+ osc.connect(g); g.connect(ac.destination)
+ const t = ac.currentTime + startOffset
+ g.gain.setValueAtTime(0.0001, t)
+ g.gain.linearRampToValueAtTime(gain, t + 0.02)
+ g.gain.exponentialRampToValueAtTime(0.0001, t + dur)
+ osc.start(t); osc.stop(t + dur + 0.02)
+}
+
+// Kurzer, freundlicher Zwei-Ton bei richtiger Antwort.
+export function playCorrect() {
+ if (isMuted()) return
+ try {
+ const ac = audioCtx()
+ if (ac?.state === 'suspended') ac.resume()
+ tone(660, 0, 0.12)
+ tone(880, 0.07, 0.16)
+ } catch { /* ignore */ }
+}
+
+// Aufsteigende Fanfare bei Milestones (Level-Up, Streak, Tagesziel).
+export function playMilestone() {
+ if (isMuted()) return
+ try {
+ const ac = audioCtx()
+ if (ac?.state === 'suspended') ac.resume()
+ ;[523, 659, 784, 1047].forEach((f, i) => tone(f, i * 0.09, 0.24, 0.06))
+ } catch { /* ignore */ }
+}