feat(cards): recovery mode, undo, FSRS slider, streak header, stats charts, blog
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:
Till JS 2026-05-13 13:37:03 +02:00
parent 21ec535173
commit abf493aeec
27 changed files with 2667 additions and 29 deletions

View file

@ -0,0 +1 @@
ALTER TABLE "cards"."reviews" ADD COLUMN "prev_snapshot" jsonb;

View file

@ -8,6 +8,13 @@
"when": 1778604624860, "when": 1778604624860,
"tag": "0000_baseline", "tag": "0000_baseline",
"breakpoints": true "breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1747180800000,
"tag": "0001_reviews_prev_snapshot",
"breakpoints": true
} }
] ]
} }

View file

@ -1,4 +1,4 @@
import { index, integer, primaryKey, real, text, timestamp } from 'drizzle-orm/pg-core'; import { index, integer, jsonb, primaryKey, real, text, timestamp } from 'drizzle-orm/pg-core';
import { cardsSchema } from './_schema.ts'; import { cardsSchema } from './_schema.ts';
import { cards } from './cards.ts'; import { cards } from './cards.ts';
@ -37,6 +37,7 @@ export const reviews = cardsSchema.table(
.notNull() .notNull()
.default('new'), .default('new'),
lastReview: timestamp('last_review', { withTimezone: true, mode: 'date' }), lastReview: timestamp('last_review', { withTimezone: true, mode: 'date' }),
prevSnapshot: jsonb('prev_snapshot').$type<Record<string, unknown> | null>().default(null),
}, },
(t) => ({ (t) => ({
pk: primaryKey({ columns: [t.cardId, t.subIndex] }), pk: primaryKey({ columns: [t.cardId, t.subIndex] }),

View file

@ -68,6 +68,47 @@ export function meRouter(deps: MeDeps = {}): Hono<{ Variables: AuthVars }> {
return c.json(payload); return c.json(payload);
}); });
/**
* Schlanker Kurzschnitt: nur Streak und fällige Karten. Ideal für
* Home-Screen-Widgets oder schnelle Polling-Clients.
*/
r.get('/summary', async (c) => {
const userId = c.get('userId');
const db = dbOf();
const now = new Date();
const ninetyAgo = new Date(Date.now() - 91 * 24 * 60 * 60 * 1000);
const [dueCountRow] = await db
.select({ n: sql<number>`count(*)::int` })
.from(reviews)
.where(and(eq(reviews.userId, userId), lte(reviews.due, now)));
const dayRows = await db
.select({
day: sql<string>`to_char(${reviews.lastReview}, 'YYYY-MM-DD')`,
n: sql<number>`count(*)::int`,
})
.from(reviews)
.where(
and(eq(reviews.userId, userId), isNotNull(reviews.lastReview), gte(reviews.lastReview, ninetyAgo))
)
.groupBy(sql`to_char(${reviews.lastReview}, 'YYYY-MM-DD')`);
const byDay = new Map(dayRows.map((r) => [r.day, r.n]));
let streak = 0;
for (let i = 0; i < 91; i++) {
const d = new Date(Date.now() - i * 24 * 60 * 60 * 1000);
const key = d.toISOString().slice(0, 10);
if ((byDay.get(key) ?? 0) > 0) streak++;
else break;
}
return c.json({
streak_days: streak,
due_now: dueCountRow?.n ?? 0,
});
});
/** /**
* Statistik-Snapshot für die Account-/Stats-Page. Nicht-cached, alle * Statistik-Snapshot für die Account-/Stats-Page. Nicht-cached, alle
* Aggregate per Query. Ein einzelner Aufruf reicht für die /stats-UI. * Aggregate per Query. Ein einzelner Aufruf reicht für die /stats-UI.
@ -221,6 +262,67 @@ export function meRouter(deps: MeDeps = {}): Hono<{ Variables: AuthVars }> {
last_review: r.lastReview ? r.lastReview.toISOString() : null, last_review: r.lastReview ? r.lastReview.toISOString() : null,
})); }));
// Difficulty-Distribution: 5 Buckets über alle non-new Reviews.
const diffRows = await db
.select({
bucket: sql<string>`CASE
WHEN ${reviews.difficulty} < 2 THEN 'very_easy'
WHEN ${reviews.difficulty} < 4 THEN 'easy'
WHEN ${reviews.difficulty} < 6 THEN 'medium'
WHEN ${reviews.difficulty} < 8 THEN 'hard'
ELSE 'very_hard'
END`,
n: sql<number>`count(*)::int`,
})
.from(reviews)
.where(and(eq(reviews.userId, userId), sql`${reviews.state} != 'new'`))
.groupBy(
sql`CASE
WHEN ${reviews.difficulty} < 2 THEN 'very_easy'
WHEN ${reviews.difficulty} < 4 THEN 'easy'
WHEN ${reviews.difficulty} < 6 THEN 'medium'
WHEN ${reviews.difficulty} < 8 THEN 'hard'
ELSE 'very_hard'
END`
);
const BUCKETS = ['very_easy', 'easy', 'medium', 'hard', 'very_hard'] as const;
const diffMap = new Map(diffRows.map((r) => [r.bucket, r.n]));
const difficultyDistribution = BUCKETS.map((b) => ({ bucket: b, n: diffMap.get(b) ?? 0 }));
// Deck-Mastery: pro Deck total reviews + mastered (stability > 21).
const deckMasteryRows = await db
.select({
deck_id: decks.id,
deck_name: decks.name,
total: sql<number>`count(${reviews.cardId})::int`,
mastered: sql<number>`count(${reviews.cardId}) FILTER (WHERE ${reviews.stability} > 21)::int`,
})
.from(decks)
.leftJoin(cards, eq(cards.deckId, decks.id))
.leftJoin(
reviews,
and(
eq(reviews.cardId, cards.id),
eq(reviews.userId, userId),
sql`${reviews.state} != 'new'`
)
)
.where(eq(decks.userId, userId))
.groupBy(decks.id, decks.name)
.orderBy(
sql`count(${reviews.cardId}) FILTER (WHERE ${reviews.stability} > 21)::float
/ NULLIF(count(${reviews.cardId}), 0) DESC NULLS LAST`
);
const deckMastery = deckMasteryRows.map((r) => ({
deck_id: r.deck_id,
deck_name: r.deck_name,
total: r.total,
mastered: r.mastered,
pct: r.total > 0 ? r.mastered / r.total : 0,
}));
return c.json({ return c.json({
user_id: userId, user_id: userId,
generated_at: now.toISOString(), generated_at: now.toISOString(),
@ -238,6 +340,8 @@ export function meRouter(deps: MeDeps = {}): Hono<{ Variables: AuthVars }> {
due_forecast: dueForecast, due_forecast: dueForecast,
leech_threshold: LEECH_THRESHOLD, leech_threshold: LEECH_THRESHOLD,
leech_cards: leechCards, leech_cards: leechCards,
difficulty_distribution: difficultyDistribution,
deck_mastery: deckMastery,
}); });
}); });

View file

@ -28,10 +28,12 @@ export function reviewsRouter(deps: ReviewsDeps = {}): Hono<{ Variables: AuthVar
r.get('/due', async (c) => { r.get('/due', async (c) => {
const userId = c.get('userId'); const userId = c.get('userId');
const deckId = c.req.query('deck_id'); const deckId = c.req.query('deck_id');
const limit = Math.min(Number(c.req.query('limit') ?? 100), 500); const recovery = c.req.query('recovery') === 'true';
const limit = recovery ? 25 : Math.min(Number(c.req.query('limit') ?? 100), 500);
const now = new Date(); const now = new Date();
const conditions = [eq(reviews.userId, userId), lte(reviews.due, now)]; const conditions = [eq(reviews.userId, userId), lte(reviews.due, now)];
const orderBy = recovery ? asc(reviews.stability) : asc(reviews.due);
if (deckId) { if (deckId) {
// Wenn deck_id angegeben, joinen wir auf cards.deck_id. // Wenn deck_id angegeben, joinen wir auf cards.deck_id.
@ -43,7 +45,7 @@ export function reviewsRouter(deps: ReviewsDeps = {}): Hono<{ Variables: AuthVar
.from(reviews) .from(reviews)
.innerJoin(cards, eq(cards.id, reviews.cardId)) .innerJoin(cards, eq(cards.id, reviews.cardId))
.where(and(...conditions, eq(cards.deckId, deckId))) .where(and(...conditions, eq(cards.deckId, deckId)))
.orderBy(asc(reviews.due)) .orderBy(orderBy)
.limit(limit); .limit(limit);
return c.json({ return c.json({
reviews: rows.map((r) => ({ ...toReviewDto(r.review), card: r.card })), reviews: rows.map((r) => ({ ...toReviewDto(r.review), card: r.card })),
@ -55,7 +57,7 @@ export function reviewsRouter(deps: ReviewsDeps = {}): Hono<{ Variables: AuthVar
.select() .select()
.from(reviews) .from(reviews)
.where(and(...conditions)) .where(and(...conditions))
.orderBy(asc(reviews.due)) .orderBy(orderBy)
.limit(limit); .limit(limit);
return c.json({ reviews: rows.map(toReviewDto), total: rows.length }); return c.json({ reviews: rows.map(toReviewDto), total: rows.length });
}); });
@ -114,6 +116,9 @@ export function reviewsRouter(deps: ReviewsDeps = {}): Hono<{ Variables: AuthVar
(hit.deck.fsrsSettings as object) ?? {} (hit.deck.fsrsSettings as object) ?? {}
); );
// Snapshot des alten Zustands vor dem Überschreiben — ermöglicht Undo.
const snapshot = toReviewDto(hit.review) as Record<string, unknown>;
const [updated] = await dbOf() const [updated] = await dbOf()
.update(reviews) .update(reviews)
.set({ .set({
@ -127,6 +132,7 @@ export function reviewsRouter(deps: ReviewsDeps = {}): Hono<{ Variables: AuthVar
lapses: next.lapses, lapses: next.lapses,
state: next.state, state: next.state,
lastReview: next.last_review ? new Date(next.last_review) : null, lastReview: next.last_review ? new Date(next.last_review) : null,
prevSnapshot: snapshot,
}) })
.where( .where(
and( and(
@ -140,6 +146,51 @@ export function reviewsRouter(deps: ReviewsDeps = {}): Hono<{ Variables: AuthVar
return c.json(toReviewDto(updated)); return c.json(toReviewDto(updated));
}); });
/**
* Macht die letzte Bewertung rückgängig stellt prevSnapshot wieder her
* und löscht den Snapshot danach. Nur einmal pro Bewertung möglich.
*/
r.post('/:cardId/:subIndex/undo', async (c) => {
const userId = c.get('userId');
const cardId = c.req.param('cardId');
const subIndex = Number(c.req.param('subIndex'));
if (!Number.isInteger(subIndex) || subIndex < 0) {
return c.json({ error: 'invalid_sub_index' }, 422);
}
const [hit] = await dbOf()
.select()
.from(reviews)
.where(and(eq(reviews.cardId, cardId), eq(reviews.subIndex, subIndex), eq(reviews.userId, userId)))
.limit(1);
if (!hit) return c.json({ error: 'not_found' }, 404);
if (!hit.prevSnapshot) return c.json({ error: 'no_snapshot' }, 409);
const snap = hit.prevSnapshot as Record<string, unknown>;
const [restored] = await dbOf()
.update(reviews)
.set({
due: new Date(snap['due'] as string),
stability: snap['stability'] as number,
difficulty: snap['difficulty'] as number,
elapsedDays: snap['elapsed_days'] as number,
scheduledDays: snap['scheduled_days'] as number,
learningSteps: snap['learning_steps'] as number,
reps: snap['reps'] as number,
lapses: snap['lapses'] as number,
state: snap['state'] as typeof reviews.$inferSelect['state'],
lastReview: snap['last_review'] ? new Date(snap['last_review'] as string) : null,
prevSnapshot: null,
})
.where(and(eq(reviews.cardId, cardId), eq(reviews.subIndex, subIndex), eq(reviews.userId, userId)))
.returning();
return c.json(toReviewDto(restored));
});
return r; return r;
} }

View file

@ -0,0 +1,75 @@
---
const posts = [
{
href: '/blog/quizlet-paywall',
tag: 'Migration',
title: 'Quizlet zieht die Paywall hoch — was jetzt?',
summary:
'Fünf Millionen Nutzer:innen haben Quizlet in den letzten zwei Jahren verlassen. Was hinter der Bezahlschranke steckt und wie der Wechsel zu Cardecky in wenigen Minuten klappt.',
},
{
href: '/blog/fsrs-algorithmus',
tag: 'Algorithmus',
title: 'Weniger lernen, mehr behalten: Was FSRS bedeutet',
summary:
'FSRS reduziert die nötigen Wiederholungen um 2030 % bei gleicher Retention. Wie der Algorithmus funktioniert — und warum er bei anderen Apps noch nicht der Standard ist.',
},
{
href: '/blog/deine-daten',
tag: 'Datenschutz',
title: 'Deine Karten, deine Daten',
summary:
'Lerndaten verraten mehr als die meisten ahnen. Was Cardecky damit macht — und was nicht — lässt sich im öffentlichen Quellcode nachprüfen.',
},
{
href: '/blog/anki-zu-kompliziert',
tag: 'Vergleich',
title: 'Anki ist mächtig — und trotzdem schwer empfehlbar',
summary:
'Anki ist technisch überlegen. Trotzdem hören die meisten Nutzer:innen auf — nicht wegen des Algorithmus, sondern wegen Interface und Lernkurve.',
},
{
href: '/blog/gute-lernkarten',
tag: 'Lernen',
title: 'Wie man Lernkarten schreibt, die wirklich funktionieren',
summary:
'Der beste Algorithmus nützt nichts, wenn die Karten schlecht gebaut sind. Fünf Prinzipien — mit Gegenbeispielen.',
},
] as const;
---
<section class="border-b border-rule py-20 sm:py-28">
<div class="mx-auto max-w-content px-6">
<p class="section-label">Artikel</p>
<h2 class="mt-3 font-serif text-display text-ink">Hintergrund &amp; Methodik.</h2>
<p class="mt-4 max-w-prose text-muted">
Warum Spaced Repetition funktioniert, was die Konkurrenz falsch macht — und wie man
Karten baut, die das Gehirn tatsächlich behält.
</p>
<ul class="mt-12 grid gap-6 sm:grid-cols-2 lg:grid-cols-3" role="list">
{posts.map((post) => (
<li>
<a
href={post.href}
class="group flex h-full flex-col rounded-xl border border-rule bg-paper p-6 transition-shadow hover:shadow-md"
>
<span class="section-label mb-3 inline-block">{post.tag}</span>
<p class="font-serif text-lg font-semibold leading-snug text-ink group-hover:text-leaf transition-colors">
{post.title}
</p>
<p class="mt-3 flex-1 text-sm leading-relaxed text-muted">
{post.summary}
</p>
<span class="mt-5 inline-flex items-center gap-1 text-sm font-medium text-leaf">
Lesen
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path d="M5 12h14M12 5l7 7-7 7"/>
</svg>
</span>
</a>
</li>
))}
</ul>
</div>
</section>

View file

@ -3,7 +3,7 @@ const year = new Date().getFullYear();
--- ---
<footer class="bg-ink py-12 text-white/60"> <footer class="bg-ink py-12 text-white/60">
<div class="mx-auto max-w-content px-6"> <div class="mx-auto max-w-content px-6">
<div class="grid gap-8 sm:grid-cols-3"> <div class="grid gap-8 sm:grid-cols-2 lg:grid-cols-4">
<div> <div>
<p class="font-serif text-base font-semibold text-white">Cardecky</p> <p class="font-serif text-base font-semibold text-white">Cardecky</p>
<p class="mt-2 text-sm leading-relaxed"> <p class="mt-2 text-sm leading-relaxed">
@ -21,6 +21,17 @@ const year = new Date().getFullYear();
</ul> </ul>
</div> </div>
<div>
<p class="text-xs font-semibold uppercase tracking-widest text-white/40">Artikel</p>
<ul class="mt-3 space-y-2 text-sm">
<li><a href="/blog/quizlet-paywall" class="hover:text-white transition-colors">Quizlet-Alternative</a></li>
<li><a href="/blog/fsrs-algorithmus" class="hover:text-white transition-colors">Was ist FSRS?</a></li>
<li><a href="/blog/deine-daten" class="hover:text-white transition-colors">Deine Daten</a></li>
<li><a href="/blog/anki-zu-kompliziert" class="hover:text-white transition-colors">Anki vs. Cardecky</a></li>
<li><a href="/blog/gute-lernkarten" class="hover:text-white transition-colors">Gute Lernkarten</a></li>
</ul>
</div>
<div> <div>
<p class="text-xs font-semibold uppercase tracking-widest text-white/40">Verein</p> <p class="text-xs font-semibold uppercase tracking-widest text-white/40">Verein</p>
<ul class="mt-3 space-y-2 text-sm"> <ul class="mt-3 space-y-2 text-sm">

View file

@ -0,0 +1,321 @@
---
import Layout from '../../layouts/Layout.astro';
import Nav from '../../components/Nav.astro';
import Footer from '../../components/Footer.astro';
import '../../styles/base.css';
const APP_URL = 'https://cardecky.mana.how';
const ANKI_IMPORT_URL = 'https://cardecky.mana.how/import';
const title = 'Anki ist mächtig — und trotzdem schwer empfehlbar | Cardecky';
const description =
'Anki ist der Goldstandard für Spaced Repetition. Aber die Lernkurve ist brutal, das' +
' Interface stammt aus 2006, und einen Freund damit anzufangen ist fast unmöglich. Was' +
' Cardecky anders macht.';
---
<Layout {title} {description}>
<Nav />
<main class="mx-auto max-w-prose px-6 py-16 sm:py-24">
<!-- Artikel-Header -->
<header class="mb-12">
<p class="section-label mb-4">Vergleich · Anki</p>
<h1 class="font-serif text-4xl leading-tight text-ink sm:text-5xl">
Anki ist mächtig&nbsp;— und trotzdem schwer empfehlbar
</h1>
<p class="mt-5 text-lg leading-relaxed text-muted">
Anki ist der technisch überlegene Lernkarten-Standard. Die meisten Nutzer:innen scheitern
trotzdem — nicht am Algorithmus, sondern am Interface. Was das konkret bedeutet und wo
Cardecky ansetzt.
</p>
<p class="mt-4 text-xs text-muted">Stand: Mai 2026</p>
</header>
<article class="prose-cardecky">
<!-- 1 -->
<h2>Das Paradox: alle wissen, dass es funktioniert</h2>
<p>
Wenn jemand über Lernkarten redet, fällt Anki. Wenn jemand über Spaced Repetition redet,
fällt Anki. Medizinstudenten schwören seit Jahren darauf, Sprachenlerner haben ganze
Karrieren darauf aufgebaut, und wer tief in der Lernforschung sitzt, kennt die Studie, die
die Studie belegt. Anki funktioniert. Das ist nicht strittig.
</p>
<p>
Und trotzdem kennt fast jeder mindestens eine Person, die Anki angefangen und aufgehört hat.
Nicht weil das Lernen nicht funktioniert hat — sondern weil die App selbst im Weg stand. Das
ist das Paradox: ein Werkzeug, das technisch unbestritten ist und dessen Nutzer:innen
massenweise aussteigen, bevor sie je richtig angefangen haben.
</p>
<!-- 2 -->
<h2>Was Anki wirklich kann</h2>
<p>
Es wäre unfair, Anki nur durch den Blick auf seine Schwächen zu beschreiben. Anki ist seit
2006 im Aufbau und hat in dieser Zeit eine Tiefe entwickelt, die kein neueres Tool auch nur
annähernd erreicht.
</p>
<p>
Das Add-on-Ökosystem allein ist bemerkenswert: hunderte von Community-Erweiterungen, von
automatischer Bildsuche über LaTeX-Rendering bis zu spezialisierten Scheduling-Varianten.
Die Community-Deck-Bibliothek im Medizin-Bereich — Zanki, Lightyear und ihre Abkömmlinge
— ist jahrelange kollektive Arbeit, die sich schlicht nicht replizieren lässt. Wer Medizin
studiert und in dieses Ökosystem einsteigt, bekommt kuratierte, geprüfte, von Tausenden
mitgepflegte Decks. Das ist ein realer Wert.
</p>
<p>
Seit 2024 ist zudem FSRS — Free Spaced Repetition Scheduler — als opt-in in Anki enthalten.
FSRS modelliert zwei Gedächtnisparameter pro Karte (Stabilität und Abrufbarkeit) und passt
Wiederholungsintervalle präziser an als der ältere SM-2-Algorithmus, auf dem Anki lange
fuhr. Wer FSRS in Anki aktiviert, hat Stand 2026 einen der genauesten Scheduling-Algorithmen,
die öffentlich verfügbar sind.
</p>
<p>
Anki ist außerdem Open Source, kostenlos auf Desktop und Android, und wird von einer
kleinen Kern-Entwicklergruppe mit bemerkenswerter Kontinuität gepflegt. Das ist keine
Selbstverständlichkeit.
</p>
<!-- 3 -->
<h2>Warum es trotzdem nicht funktioniert — für die meisten</h2>
<p>
Das Problem ist nicht der Algorithmus. Das Problem ist alles darum herum.
</p>
<p>
Wer Anki zum ersten Mal öffnet, trifft auf ein Interface, das seit 2006 im Wesentlichen
gleich geblieben ist. Das ist kein Vorwurf an die Entwickler:innen — es ist eine
Beschreibung der Realität. Decks, Tags, Note-Types, Card-Types, Fields, Templates,
Scheduling-Optionen, Add-on-Management: all das erwartet eine Einarbeitungszeit, die in
keinem Verhältnis zum eigentlichen Lernziel steht. Wer Vokabeln für die nächste
Prüfung lernen will, muss zuerst verstehen, was der Unterschied zwischen einer Note und
einer Card ist.
</p>
<p>
Das „Notes vs. Cards"-Konzept verdient einen eigenen Absatz: Anki speichert Inhalte als
„Notes" (das Quell-Datenpaar) und generiert daraus „Cards" (die tatsächlich wiederholten
Einheiten). Das ist konzeptionell sauber und erlaubt Dinge wie eine Note, die
vorwärts und rückwärts abgefragt wird. Es ist aber auch ein Konzept, das nirgendwo erklärt
wird — man stolpert irgendwann hinein. Anfänger:innen glauben oft, sie hätten hundert
Karten erstellt, und wundern sich dann, warum die Queue zweihundert Einträge zeigt.
</p>
<p>
Hinzu kommt die Card-Creation-Tax: Anki hat keine Abkürzungen für das Erstellen von Karten.
Alles ist manuell, alles erfordert Entscheidungen über Felder und Vorlagen, bevor
überhaupt der erste Inhalt steht. Für jemanden, der gerade mit Lernen anfangen will, ist
das eine hohe Hürde. Nicht unüberwindbar, aber hoch genug, dass viele vorher aufhören.
</p>
<p>
Und dann sind da noch die Einstellungen. Anki bietet eine Einstellung namens „Maximum
interval" mit einem Standardwert von „36500" — ohne sichtbare Einheit. 36500 was? Tage,
wie sich herausstellt. Das ist charakteristisch für das Interface insgesamt: viel Kontrolle,
wenig Kontext.
</p>
<!-- 4 -->
<h2>Der iOS-Preis als Zugangsbarriere</h2>
<p>
Anki ist die einzige App im Vergleichsfeld, die auf einer der großen Plattformen Geld
kostet: AnkiMobile für iOS ist $24.99. Desktop und Android sind kostenlos. Das ist kein
Fehler, sondern eine bewusste Entscheidung des Entwicklers, der die iOS-App als primäre
Finanzierungsquelle für das Gesamtprojekt nutzt — eine legitime und transparente Wahl.
</p>
<p>
Trotzdem ist es faktisch eine Barriere. Wer Anki auf dem Telefon nutzen will — und das
ist die realistische Nutzungssituation für tägliche Wiederholungen —, zahlt einmalig
$24.99. Für viele ist das in Ordnung. Für andere, gerade für Schüler:innen oder
Studierende mit knappem Budget, ist es der Moment, an dem sie aufhören zu schauen.
</p>
<!-- 5 -->
<h2>Der Backlog-Zyklus: wie man in den Burn-out kommt</h2>
<p>
Spaced Repetition funktioniert nur, wenn man es regelmäßig macht. Was passiert, wenn man
eine Woche aussetzt — Prüfungsphase, Urlaub, einfach keine Zeit —, ist bei Anki besonders
schmerzhaft: die übersprungenen Wiederholungen stapeln sich nicht auf morgen, sie stapeln
sich auf heute. Wer nach zehn Tagen Pause zurückkommt, trifft auf einen Rückstand von
mehreren hundert Karten. Nicht als Richtwert, sondern als reale Queue, die abgearbeitet
werden will.
</p>
<p>
Das ist kein Bug von Anki, sondern eine direkte Konsequenz des Algorithmus: Karten, die
hätten wiederholt werden sollen, werden fällig. Aber die psychologische Wirkung ist
destruktiv. Die meisten Menschen, die nach einer Pause auf 500+ fällige Karten schauen,
schließen die App wieder. Manche löschen das Deck. Der Backlog wird zur Mauer, die das
Weiterlernen blockiert.
</p>
<p>
Es gibt Add-ons und manuelle Workarounds, die den Rückstand kappen oder verteilen. Aber
auch das setzt voraus, dass man weiß, dass es sie gibt — und wie man sie konfiguriert.
</p>
<!-- 6 -->
<h2>Was Cardecky daraus gelernt hat — und was Cardecky nicht hat</h2>
<p>
Cardecky ist aus der Beobachtung entstanden, dass die meisten Menschen Anki nicht wegen
des Algorithmus verlassen, sondern wegen des Aufwands davor, danach und drumherum. Das
ist die Zielgruppe, die Cardecky bedienen will: Nutzer:innen, die Anki wollten und
gescheitert sind — nicht Power-User, die Anki lieben.
</p>
<p>
Was das konkret bedeutet: FSRS ist in Cardecky der Standard, kein opt-in. Card-Erstellung
ohne Vorlage-Entscheidungen, direkt starten. Backlog-Management mit weicheren
Auffang-Mechanismen nach Pausen. Kein Notes-vs-Cards-Konzept, das erklärt werden muss.
Kein Interface aus 2006.
</p>
<p>
Gleichzeitig soll hier nichts beschönigt werden. Cardecky hat nicht, was Anki in zwanzig
Jahren aufgebaut hat:
</p>
<ul>
<li>
<strong>Keine medizinischen Community-Decks.</strong> Zanki, Lightyear und ihre Varianten
sind in Anki, nicht in Cardecky. Wer Medizin studiert und von diesen Decks abhängt,
wechselt nicht zu Cardecky.
</li>
<li>
<strong>Kein Add-on-Ökosystem.</strong> Die Tiefe, die Anki durch hunderte
Community-Erweiterungen hat, gibt es bei Cardecky nicht. Was ausgeliefert wird, ist das,
was da ist.
</li>
<li>
<strong>Keine zwanzigjährige Community.</strong> Foren, Reddit-Threads, YouTube-Tutorials,
Stack-Overflow-Fragen — all das existiert für Anki in einem Umfang, den Cardecky nicht
hat und in absehbarer Zeit nicht haben wird.
</li>
<li>
<strong>Kein Desktop-Client.</strong> Cardecky ist eine Web-App, installierbar als PWA.
Eine native App für iOS und Android ist in Planung, aber noch nicht verfügbar.
</li>
</ul>
<p>
Das sind reale Einschränkungen, keine Randnotizen.
</p>
<!-- 7 -->
<h2>Wer schon Anki-Decks hat: Import mitgebracht</h2>
<p>
Für alle, die bereits in Anki investiert haben und den Wechsel trotzdem ausprobieren
wollen: Cardecky liest .apkg-Dateien direkt ein. Decks und Karten werden übernommen,
Formatierung soweit das Format es trägt. Der FSRS-Lernstand beginnt in Cardecky frisch —
das ist eine technische Einschränkung, keine Absicht: FSRS-Parameter sind algorithmisch
nicht direkt zwischen verschiedenen Implementierungen übertragbar.
</p>
<p>
Wer seine Anki-Decks nach <a href={ANKI_IMPORT_URL} target="_blank" rel="noopener">cardecky.mana.how/import</a>
hochlädt, kann sofort weiterlernen. Die Queue fängt bei null an — was je nach Perspektive
ein Nachteil oder ein Neubeginn ist.
</p>
<!-- CTA -->
<hr class="my-12 border-rule" />
<div class="rounded-xl border border-rule bg-paper p-8">
<p class="font-serif text-2xl text-ink">Anki wollte, aber nicht durchgehalten?</p>
<p class="mt-3 text-muted leading-relaxed">
Cardecky ist für genau diesen Fall gebaut. Bestehende Anki-Decks können direkt
importiert werden. Kein Abo, kein Kreditkartenfeld, keine Konfiguration vor dem ersten
Lernen.
</p>
<div class="mt-6 flex flex-wrap gap-4">
<a href={APP_URL} class="btn-primary">
Cardecky öffnen
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path d="M5 12h14M12 5l7 7-7 7"/>
</svg>
</a>
<a href={ANKI_IMPORT_URL} class="btn-ghost">Anki-Decks importieren</a>
</div>
</div>
</article>
</main>
<Footer />
</Layout>
<style>
/* Prose-Styles für den Artikel — ohne externes @tailwindcss/typography */
.prose-cardecky h2 {
font-family: var(--font-serif, Georgia, serif);
font-size: 1.375rem;
font-weight: 600;
color: theme('colors.ink');
margin-top: 2.5rem;
margin-bottom: 0.75rem;
line-height: 1.3;
}
.prose-cardecky p {
color: theme('colors.ink');
line-height: 1.75;
margin-bottom: 1.25rem;
}
.prose-cardecky ul,
.prose-cardecky ol {
margin-bottom: 1.25rem;
padding-left: 1.5rem;
}
.prose-cardecky ul {
list-style-type: disc;
}
.prose-cardecky ol {
list-style-type: decimal;
}
.prose-cardecky li {
color: theme('colors.ink');
line-height: 1.75;
margin-bottom: 0.5rem;
}
.prose-cardecky li + li {
margin-top: 0.375rem;
}
.prose-cardecky strong {
font-weight: 600;
color: theme('colors.ink');
}
.prose-cardecky a {
color: theme('colors.leaf');
text-decoration: underline;
text-underline-offset: 2px;
}
.prose-cardecky a:hover {
color: theme('colors.ink');
}
.prose-cardecky hr {
border-color: theme('colors.rule');
}
</style>

View file

@ -0,0 +1,289 @@
---
import Layout from '../../layouts/Layout.astro';
import Nav from '../../components/Nav.astro';
import Footer from '../../components/Footer.astro';
import '../../styles/base.css';
const APP_URL = 'https://cardecky.mana.how';
const GIT_URL = 'https://git.mana.how/mana/cards';
const title = 'Deine Karten, deine Daten — warum Data Ownership beim Lernen wichtig ist | Cardecky';
const description =
'Lerndaten verraten mehr als die meisten Nutzer:innen ahnen. Was Cardecky' +
' stattdessen tut: 0 Tracker, DSGVO-Self-Service, vollständiger Export — und' +
' warum die Vereinsform das strukturell absichert.';
---
<Layout {title} {description}>
<Nav />
<main class="mx-auto max-w-prose px-6 py-16 sm:py-24">
<header class="mb-12">
<p class="section-label mb-4">Datenschutz · Data Ownership</p>
<h1 class="font-serif text-4xl leading-tight text-ink sm:text-5xl">
Deine Karten, deine&nbsp;Daten
</h1>
<p class="mt-5 text-lg leading-relaxed text-muted">
Lerndaten sind eine der intimsten Datenkategorien, die eine App sammeln kann.
Was Cardecky damit macht — und was nicht —, lässt sich im Code nachprüfen.
</p>
<p class="mt-4 text-xs text-muted">Stand: Mai 2026</p>
</header>
<article class="prose-cardecky">
<h2>Was Lerndaten eigentlich verraten</h2>
<p>
Stell dir vor, du lernst seit drei Jahren Japanisch. Jeden Morgen zehn Minuten,
tausende Wiederholungen, ein Lernstand, der dich kennt: welche Zeichen du immer
wieder vergisst, wie schnell du abrufst, wann du einbrichst. Diese Muster sind
nicht banal. Sie sind ein Protokoll deines Gedächtnisses.
</p>
<p>
Ein Lernprofil über 18 Monate ist ein Fingerabdruck deiner Denkweise — präziser
als die meisten Profile, die Werbetreibende kaufen. Was lässt sich daraus
ableiten? Lerngeschwindigkeit und Konzentrationsmuster. Schwächen und Stärken
nach Fachgebiet. Lernzeiten — und damit Lebensrhythmus. Bei Kindern und
Jugendlichen kommen Schul- und Entwicklungsprofile hinzu, die besonders
schutzbedürftig sind.
</p>
<p>
Das ist kein Paranoia-Szenario. Es ist eine nüchterne Beschreibung dessen, was
eine App sammelt, die Spaced Repetition betreibt. Die Frage ist, wer diese Daten
kontrolliert.
</p>
<h2>Was andere Apps damit machen</h2>
<p>
Quizlet hat 20222023 seine Kernfunktionen hinter eine Bezahlschranke gesperrt.
Wer wechseln wollte, stand vor einem weiteren Problem: Quizlet bietet keinen
sauberen Export. Sets lassen sich nicht als standardisiertes Dateiformat
herunterladen. Wer geht, verliert seinen Lernstand oder muss ihn mühsam manuell
übertragen. Das ist kein Zufall — das ist Geschäftsmodell. Lock-in hält Churn
unten, und Churn unten zu halten ist für eine VC-finanzierte Plattform ein
wichtigerer Wert als Portabilität.
</p>
<p>
Das zweite Problem ist weniger sichtbar: Quizlets Datenschutzerklärung erlaubt
die Nutzung von Lerndaten für Produktverbesserungen, Forschung und
„personalisierte Erfahrungen" — Formulierungen, die weit genug sind, um viel
zu erlauben. Das gilt strukturell für viele ad-finanzierte oder
VC-finanzierte Bildungsplattformen: Das Geschäftsmodell enthält einen
Anreiz, Nutzerdaten zu verwerten. Dieser Anreiz verschwindet nicht, weil
die App ein Lern-Interface hat.
</p>
<h2>Was „0 Tracker" konkret bedeutet — und wie man es nachprüft</h2>
<p>
Cardecky enthält keine Tracking-Bibliotheken. Kein PostHog, kein Plausible,
kein Google Analytics, kein Mixpanel, kein Segment. Das ist nicht eine
Marketing-Aussage — es ist im Quellcode nachprüfbar.
</p>
<pre><code>git grep -i "posthog\|plausible\|mixpanel\|segment\|analytics" apps/web/src/</code></pre>
<p>
Das Ergebnis ist leer. Wer das nicht glaubt, kann es selbst ausführen —
der Code liegt öffentlich auf{' '}
<a href={GIT_URL} target="_blank" rel="noopener">git.mana.how/till/cards-web</a>.
Das ist der Unterschied zwischen einem Versprechen und einem Nachweis.
</p>
<p>
Was stattdessen passiert: Serverseitig werden funktionale Logs für Betrieb
und Fehlerdiagnose geschrieben — Request-Zeiten, HTTP-Status-Codes, keine
Nutzerprofile. Es gibt keine Cross-Session-Verknüpfung, keinen
Behaviour-Graph, keine Retargeting-Pixel. Die Server stehen auf dem eigenen
Mac Mini von mana e.V. in der Schweiz, hinter einem Cloudflare Tunnel als
reine TLS-Terminierung — kein US-Cloud-Provider verarbeitet deine Lerndaten.
</p>
<h2>DSGVO-Export und Anti-Lock-In — als aktive Features</h2>
<p>
Daten-Souveränität ist in Cardecky kein Compliance-Text im Footer. Sie ist in
konkreten Features sichtbar.
</p>
<p>
<strong>Self-Service-Export.</strong> Unter Einstellungen kannst du deine Daten
jederzeit exportieren, ohne Support-Ticket, ohne Wartezeit. Das geht in drei
Formaten:
</p>
<ul>
<li>
<strong>.apkg</strong> (Anki-Format) — der de-facto-Standard für Lernkarten,
importierbar in Anki, AnkiDroid, AnkiMobile und jede andere
FSRS-kompatible App
</li>
<li>
<strong>.csv</strong> — lesbar in jeder Tabellenkalkulation, importierbar
in andere Systeme
</li>
<li>
<strong>FSRS-State-JSON</strong> — enthält deinen vollständigen Lernstand:
Wiederholungshistorie, Stabilitätswerte, Fälligkeitsdaten. Damit nimmst du
nicht nur deine Karten mit, sondern auch das Gedächtnis, das die App über
sie aufgebaut hat
</li>
</ul>
<p>
Das ist der Unterschied zwischen Export-als-Rückgabe und Export-als-Portabilität.
Du kannst mit vollem Lernstand zu Anki wechseln. Du verlierst nichts.
</p>
<p>
<strong>DSGVO-Löschung ohne Ticket.</strong> Wer sein Konto löschen will, tut
das in der App — vollständig, ohne eine E-Mail schreiben zu müssen. Das ist
kein regulatorischer Reflex. Es folgt aus Wert 1 von mana e.V.: Daten gehören
den Nutzer:innen, nicht uns. Wer geht, geht mit allem.
</p>
<h2>Warum die Vereinsform hier einen strukturellen Unterschied macht</h2>
<p>
mana e.V. ist ein Schweizer Verein nach ZGB Art. 60 ff. Kein Investor hat
Anteile. Es gibt keine Wachstumsziele, keine Exit-Strategie, keine Runde, die
zurückgezahlt werden muss. Der Verein finanziert sich aus Mitgliedsbeiträgen,
Spenden und fairen Credit-Käufen — nicht aus Werbeeinnahmen oder dem Verkauf
von Nutzerdaten.
</p>
<p>
VC-finanzierte Apps stehen unter dem Druck, einen Return zu liefern. Das formt
Produktentscheidungen, oft unsichtbar. Lock-in ist wertvoll, weil er Churn
verhindert. Daten sind wertvoll, weil sie Targeting ermöglichen.
Engagement-Maximierung ist wertvoll, weil sie Nutzungszeit erhöht. Keiner
dieser Anreize trifft auf mana e.V. zu — nicht weil die Menschen dahinter
besonders tugendhaft sind, sondern weil die Rechtsform diese Anreize
strukturell nicht enthält.
</p>
<p>
Die Schweizer Rechtsform ist dabei kein Steuertrick. Sie gibt dem Verein
politische Unabhängigkeit außerhalb der großen Blöcke EU und USA — und sie
bedeutet: Es gibt keine Unternehmensanteile, die eine andere Firma kaufen
könnte, um die Datenschutzversprechen anschließend wegzuverhandeln.
</p>
<h2>Offene Punkte</h2>
<p>
Ehrlichkeit darüber, was noch fehlt:
</p>
<p>
<strong>Native Mobile-App.</strong> Eine SwiftUI-App für iOS und iPadOS ist in
Entwicklung — die Kern-Lernschleife ist fertig, der App-Store-Release steht noch
aus. Wer heute auf dem iPhone lernen will, nutzt die Web-App über den Browser.
</p>
<p>
<strong>Offline-Support.</strong> Die PWA ist online-first. Vollständiger
Offline-Betrieb — Karten laden und bewerten ohne Verbindung, Sync beim
nächsten Online-Gang — ist geplant, aber noch nicht live.
</p>
<p>
Das sind keine versteckten Einschränkungen. Sie stehen hier, weil eine App,
die Transparenz als Wert nennt, das auch auf sich selbst anwenden muss.
</p>
<hr class="my-12 border-rule" />
<div class="rounded-xl border border-rule bg-paper p-8">
<p class="font-serif text-2xl text-ink">Lernen ohne Tracking.</p>
<p class="mt-3 text-muted leading-relaxed">
Konto erstellen, loslegen. Keine Werbung, kein Analytics-Code,
jederzeit exportierbar.
</p>
<div class="mt-6 flex flex-wrap gap-4">
<a href={APP_URL} class="btn-primary">
Cardecky öffnen
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path d="M5 12h14M12 5l7 7-7 7"/>
</svg>
</a>
<a href={GIT_URL} target="_blank" rel="noopener" class="btn-ghost">
Code ansehen
</a>
</div>
</div>
</article>
</main>
<Footer />
</Layout>
<style>
.prose-cardecky h2 {
font-family: var(--font-serif, Georgia, serif);
font-size: 1.375rem;
font-weight: 600;
color: theme('colors.ink');
margin-top: 2.5rem;
margin-bottom: 0.75rem;
line-height: 1.3;
}
.prose-cardecky p {
color: theme('colors.ink');
line-height: 1.75;
margin-bottom: 1.25rem;
}
.prose-cardecky ul,
.prose-cardecky ol {
margin-bottom: 1.25rem;
padding-left: 1.5rem;
}
.prose-cardecky ul { list-style-type: disc; }
.prose-cardecky ol { list-style-type: decimal; }
.prose-cardecky li {
color: theme('colors.ink');
line-height: 1.75;
margin-bottom: 0.5rem;
}
.prose-cardecky strong {
font-weight: 600;
color: theme('colors.ink');
}
.prose-cardecky a {
color: theme('colors.leaf');
text-decoration: underline;
text-underline-offset: 2px;
}
.prose-cardecky a:hover { color: theme('colors.ink'); }
.prose-cardecky hr { border-color: theme('colors.rule'); }
.prose-cardecky pre {
background: theme('colors.paper');
border: 1px solid theme('colors.rule');
border-radius: 0.5rem;
padding: 1rem 1.25rem;
overflow-x: auto;
margin-bottom: 1.25rem;
}
.prose-cardecky code {
font-family: ui-monospace, 'Cascadia Code', monospace;
font-size: 0.875rem;
color: theme('colors.ink');
}
</style>

