feat(cards): recovery mode, undo, FSRS slider, streak header, stats charts, blog
Some checks are pending
CI / validate (push) Waiting to run

Study-View:
- Graceful Backlog Recovery: Banner bei >30 fälligen Karten, Recovery-Queue
  sortiert nach Stability aufsteigend (25er-Batch, ?recovery=true)
- Undo letzte Bewertung: 5s-Toast mit RAF-Fortschrittsbalken, Ctrl/Cmd+Z,
  prevSnapshot-Spalte in reviews (Migration 0001, Prod deployed)
- FSRS-Tooltip nach Reveal: State / Stability / Difficulty als Popover

Deck-Edit:
- Neuer Abschnitt „Lern-Algorithmus" mit request_retention-Slider (50–99 %)

Header:
- Streak-Pill (🔥 N) + fällige-Karten-Pill via GET /api/v1/me/summary

Stats-Page:
- Difficulty-Distribution (5 Buckets, Farb-Bars)
- Deck-Fortschritt (Mastery % = stability>21, max 6 Decks)

API:
- GET /me/summary: streak_days + due_now (leichtgewichtiger Header-Endpoint)
- GET /reviews/due: ?recovery=true → stability-sort, Limit 25
- POST /reviews/:cardId/:subIndex/undo: prevSnapshot-Restore, 409 wenn leer
- /me/stats: difficulty_distribution + deck_mastery

Landing:
- 5 Blog-Artikel (Quizlet-Paywall, FSRS, Datenschutz, Anki, Lernkarten-Tipps)
- BlogTeaser-Komponente auf Startseite, Footer-Spalte „Artikel"

i18n: 11 neue Schlüssel in DE/EN/FR/IT/ES

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-05-13 13:37:03 +02:00
parent 21ec535173
commit abf493aeec
27 changed files with 2667 additions and 29 deletions

View file

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

View file

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

View file

@ -1,4 +1,4 @@
import { index, integer, primaryKey, real, text, timestamp } from 'drizzle-orm/pg-core';
import { index, integer, jsonb, primaryKey, real, text, timestamp } from 'drizzle-orm/pg-core';
import { cardsSchema } from './_schema.ts';
import { 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<Record<string, unknown> | null>().default(null),
},
(t) => ({
pk: primaryKey({ columns: [t.cardId, t.subIndex] }),

View file

@ -68,6 +68,47 @@ export function meRouter(deps: MeDeps = {}): Hono<{ Variables: AuthVars }> {
return c.json(payload);
});
/**
* 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
* 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<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({
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,
});
});

View file

@ -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<string, unknown>;
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<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;
}