From bb863640c03a519aaa02e7777f92e36d924498e5 Mon Sep 17 00:00:00 2001 From: admin Date: Wed, 17 Jun 2026 21:43:36 +0200 Subject: [PATCH] feat: progressive Level-Kurve + atomarer /auth/progress-Vertrag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - levelForEp/levelInfo (Level 1 bei 20 EP statt fixer 500/Level), src/lib/leveling.js - /auth/me liefert level + ep_into_level + ep_to_next_level - /auth/progress liefert prev_level, streak_increased, daily_ep, daily_goal_ep, goal_just_reached (CTE fängt die Pre-Update-Werte, damit Level-Up/Streak-Up atomar erkennbar sind) Co-Authored-By: Claude Opus 4.8 --- src/lib/leveling.js | 30 +++++++++++++++++++++++++++ src/routes/auth.js | 49 ++++++++++++++++++++++++++++++++------------- 2 files changed, 65 insertions(+), 14 deletions(-) create mode 100644 src/lib/leveling.js diff --git a/src/lib/leveling.js b/src/lib/leveling.js new file mode 100644 index 0000000..eb13643 --- /dev/null +++ b/src/lib/leveling.js @@ -0,0 +1,30 @@ +// Progressive Level-Kurve — Single Source of Truth fürs Backend. +// Kumulative EP, die für Level n nötig sind: 5·n·(n+3). +// Level 1 → 20 EP, Level 2 → 50, Level 3 → 90, Level 4 → 140, Level 5 → 200 … +// Früh schnelle Level (erste Level fallen in der ersten Session), danach sanft steiler. +function epForLevel(level) { + if (level <= 0) return 0; + return 5 * level * (level + 3); +} + +// Höchstes n mit 5n²+15n ≤ ep → n ≤ (−15 + √(225 + 20·ep)) / 10 +function levelForEp(ep) { + const e = Math.max(0, ep || 0); + return Math.floor((-15 + Math.sqrt(225 + 20 * e)) / 10); +} + +// Level + Fortschritt innerhalb des Levels (für Momentum-Anzeige im Client). +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, + ep_into_level: e - base, + ep_to_next_level: next - e, + ep_for_next_level: next - base, + }; +} + +module.exports = { epForLevel, levelForEp, levelInfo }; diff --git a/src/routes/auth.js b/src/routes/auth.js index b4b4b7b..a36b329 100644 --- a/src/routes/auth.js +++ b/src/routes/auth.js @@ -2,6 +2,7 @@ const router = require('express').Router(); const bcrypt = require('bcryptjs'); const jwt = require('jsonwebtoken'); const { query } = require('../db'); +const { levelForEp, levelInfo } = require('../lib/leveling'); function signToken(user) { return jwt.sign( @@ -153,7 +154,7 @@ router.get('/me', requireJwt, async (req, res, next) => { ); if (!r.rows.length) return res.status(404).json({ error: 'User not found' }); const row = r.rows[0]; - row.level = Math.floor((row.total_ep || 0) / 500); + Object.assign(row, levelInfo(row.total_ep)); // level + ep_into_level + ep_to_next_level res.json(row); } catch (err) { next(err); } }); @@ -179,38 +180,58 @@ router.post('/progress', requireJwt, async (req, res, next) => { [userId, pair_id, isCorrect ? 1 : 0, isCorrect ? 0 : 1, pts] ); - // Tagesverlauf upserten (für Streak-Kalender, Wochengraph, Tagesziel) - await query( + // Tagesverlauf upserten (für Streak-Kalender, Wochengraph, Tagesziel). + // RETURNING ep_earned = NEUER Tagesstand → Tagesziel-Übergang erkennbar. + const day = await query( `INSERT INTO user_daily_activity (user_id, activity_date, ep_earned, cards_done, correct_count) VALUES ($1, CURRENT_DATE, $2, 1, $3) ON CONFLICT (user_id, activity_date) DO UPDATE SET ep_earned = user_daily_activity.ep_earned + $2, cards_done = user_daily_activity.cards_done + 1, - correct_count = user_daily_activity.correct_count + $3`, + correct_count = user_daily_activity.correct_count + $3 + RETURNING ep_earned`, [userId, pts, isCorrect ? 1 : 0] ); - // EP + Streak auf users_public; Streak: +1 bei neuem Tag, Reset bei Lücke > 1 Tag + // EP + Streak auf users_public; Streak: +1 bei neuem Tag, Reset bei Lücke > 1 Tag. + // CTE fängt die Pre-Update-Werte mit, damit Level-Up/Streak-Up atomar erkennbar sind. const upd = await query( - `UPDATE users_public SET - total_ep = total_ep + $2, + `WITH prev AS ( + SELECT total_ep AS prev_ep, streak_days AS prev_streak + FROM users_public WHERE user_id = $1 + ) + UPDATE users_public up SET + total_ep = up.total_ep + $2, streak_days = CASE - WHEN last_practice_at IS NULL THEN 1 - WHEN last_practice_at::date = CURRENT_DATE THEN streak_days - WHEN last_practice_at::date = CURRENT_DATE - INTERVAL '1 day' THEN streak_days + 1 + WHEN up.last_practice_at IS NULL THEN 1 + WHEN up.last_practice_at::date = CURRENT_DATE THEN up.streak_days + WHEN up.last_practice_at::date = CURRENT_DATE - INTERVAL '1 day' THEN up.streak_days + 1 ELSE 1 END, last_practice_at = NOW() - WHERE user_id = $1 - RETURNING total_ep, streak_days`, + FROM prev + WHERE up.user_id = $1 + RETURNING up.total_ep, up.streak_days, up.daily_goal_ep, prev.prev_ep, prev.prev_streak`, [userId, pts] ); if (!upd.rows.length) return res.status(409).json({ error: 'Kein Profil vorhanden. Bitte zuerst Profil anlegen.' }); - const { total_ep, streak_days } = upd.rows[0]; - res.json({ total_ep, streak_days, level: Math.floor(total_ep / 500) }); + const r = upd.rows[0]; + const daily_ep = day.rows[0]?.ep_earned ?? pts; + const daily_goal_ep = r.daily_goal_ep || 30; + res.json({ + total_ep: r.total_ep, + level: levelForEp(r.total_ep), + prev_level: levelForEp(r.prev_ep), + streak_days: r.streak_days, + streak_increased: r.streak_days > r.prev_streak, + daily_ep, + daily_goal_ep, + // Schwellen-Übergang: jetzt erreicht, vorher (ohne diese Karte) noch nicht + goal_just_reached: daily_ep >= daily_goal_ep && (daily_ep - pts) < daily_goal_ep, + }); } catch (err) { next(err); } });