diff --git a/src/db-migrate.js b/src/db-migrate.js index 1c8cdf0..e9159c1 100644 --- a/src/db-migrate.js +++ b/src/db-migrate.js @@ -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 ( diff --git a/src/routes/auth.js b/src/routes/auth.js index 98b607a..87c72ba 100644 --- a/src/routes/auth.js +++ b/src/routes/auth.js @@ -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 {