feat(cards): leech detection in /me/stats + Stats-Page-Sektion
Some checks are pending
CI / validate (push) Waiting to run
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:
parent
4bb1390180
commit
9f019d8e2f
9 changed files with 198 additions and 5 deletions
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue