- streak.js: Zustand aus last_practice_at (safe/at_risk/broken/none) - Feed: Loss-Aversion-Bar "X-Tage-Serie – nur noch Y Std heute!" wenn die Serie heute abläuft - Profil-Streak-Zeile zeigt Status (heute gesichert ✓ / noch X Std) - Lokale Tages-Erinnerung via @capacitor/local-notifications (kein APNs nötig), geplant bei Login, abgebrochen sobald heute geübt – nativ; web no-op Hinweis: nativ erst nach 'npx cap sync ios' + Rebuild aktiv. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
294 lines
12 KiB
JavaScript
294 lines
12 KiB
JavaScript
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 (
|
||
<div className="feed page-enter">
|
||
<div className="feed-empty">Lade Karten…</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
if (empty) {
|
||
return (
|
||
<div className="feed page-enter">
|
||
<div className="feed-empty">Noch keine Inhalte verfügbar.</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
const goalPct = daily && daily.daily_goal_ep ? (daily.ep || 0) / daily.daily_goal_ep : 0
|
||
|
||
// Loss-Aversion-Nudge: Serie läuft heute ab (oder ist gerissen) und heute noch nicht geübt.
|
||
const streak = streakState(user?.last_practice_at, user?.streak_days || 0)
|
||
const showStreakNudge = !practiced && !streakDismissed &&
|
||
(streak.state === 'at_risk' || streak.state === 'broken')
|
||
|
||
return (
|
||
<div className="feed page-enter">
|
||
{showStreakNudge && (
|
||
<div className="streak-nudge" role="status">
|
||
<span>
|
||
{streak.state === 'at_risk'
|
||
? `🔥 ${streak.streakDays}-Tage-Serie — nur noch ${streak.hoursLeft} Std heute!`
|
||
: '🌱 Starte heute deine Serie neu'}
|
||
</span>
|
||
<button className="streak-nudge-x" onClick={() => setStreakDismissed(true)} aria-label="Schließen">×</button>
|
||
</div>
|
||
)}
|
||
{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
|
||
value={daily ? goalPct : 1}
|
||
size={26} stroke={4}
|
||
color={goalPct >= 1 ? 'var(--success)' : 'var(--gold)'}
|
||
>
|
||
<span style={{ fontSize: 11 }}>{goalPct >= 1 ? '✓' : '⭐'}</span>
|
||
</ProgressRing>
|
||
<span className="ep-value">
|
||
{displayEp}<small>EP{daily ? ` · ${daily.ep || 0}/${daily.daily_goal_ep} heute` : ''}</small>
|
||
</span>
|
||
</div>
|
||
)}
|
||
{visible.map((item) => {
|
||
const cardWithMeta = { ...item.card, meta: item.meta }
|
||
const handler = (result) => handleComplete(item, result)
|
||
return (
|
||
<div key={item.meta.pairId} className="feed-slot">
|
||
{item.type === 'text' && <PairSentenceCard card={cardWithMeta} onComplete={handler} />}
|
||
{item.type === 'yes_no' && <PairYesNoCard card={cardWithMeta} onComplete={handler} />}
|
||
{item.type === 'word' && <PairWordCard card={cardWithMeta} onComplete={handler} />}
|
||
{item.type === 'question'&& <PairWordCard card={cardWithMeta} onComplete={handler} />}
|
||
</div>
|
||
)
|
||
})}
|
||
|
||
{/* Nachlade-Bereich */}
|
||
{!exhausted && <div ref={sentinelRef} className="feed-sentinel" aria-hidden="true" />}
|
||
{loadingMore && <div className="feed-empty">Lade weitere Karten…</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>
|
||
)
|
||
}
|