Phase 2: learning engine — history, SRS review, mistakes, stats, exam, reminders

Answer history and schedules
- Every answer is recorded (deck, activity, topic, correct/wrong, time)
  in a versioned IndexedDB log (freezmo-learning v1, stores answers +
  srs), mirrored in memory for synchronous React reads and capped at
  20k answers (oldest pruned). StudyFeed records as it grades.
- Light SM-2 schedule per activity: 1 day, 3 days, then interval x
  ease; wrong answers return in ten minutes with the ease nudged down.

Review, mistakes, stats
- Library 'Smart review' card: due count, mistakes count (latest
  answer wrong — clears itself when answered correctly), today's goal
  progress. Review sessions freeze their item list at start.
- New /review (due across decks, ?mistakes=1 global or ?deck= per
  deck) built on a shared SessionFeed with the study feed's snap/keyboard
  mechanics, wake lock and a results slide.
- New /stats: day streak, five-week calendar, accuracy per subject and
  per deck, weakest topics (>=3 answers), daily-goal progress.
- Daily goal configurable in Settings (10/20/30/50).

Exam mode
- New /exam: pick decks, question count, optional timer; no hints
  (a NoHints context hides hint rows and the self-check escape);
  results with score, per-deck and per-topic breakdown; wrong answers
  land in the mistakes set through the normal history.

Reminders (conditional)
- VAPID-gated: GET/POST/DELETE /api/push and POST /api/push/test with
  the standard route pattern, RATE_LIMIT_PUSH, zod, auth before body.
  Subscriptions stored server-side as endpoint + keys only (PUSH_DIR).
  UI hidden entirely without keys; iOS install note; service worker
  push/notificationclick handlers; scripts/send-reminders.mjs for a
  cron/systemd timer; web-push added as the one server dependency.

Verified: new phase-2 suite 42/42 (history/SRS shapes from a real study
run, mistakes clearing, review session, stats math, exam timer and
breakdowns, VAPID-less hidden state plus the configured
subscribe/validate/test/delete path against a second instance) plus
regression 74/74, mobile 52/52, wizard 80/80, declutter 116/116,
animation 8/8, native 35/35, phase1 38/38, i18n 25/25. Key parity
709/709. Lint + build clean. SW freezmo-v26.
This commit is contained in:
Martin Dvořák 2026-10-10 14:15:21 +00:00
commit e4a8e0ba01
33 changed files with 2504 additions and 47 deletions

View file

