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:
2026-06-13 16:40:57 +02:00
parent 895d7c56a1
commit f0f768ff2c
2 changed files with 119 additions and 0 deletions

View File

@@ -534,6 +534,21 @@ async function migrate() {
FOR EACH ROW EXECUTE FUNCTION update_last_seen_at()
`);
// user_daily_activity — Tagesverlauf für Streak-Kalender, Wochengraph, Tagesziel
await query(`
CREATE TABLE IF NOT EXISTS user_daily_activity (
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
activity_date DATE NOT NULL,
ep_earned INTEGER NOT NULL DEFAULT 0,
cards_done INTEGER NOT NULL DEFAULT 0,
correct_count INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (user_id, activity_date)
)
`);
// Tagesziel (EP/Tag) auf dem App-Profil
await query(`ALTER TABLE users_public ADD COLUMN IF NOT EXISTS daily_goal_ep INTEGER NOT NULL DEFAULT 30`).catch(() => {});
// audios
await query(`
CREATE TABLE IF NOT EXISTS audios (

View File

@@ -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 {