feat(cards): leech detection in /me/stats + Stats-Page-Sektion
Some checks are pending
CI / validate (push) Waiting to run

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) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-05-12 19:01:48 +02:00
parent 4bb1390180
commit 9f019d8e2f
9 changed files with 198 additions and 5 deletions

View file

@ -40,6 +40,17 @@ export function deleteMe() {
return api<DeleteMeResult>('/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() {

View file

@ -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}',
},

View file

@ -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}',
},

View file

@ -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}',
},

View file

@ -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}',
},

View file

@ -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}',
},

View file

@ -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<UserStats | null>(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 @@
</CardSurface>
</li>
<!-- Schwierige Karten (Leeches) -->
<li class="stack-wrap">
{#each leechesLayers as layer, i (i)}
<div class="layer" style:transform="translate({layer.dx}px,{layer.dy}px) rotate({layer.tilt}deg)" aria-hidden="true"></div>
{/each}
<CardSurface size="md" colorAccent="#DC2626">
<div class="card-inner">
<div class="card-corner">
<Warning size={24} weight="duotone" aria-hidden="true" />
</div>
<div class="card-body">
<p class="card-title">{t('stats.leeches_title')}</p>
<p class="card-desc">{t('stats.leeches_desc', { threshold: stats!.leech_threshold })}</p>
{#if stats!.leech_cards.length === 0}
<p class="retention-none">{t('stats.leeches_none')}</p>
{:else}
<ul class="leech-list">
{#each stats!.leech_cards as leech (leech.card_id)}
<li class="leech-item">
<a class="leech-link" href={`/cards/${leech.card_id}/edit`}>
<span class="leech-front">{leech.front_snippet || '—'}</span>
<span class="leech-meta">
<span class="leech-deck">{leech.deck_name}</span>
<span class="leech-sep">·</span>
<span class="leech-lapses">{t('stats.leeches_lapses', { n: leech.lapses })}</span>
</span>
</a>
</li>
{/each}
</ul>
{/if}
</div>
</div>
</CardSurface>
</li>
</ul>
{/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; }
</style>