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:
14
package-lock.json
generated
14
package-lock.json
generated
@@ -1,16 +1,17 @@
|
||||
{
|
||||
"name": "language-app",
|
||||
"name": "snakkimo",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "language-app",
|
||||
"name": "snakkimo",
|
||||
"version": "1.0.0",
|
||||
"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",
|
||||
@@ -367,6 +368,15 @@
|
||||
"@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": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
|
||||
|
||||
@@ -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"}}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { AuthProvider, useAuth } from './context/AuthContext'
|
||||
import AuthScreen from './components/auth/AuthScreen'
|
||||
import BottomNav from './BottomNav'
|
||||
@@ -6,6 +6,7 @@ import Feed from './pages/Feed'
|
||||
import Game from './pages/Game'
|
||||
import Pro from './pages/Pro'
|
||||
import Profil from './pages/Profil'
|
||||
import { scheduleStreakReminder } from './utils/streakReminder'
|
||||
|
||||
const PAGES = { feed: Feed, game: Game, pro: Pro, profil: Profil }
|
||||
|
||||
@@ -13,6 +14,11 @@ function AppContent() {
|
||||
const { user, loading } = useAuth()
|
||||
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) {
|
||||
return (
|
||||
<div style={{ height: '100%', overflow: 'hidden', display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'var(--bg)' }}>
|
||||
|
||||
@@ -49,6 +49,26 @@
|
||||
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 {
|
||||
position: fixed;
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -66,6 +66,8 @@
|
||||
.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-streak { font-size: 12px; color: #C4853A; margin-top: 4px; font-weight: 700; }
|
||||
.streak-warn { color: var(--danger); }
|
||||
.streak-ok { color: var(--success); }
|
||||
|
||||
/* ── Cards ──────────────────────────────────────────────────── */
|
||||
.card {
|
||||
|
||||
@@ -6,6 +6,7 @@ import ProgressRing from '../components/ProgressRing'
|
||||
import { levelInfo } from '../utils/leveling'
|
||||
import { categoryTier, capabilitySentence } from '../utils/praise'
|
||||
import { isMuted, setMuted } from '../utils/sound'
|
||||
import { streakState } from '../utils/streak'
|
||||
|
||||
// Erdige Palette für die Kategorie-Dots/Balken (harmoniert mit dem Profil-Theme)
|
||||
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 langLabel = toLang ? `${toLang.flag} ${toLang.label}` : (profil?.language_target_titel || 'Zielsprache')
|
||||
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 goal = today?.daily_goal_ep || 30
|
||||
@@ -238,7 +240,11 @@ export default function Profil() {
|
||||
<h2 className="profil-name">{greeting}, {displayName}</h2>
|
||||
<p className="profil-learning">lernt {langLabel}</p>
|
||||
{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>
|
||||
|
||||
20
src/utils/streak.js
Normal file
20
src/utils/streak.js
Normal 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 }
|
||||
}
|
||||
49
src/utils/streakReminder.js
Normal file
49
src/utils/streakReminder.js
Normal 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 */ }
|
||||
}
|
||||
Reference in New Issue
Block a user