import { useEffect, useState, useRef, useCallback } from 'react' import './Feed.css' import { useAuth } from '../context/AuthContext' import { getFeedPairs, saveProgress, getUserProgress, getStats } from '../api/directus' 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' import { streakState } from '../utils/streak' import { cancelStreakReminder } from '../utils/streakReminder' // 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 { type: pair.answer_type, meta: { pairId: pair.id, points: pair.difficulty_level || POINTS[pair.answer_type] || 2, cardType: pair.answer_type }, card: pair, } } export default function Feed() { const { user, token } = useAuth() const [cards, setCards] = useState([]) const [done, setDone] = useState(new Set()) const [loading, setLoading] = useState(true) const [empty, setEmpty] = useState(false) const [loadingMore, setLoadingMore] = useState(false) 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 const [practiced, setPracticed] = useState(false) // heute in dieser Session geübt? const [streakDismissed, setStreakDismissed] = useState(false) // 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). const loadingMoreRef = useRef(false) const exhaustedRef = useRef(false) const cardsRef = useRef(cards) cardsRef.current = cards const sentinelRef = useRef(null) // Target language from user profile, fall back to 'de' const lang = user?.language_target_short || 'de' useEffect(() => { getFeedPairs(token, lang, PAGE_SIZE) .then(pairs => { const built = pairs.map(buildCard) setCards(built) setEmpty(built.length === 0) if (built.length < PAGE_SIZE) { exhaustedRef.current = true; setExhausted(true) } }) .catch(err => { console.error('Feed load error', err); setEmpty(true) }) .finally(() => setLoading(false)) }, [token, lang, reloadKey]) useEffect(() => { getUserProgress(token) .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 + stärkste Kategorie – degradiert lautlos, falls /auth/stats fehlt getStats(token) .then(s => { if (s?.today) setDaily(s.today) if (s?.categories?.length) setTopCat(s.categories[0]) }) .catch(() => {}) }, [token]) // Weitere Karten nachladen: schon geladene (inkl. erledigte) Pair-IDs ausschließen. // Leere Antwort → Server hat keine weiteren Karten → erschöpft. const loadMore = useCallback(async () => { if (loadingMoreRef.current || exhaustedRef.current) return loadingMoreRef.current = true setLoadingMore(true) try { const known = new Set(cardsRef.current.map(c => c.meta.pairId)) const pairs = await getFeedPairs(token, lang, PAGE_SIZE, [...known]) const fresh = pairs.filter(p => !known.has(p.id)).map(buildCard) if (fresh.length) setCards(prev => [...prev, ...fresh]) if (!fresh.length || pairs.length < PAGE_SIZE) { exhaustedRef.current = true; setExhausted(true) } } catch (err) { console.error('Feed loadMore error', err) } finally { loadingMoreRef.current = false setLoadingMore(false) } }, [token, lang]) // Infinite Scroll: lädt nach, sobald der Sentinel in die Nähe des Sichtbereichs kommt. // Großzügiger rootMargin, weil scroll-snap-mandatory einen winzigen End-Sentinel // sonst schwer erreichbar macht. useEffect(() => { if (loading || exhausted) return const el = sentinelRef.current if (!el) return const io = new IntersectionObserver( (entries) => { if (entries[0].isIntersecting) loadMore() }, { root: el.closest('.feed'), rootMargin: '300px' }, ) io.observe(el) return () => io.disconnect() }, [loading, exhausted, loadMore]) function handleComplete(item, result) { setDone(prev => new Set([...prev, item.meta.pairId])) const correct = result === 'correct' const earned = correct ? item.meta.points : 0 // Heute geübt → Serie gesichert: Nudge weg + geplante Erinnerung abbrechen. if (!practiced) { setPracticed(true); cancelStreakReminder() } // 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 }) } // Neu freigeschaltete Erfolge zuletzt feiern for (const a of (res?.unlocked_achievements || [])) { queued.push({ kind: 'achievement', key: a.key, label: a.label, icon: a.icon }) } 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 (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) { return (