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

@ -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<UserStats>('/api/v1/me/stats');
}

View file

@ -32,6 +32,11 @@
class="hover:text-[var(--color-primary)]"
class:font-medium={page.url.pathname.startsWith('/import')}>Import</a
>
<a
href="/stats"
class="hover:text-[var(--color-primary)]"
class:font-medium={page.url.pathname.startsWith('/stats')}>Statistik</a
>
</nav>
<div class="flex items-center gap-3 text-sm">

View file

@ -0,0 +1,124 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { devUser } from '$lib/auth/dev-stub.svelte.ts';
import { loadStats, type UserStats } from '$lib/api/me.ts';
let stats = $state<UserStats | null>(null);
let loading = $state(true);
let error = $state<string | null>(null);
const peakDay = $derived.by(() => {
if (!stats) return 0;
return Math.max(1, ...stats.reviewed_per_day.map((d) => d.n));
});
const reviewedTotal7 = $derived.by(() => {
if (!stats) return 0;
return stats.reviewed_per_day.reduce((s, d) => s + d.n, 0);
});
onMount(async () => {
if (!devUser.id) {
goto('/');
return;
}
try {
stats = await loadStats();
} catch (e) {
error = (e as Error).message;
} finally {
loading = false;
}
});
function dayLabel(iso: string): string {
const d = new Date(`${iso}T00:00:00Z`);
return d.toLocaleDateString('de-DE', { weekday: 'short' });
}
</script>
<svelte:head>
<title>Statistik · Cards</title>
</svelte:head>
<div class="mx-auto max-w-3xl px-4 py-6">
<h1 class="text-2xl font-semibold">Statistik</h1>
{#if loading}
<p class="mt-6 text-[var(--color-muted)]">Lade…</p>
{:else if error}
<p class="mt-6 text-[var(--color-danger)]">Fehler: {error}</p>
{:else if stats}
<p class="mt-1 text-xs text-[var(--color-muted)]">
Stand {new Date(stats.generated_at).toLocaleString('de-DE')}
</p>
<section class="mt-6 grid grid-cols-2 gap-3 sm:grid-cols-4">
<div class="rounded-lg border border-[var(--color-border)] bg-[var(--color-card)] p-4">
<div class="text-xs text-[var(--color-muted)]">Decks</div>
<div class="mt-1 text-2xl font-semibold">{stats.total_decks}</div>
</div>
<div class="rounded-lg border border-[var(--color-border)] bg-[var(--color-card)] p-4">
<div class="text-xs text-[var(--color-muted)]">Karten</div>
<div class="mt-1 text-2xl font-semibold">{stats.total_cards}</div>
</div>
<div class="rounded-lg border border-[var(--color-border)] bg-[var(--color-card)] p-4">
<div class="text-xs text-[var(--color-muted)]">Reviews</div>
<div class="mt-1 text-2xl font-semibold">{stats.total_reviews}</div>
</div>
<div class="rounded-lg border border-[var(--color-border)] bg-[var(--color-card)] p-4">
<div class="text-xs text-[var(--color-muted)]">Fällig jetzt</div>
<div class="mt-1 text-2xl font-semibold">{stats.due_now}</div>
</div>
</section>
<section class="mt-6 rounded-lg border border-[var(--color-border)] bg-[var(--color-card)] p-4">
<div class="flex items-baseline justify-between">
<h2 class="text-lg font-medium">Lerntage</h2>
<span class="text-xs text-[var(--color-muted)]">
Streak: <strong class="text-[var(--color-fg)]">{stats.streak_days}</strong>
{stats.streak_days === 1 ? 'Tag' : 'Tage'} · letzte 7 Tage:
{reviewedTotal7} Reviews
</span>
</div>
<div class="mt-3 flex h-32 items-end gap-2">
{#each stats.reviewed_per_day as d (d.day)}
<div class="flex flex-1 flex-col items-center justify-end gap-1">
<div class="text-xs tabular-nums">{d.n || ''}</div>
<div
class="w-full rounded-t bg-[var(--color-primary)]/80"
style="height: {(d.n / peakDay) * 100}%; min-height: {d.n > 0 ? '4px' : '0'};"
></div>
<div class="text-xs text-[var(--color-muted)]">{dayLabel(d.day)}</div>
</div>
{/each}
</div>
</section>
<section class="mt-6 rounded-lg border border-[var(--color-border)] bg-[var(--color-card)] p-4">
<h2 class="text-lg font-medium">FSRS-Status</h2>
<p class="mt-1 text-xs text-[var(--color-muted)]">
Verteilung deiner Karten-Reviews über die FSRS-Zustände.
</p>
<dl class="mt-3 grid grid-cols-2 gap-3 sm:grid-cols-4">
<div>
<dt class="text-xs text-[var(--color-muted)]">Neu</dt>
<dd class="text-xl font-semibold">{stats.state_counts.new}</dd>
</div>
<div>
<dt class="text-xs text-[var(--color-muted)]">Lernend</dt>
<dd class="text-xl font-semibold">{stats.state_counts.learning}</dd>
</div>
<div>
<dt class="text-xs text-[var(--color-muted)]">Review</dt>
<dd class="text-xl font-semibold">{stats.state_counts.review}</dd>
</div>
<div>
<dt class="text-xs text-[var(--color-muted)]">Relearning</dt>
<dd class="text-xl font-semibold">{stats.state_counts.relearning}</dd>
</div>
</dl>
</section>
{/if}
</div>