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:
@@ -534,6 +534,21 @@ async function migrate() {
|
|||||||
FOR EACH ROW EXECUTE FUNCTION update_last_seen_at()
|
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
|
// audios
|
||||||
await query(`
|
await query(`
|
||||||
CREATE TABLE IF NOT EXISTS audios (
|
CREATE TABLE IF NOT EXISTS audios (
|
||||||
|
|||||||
@@ -138,6 +138,7 @@ router.get('/me', requireJwt, async (req, res, next) => {
|
|||||||
un.username,
|
un.username,
|
||||||
COALESCE(up.total_ep, 0) AS total_ep,
|
COALESCE(up.total_ep, 0) AS total_ep,
|
||||||
COALESCE(up.streak_days, 0) AS streak_days,
|
COALESCE(up.streak_days, 0) AS streak_days,
|
||||||
|
COALESCE(up.daily_goal_ep, 30) AS daily_goal_ep,
|
||||||
up.last_practice_at,
|
up.last_practice_at,
|
||||||
ln.id AS language_native_id, ln.short_en AS language_native_short, ln.titel_de AS language_native_titel,
|
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
|
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]
|
[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
|
// EP + Streak auf users_public; Streak: +1 bei neuem Tag, Reset bei Lücke > 1 Tag
|
||||||
const upd = await query(
|
const upd = await query(
|
||||||
`UPDATE users_public SET
|
`UPDATE users_public SET
|
||||||
@@ -201,6 +213,98 @@ router.post('/progress', requireJwt, async (req, res, next) => {
|
|||||||
} catch (err) { next(err); }
|
} 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
|
// POST /auth/profile — one-time profile creation for the authed user
|
||||||
router.post('/profile', requireJwt, async (req, res, next) => {
|
router.post('/profile', requireJwt, async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
Reference in New Issue
Block a user