From abf493aeece52a8a277943c61332975e1aa1082e Mon Sep 17 00:00:00 2001 From: Till JS Date: Wed, 13 May 2026 13:37:03 +0200 Subject: [PATCH] feat(cards): recovery mode, undo, FSRS slider, streak header, stats charts, blog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../migrations/0001_reviews_prev_snapshot.sql | 1 + apps/api/src/db/migrations/meta/_journal.json | 7 + apps/api/src/db/schema/reviews.ts | 3 +- apps/api/src/routes/me.ts | 104 +++++ apps/api/src/routes/reviews.ts | 57 ++- apps/landing/src/components/BlogTeaser.astro | 75 ++++ apps/landing/src/components/Footer.astro | 13 +- .../src/pages/blog/anki-zu-kompliziert.astro | 321 ++++++++++++++ apps/landing/src/pages/blog/deine-daten.astro | 289 +++++++++++++ .../src/pages/blog/fsrs-algorithmus.astro | 246 +++++++++++ .../src/pages/blog/gute-lernkarten.astro | 408 ++++++++++++++++++ apps/landing/src/pages/blog/index.astro | 97 +++++ .../src/pages/blog/quizlet-paywall.astro | 309 +++++++++++++ apps/landing/src/pages/index.astro | 2 + apps/web/src/lib/api/me.ts | 11 + apps/web/src/lib/api/reviews.ts | 7 +- apps/web/src/lib/components/Header.svelte | 60 +++ apps/web/src/lib/i18n/de.ts | 10 + apps/web/src/lib/i18n/en.ts | 10 + apps/web/src/lib/i18n/es.ts | 10 + apps/web/src/lib/i18n/fr.ts | 10 + apps/web/src/lib/i18n/it.ts | 10 + .../src/routes/decks/[id]/edit/+page.svelte | 83 +++- apps/web/src/routes/stats/+page.svelte | 175 ++++++++ .../src/routes/study/[deckId]/+page.svelte | 307 ++++++++++++- docs/FEATURE_IDEAS.md | 43 +- docs/marketplace/CONTENT_PLAN.md | 28 +- 27 files changed, 2667 insertions(+), 29 deletions(-) create mode 100644 apps/api/src/db/migrations/0001_reviews_prev_snapshot.sql create mode 100644 apps/landing/src/components/BlogTeaser.astro create mode 100644 apps/landing/src/pages/blog/anki-zu-kompliziert.astro create mode 100644 apps/landing/src/pages/blog/deine-daten.astro create mode 100644 apps/landing/src/pages/blog/fsrs-algorithmus.astro create mode 100644 apps/landing/src/pages/blog/gute-lernkarten.astro create mode 100644 apps/landing/src/pages/blog/index.astro create mode 100644 apps/landing/src/pages/blog/quizlet-paywall.astro diff --git a/apps/api/src/db/migrations/0001_reviews_prev_snapshot.sql b/apps/api/src/db/migrations/0001_reviews_prev_snapshot.sql new file mode 100644 index 0000000..33afd4e --- /dev/null +++ b/apps/api/src/db/migrations/0001_reviews_prev_snapshot.sql @@ -0,0 +1 @@ +ALTER TABLE "cards"."reviews" ADD COLUMN "prev_snapshot" jsonb; diff --git a/apps/api/src/db/migrations/meta/_journal.json b/apps/api/src/db/migrations/meta/_journal.json index 61ddf1c..809c946 100644 --- a/apps/api/src/db/migrations/meta/_journal.json +++ b/apps/api/src/db/migrations/meta/_journal.json @@ -8,6 +8,13 @@ "when": 1778604624860, "tag": "0000_baseline", "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1747180800000, + "tag": "0001_reviews_prev_snapshot", + "breakpoints": true } ] } \ No newline at end of file diff --git a/apps/api/src/db/schema/reviews.ts b/apps/api/src/db/schema/reviews.ts index 5b9b85e..a47d79f 100644 --- a/apps/api/src/db/schema/reviews.ts +++ b/apps/api/src/db/schema/reviews.ts @@ -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 { cards } from './cards.ts'; @@ -37,6 +37,7 @@ export const reviews = cardsSchema.table( .notNull() .default('new'), lastReview: timestamp('last_review', { withTimezone: true, mode: 'date' }), + prevSnapshot: jsonb('prev_snapshot').$type | null>().default(null), }, (t) => ({ pk: primaryKey({ columns: [t.cardId, t.subIndex] }), diff --git a/apps/api/src/routes/me.ts b/apps/api/src/routes/me.ts index 8eb2cf5..431e328 100644 --- a/apps/api/src/routes/me.ts +++ b/apps/api/src/routes/me.ts @@ -68,6 +68,47 @@ export function meRouter(deps: MeDeps = {}): Hono<{ Variables: AuthVars }> { 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`count(*)::int` }) + .from(reviews) + .where(and(eq(reviews.userId, userId), lte(reviews.due, now))); + + const dayRows = await db + .select({ + day: sql`to_char(${reviews.lastReview}, 'YYYY-MM-DD')`, + n: sql`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 * 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, })); + // Difficulty-Distribution: 5 Buckets über alle non-new Reviews. + const diffRows = await db + .select({ + bucket: 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`, + n: sql`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`count(${reviews.cardId})::int`, + mastered: sql`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({ user_id: userId, generated_at: now.toISOString(), @@ -238,6 +340,8 @@ export function meRouter(deps: MeDeps = {}): Hono<{ Variables: AuthVars }> { due_forecast: dueForecast, leech_threshold: LEECH_THRESHOLD, leech_cards: leechCards, + difficulty_distribution: difficultyDistribution, + deck_mastery: deckMastery, }); }); diff --git a/apps/api/src/routes/reviews.ts b/apps/api/src/routes/reviews.ts index 76a9483..be4dc1e 100644 --- a/apps/api/src/routes/reviews.ts +++ b/apps/api/src/routes/reviews.ts @@ -28,10 +28,12 @@ export function reviewsRouter(deps: ReviewsDeps = {}): Hono<{ Variables: AuthVar r.get('/due', async (c) => { const userId = c.get('userId'); 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 conditions = [eq(reviews.userId, userId), lte(reviews.due, now)]; + const orderBy = recovery ? asc(reviews.stability) : asc(reviews.due); if (deckId) { // Wenn deck_id angegeben, joinen wir auf cards.deck_id. @@ -43,7 +45,7 @@ export function reviewsRouter(deps: ReviewsDeps = {}): Hono<{ Variables: AuthVar .from(reviews) .innerJoin(cards, eq(cards.id, reviews.cardId)) .where(and(...conditions, eq(cards.deckId, deckId))) - .orderBy(asc(reviews.due)) + .orderBy(orderBy) .limit(limit); return c.json({ reviews: rows.map((r) => ({ ...toReviewDto(r.review), card: r.card })), @@ -55,7 +57,7 @@ export function reviewsRouter(deps: ReviewsDeps = {}): Hono<{ Variables: AuthVar .select() .from(reviews) .where(and(...conditions)) - .orderBy(asc(reviews.due)) + .orderBy(orderBy) .limit(limit); 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) ?? {} ); + // Snapshot des alten Zustands vor dem Überschreiben — ermöglicht Undo. + const snapshot = toReviewDto(hit.review) as Record; + const [updated] = await dbOf() .update(reviews) .set({ @@ -127,6 +132,7 @@ export function reviewsRouter(deps: ReviewsDeps = {}): Hono<{ Variables: AuthVar lapses: next.lapses, state: next.state, lastReview: next.last_review ? new Date(next.last_review) : null, + prevSnapshot: snapshot, }) .where( and( @@ -140,6 +146,51 @@ export function reviewsRouter(deps: ReviewsDeps = {}): Hono<{ Variables: AuthVar 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; + + 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; } diff --git a/apps/landing/src/components/BlogTeaser.astro b/apps/landing/src/components/BlogTeaser.astro new file mode 100644 index 0000000..730e0c1 --- /dev/null +++ b/apps/landing/src/components/BlogTeaser.astro @@ -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; +--- + +
+
+ +

Hintergrund & Methodik.

+

+ Warum Spaced Repetition funktioniert, was die Konkurrenz falsch macht — und wie man + Karten baut, die das Gehirn tatsächlich behält. +

+ + +
+
diff --git a/apps/landing/src/components/Footer.astro b/apps/landing/src/components/Footer.astro index 6b15944..0fc7d1e 100644 --- a/apps/landing/src/components/Footer.astro +++ b/apps/landing/src/components/Footer.astro @@ -3,7 +3,7 @@ const year = new Date().getFullYear(); ---