feat(cards): recovery mode, undo, FSRS slider, streak header, stats charts, blog
Some checks are pending
CI / validate (push) Waiting to run
Some checks are pending
CI / validate (push) Waiting to run
Study-View:
- Graceful Backlog Recovery: Banner bei >30 fälligen Karten, Recovery-Queue
sortiert nach Stability aufsteigend (25er-Batch, ?recovery=true)
- Undo letzte Bewertung: 5s-Toast mit RAF-Fortschrittsbalken, Ctrl/Cmd+Z,
prevSnapshot-Spalte in reviews (Migration 0001, Prod deployed)
- FSRS-Tooltip nach Reveal: State / Stability / Difficulty als Popover
Deck-Edit:
- Neuer Abschnitt „Lern-Algorithmus" mit request_retention-Slider (50–99 %)
Header:
- Streak-Pill (🔥 N) + fällige-Karten-Pill via GET /api/v1/me/summary
Stats-Page:
- Difficulty-Distribution (5 Buckets, Farb-Bars)
- Deck-Fortschritt (Mastery % = stability>21, max 6 Decks)
API:
- GET /me/summary: streak_days + due_now (leichtgewichtiger Header-Endpoint)
- GET /reviews/due: ?recovery=true → stability-sort, Limit 25
- POST /reviews/:cardId/:subIndex/undo: prevSnapshot-Restore, 409 wenn leer
- /me/stats: difficulty_distribution + deck_mastery
Landing:
- 5 Blog-Artikel (Quizlet-Paywall, FSRS, Datenschutz, Anki, Lernkarten-Tipps)
- BlogTeaser-Komponente auf Startseite, Footer-Spalte „Artikel"
i18n: 11 neue Schlüssel in DE/EN/FR/IT/ES
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
21ec535173
commit
abf493aeec
27 changed files with 2667 additions and 29 deletions
|
|
@ -0,0 +1 @@
|
||||||
|
ALTER TABLE "cards"."reviews" ADD COLUMN "prev_snapshot" jsonb;
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
@ -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] }),
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
75
apps/landing/src/components/BlogTeaser.astro
Normal file
75
apps/landing/src/components/BlogTeaser.astro
Normal 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 20–30 % 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 & 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>
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
321
apps/landing/src/pages/blog/anki-zu-kompliziert.astro
Normal file
321
apps/landing/src/pages/blog/anki-zu-kompliziert.astro
Normal 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 — 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>
|
||||||
289
apps/landing/src/pages/blog/deine-daten.astro
Normal file
289
apps/landing/src/pages/blog/deine-daten.astro
Normal 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 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 2022–2023 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>
|
||||||
246
apps/landing/src/pages/blog/fsrs-algorithmus.astro
Normal file
246
apps/landing/src/pages/blog/fsrs-algorithmus.astro
Normal 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 20–30 % 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 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>20–30 % 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 140–160 — 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>
|
||||||
408
apps/landing/src/pages/blog/gute-lernkarten.astro
Normal file
408
apps/landing/src/pages/blog/gute-lernkarten.astro
Normal 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>{{c1::Begriff}}</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>
|
||||||
97
apps/landing/src/pages/blog/index.astro
Normal file
97
apps/landing/src/pages/blog/index.astro
Normal 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 20–30 % 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>
|
||||||
309
apps/landing/src/pages/blog/quizlet-paywall.astro
Normal file
309
apps/landing/src/pages/blog/quizlet-paywall.astro
Normal 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 2022–2023 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 — 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>
|
||||||
|
|
@ -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 />
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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' });
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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}',
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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}',
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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}',
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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}',
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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}',
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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.5–0.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").
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 30–40 Stunden über mehrere Sessions, weil Recherche +
|
geschätzt 30–40 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.
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue