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:
30
src/lib/leveling.js
Normal file
30
src/lib/leveling.js
Normal 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 };
|
||||
@@ -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); }
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user