diff --git a/package-lock.json b/package-lock.json
index 8781883..9a2c9e4 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -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",
diff --git a/package.json b/package.json
index 33dd44b..8fd4751 100644
--- a/package.json
+++ b/package.json
@@ -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"}}
\ No newline at end of file
+{"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"}}
\ No newline at end of file
diff --git a/src/App.jsx b/src/App.jsx
index ae079ff..9a087b7 100644
--- a/src/App.jsx
+++ b/src/App.jsx
@@ -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 (
diff --git a/src/components/Moments.css b/src/components/Moments.css
index ba3d83e..508c287 100644
--- a/src/components/Moments.css
+++ b/src/components/Moments.css
@@ -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;
diff --git a/src/pages/Feed.jsx b/src/pages/Feed.jsx
index d754233..825f966 100644
--- a/src/pages/Feed.jsx
+++ b/src/pages/Feed.jsx
@@ -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 (
+ {showStreakNudge && (
+
+
+ {streak.state === 'at_risk'
+ ? `🔥 ${streak.streakDays}-Tage-Serie — nur noch ${streak.hoursLeft} Std heute!`
+ : '🌱 Starte heute deine Serie neu'}
+
+
+
+ )}
{combo >= 3 && (
🔥 {combo} in Folge
)}
diff --git a/src/pages/Profil.css b/src/pages/Profil.css
index 29c6e8c..5ec3c22 100644
--- a/src/pages/Profil.css
+++ b/src/pages/Profil.css
@@ -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 {
diff --git a/src/pages/Profil.jsx b/src/pages/Profil.jsx
index 633761e..8ae3b7a 100644
--- a/src/pages/Profil.jsx
+++ b/src/pages/Profil.jsx
@@ -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() {
{greeting}, {displayName}
lernt {langLabel}
{streak > 0 && (
-
🔥 {streak} Tag{streak !== 1 ? 'e' : ''} Streak
+
+ 🔥 {streak} Tag{streak !== 1 ? 'e' : ''} Streak
+ {streakSt.state === 'at_risk' && · noch {streakSt.hoursLeft} Std heute}
+ {streakSt.state === 'safe' && · heute gesichert ✓}
+
)}
diff --git a/src/utils/streak.js b/src/utils/streak.js
new file mode 100644
index 0000000..6ea9afe
--- /dev/null
+++ b/src/utils/streak.js
@@ -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 }
+}
diff --git a/src/utils/streakReminder.js b/src/utils/streakReminder.js
new file mode 100644
index 0000000..aa0e220
--- /dev/null
+++ b/src/utils/streakReminder.js
@@ -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 */ }
+}