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>
This commit is contained in:
2026-06-17 22:14:26 +02:00
parent 98543979db
commit 14fb0dcbe9
9 changed files with 140 additions and 5 deletions

View File

@@ -11,6 +11,8 @@ 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 }
@@ -39,6 +41,8 @@ export default function Feed() {
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.
@@ -129,6 +133,9 @@ export default function Feed() {
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) {
@@ -213,8 +220,23 @@ export default function Feed() {
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>
)}