feat: progressive Level-Kurve + atomarer /auth/progress-Vertrag

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-06-17 21:43:36 +02:00
parent 806e25c3ff
commit bb863640c0
2 changed files with 65 additions and 14 deletions

30
src/lib/leveling.js Normal file
View File

@@ -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 };

View File

@@ -2,6 +2,7 @@ const router = require('express').Router();
const bcrypt = require('bcryptjs'); const bcrypt = require('bcryptjs');
const jwt = require('jsonwebtoken'); const jwt = require('jsonwebtoken');
const { query } = require('../db'); const { query } = require('../db');
const { levelForEp, levelInfo } = require('../lib/leveling');
function signToken(user) { function signToken(user) {
return jwt.sign( 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' }); if (!r.rows.length) return res.status(404).json({ error: 'User not found' });
const row = r.rows[0]; 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); res.json(row);
} catch (err) { next(err); } } 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] [userId, pair_id, isCorrect ? 1 : 0, isCorrect ? 0 : 1, pts]
); );
// Tagesverlauf upserten (für Streak-Kalender, Wochengraph, Tagesziel) // Tagesverlauf upserten (für Streak-Kalender, Wochengraph, Tagesziel).
await query( // 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) `INSERT INTO user_daily_activity (user_id, activity_date, ep_earned, cards_done, correct_count)
VALUES ($1, CURRENT_DATE, $2, 1, $3) VALUES ($1, CURRENT_DATE, $2, 1, $3)
ON CONFLICT (user_id, activity_date) DO UPDATE SET ON CONFLICT (user_id, activity_date) DO UPDATE SET
ep_earned = user_daily_activity.ep_earned + $2, ep_earned = user_daily_activity.ep_earned + $2,
cards_done = user_daily_activity.cards_done + 1, 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] [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( const upd = await query(
`UPDATE users_public SET `WITH prev AS (
total_ep = total_ep + $2, 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 streak_days = CASE
WHEN last_practice_at IS NULL THEN 1 WHEN up.last_practice_at IS NULL THEN 1
WHEN last_practice_at::date = CURRENT_DATE THEN streak_days WHEN up.last_practice_at::date = CURRENT_DATE THEN up.streak_days
WHEN last_practice_at::date = CURRENT_DATE - INTERVAL '1 day' THEN streak_days + 1 WHEN up.last_practice_at::date = CURRENT_DATE - INTERVAL '1 day' THEN up.streak_days + 1
ELSE 1 ELSE 1
END, END,
last_practice_at = NOW() last_practice_at = NOW()
WHERE user_id = $1 FROM prev
RETURNING total_ep, streak_days`, WHERE up.user_id = $1
RETURNING up.total_ep, up.streak_days, up.daily_goal_ep, prev.prev_ep, prev.prev_streak`,
[userId, pts] [userId, pts]
); );
if (!upd.rows.length) if (!upd.rows.length)
return res.status(409).json({ error: 'Kein Profil vorhanden. Bitte zuerst Profil anlegen.' }); return res.status(409).json({ error: 'Kein Profil vorhanden. Bitte zuerst Profil anlegen.' });
const { total_ep, streak_days } = upd.rows[0]; const r = upd.rows[0];
res.json({ total_ep, streak_days, level: Math.floor(total_ep / 500) }); 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); } } catch (err) { next(err); }
}); });