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

@ -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, string>): 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<number>`sum(${reviews.lapses})::int`,
reps: sql<number>`sum(${reviews.reps})::int`,
lastReview: sql<Date | null>`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<string, string>),
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,
});
});