View file

@ -0,0 +1,246 @@
---
import Layout from '../../layouts/Layout.astro';
import Nav from '../../components/Nav.astro';
import Footer from '../../components/Footer.astro';
import '../../styles/base.css';
const APP_URL = 'https://cardecky.mana.how';
const title = 'Weniger lernen, mehr behalten: Was FSRS bedeutet | Cardecky';
const description =
'FSRS ist der modernste Spaced-Repetition-Algorithmus — und reduziert die nötigen' +
' Wiederholungen um 2030 % bei gleicher Lernretention. Wie er funktioniert,' +
' warum er noch nicht überall Standard ist, und wie Cardecky ihn einsetzt.';
---
<Layout {title} {description}>
<Nav />
<main class="mx-auto max-w-prose px-6 py-16 sm:py-24">
<header class="mb-12">
<p class="section-label mb-4">Algorithmus · Spaced Repetition</p>
<h1 class="font-serif text-4xl leading-tight text-ink sm:text-5xl">
Weniger lernen, mehr&nbsp;behalten
</h1>
<p class="mt-5 text-lg leading-relaxed text-muted">
FSRS ist der aktuell präziseste Algorithmus für Lernkarten — und er ist noch
nicht der Standard. Was sich hinter dem Kürzel verbirgt, warum es einen
Unterschied macht, und was er nicht lösen kann.
</p>
<p class="mt-4 text-xs text-muted">Stand: Mai 2026</p>
</header>
<article class="prose-cardecky">
<h2>Das Paradox des Lernens</h2>
<p>
Wer viel lernt, behält oft wenig. Das liegt nicht an mangelnder Disziplin,
sondern an einer einfachen physiologischen Tatsache: Das Gehirn vergisst nach
einer vorhersagbaren Kurve. Hermann Ebbinghaus hat das im 19. Jahrhundert
empirisch beschrieben. Seitdem hat sich an der Grundkurve nichts geändert —
aber daran, wie gut wir darauf reagieren können, hat sich einiges getan.
</p>
<p>
Die meisten Lernmethoden ignorieren die Vergessenskurve. Sie lernen in Blöcken,
mit Wiederholungen, die zu früh kommen (egal, was man schon weiß) oder zu spät
(wenn es bereits vergessen ist). Das Ergebnis: mehr Zeitaufwand, weniger Retention.
Spaced Repetition ist der direkte Angriff auf dieses Problem.
</p>
<h2>Was Spaced Repetition löst</h2>
<p>
Das Prinzip ist einfach: Eine Karte wird genau dann wieder gezeigt, wenn man sie
vergessen hat — oder kurz davor. Nicht jeden Tag. Nicht nach festem Zeitplan.
Sondern genau dann, wenn das Wiederholen etwas bringt.
</p>
<p>
Eine Karte, die man immer sofort richtig beantwortet, wird seltener gezeigt.
Eine, die man regelmäßig vergisst, kommt häufiger. Das klingt selbstverständlich.
Die entscheidende Frage ist: Wie genau berechnet ein Algorithmus diesen Zeitpunkt?
</p>
<h2>SM-2 und FSRS: Was sich geändert hat</h2>
<p>
Der Algorithmus, der die letzten Jahrzehnte dominiert hat, heißt <strong>SM-2</strong>
— entwickelt von Piotr Woźniak in den späten 1980ern und bis heute das Fundament
der meisten Lern-Apps, einschließlich des größten Teils der Anki-Nutzerbasis.
SM-2 funktioniert. Aber es hat strukturelle Schwächen.
</p>
<p>
SM-2 schätzt, wie lange eine Erinnerung hält, auf Basis eines festen
Intervall-Multiplikators und einer einfachen Bewertungsskala. Es kennt keine
separaten Konzepte für <strong>Stability</strong> (wie lange eine Erinnerung nach
einer Wiederholung hält) und <strong>Difficulty</strong> (wie schwer eine Karte
grundsätzlich ist). Es behandelt beide als einen kombinierten Faktor — was
bedeutet, dass eine Karte, die man einmal vergessen hat, dauerhaft mit schlechterer
Scheduling-Qualität bestraft wird, auch wenn man sie danach zehnmal in Folge
richtig beantwortet.
</p>
<p>
<strong>FSRS</strong> (Free Spaced Repetition Scheduler) arbeitet anders. Der
Algorithmus, hauptsächlich entwickelt von Jarrett Ye und seit 2022 quelloffen
verfügbar, modelliert Stability und Difficulty als getrennte Parameter. Er nutzt
ein Modell, das mit realen Review-Daten trainiert wurde — nicht mit theoretischen
Annahmen über das Gedächtnis.
</p>
<p>
Das Ergebnis: <strong>2030 % weniger Reviews bei gleicher Retention.</strong>
Das ist keine Marketing-Angabe, sondern das Ergebnis von Benchmarks auf realen
Datensätzen, die beim Entwickeln von FSRS veröffentlicht wurden. Wer täglich
200 Karten reviewt, kommt mit FSRS auf 140160 — bei gleichem Lernergebnis.
Über Monate und Jahre summiert sich das auf Dutzende Stunden.
</p>
<h2>Warum FSRS bei anderen nicht der Standard ist</h2>
<p>
Anki hat FSRS erst 2024 in den Kern integriert — und dann nur als opt-in. Wer
Anki installiert und keine Einstellungen ändert, nutzt heute noch SM-2. Die meisten
Nutzer:innen wissen nicht, dass FSRS existiert, geschweige denn, wie sie es
aktivieren.
</p>
<p>
Das ist keine Kritik an Anki. Es ist eine Open-Source-App mit einem langen
Kompatibilitäts-Vertrag gegenüber Millionen Nutzer:innen und deren bestehenden
Decks. Defaults zu ändern ist dort riskant — technisch und in der Community.
</p>
<p>
Quizlet ist der andere relevante Name in diesem Bereich. Dort hat Spaced
Repetition nicht den Weg ins opt-in genommen, sondern vollständig raus.
Algorithmen, die Nutzer:innen davon abhalten, dieselbe Karte immer wieder
zu sehen, erzeugen weniger Session-Zeit. Weniger Session-Zeit ist ein
schlechter Wert für eine werbefinanzierte Plattform.
</p>
<h2>Wie Cardecky das umsetzt</h2>
<p>
Cardecky nutzt FSRS als Standard — kein opt-in, kein Versteck in den
Einstellungen, keine Legacy-Defaults. Jede neue Karte, jede neue Wiederholung
wird von FSRS geplant. Das ist der Ausgangspunkt, nicht ein Premium-Feature.
</p>
<p>
Der nächste Schritt ist die <strong>FSRS-Parameter-Optimierung pro Nutzer:in</strong>.
FSRS enthält 17 Parameter, die beschreiben, wie ein individuelles Gedächtnis auf
Wiederholungen reagiert. Die Standard-Parameter wurden auf einem großen,
heterogenen Datensatz trainiert — sie sind gut, aber sie passen nicht perfekt
auf jede Person und jedes Lerngebiet. Wer Vokabeln einer neuen Sprache lernt,
vergisst anders als jemand, der medizinische Fakten büffelt. Aus der persönlichen
Review-History lassen sich diese Parameter re-optimieren. Das ist in Cardecky
in Arbeit.
</p>
<p>
Ebenfalls geplant: ein <strong>Transparenz-Tooltip pro Karte</strong> — „Warum
werde ich heute gefragt?" Nicht als technische Dokumentation, sondern als
konkreter Einblick: Stability, Difficulty, letztes Rating, berechnetes nächstes
Intervall. Wer verstehen will, warum eine Karte jetzt auftaucht und nicht in
drei Wochen, bekommt die Antwort direkt.
</p>
<h2>Was FSRS nicht löst</h2>
<p>
Ein guter Algorithmus ersetzt keine guten Karten.
</p>
<p>
FSRS kann eine Karte optimal planen — aber wenn sie schlecht formuliert ist,
zu viel Information auf einmal trägt, oder eine Verbindung zum eigenen Kontext
fehlt, hilft das beste Scheduling wenig. Das Gehirn verankert Informationen,
wenn sie bedeutungsvoll sind. Dafür ist kein Algorithmus zuständig.
</p>
<p>
Das andere offene Problem: Wer eine längere Pause macht — Urlaub, Prüfungsstress,
Krankheit — baut sich eine Backlog-Queue auf. Anki-Nutzer:innen kennen das Gefühl,
nach zehn Tagen Pause auf 500+ fällige Karten zu schauen. Mit einer besseren
Scheduling-Basis ist das Problem kleiner, weil FSRS Intervalle realistischer setzt
und weniger Überplanungen produziert — aber wer lange pausiert, kommt um den
Backlog nicht ganz herum. Strategien dafür (selektive Queue-Verkleinerung,
Deck-Priorisierung nach Prüfungsdatum) sind ein separates Problem vom Algorithmus.
</p>
<hr class="my-12 border-rule" />
<div class="rounded-xl border border-rule bg-paper p-8">
<p class="font-serif text-2xl text-ink">Mit FSRS starten — kostenlos.</p>
<p class="mt-3 text-muted leading-relaxed">
Cardecky nutzt FSRS by default. Kein Opt-in, kein Abo, kein Konfigurieren.
Einfach loslegen.
</p>
<div class="mt-6 flex flex-wrap gap-4">
<a href={APP_URL} class="btn-primary">
Cardecky öffnen
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path d="M5 12h14M12 5l7 7-7 7"/>
</svg>
</a>
</div>
</div>
</article>
</main>
<Footer />
</Layout>
<style>
.prose-cardecky h2 {
font-family: var(--font-serif, Georgia, serif);
font-size: 1.375rem;
font-weight: 600;
color: theme('colors.ink');
margin-top: 2.5rem;
margin-bottom: 0.75rem;
line-height: 1.3;
}
.prose-cardecky p {
color: theme('colors.ink');
line-height: 1.75;
margin-bottom: 1.25rem;
}
.prose-cardecky ul,
.prose-cardecky ol {
margin-bottom: 1.25rem;
padding-left: 1.5rem;
}
.prose-cardecky ul { list-style-type: disc; }
.prose-cardecky ol { list-style-type: decimal; }
.prose-cardecky li {
color: theme('colors.ink');
line-height: 1.75;
margin-bottom: 0.5rem;
}
.prose-cardecky strong {
font-weight: 600;
color: theme('colors.ink');
}
.prose-cardecky a {
color: theme('colors.leaf');
text-decoration: underline;
text-underline-offset: 2px;
}
.prose-cardecky a:hover { color: theme('colors.ink'); }
.prose-cardecky hr { border-color: theme('colors.rule'); }
</style>

View file

@ -0,0 +1,408 @@
---
import Layout from '../../layouts/Layout.astro';
import Nav from '../../components/Nav.astro';
import Footer from '../../components/Footer.astro';
import '../../styles/base.css';
const APP_URL = 'https://cardecky.mana.how';
const title = 'Wie man Lernkarten schreibt, die wirklich funktionieren | Cardecky';
const description =
'Die meisten Lernkarten sind zu lang, zu abstrakt oder zu kontextfrei. Fünf konkrete' +
' Prinzipien für Karten, die das Gehirn tatsächlich verankert — mit Gegenbeispielen.';
---
<Layout {title} {description}>
<Nav />
<main class="mx-auto max-w-prose px-6 py-16 sm:py-24">
<!-- Artikel-Header -->
<header class="mb-12">
<p class="section-label mb-4">Lernen · Methodik</p>
<h1 class="font-serif text-4xl leading-tight text-ink sm:text-5xl">
Wie man Lernkarten schreibt, die wirklich funktionieren
</h1>
<p class="mt-5 text-lg leading-relaxed text-muted">
Der beste Algorithmus nützt nichts, wenn die Karten schlecht gebaut sind. Fünf
Prinzipien für Karten, die das Gehirn tatsächlich behält.
</p>
<p class="mt-4 text-xs text-muted">Stand: Mai 2026</p>
</header>
<article class="prose-cardecky">
<!-- Einstieg -->
<p>
Wer mit Spaced Repetition anfängt und nach drei Wochen merkt, dass es nicht
funktioniert, sucht den Fehler meistens an der falschen Stelle: beim Algorithmus, bei
der App, bei der Wiederholungsfrequenz. Die eigentliche Ursache sitzt fast immer
früher — in den Karten selbst.
</p>
<p>
Schlechte Karten machen gutes Scheduling wertlos. Eine Karte, die drei Fakten
gleichzeitig abfragt, hat kein definiertes Lernergebnis. Eine Karte ohne Kontext
wird isoliert abgespeichert — und isoliert Gespeichertes verblasst, egal wie oft
es wiederholt wird. FSRS kann berechnen, wann du eine Karte wieder sehen sollst;
es kann nicht reparieren, was die Karte von vornherein falsch fragt.
</p>
<p>
Die folgenden fünf Prinzipien kommen aus der Lernforschung, aus Piotr Woźniaks
„Twenty Rules of Formulating Knowledge" und aus der Praxis der Anki- und
FSRS-Community. Sie sind nicht kompliziert — aber sie erfordern beim Schreiben
Disziplin.
</p>
<!-- Prinzip 1 -->
<h2>1. Eine Frage, eine Antwort</h2>
<p>
Das ist der häufigste Anfängerfehler, und er fühlt sich beim Schreiben nicht wie
ein Fehler an. Man kennt ein Thema, will es „effizient" auf Karten bringen, und
packt fünf zusammengehörige Fakten auf eine Karte. Das Ergebnis ist eine Karte, die
du dir nie wirklich merken kannst — weil du nie weißt, ob du sie gewusst hast.
</p>
<p>Beispiel einer schlechten Karte:</p>
<pre>F: Was sind die Eigenschaften von Mitochondrien?
A: Doppelmembran, eigene DNA, ATP-Produktion durch
oxidative Phosphorylierung, Apoptose-Steuerung,
Ursprung aus endosymbiotischer Bakterien.</pre>
<p>Besser: fünf Karten.</p>
<pre>F: Welche Membran-Struktur haben Mitochondrien?
A: Doppelmembran (Außen- und Innenmembran).
F: Wodurch unterscheidet sich die Mitochondrien-DNA
von der Zellkern-DNA?
A: Sie ist zirkulär und erinnert an Bakterien-DNA
— Hinweis auf den endosymbiontischen Ursprung.</pre>
<p>
Jede Karte hat jetzt ein klares Richtig oder Falsch. Wenn du sie beim Scheduling
als „schwierig" markierst, weiß der Algorithmus, was du nicht weißt — und kann
das gezielt wiederholen. Mit der Sammelkarte kann er das nicht.
</p>
<!-- Prinzip 2 -->
<h2>2. Kontext ist kein Luxus</h2>
<p>
Isolierte Fakten vergisst das Gehirn schneller als eingebettete. Das ist keine
Faustregel, sondern ein gut belegter Effekt: Bedeutungsträgern gegenüber
bedeutungsfreien Stimuli fällt das Behalten leichter, weil das Gehirn neue
Information an bestehende Netzwerke knüpfen kann.
</p>
<p>Eine typische kontextfreie Karte:</p>
<pre>F: Was ist Apoptose?
A: Programmierter Zelltod.</pre>
<p>Eine Karte mit Kontext:</p>
<pre>F: Warum ist Apoptose für die Embryonalentwicklung
wichtig?
A: Finger entstehen, weil die Zellen zwischen ihnen
gezielt in Apoptose gehen — nicht, weil Finger
wachsen, sondern weil das Material dazwischen
verschwindet.</pre>
<p>
Die zweite Karte fragt dasselbe Konzept ab — aber in einem Zusammenhang, der sich
bildlich einprägt. „Programmierter Zelltod" bleibt ein Begriff. Finger, die sich
aus einem Gewebeklumpen herausschälen, bleibt ein Bild.
</p>
<p>
Das bedeutet nicht, dass jede Karte einen langen Erklärungstext braucht. Ein
einziger konkreter Anwendungsfall, ein Beispiel, ein Gegenbeispiel — das reicht
meistens. Die Frage „in welchem Zusammenhang?" vor dem Schreiben stellen.
</p>
<!-- Prinzip 3 -->
<h2>3. Minimales, aber vollständiges Wissen</h2>
<p>
Piotr Woźniak nennt das „minimum information principle": eine Karte so kurz wie
möglich, aber nicht kürzer. Die Spannung liegt im „aber nicht kürzer" — eine Karte,
die so gekürzt wurde, dass sie nur noch mit Vorwissen richtig beantwortet werden
kann, das du beim Wiederholen vielleicht nicht mehr parat hast, testet nichts
Sinnvolles.
</p>
<p>Zu lang:</p>
<pre>F: Erkläre den Unterschied zwischen prokaryotischen
und eukaryotischen Zellen, inklusive Beispielen,
evolutionärem Ursprung und Zellorganellen.
A: [langer Absatz]</pre>
<p>Zu kurz (und zu vage):</p>
<pre>F: Prokaryoten?
A: Keine Zellkerne.</pre>
<p>Genau richtig:</p>
<pre>F: Was fehlt Prokaryoten, das Eukaryoten haben?
A: Einen membranumhüllten Zellkern.</pre>
<p>
Ein gutes Testkriterium: Wenn du eine Karte nach einem Monat Pause siehst und die
Frage vollständig verstehst, ist sie gut formuliert. Wenn du erst rekonstruieren
musst, was du damals gemeint hast, ist sie zu kurz oder zu vage.
</p>
<!-- Prinzip 4 -->
<h2>4. Cloze richtig einsetzen</h2>
<p>
Lückentexte — in Anki-Notation <code>&#123;&#123;c1::Begriff&#125;&#125;</code> — sind nicht
einfach eine andere Formatierung derselben Karte. Sie eignen sich besonders gut für
Prozesse, Sequenzen und Zusammenhänge, weil der umgebende Satz als Kontext direkt
sichtbar bleibt.
</p>
<p>Klassische Frage-Antwort-Karte für einen Begriff im Kontext:</p>
<pre>F: Was ist das Prinzip hinter dem Krebs-Zyklus?
A: Acetyl-CoA wird oxidiert, ATP gewonnen,
CO₂ abgegeben.</pre>
<p>Als Cloze — stärker, weil der Satz als Ganzes gelernt wird:</p>
<pre>Der Krebs-Zyklus oxidiert {{c1::Acetyl-CoA}},
gewinnt dabei {{c2::ATP}} und gibt
{{c3::CO₂}} ab.</pre>
<p>
Cloze ist schwächer, wenn der Lückentext rein zufällig wirkt — also wenn aus dem
Satz nicht klar wird, was gesucht ist, und jedes beliebige Wort passen könnte.
Dann ist eine direkte Frage ehrlicher.
</p>
<p>
Cloze ist auch kein Allheilmittel gegen das Ein-Fakt-Problem: ein Satz mit fünf
Lücken ist immer noch eine Karte mit fünf Fakten. Besser: pro Satz eine Lücke,
mehrere Karten.
</p>
<!-- Prinzip 5 -->
<h2>5. Eigene Worte, eigene Bilder</h2>
<p>
Auswendig gelernte Definitionen verblassen schneller als selbst formulierte
Erklärungen. Das liegt daran, dass bei der Eigenformulierung das Verstehen
vorausgeht — wer etwas in eigene Worte übersetzt, hat es bereits verarbeitet,
nicht nur kopiert.
</p>
<p>
Lehrbuchdefinition direkt übernommen — typische Anfänger-Karte:
</p>
<pre>F: Was ist osmotischer Druck?
A: Der Druck, der aufgewendet werden muss, um den
osmotischen Fluss einer Lösung durch eine
semipermeable Membran zu verhindern.</pre>
<p>Eigenformulierung:</p>
<pre>F: Was ist osmotischer Druck — in einem Satz,
ohne Fachsprache?
A: Das Wasser will auf die Seite mit mehr
gelöstem Zeug; der osmotische Druck ist der
Gegendruck, der das verhindert.</pre>
<p>
Die zweite Version verliert Präzision — das ist ein echter Trade-off. Für
klausurrelevante exakte Definitionen braucht man beide: die präzise Formulierung
und die eigene Erklärung. Dann zwei Karten. Nicht eine Karte, die beides
gleichzeitig will.
</p>
<p>
Bilder funktionieren nach demselben Prinzip: visuell verankerte Information hat
mehr Anknüpfungspunkte im Gedächtnis als rein verbale. Ein Diagramm, ein
selbst gezeichneter Sketch, ein Screenshot aus einem Lehrvideo — das schlägt
eine nochmals kopierte Textdefinition fast immer.
</p>
<!-- AI-Generierung -->
<h2>Was AI-Generierung kann — und was nicht</h2>
<p>
AI-Tools können Karten aus einem Text generieren, und das kann die Arbeit
reduzieren: statt zwanzig Karten von Hand zu schreiben, bekommt man einen
Entwurf, der überarbeitet werden kann. Das ist ein nützliches Werkzeug.
</p>
<p>
Es ersetzt aber nicht das Nachdenken über die eigene Formulierung. AI-generierte
Karten folgen oft dem Muster der Lehrbuchdefinition — sie übernehmen Sprache
aus dem Quelltext, statt sie zu übersetzen. Sie splitten zu selten von alleine
auf ein Fakt pro Karte. Und sie können nicht wissen, welcher Kontext für dich
der einprägsame ist.
</p>
<p>
Der sinnvolle Workflow: AI generiert einen Rohsatz von Karten, du überarbeitest
ihn nach den fünf Prinzipien. Das ist weniger Arbeit als von null, aber es ist
immer noch Arbeit.
</p>
<!-- Cardecky-Einbindung -->
<h2>Welche Kartentypen welches Prinzip unterstützen</h2>
<p>
Cardecky hat vier native Kartentypen, die direkt auf diese Prinzipien einzahlen:
</p>
<ul>
<li>
<strong>Vorderseite/Rückseite</strong> — der Standard für Prinzip 1 und 3. Eine
Frage, eine Antwort. Keine Extras.
</li>
<li>
<strong>Lückentext (Cloze)</strong> — für Prinzip 4: Prozesse und Zusammenhänge,
bei denen der umgebende Satz als Kontext stehen bleibt.
</li>
<li>
<strong>Bild-Karte</strong> — für Prinzip 5: Diagramme, Sketchnotes, annotierte
Screenshots als eigenständiger Karteninhalt.
</li>
<li>
<strong>Typing-Karte</strong> — erzwingt freies Erinnern statt
Wiedererkennen; sinnvoll für Formeln, Fachbegriffe und exakte
Definitionen, bei denen das Erkennen zu wenig ist.
</li>
</ul>
<p>
Multiple Choice ist ebenfalls verfügbar — nützlich für erste Annäherungen an neues
Material, wo freies Erinnern noch nicht möglich ist. Für konsolidiertes Wissen ist
Wiedererkennen aus Optionen aber schwächer als freies Abrufen.
</p>
<p>
In Arbeit: ein AI-Cloze-Generator, der aus einem eigenen Text direkt
Lückentextkarten erstellt, und ein Card-Split-Vorschlag, der Karten mit
mehreren Fakten automatisch zur Teilung vorschlägt. Beide Features sind noch nicht
live — sie werden ergänzt, wenn sie auf einem Qualitätsniveau sind, das die
oben beschriebenen Prinzipien nicht untergräbt.
</p>
<!-- CTA -->
<hr class="my-12 border-rule" />
<div class="rounded-xl border border-rule bg-paper p-8">
<p class="font-serif text-2xl text-ink">Karten schreiben — in Cardecky</p>
<p class="mt-3 text-muted leading-relaxed">
Alle fünf Kartentypen verfügbar, FSRS-Scheduling ab der ersten Karte,
kein Abo für das eigentliche Lernen.
</p>
<div class="mt-6 flex flex-wrap gap-4">
<a href={APP_URL} class="btn-primary">
Cardecky öffnen
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path d="M5 12h14M12 5l7 7-7 7"/>
</svg>
</a>
</div>
</div>
</article>
</main>
<Footer />
</Layout>
<style>
/* Prose-Styles für den Artikel — ohne externes @tailwindcss/typography */
.prose-cardecky h2 {
font-family: var(--font-serif, Georgia, serif);
font-size: 1.375rem;
font-weight: 600;
color: theme('colors.ink');
margin-top: 2.5rem;
margin-bottom: 0.75rem;
line-height: 1.3;
}
.prose-cardecky p {
color: theme('colors.ink');
line-height: 1.75;
margin-bottom: 1.25rem;
}
.prose-cardecky ul,
.prose-cardecky ol {
margin-bottom: 1.25rem;
padding-left: 1.5rem;
}
.prose-cardecky ul {
list-style-type: disc;
}
.prose-cardecky ol {
list-style-type: decimal;
}
.prose-cardecky li {
color: theme('colors.ink');
line-height: 1.75;
margin-bottom: 0.5rem;
}
.prose-cardecky li + li {
margin-top: 0.375rem;
}
.prose-cardecky strong {
font-weight: 600;
color: theme('colors.ink');
}
.prose-cardecky a {
color: theme('colors.leaf');
text-decoration: underline;
text-underline-offset: 2px;
}
.prose-cardecky a:hover {
color: theme('colors.ink');
}
.prose-cardecky hr {
border-color: theme('colors.rule');
}
.prose-cardecky pre {
font-family: var(--font-mono, ui-monospace, monospace);
font-size: 0.875rem;
line-height: 1.6;
background-color: theme('colors.paper');
border: 1px solid theme('colors.rule');
border-radius: 0.5rem;
padding: 1rem 1.25rem;
margin-bottom: 1.25rem;
overflow-x: auto;
color: theme('colors.ink');
white-space: pre-wrap;
}
.prose-cardecky code {
font-family: var(--font-mono, ui-monospace, monospace);
font-size: 0.875em;
background-color: theme('colors.paper');
border: 1px solid theme('colors.rule');
border-radius: 0.25rem;
padding: 0.1em 0.35em;
}
</style>

View file

@ -0,0 +1,97 @@
---
import Layout from '../../layouts/Layout.astro';
import Nav from '../../components/Nav.astro';
import Footer from '../../components/Footer.astro';
import '../../styles/base.css';
const posts = [
{
href: '/blog/quizlet-paywall',
tag: 'Migration',
title: 'Quizlet zieht die Paywall hoch — was jetzt?',
summary:
'Fünf Millionen Nutzer:innen haben Quizlet in den letzten zwei Jahren verlassen. Was hinter der Bezahlschranke steckt und wie der Wechsel in wenigen Minuten klappt.',
date: 'Mai 2026',
},
{
href: '/blog/fsrs-algorithmus',
tag: 'Algorithmus',
title: 'Weniger lernen, mehr behalten: Was FSRS bedeutet',
summary:
'FSRS reduziert die nötigen Wiederholungen um 2030 % bei gleicher Retention. Wie der Algorithmus funktioniert — und warum er bei anderen Apps noch nicht der Standard ist.',
date: 'Mai 2026',
},
{
href: '/blog/deine-daten',
tag: 'Datenschutz',
title: 'Deine Karten, deine Daten',
summary:
'Lerndaten verraten mehr als die meisten ahnen. Was Cardecky damit macht — und was nicht — lässt sich im öffentlichen Quellcode nachprüfen.',
date: 'Mai 2026',
},
{
href: '/blog/anki-zu-kompliziert',
tag: 'Vergleich',
title: 'Anki ist mächtig — und trotzdem schwer empfehlbar',
summary:
'Anki ist technisch überlegen. Trotzdem hören die meisten auf — nicht wegen des Algorithmus, sondern wegen Interface und Lernkurve.',
date: 'Mai 2026',
},
{
href: '/blog/gute-lernkarten',
tag: 'Lernen',
title: 'Wie man Lernkarten schreibt, die wirklich funktionieren',
summary:
'Der beste Algorithmus nützt nichts, wenn die Karten schlecht gebaut sind. Fünf Prinzipien — mit Gegenbeispielen.',
date: 'Mai 2026',
},
] as const;
---
<Layout
title="Artikel — Cardecky"
description="Hintergrund und Methodik rund um Spaced Repetition, Lernkarten und die Cardecky-App."
>
<Nav />
<main class="mx-auto max-w-content px-6 py-16 sm:py-24">
<p class="section-label">Blog</p>
<h1 class="mt-3 font-serif text-display text-ink">Artikel.</h1>
<p class="mt-4 max-w-prose text-muted">
Hintergrund zu Spaced Repetition, Vergleiche mit anderen Apps und Methodik
für besseres Lernen.
</p>
<ul class="mt-12 space-y-4" role="list">
{posts.map((post) => (
<li>
<a
href={post.href}
class="group flex flex-col gap-2 rounded-xl border border-rule bg-paper p-6 transition-shadow hover:shadow-md sm:flex-row sm:items-start sm:gap-8"
>
<div class="flex shrink-0 items-center gap-3 sm:w-40 sm:flex-col sm:items-start sm:gap-1">
<span class="section-label">{post.tag}</span>
<span class="text-xs text-muted">{post.date}</span>
</div>
<div class="flex-1">
<p class="font-serif text-xl font-semibold leading-snug text-ink group-hover:text-leaf transition-colors">
{post.title}
</p>
<p class="mt-2 text-sm leading-relaxed text-muted">
{post.summary}
</p>
</div>
<span class="hidden shrink-0 items-center gap-1 text-sm font-medium text-leaf sm:flex">
Lesen
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path d="M5 12h14M12 5l7 7-7 7"/>
</svg>
</span>
</a>
</li>
))}
</ul>
</main>
<Footer />
</Layout>

View file

@ -0,0 +1,309 @@
---
import Layout from '../../layouts/Layout.astro';
import Nav from '../../components/Nav.astro';
import Footer from '../../components/Footer.astro';
import '../../styles/base.css';
const APP_URL = 'https://cardecky.mana.how';
const IMPORT_URL = 'https://cardecky.mana.how/import';
const title = 'Quizlet zieht die Paywall hoch — was jetzt? | Cardecky';
const description =
'Quizlet hat 20222023 Learn Mode, Test Mode und echtes Spaced Repetition hinter eine' +
' Bezahlschranke gesperrt. Was das konkret bedeutet — und wie der Wechsel zu Cardecky in' +
' wenigen Minuten klappt.';
---
<Layout {title} {description}>
<Nav />
<main class="mx-auto max-w-prose px-6 py-16 sm:py-24">
<!-- Artikel-Header -->
<header class="mb-12">
<p class="section-label mb-4">Lernkarten · Migration</p>
<h1 class="font-serif text-4xl leading-tight text-ink sm:text-5xl">
Quizlet zieht die Paywall hoch&nbsp;— was jetzt?
</h1>
<p class="mt-5 text-lg leading-relaxed text-muted">
Millionen von Nutzer:innen haben jahrelang auf Quizlet gelernt. Dann kam die
Bezahlschranke. Was dahintersteckt, was du verloren hast — und wie du in wenigen Minuten
auf Cardecky umziehst.
</p>
<p class="mt-4 text-xs text-muted">Stand: Mai 2026</p>
</header>
<article class="prose-cardecky">
<!-- 1 -->
<h2>Was Quizlet getan hat</h2>
<p>
Quizlet startete 2005 als Karteikarten-Tool ohne Geschäftsmodell. Das hat sich geändert.
2022 und 2023 hat das Unternehmen schrittweise die Kern-Lernmodi — Learn Mode, Test Mode
und den zugehörigen Scheduling-Algorithmus — hinter ein Abo (Quizlet Plus,
$35.99/Jahr) gesperrt. Wer nicht zahlt, kann Karten ansehen und in simplen
Karteikarten-Ansichten blättern. Der eigentliche Lernmechanismus, der aus dem
Blättern ein gezieltes Üben macht, ist nur noch für Zahler zugänglich.
</p>
<p>
Quizlet hat das nicht als Einschnitt kommuniziert, sondern als
„Premium-Erweiterung". Die Realität: wer sich über Monate eine Karten-Sammlung
aufgebaut hat, kann diese ohne Abo kaum noch sinnvoll nutzen.
</p>
<!-- 2 -->
<h2>Was konkret hinter der Paywall verschwunden ist</h2>
<p>
Das sind keine Randfeatures. Learn Mode ist der Modus, der Quizlet überhaupt vom
einfachen Karteikarten-Blättern unterscheidet: er verfolgt deinen Fortschritt,
wiederholt Karten, die du falsch beantwortest hast, und passt die Reihenfolge
deiner Lernkarten dynamisch an. Test Mode erstellt automatisierte Prüfungs-
Simulationen aus deinen Sets. Ohne diese Modi ist Quizlet ein Karten-Viewer —
nützlich zum Nachschlagen, nicht zum Lernen.
</p>
<p>
Hinzu kommt: Quizlet sammelt Nutzungsdaten und schaltet Werbung für Nicht-Zahler.
Der Code ist proprietär — du kannst nicht nachprüfen, welche Daten wohin gehen.
Und Daten-Export gibt es nur in begrenztem Umfang; wer zu einem anderen Dienst
wechseln will, hat keine einfache Brücke.
</p>
<p>
Rund fünf Millionen Nutzer:innen haben in den vergangenen zwei Jahren aktiv nach
Alternativen gesucht. Das ist kein Protest, sondern eine vernünftige Reaktion
darauf, dass ein Werkzeug, auf das man sich verlassen hatte, unter den Händen
wegbricht.
</p>
<!-- 3 -->
<h2>Was „echtes Spaced Repetition" bedeutet</h2>
<p>
Spaced Repetition ist ein lernpsychologisches Prinzip: Karten, die du sicher
weißt, siehst du seltener; Karten, bei denen du schwankst oder scheitern,
kommen früher wieder. Der Effekt ist gut belegt — das Vergessen-Intervall passt
sich an dein tatsächliches Erinnerungsvermögen an, statt nach fester Reihenfolge
zu laufen.
</p>
<p>
Die Qualität hängt am Algorithmus. Quizlets hauseigene Implementierung war eine
vereinfachte Variante. Der heute wissenschaftlich bevorzugte Standard ist
<strong>FSRS</strong> (Free Spaced Repetition Scheduler) — entwickelt 2022 von
Jarrett Ye, inzwischen in mehreren Studien gegen ältere Algorithmen getestet,
standardmäßig in Anki 23.10+ enthalten. FSRS modelliert zwei Gedächtnisparameter
pro Karte (Stabilität und Abrufbarkeit) und passt die Wiederholungsabstände
präziser an als frühere Verfahren.
</p>
<p>
Cardecky implementiert FSRS direkt — nicht als nachträgliches Feature, sondern
als Kern des Scheduling-Systems ab Tag 1.
</p>
<!-- 4 -->
<h2>Cardecky als Alternative — was wir bieten und was noch fehlt</h2>
<p>
Cardecky ist die Spaced-Repetition-App des mana e.V., eines Schweizer Vereins.
Das ist keine Marketing-Formel, sondern eine Aussage über das Geschäftsmodell:
kein Investor, kein Renditedruck, keine Exit-Strategie. Die App läuft auf
selbst-betriebener Infrastruktur in Deutschland.
</p>
<p>
Was heute verfügbar ist:
</p>
<ul>
<li>
<strong>FSRS-Algorithmus</strong> — aktueller Stand der Forschung, serverside
berechnet, keine Näherungslösung.
</li>
<li>
<strong>Sechs Kartentypen</strong> — Vorderseite/Rückseite, Lückentext, Multiple
Choice, Bild, Tipp-Karte, Rich Text.
</li>
<li>
<strong>Quizlet-Import</strong> — direkt eingebaut. Deine Sets landen in Minuten
in Cardecky, mit Formatierung.
</li>
<li>
<strong>Anki-Import</strong> — .apkg-Dateien werden geparst, Decks und Karten
übernommen.
</li>
<li>
<strong>Kein Tracking, keine Werbung</strong> — kein Analytics-Code, nachweisbar
im öffentlichen Quellcode unter
<a href="https://git.mana.how/mana/cards" target="_blank" rel="noopener">
git.mana.how/mana/cards</a>.
</li>
<li>
<strong>Anti-Lock-in per Design</strong> — Export jederzeit als .apkg, CSV
oder DSGVO-Vollexport. Wir bauen keine Karte, die du nicht mitnehmen kannst.
</li>
</ul>
<p>
Was noch fehlt, und das soll hier nicht versteckt werden: Cardecky ist jung.
Eine native App (iOS/Android) ist in Planung, aber noch nicht verfügbar — heute
ist Cardecky eine Web-App, installierbar als PWA. Die kuratierte Deck-Bibliothek
wächst; wer spezifische Fachbereiche sucht, findet dort heute noch Lücken.
Kollaborative Decks im Team sind auf der Roadmap, noch nicht live. Wer eine
ausgereifte Desktop-App mit jahrelanger Community erwartet, sollte auch Anki
in Betracht ziehen — das ist keine Schwäche, sondern ein Hinweis auf den
unterschiedlichen Stand.
</p>
<p>
Die Kernfunktionen — Karten anlegen, lernen, importieren, exportieren — sind
vollständig und ohne Bezahlschranke zugänglich. Tiers gibt es nur für
KI-gestützte Features (automatische Kartengenerierung, Erklärungen via mana LLM)
und erweiterten Storage. Das eigentliche Lernen ist und bleibt kostenlos.
</p>
<!-- 5 -->
<h2>Migration in drei Schritten</h2>
<p>
Der Wechsel von Quizlet zu Cardecky dauert je nach Set-Größe fünf bis fünfzehn
Minuten.
</p>
<ol>
<li>
<strong>Quizlet-Export vorbereiten.</strong> Öffne in Quizlet das Set, das du
mitnehmen willst. Unter „Mehr" → „Exportieren" kannst du das Set als
Tab-getrennte Textdatei herunterladen. Quizlet bietet diesen Export auch ohne
Plus-Abo an.
</li>
<li>
<strong>In Cardecky importieren.</strong> Gehe auf
<a href={IMPORT_URL} target="_blank" rel="noopener">cardecky.mana.how/import</a>
und wähle „Quizlet-Import". Lade die Textdatei hoch oder füge den Text direkt
ein — Cardecky erkennt das Format automatisch und zeigt dir vor dem Import eine
Vorschau der Karten.
</li>
<li>
<strong>Deck einrichten und starten.</strong> Nach dem Import landet das Deck in
deiner Bibliothek. FSRS-Scheduling startet automatisch beim ersten Lernen. Du
musst nichts konfigurieren — das System passt sich anhand deiner Antworten an.
</li>
</ol>
<p>
Wer bereits Anki-Decks hat, kann alternativ .apkg-Dateien direkt hochladen — der
Import-Flow ist derselbe.
</p>
<!-- 6 -->
<h2>Datenschutz und Unabhängigkeit</h2>
<p>
Cardecky wird von mana e.V. betrieben — einem Schweizer Verein ohne Investoren,
ohne Werbepartner, ohne Renditeziel. Server stehen in Deutschland, Datenverarbeitung
läuft ausschließlich auf Vereinsinfrastruktur. Es gibt keinen Analytics-Code,
keinen Tracking-Pixel, keine Drittanbieter-SDK für Nutzungsauswertung. Das lässt
sich im öffentlichen Quellcode nachprüfen.
</p>
<p>
Daten-Souveränität ist für mana kein Marketingversprechen, sondern Wert 1 in der
Werte-Hierarchie des Vereins: Daten gehören den Nutzer:innen, nicht uns. Jederzeit
Export, jederzeit Löschung, DSGVO-Auskunft auf Knopfdruck. Diese Versprechen
stehen in den Vereinsstatuten und können nicht durch eine Unternehmens-Übernahme
wegverhandelt werden — weil es keine Unternehmensanteile gibt, die man kaufen
könnte.
</p>
<!-- CTA -->
<hr class="my-12 border-rule" />
<div class="rounded-xl border border-rule bg-paper p-8">
<p class="font-serif text-2xl text-ink">Bereit für den Wechsel?</p>
<p class="mt-3 text-muted leading-relaxed">
Import deiner Quizlet-Sets dauert wenige Minuten. Kein Abo, kein
Kreditkartenfeld, keine Fallstricke.
</p>
<div class="mt-6 flex flex-wrap gap-4">
<a href={APP_URL} class="btn-primary">
Cardecky öffnen
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path d="M5 12h14M12 5l7 7-7 7"/>
</svg>
</a>
<a href={IMPORT_URL} class="btn-ghost">Direkt zum Import</a>
</div>
</div>
</article>
</main>
<Footer />
</Layout>
<style>
/* Prose-Styles für den Artikel — ohne externes @tailwindcss/typography */
.prose-cardecky h2 {
font-family: var(--font-serif, Georgia, serif);
font-size: 1.375rem;
font-weight: 600;
color: theme('colors.ink');
margin-top: 2.5rem;
margin-bottom: 0.75rem;
line-height: 1.3;
}
.prose-cardecky p {
color: theme('colors.ink');
line-height: 1.75;
margin-bottom: 1.25rem;
}
.prose-cardecky ul,
.prose-cardecky ol {
margin-bottom: 1.25rem;
padding-left: 1.5rem;
}
.prose-cardecky ul {
list-style-type: disc;
}
.prose-cardecky ol {
list-style-type: decimal;
}
.prose-cardecky li {
color: theme('colors.ink');
line-height: 1.75;
margin-bottom: 0.5rem;
}
.prose-cardecky li + li {
margin-top: 0.375rem;
}
.prose-cardecky strong {
font-weight: 600;
color: theme('colors.ink');
}
.prose-cardecky a {
color: theme('colors.leaf');
text-decoration: underline;
text-underline-offset: 2px;
}
.prose-cardecky a:hover {
color: theme('colors.ink');
}
.prose-cardecky hr {
border-color: theme('colors.rule');
}
</style>

View file

@ -6,6 +6,7 @@ import CardTypes from '../components/CardTypes.astro';
import HowItWorks from '../components/HowItWorks.astro'; import HowItWorks from '../components/HowItWorks.astro';
import Features from '../components/Features.astro'; import Features from '../components/Features.astro';
import ManaSection from '../components/ManaSection.astro'; import ManaSection from '../components/ManaSection.astro';
import BlogTeaser from '../components/BlogTeaser.astro';
import CTASection from '../components/CTASection.astro'; import CTASection from '../components/CTASection.astro';
import Footer from '../components/Footer.astro'; import Footer from '../components/Footer.astro';
import '../styles/base.css'; import '../styles/base.css';
@ -21,6 +22,7 @@ import '../styles/base.css';
<HowItWorks /> <HowItWorks />
<Features /> <Features />
<ManaSection /> <ManaSection />
<BlogTeaser />
<CTASection /> <CTASection />
</main> </main>
<Footer /> <Footer />

View file

@ -68,8 +68,19 @@ export interface UserStats {
due_forecast: { day: string; n: number }[]; due_forecast: { day: string; n: number }[];
leech_threshold: number; leech_threshold: number;
leech_cards: LeechCard[]; 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() { export function loadStats() {
return api<UserStats>('/api/v1/me/stats'); 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');
}

View file

@ -5,10 +5,11 @@ export type DueReview = Review & {
card?: Pick<Card, 'id' | 'deck_id' | 'type' | 'fields'>; 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(); const params = new URLSearchParams();
if (opts.deckId) params.set('deck_id', opts.deckId); if (opts.deckId) params.set('deck_id', opts.deckId);
if (opts.limit) params.set('limit', String(opts.limit)); if (opts.limit) params.set('limit', String(opts.limit));
if (opts.recovery) params.set('recovery', 'true');
const qs = params.toString(); const qs = params.toString();
return api<{ reviews: DueReview[]; total: number }>( return api<{ reviews: DueReview[]; total: number }>(
`/api/v1/reviews/due${qs ? `?${qs}` : ''}` `/api/v1/reviews/due${qs ? `?${qs}` : ''}`
@ -21,3 +22,7 @@ export function gradeReview(cardId: string, subIndex: number, rating: Rating) {
body: { rating }, body: { rating },
}); });
} }
export function undoReview(cardId: string, subIndex: number) {
return api<Review>(`/api/v1/reviews/${cardId}/${subIndex}/undo`, { method: 'POST' });
}

View file

@ -1,8 +1,10 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte';
import { page } from '$app/state'; import { page } from '$app/state';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { devUser } from '$lib/auth/dev-stub.svelte.ts'; import { devUser } from '$lib/auth/dev-stub.svelte.ts';
import { t } from '$lib/i18n/index.svelte.ts'; import { t } from '$lib/i18n/index.svelte.ts';
import { loadSummary } from '$lib/api/me.ts';
const navItems = $derived([ const navItems = $derived([
{ id: 'decks', label: t('nav.decks') }, { id: 'decks', label: t('nav.decks') },
@ -30,6 +32,20 @@
devUser.user?.email?.charAt(0).toUpperCase() ?? 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> </script>
<div class="bottom-bar" role="navigation" aria-label={t('common.main_nav')}> <div class="bottom-bar" role="navigation" aria-label={t('common.main_nav')}>
@ -38,6 +54,22 @@
<div class="divider" aria-hidden="true"></div> <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 --> <!-- Hauptnavigation -->
{#each navItems as item (item.id)} {#each navItems as item (item.id)}
<button <button
@ -187,4 +219,32 @@
text-decoration: none; text-decoration: none;
flex-shrink: 0; 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> </style>

View file

@ -315,6 +315,16 @@ export const de: TranslationNode = {
leeches_none: 'Keine zähen Karten — sauber.', leeches_none: 'Keine zähen Karten — sauber.',
leeches_lapses: '{n} Ausrutscher', leeches_lapses: '{n} Ausrutscher',
leeches_lapses_one: '{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…', loading: 'Lade…',
error: 'Fehler: {msg}', error: 'Fehler: {msg}',
}, },

View file

@ -310,6 +310,16 @@ export const en: TranslationNode = {
leeches_none: 'No leeches — clean slate.', leeches_none: 'No leeches — clean slate.',
leeches_lapses: '{n} lapses', leeches_lapses: '{n} lapses',
leeches_lapses_one: '{n} lapse', 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…', loading: 'Loading…',
error: 'Error: {msg}', error: 'Error: {msg}',
}, },

View file

@ -310,6 +310,16 @@ export const es: TranslationNode = {
leeches_none: 'Ninguna carta difícil — todo bien.', leeches_none: 'Ninguna carta difícil — todo bien.',
leeches_lapses: '{n} olvidos', leeches_lapses: '{n} olvidos',
leeches_lapses_one: '{n} olvido', 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…', loading: 'Cargando…',
error: 'Error: {msg}', error: 'Error: {msg}',
}, },

View file

@ -310,6 +310,16 @@ export const fr: TranslationNode = {
leeches_none: 'Aucune carte difficile — tout va bien.', leeches_none: 'Aucune carte difficile — tout va bien.',
leeches_lapses: '{n} oublis', leeches_lapses: '{n} oublis',
leeches_lapses_one: '{n} oubli', 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…', loading: 'Chargement…',
error: 'Erreur : {msg}', error: 'Erreur : {msg}',
}, },

View file

@ -310,6 +310,16 @@ export const it: TranslationNode = {
leeches_none: 'Nessuna carta difficile — pulito.', leeches_none: 'Nessuna carta difficile — pulito.',
leeches_lapses: '{n} ricadute', leeches_lapses: '{n} ricadute',
leeches_lapses_one: '{n} ricaduta', 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…', loading: 'Caricamento…',
error: 'Errore: {msg}', error: 'Errore: {msg}',
}, },

View file

@ -27,6 +27,8 @@
let category = $state<DeckCategoryId | null>(null); let category = $state<DeckCategoryId | null>(null);
let visibility = $state<'private' | 'space' | 'public'>('private'); let visibility = $state<'private' | 'space' | 'public'>('private');
let fsrsRetention = $state(0.9);
let loading = $state(true); let loading = $state(true);
let saving = $state(false); let saving = $state(false);
let deleting = $state(false); let deleting = $state(false);
@ -54,6 +56,7 @@
color = d.color ?? '#6366f1'; color = d.color ?? '#6366f1';
category = (d.category as DeckCategoryId | null) ?? null; category = (d.category as DeckCategoryId | null) ?? null;
visibility = (d.visibility as 'private' | 'space' | 'public') ?? 'private'; 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) { if (d.forked_from_marketplace_deck_id) {
void loadMarketplaceInfo(d); void loadMarketplaceInfo(d);
} }
@ -98,6 +101,7 @@
color, color,
category: category ?? undefined, category: category ?? undefined,
visibility, visibility,
fsrs_settings: { request_retention: fsrsRetention },
}); });
toasts.success('Gespeichert.'); toasts.success('Gespeichert.');
goto(`/decks/${deckId}`); goto(`/decks/${deckId}`);
@ -259,7 +263,45 @@
</form> </form>
</section> </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} {#if isForked}
<section class="settings-section"> <section class="settings-section">
<h2 class="section-title">Marketplace</h2> <h2 class="section-title">Marketplace</h2>
@ -295,7 +337,7 @@
</section> </section>
{/if} {/if}
<!-- ── 3. Gefahrenzone ────────────────────────────────────────── --> <!-- ── 4. Gefahrenzone ────────────────────────────────────────── -->
<section class="settings-section danger-section"> <section class="settings-section danger-section">
<h2 class="section-title">Weitere Aktionen</h2> <h2 class="section-title">Weitere Aktionen</h2>
<div class="section-body danger-body"> <div class="section-body danger-body">
@ -725,4 +767,41 @@
.btn-ghost:hover { .btn-ghost:hover {
color: hsl(var(--color-foreground)); 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> </style>

View file

@ -20,6 +20,8 @@
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 leechesLayers = stackLayers('stats-leeches', 3);
const diffLayers = stackLayers('stats-diff', 3);
const masteryLayers = stackLayers('stats-mastery', 3);
const peakDay = $derived.by(() => { const peakDay = $derived.by(() => {
if (!stats) return 1; if (!stats) return 1;
@ -57,6 +59,11 @@
return Math.max(1, ...stats.due_forecast.map((d) => d.n)); 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 { function cellLevel(n: number): number {
if (n === 0) return 0; if (n === 0) return 0;
if (n <= 2) return 1; if (n <= 2) return 1;
@ -347,6 +354,79 @@
</CardSurface> </CardSurface>
</li> </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> </ul>
{/if} {/if}
@ -627,4 +707,99 @@
.leech-sep { opacity: 0.5; } .leech-sep { opacity: 0.5; }
.leech-lapses { color: #DC2626; font-weight: 500; } .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> </style>

View file

@ -11,7 +11,7 @@
} from '@cards/domain'; } from '@cards/domain';
import { apiErrorMessage } from '$lib/api/error.ts'; import { apiErrorMessage } from '$lib/api/error.ts';
import { getDeck } from '$lib/api/decks.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 { devUser } from '$lib/auth/dev-stub.svelte.ts';
import { renderMarkdown } from '$lib/markdown.ts'; import { renderMarkdown } from '$lib/markdown.ts';
import { toasts } from '$lib/stores/toasts.svelte.ts'; import { toasts } from '$lib/stores/toasts.svelte.ts';
@ -32,6 +32,15 @@
let loading = $state(true); let loading = $state(true);
let busy = $state(false); let busy = $state(false);
let stats = $state({ reviewed: 0, again: 0 }); 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 current = $derived(queue[queueIndex]);
const isDone = $derived(!loading && queueIndex >= queue.length); 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 () => { onMount(async () => {
if (!devUser.id) { if (!devUser.id) {
goto('/'); goto('/');
@ -143,7 +160,9 @@
]); ]);
deckName = d.name; deckName = d.name;
deckColor = d.color ?? null; deckColor = d.color ?? null;
dueTotal = due.total;
queue = due.reviews; queue = due.reviews;
if (due.total > 30 && !recoveryMode) showRecovery = true;
} catch (e) { } catch (e) {
toasts.error(`Sitzung konnte nicht geladen werden: ${apiErrorMessage(e)}`); toasts.error(`Sitzung konnte nicht geladen werden: ${apiErrorMessage(e)}`);
goto('/study'); goto('/study');
@ -153,10 +172,27 @@
window.addEventListener('keydown', onKey); 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(() => { onDestroy(() => {
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
window.removeEventListener('keydown', onKey); window.removeEventListener('keydown', onKey);
} }
clearUndoTimer();
}); });
function onKey(e: KeyboardEvent) { function onKey(e: KeyboardEvent) {
@ -166,6 +202,12 @@
if (isTyping) return; // TypingView übernimmt per svelte:window if (isTyping) return; // TypingView übernimmt per svelte:window
if (isMultipleChoice) return; // MultipleChoiceView ü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 (!revealed) {
if (e.key === ' ' || e.key === 'Enter') { if (e.key === ' ' || e.key === 'Enter') {
e.preventDefault(); e.preventDefault();
@ -190,12 +232,59 @@
if (rating === 'again') stats.again += 1; if (rating === 'again') stats.again += 1;
queueIndex += 1; queueIndex += 1;
revealed = false; 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) { } catch (e) {
toasts.error(t('card_edit.save_failed', { msg: apiErrorMessage(e) })); toasts.error(t('card_edit.save_failed', { msg: apiErrorMessage(e) }));
} finally { } finally {
busy = false; 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> </script>
<div class="study-page"> <div class="study-page">
@ -213,6 +302,19 @@
{queueIndex + 1} / {queue.length} {queueIndex + 1} / {queue.length}
{/if} {/if}
</p> </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> <a class="aside-manage" href="/decks/{deckId}">{t('study_session.manage_link')}</a>
</aside> </aside>
@ -290,6 +392,34 @@
<div class="prose answer">{@html answerHtml}</div> <div class="prose answer">{@html answerHtml}</div>
{/if} {/if}
{/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> </article>
</CardSurface> </CardSurface>
</div> </div>
@ -327,6 +457,15 @@
</button> </button>
</div> </div>
</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} {/if}
</div> </div>
</div> </div>
@ -437,6 +576,7 @@
Portrait-Format (5:7) an: Inhalt vertikal zentriert, Padding rechts Portrait-Format (5:7) an: Inhalt vertikal zentriert, Padding rechts
vom Color-Stripe. */ vom Color-Stripe. */
.study-inner { .study-inner {
position: relative;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: center; justify-content: center;
@ -667,4 +807,169 @@
.grade.grade-easy:hover:not(:disabled) { .grade.grade-easy:hover:not(:disabled) {
background: hsl(var(--color-success) / 0.08); 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> </style>

View file

@ -77,17 +77,23 @@ aber im Code längst gelandet:
### Scheduler-Verbesserungen ### Scheduler-Verbesserungen
- **FSRS-Parameter pro User optimieren**`ts-fsrs` liefert - **FSRS-Parameter pro User optimieren**`ts-fsrs@5.3.2` hat
`computeParameters()` aus Review-History; Schema (`decks.fsrs_settings`) **kein** `computeParameters()` aus Review-History (das erfordert den
und Per-Deck-Override sind vorbereitet, aber kein Endpoint und kein UI. Python `fsrs-optimizer`). Der praktisch nutzbare Hebel ist
Größter messbarer Retention-Gewinn pro Aufwand. `request_retention` (0.50.99): höherer Wert → mehr Wiederholungen,
besser für prüfungsrelevantes Lernen; niedrigerer Wert → lockerer,
gut für Allgemeinwissen. Schema (`decks.fsrs_settings`) und
Per-Deck-PATCH-Endpoint sind da; **Retention-Slider im Deck-Edit
gebaut 2026-05-13.** w-Gewichte-Optimierung (Offline-Python-Skript
→ JSON-Upload) bleibt offen.
- **Leech-Detection — gebaut 2026-05-12.** `me/stats` liefert - **Leech-Detection — gebaut 2026-05-12.** `me/stats` liefert
`leech_cards` (Threshold 4 Lapses, Limit 20, sortiert nach `leech_cards` (Threshold 4 Lapses, Limit 20, sortiert nach
Lapses desc, mit Front-Snippet + Deck-Name). Stats-Page hat eine Lapses desc, mit Front-Snippet + Deck-Name). Stats-Page hat eine
rote Leech-Sektion mit Link in den Card-Editor. i18n in DE/EN/ rote Leech-Sektion mit Link in den Card-Editor. i18n in DE/EN/
FR/IT/ES. Suspension/Aufteilen-Vorschläge sind nächste Welle. FR/IT/ES. Suspension/Aufteilen-Vorschläge sind nächste Welle.
- **Undo letzte Bewertung** — wichtiger UX-Reflex; aktuell muss man - **Undo letzte Bewertung — gebaut 2026-05-13.** `prevSnapshot`-
das Review händisch zurückbauen. Spalte in `reviews`, API `POST /:cardId/:subIndex/undo`, 5-s-Toast
mit Fortschrittsbalken im Study-View, Ctrl/Cmd+Z-Shortcut.
- **Card Burial / Suspension** — Karten temporär deaktivieren ohne - **Card Burial / Suspension** — Karten temporär deaktivieren ohne
Löschen; häufig angefragtes Anki-Feature. Löschen; häufig angefragtes Anki-Feature.
- **Geschwister-Burial** — Cloze-Cluster und basic-reverse-Seiten - **Geschwister-Burial** — Cloze-Cluster und basic-reverse-Seiten
@ -100,9 +106,9 @@ aber im Code längst gelandet:
Fehler" als saubere Filter im Queue-Loader. Fehler" als saubere Filter im Queue-Loader.
- **Subdeck-Unterstützung** — hierarchische Deck-Struktur - **Subdeck-Unterstützung** — hierarchische Deck-Struktur
(z. B. Vokabeln → Nomen / Verben). (z. B. Vokabeln → Nomen / Verben).
- **Cloze-Hint-Anzeige** — `{{c1::answer::hint}}` wird aktuell beim - **Cloze-Hint-Anzeige — bereits implementiert.** `renderClozePrompt`
Rendern fallen gelassen; Hint-Anzeige steht in `STATUS.md` als in `packages/cards-domain/src/cloze.ts` rendert `{{c1::answer::hint}}`
verbleibender Phase-9-Punkt. bereits als `[hint]`. `STATUS.md`-Eintrag ist veraltet.
### Kartentypen, Schema vorhanden / vorbereitet ### Kartentypen, Schema vorhanden / vorbereitet
@ -119,8 +125,9 @@ aber im Code längst gelandet:
- **Streak-Freeze-Token** — ein Streak-Schutztag pro Woche, optional - **Streak-Freeze-Token** — ein Streak-Schutztag pro Woche, optional
durch Credits kaufbar. durch Credits kaufbar.
- **Streak im Header** — heute nur im Stats-Dashboard sichtbar; - **Streak im Header — gebaut 2026-05-13.** `GET /api/v1/me/summary`
Header-Glyph mit Zahl wäre Mikro-Aufwand, sichtbare Wirkung. liefert `streak_days` + `due_now`; Header zeigt Flammen-Pill und
fällige-Karten-Pill via `onMount`.
- **XP + Badges** — Meilensteine (erstes Deck, 100 Karten, 30-Tage- - **XP + Badges** — Meilensteine (erstes Deck, 100 Karten, 30-Tage-
Streak). Streak).
- **Tages-Ziele** — „Heute: 20 Karten" mit Progress-Bar im Dashboard. - **Tages-Ziele** — „Heute: 20 Karten" mit Progress-Bar im Dashboard.
@ -152,17 +159,21 @@ aber im Code längst gelandet:
## Analytics & Insights ## Analytics & Insights
- **Vergessenskurven-Visualisierung** — pro Deck und Tag, aus - **Vergessenskurven-Visualisierung / Graceful Backlog Recovery —
FSRS-State ableitbar. gebaut 2026-05-13.** Stats-Page zeigt Difficulty-Distribution
(5 Buckets, Farb-Bars) + Deck-Fortschritt (Mastery %, Stability>21).
Study-View zeigt Recovery-Banner wenn total>30 fällige Karten,
startet Queue nach Stability sortiert (25er-Batch).
- **Retention-Rate** — aufgeschlüsselt nach Kategorie und Sprache. - **Retention-Rate** — aufgeschlüsselt nach Kategorie und Sprache.
- **Lernzeit-Tracking** — Minuten pro Session, Wochentrend. - **Lernzeit-Tracking** — Minuten pro Session, Wochentrend.
- **Karten-Schwierigkeits-Heatmap** — welche Karten kosten die - **Karten-Schwierigkeits-Heatmap** — welche Karten kosten die
meiste Review-Zeit. meiste Review-Zeit.
- **Wöchentliche Zusammenfassung** — In-App oder per Email via - **Wöchentliche Zusammenfassung** — In-App oder per Email via
`mana-notify`. `mana-notify`.
- **Algorithmus-Transparenz pro Karte** — kleines „Wieso wurde ich - **Algorithmus-Transparenz pro Karte — gebaut 2026-05-13.**
befragt?"-Tooltip mit Stability/Difficulty/letztem Rating. Macht FSRS-Tooltip im Study-View: ⓘ-Button nach Reveal öffnet Popover
FSRS sichtbar (Mission-Wert „Souveränität"). mit State, Stability, Difficulty, letztem Rating. Macht FSRS
sichtbar (Mission-Wert „Souveränität").
--- ---

View file

@ -181,10 +181,10 @@ weil sie den breitesten Markt haben.
| # | Deck-Slug | Titel | Karten | Lizenz | Sprache | | # | Deck-Slug | Titel | Karten | Lizenz | Sprache |
|---|---|---|---|---|---| |---|---|---|---|---|---|
| 1 | `cardecky/englisch-a2-grundwortschatz` | Englisch A2 — 500 häufigste Wörter | 500 (basic-reverse) | CC-BY-4.0 | de | | 1 | `cardecky/englisch-a2-grundwortschatz` | Englisch A2 — 500 häufigste Wörter | 500 (basic-reverse) | CC-BY-4.0 | de | ✅ live 2026-05-12 (557 Karten) |
| 2 | `cardecky/englisch-b1-aufbauwortschatz` | Englisch B1 — Aufbau-Wortschatz | 600 (basic-reverse) | CC-BY-4.0 | de | | 2 | `cardecky/englisch-b1-aufbauwortschatz` | Englisch B1 — Aufbau-Wortschatz | 600 (basic-reverse) | CC-BY-4.0 | de | ✅ live 2026-05-13 (689 Karten) |
| 3 | `cardecky/franzoesisch-a2-grundwortschatz` | Französisch A2 — Grundwortschatz | 500 (basic-reverse) | CC-BY-4.0 | de | | 3 | `cardecky/franzoesisch-a2-grundwortschatz` | Französisch A2 — Grundwortschatz | 500 (basic-reverse) | CC-BY-4.0 | de | ✅ live 2026-05-13 (575 Karten) |
| 4 | `cardecky/mathematik-sek1-grundbegriffe` | Mathematik Sek 1 — Begriffe + Formeln | 200 (basic + cloze) | CC-BY-4.0 | de | | 4 | `cardecky/mathematik-sek1-grundbegriffe` | Mathematik Sek 1 — Begriffe + Formeln | 200 (basic + cloze) | CC-BY-4.0 | de | ✅ live 2026-05-13 (178 Karten) |
| 5 | `cardecky/geografie-welt-hauptstaedte` | Geografie Welt — Länder + Hauptstädte | 195 (basic-reverse) | CC-BY-4.0 | de | | 5 | `cardecky/geografie-welt-hauptstaedte` | Geografie Welt — Länder + Hauptstädte | 195 (basic-reverse) | CC-BY-4.0 | de |
| 6 | `cardecky/periodensystem-elemente` | Periodensystem — 118 Elemente | 118 (basic-reverse) | CC-BY-4.0 | de | | 6 | `cardecky/periodensystem-elemente` | Periodensystem — 118 Elemente | 118 (basic-reverse) | CC-BY-4.0 | de |
| 7 | `cardecky/biologie-zelle-grundlagen` | Biologie — Zelle und Zellbiologie | 80 (basic + cloze) | CC-BY-4.0 | de | | 7 | `cardecky/biologie-zelle-grundlagen` | Biologie — Zelle und Zellbiologie | 80 (basic + cloze) | CC-BY-4.0 | de |
@ -206,6 +206,26 @@ weil sie den breitesten Markt haben.
geschätzt 3040 Stunden über mehrere Sessions, weil Recherche + geschätzt 3040 Stunden über mehrere Sessions, weil Recherche +
Quellen-Belege + Stufen-Validierung pro Deck Zeit kosten. Quellen-Belege + Stufen-Validierung pro Deck Zeit kosten.
## 8b. Bonus-Decks (außerhalb Phase-1-Seed)
| # | Deck-Slug | Titel | Karten | Lizenz | Sprache | Status |
|---|---|---|---|---|---|---|
| B-1 | `cardecky/taegerwilen-bodensee` | Tägerwilen am Bodensee | 66 (basic + cloze + mc) | CC0-1.0 | de | ✅ live 2026-05-12 |
| B-2 | `cardecky/kreuzlingen-thurgau` | Kreuzlingen am Bodensee | 70 (basic + cloze + mc) | CC0-1.0 | de | ✅ live 2026-05-12 |
| B-3 | `cardecky/gottlieben-seerhein` | Gottlieben am Seerhein | 50 (basic + cloze + mc) | CC0-1.0 | de | ✅ live 2026-05-12 |
| B-4 | `cardecky/konstanz-bodensee` | Konstanz am Bodensee | 50 (basic + cloze + mc) | CC0-1.0 | de | ✅ live 2026-05-12 |
| B-5 | `cardecky/meersburg-bodensee` | Meersburg am Bodensee | 50 (basic + cloze + mc) | CC0-1.0 | de | ✅ live 2026-05-12 |
| B-6 | `cardecky/muensterlingen-bodensee` | Münsterlingen am Bodensee | 50 (basic + cloze + mc) | CC0-1.0 | de | ✅ live 2026-05-12 |
| B-7 | `cardecky/reichenau-bodensee` | Reichenau am Bodensee | 50 (basic + cloze + mc) | CC0-1.0 | de | ✅ live 2026-05-12 |
| B-8 | `cardecky/friedrichshafen-bodensee` | Friedrichshafen am Bodensee | 50 (basic + cloze + mc) | CC0-1.0 | de | ✅ live 2026-05-12 |
| B-9 | `cardecky/lindau-bodensee` | Lindau am Bodensee | 50 (basic + cloze + mc) | CC0-1.0 | de | ✅ live 2026-05-12 |
| B-10 | `cardecky/bregenz-vorarlberg` | Bregenz am Bodensee | 50 (basic + cloze + mc) | CC0-1.0 | de | ✅ live 2026-05-12 |
| B-11 | `cardecky/ueberlingen-bodensee` | Überlingen am Bodensee | 50 (basic + cloze + mc) | CC0-1.0 | de | ✅ live 2026-05-12 |
| B-12 | `cardecky/romanshorn-bodensee` | Romanshorn am Bodensee | 50 (basic + cloze + mc) | CC0-1.0 | de | ✅ live 2026-05-12 |
| B-13 | `cardecky/allensbach-bodensee` | Allensbach am Bodensee | 50 (basic + cloze + mc) | CC0-1.0 | de | ✅ live 2026-05-12 |
| B-14 | `cardecky/bodensee` | Der Bodensee | 50 (basic + cloze + mc) | CC0-1.0 | de | ✅ live 2026-05-12 |
| B-15 | `cardecky/thurgau` | Kanton Thurgau | 50 (basic + cloze + mc) | CC0-1.0 | de | ✅ live 2026-05-12 |
**Lizenzen:** **Lizenzen:**
- Default `CC-BY-4.0` mit Attribution „Cardecky / mana e.V." erlaubt - Default `CC-BY-4.0` mit Attribution „Cardecky / mana e.V." erlaubt
Re-Use durch Lehrer/innen, Forks, Schul-Repositories. Re-Use durch Lehrer/innen, Forks, Schul-Repositories.