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:
parent
03117d5869
commit
6db6dc3e42
4 changed files with 238 additions and 2 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
124
apps/web/src/routes/stats/+page.svelte
Normal file
124
apps/web/src/routes/stats/+page.svelte
Normal 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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue