Files
app-hejyou/src/pages/Feed.jsx
admin 14fb0dcbe9 feat: Streak-Reminder – In-App-Nudge + lokale Tages-Erinnerung
- 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>
2026-06-17 22:14:26 +02:00

294 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
)
}