diff --git a/apps/api/src/routes/me.ts b/apps/api/src/routes/me.ts index 301139c..c8e049b 100644 --- a/apps/api/src/routes/me.ts +++ b/apps/api/src/routes/me.ts @@ -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`count(*)::int` }) + .from(decks) + .where(eq(decks.userId, userId)); + + const [cardCountRow] = await db + .select({ n: sql`count(*)::int` }) + .from(cards) + .where(eq(cards.userId, userId)); + + const [reviewCountRow] = await db + .select({ n: sql`count(*)::int` }) + .from(reviews) + .where(eq(reviews.userId, userId)); + + const [dueCountRow] = await db + .select({ n: sql`count(*)::int` }) + .from(reviews) + .where(and(eq(reviews.userId, userId), lte(reviews.due, now))); + + const stateRows = await db + .select({ state: reviews.state, n: sql`count(*)::int` }) + .from(reviews) + .where(eq(reviews.userId, userId)) + .groupBy(reviews.state); + + const stateCounts: Record = { + 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`to_char(${reviews.lastReview}, 'YYYY-MM-DD')`, + n: sql`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 diff --git a/apps/web/src/lib/api/me.ts b/apps/web/src/lib/api/me.ts index feed378..e9ddfd4 100644 --- a/apps/web/src/lib/api/me.ts +++ b/apps/web/src/lib/api/me.ts @@ -28,3 +28,19 @@ export function deleteMe() { { method: 'POST' } ); } + +export interface UserStats { + user_id: string; + generated_at: string; + total_decks: number; + total_cards: number; + total_reviews: number; + due_now: number; + state_counts: { new: number; learning: number; review: number; relearning: number }; + reviewed_per_day: { day: string; n: number }[]; + streak_days: number; +} + +export function loadStats() { + return api('/api/v1/me/stats'); +} diff --git a/apps/web/src/lib/components/Header.svelte b/apps/web/src/lib/components/Header.svelte index a324f41..17c2b02 100644 --- a/apps/web/src/lib/components/Header.svelte +++ b/apps/web/src/lib/components/Header.svelte @@ -32,6 +32,11 @@ class="hover:text-[var(--color-primary)]" class:font-medium={page.url.pathname.startsWith('/import')}>Import + Statistik
diff --git a/apps/web/src/routes/stats/+page.svelte b/apps/web/src/routes/stats/+page.svelte new file mode 100644 index 0000000..d8545be --- /dev/null +++ b/apps/web/src/routes/stats/+page.svelte @@ -0,0 +1,124 @@ + + + + Statistik · Cards + + +
+

Statistik

+ + {#if loading} +

Lade…

+ {:else if error} +

Fehler: {error}

+ {:else if stats} +

+ Stand {new Date(stats.generated_at).toLocaleString('de-DE')} +

+ +
+
+
Decks
+
{stats.total_decks}
+
+
+
Karten
+
{stats.total_cards}
+
+
+
Reviews
+
{stats.total_reviews}
+
+
+
Fällig jetzt
+
{stats.due_now}
+
+
+ +
+
+

Lerntage

+ + Streak: {stats.streak_days} + {stats.streak_days === 1 ? 'Tag' : 'Tage'} · letzte 7 Tage: + {reviewedTotal7} Reviews + +
+
+ {#each stats.reviewed_per_day as d (d.day)} +
+
{d.n || ''}
+
+
{dayLabel(d.day)}
+
+ {/each} +
+
+ +
+

FSRS-Status

+

+ Verteilung deiner Karten-Reviews über die FSRS-Zustände. +

+
+
+
Neu
+
{stats.state_counts.new}
+
+
+
Lernend
+
{stats.state_counts.learning}
+
+
+
Review
+
{stats.state_counts.review}
+
+
+
Relearning
+
{stats.state_counts.relearning}
+
+
+
+ {/if} +