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 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); }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user