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:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user