feat(cards): recovery mode, undo, FSRS slider, streak header, stats charts, blog
Some checks are pending
CI / validate (push) Waiting to run
Some checks are pending
CI / validate (push) Waiting to run
Study-View:
- Graceful Backlog Recovery: Banner bei >30 fälligen Karten, Recovery-Queue
sortiert nach Stability aufsteigend (25er-Batch, ?recovery=true)
- Undo letzte Bewertung: 5s-Toast mit RAF-Fortschrittsbalken, Ctrl/Cmd+Z,
prevSnapshot-Spalte in reviews (Migration 0001, Prod deployed)
- FSRS-Tooltip nach Reveal: State / Stability / Difficulty als Popover
Deck-Edit:
- Neuer Abschnitt „Lern-Algorithmus" mit request_retention-Slider (50–99 %)
Header:
- Streak-Pill (🔥 N) + fällige-Karten-Pill via GET /api/v1/me/summary
Stats-Page:
- Difficulty-Distribution (5 Buckets, Farb-Bars)
- Deck-Fortschritt (Mastery % = stability>21, max 6 Decks)
API:
- GET /me/summary: streak_days + due_now (leichtgewichtiger Header-Endpoint)
- GET /reviews/due: ?recovery=true → stability-sort, Limit 25
- POST /reviews/:cardId/:subIndex/undo: prevSnapshot-Restore, 409 wenn leer
- /me/stats: difficulty_distribution + deck_mastery
Landing:
- 5 Blog-Artikel (Quizlet-Paywall, FSRS, Datenschutz, Anki, Lernkarten-Tipps)
- BlogTeaser-Komponente auf Startseite, Footer-Spalte „Artikel"
i18n: 11 neue Schlüssel in DE/EN/FR/IT/ES
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
21ec535173
commit
abf493aeec
27 changed files with 2667 additions and 29 deletions
|
|
@ -68,8 +68,19 @@ export interface UserStats {
|
|||
due_forecast: { day: string; n: number }[];
|
||||
leech_threshold: number;
|
||||
leech_cards: LeechCard[];
|
||||
difficulty_distribution: { bucket: 'very_easy' | 'easy' | 'medium' | 'hard' | 'very_hard'; n: number }[];
|
||||
deck_mastery: { deck_id: string; deck_name: string; total: number; mastered: number; pct: number }[];
|
||||
}
|
||||
|
||||
export function loadStats() {
|
||||
return api<UserStats>('/api/v1/me/stats');
|
||||
}
|
||||
|
||||
export interface UserSummary {
|
||||
streak_days: number;
|
||||
due_now: number;
|
||||
}
|
||||
|
||||
export function loadSummary() {
|
||||
return api<UserSummary>('/api/v1/me/summary');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,10 +5,11 @@ export type DueReview = Review & {
|
|||
card?: Pick<Card, 'id' | 'deck_id' | 'type' | 'fields'>;
|
||||
};
|
||||
|
||||
export function listDueReviews(opts: { deckId?: string; limit?: number } = {}) {
|
||||
export function listDueReviews(opts: { deckId?: string; limit?: number; recovery?: boolean } = {}) {
|
||||
const params = new URLSearchParams();
|
||||
if (opts.deckId) params.set('deck_id', opts.deckId);
|
||||
if (opts.limit) params.set('limit', String(opts.limit));
|
||||
if (opts.recovery) params.set('recovery', 'true');
|
||||
const qs = params.toString();
|
||||
return api<{ reviews: DueReview[]; total: number }>(
|
||||
`/api/v1/reviews/due${qs ? `?${qs}` : ''}`
|
||||
|
|
@ -21,3 +22,7 @@ export function gradeReview(cardId: string, subIndex: number, rating: Rating) {
|
|||
body: { rating },
|
||||
});
|
||||
}
|
||||
|
||||
export function undoReview(cardId: string, subIndex: number) {
|
||||
return api<Review>(`/api/v1/reviews/${cardId}/${subIndex}/undo`, { method: 'POST' });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { page } from '$app/state';
|
||||
import { goto } from '$app/navigation';
|
||||
import { devUser } from '$lib/auth/dev-stub.svelte.ts';
|
||||
import { t } from '$lib/i18n/index.svelte.ts';
|
||||
import { loadSummary } from '$lib/api/me.ts';
|
||||
|
||||
const navItems = $derived([
|
||||
{ id: 'decks', label: t('nav.decks') },
|
||||
|
|
@ -30,6 +32,20 @@
|
|||
devUser.user?.email?.charAt(0).toUpperCase() ??
|
||||
'?'
|
||||
);
|
||||
|
||||
let streakDays = $state(0);
|
||||
let dueNow = $state(0);
|
||||
|
||||
onMount(async () => {
|
||||
if (!devUser.id) return;
|
||||
try {
|
||||
const s = await loadSummary();
|
||||
streakDays = s.streak_days;
|
||||
dueNow = s.due_now;
|
||||
} catch {
|
||||
// ignorieren — Header ist non-critical
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="bottom-bar" role="navigation" aria-label={t('common.main_nav')}>
|
||||
|
|
@ -38,6 +54,22 @@
|
|||
|
||||
<div class="divider" aria-hidden="true"></div>
|
||||
|
||||
{#if devUser.id && (streakDays > 0 || dueNow > 0)}
|
||||
<div class="header-meta" aria-label="Lernstatus">
|
||||
{#if streakDays > 0}
|
||||
<span class="streak-pill" title="{streakDays} Tage Streak">
|
||||
🔥 {streakDays}
|
||||
</span>
|
||||
{/if}
|
||||
{#if dueNow > 0}
|
||||
<span class="due-pill" title="{dueNow} Karten fällig">
|
||||
{dueNow}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="divider" aria-hidden="true"></div>
|
||||
{/if}
|
||||
|
||||
<!-- Hauptnavigation -->
|
||||
{#each navItems as item (item.id)}
|
||||
<button
|
||||
|
|
@ -187,4 +219,32 @@
|
|||
text-decoration: none;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.header-meta {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.streak-pill {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--color-foreground));
|
||||
padding: 0.125rem 0.375rem;
|
||||
border-radius: 9999px;
|
||||
background: hsl(30 100% 55% / 0.12);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.due-pill {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 700;
|
||||
color: hsl(var(--color-primary-foreground));
|
||||
background: hsl(var(--color-primary));
|
||||
padding: 0.125rem 0.4rem;
|
||||
border-radius: 9999px;
|
||||
min-width: 1.25rem;
|
||||
text-align: center;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -315,6 +315,16 @@ export const de: TranslationNode = {
|
|||
leeches_none: 'Keine zähen Karten — sauber.',
|
||||
leeches_lapses: '{n} Ausrutscher',
|
||||
leeches_lapses_one: '{n} Ausrutscher',
|
||||
difficulty_title: 'Schwierigkeitsverteilung',
|
||||
difficulty_desc: 'Wie schwer sind deine Karten laut FSRS.',
|
||||
difficulty_very_easy: 'Sehr leicht',
|
||||
difficulty_easy: 'Leicht',
|
||||
difficulty_medium: 'Mittel',
|
||||
difficulty_hard: 'Schwer',
|
||||
difficulty_very_hard: 'Sehr schwer',
|
||||
mastery_title: 'Deck-Fortschritt',
|
||||
mastery_desc: 'Anteil gemeisterter Karten (Stabilität > 21 Tage).',
|
||||
mastery_empty: 'Noch keine Review-Daten.',
|
||||
loading: 'Lade…',
|
||||
error: 'Fehler: {msg}',
|
||||
},
|
||||
|
|
|
|||
|
|
@ -310,6 +310,16 @@ export const en: TranslationNode = {
|
|||
leeches_none: 'No leeches — clean slate.',
|
||||
leeches_lapses: '{n} lapses',
|
||||
leeches_lapses_one: '{n} lapse',
|
||||
difficulty_title: 'Difficulty distribution',
|
||||
difficulty_desc: 'How difficult are your cards according to FSRS.',
|
||||
difficulty_very_easy: 'Very easy',
|
||||
difficulty_easy: 'Easy',
|
||||
difficulty_medium: 'Medium',
|
||||
difficulty_hard: 'Hard',
|
||||
difficulty_very_hard: 'Very hard',
|
||||
mastery_title: 'Deck progress',
|
||||
mastery_desc: 'Share of mastered cards (stability > 21 days).',
|
||||
mastery_empty: 'No review data yet.',
|
||||
loading: 'Loading…',
|
||||
error: 'Error: {msg}',
|
||||
},
|
||||
|
|
|
|||
|
|
@ -310,6 +310,16 @@ export const es: TranslationNode = {
|
|||
leeches_none: 'Ninguna carta difícil — todo bien.',
|
||||
leeches_lapses: '{n} olvidos',
|
||||
leeches_lapses_one: '{n} olvido',
|
||||
difficulty_title: 'Difficulty distribution',
|
||||
difficulty_desc: 'How difficult are your cards according to FSRS.',
|
||||
difficulty_very_easy: 'Very easy',
|
||||
difficulty_easy: 'Easy',
|
||||
difficulty_medium: 'Medium',
|
||||
difficulty_hard: 'Hard',
|
||||
difficulty_very_hard: 'Very hard',
|
||||
mastery_title: 'Deck progress',
|
||||
mastery_desc: 'Share of mastered cards (stability > 21 days).',
|
||||
mastery_empty: 'No review data yet.',
|
||||
loading: 'Cargando…',
|
||||
error: 'Error: {msg}',
|
||||
},
|
||||
|
|
|
|||
|
|
@ -310,6 +310,16 @@ export const fr: TranslationNode = {
|
|||
leeches_none: 'Aucune carte difficile — tout va bien.',
|
||||
leeches_lapses: '{n} oublis',
|
||||
leeches_lapses_one: '{n} oubli',
|
||||
difficulty_title: 'Difficulty distribution',
|
||||
difficulty_desc: 'How difficult are your cards according to FSRS.',
|
||||
difficulty_very_easy: 'Very easy',
|
||||
difficulty_easy: 'Easy',
|
||||
difficulty_medium: 'Medium',
|
||||
difficulty_hard: 'Hard',
|
||||
difficulty_very_hard: 'Very hard',
|
||||
mastery_title: 'Deck progress',
|
||||
mastery_desc: 'Share of mastered cards (stability > 21 days).',
|
||||
mastery_empty: 'No review data yet.',
|
||||
loading: 'Chargement…',
|
||||
error: 'Erreur : {msg}',
|
||||
},
|
||||
|
|
|
|||
|
|
@ -310,6 +310,16 @@ export const it: TranslationNode = {
|
|||
leeches_none: 'Nessuna carta difficile — pulito.',
|
||||
leeches_lapses: '{n} ricadute',
|
||||
leeches_lapses_one: '{n} ricaduta',
|
||||
difficulty_title: 'Difficulty distribution',
|
||||
difficulty_desc: 'How difficult are your cards according to FSRS.',
|
||||
difficulty_very_easy: 'Very easy',
|
||||
difficulty_easy: 'Easy',
|
||||
difficulty_medium: 'Medium',
|
||||
difficulty_hard: 'Hard',
|
||||
difficulty_very_hard: 'Very hard',
|
||||
mastery_title: 'Deck progress',
|
||||
mastery_desc: 'Share of mastered cards (stability > 21 days).',
|
||||
mastery_empty: 'No review data yet.',
|
||||
loading: 'Caricamento…',
|
||||
error: 'Errore: {msg}',
|
||||
},
|
||||
|
|
|
|||
|
|
@ -27,6 +27,8 @@
|
|||
let category = $state<DeckCategoryId | null>(null);
|
||||
let visibility = $state<'private' | 'space' | 'public'>('private');
|
||||
|
||||
let fsrsRetention = $state(0.9);
|
||||
|
||||
let loading = $state(true);
|
||||
let saving = $state(false);
|
||||
let deleting = $state(false);
|
||||
|
|
@ -54,6 +56,7 @@
|
|||
color = d.color ?? '#6366f1';
|
||||
category = (d.category as DeckCategoryId | null) ?? null;
|
||||
visibility = (d.visibility as 'private' | 'space' | 'public') ?? 'private';
|
||||
fsrsRetention = (d.fsrs_settings as { request_retention?: number } | null)?.request_retention ?? 0.9;
|
||||
if (d.forked_from_marketplace_deck_id) {
|
||||
void loadMarketplaceInfo(d);
|
||||
}
|
||||
|
|
@ -98,6 +101,7 @@
|
|||
color,
|
||||
category: category ?? undefined,
|
||||
visibility,
|
||||
fsrs_settings: { request_retention: fsrsRetention },
|
||||
});
|
||||
toasts.success('Gespeichert.');
|
||||
goto(`/decks/${deckId}`);
|
||||
|
|
@ -259,7 +263,45 @@
|
|||
</form>
|
||||
</section>
|
||||
|
||||
<!-- ── 2. Marketplace (nur für Forks) ─────────────────────────── -->
|
||||
<!-- ── 2. Lern-Algorithmus ───────────────────────────────────────── -->
|
||||
<section class="settings-section">
|
||||
<h2 class="section-title">Lern-Algorithmus</h2>
|
||||
<div class="section-body">
|
||||
<div class="field">
|
||||
<div class="retention-header">
|
||||
<span class="field-label">Ziel-Retention</span>
|
||||
<span class="retention-value">{Math.round(fsrsRetention * 100)} %</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min="50"
|
||||
max="99"
|
||||
step="1"
|
||||
value={Math.round(fsrsRetention * 100)}
|
||||
oninput={(e) => (fsrsRetention = Number((e.target as HTMLInputElement).value) / 100)}
|
||||
class="retention-slider"
|
||||
/>
|
||||
<div class="retention-ticks" aria-hidden="true">
|
||||
<span>50 %</span>
|
||||
<span>70 %</span>
|
||||
<span>90 %</span>
|
||||
<span>99 %</span>
|
||||
</div>
|
||||
<p class="field-hint">
|
||||
Wie viele Karten du beim Abfragen richtig beantwortest (im Durchschnitt).
|
||||
Höher = mehr Wiederholungen, aber bessere Erinnerung.
|
||||
<strong>90 % ist ein guter Ausgangswert.</strong>
|
||||
</p>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button type="button" disabled={!canSave} class="btn-primary" onclick={onSave}>
|
||||
{saving ? 'Speichern…' : 'Speichern'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ── 3. Marketplace (nur für Forks) ─────────────────────────── -->
|
||||
{#if isForked}
|
||||
<section class="settings-section">
|
||||
<h2 class="section-title">Marketplace</h2>
|
||||
|
|
@ -295,7 +337,7 @@
|
|||
</section>
|
||||
{/if}
|
||||
|
||||
<!-- ── 3. Gefahrenzone ────────────────────────────────────────── -->
|
||||
<!-- ── 4. Gefahrenzone ────────────────────────────────────────── -->
|
||||
<section class="settings-section danger-section">
|
||||
<h2 class="section-title">Weitere Aktionen</h2>
|
||||
<div class="section-body danger-body">
|
||||
|
|
@ -725,4 +767,41 @@
|
|||
.btn-ghost:hover {
|
||||
color: hsl(var(--color-foreground));
|
||||
}
|
||||
|
||||
/* ── Retention slider ──────────────────────────────────────────── */
|
||||
|
||||
.retention-header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.retention-value {
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
color: hsl(var(--color-primary));
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.retention-slider {
|
||||
width: 100%;
|
||||
accent-color: hsl(var(--color-primary));
|
||||
cursor: pointer;
|
||||
margin: 0.25rem 0 0.125rem;
|
||||
}
|
||||
|
||||
.retention-ticks {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 0.6875rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
padding: 0 0.125rem;
|
||||
}
|
||||
|
||||
.field-hint {
|
||||
font-size: 0.8125rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
line-height: 1.5;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -20,6 +20,8 @@
|
|||
const retentionLayers = stackLayers('stats-retention', 3);
|
||||
const forecastLayers = stackLayers('stats-forecast', 3);
|
||||
const leechesLayers = stackLayers('stats-leeches', 3);
|
||||
const diffLayers = stackLayers('stats-diff', 3);
|
||||
const masteryLayers = stackLayers('stats-mastery', 3);
|
||||
|
||||
const peakDay = $derived.by(() => {
|
||||
if (!stats) return 1;
|
||||
|
|
@ -57,6 +59,11 @@
|
|||
return Math.max(1, ...stats.due_forecast.map((d) => d.n));
|
||||
});
|
||||
|
||||
const maxDiffN = $derived.by(() => {
|
||||
if (!stats) return 1;
|
||||
return Math.max(1, ...stats.difficulty_distribution.map(d => d.n));
|
||||
});
|
||||
|
||||
function cellLevel(n: number): number {
|
||||
if (n === 0) return 0;
|
||||
if (n <= 2) return 1;
|
||||
|
|
@ -347,6 +354,79 @@
|
|||
</CardSurface>
|
||||
</li>
|
||||
|
||||
<!-- Schwierigkeitsverteilung -->
|
||||
<li class="stack-wrap">
|
||||
{#each diffLayers 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="#EC4899">
|
||||
<div class="card-inner">
|
||||
<div class="card-corner">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" aria-hidden="true"><path d="M22 3H2l8 9.46V19l4 2v-8.54L22 3z"/></svg>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="card-title">{t('stats.difficulty_title')}</p>
|
||||
<p class="card-desc">{t('stats.difficulty_desc')}</p>
|
||||
<div class="diff-list">
|
||||
{#each stats!.difficulty_distribution as item (item.bucket)}
|
||||
{@const colors = { very_easy: '#10B981', easy: '#84CC16', medium: '#F59E0B', hard: '#F97316', very_hard: '#EF4444' }}
|
||||
{@const labels = { very_easy: t('stats.difficulty_very_easy'), easy: t('stats.difficulty_easy'), medium: t('stats.difficulty_medium'), hard: t('stats.difficulty_hard'), very_hard: t('stats.difficulty_very_hard') }}
|
||||
<div class="diff-row">
|
||||
<span class="diff-label">{labels[item.bucket]}</span>
|
||||
<div class="diff-bar-wrap">
|
||||
<div
|
||||
class="diff-bar-fill"
|
||||
style:width="{(item.n / maxDiffN) * 100}%"
|
||||
style:background={colors[item.bucket]}
|
||||
></div>
|
||||
</div>
|
||||
<span class="diff-count">{item.n}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardSurface>
|
||||
</li>
|
||||
|
||||
<!-- Deck-Fortschritt -->
|
||||
<li class="stack-wrap">
|
||||
{#each masteryLayers 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="#7C3AED">
|
||||
<div class="card-inner">
|
||||
<div class="card-corner">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" aria-hidden="true"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="card-title">{t('stats.mastery_title')}</p>
|
||||
<p class="card-desc">{t('stats.mastery_desc')}</p>
|
||||
{#if stats!.deck_mastery.length === 0}
|
||||
<p class="retention-none">{t('stats.mastery_empty')}</p>
|
||||
{:else}
|
||||
<div class="mastery-list">
|
||||
{#each stats!.deck_mastery.slice(0, 6) as deck (deck.deck_id)}
|
||||
<div class="mastery-row">
|
||||
<div class="mastery-head">
|
||||
<span class="mastery-name">{deck.deck_name}</span>
|
||||
<span class="mastery-count">{deck.mastered} / {deck.total}</span>
|
||||
</div>
|
||||
<div class="mastery-bar-wrap">
|
||||
<div
|
||||
class="mastery-bar-fill"
|
||||
style:width="{deck.pct * 100}%"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</CardSurface>
|
||||
</li>
|
||||
|
||||
</ul>
|
||||
{/if}
|
||||
|
||||
|
|
@ -627,4 +707,99 @@
|
|||
|
||||
.leech-sep { opacity: 0.5; }
|
||||
.leech-lapses { color: #DC2626; font-weight: 500; }
|
||||
|
||||
/* Difficulty Distribution */
|
||||
.diff-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.diff-row {
|
||||
display: grid;
|
||||
grid-template-columns: 4.5rem 1fr 1.5rem;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.diff-label {
|
||||
font-size: 0.5625rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.diff-bar-wrap {
|
||||
height: 0.375rem;
|
||||
background: hsl(var(--color-border));
|
||||
border-radius: 9999px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.diff-bar-fill {
|
||||
height: 100%;
|
||||
border-radius: 9999px;
|
||||
transition: width 0.4s ease;
|
||||
}
|
||||
|
||||
.diff-count {
|
||||
font-size: 0.5625rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* Deck Mastery */
|
||||
.mastery-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
margin-top: auto;
|
||||
max-height: 11rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.mastery-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
|
||||
.mastery-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
.mastery-name {
|
||||
font-size: 0.625rem;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 8rem;
|
||||
}
|
||||
|
||||
.mastery-count {
|
||||
font-size: 0.5625rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mastery-bar-wrap {
|
||||
height: 0.3125rem;
|
||||
background: hsl(var(--color-border));
|
||||
border-radius: 9999px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.mastery-bar-fill {
|
||||
height: 100%;
|
||||
background: #7C3AED;
|
||||
border-radius: 9999px;
|
||||
transition: width 0.4s ease;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@
|
|||
} from '@cards/domain';
|
||||
import { apiErrorMessage } from '$lib/api/error.ts';
|
||||
import { getDeck } from '$lib/api/decks.ts';
|
||||
import { listDueReviews, gradeReview, type DueReview } from '$lib/api/reviews.ts';
|
||||
import { listDueReviews, gradeReview, undoReview, type DueReview } from '$lib/api/reviews.ts';
|
||||
import { devUser } from '$lib/auth/dev-stub.svelte.ts';
|
||||
import { renderMarkdown } from '$lib/markdown.ts';
|
||||
import { toasts } from '$lib/stores/toasts.svelte.ts';
|
||||
|
|
@ -32,6 +32,15 @@
|
|||
let loading = $state(true);
|
||||
let busy = $state(false);
|
||||
let stats = $state({ reviewed: 0, again: 0 });
|
||||
let dueTotal = $state(0);
|
||||
let recoveryMode = $state(false);
|
||||
let showRecovery = $state(false);
|
||||
let fsrsTooltipOpen = $state(false);
|
||||
let undoCardId = $state<string | null>(null);
|
||||
let undoSubIndex = $state<number | null>(null);
|
||||
let undoTimer = $state<ReturnType<typeof setTimeout> | null>(null);
|
||||
let undoProgress = $state(0); // 0→100 für den Ablauf-Balken
|
||||
let undoRafId = $state<number | null>(null);
|
||||
|
||||
const current = $derived(queue[queueIndex]);
|
||||
const isDone = $derived(!loading && queueIndex >= queue.length);
|
||||
|
|
@ -131,6 +140,14 @@
|
|||
};
|
||||
});
|
||||
|
||||
const STATE_LABELS: Record<string, string> = {
|
||||
new: 'Neu', learning: 'Lernend', review: 'Wiederholen', relearning: 'Nachlernen'
|
||||
};
|
||||
|
||||
$effect(() => {
|
||||
if (!revealed) fsrsTooltipOpen = false;
|
||||
});
|
||||
|
||||
onMount(async () => {
|
||||
if (!devUser.id) {
|
||||
goto('/');
|
||||
|
|
@ -143,7 +160,9 @@
|
|||
]);
|
||||
deckName = d.name;
|
||||
deckColor = d.color ?? null;
|
||||
dueTotal = due.total;
|
||||
queue = due.reviews;
|
||||
if (due.total > 30 && !recoveryMode) showRecovery = true;
|
||||
} catch (e) {
|
||||
toasts.error(`Sitzung konnte nicht geladen werden: ${apiErrorMessage(e)}`);
|
||||
goto('/study');
|
||||
|
|
@ -153,10 +172,27 @@
|
|||
window.addEventListener('keydown', onKey);
|
||||
});
|
||||
|
||||
async function startRecovery() {
|
||||
showRecovery = false;
|
||||
recoveryMode = true;
|
||||
loading = true;
|
||||
try {
|
||||
const due = await listDueReviews({ deckId, recovery: true });
|
||||
queue = due.reviews;
|
||||
queueIndex = 0;
|
||||
revealed = false;
|
||||
} catch (e) {
|
||||
toasts.error(`Fehler: ${apiErrorMessage(e)}`);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
onDestroy(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
window.removeEventListener('keydown', onKey);
|
||||
}
|
||||
clearUndoTimer();
|
||||
});
|
||||
|
||||
function onKey(e: KeyboardEvent) {
|
||||
|
|
@ -166,6 +202,12 @@
|
|||
if (isTyping) return; // TypingView übernimmt per svelte:window
|
||||
if (isMultipleChoice) return; // MultipleChoiceView übernimmt per svelte:window
|
||||
|
||||
if (e.key === 'z' && (e.ctrlKey || e.metaKey)) {
|
||||
e.preventDefault();
|
||||
void undo();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!revealed) {
|
||||
if (e.key === ' ' || e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
|
|
@ -190,12 +232,59 @@
|
|||
if (rating === 'again') stats.again += 1;
|
||||
queueIndex += 1;
|
||||
revealed = false;
|
||||
// Undo-Fenster öffnen
|
||||
clearUndoTimer();
|
||||
undoCardId = c.card_id;
|
||||
undoSubIndex = c.sub_index;
|
||||
undoProgress = 100;
|
||||
const startTime = Date.now();
|
||||
const DURATION = 5000;
|
||||
function tick() {
|
||||
const elapsed = Date.now() - startTime;
|
||||
undoProgress = Math.max(0, 100 - (elapsed / DURATION) * 100);
|
||||
if (elapsed < DURATION) {
|
||||
undoRafId = requestAnimationFrame(tick);
|
||||
} else {
|
||||
clearUndoTimer();
|
||||
}
|
||||
}
|
||||
undoRafId = requestAnimationFrame(tick);
|
||||
undoTimer = setTimeout(clearUndoTimer, DURATION);
|
||||
} catch (e) {
|
||||
toasts.error(t('card_edit.save_failed', { msg: apiErrorMessage(e) }));
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
function clearUndoTimer() {
|
||||
if (undoTimer !== null) { clearTimeout(undoTimer); undoTimer = null; }
|
||||
if (undoRafId !== null) { cancelAnimationFrame(undoRafId); undoRafId = null; }
|
||||
undoCardId = null;
|
||||
undoSubIndex = null;
|
||||
undoProgress = 0;
|
||||
}
|
||||
|
||||
async function undo() {
|
||||
if (undoCardId === null || undoSubIndex === null || busy) return;
|
||||
const cid = undoCardId;
|
||||
const si = undoSubIndex;
|
||||
clearUndoTimer();
|
||||
busy = true;
|
||||
try {
|
||||
await undoReview(cid, si);
|
||||
// Einen Schritt zurück — queue-Index dekrementieren, Karte wieder zeigen
|
||||
queueIndex = Math.max(0, queueIndex - 1);
|
||||
revealed = false;
|
||||
stats.reviewed = Math.max(0, stats.reviewed - 1);
|
||||
// War die Bewertung 'again'? Nicht eindeutig rückwirkend bestimmbar,
|
||||
// daher konservativ: kein stats.again decrement
|
||||
} catch (e) {
|
||||
toasts.error(`Undo fehlgeschlagen: ${apiErrorMessage(e)}`);
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="study-page">
|
||||
|
|
@ -213,6 +302,19 @@
|
|||
{queueIndex + 1} / {queue.length}
|
||||
{/if}
|
||||
</p>
|
||||
{#if showRecovery}
|
||||
<div class="recovery-banner">
|
||||
<p class="recovery-text">
|
||||
{dueTotal > 50 ? 'Großer Rückstand' : 'Rückstand'}: {queue.length} Karten fällig.
|
||||
</p>
|
||||
<button class="recovery-btn" onclick={startRecovery}>
|
||||
Sanfter Einstieg <span class="recovery-sub">25 schwächste zuerst</span>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
{#if recoveryMode}
|
||||
<p class="recovery-active">⚡ Sanfter Einstieg · {queue.length} Karten</p>
|
||||
{/if}
|
||||
<a class="aside-manage" href="/decks/{deckId}">{t('study_session.manage_link')}</a>
|
||||
</aside>
|
||||
|
||||
|
|
@ -290,6 +392,34 @@
|
|||
<div class="prose answer">{@html answerHtml}</div>
|
||||
{/if}
|
||||
{/if}
|
||||
{#if revealed && current && !isTyping && !isMultipleChoice && !isImageOcclusion}
|
||||
<div class="fsrs-info-wrap">
|
||||
<button
|
||||
class="fsrs-info-btn"
|
||||
onclick={() => (fsrsTooltipOpen = !fsrsTooltipOpen)}
|
||||
aria-label="FSRS-Daten anzeigen"
|
||||
aria-expanded={fsrsTooltipOpen}
|
||||
>ⓘ</button>
|
||||
{#if fsrsTooltipOpen}
|
||||
<div class="fsrs-popover" role="status" aria-live="polite">
|
||||
<div class="fsrs-row">
|
||||
<span class="fsrs-label">Zustand</span>
|
||||
<span class="fsrs-val">{STATE_LABELS[current.state] ?? current.state}</span>
|
||||
</div>
|
||||
<div class="fsrs-row">
|
||||
<span class="fsrs-label">Stabilität</span>
|
||||
<span class="fsrs-val">
|
||||
{current.stability > 0 ? current.stability.toFixed(1) + ' Tage' : '–'}
|
||||
</span>
|
||||
</div>
|
||||
<div class="fsrs-row">
|
||||
<span class="fsrs-label">Schwierigkeit</span>
|
||||
<span class="fsrs-val">{current.difficulty.toFixed(1)} / 10</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</article>
|
||||
</CardSurface>
|
||||
</div>
|
||||
|
|
@ -327,6 +457,15 @@
|
|||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{#if undoCardId !== null}
|
||||
<div class="undo-toast" role="status" aria-live="polite">
|
||||
<div class="undo-bar" style:width="{undoProgress}%"></div>
|
||||
<span class="undo-text">Bewertet</span>
|
||||
<button class="undo-btn" onclick={undo} disabled={busy}>
|
||||
Rückgängig <kbd>⌘Z</kbd>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -437,6 +576,7 @@
|
|||
Portrait-Format (5:7) an: Inhalt vertikal zentriert, Padding rechts
|
||||
vom Color-Stripe. */
|
||||
.study-inner {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
|
|
@ -667,4 +807,169 @@
|
|||
.grade.grade-easy:hover:not(:disabled) {
|
||||
background: hsl(var(--color-success) / 0.08);
|
||||
}
|
||||
|
||||
.fsrs-info-wrap {
|
||||
position: absolute;
|
||||
bottom: 0.75rem;
|
||||
right: 0.75rem;
|
||||
}
|
||||
|
||||
.fsrs-info-btn {
|
||||
width: 1.375rem;
|
||||
height: 1.375rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
opacity: 0.6;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.fsrs-info-btn:hover { opacity: 1; }
|
||||
|
||||
.fsrs-popover {
|
||||
position: absolute;
|
||||
bottom: 1.875rem;
|
||||
right: 0;
|
||||
background: hsl(var(--color-surface));
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.625rem 0.875rem;
|
||||
min-width: 10rem;
|
||||
z-index: 20;
|
||||
box-shadow: 0 4px 16px hsl(var(--color-foreground) / 0.1);
|
||||
}
|
||||
|
||||
.fsrs-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 0.125rem 0;
|
||||
}
|
||||
|
||||
.fsrs-label {
|
||||
font-size: 0.6875rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
|
||||
.fsrs-val {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 500;
|
||||
color: hsl(var(--color-foreground));
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.recovery-banner {
|
||||
margin-top: 0.25rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: hsl(45 96% 58% / 0.12);
|
||||
border: 1px solid hsl(45 96% 58% / 0.35);
|
||||
border-radius: 0.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.recovery-text {
|
||||
margin: 0;
|
||||
font-size: 0.6875rem;
|
||||
color: hsl(var(--color-foreground));
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.recovery-btn {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 0.375rem;
|
||||
background: hsl(45 96% 58%);
|
||||
color: hsl(30 60% 15%);
|
||||
border: none;
|
||||
border-radius: 0.375rem;
|
||||
padding: 0.3125rem 0.625rem;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.recovery-btn:hover { opacity: 0.85; }
|
||||
|
||||
.recovery-sub {
|
||||
font-weight: 400;
|
||||
opacity: 0.8;
|
||||
font-size: 0.625rem;
|
||||
}
|
||||
|
||||
.recovery-active {
|
||||
margin: 0.25rem 0 0;
|
||||
font-size: 0.6875rem;
|
||||
color: hsl(45 96% 45%);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.undo-toast {
|
||||
position: relative;
|
||||
margin-top: 0.75rem;
|
||||
width: 100%;
|
||||
max-width: 24rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.625rem 0.875rem;
|
||||
background: hsl(var(--color-surface));
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
border-radius: 0.625rem;
|
||||
box-shadow: 0 2px 8px hsl(var(--color-foreground) / 0.08);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.undo-bar {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
height: 2px;
|
||||
background: hsl(var(--color-primary));
|
||||
transition: width 0.1s linear;
|
||||
border-radius: 0 0 0 0.625rem;
|
||||
}
|
||||
|
||||
.undo-text {
|
||||
font-size: 0.8125rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.undo-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.3125rem 0.75rem;
|
||||
background: hsl(var(--color-primary) / 0.1);
|
||||
color: hsl(var(--color-primary));
|
||||
border: 1px solid hsl(var(--color-primary) / 0.25);
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
transition: background 0.15s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.undo-btn:hover:not(:disabled) {
|
||||
background: hsl(var(--color-primary) / 0.18);
|
||||
}
|
||||
.undo-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.undo-btn kbd {
|
||||
font-size: 0.6875rem;
|
||||
color: hsl(var(--color-primary) / 0.7);
|
||||
font-family: inherit;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue