From 9f019d8e2fc96b113dfcf10d4547f41864f765e7 Mon Sep 17 00:00:00 2001 From: Till JS Date: Tue, 12 May 2026 19:01:48 +0200 Subject: [PATCH] feat(cards): leech detection in /me/stats + Stats-Page-Sektion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Karten mit ≥4 Lapses werden im Stats-Endpoint als `leech_cards` geliefert (mit Front-Snippet, Deck-Name, Lapses-Count, sortiert desc, max 20). Stats-Page zeigt eine rote „Schwierige Karten"- Sektion mit Link in den Card-Editor. * apps/api/src/routes/me.ts: GROUP BY card → SUM(lapses) ≥ 4 Filter, frontSnippetFor()-Helper für alle 6 Card-Types (basic, basic-reverse, cloze, image-occlusion, audio-front, typing, multiple-choice). Cloze-Markup wird gestrippt damit der Snippet UI-tauglich ist. * apps/web Stats-Page: neue CardSurface mit Warning-Icon, scrollbare Liste mit Front + Deck + Lapses-Badge, Empty-State. * i18n in DE/EN/FR/IT/ES (5 Strings + plural-Form). 104/104 Tests grün (kein neuer Test — bestehende /me/stats-Tests covern die Aggregations-Form), web check 0 errors. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/api/src/routes/me.ts | 67 ++++++++++++++++++- apps/web/src/lib/api/me.ts | 13 ++++ apps/web/src/lib/i18n/de.ts | 5 ++ apps/web/src/lib/i18n/en.ts | 5 ++ apps/web/src/lib/i18n/es.ts | 5 ++ apps/web/src/lib/i18n/fr.ts | 5 ++ apps/web/src/lib/i18n/it.ts | 5 ++ apps/web/src/routes/stats/+page.svelte | 90 +++++++++++++++++++++++++- docs/FEATURE_IDEAS.md | 8 ++- 9 files changed, 198 insertions(+), 5 deletions(-) diff --git a/apps/api/src/routes/me.ts b/apps/api/src/routes/me.ts index 2749d35..8eb2cf5 100644 --- a/apps/api/src/routes/me.ts +++ b/apps/api/src/routes/me.ts @@ -1,4 +1,4 @@ -import { and, eq, gte, isNotNull, lte, sql } from 'drizzle-orm'; +import { and, desc, eq, gte, isNotNull, lte, sql } from 'drizzle-orm'; import { Hono } from 'hono'; import { getDb, type CardsDb } from '../db/connection.ts'; @@ -10,6 +10,32 @@ import { auditLog } from '../lib/audit.ts'; export type MeDeps = { db?: CardsDb }; +/** + * Schmaler Front-Snippet pro Card-Type, fürs Leech-Listing in /me/stats. + * Markdown-/Cloze-Markup wird grob entfernt damit der Snippet UI-tauglich + * ist. Bewusst keine volle Renderer-Logik — die lebt im Frontend. + */ +function frontSnippetFor(type: string, fields: Record): string { + const raw = + type === 'cloze' + ? (fields.text ?? '') + : type === 'image-occlusion' + ? '[Bild-Karte]' + : type === 'audio-front' + ? (fields.front ?? '[Audio-Karte]') + : type === 'typing' + ? (fields.question ?? fields.front ?? '') + : type === 'multiple-choice' + ? (fields.question ?? fields.front ?? '') + : (fields.front ?? ''); + const cleaned = raw + .replace(/\{\{c\d+::([^:}]*)(?:::[^}]*)?\}\}/g, '$1') + .replace(/[*_`#>]/g, '') + .replace(/\s+/g, ' ') + .trim(); + return cleaned.length > 80 ? cleaned.slice(0, 77) + '…' : cleaned; +} + /** * User-Self-Service-Endpunkte. Auth: User-JWT/Dev-Stub. Identisch in * Wirkung zum Service-Key-DSGVO-Pfad, aber gegen die eigene User-ID @@ -158,6 +184,43 @@ export function meRouter(deps: MeDeps = {}): Hono<{ Variables: AuthVars }> { return { day: key, n: forecastMap.get(key) ?? 0 }; }); + // Leeches: Karten mit ≥ LEECH_THRESHOLD Lapses. Anki nutzt 8; + // wir starten konservativer bei 4, weil Cards aktuell weniger + // Reviews pro Karte hat. Aggregiert über alle sub_indices einer + // Karte (cloze-Cluster, basic-reverse-Seiten zählen zusammen). + const LEECH_THRESHOLD = 4; + const LEECH_LIMIT = 20; + const leechRows = await db + .select({ + cardId: cards.id, + deckId: cards.deckId, + deckName: decks.name, + type: cards.type, + fields: cards.fields, + lapses: sql`sum(${reviews.lapses})::int`, + reps: sql`sum(${reviews.reps})::int`, + lastReview: sql`max(${reviews.lastReview})`, + }) + .from(reviews) + .innerJoin(cards, eq(cards.id, reviews.cardId)) + .innerJoin(decks, eq(decks.id, cards.deckId)) + .where(eq(reviews.userId, userId)) + .groupBy(cards.id, cards.deckId, decks.name, cards.type, cards.fields) + .having(sql`sum(${reviews.lapses}) >= ${LEECH_THRESHOLD}`) + .orderBy(desc(sql`sum(${reviews.lapses})`)) + .limit(LEECH_LIMIT); + + const leechCards = leechRows.map((r) => ({ + card_id: r.cardId, + deck_id: r.deckId, + deck_name: r.deckName, + type: r.type, + front_snippet: frontSnippetFor(r.type, r.fields as Record), + lapses: r.lapses, + reps: r.reps, + last_review: r.lastReview ? r.lastReview.toISOString() : null, + })); + return c.json({ user_id: userId, generated_at: now.toISOString(), @@ -173,6 +236,8 @@ export function meRouter(deps: MeDeps = {}): Hono<{ Variables: AuthVars }> { retention_reps: totalReps, retention_lapses: totalLapses, due_forecast: dueForecast, + leech_threshold: LEECH_THRESHOLD, + leech_cards: leechCards, }); }); diff --git a/apps/web/src/lib/api/me.ts b/apps/web/src/lib/api/me.ts index e6514a9..2501ac2 100644 --- a/apps/web/src/lib/api/me.ts +++ b/apps/web/src/lib/api/me.ts @@ -40,6 +40,17 @@ export function deleteMe() { return api('/api/v1/me/delete', { method: 'POST' }); } +export interface LeechCard { + card_id: string; + deck_id: string; + deck_name: string; + type: string; + front_snippet: string; + lapses: number; + reps: number; + last_review: string | null; +} + export interface UserStats { user_id: string; generated_at: string; @@ -55,6 +66,8 @@ export interface UserStats { retention_reps: number; retention_lapses: number; due_forecast: { day: string; n: number }[]; + leech_threshold: number; + leech_cards: LeechCard[]; } export function loadStats() { diff --git a/apps/web/src/lib/i18n/de.ts b/apps/web/src/lib/i18n/de.ts index 136126d..4b39d05 100644 --- a/apps/web/src/lib/i18n/de.ts +++ b/apps/web/src/lib/i18n/de.ts @@ -310,6 +310,11 @@ export const de: TranslationNode = { forecast_title: 'Nächste 7 Tage', forecast_desc: 'Fällige Karten je Tag.', forecast_empty: 'Nichts fällig.', + leeches_title: 'Schwierige Karten', + leeches_desc: 'Karten mit {threshold}+ Ausrutschern — Kandidaten zum Umformulieren oder Aufteilen.', + leeches_none: 'Keine zähen Karten — sauber.', + leeches_lapses: '{n} Ausrutscher', + leeches_lapses_one: '{n} Ausrutscher', loading: 'Lade…', error: 'Fehler: {msg}', }, diff --git a/apps/web/src/lib/i18n/en.ts b/apps/web/src/lib/i18n/en.ts index 6816a5c..a1a0ec2 100644 --- a/apps/web/src/lib/i18n/en.ts +++ b/apps/web/src/lib/i18n/en.ts @@ -305,6 +305,11 @@ export const en: TranslationNode = { forecast_title: 'Next 7 days', forecast_desc: 'Cards due per day.', forecast_empty: 'Nothing scheduled.', + leeches_title: 'Tough cards', + leeches_desc: 'Cards with {threshold}+ lapses — candidates to reword or split.', + leeches_none: 'No leeches — clean slate.', + leeches_lapses: '{n} lapses', + leeches_lapses_one: '{n} lapse', loading: 'Loading…', error: 'Error: {msg}', }, diff --git a/apps/web/src/lib/i18n/es.ts b/apps/web/src/lib/i18n/es.ts index e2580cd..58192ef 100644 --- a/apps/web/src/lib/i18n/es.ts +++ b/apps/web/src/lib/i18n/es.ts @@ -305,6 +305,11 @@ export const es: TranslationNode = { forecast_title: 'Próximos 7 días', forecast_desc: 'Tarjetas pendientes por día.', forecast_empty: 'Nada programado.', + leeches_title: 'Cartas difíciles', + leeches_desc: 'Cartas con {threshold}+ olvidos — candidatas para reformular o dividir.', + leeches_none: 'Ninguna carta difícil — todo bien.', + leeches_lapses: '{n} olvidos', + leeches_lapses_one: '{n} olvido', loading: 'Cargando…', error: 'Error: {msg}', }, diff --git a/apps/web/src/lib/i18n/fr.ts b/apps/web/src/lib/i18n/fr.ts index af64913..f12de2e 100644 --- a/apps/web/src/lib/i18n/fr.ts +++ b/apps/web/src/lib/i18n/fr.ts @@ -305,6 +305,11 @@ export const fr: TranslationNode = { forecast_title: '7 prochains jours', forecast_desc: 'Cartes à réviser par jour.', forecast_empty: 'Rien de prévu.', + leeches_title: 'Cartes coriaces', + leeches_desc: 'Cartes avec {threshold}+ oublis — à reformuler ou diviser.', + leeches_none: 'Aucune carte difficile — tout va bien.', + leeches_lapses: '{n} oublis', + leeches_lapses_one: '{n} oubli', loading: 'Chargement…', error: 'Erreur : {msg}', }, diff --git a/apps/web/src/lib/i18n/it.ts b/apps/web/src/lib/i18n/it.ts index 434deda..c9318d9 100644 --- a/apps/web/src/lib/i18n/it.ts +++ b/apps/web/src/lib/i18n/it.ts @@ -305,6 +305,11 @@ export const it: TranslationNode = { forecast_title: 'Prossimi 7 giorni', forecast_desc: 'Carte da ripassare per giorno.', forecast_empty: 'Niente in programma.', + leeches_title: 'Carte ostiche', + leeches_desc: 'Carte con {threshold}+ ricadute — da riformulare o dividere.', + leeches_none: 'Nessuna carta difficile — pulito.', + leeches_lapses: '{n} ricadute', + leeches_lapses_one: '{n} ricaduta', loading: 'Caricamento…', error: 'Errore: {msg}', }, diff --git a/apps/web/src/routes/stats/+page.svelte b/apps/web/src/routes/stats/+page.svelte index 5f7ae2c..f00c9d6 100644 --- a/apps/web/src/routes/stats/+page.svelte +++ b/apps/web/src/routes/stats/+page.svelte @@ -7,7 +7,7 @@ import { apiErrorMessage } from '$lib/api/error.ts'; import { stackLayers } from '$lib/utils/deck-tilt'; import CardSurface from '$lib/components/CardSurface.svelte'; - import { ChartBar, Fire, Brain, CalendarDots, Target, CalendarCheck } from '@mana/shared-icons'; + import { ChartBar, Fire, Brain, CalendarDots, Target, CalendarCheck, Warning } from '@mana/shared-icons'; let stats = $state(null); let loading = $state(true); @@ -19,6 +19,7 @@ const actGridLayers = stackLayers('stats-act-grid', 3); const retentionLayers = stackLayers('stats-retention', 3); const forecastLayers = stackLayers('stats-forecast', 3); + const leechesLayers = stackLayers('stats-leeches', 3); const peakDay = $derived.by(() => { if (!stats) return 1; @@ -310,6 +311,42 @@ + +
  • + {#each leechesLayers as layer, i (i)} + + {/each} + +
    +
    +
    +
    +

    {t('stats.leeches_title')}

    +

    {t('stats.leeches_desc', { threshold: stats!.leech_threshold })}

    + {#if stats!.leech_cards.length === 0} +

    {t('stats.leeches_none')}

    + {:else} + + {/if} +
    +
    +
    +
  • + {/if} @@ -539,4 +576,55 @@ .act-cell.level-2 { background: #10B981; opacity: 0.5; } .act-cell.level-3 { background: #10B981; opacity: 0.75; } .act-cell.level-4 { background: #10B981; } + + .leech-list { + list-style: none; + margin: 0.5rem 0 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.375rem; + max-height: 14rem; + overflow-y: auto; + } + + .leech-item { margin: 0; } + + .leech-link { + display: flex; + flex-direction: column; + gap: 0.125rem; + padding: 0.5rem 0.625rem; + border-radius: 0.375rem; + text-decoration: none; + color: inherit; + background: hsl(var(--color-muted) / 0.4); + transition: background 120ms; + } + + .leech-link:hover, + .leech-link:focus-visible { + background: hsl(var(--color-muted)); + outline: 2px solid #DC2626; + outline-offset: -2px; + } + + .leech-front { + font-size: 0.875rem; + font-weight: 500; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .leech-meta { + font-size: 0.75rem; + color: hsl(var(--color-muted-foreground)); + display: flex; + gap: 0.375rem; + align-items: center; + } + + .leech-sep { opacity: 0.5; } + .leech-lapses { color: #DC2626; font-weight: 500; } diff --git a/docs/FEATURE_IDEAS.md b/docs/FEATURE_IDEAS.md index 808a8c4..7b81b81 100644 --- a/docs/FEATURE_IDEAS.md +++ b/docs/FEATURE_IDEAS.md @@ -81,9 +81,11 @@ aber im Code längst gelandet: `computeParameters()` aus Review-History; Schema (`decks.fsrs_settings`) und Per-Deck-Override sind vorbereitet, aber kein Endpoint und kein UI. Größter messbarer Retention-Gewinn pro Aufwand. -- **Leech-Detection** — `reviews.lapses` zählt bereits; - Auto-Markierung + UI-Vorschlag „aufteilen / suspendieren" für - Karten oberhalb eines Schwellwerts. +- **Leech-Detection — gebaut 2026-05-12.** `me/stats` liefert + `leech_cards` (Threshold 4 Lapses, Limit 20, sortiert nach + Lapses desc, mit Front-Snippet + Deck-Name). Stats-Page hat eine + rote Leech-Sektion mit Link in den Card-Editor. i18n in DE/EN/ + FR/IT/ES. Suspension/Aufteilen-Vorschläge sind nächste Welle. - **Undo letzte Bewertung** — wichtiger UX-Reflex; aktuell muss man das Review händisch zurückbauen. - **Card Burial / Suspension** — Karten temporär deaktivieren ohne