@ -764,6 +764,12 @@ const en: Record<string, string> = {
"server.syncTooLarge": "This sync upload is too large.",
"server.syncStorageFull": "The sync storage is full. Delete some synced data and try again.",
"server.syncNotFound": "No synced data found for that ID.",
"server.pushDisabled": "Push reminders are not configured on this server (set VAPID_PUBLIC_KEY and VAPID_PRIVATE_KEY).",
"server.pushNotSubscribed": "This browser is not subscribed to reminders.",
"server.pushGone": "That subscription is no longer valid — reminders were turned off for it.",
"server.pushSendFailed": "The reminder could not be sent.",
"server.pushReminderTitle": "Time to review",
"server.pushReminderBody": "Your decks are waiting — a few due cards keep the streak alive.",
/* folders / files -------------------------------------------------- */
"folder.nameRequired": "Give the folder a name.",
@ -782,6 +788,74 @@ const en: Record<string, string> = {
"error.openOllama": "Open Ollama",
"error.throttledTitle": "Ollama is busy",
"error.throttledBody": "Ollama is temporarily rate limiting requests. Try again in a moment.",
/* learning: review, mistakes, stats, exam -------------------------- */
"review.cardTitle": "Smart review",
"review.title": "Review due",
"review.mistakesTitle": "Mistakes",
"review.mistakesDeck": "Mistakes in this deck",
"review.nothingDue": "Nothing is due right now. Study a deck and the schedule fills up on its own.",
"review.noMistakes": "No open mistakes — everything you got wrong has since been answered correctly.",
"review.done": "Back to the Library",
"review.seeResults": "See results",
"review.finishFirst": "Finish the last activity to see your results.",
"review.results": "Results",
"review.due.one": "{n} due",
"review.due.few": "{n} due",
"review.due.many": "{n} due",
"review.mistakes.one": "{n} mistake",
"review.mistakes.few": "{n} mistakes",
"review.mistakes.many": "{n} mistakes",
"exam.title": "Exam mode",
"exam.subtitle": "Pick one or more decks, set a timer, no hints — see how you'd do.",
"exam.pickDecks": "Decks",
"exam.noDecks": "Create a deck first.",
"exam.questions": "Questions",
"exam.timer": "Timer",
"exam.timerOff": "Off",
"exam.start": "Start exam ({n} questions)",
"exam.results": "Exam results",
"exam.byDeck": "By deck",
"exam.byTopic": "By topic",
"exam.studyMistakes": "Study mistakes",
"exam.timeLeft": "{time} left",
"stats.title": "Your progress",
"stats.subtitle": "Streaks, accuracy and the topics that deserve another pass.",
"stats.streak": "Day streak",
"stats.today": "Today",
"stats.todayOf": "{n} / {goal} answers",
"stats.goalDone": "Daily goal done 🎉",
"stats.accuracy": "Accuracy",
"stats.answers.one": "{n} answer",
"stats.answers.few": "{n} answers",
"stats.answers.many": "{n} answers",
"stats.calendar": "Last 5 weeks",
"stats.bySubject": "By subject",
"stats.byDeck": "By deck",
"stats.weakest": "Weakest topics",
"stats.noSubject": "No subject",
"stats.correctOf": "{correct} / {total} correct",
"stats.empty": "No answers yet",
"stats.emptyBody": "Study a deck and your progress shows up here.",
"stats.start": "Study a deck",
"stats.legendLess": "Less",
"stats.legendMore": "More",
"nav.stats": "Progress",
"nav.exam": "Exam mode",
"settings.learning": "Learning",
"settings.dailyGoal": "Daily goal",
"settings.dailyGoalNote": "Answers per day that count as done.",
"settings.reminders": "Daily reminder",
"settings.remindersNote":
"A gentle nudge when your review is due. Needs the installed app — on iPhone that is required.",
"settings.reminderEnable": "Turn on reminders",
"settings.reminderDisable": "Turn off",
"settings.reminderTest": "Send a test",
"settings.reminderOn": "Reminders are on.",
"settings.reminderOff": "Reminders are off.",
"settings.reminderDenied": "Notifications are blocked in your browser settings.",
"settings.reminderError": "Could not set up reminders.",
"library.learningToday": "Today: {n} / {goal}",
"doc.readingPage": "Reading page {n} of {total}…",
"doc.readingWord": "Reading the Word document…",
"doc.readingSlides": "Reading the slides…",
@ -1492,6 +1566,12 @@ const cs: Record<string, string> = {
"server.syncTooLarge": "Tahle synchronizace je příliš velká.",
"server.syncStorageFull": "Úložiště synchronizace je plné. Smaž nějaká synchronizovaná data a zkus to znovu.",
"server.syncNotFound": "Pro tenhle ID nejsou žádná synchronizovaná data.",
"server.pushDisabled": "Push připomínky nejsou na tomto serveru nastavené (nastav VAPID_PUBLIC_KEY a VAPID_PRIVATE_KEY).",
"server.pushNotSubscribed": "Tento prohlížeč není k připomínkám přihlášený.",
"server.pushGone": "Ta přihláška už není platná — připomínky pro ni byly vypnuté.",
"server.pushSendFailed": "Připomínku se nepodařilo poslat.",
"server.pushReminderTitle": "Čas na opakování",
"server.pushReminderBody": "Sady na tebe čekají — pár kartiček udrží sérii.",
/* folders / files -------------------------------------------------- */
"folder.nameRequired": "Dej složce název.",
@ -1510,6 +1590,74 @@ const cs: Record<string, string> = {
"error.openOllama": "Otevřít Ollamu",
"error.throttledTitle": "Ollama je zaneprázdněná",
"error.throttledBody": "Ollama dočasně omezuje požadavky. Zkus to za chvíli znovu.",
/* learning: review, mistakes, stats, exam -------------------------- */
"review.cardTitle": "Chytré opakování",
"review.title": "K opakování",
"review.mistakesTitle": "Chyby",
"review.mistakesDeck": "Chyby v této sadě",
"review.nothingDue": "Teď není co opakovat. Zauč se do sady a rozvrh se sám naplní.",
"review.noMistakes": "Žádné otevřené chyby — co jsi měl špatně, jsi od té doby zodpověděl správně.",
"review.done": "Zpět do knihovny",
"review.seeResults": "Zobrazit výsledky",
"review.finishFirst": "Dokonči poslední aktivitu a uvidíš výsledky.",
"review.results": "Výsledky",
"review.due.one": "{n} k opakování",
"review.due.few": "{n} k opakování",
"review.due.many": "{n} k opakování",
"review.mistakes.one": "{n} chyba",
"review.mistakes.few": "{n} chyby",
"review.mistakes.many": "{n} chyb",
"exam.title": "Zkouškové režim",
"exam.subtitle": "Vyber sady, nastav časovač, žádné nápovědy — uvidíš, jak bys dopadl.",
"exam.pickDecks": "Sady",
"exam.noDecks": "Nejdřív si vytvoř sadu.",
"exam.questions": "Otázky",
"exam.timer": "Časovač",
"exam.timerOff": "Vypnuto",
"exam.start": "Spustit zkoušku ({n} otázek)",
"exam.results": "Výsledky zkoušky",
"exam.byDeck": "Podle sady",
"exam.byTopic": "Podle tématu",
"exam.studyMistakes": "Procvičit chyby",
"exam.timeLeft": "zbývá {time}",
"stats.title": "Tvůj pokrok",
"stats.subtitle": "Série, úspěšnost a témata, která si zaslouží další kolo.",
"stats.streak": "Série dní",
"stats.today": "Dnes",
"stats.todayOf": "{n} / {goal} odpovědí",
"stats.goalDone": "Denní cíl splněn 🎉",
"stats.accuracy": "Úspěšnost",
"stats.answers.one": "{n} odpověď",
"stats.answers.few": "{n} odpovědi",
"stats.answers.many": "{n} odpovědí",
"stats.calendar": "Posledních 5 týdnů",
"stats.bySubject": "Podle předmětu",
"stats.byDeck": "Podle sady",
"stats.weakest": "Nejslabší témata",
"stats.noSubject": "Bez předmětu",
"stats.correctOf": "{correct} / {total} správně",
"stats.empty": "Zatím žádné odpovědi",
"stats.emptyBody": "Zauč se do sady a tvůj pokrok se objeví tady.",
"stats.start": "Jít studovat",
"stats.legendLess": "Méně",
"stats.legendMore": "Více",
"nav.stats": "Pokrok",
"nav.exam": "Zkouškový režim",
"settings.learning": "Učení",
"settings.dailyGoal": "Denní cíl",
"settings.dailyGoalNote": "Kolik odpovědí za den se počítá jako splněno.",
"settings.reminders": "Denní připomínka",
"settings.remindersNote":
"Jemné připomenutí, když je co opakovat. Potřebuje nainstalovanou aplikaci — na iPhonu je to podmínka.",
"settings.reminderEnable": "Zapnout připomínky",
"settings.reminderDisable": "Vypnout",
"settings.reminderTest": "Poslat zkušební",
"settings.reminderOn": "Připomínky jsou zapnuté.",
"settings.reminderOff": "Připomínky jsou vypnuté.",
"settings.reminderDenied": "Oznámení jsou v nastavení prohlížeče zablokovaná.",
"settings.reminderError": "Připomínky se nepodařilo nastavit.",
"library.learningToday": "Dnes: {n} / {goal}",
"doc.readingPage": "Čtu stranu {n} z {total}…",
"doc.readingWord": "Čtu dokument Word…",
"doc.readingSlides": "Čtu snímky…",