From 14fb0dcbe932ae84be7d73f92ee61f68550e0c2a Mon Sep 17 00:00:00 2001 From: admin Date: Wed, 17 Jun 2026 22:14:26 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20Streak-Reminder=20=E2=80=93=20In-App-Nu?= =?UTF-8?q?dge=20+=20lokale=20Tages-Erinnerung?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- package-lock.json | 14 +++++++++-- package.json | 2 +- src/App.jsx | 8 +++++- src/components/Moments.css | 20 +++++++++++++++ src/pages/Feed.jsx | 22 +++++++++++++++++ src/pages/Profil.css | 2 ++ src/pages/Profil.jsx | 8 +++++- src/utils/streak.js | 20 +++++++++++++++ src/utils/streakReminder.js | 49 +++++++++++++++++++++++++++++++++++++ 9 files changed, 140 insertions(+), 5 deletions(-) create mode 100644 src/utils/streak.js create mode 100644 src/utils/streakReminder.js 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 && ( )} 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 */ } +}