Compare commits
5 Commits
c998242cc6
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 0e51fd8847 | |||
| 14fb0dcbe9 | |||
| 98543979db | |||
| 039d2cbbf4 | |||
| 9e8af27d51 |
25
CLAUDE.md
25
CLAUDE.md
@@ -33,12 +33,31 @@ React 19 + Vite SPA, **no router package**. There are no DB cards/feed entries f
|
|||||||
| Function | Endpoint | Notes |
|
| Function | Endpoint | Notes |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `login` / `registerUser` | `POST /auth/login`, `/auth/register` | return `{ token, userId, needsProfile }` |
|
| `login` / `registerUser` | `POST /auth/login`, `/auth/register` | return `{ token, userId, needsProfile }` |
|
||||||
| `getMe` | `GET /auth/me` | basis for auth check + progress (EP/streak/level) |
|
| `getMe` | `GET /auth/me` | auth check + progress: `total_ep`, `streak_days`, `level`, `ep_into_level`, `ep_to_next_level`, `last_practice_at`; also `language_target_greeting` (Profil-Anrede „Hej, …") |
|
||||||
| `checkUsername` | `GET /auth/check-username` | |
|
| `checkUsername` | `GET /auth/check-username` | |
|
||||||
| `createProfile` | `POST /auth/profile` | username + native/target lang |
|
| `createProfile` | `POST /auth/profile` | username + native/target lang |
|
||||||
| `getLanguageOptions` | `GET /auth/languages` | merged with local `LANG_META` (flag + Web Speech code) |
|
| `getLanguageOptions` | `GET /auth/languages` | merged with local `LANG_META` (flag + Web Speech code) |
|
||||||
| `getFeedPairs` | `GET /auth/feed?lang=&limit=` | returns "pairs", the feed unit |
|
| `getFeedPairs` | `GET /auth/feed?lang=&limit=&exclude=` | returns "pairs", the feed unit |
|
||||||
| `saveProgress` | `POST /auth/progress` | books EP/streak, returns updated `total_ep` |
|
| `saveProgress` | `POST /auth/progress` | books EP/streak; returns the **milestone contract** (see below) |
|
||||||
|
| `getStats` | `GET /auth/stats` | Profil-Daten: `daily`/`today`/`totals`/`skills` + `categories[]` (Punkte je Kategorie) |
|
||||||
|
| `getAchievements` | `GET /auth/achievements` | `[{ key, label, icon, unlocked, unlocked_at }]` für die Profil-Sektion |
|
||||||
|
| `setDailyGoal` | `PUT /auth/goal` | Tagesziel (EP/Tag) setzen; Backend klemmt auf 5–500 |
|
||||||
|
|
||||||
|
`saveProgress` returns `{ total_ep, level, prev_level, streak_days, streak_increased, daily_ep, daily_goal_ep, goal_just_reached, unlocked_achievements }` — `Feed.jsx` leitet daraus die Feier-Momente ab (Level-Up/Streak/Tagesziel/Achievement). Felder degradieren defensiv: fehlen sie (älteres Backend), greifen lokale Fallbacks.
|
||||||
|
|
||||||
|
`src/pages/Profil.jsx` rendert die Begrüßung (`language_target_greeting`), **führt mit Momentum** (`% bis Level X` + Capability-Satz), zeigt Kategorie-Stufen (`stats.categories` + `categoryTier`), Wochenvergleich, Streak-Status, Erfolge-Grid (`getAchievements`) und einen Sound-Toggle.
|
||||||
|
|
||||||
|
### Fortschritts-/Feier-System (Momente)
|
||||||
|
|
||||||
|
Macht Fortschritt spürbar statt nur zählbar. Bausteine:
|
||||||
|
|
||||||
|
- **`src/utils/leveling.js`** — spiegelt die Backend-Level-Kurve (`levelForEp`/`levelInfo`, Level 1 bei 20 EP). Backend ist Single Source of Truth; das ist Fallback + %-Anzeige.
|
||||||
|
- **`MilestoneOverlay`** (`components/`) — Vollbild-Feier, getriggert aus der `saveProgress`-Response. Typen: `level` / `streak` (Schwellen 3/7/14/30/50/100/200/365) / `goal` / `achievement`. Konfetti via `utils/confetti.js`.
|
||||||
|
- **`EpFloat`** — „+N EP" schwebt am Bestätigen-Button auf; EP-Badge zählt hoch (`hooks/useCountUp.js`).
|
||||||
|
- **`SessionSummary`** — ersetzt die End-Sackgasse mit Zahlen + Story-Zeilen.
|
||||||
|
- **Combo** + variables Lob/ermutigendes Fehler-Feedback (`utils/praise.js`, dort auch `categoryTier`/`capabilitySentence`).
|
||||||
|
- **`utils/sound.js`** — dezente WebAudio-Belohnung (Mute-Pref in localStorage).
|
||||||
|
- **`utils/streak.js`** + **`utils/streakReminder.js`** — Loss-Aversion-Nudge im Feed („Serie endet in X Std") und lokale Tages-Erinnerung via **`@capacitor/local-notifications`** (kein APNs nötig; nur nativ, web no-op — braucht `npx cap sync ios`).
|
||||||
|
|
||||||
Several content functions (`getWords`, `getQuestions`, `getActiveLearningPair`, `assetUrl`, …) are **stubs** returning empty/null — content endpoints are not built yet. Don't assume they fetch anything.
|
Several content functions (`getWords`, `getQuestions`, `getActiveLearningPair`, `assetUrl`, …) are **stubs** returning empty/null — content endpoints are not built yet. Don't assume they fetch anything.
|
||||||
|
|
||||||
|
|||||||
14
package-lock.json
generated
14
package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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 { 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)' }}>
|
||||||
|
|||||||
@@ -120,6 +120,14 @@ export async function getStats(userToken) {
|
|||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Alle Erfolge mit Freischalt-Status: [{ key, label, icon, unlocked, unlocked_at }].
|
||||||
|
export async function getAchievements(userToken) {
|
||||||
|
const res = await fetch(`${BASE}/auth/achievements`, { headers: auth(userToken) })
|
||||||
|
const data = await res.json().catch(() => [])
|
||||||
|
if (!res.ok) throw new Error('Erfolge konnten nicht geladen werden.')
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
// Tagesziel (EP/Tag) setzen. Gibt { daily_goal_ep } zurück.
|
// Tagesziel (EP/Tag) setzen. Gibt { daily_goal_ep } zurück.
|
||||||
export async function setDailyGoal(dailyGoalEp, userToken) {
|
export async function setDailyGoal(dailyGoalEp, userToken) {
|
||||||
const res = await fetch(`${BASE}/auth/goal`, {
|
const res = await fetch(`${BASE}/auth/goal`, {
|
||||||
|
|||||||
12
src/components/EpFloat.jsx
Normal file
12
src/components/EpFloat.jsx
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { useEffect } from 'react'
|
||||||
|
import './Moments.css'
|
||||||
|
|
||||||
|
// Kurzes „+N EP", das vom Bestätigen-Button aufschwebt — Belohnung genau dort,
|
||||||
|
// wo das Auge ist. Eltern-Element braucht position: relative.
|
||||||
|
export default function EpFloat({ points, onDone }) {
|
||||||
|
useEffect(() => {
|
||||||
|
const t = setTimeout(() => onDone?.(), 1000)
|
||||||
|
return () => clearTimeout(t)
|
||||||
|
}, [onDone])
|
||||||
|
return <span className="ep-float" aria-hidden="true">+{points} EP</span>
|
||||||
|
}
|
||||||
37
src/components/MilestoneOverlay.jsx
Normal file
37
src/components/MilestoneOverlay.jsx
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { useEffect } from 'react'
|
||||||
|
import confetti from 'canvas-confetti'
|
||||||
|
import './Moments.css'
|
||||||
|
|
||||||
|
const COLORS = ['#C4A85A', '#7A5C2E', '#3D7055', '#E8C9A8', '#fff']
|
||||||
|
|
||||||
|
function celebrate() {
|
||||||
|
const reduce = window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches
|
||||||
|
if (reduce) return
|
||||||
|
confetti({ particleCount: 140, spread: 90, origin: { y: 0.5 }, colors: COLORS, scalar: 1 })
|
||||||
|
setTimeout(() => confetti({ particleCount: 70, spread: 110, origin: { y: 0.45 }, colors: COLORS, scalar: 0.8 }), 220)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Texte je Milestone-Art. value = Level-Nummer / Streak-Tage / Tagesziel-EP.
|
||||||
|
function content(m) {
|
||||||
|
const { kind, value } = m
|
||||||
|
if (kind === 'level') return { cls: '', icon: '🏆', title: `Level ${value} erreicht!`, sub: 'Du wächst spürbar — weiter so.' }
|
||||||
|
if (kind === 'streak') return { cls: 'streak', icon: '🔥', title: `${value} Tage am Stück!`, sub: 'Dranbleiben zahlt sich aus.' }
|
||||||
|
if (kind === 'goal') return { cls: 'goal', icon: '🎯', title: 'Tagesziel erreicht!', sub: 'Stark — heute hast du dein Pensum geschafft.' }
|
||||||
|
if (kind === 'achievement') return { cls: 'streak', icon: m.icon || '🎖️', title: m.label || 'Erfolg!', sub: 'Erfolg freigeschaltet! 🎉' }
|
||||||
|
return { cls: '', icon: '🎉', title: 'Geschafft!', sub: '' }
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MilestoneOverlay({ milestone, onClose }) {
|
||||||
|
useEffect(() => { celebrate() }, [milestone])
|
||||||
|
const { cls, icon, title, sub } = content(milestone)
|
||||||
|
return (
|
||||||
|
<div className="milestone-overlay" onClick={onClose} role="dialog" aria-label={title}>
|
||||||
|
<div className="milestone-card" onClick={e => e.stopPropagation()}>
|
||||||
|
<div className={`milestone-badge ${cls}`} aria-hidden="true">{icon}</div>
|
||||||
|
<h2 className="milestone-title">{title}</h2>
|
||||||
|
{sub && <p className="milestone-sub">{sub}</p>}
|
||||||
|
<button className="milestone-btn" onClick={onClose} autoFocus>Weiter</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
203
src/components/Moments.css
Normal file
203
src/components/Moments.css
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
/* ── EP-Float: „+3 EP" schwebt vom Button auf ───────────────── */
|
||||||
|
.ep-float {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
bottom: 100%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 4px 12px;
|
||||||
|
border-radius: var(--r-pill);
|
||||||
|
background: var(--gold-soft);
|
||||||
|
color: var(--accent-strong);
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-weight: 800;
|
||||||
|
font-size: 15px;
|
||||||
|
white-space: nowrap;
|
||||||
|
pointer-events: none;
|
||||||
|
box-shadow: var(--shadow-soft);
|
||||||
|
animation: epFloatUp 1s var(--ease) forwards;
|
||||||
|
}
|
||||||
|
@keyframes epFloatUp {
|
||||||
|
0% { opacity: 0; transform: translateX(-50%) translateY(8px) scale(0.8); }
|
||||||
|
20% { opacity: 1; transform: translateX(-50%) translateY(0) scale(1.05); }
|
||||||
|
35% { transform: translateX(-50%) translateY(0) scale(1); }
|
||||||
|
100% { opacity: 0; transform: translateX(-50%) translateY(-46px) scale(0.95); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Combo-Pill (oben rechts, neben dem zentrierten EP-Badge) ── */
|
||||||
|
.combo-pill {
|
||||||
|
position: fixed;
|
||||||
|
top: calc(env(safe-area-inset-top, 0px) + 14px);
|
||||||
|
right: 14px;
|
||||||
|
z-index: 30;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
padding: 5px 12px;
|
||||||
|
border-radius: var(--r-pill);
|
||||||
|
background: var(--accent);
|
||||||
|
color: #F5EFE6;
|
||||||
|
font-weight: 800;
|
||||||
|
font-size: 13px;
|
||||||
|
box-shadow: var(--shadow-pop);
|
||||||
|
animation: comboPop 0.32s var(--ease);
|
||||||
|
}
|
||||||
|
@keyframes comboPop {
|
||||||
|
from { opacity: 0; transform: translateY(-6px) scale(0.85); }
|
||||||
|
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;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 100;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: var(--sp-5);
|
||||||
|
background: rgba(58, 37, 21, 0.55);
|
||||||
|
backdrop-filter: blur(3px);
|
||||||
|
-webkit-backdrop-filter: blur(3px);
|
||||||
|
animation: msFade 0.25s var(--ease);
|
||||||
|
}
|
||||||
|
@keyframes msFade { from { opacity: 0; } to { opacity: 1; } }
|
||||||
|
|
||||||
|
.milestone-card {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 320px;
|
||||||
|
background: var(--surface);
|
||||||
|
border-radius: var(--r-lg);
|
||||||
|
padding: var(--sp-6) var(--sp-5) var(--sp-5);
|
||||||
|
text-align: center;
|
||||||
|
box-shadow: var(--shadow-card);
|
||||||
|
animation: msPop 0.4s var(--ease);
|
||||||
|
}
|
||||||
|
@keyframes msPop {
|
||||||
|
from { opacity: 0; transform: translateY(14px) scale(0.92); }
|
||||||
|
to { opacity: 1; transform: none; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.milestone-badge {
|
||||||
|
width: 84px;
|
||||||
|
height: 84px;
|
||||||
|
margin: 0 auto var(--sp-4);
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 40px;
|
||||||
|
background: var(--gold-soft);
|
||||||
|
animation: msBadge 0.6s var(--ease) 0.1s both;
|
||||||
|
}
|
||||||
|
@keyframes msBadge {
|
||||||
|
0% { transform: scale(0.4) rotate(-12deg); opacity: 0; }
|
||||||
|
60% { transform: scale(1.12) rotate(4deg); opacity: 1; }
|
||||||
|
100% { transform: scale(1) rotate(0); }
|
||||||
|
}
|
||||||
|
.milestone-badge.streak { background: #F6E0CB; }
|
||||||
|
.milestone-badge.goal { background: var(--success-soft); }
|
||||||
|
|
||||||
|
.milestone-title {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-strong);
|
||||||
|
margin: 0 0 6px;
|
||||||
|
}
|
||||||
|
.milestone-sub {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin: 0 0 var(--sp-5);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.milestone-btn {
|
||||||
|
width: 100%;
|
||||||
|
padding: 13px;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--r-md);
|
||||||
|
background: var(--accent);
|
||||||
|
color: #F5EFE6;
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 15px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform var(--dur-fast) var(--ease);
|
||||||
|
}
|
||||||
|
.milestone-btn:active { transform: scale(0.98); }
|
||||||
|
|
||||||
|
/* ── Session-Summary (ersetzt die End-Sackgasse) ────────────── */
|
||||||
|
.session-summary {
|
||||||
|
margin: var(--sp-4) auto;
|
||||||
|
width: calc(100% - 2 * var(--sp-4));
|
||||||
|
max-width: 460px;
|
||||||
|
background: var(--surface);
|
||||||
|
border-radius: var(--r-lg);
|
||||||
|
padding: var(--sp-5) var(--sp-5) var(--sp-4);
|
||||||
|
text-align: center;
|
||||||
|
box-shadow: var(--shadow-card);
|
||||||
|
}
|
||||||
|
.session-summary .ss-icon {
|
||||||
|
width: 64px; height: 64px;
|
||||||
|
margin: 0 auto var(--sp-3);
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--gold-soft);
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
font-size: 30px;
|
||||||
|
}
|
||||||
|
.session-summary .ss-title {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 21px; font-weight: 700;
|
||||||
|
color: var(--text-strong);
|
||||||
|
margin: 0 0 2px;
|
||||||
|
}
|
||||||
|
.session-summary .ss-sub {
|
||||||
|
font-size: 13px; color: var(--text-muted); margin: 0 0 var(--sp-4);
|
||||||
|
}
|
||||||
|
.ss-stats {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
margin-bottom: var(--sp-4);
|
||||||
|
}
|
||||||
|
.ss-stat {
|
||||||
|
flex: 1;
|
||||||
|
background: var(--surface-2);
|
||||||
|
border-radius: var(--r-sm);
|
||||||
|
padding: var(--sp-3) var(--sp-2);
|
||||||
|
}
|
||||||
|
.ss-stat .n { display: block; font-size: 20px; font-weight: 800; color: var(--text-strong); }
|
||||||
|
.ss-stat .c { display: block; font-size: 11px; color: var(--text-muted); margin-top: 2px; }
|
||||||
|
.ss-story {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
text-align: left;
|
||||||
|
background: var(--accent-soft);
|
||||||
|
border-radius: var(--r-sm);
|
||||||
|
padding: 10px 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.ss-story .si { font-size: 18px; }
|
||||||
@@ -2,6 +2,7 @@ import { useState, useRef, useMemo } from 'react'
|
|||||||
import confetti from 'canvas-confetti'
|
import confetti from 'canvas-confetti'
|
||||||
import usePairAudio from '../hooks/usePairAudio'
|
import usePairAudio from '../hooks/usePairAudio'
|
||||||
import SelectionOverlay from './SelectionOverlay'
|
import SelectionOverlay from './SelectionOverlay'
|
||||||
|
import EpFloat from './EpFloat'
|
||||||
import { buildChipTimings, activeChipIdAt } from '../utils/chipTimings'
|
import { buildChipTimings, activeChipIdAt } from '../utils/chipTimings'
|
||||||
import { speak } from '../utils/speak'
|
import { speak } from '../utils/speak'
|
||||||
import './PairCards.css'
|
import './PairCards.css'
|
||||||
@@ -65,7 +66,9 @@ export default function PairSentenceCard({ card, onComplete }) {
|
|||||||
const [showTranslation, setShowTranslation] = useState(false)
|
const [showTranslation, setShowTranslation] = useState(false)
|
||||||
const [holding, setHolding] = useState(false)
|
const [holding, setHolding] = useState(false)
|
||||||
const [unlocked, setUnlocked] = useState(false)
|
const [unlocked, setUnlocked] = useState(false)
|
||||||
|
const [showFloat, setShowFloat] = useState(false)
|
||||||
const holdCompleted = useRef(false)
|
const holdCompleted = useRef(false)
|
||||||
|
const points = card.meta?.points ?? 2
|
||||||
|
|
||||||
const lang = card.lang || 'de'
|
const lang = card.lang || 'de'
|
||||||
const native = lang === 'de' ? 'en' : 'de'
|
const native = lang === 'de' ? 'en' : 'de'
|
||||||
@@ -100,6 +103,7 @@ export default function PairSentenceCard({ card, onComplete }) {
|
|||||||
if (done || !unlocked) return
|
if (done || !unlocked) return
|
||||||
setDone(true)
|
setDone(true)
|
||||||
setActiveChip(null)
|
setActiveChip(null)
|
||||||
|
setShowFloat(true)
|
||||||
triggerConfetti()
|
triggerConfetti()
|
||||||
setTimeout(() => onComplete('correct'), 900)
|
setTimeout(() => onComplete('correct'), 900)
|
||||||
}
|
}
|
||||||
@@ -232,7 +236,8 @@ export default function PairSentenceCard({ card, onComplete }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="pair-btn-row" style={{ marginTop: 20 }}>
|
<div className="pair-btn-row" style={{ marginTop: 20, position: 'relative' }}>
|
||||||
|
{showFloat && <EpFloat points={points} onDone={() => setShowFloat(false)} />}
|
||||||
<button
|
<button
|
||||||
className={`pair-btn ${done ? 'pair-btn-correct' : unlocked ? 'pair-btn-primary' : 'pair-btn-locked'}`}
|
className={`pair-btn ${done ? 'pair-btn-correct' : unlocked ? 'pair-btn-primary' : 'pair-btn-locked'}`}
|
||||||
onClick={handleConfirm}
|
onClick={handleConfirm}
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { useState, useMemo } from 'react'
|
|||||||
import confetti from 'canvas-confetti'
|
import confetti from 'canvas-confetti'
|
||||||
import usePairAudio from '../hooks/usePairAudio'
|
import usePairAudio from '../hooks/usePairAudio'
|
||||||
import SelectionOverlay from './SelectionOverlay'
|
import SelectionOverlay from './SelectionOverlay'
|
||||||
|
import EpFloat from './EpFloat'
|
||||||
|
import { praise, encourage } from '../utils/praise'
|
||||||
import { buildChipTimings, activeChipIdAt } from '../utils/chipTimings'
|
import { buildChipTimings, activeChipIdAt } from '../utils/chipTimings'
|
||||||
import { speak } from '../utils/speak'
|
import { speak } from '../utils/speak'
|
||||||
import './PairCards.css'
|
import './PairCards.css'
|
||||||
@@ -58,6 +60,10 @@ export default function PairWordCard({ card, onComplete }) {
|
|||||||
const [confirmed, setConfirmed] = useState(false) // after Bestätigen
|
const [confirmed, setConfirmed] = useState(false) // after Bestätigen
|
||||||
const [isCorrect, setIsCorrect] = useState(false)
|
const [isCorrect, setIsCorrect] = useState(false)
|
||||||
const [activeChip, setActiveChip] = useState(null)
|
const [activeChip, setActiveChip] = useState(null)
|
||||||
|
const [showFloat, setShowFloat] = useState(false)
|
||||||
|
const [praiseWord] = useState(() => praise())
|
||||||
|
const [encourageWord] = useState(() => encourage())
|
||||||
|
const points = card.meta?.points ?? 3
|
||||||
|
|
||||||
const lang = card.lang || 'de'
|
const lang = card.lang || 'de'
|
||||||
const native = lang === 'de' ? 'en' : 'de'
|
const native = lang === 'de' ? 'en' : 'de'
|
||||||
@@ -117,7 +123,7 @@ export default function PairWordCard({ card, onComplete }) {
|
|||||||
const ok = noWrongSelected
|
const ok = noWrongSelected
|
||||||
setIsCorrect(ok)
|
setIsCorrect(ok)
|
||||||
setConfirmed(true)
|
setConfirmed(true)
|
||||||
if (ok) triggerConfetti()
|
if (ok) { setShowFloat(true); triggerConfetti() }
|
||||||
setTimeout(() => onComplete(ok ? 'correct' : 'wrong'), 900)
|
setTimeout(() => onComplete(ok ? 'correct' : 'wrong'), 900)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -188,12 +194,15 @@ export default function PairWordCard({ card, onComplete }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{confirmed && (
|
{confirmed && (
|
||||||
|
<div style={{ position: 'relative' }}>
|
||||||
|
{isCorrect && showFloat && <EpFloat points={points} onDone={() => setShowFloat(false)} />}
|
||||||
<p className={`pair-feedback ${isCorrect ? 'correct' : 'wrong'}`}>
|
<p className={`pair-feedback ${isCorrect ? 'correct' : 'wrong'}`}>
|
||||||
{isCorrect
|
{isCorrect
|
||||||
? '✓ Richtig!'
|
? `✓ ${praiseWord}`
|
||||||
: `✗ Richtig wären: ${options.filter(o => o.correct).map(o => o[lang] || o.de).join(', ')}`
|
: `${encourageWord} Richtig wären: ${options.filter(o => o.correct).map(o => o[lang] || o.de).join(', ')}`
|
||||||
}
|
}
|
||||||
</p>
|
</p>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!confirmed && (
|
{!confirmed && (
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { useState, useMemo } from 'react'
|
|||||||
import confetti from 'canvas-confetti'
|
import confetti from 'canvas-confetti'
|
||||||
import usePairAudio from '../hooks/usePairAudio'
|
import usePairAudio from '../hooks/usePairAudio'
|
||||||
import SelectionOverlay from './SelectionOverlay'
|
import SelectionOverlay from './SelectionOverlay'
|
||||||
|
import EpFloat from './EpFloat'
|
||||||
|
import { praise, encourage } from '../utils/praise'
|
||||||
import { buildChipTimings, activeChipIdAt } from '../utils/chipTimings'
|
import { buildChipTimings, activeChipIdAt } from '../utils/chipTimings'
|
||||||
import { speak } from '../utils/speak'
|
import { speak } from '../utils/speak'
|
||||||
import './PairCards.css'
|
import './PairCards.css'
|
||||||
@@ -47,6 +49,10 @@ function resolveSentence(sentence, placeholders, onChipClick, activeId) {
|
|||||||
export default function PairYesNoCard({ card, onComplete }) {
|
export default function PairYesNoCard({ card, onComplete }) {
|
||||||
const [result, setResult] = useState(null)
|
const [result, setResult] = useState(null)
|
||||||
const [activeChip, setActiveChip] = useState(null)
|
const [activeChip, setActiveChip] = useState(null)
|
||||||
|
const [showFloat, setShowFloat] = useState(false)
|
||||||
|
const [praiseWord] = useState(() => praise())
|
||||||
|
const [encourageWord] = useState(() => encourage())
|
||||||
|
const points = card.meta?.points ?? 2
|
||||||
|
|
||||||
const lang = card.lang || 'de'
|
const lang = card.lang || 'de'
|
||||||
const native = lang === 'de' ? 'en' : 'de'
|
const native = lang === 'de' ? 'en' : 'de'
|
||||||
@@ -88,7 +94,7 @@ export default function PairYesNoCard({ card, onComplete }) {
|
|||||||
const isCorrect = answer === correct
|
const isCorrect = answer === correct
|
||||||
const r = isCorrect ? 'correct' : 'wrong'
|
const r = isCorrect ? 'correct' : 'wrong'
|
||||||
setResult(r)
|
setResult(r)
|
||||||
if (isCorrect) triggerConfetti()
|
if (isCorrect) { setShowFloat(true); triggerConfetti() }
|
||||||
setTimeout(() => onComplete(r), 900)
|
setTimeout(() => onComplete(r), 900)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,9 +141,12 @@ export default function PairYesNoCard({ card, onComplete }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{result && (
|
{result && (
|
||||||
|
<div style={{ position: 'relative' }}>
|
||||||
|
{result === 'correct' && showFloat && <EpFloat points={points} onDone={() => setShowFloat(false)} />}
|
||||||
<p className={`pair-feedback ${result}`}>
|
<p className={`pair-feedback ${result}`}>
|
||||||
{result === 'correct' ? '✓ Richtig!' : `✗ Die Antwort war: ${correct ? 'Ja' : 'Nein'}`}
|
{result === 'correct' ? `✓ ${praiseWord}` : `${encourageWord} Die Antwort war: ${correct ? 'Ja' : 'Nein'}`}
|
||||||
</p>
|
</p>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="pair-btn-row" style={{ marginTop: result ? '12px' : '6px' }}>
|
<div className="pair-btn-row" style={{ marginTop: result ? '12px' : '6px' }}>
|
||||||
|
|||||||
47
src/components/SessionSummary.jsx
Normal file
47
src/components/SessionSummary.jsx
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import './Moments.css'
|
||||||
|
import { categoryTier } from '../utils/praise'
|
||||||
|
|
||||||
|
// Ersetzt die End-Sackgasse („Super! Alle Karten…") durch einen echten
|
||||||
|
// Abschluss-Moment: Zahlen dieser Session + 1–3 Story-Zeilen.
|
||||||
|
export default function SessionSummary({ cards, ep, correct, streak, topCategory, onReload }) {
|
||||||
|
const stories = []
|
||||||
|
if (cards > 0) {
|
||||||
|
stories.push({ icon: '✅', text: `${correct} von ${cards} Karten auf Anhieb richtig` })
|
||||||
|
}
|
||||||
|
if (streak > 0) {
|
||||||
|
stories.push({ icon: '🔥', text: `Tag ${streak} in Folge — Streak gehalten` })
|
||||||
|
}
|
||||||
|
if (topCategory?.label) {
|
||||||
|
const tier = categoryTier(topCategory.points)
|
||||||
|
stories.push({ icon: '📚', text: `„${topCategory.label}" — Stufe ${tier.label}` })
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="session-summary">
|
||||||
|
<div className="ss-icon" aria-hidden="true">🌟</div>
|
||||||
|
<h3 className="ss-title">Stark gemacht!</h3>
|
||||||
|
<p className="ss-sub">Session beendet · {ep} EP gesammelt</p>
|
||||||
|
|
||||||
|
<div className="ss-stats">
|
||||||
|
<div className="ss-stat"><span className="n">{cards}</span><span className="c">Karten</span></div>
|
||||||
|
<div className="ss-stat"><span className="n">{ep}</span><span className="c">EP heute</span></div>
|
||||||
|
<div className="ss-stat"><span className="n">{cards ? Math.round((correct / cards) * 100) : 0}%</span><span className="c">richtig</span></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{stories.length > 0 && (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginBottom: 16 }}>
|
||||||
|
{stories.map((s, i) => (
|
||||||
|
<div key={i} className="ss-story">
|
||||||
|
<span className="si" aria-hidden="true">{s.icon}</span>
|
||||||
|
<span>{s.text}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{onReload && (
|
||||||
|
<button className="milestone-btn" onClick={onReload}>Nach neuen Karten suchen</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
32
src/hooks/useCountUp.js
Normal file
32
src/hooks/useCountUp.js
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
|
||||||
|
// Zählt eine Zahl sanft vom alten auf den neuen Wert hoch (EP-Badge etc.),
|
||||||
|
// damit Belohnung nicht stumm „umspringt". Respektiert prefers-reduced-motion.
|
||||||
|
export default function useCountUp(target, { duration = 600 } = {}) {
|
||||||
|
const [display, setDisplay] = useState(target ?? 0)
|
||||||
|
const fromRef = useRef(target ?? 0)
|
||||||
|
const rafRef = useRef(0)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (target == null) return
|
||||||
|
const from = fromRef.current
|
||||||
|
const to = target
|
||||||
|
if (from === to) { setDisplay(to); return }
|
||||||
|
|
||||||
|
const reduce = window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches
|
||||||
|
if (reduce) { fromRef.current = to; setDisplay(to); return }
|
||||||
|
|
||||||
|
const start = performance.now()
|
||||||
|
const tick = (now) => {
|
||||||
|
const t = Math.min(1, (now - start) / duration)
|
||||||
|
const eased = 1 - Math.pow(1 - t, 3)
|
||||||
|
setDisplay(Math.round(from + (to - from) * eased))
|
||||||
|
if (t < 1) rafRef.current = requestAnimationFrame(tick)
|
||||||
|
else fromRef.current = to
|
||||||
|
}
|
||||||
|
rafRef.current = requestAnimationFrame(tick)
|
||||||
|
return () => cancelAnimationFrame(rafRef.current)
|
||||||
|
}, [target, duration])
|
||||||
|
|
||||||
|
return display
|
||||||
|
}
|
||||||
@@ -6,10 +6,18 @@ import ProgressRing from '../components/ProgressRing'
|
|||||||
import PairSentenceCard from '../components/PairSentenceCard'
|
import PairSentenceCard from '../components/PairSentenceCard'
|
||||||
import PairYesNoCard from '../components/PairYesNoCard'
|
import PairYesNoCard from '../components/PairYesNoCard'
|
||||||
import PairWordCard from '../components/PairWordCard'
|
import PairWordCard from '../components/PairWordCard'
|
||||||
|
import MilestoneOverlay from '../components/MilestoneOverlay'
|
||||||
|
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
|
// 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 }
|
||||||
const PAGE_SIZE = 20
|
const PAGE_SIZE = 20
|
||||||
|
const STREAK_MILESTONES = [3, 7, 14, 30, 50, 100, 200, 365]
|
||||||
|
|
||||||
function buildCard(pair) {
|
function buildCard(pair) {
|
||||||
return {
|
return {
|
||||||
@@ -29,6 +37,20 @@ export default function Feed() {
|
|||||||
const [exhausted, setExhausted] = useState(false)
|
const [exhausted, setExhausted] = useState(false)
|
||||||
const [totalEp, setTotalEp] = useState(null)
|
const [totalEp, setTotalEp] = useState(null)
|
||||||
const [daily, setDaily] = useState(null) // { ep, daily_goal_ep } – wenn /auth/stats verfügbar
|
const [daily, setDaily] = useState(null) // { ep, daily_goal_ep } – wenn /auth/stats verfügbar
|
||||||
|
const [combo, setCombo] = useState(0) // richtige Antworten in Folge (diese Session)
|
||||||
|
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.
|
||||||
|
const session = useRef({ cards: 0, correct: 0, ep: 0 })
|
||||||
|
const progress = useRef({ level: 0, streak: 0 })
|
||||||
|
|
||||||
|
// Sanft hochzählender EP-Wert fürs Badge (statt stummem Umspringen).
|
||||||
|
const displayEp = useCountUp(totalEp ?? 0)
|
||||||
|
|
||||||
// Refs für den Nachlade-Pfad: Re-Entrancy-Schutz + immer aktuelle Kartenliste
|
// Refs für den Nachlade-Pfad: Re-Entrancy-Schutz + immer aktuelle Kartenliste
|
||||||
// (Closure im IntersectionObserver wäre sonst veraltet).
|
// (Closure im IntersectionObserver wäre sonst veraltet).
|
||||||
@@ -51,15 +73,23 @@ export default function Feed() {
|
|||||||
})
|
})
|
||||||
.catch(err => { console.error('Feed load error', err); setEmpty(true) })
|
.catch(err => { console.error('Feed load error', err); setEmpty(true) })
|
||||||
.finally(() => setLoading(false))
|
.finally(() => setLoading(false))
|
||||||
}, [token, lang])
|
}, [token, lang, reloadKey])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
getUserProgress(token)
|
getUserProgress(token)
|
||||||
.then(p => setTotalEp(p.total_ep))
|
.then(p => {
|
||||||
|
setTotalEp(p.total_ep)
|
||||||
|
// Level aus EP über die (mit dem Backend identische) Kurve ableiten, damit Level-Up
|
||||||
|
// unabhängig vom Backend-Deploy korrekt erkannt wird.
|
||||||
|
progress.current = { level: levelForEp(p.total_ep), streak: p.streak_days ?? 0 }
|
||||||
|
})
|
||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
// Tagesziel-Fortschritt – degradiert lautlos, falls /auth/stats noch nicht deployed ist
|
// Tagesziel-Fortschritt + stärkste Kategorie – degradiert lautlos, falls /auth/stats fehlt
|
||||||
getStats(token)
|
getStats(token)
|
||||||
.then(s => { if (s?.today) setDaily(s.today) })
|
.then(s => {
|
||||||
|
if (s?.today) setDaily(s.today)
|
||||||
|
if (s?.categories?.length) setTopCat(s.categories[0])
|
||||||
|
})
|
||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
}, [token])
|
}, [token])
|
||||||
|
|
||||||
@@ -102,18 +132,74 @@ export default function Feed() {
|
|||||||
setDone(prev => new Set([...prev, item.meta.pairId]))
|
setDone(prev => new Set([...prev, item.meta.pairId]))
|
||||||
const correct = result === 'correct'
|
const correct = result === 'correct'
|
||||||
const earned = correct ? item.meta.points : 0
|
const earned = correct ? item.meta.points : 0
|
||||||
saveProgress({
|
|
||||||
pairId: item.meta.pairId,
|
// Heute geübt → Serie gesichert: Nudge weg + geplante Erinnerung abbrechen.
|
||||||
correct,
|
if (!practiced) { setPracticed(true); cancelStreakReminder() }
|
||||||
points: earned,
|
|
||||||
userToken: token,
|
// Session-Zähler + Combo + Sound
|
||||||
|
session.current.cards += 1
|
||||||
|
if (correct) {
|
||||||
|
session.current.correct += 1
|
||||||
|
session.current.ep += earned
|
||||||
|
setCombo(c => c + 1)
|
||||||
|
playCorrect()
|
||||||
|
} else {
|
||||||
|
setCombo(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optimistische Tagesziel-Erkennung als Fallback, falls die API kein
|
||||||
|
// goal_just_reached liefert (älteres Backend).
|
||||||
|
const dayBefore = daily?.ep ?? null
|
||||||
|
const goalEp = daily?.daily_goal_ep ?? null
|
||||||
|
const optimisticGoal = earned > 0 && dayBefore != null && goalEp != null &&
|
||||||
|
dayBefore < goalEp && dayBefore + earned >= goalEp
|
||||||
|
|
||||||
|
saveProgress({ pairId: item.meta.pairId, correct, points: earned, userToken: token })
|
||||||
|
.then(res => {
|
||||||
|
if (res?.total_ep != null) setTotalEp(res.total_ep)
|
||||||
|
if (res?.daily_ep != null && res?.daily_goal_ep != null) {
|
||||||
|
setDaily(d => ({ ...(d || {}), ep: res.daily_ep, daily_goal_ep: res.daily_goal_ep }))
|
||||||
|
}
|
||||||
|
|
||||||
|
const queued = []
|
||||||
|
// Level aus dem aktuellen EP-Stand ableiten (deploy-unabhängig, identische Kurve);
|
||||||
|
// prev = zuletzt bekannter Level vor dieser Karte.
|
||||||
|
const newLevel = res?.total_ep != null ? levelForEp(res.total_ep) : progress.current.level
|
||||||
|
const prevLevel = progress.current.level
|
||||||
|
if (newLevel > prevLevel) queued.push({ kind: 'level', value: newLevel })
|
||||||
|
|
||||||
|
const newStreak = res?.streak_days ?? progress.current.streak
|
||||||
|
const streakUp = res?.streak_increased ?? (newStreak > progress.current.streak)
|
||||||
|
if (streakUp && STREAK_MILESTONES.includes(newStreak)) queued.push({ kind: 'streak', value: newStreak })
|
||||||
|
|
||||||
|
if (res?.goal_just_reached ?? optimisticGoal) {
|
||||||
|
queued.push({ kind: 'goal', value: res?.daily_goal_ep ?? goalEp })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Neu freigeschaltete Erfolge zuletzt feiern
|
||||||
|
for (const a of (res?.unlocked_achievements || [])) {
|
||||||
|
queued.push({ kind: 'achievement', key: a.key, label: a.label, icon: a.icon })
|
||||||
|
}
|
||||||
|
|
||||||
|
progress.current = { level: newLevel, streak: newStreak }
|
||||||
|
if (queued.length) { setMilestones(q => [...q, ...queued]); playMilestone() }
|
||||||
})
|
})
|
||||||
.then(res => { if (res?.total_ep != null) setTotalEp(res.total_ep) })
|
|
||||||
.catch(err => console.error('saveProgress error', err))
|
.catch(err => console.error('saveProgress error', err))
|
||||||
// Tagesziel optimistisch hochzählen
|
|
||||||
|
// Tagesziel optimistisch hochzählen (wird vom Server-Response ggf. überschrieben)
|
||||||
if (earned > 0) setDaily(d => d ? { ...d, ep: (d.ep || 0) + earned } : d)
|
if (earned > 0) setDaily(d => d ? { ...d, ep: (d.ep || 0) + earned } : d)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Nach der Summary erneut nach Karten suchen (Server schließt erledigte Pairs aus).
|
||||||
|
const handleReload = () => {
|
||||||
|
setDone(new Set())
|
||||||
|
setExhausted(false); exhaustedRef.current = false
|
||||||
|
session.current = { cards: 0, correct: 0, ep: 0 }
|
||||||
|
setCombo(0)
|
||||||
|
setLoading(true)
|
||||||
|
setReloadKey(k => k + 1)
|
||||||
|
}
|
||||||
|
|
||||||
const visible = cards.filter(c => !done.has(c.meta.pairId))
|
const visible = cards.filter(c => !done.has(c.meta.pairId))
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
@@ -134,8 +220,32 @@ 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 && (
|
||||||
|
<div key={combo} className="combo-pill" aria-hidden="true">🔥 {combo} in Folge</div>
|
||||||
|
)}
|
||||||
|
{milestones.length > 0 && (
|
||||||
|
<MilestoneOverlay
|
||||||
|
milestone={milestones[0]}
|
||||||
|
onClose={() => setMilestones(q => q.slice(1))}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{totalEp != null && (
|
{totalEp != null && (
|
||||||
<div className="ep-badge">
|
<div className="ep-badge">
|
||||||
<ProgressRing
|
<ProgressRing
|
||||||
@@ -146,7 +256,7 @@ export default function Feed() {
|
|||||||
<span style={{ fontSize: 11 }}>{goalPct >= 1 ? '✓' : '⭐'}</span>
|
<span style={{ fontSize: 11 }}>{goalPct >= 1 ? '✓' : '⭐'}</span>
|
||||||
</ProgressRing>
|
</ProgressRing>
|
||||||
<span className="ep-value">
|
<span className="ep-value">
|
||||||
{totalEp}<small>EP{daily ? ` · ${daily.ep || 0}/${daily.daily_goal_ep} heute` : ''}</small>
|
{displayEp}<small>EP{daily ? ` · ${daily.ep || 0}/${daily.daily_goal_ep} heute` : ''}</small>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -166,7 +276,18 @@ export default function Feed() {
|
|||||||
{/* Nachlade-Bereich */}
|
{/* Nachlade-Bereich */}
|
||||||
{!exhausted && <div ref={sentinelRef} className="feed-sentinel" aria-hidden="true" />}
|
{!exhausted && <div ref={sentinelRef} className="feed-sentinel" aria-hidden="true" />}
|
||||||
{loadingMore && <div className="feed-empty">Lade weitere Karten…</div>}
|
{loadingMore && <div className="feed-empty">Lade weitere Karten…</div>}
|
||||||
{exhausted && <div className="feed-empty">Super! Alle Karten abgeschlossen. 🎉</div>}
|
{exhausted && (
|
||||||
|
visible.length === 0
|
||||||
|
? <SessionSummary
|
||||||
|
cards={session.current.cards}
|
||||||
|
ep={session.current.ep}
|
||||||
|
correct={session.current.correct}
|
||||||
|
streak={progress.current.streak}
|
||||||
|
topCategory={topCat}
|
||||||
|
onReload={handleReload}
|
||||||
|
/>
|
||||||
|
: <div className="feed-empty">Super! Alle Karten abgeschlossen. 🎉</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
@@ -110,6 +112,35 @@
|
|||||||
.level-pill { background: var(--accent); color: var(--bg); font-size: 11px; font-weight: 800; padding: 3px 11px; border-radius: var(--r-pill); }
|
.level-pill { background: var(--accent); color: var(--bg); font-size: 11px; font-weight: 800; padding: 3px 11px; border-radius: var(--r-pill); }
|
||||||
.level-hint { font-size: 11px; color: var(--text-muted); }
|
.level-hint { font-size: 11px; color: var(--text-muted); }
|
||||||
|
|
||||||
|
/* Capability-Satz: „was du jetzt kannst" statt nur Zahlen */
|
||||||
|
.capability-line {
|
||||||
|
margin-top: var(--sp-3);
|
||||||
|
padding-top: var(--sp-3);
|
||||||
|
border-top: 1px solid var(--border-soft);
|
||||||
|
font-size: 13px;
|
||||||
|
font-style: italic;
|
||||||
|
color: var(--text);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Wochenvergleich */
|
||||||
|
.week-compare { font-size: 12px; font-weight: 700; margin-bottom: var(--sp-3); }
|
||||||
|
.week-compare.up { color: var(--success); }
|
||||||
|
.week-compare.down { color: var(--text-muted); }
|
||||||
|
|
||||||
|
/* Sound-Toggle (spiegelt den Logout-Button, oben links) */
|
||||||
|
.profil-sound {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(env(safe-area-inset-top) + 20px);
|
||||||
|
left: 4px;
|
||||||
|
background: none; border: none; cursor: pointer;
|
||||||
|
padding: 6px; border-radius: var(--r-sm);
|
||||||
|
color: var(--text-soft);
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
transition: color var(--dur-fast), background var(--dur-fast);
|
||||||
|
}
|
||||||
|
.profil-sound:hover { color: var(--accent); background: var(--accent-soft); }
|
||||||
|
|
||||||
/* ── Wochen-Graph ── */
|
/* ── Wochen-Graph ── */
|
||||||
.weekbars { display: flex; align-items: flex-end; gap: var(--sp-2); height: 96px; }
|
.weekbars { display: flex; align-items: flex-end; gap: var(--sp-2); height: 96px; }
|
||||||
.weekbar-col { flex: 1; display: flex; flex-direction: column; align-items: center; gap: 6px; height: 100%; }
|
.weekbar-col { flex: 1; display: flex; flex-direction: column; align-items: center; gap: 6px; height: 100%; }
|
||||||
@@ -154,7 +185,30 @@
|
|||||||
.cat-head { display: flex; align-items: center; gap: var(--sp-2); }
|
.cat-head { display: flex; align-items: center; gap: var(--sp-2); }
|
||||||
.cat-dot { width: 9px; height: 9px; border-radius: 50%; flex-shrink: 0; }
|
.cat-dot { width: 9px; height: 9px; border-radius: 50%; flex-shrink: 0; }
|
||||||
.cat-label { flex: 1; font-size: 13px; font-weight: 700; color: var(--text); }
|
.cat-label { flex: 1; font-size: 13px; font-weight: 700; color: var(--text); }
|
||||||
|
.cat-tier { font-size: 11px; font-weight: 600; color: var(--text-soft); }
|
||||||
.cat-points { font-size: 12px; font-weight: 800; color: var(--accent); }
|
.cat-points { font-size: 12px; font-weight: 800; color: var(--accent); }
|
||||||
|
|
||||||
|
/* ── Erfolge ── */
|
||||||
|
.ach-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: var(--sp-2);
|
||||||
|
}
|
||||||
|
.ach-tile {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: var(--sp-3) var(--sp-2);
|
||||||
|
border-radius: var(--r-sm);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.ach-tile.on { background: var(--gold-soft); }
|
||||||
|
.ach-tile.off { background: var(--surface-2); opacity: 0.7; }
|
||||||
|
.ach-icon { font-size: 24px; line-height: 1; }
|
||||||
|
.ach-tile.off .ach-icon { filter: grayscale(1); opacity: 0.6; }
|
||||||
|
.ach-label { font-size: 11px; font-weight: 700; color: var(--text); line-height: 1.25; }
|
||||||
|
.ach-tile.off .ach-label { color: var(--text-muted); }
|
||||||
.cat-bar { height: 6px; width: 100%; background: var(--surface-2); border-radius: var(--r-pill); overflow: hidden; }
|
.cat-bar { height: 6px; width: 100%; background: var(--surface-2); border-radius: var(--r-pill); overflow: hidden; }
|
||||||
.cat-bar-fill { height: 100%; border-radius: var(--r-pill); transition: width 0.6s var(--ease); }
|
.cat-bar-fill { height: 100%; border-radius: var(--r-pill); transition: width 0.6s var(--ease); }
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState, useMemo } from 'react'
|
||||||
import './Profil.css'
|
import './Profil.css'
|
||||||
import { useAuth } from '../context/AuthContext'
|
import { useAuth } from '../context/AuthContext'
|
||||||
import { getProfilData, getStats, getLanguageOptions, langById } from '../api/directus'
|
import { getProfilData, getStats, getLanguageOptions, langById, getAchievements } from '../api/directus'
|
||||||
import ProgressRing from '../components/ProgressRing'
|
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)
|
// 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']
|
||||||
@@ -20,6 +24,24 @@ function LogoutButton() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function SoundToggle() {
|
||||||
|
const [muted, setM] = useState(isMuted())
|
||||||
|
const toggle = () => { const next = !muted; setMuted(next); setM(next) }
|
||||||
|
return (
|
||||||
|
<button onClick={toggle} title={muted ? 'Töne an' : 'Töne aus'} className="profil-sound">
|
||||||
|
{muted ? (
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><line x1="23" y1="9" x2="17" y2="15"/><line x1="17" y1="9" x2="23" y2="15"/>
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><path d="M15.54 8.46a5 5 0 0 1 0 7.07"/><path d="M19.07 4.93a10 10 0 0 1 0 14.14"/>
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Radar Chart (echte Skill-Daten) ─────────────────────────── */
|
/* ── Radar Chart (echte Skill-Daten) ─────────────────────────── */
|
||||||
function RadarChart({ skills, animate }) {
|
function RadarChart({ skills, animate }) {
|
||||||
const size = 220, cx = 110, cy = 105, r = 70, n = skills.length
|
const size = 220, cx = 110, cy = 105, r = 70, n = skills.length
|
||||||
@@ -125,6 +147,7 @@ export default function Profil() {
|
|||||||
const [profil, setProfil] = useState(null)
|
const [profil, setProfil] = useState(null)
|
||||||
const [stats, setStats] = useState(null)
|
const [stats, setStats] = useState(null)
|
||||||
const [langs, setLangs] = useState([])
|
const [langs, setLangs] = useState([])
|
||||||
|
const [achievements, setAchievements] = useState([])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const t = setTimeout(() => setRadarReady(true), 120)
|
const t = setTimeout(() => setRadarReady(true), 120)
|
||||||
@@ -139,6 +162,8 @@ export default function Profil() {
|
|||||||
} catch { /* Fallback unten */ }
|
} catch { /* Fallback unten */ }
|
||||||
// Stats getrennt – degradiert lautlos, falls /auth/stats noch nicht deployed ist
|
// Stats getrennt – degradiert lautlos, falls /auth/stats noch nicht deployed ist
|
||||||
try { setStats(await getStats(token)) } catch { /* kein Tracking verfügbar */ }
|
try { setStats(await getStats(token)) } catch { /* kein Tracking verfügbar */ }
|
||||||
|
// Erfolge – degradiert lautlos, falls /auth/achievements noch nicht deployed ist
|
||||||
|
try { setAchievements(await getAchievements(token)) } catch { /* keine Erfolge verfügbar */ }
|
||||||
}
|
}
|
||||||
load()
|
load()
|
||||||
}, [token])
|
}, [token])
|
||||||
@@ -147,13 +172,19 @@ export default function Profil() {
|
|||||||
const initials = displayName.slice(0, 2).toUpperCase()
|
const initials = displayName.slice(0, 2).toUpperCase()
|
||||||
const greeting = profil?.language_target_greeting || 'Hallo'
|
const greeting = profil?.language_target_greeting || 'Hallo'
|
||||||
const points = profil?.total_ep ?? user?.total_ep ?? 0
|
const points = profil?.total_ep ?? user?.total_ep ?? 0
|
||||||
const level = profil?.level ?? Math.floor(points / 500)
|
const li = levelInfo(points)
|
||||||
const epIntoLevel = points - level * 500
|
// Level + Progress immer als Set aus EINER Quelle (sonst „Level 0 / 33 % bis Level 1"-
|
||||||
const epPerLevel = 500
|
// Mischmasch, solange das Backend die neue Kurve noch nicht deployed hat).
|
||||||
|
const hasApiLevel = profil?.ep_to_next_level != null
|
||||||
|
const level = hasApiLevel ? profil.level : li.level
|
||||||
|
const epIntoLevel = hasApiLevel ? profil.ep_into_level : li.epIntoLevel
|
||||||
|
const epToNext = hasApiLevel ? profil.ep_to_next_level : li.epToNextLevel
|
||||||
|
const epPerLevel = Math.max(1, epIntoLevel + epToNext)
|
||||||
const xpPct = Math.min((epIntoLevel / epPerLevel) * 100, 100)
|
const xpPct = Math.min((epIntoLevel / epPerLevel) * 100, 100)
|
||||||
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
|
||||||
@@ -166,9 +197,25 @@ export default function Profil() {
|
|||||||
const accuracyPct = totals ? Math.round((totals.accuracy || 0) * 100) : null
|
const accuracyPct = totals ? Math.round((totals.accuracy || 0) * 100) : null
|
||||||
const categories = stats?.categories || []
|
const categories = stats?.categories || []
|
||||||
const maxCatPoints = Math.max(1, ...categories.map(c => c.points))
|
const maxCatPoints = Math.max(1, ...categories.map(c => c.points))
|
||||||
|
const capability = capabilitySentence(categories)
|
||||||
|
|
||||||
|
// Wochenvergleich (soziale/zeitliche Validierung) aus dem Tagesverlauf.
|
||||||
|
const weekCompare = useMemo(() => {
|
||||||
|
const startOfToday = new Date(); startOfToday.setHours(0, 0, 0, 0)
|
||||||
|
let thisW = 0, lastW = 0
|
||||||
|
for (const d of daily) {
|
||||||
|
const dt = new Date(d.date); dt.setHours(0, 0, 0, 0)
|
||||||
|
const diff = Math.round((startOfToday - dt) / 86400000)
|
||||||
|
if (diff >= 0 && diff < 7) thisW += d.ep || 0
|
||||||
|
else if (diff >= 7 && diff < 14) lastW += d.ep || 0
|
||||||
|
}
|
||||||
|
const delta = lastW > 0 ? Math.round(((thisW - lastW) / lastW) * 100) : null
|
||||||
|
return { thisW, lastW, delta }
|
||||||
|
}, [daily])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="profil page-enter">
|
<div className="profil page-enter">
|
||||||
|
<SoundToggle />
|
||||||
<LogoutButton />
|
<LogoutButton />
|
||||||
|
|
||||||
{/* ── Header ── */}
|
{/* ── Header ── */}
|
||||||
@@ -193,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>
|
||||||
@@ -213,24 +264,30 @@ export default function Profil() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Fortschritt (Level/EP) ── */}
|
{/* ── Fortschritt (Level/EP) – führt mit Momentum statt nackter Zahl ── */}
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<p className="card-title">DEIN FORTSCHRITT</p>
|
<p className="card-title">DEIN FORTSCHRITT</p>
|
||||||
<div className="xp-row">
|
<div className="xp-row">
|
||||||
<span className="lang-label">{langLabel}</span>
|
<span className="level-pill">Level {level}</span>
|
||||||
<span className="xp-value">{points.toLocaleString('de')} EP gesamt</span>
|
<span className="xp-value">{Math.round(xpPct)} % bis Level {level + 1}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="xp-bar"><div className="xp-fill" style={{ width: `${xpPct}%` }} /></div>
|
<div className="xp-bar"><div className="xp-fill" style={{ width: `${xpPct}%` }} /></div>
|
||||||
<div className="level-row">
|
<div className="level-row">
|
||||||
<span className="level-pill">Level {level}</span>
|
<span className="level-hint">noch {epToNext.toLocaleString('de')} EP</span>
|
||||||
<span className="level-hint">{(epPerLevel - epIntoLevel).toLocaleString('de')} EP bis Level {level + 1}</span>
|
<span className="level-hint">{points.toLocaleString('de')} EP gesamt · {langLabel}</span>
|
||||||
</div>
|
</div>
|
||||||
|
{capability && <p className="capability-line">{capability}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Wochen-Aktivität ── */}
|
{/* ── Wochen-Aktivität ── */}
|
||||||
{stats && (
|
{stats && (
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<p className="card-title">DIESE WOCHE</p>
|
<p className="card-title">DIESE WOCHE</p>
|
||||||
|
{weekCompare.delta != null && (
|
||||||
|
<p className={`week-compare ${weekCompare.delta >= 0 ? 'up' : 'down'}`}>
|
||||||
|
{weekCompare.thisW} EP · {weekCompare.delta >= 0 ? '▲' : '▼'} {Math.abs(weekCompare.delta)} % {weekCompare.delta >= 0 ? 'mehr' : 'weniger'} als letzte Woche
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
<WeekBars daily={daily} goal={goal} />
|
<WeekBars daily={daily} goal={goal} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -247,7 +304,10 @@ export default function Profil() {
|
|||||||
<div key={c.id} className="cat-row">
|
<div key={c.id} className="cat-row">
|
||||||
<div className="cat-head">
|
<div className="cat-head">
|
||||||
<span className="cat-dot" style={{ background: color }} />
|
<span className="cat-dot" style={{ background: color }} />
|
||||||
<span className="cat-label">{c.label || 'Allgemein'}</span>
|
<span className="cat-label">
|
||||||
|
{c.label || 'Allgemein'}
|
||||||
|
<span className="cat-tier"> · {categoryTier(c.points).label}</span>
|
||||||
|
</span>
|
||||||
<span className="cat-points">{c.points} P</span>
|
<span className="cat-points">{c.points} P</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="cat-bar">
|
<div className="cat-bar">
|
||||||
@@ -264,6 +324,21 @@ export default function Profil() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* ── Erfolge ── */}
|
||||||
|
{achievements.length > 0 && (
|
||||||
|
<div className="card">
|
||||||
|
<p className="card-title">ERFOLGE · {achievements.filter(a => a.unlocked).length}/{achievements.length}</p>
|
||||||
|
<div className="ach-grid">
|
||||||
|
{achievements.map(a => (
|
||||||
|
<div key={a.key} className={`ach-tile ${a.unlocked ? 'on' : 'off'}`} title={a.label}>
|
||||||
|
<span className="ach-icon">{a.unlocked ? a.icon : '🔒'}</span>
|
||||||
|
<span className="ach-label">{a.label}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* ── Streak-Kalender ── */}
|
{/* ── Streak-Kalender ── */}
|
||||||
{stats && (
|
{stats && (
|
||||||
<div className="card">
|
<div className="card">
|
||||||
|
|||||||
24
src/utils/leveling.js
Normal file
24
src/utils/leveling.js
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
// Spiegelt die Backend-Kurve (snakkimo-API/src/lib/leveling.js).
|
||||||
|
// Dient als Fallback, falls die API noch keine level/ep_into_level-Felder liefert,
|
||||||
|
// und für die %-Anzeige innerhalb eines Levels.
|
||||||
|
export function epForLevel(level) {
|
||||||
|
return level <= 0 ? 0 : 5 * level * (level + 3)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function levelForEp(ep) {
|
||||||
|
const e = Math.max(0, ep || 0)
|
||||||
|
return Math.floor((-15 + Math.sqrt(225 + 20 * e)) / 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function levelInfo(ep) {
|
||||||
|
const e = Math.max(0, ep || 0)
|
||||||
|
const level = levelForEp(e)
|
||||||
|
const base = epForLevel(level)
|
||||||
|
const next = epForLevel(level + 1)
|
||||||
|
return {
|
||||||
|
level,
|
||||||
|
epIntoLevel: e - base,
|
||||||
|
epToNextLevel: next - e,
|
||||||
|
epForNextLevel: next - base,
|
||||||
|
}
|
||||||
|
}
|
||||||
42
src/utils/praise.js
Normal file
42
src/utils/praise.js
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
// Variables Erfolgs-Feedback statt immer „✓ Richtig!" — kleine Abwechslung
|
||||||
|
// hält den Belohnungsmoment frisch.
|
||||||
|
const PRAISE = ['Stark!', 'Genau!', 'Sitzt!', 'Perfekt!', 'Bravo!', 'Sauber!', 'Weiter so!']
|
||||||
|
export function praise() {
|
||||||
|
return PRAISE[Math.floor(Math.random() * PRAISE.length)]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ermutigende Einleitung für falsche Antworten — Fehler als Lernschritt rahmen,
|
||||||
|
// nicht als Bestrafung.
|
||||||
|
const ENCOURAGE = ['Fast!', 'Kein Problem —', 'Gleich hast du\'s!', 'Daraus lernst du:']
|
||||||
|
export function encourage() {
|
||||||
|
return ENCOURAGE[Math.floor(Math.random() * ENCOURAGE.length)]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kategorie-Meisterungsstufen (MVP, client-seitig). Idealerweise später
|
||||||
|
// backend-/content-getrieben mit echten Schwellen je Thema → Plan C3.
|
||||||
|
const TIERS = [
|
||||||
|
{ min: 0, label: 'Erste Schritte' },
|
||||||
|
{ min: 5, label: 'Vertraut' },
|
||||||
|
{ min: 12, label: 'Sicher' },
|
||||||
|
{ min: 25, label: 'Gemeistert' },
|
||||||
|
]
|
||||||
|
export function categoryTier(points) {
|
||||||
|
let tier = TIERS[0]
|
||||||
|
for (const t of TIERS) if ((points || 0) >= t.min) tier = t
|
||||||
|
return tier
|
||||||
|
}
|
||||||
|
|
||||||
|
// Punkte bis zur nächsten Stufe (für „2 P bis Vertraut").
|
||||||
|
export function pointsToNextTier(points) {
|
||||||
|
const p = points || 0
|
||||||
|
for (const t of TIERS) if (p < t.min) return { label: t.label, remaining: t.min - p }
|
||||||
|
return null // bereits höchste Stufe
|
||||||
|
}
|
||||||
|
|
||||||
|
// Story-Satz für die stärkste Kategorie — „was du jetzt kannst", nicht nur Zahlen.
|
||||||
|
export function capabilitySentence(categories) {
|
||||||
|
if (!categories?.length) return null
|
||||||
|
const top = categories[0]
|
||||||
|
const tier = categoryTier(top.points)
|
||||||
|
return `Dein stärkstes Thema: „${top.label}" — Stufe ${tier.label}.`
|
||||||
|
}
|
||||||
56
src/utils/sound.js
Normal file
56
src/utils/sound.js
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
// Dezente Belohnungs-Sounds via WebAudio (kein Asset-Download nötig).
|
||||||
|
// Mute-Pref in localStorage; standardmäßig an.
|
||||||
|
const KEY = 'snakkimo_sound'
|
||||||
|
let ctx = null
|
||||||
|
|
||||||
|
function audioCtx() {
|
||||||
|
if (typeof window === 'undefined') return null
|
||||||
|
if (!ctx) {
|
||||||
|
const C = window.AudioContext || window.webkitAudioContext
|
||||||
|
ctx = C ? new C() : null
|
||||||
|
}
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isMuted() {
|
||||||
|
try { return localStorage.getItem(KEY) === 'off' } catch { return false }
|
||||||
|
}
|
||||||
|
export function setMuted(muted) {
|
||||||
|
try { localStorage.setItem(KEY, muted ? 'off' : 'on') } catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
function tone(freq, startOffset, dur, gain = 0.05) {
|
||||||
|
const ac = audioCtx()
|
||||||
|
if (!ac) return
|
||||||
|
const osc = ac.createOscillator()
|
||||||
|
const g = ac.createGain()
|
||||||
|
osc.type = 'sine'
|
||||||
|
osc.frequency.value = freq
|
||||||
|
osc.connect(g); g.connect(ac.destination)
|
||||||
|
const t = ac.currentTime + startOffset
|
||||||
|
g.gain.setValueAtTime(0.0001, t)
|
||||||
|
g.gain.linearRampToValueAtTime(gain, t + 0.02)
|
||||||
|
g.gain.exponentialRampToValueAtTime(0.0001, t + dur)
|
||||||
|
osc.start(t); osc.stop(t + dur + 0.02)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kurzer, freundlicher Zwei-Ton bei richtiger Antwort.
|
||||||
|
export function playCorrect() {
|
||||||
|
if (isMuted()) return
|
||||||
|
try {
|
||||||
|
const ac = audioCtx()
|
||||||
|
if (ac?.state === 'suspended') ac.resume()
|
||||||
|
tone(660, 0, 0.12)
|
||||||
|
tone(880, 0.07, 0.16)
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Aufsteigende Fanfare bei Milestones (Level-Up, Streak, Tagesziel).
|
||||||
|
export function playMilestone() {
|
||||||
|
if (isMuted()) return
|
||||||
|
try {
|
||||||
|
const ac = audioCtx()
|
||||||
|
if (ac?.state === 'suspended') ac.resume()
|
||||||
|
;[523, 659, 784, 1047].forEach((f, i) => tone(f, i * 0.09, 0.24, 0.06))
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
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