feat: Fortschritt spürbar machen – Momente, Momentum & Storytelling

- +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 <noreply@anthropic.com>
This commit is contained in:
2026-06-17 21:43:56 +02:00
parent 9e8af27d51
commit 039d2cbbf4
14 changed files with 665 additions and 36 deletions

View File

@@ -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 (
<div className="feed page-enter">
{combo >= 3 && (
<div key={combo} className="combo-pill" aria-hidden="true">🔥 {combo} in Folge</div>
)}
{milestones.length > 0 && (
<MilestoneOverlay
milestone={milestones[0]}
onClose={() => setMilestones(q => q.slice(1))}
/>
)}
{totalEp != null && (
<div className="ep-badge">
<ProgressRing
@@ -146,7 +229,7 @@ export default function Feed() {
<span style={{ fontSize: 11 }}>{goalPct >= 1 ? '✓' : '⭐'}</span>
</ProgressRing>
<span className="ep-value">
{totalEp}<small>EP{daily ? ` · ${daily.ep || 0}/${daily.daily_goal_ep} heute` : ''}</small>
{displayEp}<small>EP{daily ? ` · ${daily.ep || 0}/${daily.daily_goal_ep} heute` : ''}</small>
</span>
</div>
)}
@@ -166,7 +249,18 @@ export default function Feed() {
{/* Nachlade-Bereich */}
{!exhausted && <div ref={sentinelRef} className="feed-sentinel" aria-hidden="true" />}
{loadingMore && <div className="feed-empty">Lade weitere Karten</div>}
{exhausted && <div className="feed-empty">Super! Alle Karten abgeschlossen. 🎉</div>}
{exhausted && (
visible.length === 0
? <SessionSummary
cards={session.current.cards}
ep={session.current.ep}
correct={session.current.correct}
streak={progress.current.streak}
topCategory={topCat}
onReload={handleReload}
/>
: <div className="feed-empty">Super! Alle Karten abgeschlossen. 🎉</div>
)}
</div>
)
}