From 039d2cbbf4d5769c6a53692b5377af52fbf6d2a4 Mon Sep 17 00:00:00 2001 From: admin Date: Wed, 17 Jun 2026 21:43:56 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20Fortschritt=20sp=C3=BCrbar=20machen=20?= =?UTF-8?q?=E2=80=93=20Momente,=20Momentum=20&=20Storytelling?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - +EP-Float am Button + hochzählendes EP-Badge (EpFloat, useCountUp) - Level-/Streak-/Tagesziel-Overlay (MilestoneOverlay), getriggert aus der saveProgress-Response - Combo-Zähler + variables Lob, ermutigendes Fehler-Feedback statt stillem Verschwinden - Session-Summary mit Story-Zeilen statt End-Sackgasse - Profil führt mit %-bis-Level + Capability-Satz; Kategorie-Stufen, Wochenvergleich, Sound-Toggle - Level-Kurve gespiegelt (utils/leveling.js); Level deploy-unabhängig aus EP abgeleitet Co-Authored-By: Claude Opus 4.8 --- src/components/EpFloat.jsx | 12 ++ src/components/MilestoneOverlay.jsx | 35 ++++++ src/components/Moments.css | 183 ++++++++++++++++++++++++++++ src/components/PairSentenceCard.jsx | 7 +- src/components/PairWordCard.jsx | 23 ++-- src/components/PairYesNoCard.jsx | 17 ++- src/components/SessionSummary.jsx | 47 +++++++ src/hooks/useCountUp.js | 32 +++++ src/pages/Feed.jsx | 122 ++++++++++++++++--- src/pages/Profil.css | 30 +++++ src/pages/Profil.jsx | 71 +++++++++-- src/utils/leveling.js | 24 ++++ src/utils/praise.js | 42 +++++++ src/utils/sound.js | 56 +++++++++ 14 files changed, 665 insertions(+), 36 deletions(-) create mode 100644 src/components/EpFloat.jsx create mode 100644 src/components/MilestoneOverlay.jsx create mode 100644 src/components/Moments.css create mode 100644 src/components/SessionSummary.jsx create mode 100644 src/hooks/useCountUp.js create mode 100644 src/utils/leveling.js create mode 100644 src/utils/praise.js create mode 100644 src/utils/sound.js 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 +} 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()}> + +

{title}

+ {sub &&

{sub}

} + +
+
+ ) +} 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)} />}
{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.text} +
+ ))} +
+ )} + + {onReload && ( + + )} +
+ ) +} 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 && ( + + )} + {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 && ) } 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 ( + + ) +} + /* ── 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 */ } +}