diff --git a/apps/api/src/routes/me.ts b/apps/api/src/routes/me.ts index 89072b1..0b89e0e 100644 --- a/apps/api/src/routes/me.ts +++ b/apps/api/src/routes/me.ts @@ -40,7 +40,7 @@ export function meRouter(deps: MeDeps = {}): Hono<{ Variables: AuthVars }> { const userId = c.get('userId'); const db = dbOf(); const now = new Date(); - const thirtyAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); + const ninetyAgo = new Date(Date.now() - 91 * 24 * 60 * 60 * 1000); const [deckCountRow] = await db .select({ n: sql`count(*)::int` }) @@ -76,7 +76,7 @@ export function meRouter(deps: MeDeps = {}): Hono<{ Variables: AuthVars }> { }; for (const row of stateRows) stateCounts[row.state] = row.n; - // Reviews pro Tag — gruppiert auf UTC-Tag. + // Reviews pro Tag — 91-Tage-Fenster für Grid + Streak + 7-Tage-Chart. const dayRows = await db .select({ day: sql`to_char(${reviews.lastReview}, 'YYYY-MM-DD')`, @@ -84,7 +84,7 @@ export function meRouter(deps: MeDeps = {}): Hono<{ Variables: AuthVars }> { }) .from(reviews) .where( - and(eq(reviews.userId, userId), isNotNull(reviews.lastReview), gte(reviews.lastReview, thirtyAgo)) + and(eq(reviews.userId, userId), isNotNull(reviews.lastReview), gte(reviews.lastReview, ninetyAgo)) ) .groupBy(sql`to_char(${reviews.lastReview}, 'YYYY-MM-DD')`); @@ -96,15 +96,53 @@ export function meRouter(deps: MeDeps = {}): Hono<{ Variables: AuthVars }> { return { day: key, n: byDay.get(key) ?? 0 }; }); - // Streak: rückwärts ab heute (UTC) bis zum ersten Tag ohne Reviews. + // 84 Tage (12 Wochen) für das Activity-Grid, ältester Tag zuerst. + const activity84 = Array.from({ length: 84 }, (_, i) => { + const d = new Date(Date.now() - (83 - 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 bis zum ersten Tag ohne Reviews. let streak = 0; - for (let i = 0; i < 30; i++) { + for (let i = 0; i < 91; 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; } + // Retention: Verhältnis (reps - lapses) / reps über alle Reviews. + const [retentionRow] = await db + .select({ + totalReps: sql`coalesce(sum(${reviews.reps}), 0)::int`, + totalLapses: sql`coalesce(sum(${reviews.lapses}), 0)::int`, + }) + .from(reviews) + .where(eq(reviews.userId, userId)); + + const totalReps = retentionRow?.totalReps ?? 0; + const totalLapses = retentionRow?.totalLapses ?? 0; + const retentionRate = totalReps > 0 ? (totalReps - totalLapses) / totalReps : null; + + // Fälligkeitsvorschau: nächste 7 Tage (ab jetzt). + const sevenAhead = new Date(Date.now() + 8 * 24 * 60 * 60 * 1000); + const forecastRows = await db + .select({ + day: sql`to_char(${reviews.due}, 'YYYY-MM-DD')`, + n: sql`count(*)::int`, + }) + .from(reviews) + .where(and(eq(reviews.userId, userId), gte(reviews.due, now), lte(reviews.due, sevenAhead))) + .groupBy(sql`to_char(${reviews.due}, 'YYYY-MM-DD')`); + + const forecastMap = new Map(forecastRows.map((r) => [r.day, r.n])); + const dueForecast = Array.from({ length: 7 }, (_, i) => { + const d = new Date(Date.now() + i * 24 * 60 * 60 * 1000); + const key = d.toISOString().slice(0, 10); + return { day: key, n: forecastMap.get(key) ?? 0 }; + }); + return c.json({ user_id: userId, generated_at: now.toISOString(), @@ -114,7 +152,12 @@ export function meRouter(deps: MeDeps = {}): Hono<{ Variables: AuthVars }> { due_now: dueCountRow?.n ?? 0, state_counts: stateCounts, reviewed_per_day: reviewed7, + activity_days: activity84, streak_days: streak, + retention_rate: retentionRate, + retention_reps: totalReps, + retention_lapses: totalLapses, + due_forecast: dueForecast, }); }); diff --git a/apps/web/src/lib/api/decks.ts b/apps/web/src/lib/api/decks.ts index cffa065..c2f9df1 100644 --- a/apps/web/src/lib/api/decks.ts +++ b/apps/web/src/lib/api/decks.ts @@ -30,7 +30,7 @@ export function getMarketplaceSource(id: string) { return api<{ slug: string } | null>(`/api/v1/decks/${id}/marketplace-source`); } -export function generateDeck(input: { prompt: string; language?: 'de' | 'en'; count?: number; url?: string }) { +export function generateDeck(input: { prompt: string; language?: string; count?: number; url?: string }) { return api<{ deck: Deck; cards_created: number }>('/api/v1/decks/generate', { method: 'POST', body: input, @@ -51,7 +51,7 @@ export function fetchDistractors( export function generateDeckFromImage( files: File | File[], - opts: { language?: 'de' | 'en'; count?: number; url?: string }, + opts: { language?: string; count?: number; url?: string }, ) { const arr = Array.isArray(files) ? files : [files]; diff --git a/apps/web/src/lib/api/me.ts b/apps/web/src/lib/api/me.ts index e9ddfd4..0888f21 100644 --- a/apps/web/src/lib/api/me.ts +++ b/apps/web/src/lib/api/me.ts @@ -38,7 +38,12 @@ export interface UserStats { due_now: number; state_counts: { new: number; learning: number; review: number; relearning: number }; reviewed_per_day: { day: string; n: number }[]; + activity_days: { day: string; n: number }[]; streak_days: number; + retention_rate: number | null; + retention_reps: number; + retention_lapses: number; + due_forecast: { day: string; n: number }[]; } export function loadStats() { diff --git a/apps/web/src/lib/auth/dev-stub.svelte.ts b/apps/web/src/lib/auth/dev-stub.svelte.ts index 57820c8..a4b036d 100644 --- a/apps/web/src/lib/auth/dev-stub.svelte.ts +++ b/apps/web/src/lib/auth/dev-stub.svelte.ts @@ -1,18 +1,13 @@ /** - * Auth-Session für cards-web (Phase 10c). + * Auth-Session für cards-web. * - * Echte SSO gegen mana-auth: `accessToken` (EdDSA-JWT von - * `auth.mana.how`) lebt in localStorage, wird als - * `Authorization: Bearer ` an cards-api geschickt. + * Login-Flow: cards-web redirectet zu auth.mana.how (mana-auth-web), + * dort setzt mana-auth den SSO-Cookie, dann Redirect zurück zu + * /auth/callback, welcher via `tryRefresh()` + `loadUserFromToken()` + * den JWT holt und in localStorage speichert. * - * Der Datei-Name `dev-stub.svelte.ts` ist Legacy aus Phase 4 — alle - * Importer nutzen `devUser`-Symbol, wir behalten den Namen, damit der - * Sprint nicht fünfzig Imports umschreiben muss. Inhalt ist jetzt die - * echte Session. - * - * Token-Refresh: aktuell nicht implementiert — wenn der Token abläuft - * (15 min Default in mana-auth), gibt cards-api 401 und der User wird - * auf `/` zurückgeworfen. Refresh-Token-Pfad ist Phase-10d-Polish. + * Der Datei-Name `dev-stub.svelte.ts` ist Legacy — alle Importer + * nutzen `devUser`-Symbol, Umbenennen würde ~50 Imports erfordern. */ const TOKEN_KEY = 'cards.auth.accessToken'; @@ -100,46 +95,18 @@ class Session { } /** - * Login gegen mana-auth — zwei Calls in einem Flow: - * - * 1. Better-Auth-Native `POST /api/auth/sign-in/email` setzt das - * SSO-Cookie (__Secure-mana.session_token) auf Domain `.mana.how` - * und liefert das User-Profil. Dieser Endpoint erzeugt im - * Gegensatz zu `/api/v1/auth/login` ein gültiges Set-Cookie auf - * der Response. - * 2. `POST /api/v1/auth/refresh` mit Cookie-Auth liefert den - * EdDSA-JWT-accessToken aus der frischen Session. - * - * Damit funktioniert auch der spätere Background-Refresh (gleicher - * Endpoint) — und User-Sessions überleben einen Tab-Close, wenn das - * localStorage geleert wurde aber das HttpOnly-Cookie noch lebt. + * User-Profil aus dem aktuell gespeicherten JWT-Token laden. + * Wird vom /auth/callback nach erfolgreichem tryRefresh() aufgerufen. */ - async login(email: string, password: string): Promise { - const signIn = await fetch(`${authBaseUrl()}/api/auth/sign-in/email`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - credentials: 'include', - body: JSON.stringify({ email, password }), - }); - if (!signIn.ok) { - const body = await signIn.text().catch(() => ''); - throw new Error(`Login fehlgeschlagen (${signIn.status}): ${body.slice(0, 120)}`); - } - const signInData = (await signIn.json()) as { - user: { id: string; email: string; name?: string; accessTier?: string }; - }; - - // JWT aus der Cookie-Session ziehen. - const refreshOk = await this.tryRefresh(); - if (!refreshOk || !this.token) { - throw new Error('Auth-Server lieferte kein gültiges Token nach Login.'); - } - + loadUserFromToken(): void { + if (!this.token) return; + const claims = decodeJwt(this.token); + if (!claims) return; this.user = { - id: signInData.user.id, - email: signInData.user.email, - name: signInData.user.name ?? null, - tier: signInData.user.accessTier ?? 'public', + id: claims.sub, + email: claims.email ?? '', + name: claims.name ?? null, + tier: claims.tier ?? 'public', }; if (typeof window !== 'undefined') { window.localStorage.setItem(USER_KEY, JSON.stringify(this.user)); @@ -205,6 +172,15 @@ class Session { } } + /** Aktualisiert den lokal gecachten Anzeigenamen. Schreibt in localStorage. */ + patchProfile(patch: { name?: string }) { + if (!this.user) return; + if (patch.name !== undefined) this.user = { ...this.user, name: patch.name.trim() || null }; + if (typeof window !== 'undefined') { + window.localStorage.setItem(USER_KEY, JSON.stringify(this.user)); + } + } + clear() { // Auch SSO-Cookie auf .mana.how aufräumen — best-effort, schlägt // bei abgelaufener Session ohne Drama fehl. diff --git a/apps/web/src/lib/components/ClozeCardForm.svelte b/apps/web/src/lib/components/ClozeCardForm.svelte index 4ebcf03..841d3bc 100644 --- a/apps/web/src/lib/components/ClozeCardForm.svelte +++ b/apps/web/src/lib/components/ClozeCardForm.svelte @@ -8,7 +8,7 @@ }: { text: string; extra: string; - clusterIds: string[]; + clusterIds: number[]; } = $props(); diff --git a/apps/web/src/lib/components/Header.svelte b/apps/web/src/lib/components/Header.svelte index 8277622..9c244f0 100644 --- a/apps/web/src/lib/components/Header.svelte +++ b/apps/web/src/lib/components/Header.svelte @@ -2,15 +2,9 @@ import { page } from '$app/state'; import { goto } from '$app/navigation'; import { devUser } from '$lib/auth/dev-stub.svelte.ts'; - import { i18n, t } from '$lib/i18n/index.svelte.ts'; - import { PillTabGroup } from '@mana/shared-ui-2'; + import { t } from '$lib/i18n/index.svelte.ts'; - const langOptions = [ - { id: 'de', label: 'DE', title: 'Deutsch' }, - { id: 'en', label: 'EN', title: 'English' }, - ]; - - const navOptions = $derived([ + const navItems = $derived([ { id: 'decks', label: t('nav.decks') }, { id: 'explore', label: t('nav.explore') }, { id: 'import', label: t('nav.import') }, @@ -36,10 +30,6 @@ devUser.user?.email?.charAt(0).toUpperCase() ?? '?' ); - - function navTo(id: string) { - goto('/' + id); - }