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,
|
||||
"tag": "0000_baseline",
|
||||
"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 { 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] }),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue