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 { Hono } from 'hono';
|
||||||
|
|
||||||
import { getDb, type CardsDb } from '../db/connection.ts';
|
import { getDb, type CardsDb } from '../db/connection.ts';
|
||||||
|
|
@ -10,6 +10,32 @@ import { auditLog } from '../lib/audit.ts';
|
||||||
|
|
||||||
export type MeDeps = { db?: CardsDb };
|
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
|
* User-Self-Service-Endpunkte. Auth: User-JWT/Dev-Stub. Identisch in
|
||||||
* Wirkung zum Service-Key-DSGVO-Pfad, aber gegen die eigene User-ID
|
* 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 };
|
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({
|
return c.json({
|
||||||
user_id: userId,
|
user_id: userId,
|
||||||
generated_at: now.toISOString(),
|
generated_at: now.toISOString(),
|
||||||
|
|
@ -173,6 +236,8 @@ export function meRouter(deps: MeDeps = {}): Hono<{ Variables: AuthVars }> {
|
||||||
retention_reps: totalReps,
|
retention_reps: totalReps,
|
||||||
retention_lapses: totalLapses,
|
retention_lapses: totalLapses,
|
||||||
due_forecast: dueForecast,
|
due_forecast: dueForecast,
|
||||||
|
leech_threshold: LEECH_THRESHOLD,
|
||||||
|
leech_cards: leechCards,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,17 @@ export function deleteMe() {
|
||||||
return api<DeleteMeResult>('/api/v1/me/delete', { method: 'POST' });
|
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 {
|
export interface UserStats {
|
||||||
user_id: string;
|
user_id: string;
|
||||||
generated_at: string;
|
generated_at: string;
|
||||||
|
|
@ -55,6 +66,8 @@ export interface UserStats {
|
||||||
retention_reps: number;
|
retention_reps: number;
|
||||||
retention_lapses: number;
|
retention_lapses: number;
|
||||||
due_forecast: { day: string; n: number }[];
|
due_forecast: { day: string; n: number }[];
|
||||||
|
leech_threshold: number;
|
||||||
|
leech_cards: LeechCard[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function loadStats() {
|
export function loadStats() {
|
||||||
|
|
|
||||||
|
|
@ -310,6 +310,11 @@ export const de: TranslationNode = {
|
||||||
forecast_title: 'Nächste 7 Tage',
|
forecast_title: 'Nächste 7 Tage',
|
||||||
forecast_desc: 'Fällige Karten je Tag.',
|
forecast_desc: 'Fällige Karten je Tag.',
|
||||||
forecast_empty: 'Nichts fällig.',
|
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…',
|
loading: 'Lade…',
|
||||||
error: 'Fehler: {msg}',
|
error: 'Fehler: {msg}',
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -305,6 +305,11 @@ export const en: TranslationNode = {
|
||||||
forecast_title: 'Next 7 days',
|
forecast_title: 'Next 7 days',
|
||||||
forecast_desc: 'Cards due per day.',
|
forecast_desc: 'Cards due per day.',
|
||||||
forecast_empty: 'Nothing scheduled.',
|
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…',
|
loading: 'Loading…',
|
||||||
error: 'Error: {msg}',
|
error: 'Error: {msg}',
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -305,6 +305,11 @@ export const es: TranslationNode = {
|
||||||
forecast_title: 'Próximos 7 días',
|
forecast_title: 'Próximos 7 días',
|
||||||
forecast_desc: 'Tarjetas pendientes por día.',
|
forecast_desc: 'Tarjetas pendientes por día.',
|
||||||
forecast_empty: 'Nada programado.',
|
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…',
|
loading: 'Cargando…',
|
||||||
error: 'Error: {msg}',
|
error: 'Error: {msg}',
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -305,6 +305,11 @@ export const fr: TranslationNode = {
|
||||||
forecast_title: '7 prochains jours',
|
forecast_title: '7 prochains jours',
|
||||||
forecast_desc: 'Cartes à réviser par jour.',
|
forecast_desc: 'Cartes à réviser par jour.',
|
||||||
forecast_empty: 'Rien de prévu.',
|
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…',
|
loading: 'Chargement…',
|
||||||
error: 'Erreur : {msg}',
|
error: 'Erreur : {msg}',
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -305,6 +305,11 @@ export const it: TranslationNode = {
|
||||||
forecast_title: 'Prossimi 7 giorni',
|
forecast_title: 'Prossimi 7 giorni',
|
||||||
forecast_desc: 'Carte da ripassare per giorno.',
|
forecast_desc: 'Carte da ripassare per giorno.',
|
||||||
forecast_empty: 'Niente in programma.',
|
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…',
|
loading: 'Caricamento…',
|
||||||
error: 'Errore: {msg}',
|
error: 'Errore: {msg}',
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
import { apiErrorMessage } from '$lib/api/error.ts';
|
import { apiErrorMessage } from '$lib/api/error.ts';
|
||||||
import { stackLayers } from '$lib/utils/deck-tilt';
|
import { stackLayers } from '$lib/utils/deck-tilt';
|
||||||
import CardSurface from '$lib/components/CardSurface.svelte';
|
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 stats = $state<UserStats | null>(null);
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
|
|
@ -19,6 +19,7 @@
|
||||||
const actGridLayers = stackLayers('stats-act-grid', 3);
|
const actGridLayers = stackLayers('stats-act-grid', 3);
|
||||||
const retentionLayers = stackLayers('stats-retention', 3);
|
const retentionLayers = stackLayers('stats-retention', 3);
|
||||||
const forecastLayers = stackLayers('stats-forecast', 3);
|
const forecastLayers = stackLayers('stats-forecast', 3);
|
||||||
|
const leechesLayers = stackLayers('stats-leeches', 3);
|
||||||
|
|
||||||
const peakDay = $derived.by(() => {
|
const peakDay = $derived.by(() => {
|
||||||
if (!stats) return 1;
|
if (!stats) return 1;
|
||||||
|
|
@ -310,6 +311,42 @@
|
||||||
</CardSurface>
|
</CardSurface>
|
||||||
</li>
|
</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>
|
</ul>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
|
@ -539,4 +576,55 @@
|
||||||
.act-cell.level-2 { background: #10B981; opacity: 0.5; }
|
.act-cell.level-2 { background: #10B981; opacity: 0.5; }
|
||||||
.act-cell.level-3 { background: #10B981; opacity: 0.75; }
|
.act-cell.level-3 { background: #10B981; opacity: 0.75; }
|
||||||
.act-cell.level-4 { background: #10B981; }
|
.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>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -81,9 +81,11 @@ aber im Code längst gelandet:
|
||||||
`computeParameters()` aus Review-History; Schema (`decks.fsrs_settings`)
|
`computeParameters()` aus Review-History; Schema (`decks.fsrs_settings`)
|
||||||
und Per-Deck-Override sind vorbereitet, aber kein Endpoint und kein UI.
|
und Per-Deck-Override sind vorbereitet, aber kein Endpoint und kein UI.
|
||||||
Größter messbarer Retention-Gewinn pro Aufwand.
|
Größter messbarer Retention-Gewinn pro Aufwand.
|
||||||
- **Leech-Detection** — `reviews.lapses` zählt bereits;
|
- **Leech-Detection — gebaut 2026-05-12.** `me/stats` liefert
|
||||||
Auto-Markierung + UI-Vorschlag „aufteilen / suspendieren" für
|
`leech_cards` (Threshold 4 Lapses, Limit 20, sortiert nach
|
||||||
Karten oberhalb eines Schwellwerts.
|
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
|
- **Undo letzte Bewertung** — wichtiger UX-Reflex; aktuell muss man
|
||||||
das Review händisch zurückbauen.
|
das Review händisch zurückbauen.
|
||||||
- **Card Burial / Suspension** — Karten temporär deaktivieren ohne
|
- **Card Burial / Suspension** — Karten temporär deaktivieren ohne
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue