Phase 9f: Statistik-Dashboard

Neuer Endpoint GET /api/v1/me/stats liefert in einem Aufruf alle
Aggregate für die Stats-UI:
- total_decks / total_cards / total_reviews / due_now
- state_counts pro FSRS-Zustand (new/learning/review/relearning)
- reviewed_per_day für die letzten 7 Tage (Quelle: reviews.last_review,
  via to_char(day, 'YYYY-MM-DD') auf Postgres-Seite gruppiert)
- streak_days (rückwärts ab heute bis zum ersten Tag ohne Review)

study_sessions wird aktuell NICHT befüllt — der Schema-Slot existiert
seit Phase 3, aber der Session-Tracker kommt erst, wenn das Lern-
Flow-Layer ausgebaut wird. last_review reicht für jetzt.

/stats-Page rendert vier KPI-Cards, einen 7-Tage-Säulen-Chart per
CSS-Heights, plus eine FSRS-State-Distribution. Header-Nav um
"Statistik" ergänzt.

E2E-Smoke gegen lokale Postgres bestätigt: bestehender Cloze-User
zeigt 1 Deck, 1 Karte, 2 Reviews, 2 due, alle "new"-State, 0
Streak — passt zum gestern eingespielten Smoke-User.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-05-08 18:06:13 +02:00
parent 03117d5869
commit 6db6dc3e42
4 changed files with 238 additions and 2 deletions

View file

@ -1,8 +1,8 @@
import { eq } from 'drizzle-orm';
import { and, eq, gte, isNotNull, lte, sql } from 'drizzle-orm';
import { Hono } from 'hono';
import { getDb, type CardsDb } from '../db/connection.ts';
import { decks, importJobs } from '../db/schema/index.ts';
import { cards, decks, importJobs, reviews } from '../db/schema/index.ts';
import { authMiddleware, type AuthVars } from '../middleware/auth.ts';
import { buildUserExport } from './dsgvo.ts';
@ -26,6 +26,97 @@ export function meRouter(deps: MeDeps = {}): Hono<{ Variables: AuthVars }> {
return c.json(await buildUserExport(dbOf(), userId));
});
/**
* Statistik-Snapshot für die Account-/Stats-Page. Nicht-cached, alle
* Aggregate per Query. Ein einzelner Aufruf reicht für die /stats-UI.
*
* `reviewed_per_day` ist ein 7-Tage-Fenster (heute + 6 zurück), basierend
* auf `reviews.last_review` nicht study_sessions, weil die aktuell
* nicht befüllt werden (Schema-Slot existiert, Writer kommt mit dem
* Session-Tracker in einer späteren Phase).
*/
r.get('/stats', async (c) => {
const userId = c.get('userId');
const db = dbOf();
const now = new Date();
const thirtyAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
const [deckCountRow] = await db
.select({ n: sql<number>`count(*)::int` })
.from(decks)
.where(eq(decks.userId, userId));
const [cardCountRow] = await db
.select({ n: sql<number>`count(*)::int` })
.from(cards)
.where(eq(cards.userId, userId));
const [reviewCountRow] = await db
.select({ n: sql<number>`count(*)::int` })
.from(reviews)
.where(eq(reviews.userId, userId));
const [dueCountRow] = await db
.select({ n: sql<number>`count(*)::int` })
.from(reviews)
.where(and(eq(reviews.userId, userId), lte(reviews.due, now)));
const stateRows = await db
.select({ state: reviews.state, n: sql<number>`count(*)::int` })
.from(reviews)
.where(eq(reviews.userId, userId))
.groupBy(reviews.state);
const stateCounts: Record<string, number> = {
new: 0,
learning: 0,
review: 0,
relearning: 0,
};
for (const row of stateRows) stateCounts[row.state] = row.n;
// Reviews pro Tag — gruppiert auf UTC-Tag.
const dayRows = await db
.select({
day: sql<string>`to_char(${reviews.lastReview}, 'YYYY-MM-DD')`,
n: sql<number>`count(*)::int`,
})
.from(reviews)
.where(
and(eq(reviews.userId, userId), isNotNull(reviews.lastReview), gte(reviews.lastReview, thirtyAgo))
)
.groupBy(sql`to_char(${reviews.lastReview}, 'YYYY-MM-DD')`);
const byDay = new Map(dayRows.map((r) => [r.day, r.n]));
const reviewed7 = Array.from({ length: 7 }, (_, i) => {
const d = new Date(Date.now() - (6 - i) * 24 * 60 * 60 * 1000);
const key = d.toISOString().slice(0, 10);
return { day: key, n: byDay.get(key) ?? 0 };
});
// Streak: rückwärts ab heute (UTC) bis zum ersten Tag ohne Reviews.
let streak = 0;
for (let i = 0; i < 30; i++) {
const d = new Date(Date.now() - i * 24 * 60 * 60 * 1000);
const key = d.toISOString().slice(0, 10);
if ((byDay.get(key) ?? 0) > 0) streak++;
else break;
}
return c.json({
user_id: userId,
generated_at: now.toISOString(),
total_decks: deckCountRow?.n ?? 0,
total_cards: cardCountRow?.n ?? 0,
total_reviews: reviewCountRow?.n ?? 0,
due_now: dueCountRow?.n ?? 0,
state_counts: stateCounts,
reviewed_per_day: reviewed7,
streak_days: streak,
});
});
/**
* Vollständige Löschung der eigenen Cards-Daten. Identisch zur
* Service-Key-Variante in /dsgvo/delete, aber für den eingeloggten