feat: Fortschritts-Tracking – user_daily_activity, Tagesziel & GET /auth/stats
- Neue Tabelle user_daily_activity (Tagesverlauf) + Spalte daily_goal_ep - POST /auth/progress schreibt Tagesaktivität mit - GET /auth/me liefert daily_goal_ep - Neuer GET /auth/stats: Tagesverlauf, Tagesziel, Totals, echte Skills je answer_type - Neuer PUT /auth/goal zum Setzen des Tagesziels Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -138,6 +138,7 @@ router.get('/me', requireJwt, async (req, res, next) => {
|
||||
un.username,
|
||||
COALESCE(up.total_ep, 0) AS total_ep,
|
||||
COALESCE(up.streak_days, 0) AS streak_days,
|
||||
COALESCE(up.daily_goal_ep, 30) AS daily_goal_ep,
|
||||
up.last_practice_at,
|
||||
ln.id AS language_native_id, ln.short_en AS language_native_short, ln.titel_de AS language_native_titel,
|
||||
lt.id AS language_target_id, lt.short_en AS language_target_short, lt.titel_de AS language_target_titel
|
||||
@@ -177,6 +178,17 @@ 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(
|
||||
`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`,
|
||||
[userId, pts, isCorrect ? 1 : 0]
|
||||
);
|
||||
|
||||
// EP + Streak auf users_public; Streak: +1 bei neuem Tag, Reset bei Lücke > 1 Tag
|
||||
const upd = await query(
|
||||
`UPDATE users_public SET
|
||||
@@ -201,6 +213,98 @@ router.post('/progress', requireJwt, async (req, res, next) => {
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// GET /auth/stats — Fortschrittsdaten für das Profil (Verlauf, Tagesziel, Skills)
|
||||
router.get('/stats', requireJwt, async (req, res, next) => {
|
||||
try {
|
||||
const userId = req.user.userId;
|
||||
|
||||
// Tagesverlauf der letzten ~84 Tage (für Heatmap-Kalender + Wochengraph)
|
||||
const daily = await query(
|
||||
`SELECT to_char(activity_date, 'YYYY-MM-DD') AS date, ep_earned AS ep, cards_done AS cards, correct_count AS correct
|
||||
FROM user_daily_activity
|
||||
WHERE user_id = $1 AND activity_date >= CURRENT_DATE - INTERVAL '83 days'
|
||||
ORDER BY activity_date ASC`,
|
||||
[userId]
|
||||
);
|
||||
|
||||
// Heute (für Tagesziel-Ring) + Tagesziel aus dem Profil
|
||||
const today = await query(
|
||||
`SELECT COALESCE(da.ep_earned, 0) AS ep, COALESCE(da.cards_done, 0) AS cards,
|
||||
COALESCE(up.daily_goal_ep, 30) AS daily_goal_ep
|
||||
FROM users_public up
|
||||
LEFT JOIN user_daily_activity da
|
||||
ON da.user_id = up.user_id AND da.activity_date = CURRENT_DATE
|
||||
WHERE up.user_id = $1`,
|
||||
[userId]
|
||||
);
|
||||
|
||||
// Gesamtstatistik aus user_pair_progress
|
||||
const totals = await query(
|
||||
`SELECT COUNT(*)::int AS pairs_practiced,
|
||||
COALESCE(SUM(seen_count), 0)::int AS total_seen,
|
||||
COALESCE(SUM(correct_count), 0)::int AS total_correct
|
||||
FROM user_pair_progress
|
||||
WHERE user_id = $1`,
|
||||
[userId]
|
||||
);
|
||||
|
||||
// Skills: echte Genauigkeit je answer_type des Pairs.
|
||||
// Mapping answer_type → Skill-Label: word/question → Vokabular, text → Lesen, yes_no → Verständnis.
|
||||
const skillRows = await query(
|
||||
`SELECT p.answer_type,
|
||||
COALESCE(SUM(upp.correct_count), 0)::int AS correct,
|
||||
COALESCE(SUM(upp.seen_count), 0)::int AS seen
|
||||
FROM user_pair_progress upp
|
||||
JOIN pairs p ON p.id = upp.pair_id
|
||||
WHERE upp.user_id = $1
|
||||
GROUP BY p.answer_type`,
|
||||
[userId]
|
||||
);
|
||||
|
||||
const SKILL_MAP = { word: 'Vokabular', question: 'Vokabular', text: 'Lesen', yes_no: 'Verständnis' };
|
||||
const skillAcc = {}; // label -> { correct, seen }
|
||||
for (const r of skillRows.rows) {
|
||||
const label = SKILL_MAP[r.answer_type] || 'Sonstige';
|
||||
const acc = (skillAcc[label] ||= { correct: 0, seen: 0 });
|
||||
acc.correct += r.correct;
|
||||
acc.seen += r.seen;
|
||||
}
|
||||
// Feste Reihenfolge, damit der Radar stabil bleibt; value = Genauigkeit (0..1)
|
||||
const skills = ['Vokabular', 'Lesen', 'Verständnis'].map((label) => {
|
||||
const acc = skillAcc[label];
|
||||
return { label, value: acc && acc.seen > 0 ? acc.correct / acc.seen : 0, seen: acc?.seen || 0 };
|
||||
});
|
||||
|
||||
const t = totals.rows[0] || { pairs_practiced: 0, total_seen: 0, total_correct: 0 };
|
||||
const td = today.rows[0] || { ep: 0, cards: 0, daily_goal_ep: 30 };
|
||||
|
||||
res.json({
|
||||
daily: daily.rows,
|
||||
today: { ep: td.ep, cards: td.cards, daily_goal_ep: td.daily_goal_ep },
|
||||
totals: {
|
||||
pairs_practiced: t.pairs_practiced,
|
||||
total_seen: t.total_seen,
|
||||
total_correct: t.total_correct,
|
||||
accuracy: t.total_seen > 0 ? t.total_correct / t.total_seen : 0,
|
||||
},
|
||||
skills,
|
||||
});
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// PUT /auth/goal — Tagesziel (EP/Tag) setzen
|
||||
router.put('/goal', requireJwt, async (req, res, next) => {
|
||||
try {
|
||||
const goal = Math.max(5, Math.min(500, parseInt(req.body?.daily_goal_ep) || 0));
|
||||
const upd = await query(
|
||||
`UPDATE users_public SET daily_goal_ep = $2 WHERE user_id = $1 RETURNING daily_goal_ep`,
|
||||
[req.user.userId, goal]
|
||||
);
|
||||
if (!upd.rows.length) return res.status(409).json({ error: 'Kein Profil vorhanden.' });
|
||||
res.json({ daily_goal_ep: upd.rows[0].daily_goal_ep });
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// POST /auth/profile — one-time profile creation for the authed user
|
||||
router.post('/profile', requireJwt, async (req, res, next) => {
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user