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

14
package-lock.json generated
View File

@@ -1,16 +1,17 @@
{ {
"name": "language-app", "name": "snakkimo",
"version": "1.0.0", "version": "1.0.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "language-app", "name": "snakkimo",
"version": "1.0.0", "version": "1.0.0",
"dependencies": { "dependencies": {
"@capacitor/cli": "^8.4.0", "@capacitor/cli": "^8.4.0",
"@capacitor/core": "^8.4.0", "@capacitor/core": "^8.4.0",
"@capacitor/ios": "^8.4.0", "@capacitor/ios": "^8.4.0",
"@capacitor/local-notifications": "^8.2.0",
"canvas-confetti": "^1.9.4", "canvas-confetti": "^1.9.4",
"capacitor-secure-storage-plugin": "^0.13.0", "capacitor-secure-storage-plugin": "^0.13.0",
"react": "^19.0.0", "react": "^19.0.0",
@@ -367,6 +368,15 @@
"@capacitor/core": "^8.4.0" "@capacitor/core": "^8.4.0"
} }
}, },
"node_modules/@capacitor/local-notifications": {
"version": "8.2.0",
"resolved": "https://registry.npmjs.org/@capacitor/local-notifications/-/local-notifications-8.2.0.tgz",
"integrity": "sha512-fvLY0w2w4MiX+DD4+Wv4DOwOLdzKZsMDwAcRv/Juudd+QbKbn69s6cM3xVqPwAiDqfnqsY4/S8xtQD6M73wY2A==",
"license": "MIT",
"peerDependencies": {
"@capacitor/core": ">=8.0.0"
}
},
"node_modules/@esbuild/aix-ppc64": { "node_modules/@esbuild/aix-ppc64": {
"version": "0.25.12", "version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",

View File

@@ -1 +1 @@
{"name":"snakkimo","version":"1.0.0","type":"module","scripts":{"dev":"vite","build":"vite build","preview":"vite preview"},"dependencies":{"@capacitor/cli":"^8.4.0","@capacitor/core":"^8.4.0","@capacitor/ios":"^8.4.0","canvas-confetti":"^1.9.4","capacitor-secure-storage-plugin":"^0.13.0","react":"^19.0.0","react-dom":"^19.0.0"},"devDependencies":{"@types/react":"^19.0.0","@types/react-dom":"^19.0.0","@vitejs/plugin-react":"^4.3.4","vite":"^6.3.1"}} {"name":"snakkimo","version":"1.0.0","type":"module","scripts":{"dev":"vite","build":"vite build","preview":"vite preview"},"dependencies":{"@capacitor/cli":"^8.4.0","@capacitor/core":"^8.4.0","@capacitor/ios":"^8.4.0","@capacitor/local-notifications":"^8.2.0","canvas-confetti":"^1.9.4","capacitor-secure-storage-plugin":"^0.13.0","react":"^19.0.0","react-dom":"^19.0.0"},"devDependencies":{"@types/react":"^19.0.0","@types/react-dom":"^19.0.0","@vitejs/plugin-react":"^4.3.4","vite":"^6.3.1"}}

View File

@@ -1,4 +1,4 @@
import { useState } from 'react' import { useState, useEffect } from 'react'
import { AuthProvider, useAuth } from './context/AuthContext' import { AuthProvider, useAuth } from './context/AuthContext'
import AuthScreen from './components/auth/AuthScreen' import AuthScreen from './components/auth/AuthScreen'
import BottomNav from './BottomNav' import BottomNav from './BottomNav'
@@ -6,6 +6,7 @@ import Feed from './pages/Feed'
import Game from './pages/Game' import Game from './pages/Game'
import Pro from './pages/Pro' import Pro from './pages/Pro'
import Profil from './pages/Profil' import Profil from './pages/Profil'
import { scheduleStreakReminder } from './utils/streakReminder'
const PAGES = { feed: Feed, game: Game, pro: Pro, profil: Profil } const PAGES = { feed: Feed, game: Game, pro: Pro, profil: Profil }
@@ -13,6 +14,11 @@ function AppContent() {
const { user, loading } = useAuth() const { user, loading } = useAuth()
const [page, setPage] = useState('feed') const [page, setPage] = useState('feed')
// Lokale Tages-Erinnerung planen, sobald ein eingeloggter Nutzer da ist (nativ; web no-op).
useEffect(() => {
if (user?.username) scheduleStreakReminder(user.streak_days || 0)
}, [user?.username, user?.streak_days])
if (loading) { if (loading) {
return ( return (
<div style={{ height: '100%', overflow: 'hidden', display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'var(--bg)' }}> <div style={{ height: '100%', overflow: 'hidden', display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'var(--bg)' }}>

View File

@@ -49,6 +49,26 @@
to { opacity: 1; transform: translateY(0) scale(1); } to { opacity: 1; transform: translateY(0) scale(1); }
} }
/* ── Streak-Nudge (Loss-Aversion-Bar oben) ──────────────────── */
.streak-nudge {
position: fixed;
top: 0; left: 0; right: 0;
z-index: 40;
display: flex; align-items: center; justify-content: center; gap: 10px;
padding: calc(env(safe-area-inset-top, 0px) + 9px) 16px 9px;
background: var(--accent);
color: #F5EFE6;
font-size: 13px; font-weight: 700;
box-shadow: var(--shadow-soft);
animation: comboPop 0.3s var(--ease);
}
.streak-nudge-x {
flex: none;
background: rgba(255, 255, 255, 0.18); border: none; color: #F5EFE6;
width: 22px; height: 22px; border-radius: 50%;
font-size: 15px; line-height: 1; cursor: pointer;
}
/* ── Milestone-Overlay (Level-Up / Streak / Tagesziel) ──────── */ /* ── Milestone-Overlay (Level-Up / Streak / Tagesziel) ──────── */
.milestone-overlay { .milestone-overlay {
position: fixed; position: fixed;

View File

@@ -11,6 +11,8 @@ import SessionSummary from '../components/SessionSummary'
import useCountUp from '../hooks/useCountUp' import useCountUp from '../hooks/useCountUp'
import { levelForEp } from '../utils/leveling' import { levelForEp } from '../utils/leveling'
import { playCorrect, playMilestone } from '../utils/sound' import { playCorrect, playMilestone } from '../utils/sound'
import { streakState } from '../utils/streak'
import { cancelStreakReminder } from '../utils/streakReminder'
// Points per answer_type // Points per answer_type
const POINTS = { text: 2, yes_no: 2, word: 3, question: 3 } 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 [milestones, setMilestones] = useState([]) // Queue: Level-Up / Streak / Tagesziel
const [topCat, setTopCat] = useState(null) // stärkste Kategorie für die Session-Summary const [topCat, setTopCat] = useState(null) // stärkste Kategorie für die Session-Summary
const [reloadKey, setReloadKey] = useState(0) // erneutes Laden nach der 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, // Session-Zähler (lokal, für die Abschluss-Summary) + zuletzt bekannter Fortschritt,
// um Level-Up/Streak-Up im saveProgress-Response zu erkennen. // um Level-Up/Streak-Up im saveProgress-Response zu erkennen.
@@ -129,6 +133,9 @@ export default function Feed() {
const correct = result === 'correct' const correct = result === 'correct'
const earned = correct ? item.meta.points : 0 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-Zähler + Combo + Sound
session.current.cards += 1 session.current.cards += 1
if (correct) { 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 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 ( return (
<div className="feed page-enter"> <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 && ( {combo >= 3 && (
<div key={combo} className="combo-pill" aria-hidden="true">🔥 {combo} in Folge</div> <div key={combo} className="combo-pill" aria-hidden="true">🔥 {combo} in Folge</div>
)} )}

View File

@@ -66,6 +66,8 @@
.profil-name { font-family: var(--font-display); font-size: 19px; font-weight: 700; color: var(--text); } .profil-name { font-family: var(--font-display); font-size: 19px; font-weight: 700; color: var(--text); }
.profil-learning { font-size: 12px; color: var(--text-muted); font-weight: 600; } .profil-learning { font-size: 12px; color: var(--text-muted); font-weight: 600; }
.profil-streak { font-size: 12px; color: #C4853A; margin-top: 4px; font-weight: 700; } .profil-streak { font-size: 12px; color: #C4853A; margin-top: 4px; font-weight: 700; }
.streak-warn { color: var(--danger); }
.streak-ok { color: var(--success); }
/* ── Cards ──────────────────────────────────────────────────── */ /* ── Cards ──────────────────────────────────────────────────── */
.card { .card {

View File

@@ -6,6 +6,7 @@ import ProgressRing from '../components/ProgressRing'
import { levelInfo } from '../utils/leveling' import { levelInfo } from '../utils/leveling'
import { categoryTier, capabilitySentence } from '../utils/praise' import { categoryTier, capabilitySentence } from '../utils/praise'
import { isMuted, setMuted } from '../utils/sound' import { isMuted, setMuted } from '../utils/sound'
import { streakState } from '../utils/streak'
// Erdige Palette für die Kategorie-Dots/Balken (harmoniert mit dem Profil-Theme) // Erdige Palette für die Kategorie-Dots/Balken (harmoniert mit dem Profil-Theme)
const CAT_COLORS = ['#C4A85A', '#7A5C3A', '#3D7055', '#B5732E', '#5B7DB1', '#9C5A8A'] const CAT_COLORS = ['#C4A85A', '#7A5C3A', '#3D7055', '#B5732E', '#5B7DB1', '#9C5A8A']
@@ -183,6 +184,7 @@ export default function Profil() {
const toLang = profil?.language_target_id ? langById(profil.language_target_id, langs) : null const toLang = profil?.language_target_id ? langById(profil.language_target_id, langs) : null
const langLabel = toLang ? `${toLang.flag} ${toLang.label}` : (profil?.language_target_titel || 'Zielsprache') const langLabel = toLang ? `${toLang.flag} ${toLang.label}` : (profil?.language_target_titel || 'Zielsprache')
const streak = profil?.streak_days ?? user?.streak_days ?? 0 const streak = profil?.streak_days ?? user?.streak_days ?? 0
const streakSt = streakState(profil?.last_practice_at ?? user?.last_practice_at, streak)
const today = stats?.today const today = stats?.today
const goal = today?.daily_goal_ep || 30 const goal = today?.daily_goal_ep || 30
@@ -238,7 +240,11 @@ export default function Profil() {
<h2 className="profil-name">{greeting}, {displayName}</h2> <h2 className="profil-name">{greeting}, {displayName}</h2>
<p className="profil-learning">lernt {langLabel}</p> <p className="profil-learning">lernt {langLabel}</p>
{streak > 0 && ( {streak > 0 && (
<p className="profil-streak">🔥 {streak} Tag{streak !== 1 ? 'e' : ''} Streak</p> <p className="profil-streak">
🔥 {streak} Tag{streak !== 1 ? 'e' : ''} Streak
{streakSt.state === 'at_risk' && <span className="streak-warn"> · noch {streakSt.hoursLeft} Std heute</span>}
{streakSt.state === 'safe' && <span className="streak-ok"> · heute gesichert </span>}
</p>
)} )}
</div> </div>
</div> </div>

20
src/utils/streak.js Normal file
View File

@@ -0,0 +1,20 @@
// Streak-Zustand aus last_practice_at ableiten — für den Loss-Aversion-Nudge.
// safe = heute schon geübt → Serie sicher
// at_risk = gestern zuletzt, heute noch nicht → läuft heute ab
// broken = Lücke ≥ 2 Tage → Serie effektiv verloren (Backend setzt beim nächsten Üben zurück)
// none = noch nie geübt
export function streakState(lastPracticeAt, streakDays = 0, now = new Date()) {
if (!lastPracticeAt) return { state: 'none', streakDays: 0, hoursLeft: 0 }
const last = new Date(lastPracticeAt)
const startToday = new Date(now); startToday.setHours(0, 0, 0, 0)
const startLast = new Date(last); startLast.setHours(0, 0, 0, 0)
const dayDiff = Math.round((startToday - startLast) / 86400000)
const endOfDay = new Date(now); endOfDay.setHours(24, 0, 0, 0)
const hoursLeft = Math.max(1, Math.ceil((endOfDay - now) / 3600000))
if (dayDiff <= 0) return { state: 'safe', streakDays, hoursLeft }
if (dayDiff === 1) return { state: 'at_risk', streakDays, hoursLeft }
return { state: 'broken', streakDays, hoursLeft }
}

View File

@@ -0,0 +1,49 @@
import { Capacitor } from '@capacitor/core'
// Lokale Tages-Erinnerung plant auf dem Gerät selbst, KEIN APNs/Push-Server nötig.
// Auf Web/Server ein No-op (Plugin nur nativ verfügbar).
const REMINDER_ID = 4711
const REMIND_HOUR = 19
async function getPlugin() {
if (!Capacitor?.isNativePlatform?.()) return null
try {
const mod = await import('@capacitor/local-notifications')
return mod.LocalNotifications
} catch {
return null
}
}
// Erinnerung für heute (oder, wenn 19 Uhr vorbei, morgen) planen.
// Beim nächsten Login wird neu geplant → faktisch täglich, aber nie nervig wiederholend.
export async function scheduleStreakReminder(streakDays = 0) {
const LN = await getPlugin()
if (!LN) return
try {
const perm = await LN.requestPermissions()
if (perm.display !== 'granted') return
await LN.cancel({ notifications: [{ id: REMINDER_ID }] })
const at = new Date(); at.setHours(REMIND_HOUR, 0, 0, 0)
if (at <= new Date()) at.setDate(at.getDate() + 1)
await LN.schedule({
notifications: [{
id: REMINDER_ID,
title: 'Deine Serie wartet 🔥',
body: streakDays > 0
? `Halte deine ${streakDays}-Tage-Serie am Leben kurz üben reicht!`
: 'Zeit für deine kurze Lern-Session!',
schedule: { at },
}],
})
} catch { /* Erinnerung ist optional */ }
}
// Abbrechen, sobald heute geübt wurde (Serie ist gesichert).
export async function cancelStreakReminder() {
const LN = await getPlugin()
if (!LN) return
try { await LN.cancel({ notifications: [{ id: REMINDER_ID }] }) } catch { /* ignore */ }
}