diff --git a/.env.example b/.env.example index 4821c26..21d4d3d 100644 --- a/.env.example +++ b/.env.example @@ -45,6 +45,21 @@ TEXT_MODEL=gpt-oss:120b-cloud # RATE_LIMIT_HEALTH=90 # Encrypted sync (no model calls, but shouldn't be hammered). # RATE_LIMIT_SYNC=60 +# Push subscription changes and test sends. +# RATE_LIMIT_PUSH=30 + +# PUSH REMINDERS (optional) ---------------------------------------------------- +# Web Push reminders only appear in the UI when BOTH VAPID keys are set. +# Generate them once with: npx web-push generate-vapid-keys +# VAPID_PUBLIC_KEY= +# VAPID_PRIVATE_KEY= +# VAPID_SUBJECT=mailto:you@example.com +# Where subscriptions are stored (endpoint + keys only, no personal data). +# PUSH_DIR=.freezmo/push +# The daily sender is a script you schedule yourself (cron/systemd timer): +# node scripts/send-reminders.mjs +# PUSH_REMINDER_TITLE=Time to review +# PUSH_REMINDER_BODY=Your decks are waiting — a few due cards keep the streak alive. # ENCRYPTED SYNC STORAGE ------------------------------------------------------- # Opt-in sync uploads an opaque AES-GCM blob; the server can't read it and diff --git a/README.md b/README.md index 2a23873..d2f270a 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,17 @@ no database, no data leaving your machine except the requests you send to Ollama study sessions and homework guides; wizard drafts survive a reload or an OS kill; re-tapping the active tab scrolls to the top; an offline pill appears and generation is disabled with a reason until the connection is back. +- **Learning engine**: every answer is recorded (deck, activity, topic, correct/wrong, time) in an + IndexedDB log, and each activity gets a light **SM-2 schedule** — correct answers come back after + 1 day, then 3, then interval × ease; wrong ones after ten minutes. The Library shows a **Smart + review** card with the due count, a **Mistakes** count (activities whose latest answer was wrong — + they clear themselves once answered correctly) and today's **daily goal** progress. A **Progress** + screen (drawer) has the day streak, a five-week calendar, accuracy per subject and per deck and + the weakest topics. **Exam mode** (drawer) takes one or more decks, a question count and an + optional timer, hides hints and shows a per-deck/per-topic breakdown at the end. **Daily + reminders** appear in Settings only when the server has VAPID keys configured; subscriptions are + stored as endpoint + keys only, and `node scripts/send-reminders.mjs` (cron/systemd timer) does + the sending. - **Backup, sync and limits**: *Settings → Data & backup* exports **everything** (decks, folders, homework, progress, safe settings — never the API key) to one JSON file and imports a backup with a preview and a merge/replace choice; a gentle, dismissible nudge appears after 5 new decks or 14 @@ -414,6 +425,7 @@ client IP with a sliding window. Everything is configurable in `.env.local`: | `POST /api/grade` | 40 per 5 minutes | `RATE_LIMIT_GRADE` | | `GET /api/health` | 90 per 5 minutes | `RATE_LIMIT_HEALTH` | | `POST/GET/DELETE /api/sync` | 60 per 5 minutes | `RATE_LIMIT_SYNC` | +| `GET/POST/DELETE /api/push`, `POST /api/push/test` | 30 per 5 minutes | `RATE_LIMIT_PUSH` | | window for all of them | 5 minutes | `RATE_LIMIT_WINDOW_MS` | When a limit is hit the route answers **429** with a `Retry-After` header and a friendly message @@ -713,6 +725,10 @@ scripts/ | `POST /api/sync` | `{ id, envelope }` (opaque AES-GCM blob) | `{ updatedAt }` — 413 over `SYNC_MAX_BYTES`, 507 over `SYNC_TOTAL_BYTES` | | `GET /api/sync?id=` | – | `{ updatedAt, envelope }` or 404 | | `DELETE /api/sync?id=` | – | `{ ok: true }` (revokes the synced copy) | +| `GET /api/push` | – | `{ enabled, publicKey? }` — reminders are hidden when `enabled` is false | +| `POST /api/push` | `{ endpoint, keys }` | `{ ok, count }` (stored server-side, endpoint + keys only) | +| `DELETE /api/push?endpoint=` | – | `{ ok, count }` | +| `POST /api/push/test` | `{ endpoint }` | `{ ok: true }` or 502 when the push service refuses | All four routes accept an optional **`x-ollama-key`** header (the browser's own key; required in `user-key` auth mode). Missing keys answer **401** with `kind: "needs_key"`, a rejected key answers @@ -766,6 +782,12 @@ grading, regeneration and “add more” (and the assignment language for homewo `SYNC_DIR` (default `.freezmo/sync`), can't decrypt it, and never receives the passphrase or the key. Deleting the synced copy is one button in Settings (and works even on a device that can't unlock the data). +- **Answer history lives in IndexedDB** (`freezmo-learning`, versioned stores `answers` + `srs`), + because an event log would eventually fill `localStorage`; the log is capped at 20 000 answers + (oldest pruned). Backups and sync include it, so progress survives a device move. Push + subscriptions are the only reminder state on the server (`PUSH_DIR`, endpoint + keys only) and + are removed when you turn reminders off — or automatically when the push service reports them + gone. --- @@ -778,6 +800,7 @@ grading, regeneration and “add more” (and the assignment language for homewo | PDF uploaded but no activities about its content | The PDF is probably image-only (a scan). Freezmo renders such pages and sends them to the vision model — check the card note (“N scanned pages were turned into images”); if it says “No readable text”, the scan may be too low quality. | | Deck came back in English even though the notes are not | Make sure both model calls succeed (a failed vision step falls back to raw text). The prompts force the notes' language; if you still see translation, switch `TEXT_MODEL` to a stronger instruction-follower such as `gpt-oss:120b-cloud`. | | “You've run out of your free monthly Ollama usage” | The account's free monthly usage is used up — this is not a short-term rate limit. Upgrade the account at `ollama.com/settings`, or create a new account and paste its key in Settings (the button jumps straight to the field). “Ollama is busy” is the short-term variant: just press Retry. | +| Reminders are missing from Settings | They only appear when `VAPID_PUBLIC_KEY` and `VAPID_PRIVATE_KEY` are set in `.env.local` (generate with `npx web-push generate-vapid-keys`). On iPhone, add Freezmo to the Home Screen first — Safari tabs can't receive push. | | “Add your Ollama API key first” | Bring-your-own-key mode is active. Paste a key from on the key gate (or in the ⚙️ dialog) — it is stored only in your browser. | | “Ollama rejected this API key” | The key is invalid or revoked. Create a new one and paste it again; the check uses a real 1-token request, so a green tick means it truly works. | | “this model is not included in your free usage” | The key works, but the model is outside your plan. Use one of the ✅ models in the table above, or add credits at . | @@ -1036,6 +1059,21 @@ Things I chose while building, with the reasoning: phase-1 suite (38 checks, including the real route mapping against a stub Ollama returning a monthly-limit body, and a two-device sync round-trip). +41. **The learning log is an event log, so it lives in IndexedDB.** Decks and homework stay in + `localStorage` (small, always loaded), but answers + SRS schedules go to a versioned + `freezmo-learning` database with an in-memory mirror so React can read them synchronously via + `useSyncExternalStore`; writes update the mirror first and persist asynchronously, and the log + is capped at 20 000 answers. The schedule is deliberately simple (1 day → 3 days → interval × + ease; wrong = ten minutes, ease −0.2): predictable beats clever, and the mistakes set needs no + storage at all — it is *the latest answer per activity being wrong*, so it clears itself the + moment the activity is answered correctly. Review and exam sessions **freeze their item list at + start** (a bug the phase-2 suite caught: answering a mistake removed it from the live list and + collapsed the session mid-run). Push reminders are all-or-nothing: no VAPID keys → no UI, no + endpoints, nothing to misconfigure; `web-push` is the one added server dependency and the daily + send is an explicit cron/timer script. Verified by the phase-2 suite (42 checks: history and + SRS shapes, the review/mistakes sessions, stats math, exam with timer and breakdowns, the + VAPID-less hidden state and the configured subscribe/validate/test/delete path). + 14. **`gpt-oss:120b` does all content work — but it cannot read images** (`this model does not support image input`), so `VISION_MODEL` has to stay a multimodal model (`gemma4:31b-cloud`). Text-only inputs — pasted text, PDFs with a text layer, DOCX/PPTX/ODT — never touch the vision diff --git a/app/api/push/route.ts b/app/api/push/route.ts new file mode 100644 index 0000000..e7b8c78 --- /dev/null +++ b/app/api/push/route.ts @@ -0,0 +1,131 @@ +import { z } from "zod"; + +import { authErrorKind, authErrorMessage, getServerConfig, resolveAuth } from "@/lib/config"; +import { rejectCrossOrigin, rejectOversizedBody, requestLocale } from "@/lib/http-guard"; +import { translate } from "@/lib/i18n-dict"; +import { addSubscription, removeSubscription } from "@/lib/push-store"; +import { clientKey, rateLimit, rateLimitResponse } from "@/lib/rate-limit"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +/** + * Push reminder subscriptions. GET reports whether reminders are available + * (VAPID configured) and hands out the public key; POST/DELETE manage this + * browser's subscription. The feature is hidden client-side when `enabled` + * is false — no half-configured UI ever shows. + */ + +const subscriptionSchema = z.object({ + endpoint: z.string().url().max(2000), + keys: z.object({ + p256dh: z.string().min(16).max(400), + auth: z.string().min(8).max(200), + }), +}); + +export async function GET(request: Request) { + const locale = requestLocale(request); + const crossOrigin = rejectCrossOrigin(request); + if (crossOrigin) return crossOrigin; + + const config = getServerConfig(); + const limit = rateLimit(`push:${clientKey(request)}`, config.rateLimits.push, config.rateLimits.windowMs); + if (!limit.allowed) return rateLimitResponse(limit, locale); + + const auth = resolveAuth(config, request); + if ("error" in auth) { + return Response.json( + { error: authErrorMessage(locale, auth.error), kind: authErrorKind(auth.error) }, + { status: 401 }, + ); + } + + const enabled = Boolean(config.push.publicKey && config.push.privateKey); + return Response.json( + { enabled, publicKey: enabled ? config.push.publicKey : undefined }, + { headers: { "Cache-Control": "no-store" } }, + ); +} + +export async function POST(request: Request) { + const locale = requestLocale(request); + const crossOrigin = rejectCrossOrigin(request); + if (crossOrigin) return crossOrigin; + + const config = getServerConfig(); + const oversized = rejectOversizedBody(request, 64 * 1024); + if (oversized) return oversized; + + const limit = rateLimit(`push:${clientKey(request)}`, config.rateLimits.push, config.rateLimits.windowMs); + if (!limit.allowed) return rateLimitResponse(limit, locale); + + const auth = resolveAuth(config, request); + if ("error" in auth) { + return Response.json( + { error: authErrorMessage(locale, auth.error), kind: authErrorKind(auth.error) }, + { status: 401 }, + ); + } + + if (!config.push.publicKey || !config.push.privateKey) { + return Response.json( + { error: translate(locale, "server.pushDisabled"), kind: "push_disabled" }, + { status: 404 }, + ); + } + + let payload: unknown; + try { + payload = await request.json(); + } catch { + return Response.json({ error: translate(locale, "server.bodyNotJson") }, { status: 400 }); + } + + const parsed = subscriptionSchema.safeParse(payload); + if (!parsed.success) { + return Response.json( + { + error: translate(locale, "server.invalidRequest", { + detail: parsed.error.issues + .slice(0, 3) + .map((issue) => `${issue.path.join(".") || "body"} ${issue.message}`) + .join("; "), + }), + }, + { status: 400 }, + ); + } + + const count = await addSubscription(config.push.dir, parsed.data); + return Response.json({ ok: true, count }, { headers: { "Cache-Control": "no-store" } }); +} + +export async function DELETE(request: Request) { + const locale = requestLocale(request); + const crossOrigin = rejectCrossOrigin(request); + if (crossOrigin) return crossOrigin; + + const config = getServerConfig(); + const limit = rateLimit(`push:${clientKey(request)}`, config.rateLimits.push, config.rateLimits.windowMs); + if (!limit.allowed) return rateLimitResponse(limit, locale); + + const auth = resolveAuth(config, request); + if ("error" in auth) { + return Response.json( + { error: authErrorMessage(locale, auth.error), kind: authErrorKind(auth.error) }, + { status: 401 }, + ); + } + + const endpoint = new URL(request.url).searchParams.get("endpoint") ?? ""; + if (!endpoint) { + return Response.json( + { error: translate(locale, "server.invalidRequest", { detail: "endpoint" }) }, + { status: 400 }, + ); + } + + const count = await removeSubscription(config.push.dir, endpoint); + return Response.json({ ok: true, count }, { headers: { "Cache-Control": "no-store" } }); +} diff --git a/app/api/push/test/route.ts b/app/api/push/test/route.ts new file mode 100644 index 0000000..b98ab04 --- /dev/null +++ b/app/api/push/test/route.ts @@ -0,0 +1,89 @@ +import webpush from "web-push"; +import { z } from "zod"; + +import { authErrorKind, authErrorMessage, getServerConfig, resolveAuth } from "@/lib/config"; +import { rejectCrossOrigin, rejectOversizedBody, requestLocale } from "@/lib/http-guard"; +import { translate } from "@/lib/i18n-dict"; +import { readSubscriptions, removeSubscription } from "@/lib/push-store"; +import { clientKey, rateLimit, rateLimitResponse } from "@/lib/rate-limit"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +const bodySchema = z.object({ endpoint: z.string().url().max(2000) }); + +/** Sends one test notification to this browser's subscription. */ +export async function POST(request: Request) { + const locale = requestLocale(request); + const crossOrigin = rejectCrossOrigin(request); + if (crossOrigin) return crossOrigin; + + const config = getServerConfig(); + const oversized = rejectOversizedBody(request, 16 * 1024); + if (oversized) return oversized; + + const limit = rateLimit(`push:${clientKey(request)}`, config.rateLimits.push, config.rateLimits.windowMs); + if (!limit.allowed) return rateLimitResponse(limit, locale); + + const auth = resolveAuth(config, request); + if ("error" in auth) { + return Response.json( + { error: authErrorMessage(locale, auth.error), kind: authErrorKind(auth.error) }, + { status: 401 }, + ); + } + + if (!config.push.publicKey || !config.push.privateKey) { + return Response.json( + { error: translate(locale, "server.pushDisabled"), kind: "push_disabled" }, + { status: 404 }, + ); + } + + let payload: unknown; + try { + payload = await request.json(); + } catch { + return Response.json({ error: translate(locale, "server.bodyNotJson") }, { status: 400 }); + } + const parsed = bodySchema.safeParse(payload); + if (!parsed.success) { + return Response.json({ error: translate(locale, "server.invalidRequest", { detail: "endpoint" }) }, { status: 400 }); + } + + const subscriptions = await readSubscriptions(config.push.dir); + const target = subscriptions.find((entry) => entry.endpoint === parsed.data.endpoint); + if (!target) { + return Response.json( + { error: translate(locale, "server.pushNotSubscribed"), kind: "not_subscribed" }, + { status: 404 }, + ); + } + + webpush.setVapidDetails(config.push.subject, config.push.publicKey, config.push.privateKey); + try { + await webpush.sendNotification( + { endpoint: target.endpoint, keys: target.keys }, + JSON.stringify({ + title: translate(locale, "server.pushReminderTitle"), + body: translate(locale, "server.pushReminderBody"), + url: "/", + }), + ); + } catch (error) { + const status = (error as { statusCode?: number }).statusCode; + if (status === 404 || status === 410) { + await removeSubscription(config.push.dir, target.endpoint); + return Response.json( + { error: translate(locale, "server.pushGone"), kind: "gone" }, + { status: 410 }, + ); + } + return Response.json( + { error: translate(locale, "server.pushSendFailed"), kind: "push_failed" }, + { status: 502 }, + ); + } + + return Response.json({ ok: true }, { headers: { "Cache-Control": "no-store" } }); +} diff --git a/app/exam/page.tsx b/app/exam/page.tsx new file mode 100644 index 0000000..9db4110 --- /dev/null +++ b/app/exam/page.tsx @@ -0,0 +1,26 @@ +import { ApiKeyGate } from "@/components/ApiKeyGate"; +import { AppHeader } from "@/components/AppHeader"; +import { ExamScreen } from "@/components/exam/ExamScreen"; +import { MobileSidebar, Sidebar, SidebarOffset } from "@/components/Sidebar"; + +export const metadata = { + title: "Exam mode", +}; + +export default function ExamPage() { + return ( + +
+ + + +
+ +
+ +
+
+
+
+ ); +} diff --git a/app/review/page.tsx b/app/review/page.tsx new file mode 100644 index 0000000..caae6cb --- /dev/null +++ b/app/review/page.tsx @@ -0,0 +1,22 @@ +import { ApiKeyGate } from "@/components/ApiKeyGate"; +import { ReviewClient } from "@/components/ReviewClient"; + +export const metadata = { + title: "Review", +}; + +export default async function ReviewPage({ + searchParams, +}: { + searchParams: Promise<{ [key: string]: string | string[] | undefined }>; +}) { + const params = await searchParams; + return ( + + + + ); +} diff --git a/app/stats/page.tsx b/app/stats/page.tsx new file mode 100644 index 0000000..d0d6c7d --- /dev/null +++ b/app/stats/page.tsx @@ -0,0 +1,26 @@ +import { ApiKeyGate } from "@/components/ApiKeyGate"; +import { AppHeader } from "@/components/AppHeader"; +import { MobileSidebar, Sidebar, SidebarOffset } from "@/components/Sidebar"; +import { StatsScreen } from "@/components/StatsScreen"; + +export const metadata = { + title: "Progress", +}; + +export default function StatsPage() { + return ( + +
+ + + +
+ +
+ +
+
+
+
+ ); +} diff --git a/components/DataSection.tsx b/components/DataSection.tsx index 235ded2..fb6241a 100644 --- a/components/DataSection.tsx +++ b/components/DataSection.tsx @@ -50,9 +50,9 @@ export function DataSection() { if (fileRef.current) fileRef.current.value = ""; }; - const apply = (mode: "merge" | "replace") => { + const apply = async (mode: "merge" | "replace") => { if (!pending) return; - const done = applyImport(pending.backup, mode); + const done = await applyImport(pending.backup, mode); setNotice(t("settings.importDone", { decks: done.decks, homework: done.homework })); reset(); }; @@ -69,7 +69,7 @@ export function DataSection() {

{t("settings.dataNote")}

- - + ))} +
+ + + {pushSupported && status?.enabled ? ( +
+

{t("settings.reminders")}

+

{t("settings.remindersNote")}

+ {iosNeedsInstall ? ( +

+ {t("settings.remindersIos")} +

+ ) : null} +
+ {subscribed ? ( + <> + + + + ) : ( + + )} +
+ {notice ?

{notice}

: null} +
+ ) : null} + + ); +} diff --git a/components/LibraryScreen.tsx b/components/LibraryScreen.tsx index b201315..9f37127 100644 --- a/components/LibraryScreen.tsx +++ b/components/LibraryScreen.tsx @@ -9,10 +9,13 @@ import { FolderBar } from "@/components/FolderBar"; import { HomeworkCard } from "@/components/HomeworkCard"; import { FolderIcon, UploadIcon } from "@/components/icons"; import { SegmentedPill } from "@/components/SegmentedPill"; -import { Button, EmptyState } from "@/components/ui"; +import { Button, EmptyState, ProgressBar } from "@/components/ui"; import { folderStore, renameFolder } from "@/lib/folders"; import { deleteHomework, homeworkStore, sortHomework } from "@/lib/homework"; +import { learningStore } from "@/lib/history"; import { useI18n } from "@/lib/i18n"; +import { computeStats, dueCount, mistakeCount } from "@/lib/learning"; +import { settingsStore } from "@/lib/settings"; import { ALL_FOLDERS, folderSelection, useSelectedFolder } from "@/lib/nav"; import { springGentle } from "@/lib/motion"; import { UNORGANIZED_ID, type Deck } from "@/lib/schemas"; @@ -52,6 +55,26 @@ export function LibraryScreen({ initialTab = "decks" }: { initialTab?: "decks" | useEffect(() => { setNudgeDue(backupNudgeDue(decks.length)); }, [decks.length]); + + /* Learning strip: SRS due count, open mistakes, today's goal. */ + const answers = useSyncExternalStore( + learningStore.subscribe, + learningStore.getAnswersSnapshot, + learningStore.getServerAnswers, + ); + const srs = useSyncExternalStore( + learningStore.subscribe, + learningStore.getSrsSnapshot, + learningStore.getServerSrs, + ); + const dailyGoal = useSyncExternalStore( + settingsStore.subscribe, + () => settingsStore.getSnapshot().dailyGoal, + () => 20, + ); + const due = useMemo(() => dueCount(srs), [srs]); + const mistakes = useMemo(() => mistakeCount(answers), [answers]); + const todayCount = useMemo(() => computeStats(answers).todayCount, [answers]); const importRef = useRef(null); const sorted = useMemo(() => sortDecks(decks), [decks]); @@ -233,6 +256,47 @@ export function LibraryScreen({ initialTab = "decks" }: { initialTab?: "decks" | exit={isPhone ? { opacity: 0, y: -6, transition: { duration: 0.16 } } : undefined} transition={springGentle} > + {/* learning: review due, mistakes, exam, daily goal ---------------- */} +
+
+

+ {t("review.cardTitle")} +

+

+ {t("library.learningToday", { n: todayCount, goal: dailyGoal })} +

+
+ +
+ 0 ? "btn-primary" : "btn-secondary pointer-events-none opacity-60", + )} + > + {t("review.title")} · {plural(due, "review.due")} + + 0 ? "btn-secondary" : "btn-secondary pointer-events-none opacity-60", + )} + > + {t("review.mistakesTitle")} · {plural(mistakes, "review.mistakes")} + + + {t("exam.title")} + +
+
+ {/* gentle backup nudge — dismissible, never blocks ---------------- */} {nudgeDue ? (
diff --git a/components/ReviewClient.tsx b/components/ReviewClient.tsx new file mode 100644 index 0000000..28f2892 --- /dev/null +++ b/components/ReviewClient.tsx @@ -0,0 +1,117 @@ +"use client"; + +import { useEffect, useMemo, useState, useSyncExternalStore } from "react"; +import { useRouter } from "next/navigation"; + +import { SessionFeed, type SessionItem } from "@/components/SessionFeed"; +import { Button } from "@/components/ui"; +import { haptic } from "@/lib/haptics"; +import { ensureLearningHydrated, learningStore, recordAnswer } from "@/lib/history"; +import { useI18n } from "@/lib/i18n"; +import { dueItems, mistakeItems, type ReviewItem } from "@/lib/learning"; + +/** + * Review / mistakes session: built from due items (SRS) across every deck, or + * from the activities whose latest answer was wrong. Every answer is recorded, + * so mistakes clear themselves once answered correctly. + */ +export function ReviewClient({ mistakes, deckId }: { mistakes: boolean; deckId?: string }) { + const { t } = useI18n(); + const router = useRouter(); + const answers = useSyncExternalStore( + learningStore.subscribe, + learningStore.getAnswersSnapshot, + learningStore.getServerAnswers, + ); + const srs = useSyncExternalStore( + learningStore.subscribe, + learningStore.getSrsSnapshot, + learningStore.getServerSrs, + ); + const [ready, setReady] = useState(false); + /** Frozen at session start: answering must not shrink the live queue. */ + const [session, setSession] = useState(null); + + useEffect(() => { + void ensureLearningHydrated().then(() => setReady(true)); + }, []); + + const items = useMemo( + () => (mistakes ? mistakeItems(answers, deckId) : dueItems(srs)), + [mistakes, deckId, answers, srs], + ); + + useEffect(() => { + if (!ready || session || items.length === 0) return; + setSession(items); + }, [ready, session, items]); + + const title = mistakes + ? deckId + ? t("review.mistakesDeck") + : t("review.mistakesTitle") + : t("review.title"); + + if (!ready) { + return ( +
+
+
+
+
+
+ ); + } + + if (!session) { + return ( +
+
+

{title}

+

+ {mistakes ? t("review.noMistakes") : t("review.nothingDue")} +

+ +
+
+ ); + } + + const sessionItems: SessionItem[] = session.map(({ deck, activity }) => ({ deck, activity })); + + return ( + router.push("/")} + onAnswered={(item, correct) => { + haptic(correct ? "success" : "error"); + recordAnswer({ + deckId: item.deck.id, + deckTitle: item.deck.title, + activityId: item.activity.id, + type: item.activity.type, + topic: item.activity.topic, + correct, + }); + }} + renderResults={({ correct, total, score }) => ( +
+

{title}

+

{score}%

+

+ {t("results.gotOf", { correct, total, retry: "" })} +

+
+ +
+
+ )} + /> + ); +} diff --git a/components/SessionFeed.tsx b/components/SessionFeed.tsx new file mode 100644 index 0000000..ab5afcb --- /dev/null +++ b/components/SessionFeed.tsx @@ -0,0 +1,232 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; + +import { ActivityRenderer } from "@/components/activities"; +import { XIcon } from "@/components/icons"; +import { ProgressBar } from "@/components/ui"; +import type { Activity, Deck } from "@/lib/schemas"; +import { useI18n } from "@/lib/i18n"; +import { useWakeLock } from "@/lib/use-wake-lock"; +import { cn } from "@/lib/utils"; + +export type SessionItem = { deck: Deck; activity: Activity }; +export type SessionResult = { correct: boolean; userAnswer?: string }; +export type SessionRenderResults = (state: { + results: Record; + items: SessionItem[]; + correct: number; + total: number; + score: number; +}) => React.ReactNode; + +/** + * The scroll-snap activity feed shared by review, mistakes and exam sessions. + * Same mechanics as the study feed (remount per run, scroll tracking, keyboard + * navigation) but with a pluggable results slide and an optional timer. + */ +export function SessionFeed({ + title, + items, + timerSeconds, + noHints = false, + testId, + onExit, + onAnswered, + renderResults, +}: { + title: string; + items: SessionItem[]; + timerSeconds?: number; + noHints?: boolean; + testId?: string; + onExit: () => void; + onAnswered?: (item: SessionItem, correct: boolean, meta?: { userAnswer?: string }) => void; + renderResults: SessionRenderResults; +}) { + const { t } = useI18n(); + useWakeLock(true); + const [index, setIndex] = useState(0); + const [results, setResults] = useState>({}); + const [runId] = useState(0); + const [remaining, setRemaining] = useState(timerSeconds ?? 0); + const scrollRef = useRef(null); + + const total = items.length; + const slideCount = total + 1; + + const goTo = useCallback( + (target: number) => { + const container = scrollRef.current; + if (!container) return; + const clamped = Math.max(0, Math.min(target, slideCount - 1)); + container.scrollTo({ top: clamped * container.clientHeight, behavior: "smooth" }); + }, + [slideCount], + ); + + const next = useCallback(() => goTo(index + 1), [goTo, index]); + + useEffect(() => { + const container = scrollRef.current; + if (!container) return; + let frame = 0; + const onScroll = () => { + cancelAnimationFrame(frame); + frame = requestAnimationFrame(() => { + const height = container.clientHeight || 1; + const current = Math.round(container.scrollTop / height); + setIndex((previous) => (previous === current ? previous : current)); + }); + }; + container.addEventListener("scroll", onScroll, { passive: true }); + return () => { + container.removeEventListener("scroll", onScroll); + cancelAnimationFrame(frame); + }; + }, [runId]); + + useEffect(() => { + const onKey = (event: KeyboardEvent) => { + const target = event.target as HTMLElement | null; + if (target && (/^(INPUT|TEXTAREA|SELECT)$/.test(target.tagName) || target.isContentEditable)) return; + if (event.metaKey || event.ctrlKey || event.altKey) return; + if (event.key === "ArrowDown" || event.key === "PageDown" || event.key === "j") { + event.preventDefault(); + next(); + } else if (event.key === "ArrowUp" || event.key === "PageUp" || event.key === "k") { + event.preventDefault(); + goTo(index - 1); + } + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [goTo, index, next]); + + // Countdown (exam mode); reaching zero jumps to the results slide. + useEffect(() => { + if (!timerSeconds) return; + const interval = window.setInterval(() => { + setRemaining((value) => { + if (value <= 1) { + window.clearInterval(interval); + return 0; + } + return value - 1; + }); + }, 1000); + return () => window.clearInterval(interval); + }, [timerSeconds]); + + useEffect(() => { + if (timerSeconds && remaining === 0) goTo(slideCount - 1); + }, [remaining, timerSeconds, goTo, slideCount]); + + const handleAnswered = useCallback( + (item: SessionItem, correct: boolean, meta?: { userAnswer?: string }) => { + setResults((current) => { + if (current[item.activity.id]) return current; + return { ...current, [item.activity.id]: { correct, userAnswer: meta?.userAnswer } }; + }); + onAnswered?.(item, correct, meta); + }, + [onAnswered], + ); + + const finished = index >= total; + const correct = items.filter((item) => results[item.activity.id]?.correct).length; + const score = total > 0 ? Math.round((correct / total) * 100) : 0; + + return ( +
+
+
+
+ +
+ +
+ {timerSeconds ? ( + + {formatClock(remaining)} + + ) : null} +
+
+ {title} + + {Math.min(index + 1, total)}/{total} + +
+
+
+ +
+ {items.map((item, itemIndex) => ( +
+
+ handleAnswered(item, correct, meta)} + onNext={next} + isLast={itemIndex === total - 1} + nextLabel={itemIndex === total - 1 ? t("review.seeResults") : undefined} + /> +
+
+ ))} + +
+
+ {finished + ? renderResults({ results, items, correct, total, score }) + : ( +
+

{t("review.results")}

+

{t("review.finishFirst")}

+
+ )} +
+
+
+
+ ); +} + +function formatClock(seconds: number): string { + const safe = Math.max(0, seconds); + const minutes = Math.floor(safe / 60); + return `${minutes}:${String(safe % 60).padStart(2, "0")}`; +} diff --git a/components/SettingsDialog.tsx b/components/SettingsDialog.tsx index 3b60882..5259959 100644 --- a/components/SettingsDialog.tsx +++ b/components/SettingsDialog.tsx @@ -9,6 +9,7 @@ import { modelErrorCopy } from "@/lib/model-errors"; import { springSheet } from "@/lib/motion"; import { ModelErrorActions } from "@/components/ModelErrorActions"; import { DataSection } from "@/components/DataSection"; +import { LearningSection } from "@/components/LearningSection"; import { SyncSection } from "@/components/SyncSection"; import { clearApiKey, saveSettings, settingsStore } from "@/lib/settings"; import { useBackClose } from "@/lib/use-back-close"; @@ -393,6 +394,7 @@ export function SettingsDialog({ {/* ------------------------------------------- data & sync */} + @@ -708,6 +710,7 @@ export function SettingsDialog({ {/* ------------------------------------------- data & sync */} + diff --git a/components/Sidebar.tsx b/components/Sidebar.tsx index 790a5da..5cea6ee 100644 --- a/components/Sidebar.tsx +++ b/components/Sidebar.tsx @@ -7,6 +7,8 @@ import { AnimatePresence, motion } from "framer-motion"; import { ChevronLeftIcon, ChevronRightIcon, + CompassIcon, + FlameIcon, FolderIcon, GearIcon, LayersIcon, @@ -294,6 +296,24 @@ function NavContent({ {/* footer ---------------------------------------------------------- */}
+ + + {!collapsed ? {t("nav.stats")} : null} + + + + {!collapsed ? {t("nav.exam")} : null} + +
+ ); + } + + // Calendar: five weeks, columns aligned to weekdays (Monday first). + const today = new Date(); + const weekdayIndex = (today.getDay() + 6) % 7; + const start = new Date(today); + start.setDate(today.getDate() - weekdayIndex - 28); + const byDay = new Map(stats.days.map((entry) => [entry.day, entry])); + const cells = Array.from({ length: 35 }, (_, index) => { + const date = new Date(start); + date.setDate(start.getDate() + index); + const key = dayKey(date.getTime()); + return { key, date, future: date.getTime() > today.getTime() + 86_400_000, entry: byDay.get(key) }; + }); + const weekdayLabels = Array.from({ length: 7 }, (_, index) => { + const date = new Date(start); + date.setDate(start.getDate() + index); + return new Intl.DateTimeFormat(locale, { weekday: "narrow" }).format(date); + }); + const intensity = (count: number) => + count === 0 + ? "bg-[var(--surface-sunken)]" + : count <= 2 + ? "bg-sky-200/80 dark:bg-sky-400/20" + : count <= 5 + ? "bg-sky-400/70 dark:bg-sky-400/40" + : "grad-ice"; + + return ( +
+
+

+ {t("stats.title")} +

+

{t("stats.subtitle")}

+
+ +
+
+

{t("stats.streak")}

+

{stats.streak}

+

{plural(stats.total, "stats.answers")}

+
+
+

{t("stats.today")}

+

+ {t("stats.todayOf", { n: stats.todayCount, goal: dailyGoal })} +

+ + {stats.todayCount >= dailyGoal ? ( +

{t("stats.goalDone")}

+ ) : null} +
+
+

{t("stats.accuracy")}

+

+ {Math.round(stats.accuracy * 100)}% +

+

+ {t("stats.correctOf", { correct: stats.correct, total: stats.total })} +

+
+
+ +
+
+

{t("stats.calendar")}

+

+ {t("stats.legendLess")} + + + + + {t("stats.legendMore")} +

+
+
+ {weekdayLabels.map((label, index) => ( + + {label} + + ))} + {cells.map((cell) => ( + + ))} +
+
+ + {stats.subjects.length > 0 ? ( +
+

{t("stats.bySubject")}

+ {stats.subjects.map((entry) => ( +
+ {entry.label} + + {Math.round((entry.correct / Math.max(1, entry.answered)) * 100)}% ·{" "} + {t("stats.correctOf", { correct: entry.correct, total: entry.answered })} + +
+ ))} +
+ ) : null} + +
+

{t("stats.byDeck")}

+ {stats.decks.map((entry) => ( +
+ {entry.label} + + {Math.round((entry.correct / Math.max(1, entry.answered)) * 100)}% ·{" "} + {t("stats.correctOf", { correct: entry.correct, total: entry.answered })} + +
+ ))} +
+ + {stats.weakestTopics.length > 0 ? ( +
+

{t("stats.weakest")}

+ {stats.weakestTopics.map((entry) => ( +
+ {entry.label} + + {Math.round((entry.correct / Math.max(1, entry.answered)) * 100)}% + +
+ ))} +
+ ) : null} + +
+ + {t("review.title")} + + + {t("exam.title")} + +
+
+ ); +} diff --git a/components/StudyFeed.tsx b/components/StudyFeed.tsx index 28722a4..f2766cb 100644 --- a/components/StudyFeed.tsx +++ b/components/StudyFeed.tsx @@ -9,6 +9,7 @@ import { celebrate } from "@/lib/confetti"; import { ApiError, generateDeck } from "@/lib/client-api"; import { materialiseActivities } from "@/lib/deck-ops"; import { haptic } from "@/lib/haptics"; +import { recordAnswer } from "@/lib/history"; import { useI18n } from "@/lib/i18n"; import type { Activity, Deck } from "@/lib/schemas"; import { recordAttempt, upsertDeck } from "@/lib/storage"; @@ -124,6 +125,15 @@ export function StudyFeed({ deck }: { deck: Deck }) { if (current[activity.id]) return current; // first answer counts return { ...current, [activity.id]: { correct, userAnswer: meta?.userAnswer } }; }); + // Every answer feeds the history, the SRS schedule and the mistakes set. + recordAnswer({ + deckId: currentDeck.id, + deckTitle: currentDeck.title, + activityId: activity.id, + type: activity.type, + topic: activity.topic, + correct, + }); if (correct) { haptic("success"); const nextStreak = streak + 1; @@ -136,7 +146,7 @@ export function StudyFeed({ deck }: { deck: Deck }) { setStreak(0); } }, - [streak], + [currentDeck.id, currentDeck.title, streak], ); const missed = useMemo( diff --git a/components/activities/ShortAnswer.tsx b/components/activities/ShortAnswer.tsx index e62f9c8..9aa4326 100644 --- a/components/activities/ShortAnswer.tsx +++ b/components/activities/ShortAnswer.tsx @@ -16,6 +16,7 @@ import { Hint, PromptCard, QuestionText, + useNoHints, type ActivityComponentProps, } from "./shared"; @@ -30,6 +31,7 @@ type GradeState = export function ShortAnswer({ activity, active, language, onAnswered, onNext, isLast, nextLabel }: ActivityComponentProps) { const { t } = useI18n(); + const noHints = useNoHints(); const [value, setValue] = useState(""); const [state, setState] = useState({ status: "idle" }); const textareaRef = useRef(null); @@ -129,9 +131,11 @@ export function ShortAnswer({ activity, active, language, onAnswered, onNext, is {t("common.retry")} - + {!noHints ? ( + + ) : null}
) : null} diff --git a/components/activities/index.tsx b/components/activities/index.tsx index 2836f14..8124fda 100644 --- a/components/activities/index.tsx +++ b/components/activities/index.tsx @@ -8,7 +8,7 @@ import { Ordering } from "./Ordering"; import { ShortAnswer } from "./ShortAnswer"; import { TrueFalse } from "./TrueFalse"; import { t } from "@/lib/i18n"; -import { type ActivityComponentProps } from "./shared"; +import { NoHintsProvider, type ActivityComponentProps } from "./shared"; export type { ActivityComponentProps } from "./shared"; export { TYPE_META } from "./shared"; @@ -18,28 +18,32 @@ export { TYPE_META } from "./shared"; * Every activity type is a self-contained React component. */ export function ActivityRenderer(props: ActivityComponentProps) { - const { activity } = props; + const { activity, noHints } = props; - switch (activity.type) { - case "flashcard": - return ; - case "multiple_choice": - return ; - case "true_false": - return ; - case "fill_blank": - return ; - case "matching": - return ; - case "ordering": - return ; - case "short_answer": - return ; - default: { - // Exhaustiveness guard: adding a new activity type without a component - // becomes a TypeScript error. - const never: never = activity; - return

{t("activity.unsupported", { json: JSON.stringify(never) })}

; + const rendered = (() => { + switch (activity.type) { + case "flashcard": + return ; + case "multiple_choice": + return ; + case "true_false": + return ; + case "fill_blank": + return ; + case "matching": + return ; + case "ordering": + return ; + case "short_answer": + return ; + default: { + // Exhaustiveness guard: adding a new activity type without a component + // becomes a TypeScript error. + const never: never = activity; + return

{t("activity.unsupported", { json: JSON.stringify(never) })}

; + } } - } + })(); + + return {rendered}; } diff --git a/components/activities/shared.tsx b/components/activities/shared.tsx index 0a31592..be14f52 100644 --- a/components/activities/shared.tsx +++ b/components/activities/shared.tsx @@ -1,6 +1,7 @@ "use client"; import { motion, useReducedMotion } from "framer-motion"; +import { createContext, useContext } from "react"; import { useI18n } from "@/lib/i18n"; import type { Activity } from "@/lib/schemas"; @@ -21,6 +22,8 @@ export type ActivityComponentProps = { isLast: boolean; /** Overrides the feedback button label (the homework guide uses this). */ nextLabel?: string; + /** Exam mode: hide hint rows and answer-reveal affordances. */ + noHints?: boolean; }; export const TYPE_META: Record = { @@ -213,5 +216,22 @@ export function FeedbackPanel({ /** Small hint row under a question, e.g. "Tap a term, then its definition". */ export function Hint({ children }: { children: React.ReactNode }) { + const noHints = useNoHints(); + if (noHints) return null; return

{children}

; } + +/** + * Exam mode sets this through ActivityRenderer's `noHints` prop so the + * affordances that give the answer away disappear without touching every + * activity component. + */ +const NoHintsContext = createContext(false); + +export function NoHintsProvider({ value, children }: { value: boolean; children: React.ReactNode }) { + return {children}; +} + +export function useNoHints(): boolean { + return useContext(NoHintsContext); +} diff --git a/components/exam/ExamScreen.tsx b/components/exam/ExamScreen.tsx new file mode 100644 index 0000000..c35a84f --- /dev/null +++ b/components/exam/ExamScreen.tsx @@ -0,0 +1,254 @@ +"use client"; + +import { useMemo, useState, useSyncExternalStore } from "react"; +import { useRouter } from "next/navigation"; + +import { SessionFeed, type SessionItem } from "@/components/SessionFeed"; +import { Button } from "@/components/ui"; +import { haptic } from "@/lib/haptics"; +import { recordAnswer } from "@/lib/history"; +import { useI18n } from "@/lib/i18n"; +import { deckStore } from "@/lib/storage"; +import { cn } from "@/lib/utils"; + +const COUNTS = [5, 10, 15, 20]; +const TIMERS = [0, 300, 600, 900]; + +/** + * Exam mode: pick one or more decks, a question count and an optional timer, + * then answer without hints. Results show the score plus a per-deck and + * per-topic breakdown; every wrong answer lands in the mistakes set (the + * normal history does that automatically). + */ +export function ExamScreen() { + const { t } = useI18n(); + const router = useRouter(); + const decks = useSyncExternalStore(deckStore.subscribe, deckStore.getSnapshot, deckStore.getServerSnapshot); + const [selected, setSelected] = useState>(new Set()); + const [count, setCount] = useState(10); + const [timer, setTimer] = useState(0); + const [running, setRunning] = useState(null); + + const pool = useMemo( + () => + decks + .filter((deck) => selected.has(deck.id)) + .flatMap((deck) => deck.activities.map((activity) => ({ deck, activity }))), + [decks, selected], + ); + const effectiveCount = Math.min(count, pool.length); + + const toggle = (id: string) => { + haptic("toggle"); + setSelected((current) => { + const next = new Set(current); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + }; + + const start = () => { + const shuffled = [...pool]; + for (let index = shuffled.length - 1; index > 0; index--) { + const swap = Math.floor(Math.random() * (index + 1)); + [shuffled[index], shuffled[swap]] = [shuffled[swap], shuffled[index]]; + } + setRunning(shuffled.slice(0, effectiveCount)); + }; + + if (running) { + return ( + setRunning(null)} + onAnswered={(item, correct) => { + haptic(correct ? "success" : "error"); + recordAnswer({ + deckId: item.deck.id, + deckTitle: item.deck.title, + activityId: item.activity.id, + type: item.activity.type, + topic: item.activity.topic, + correct, + }); + }} + renderResults={({ results, items, correct, total, score }) => { + const byDeck = new Map(); + const byTopic = new Map(); + for (const item of items) { + const result = results[item.activity.id]; + if (!result) continue; + const deck = byDeck.get(item.deck.id) ?? { title: item.deck.title, answered: 0, correct: 0 }; + deck.answered += 1; + if (result.correct) deck.correct += 1; + byDeck.set(item.deck.id, deck); + if (item.activity.topic) { + const topic = byTopic.get(item.activity.topic) ?? { answered: 0, correct: 0 }; + topic.answered += 1; + if (result.correct) topic.correct += 1; + byTopic.set(item.activity.topic, topic); + } + } + return ( +
+
+

+ {t("exam.results")} +

+

{score}%

+

+ {t("results.gotOf", { correct, total, retry: "" })} +

+
+ +
+

+ {t("exam.byDeck")} +

+ {[...byDeck.entries()].map(([id, entry]) => ( +
+ {entry.title} + + {entry.correct}/{entry.answered} + +
+ ))} +
+ + {byTopic.size > 0 ? ( +
+

+ {t("exam.byTopic")} +

+ {[...byTopic.entries()].map(([topic, entry]) => ( +
+ {topic} + + {entry.correct}/{entry.answered} + +
+ ))} +
+ ) : null} + +
+ + {correct < total ? ( + + ) : null} +
+
+ ); + }} + /> + ); + } + + return ( +
+
+

+ {t("exam.title")} +

+

{t("exam.subtitle")}

+
+ +
+

{t("exam.pickDecks")}

+ {decks.length === 0 ? ( +

{t("exam.noDecks")}

+ ) : ( +
    + {decks.map((deck) => { + const active = selected.has(deck.id); + return ( +
  • + +
  • + ); + })} +
+ )} +
+ +
+

{t("exam.questions")}

+
+ {COUNTS.map((option) => ( + + ))} +
+

{t("exam.timer")}

+
+ {TIMERS.map((option) => ( + + ))} +
+
+ + +
+ ); +} diff --git a/lib/backup.ts b/lib/backup.ts index 80152d0..8240697 100644 --- a/lib/backup.ts +++ b/lib/backup.ts @@ -14,8 +14,9 @@ import { z } from "zod"; import { applyTheme } from "@/components/ThemeToggle"; import { replaceFolders, folderStore } from "./folders"; import { homeworkStore, replaceHomework } from "./homework"; +import { ensureLearningHydrated, learningStore, replaceLearning } from "./history"; import { setLocale, t } from "./i18n"; -import { folderSchema, deckSchema, homeworkRecordSchema } from "./schemas"; +import { folderSchema, deckSchema, homeworkRecordSchema, answerRecordSchema, srsRecordSchema } from "./schemas"; import { saveSettings, settingsStore } from "./settings"; import { deckStore, replaceDecks } from "./storage"; @@ -25,12 +26,16 @@ const settingsSchema = z.object({ haptics: z.boolean().optional(), locale: z.enum(["en", "cs"]).optional(), theme: z.enum(["light", "dark"]).optional(), + dailyGoal: z.number().int().min(1).max(500).optional(), }); export const backupDataSchema = z.object({ decks: z.array(deckSchema).max(2000), folders: z.array(folderSchema).max(500), homework: z.array(homeworkRecordSchema).max(2000), + /** Answer log and SRS schedule (IndexedDB). Optional for old backups. */ + history: z.array(answerRecordSchema).max(20_000).optional(), + srs: z.array(srsRecordSchema).max(50_000).optional(), }); export const backupSchema = z.object({ @@ -47,7 +52,8 @@ export type BackupFile = z.infer; /* Building and downloading */ /* ------------------------------------------------------------------ */ -export function buildBackup(): BackupFile { +export async function buildBackup(): Promise { + await ensureLearningHydrated(); return { app: "freezmo", kind: "backup", @@ -57,8 +63,11 @@ export function buildBackup(): BackupFile { decks: deckStore.getSnapshot(), folders: folderStore.getSnapshot(), homework: homeworkStore.getSnapshot(), + history: learningStore.getAnswersSnapshot(), + srs: learningStore.getSrsSnapshot(), settings: { haptics: settingsStore.getSnapshot().haptics, + dailyGoal: settingsStore.getSnapshot().dailyGoal, locale: (window.localStorage.getItem("freezmo.locale.v1") as "en" | "cs" | null) ?? undefined, theme: (window.localStorage.getItem("freezmo.theme") as "light" | "dark" | null) ?? undefined, }, @@ -66,8 +75,8 @@ export function buildBackup(): BackupFile { }; } -export function downloadBackup(): BackupFile { - const backup = buildBackup(); +export async function downloadBackup(): Promise { + const backup = await buildBackup(); const blob = new Blob([JSON.stringify(backup, null, 2)], { type: "application/json" }); const url = URL.createObjectURL(blob); const anchor = document.createElement("a"); @@ -140,7 +149,10 @@ export function previewImport(backup: BackupFile): ImportPreview { } /** Applies a backup; the API key is never touched (it is not in the file). */ -export function applyImport(backup: BackupFile, mode: "merge" | "replace"): { decks: number; homework: number } { +export async function applyImport( + backup: BackupFile, + mode: "merge" | "replace", +): Promise<{ decks: number; homework: number }> { const data = backup.data; const decks = mode === "replace" ? data.decks : mergeById(deckStore.getSnapshot(), data.decks); @@ -155,9 +167,27 @@ export function applyImport(backup: BackupFile, mode: "merge" | "replace"): { de replaceDecks(decks); replaceHomework(homework); + // Answer log + schedule: merge by id (answers) and by key (schedules). + await ensureLearningHydrated(); + const incomingAnswers = data.history ?? []; + const incomingSrs = data.srs ?? []; + if (incomingAnswers.length > 0 || incomingSrs.length > 0) { + const currentAnswers = learningStore.getAnswersSnapshot(); + const currentSrs = learningStore.getSrsSnapshot(); + const answers = + mode === "replace" ? incomingAnswers : mergeById(currentAnswers, incomingAnswers); + const srsByKey = new Map(currentSrs.map((entry) => [entry.key, entry])); + for (const entry of incomingSrs) { + const current = srsByKey.get(entry.key); + if (!current || current.lastAt < entry.lastAt) srsByKey.set(entry.key, entry); + } + await replaceLearning(answers, [...srsByKey.values()]); + } + const settings = data.settings; if (settings) { if (typeof settings.haptics === "boolean") saveSettings({ haptics: settings.haptics }); + if (typeof settings.dailyGoal === "number") saveSettings({ dailyGoal: settings.dailyGoal }); if (settings.locale) setLocale(settings.locale); if (settings.theme) applyTheme(settings.theme); } diff --git a/lib/config.ts b/lib/config.ts index 0551405..88e5c51 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -31,6 +31,7 @@ export type ServerConfig = { homework: number; health: number; sync: number; + push: number; }; sync: { /** Where opaque encrypted sync blobs live (SYNC_DIR). */ @@ -40,6 +41,14 @@ export type ServerConfig = { /** Total cap for the whole directory in bytes (SYNC_TOTAL_BYTES). */ totalBytes: number; }; + push: { + /** VAPID keys — reminders are hidden entirely when either is empty. */ + publicKey: string; + privateKey: string; + subject: string; + /** Where push subscriptions live (PUSH_DIR). */ + dir: string; + }; }; /** What a single request should use to talk to Ollama. */ @@ -111,12 +120,20 @@ export function getServerConfig(): ServerConfig { health: positiveInt(process.env.RATE_LIMIT_HEALTH, 90), // Sync is cheap (no model calls) but shouldn't be hammered. sync: positiveInt(process.env.RATE_LIMIT_SYNC, 60), + // Push subscription changes and test sends. + push: positiveInt(process.env.RATE_LIMIT_PUSH, 30), }, sync: { dir: process.env.SYNC_DIR?.trim() || path.join(process.cwd(), ".freezmo", "sync"), maxBytes: positiveInt(process.env.SYNC_MAX_BYTES, 12 * 1024 * 1024), totalBytes: positiveInt(process.env.SYNC_TOTAL_BYTES, 1024 * 1024 * 1024), }, + push: { + publicKey: process.env.VAPID_PUBLIC_KEY?.trim() || "", + privateKey: process.env.VAPID_PRIVATE_KEY?.trim() || "", + subject: process.env.VAPID_SUBJECT?.trim() || "mailto:freezmo@localhost", + dir: process.env.PUSH_DIR?.trim() || path.join(process.cwd(), ".freezmo", "push"), + }, }; } diff --git a/lib/history.ts b/lib/history.ts new file mode 100644 index 0000000..742e8ab --- /dev/null +++ b/lib/history.ts @@ -0,0 +1,252 @@ +"use client"; + +/** + * Answer history and the spaced-repetition schedule, kept in IndexedDB + * (unbounded event log — localStorage would fill up). The in-memory mirror + * makes reads synchronous for React (`useSyncExternalStore`); writes update + * the mirror immediately and persist asynchronously. + * + * Versioned DB `freezmo-learning` v1 with stores: + * answers keyPath id (indexes: at, deckId) + * srs keyPath key (index: dueAt) key = `${deckId}:${activityId}` + */ + +import { type AnswerRecord, type SrsRecord } from "./schemas"; +import { markSyncDirty } from "./sync-state"; + +export type { AnswerRecord, SrsRecord }; + +const DB_NAME = "freezmo-learning"; +const DB_VERSION = 1; +const ANSWERS = "answers"; +const SRS = "srs"; +/** The log is pruned beyond this many answers (oldest first). */ +export const MAX_ANSWERS = 20_000; +/** A wrong answer comes back after this long, even inside a session. */ +export const WRONG_RETRY_MS = 10 * 60_000; + +const EMPTY_ANSWERS: AnswerRecord[] = []; +const EMPTY_SRS: SrsRecord[] = []; + +type Listener = () => void; + +let answers: AnswerRecord[] = []; +let schedules = new Map(); +let cachedAnswers: AnswerRecord[] = EMPTY_ANSWERS; +let cachedSrs: SrsRecord[] = EMPTY_SRS; +const listeners = new Set(); +let hydrated = false; +let hydration: Promise | null = null; + +function notify(): void { + cachedAnswers = answers; + cachedSrs = [...schedules.values()]; + listeners.forEach((listener) => listener()); +} + +function openDb(): Promise { + return new Promise((resolve, reject) => { + const request = indexedDB.open(DB_NAME, DB_VERSION); + request.onupgradeneeded = () => { + const db = request.result; + if (!db.objectStoreNames.contains(ANSWERS)) { + const store = db.createObjectStore(ANSWERS, { keyPath: "id" }); + store.createIndex("at", "at"); + store.createIndex("deckId", "deckId"); + } + if (!db.objectStoreNames.contains(SRS)) { + const store = db.createObjectStore(SRS, { keyPath: "key" }); + store.createIndex("dueAt", "dueAt"); + } + }; + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); +} + +async function withDb(mode: IDBTransactionMode, run: (db: IDBDatabase) => T): Promise { + const db = await openDb(); + try { + return run(db); + } finally { + // Transactions keep the connection alive until they settle; close on the + // next task so pending requests finish first. + window.setTimeout(() => db.close(), 1000); + } +} + +function requestToPromise(request: IDBRequest): Promise { + return new Promise((resolve, reject) => { + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); +} + +/** Loads IndexedDB into the mirror exactly once. Safe to call repeatedly. */ +export function ensureLearningHydrated(): Promise { + if (hydrated) return Promise.resolve(); + if (hydration) return hydration; + hydration = (async () => { + if (typeof indexedDB === "undefined") { + hydrated = true; + return; + } + try { + await withDb("readonly", async (db) => { + const transaction = db.transaction([ANSWERS, SRS], "readonly"); + const [loadedAnswers, loadedSrs] = await Promise.all([ + requestToPromise(transaction.objectStore(ANSWERS).getAll() as IDBRequest), + requestToPromise(transaction.objectStore(SRS).getAll() as IDBRequest), + ]); + // Writes that happened before hydration win (dedupe by id/key). + const known = new Set(answers.map((entry) => entry.id)); + const merged = [...answers, ...loadedAnswers.filter((entry) => !known.has(entry.id))]; + merged.sort((a, b) => b.at - a.at); + answers = merged.slice(0, MAX_ANSWERS); + for (const entry of loadedSrs) { + const current = schedules.get(entry.key); + if (!current || current.lastAt < entry.lastAt) schedules.set(entry.key, entry); + } + }); + } catch (error) { + console.warn("[freezmo] could not read the learning history", error); + } finally { + hydrated = true; + notify(); + } + })(); + return hydration; +} + +export const learningStore = { + subscribe(listener: Listener): () => void { + listeners.add(listener); + void ensureLearningHydrated(); + return () => listeners.delete(listener); + }, + getAnswersSnapshot(): AnswerRecord[] { + return cachedAnswers; + }, + getSrsSnapshot(): SrsRecord[] { + return cachedSrs; + }, + getServerAnswers(): AnswerRecord[] { + return EMPTY_ANSWERS; + }, + getServerSrs(): SrsRecord[] { + return EMPTY_SRS; + }, +}; + +export type AnswerInput = { + deckId: string; + deckTitle: string; + activityId: string; + type: string; + topic?: string; + correct: boolean; +}; + +/** + * Records one answer and updates its schedule. The schedule is a light SM-2: + * correct → 1 day, then 3 days, then interval × ease; wrong → back in ten + * minutes with the ease nudged down. + */ +export function recordAnswer(input: AnswerInput, at = Date.now()): void { + const record: AnswerRecord = { + id: `${at.toString(36)}-${Math.random().toString(36).slice(2, 8)}`, + deckId: input.deckId, + deckTitle: input.deckTitle, + activityId: input.activityId, + type: input.type, + topic: input.topic, + correct: input.correct, + at, + }; + answers = [record, ...answers].slice(0, MAX_ANSWERS); + + const key = `${input.deckId}:${input.activityId}`; + const previous = schedules.get(key); + let ease = previous?.ease ?? 2.5; + let reps = previous?.reps ?? 0; + let intervalDays = previous?.intervalDays ?? 0; + let lapses = previous?.lapses ?? 0; + + if (input.correct) { + ease = Math.min(3, ease + 0.1); + reps += 1; + intervalDays = reps === 1 ? 1 : reps === 2 ? 3 : Math.min(365, Math.round(Math.max(1, intervalDays) * ease)); + } else { + ease = Math.max(1.3, ease - 0.2); + reps = 0; + intervalDays = 0; + lapses += 1; + } + + const schedule: SrsRecord = { + key, + deckId: input.deckId, + activityId: input.activityId, + ease, + reps, + intervalDays, + lapses, + dueAt: at + (input.correct ? intervalDays * 86_400_000 : WRONG_RETRY_MS), + lastAt: at, + lastCorrect: input.correct, + }; + schedules = new Map(schedules); + schedules.set(key, schedule); + + notify(); + markSyncDirty(); + + void (async () => { + try { + await ensureLearningHydrated(); + await withDb("readwrite", (db) => { + const transaction = db.transaction([ANSWERS, SRS], "readwrite"); + transaction.objectStore(ANSWERS).put(record); + transaction.objectStore(SRS).put(schedule); + }); + // Prune the log beyond the cap (oldest first) in a separate pass. + const overflow = answers.length - MAX_ANSWERS; + if (overflow > 0) { + const oldest = answers.slice(MAX_ANSWERS).map((entry) => entry.id); + await withDb("readwrite", (db) => { + const transaction = db.transaction(ANSWERS, "readwrite"); + const store = transaction.objectStore(ANSWERS); + for (const id of oldest) store.delete(id); + }); + } + } catch (error) { + console.warn("[freezmo] could not save the answer history", error); + } + })(); +} + +/** Used by backup import and sync pulls. */ +export async function replaceLearning(nextAnswers: AnswerRecord[], nextSrs: SrsRecord[]): Promise { + const sorted = [...nextAnswers].sort((a, b) => b.at - a.at).slice(0, MAX_ANSWERS); + answers = sorted; + schedules = new Map(nextSrs.map((entry) => [entry.key, entry])); + notify(); + try { + await withDb("readwrite", (db) => { + const transaction = db.transaction([ANSWERS, SRS], "readwrite"); + const answersStore = transaction.objectStore(ANSWERS); + const srsStore = transaction.objectStore(SRS); + answersStore.clear(); + srsStore.clear(); + for (const entry of sorted) answersStore.put(entry); + for (const entry of nextSrs) srsStore.put(entry); + }); + } catch (error) { + console.warn("[freezmo] could not replace the learning history", error); + } +} + +/** Wipes the log and every schedule (used by the stats screen). */ +export async function clearLearning(): Promise { + await replaceLearning([], []); +} diff --git a/lib/i18n-dict.ts b/lib/i18n-dict.ts index 93f0666..15663ca 100644 --- a/lib/i18n-dict.ts +++ b/lib/i18n-dict.ts @@ -764,6 +764,12 @@ const en: Record = { "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 = { "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 = { "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 = { "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…", diff --git a/lib/learning.ts b/lib/learning.ts new file mode 100644 index 0000000..c50371e --- /dev/null +++ b/lib/learning.ts @@ -0,0 +1,173 @@ +"use client"; + +/** + * Pure selectors over the learning history: due items (SRS), mistakes, + * streaks and accuracy breakdowns. No React, no storage side effects — + * easy to reason about and to test. + */ + +import type { AnswerRecord, SrsRecord } from "./history"; +import type { Activity, Deck } from "./schemas"; +import { deckStore } from "./storage"; + +export type ReviewItem = { deck: Deck; activity: Activity; dueAt?: number }; + +function deckMap(): Map { + return new Map(deckStore.getSnapshot().map((deck) => [deck.id, deck])); +} + +export function itemKey(deckId: string, activityId: string): string { + return `${deckId}:${activityId}`; +} + +/* ------------------------------------------------------------------ */ +/* Review (SRS) */ +/* ------------------------------------------------------------------ */ + +export function dueCount(schedules: SrsRecord[], now = Date.now()): number { + return schedules.filter((entry) => entry.dueAt <= now).length; +} + +/** Activities whose schedule is due, soonest first, across every deck. */ +export function dueItems(schedules: SrsRecord[], now = Date.now()): ReviewItem[] { + const decks = deckMap(); + const items: ReviewItem[] = []; + for (const schedule of schedules) { + if (schedule.dueAt > now) continue; + const deck = decks.get(schedule.deckId); + const activity = deck?.activities.find((entry) => entry.id === schedule.activityId); + if (deck && activity) items.push({ deck, activity, dueAt: schedule.dueAt }); + } + return items.sort((a, b) => (a.dueAt ?? 0) - (b.dueAt ?? 0)); +} + +/* ------------------------------------------------------------------ */ +/* Mistakes */ +/* ------------------------------------------------------------------ */ + +/** + * Activities whose **latest** answer was wrong (optionally within one deck). + * Answering one correctly later removes it — no separate store to maintain. + */ +export function mistakeItems(answers: AnswerRecord[], deckId?: string): ReviewItem[] { + const latest = new Map(); + for (const answer of answers) { + if (deckId && answer.deckId !== deckId) continue; + const key = itemKey(answer.deckId, answer.activityId); + if (!latest.has(key)) latest.set(key, answer); + } + const decks = deckMap(); + const items: ReviewItem[] = []; + for (const answer of latest.values()) { + if (answer.correct) continue; + const deck = decks.get(answer.deckId); + const activity = deck?.activities.find((entry) => entry.id === answer.activityId); + if (deck && activity) items.push({ deck, activity }); + } + return items; +} + +export function mistakeCount(answers: AnswerRecord[], deckId?: string): number { + return mistakeItems(answers, deckId).length; +} + +/* ------------------------------------------------------------------ */ +/* Stats */ +/* ------------------------------------------------------------------ */ + +export type DayStat = { day: string; count: number; correct: number }; +export type BucketStat = { label: string; answered: number; correct: number }; +export type LearningStats = { + total: number; + correct: number; + accuracy: number; + todayCount: number; + streak: number; + days: DayStat[]; + decks: BucketStat[]; + subjects: BucketStat[]; + weakestTopics: BucketStat[]; +}; + +export function dayKey(at: number): string { + const date = new Date(at); + return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`; +} + +export function computeStats(answers: AnswerRecord[], now = Date.now()): LearningStats { + const total = answers.length; + const correct = answers.filter((entry) => entry.correct).length; + + const byDay = new Map(); + for (const answer of answers) { + const key = dayKey(answer.at); + const entry = byDay.get(key) ?? { day: key, count: 0, correct: 0 }; + entry.count += 1; + if (answer.correct) entry.correct += 1; + byDay.set(key, entry); + } + + // Streak: consecutive days with at least one answer, ending today or + // yesterday (a day that hasn't started yet shouldn't break it). + let streak = 0; + const cursor = new Date(now); + if (!byDay.has(dayKey(now))) cursor.setDate(cursor.getDate() - 1); + for (;;) { + const key = dayKey(cursor.getTime()); + if (!byDay.has(key)) break; + streak += 1; + cursor.setDate(cursor.getDate() - 1); + } + + const days: DayStat[] = []; + const dayCursor = new Date(now); + dayCursor.setDate(dayCursor.getDate() - 34); + for (let index = 0; index < 35; index++) { + const key = dayKey(dayCursor.getTime()); + days.push(byDay.get(key) ?? { day: key, count: 0, correct: 0 }); + dayCursor.setDate(dayCursor.getDate() + 1); + } + + const decks = new Map(); + const subjects = new Map(); + const topics = new Map(); + const lookup = deckMap(); + for (const answer of answers) { + const deck = decks.get(answer.deckId) ?? { label: answer.deckTitle, answered: 0, correct: 0 }; + deck.answered += 1; + if (answer.correct) deck.correct += 1; + decks.set(answer.deckId, deck); + + const subject = lookup.get(answer.deckId)?.subject?.trim(); + if (subject) { + const bucket = subjects.get(subject) ?? { label: subject, answered: 0, correct: 0 }; + bucket.answered += 1; + if (answer.correct) bucket.correct += 1; + subjects.set(subject, bucket); + } + + if (answer.topic) { + const bucket = topics.get(answer.topic) ?? { label: answer.topic, answered: 0, correct: 0 }; + bucket.answered += 1; + if (answer.correct) bucket.correct += 1; + topics.set(answer.topic, bucket); + } + } + + const accuracyOf = (bucket: BucketStat) => bucket.correct / Math.max(1, bucket.answered); + + return { + total, + correct, + accuracy: total > 0 ? correct / total : 0, + todayCount: byDay.get(dayKey(now))?.count ?? 0, + streak, + days, + decks: [...decks.values()].sort((a, b) => b.answered - a.answered), + subjects: [...subjects.values()].sort((a, b) => b.answered - a.answered), + weakestTopics: [...topics.values()] + .filter((bucket) => bucket.answered >= 3) + .sort((a, b) => accuracyOf(a) - accuracyOf(b)) + .slice(0, 5), + }; +} diff --git a/lib/push-store.ts b/lib/push-store.ts new file mode 100644 index 0000000..1e9c749 --- /dev/null +++ b/lib/push-store.ts @@ -0,0 +1,64 @@ +import { promises as fs } from "node:fs"; +import path from "node:path"; + +/** + * Server-side push subscriptions: one JSON file with endpoint + keys and a + * creation timestamp. No personal data, no history, no user ids — and the + * whole feature stays hidden unless VAPID keys are configured. + */ + +export type PushSubscriptionRecord = { + endpoint: string; + keys: { p256dh: string; auth: string }; + createdAt: number; +}; + +function fileFor(dir: string): string { + return path.join(dir, "subscriptions.json"); +} + +export async function readSubscriptions(dir: string): Promise { + try { + const raw = await fs.readFile(fileFor(dir), "utf8"); + const parsed: unknown = JSON.parse(raw); + if (!Array.isArray(parsed)) return []; + return parsed.filter( + (entry): entry is PushSubscriptionRecord => + Boolean(entry) && + typeof (entry as PushSubscriptionRecord).endpoint === "string" && + typeof (entry as PushSubscriptionRecord).keys?.p256dh === "string" && + typeof (entry as PushSubscriptionRecord).keys?.auth === "string", + ); + } catch { + return []; + } +} + +async function writeSubscriptions(dir: string, next: PushSubscriptionRecord[]): Promise { + await fs.mkdir(dir, { recursive: true }); + const file = fileFor(dir); + const temporary = `${file}.${crypto.randomUUID()}.tmp`; + await fs.writeFile(temporary, JSON.stringify(next, null, 2), "utf8"); + await fs.rename(temporary, file); +} + +/** Adds or refreshes a subscription (deduped by endpoint). */ +export async function addSubscription( + dir: string, + subscription: { endpoint: string; keys: { p256dh: string; auth: string } }, +): Promise { + const current = await readSubscriptions(dir); + const next = current.filter((entry) => entry.endpoint !== subscription.endpoint); + next.push({ ...subscription, createdAt: Date.now() }); + // A cap keeps a runaway script from filling the disk. + const capped = next.slice(-2000); + await writeSubscriptions(dir, capped); + return capped.length; +} + +export async function removeSubscription(dir: string, endpoint: string): Promise { + const current = await readSubscriptions(dir); + const next = current.filter((entry) => entry.endpoint !== endpoint); + await writeSubscriptions(dir, next); + return next.length; +} diff --git a/lib/schemas.ts b/lib/schemas.ts index 5f89f46..585de13 100644 --- a/lib/schemas.ts +++ b/lib/schemas.ts @@ -280,3 +280,33 @@ export const deckSchema: z.ZodType = z.object({ attempts: z.number(), bestScore: z.number().optional(), }); + +/* ------------------------------------------------------------------ */ +/* Learning history (IndexedDB, included in backups and sync) */ +/* ------------------------------------------------------------------ */ + +export const answerRecordSchema = z.object({ + id: z.string().min(1).max(80), + deckId: z.string().min(1).max(80), + deckTitle: z.string().max(200), + activityId: z.string().min(1).max(80), + type: z.string().max(40), + topic: z.string().max(120).optional(), + correct: z.boolean(), + at: z.number(), +}); +export type AnswerRecord = z.infer; + +export const srsRecordSchema = z.object({ + key: z.string().min(1).max(200), + deckId: z.string().min(1).max(80), + activityId: z.string().min(1).max(80), + ease: z.number().min(1).max(4), + reps: z.number().int().min(0).max(1000), + intervalDays: z.number().int().min(0).max(3650), + lapses: z.number().int().min(0).max(10000), + dueAt: z.number(), + lastAt: z.number(), + lastCorrect: z.boolean(), +}); +export type SrsRecord = z.infer; diff --git a/lib/settings.ts b/lib/settings.ts index 67ba7b4..3bcc1c0 100644 --- a/lib/settings.ts +++ b/lib/settings.ts @@ -22,9 +22,13 @@ export type BrowserSettings = { apiKey: string; /** Best-effort haptics on supported devices (phones). On by default. */ haptics: boolean; + /** How many answers count as a finished day in the Library/stats. */ + dailyGoal: number; }; -const EMPTY: BrowserSettings = { apiKey: "", haptics: true }; +const DEFAULT_GOAL = 20; + +const EMPTY: BrowserSettings = { apiKey: "", haptics: true, dailyGoal: DEFAULT_GOAL }; let cached: BrowserSettings | null = null; const listeners = new Set<() => void>(); @@ -35,9 +39,11 @@ function read(): BrowserSettings { const raw = window.localStorage.getItem(STORAGE_KEY); if (!raw) return EMPTY; const parsed = JSON.parse(raw) as Partial; + const goal = Number.parseInt(String(parsed.dailyGoal ?? DEFAULT_GOAL), 10); return { apiKey: typeof parsed.apiKey === "string" ? parsed.apiKey.slice(0, 512) : "", haptics: parsed.haptics !== false, + dailyGoal: Number.isFinite(goal) ? Math.min(500, Math.max(1, goal)) : DEFAULT_GOAL, }; } catch { return EMPTY; @@ -47,8 +53,11 @@ function read(): BrowserSettings { function write(next: BrowserSettings): void { cached = next; try { - if (next.apiKey || next.haptics === false) { - window.localStorage.setItem(STORAGE_KEY, JSON.stringify({ apiKey: next.apiKey, haptics: next.haptics })); + if (next.apiKey || next.haptics === false || next.dailyGoal !== DEFAULT_GOAL) { + window.localStorage.setItem( + STORAGE_KEY, + JSON.stringify({ apiKey: next.apiKey, haptics: next.haptics, dailyGoal: next.dailyGoal }), + ); } else { window.localStorage.removeItem(STORAGE_KEY); } diff --git a/lib/sync.ts b/lib/sync.ts index 895ee28..15f9b50 100644 --- a/lib/sync.ts +++ b/lib/sync.ts @@ -16,6 +16,7 @@ import { backupDataSchema } from "./backup"; import { ApiError } from "./client-api"; import { replaceFolders } from "./folders"; import { replaceHomework } from "./homework"; +import { ensureLearningHydrated, learningStore, replaceLearning } from "./history"; import { localeHeader, t } from "./i18n"; import { authHeaders } from "./settings"; import { deckStore, replaceDecks } from "./storage"; @@ -159,7 +160,8 @@ async function apiDelete(id: string): Promise { /* Payload */ /* ------------------------------------------------------------------ */ -function buildPayload(): SyncPayload { +async function buildPayload(): Promise { + await ensureLearningHydrated(); return { app: "freezmo", kind: "sync", @@ -169,6 +171,8 @@ function buildPayload(): SyncPayload { decks: deckStore.getSnapshot(), folders: folderStore.getSnapshot(), homework: homeworkStore.getSnapshot(), + history: learningStore.getAnswersSnapshot(), + srs: learningStore.getSrsSnapshot(), }, }; } @@ -182,6 +186,7 @@ async function applyRemote(key: CryptoKey, remote: { updatedAt: number; envelope replaceDecks(parsed.data.decks); replaceHomework(parsed.data.homework); }); + await replaceLearning(parsed.data.history ?? [], parsed.data.srs ?? []); setSyncRecord({ lastSyncedAt: remote.updatedAt, dirtyAt: null, conflict: false }); } @@ -218,7 +223,7 @@ export async function syncNow(strategy: "auto" | "push" | "pull" = "auto"): Prom return { status: "up-to-date" }; } - const { iv, ciphertext } = await encryptWithKey(key, buildPayload()); + const { iv, ciphertext } = await encryptWithKey(key, await buildPayload()); const envelope: SyncEnvelope = { v: 1, kdf: KDF, diff --git a/package-lock.json b/package-lock.json index b87431c..fc21912 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "pdfjs-dist": "^6.4.299", "react": "19.1.0", "react-dom": "19.1.0", + "web-push": "^3.6.7", "zod": "^4.1.13" }, "devDependencies": { @@ -25,6 +26,7 @@ "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", + "@types/web-push": "^3.6.4", "eslint": "^9", "eslint-config-next": "15.5.27", "tailwindcss": "^4", @@ -1752,6 +1754,16 @@ "@types/react": "^19.3.0" } }, + "node_modules/@types/web-push": { + "version": "3.6.4", + "resolved": "https://registry.npmjs.org/@types/web-push/-/web-push-3.6.4.tgz", + "integrity": "sha512-GnJmSr40H3RAnj0s34FNTcJi1hmWFV5KXugE0mYWnYhgTAHLJ/dJKAwDmvPJYMke0RplY2XE9LnM4hqSqKIjhQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.71.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.71.0.tgz", @@ -2420,6 +2432,15 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/ajv": { "version": "6.15.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", @@ -2630,6 +2651,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/asn1.js": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz", + "integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==", + "license": "MIT", + "dependencies": { + "bn.js": "^4.0.0", + "inherits": "^2.0.1", + "minimalistic-assert": "^1.0.0", + "safer-buffer": "^2.1.0" + } + }, "node_modules/ast-types-flow": { "version": "0.0.8", "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz", @@ -2710,6 +2743,12 @@ ], "license": "MIT" }, + "node_modules/bn.js": { + "version": "4.12.5", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.5.tgz", + "integrity": "sha512-3aRg6/JxfffFD+OlOjOFR3Vo79l39ooBTFucxx+MT3dhCtzn3EmiUPQo+6/OZuI2jbXi3YKgmiTFBgChQMwIRQ==", + "license": "MIT" + }, "node_modules/brace-expansion": { "version": "1.1.21", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", @@ -2734,6 +2773,12 @@ "node": ">=8" } }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, "node_modules/call-bind": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", @@ -2967,7 +3012,6 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -3077,6 +3121,15 @@ "node": ">= 0.4" } }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/emoji-regex": { "version": "9.2.2", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", @@ -4167,6 +4220,28 @@ "node": ">= 0.4" } }, + "node_modules/http_ece": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/http_ece/-/http_ece-1.2.0.tgz", + "integrity": "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==", + "license": "MIT", + "engines": { + "node": ">=16" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -4783,6 +4858,27 @@ "setimmediate": "^1.0.5" } }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -5231,6 +5327,12 @@ "node": ">=8.6" } }, + "node_modules/minimalistic-assert": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", + "license": "ISC" + }, "node_modules/minimatch": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", @@ -5248,7 +5350,6 @@ "version": "1.2.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -5273,7 +5374,6 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, "node_modules/nanoid": { @@ -6032,6 +6132,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, "node_modules/scheduler": { "version": "0.26.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz", @@ -6790,6 +6896,25 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "license": "MIT" }, + "node_modules/web-push": { + "version": "3.6.7", + "resolved": "https://registry.npmjs.org/web-push/-/web-push-3.6.7.tgz", + "integrity": "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==", + "license": "MPL-2.0", + "dependencies": { + "asn1.js": "^5.3.0", + "http_ece": "1.2.0", + "https-proxy-agent": "^7.0.0", + "jws": "^4.0.0", + "minimist": "^1.2.5" + }, + "bin": { + "web-push": "src/cli.js" + }, + "engines": { + "node": ">= 16" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/package.json b/package.json index c75f62f..428d0cf 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "pdfjs-dist": "^6.4.299", "react": "19.1.0", "react-dom": "19.1.0", + "web-push": "^3.6.7", "zod": "^4.1.13" }, "devDependencies": { @@ -28,6 +29,7 @@ "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", + "@types/web-push": "^3.6.4", "eslint": "^9", "eslint-config-next": "15.5.27", "tailwindcss": "^4", diff --git a/public/sw.js b/public/sw.js index b1680e8..2240c7c 100644 --- a/public/sw.js +++ b/public/sw.js @@ -9,7 +9,7 @@ * * Bumping CACHE_VERSION is how a new deploy invalidates old assets. */ -const CACHE_VERSION = "freezmo-v25"; +const CACHE_VERSION = "freezmo-v26"; const SHELL_CACHE = `${CACHE_VERSION}-shell`; const ASSET_CACHE = `${CACHE_VERSION}-assets`; @@ -53,6 +53,43 @@ function isCacheableAsset(url) { ); } +/* ------------------------------------------------------------------ */ +/* Push reminders (opt-in; only active when VAPID is configured) */ +/* ------------------------------------------------------------------ */ + +self.addEventListener("push", (event) => { + let data = {}; + try { + data = event.data ? event.data.json() : {}; + } catch { + data = {}; + } + event.waitUntil( + self.registration.showNotification(data.title || "Freezmo", { + body: data.body || "", + icon: "/icons/icon-192.png", + badge: "/icons/icon-192.png", + data: { url: data.url || "/" }, + }), + ); +}); + +self.addEventListener("notificationclick", (event) => { + event.notification.close(); + const url = (event.notification.data && event.notification.data.url) || "/"; + event.waitUntil( + self.clients.matchAll({ type: "window", includeUncontrolled: true }).then((clients) => { + for (const client of clients) { + if ("focus" in client) { + client.navigate(url).catch(() => {}); + return client.focus(); + } + } + return self.clients.openWindow(url); + }), + ); +}); + self.addEventListener("fetch", (event) => { const request = event.request; if (request.method !== "GET") return; diff --git a/scripts/send-reminders.mjs b/scripts/send-reminders.mjs new file mode 100644 index 0000000..c4a4f20 --- /dev/null +++ b/scripts/send-reminders.mjs @@ -0,0 +1,67 @@ +/** + * Sends the daily review reminder to every stored push subscription. + * + * Meant for cron or a systemd timer, e.g.: + * 0 18 * * * cd /path/to/freezmo && node scripts/send-reminders.mjs + * + * Silently does nothing when VAPID keys are not configured (the whole + * reminder feature stays hidden in the UI in that case). + */ +import { readFile, rename, writeFile } from "node:fs/promises"; +import path from "node:path"; +import webpush from "web-push"; + +const dir = process.env.PUSH_DIR?.trim() || path.join(process.cwd(), ".freezmo", "push"); +const publicKey = process.env.VAPID_PUBLIC_KEY?.trim(); +const privateKey = process.env.VAPID_PRIVATE_KEY?.trim(); +const subject = process.env.VAPID_SUBJECT?.trim() || "mailto:freezmo@localhost"; +const title = process.env.PUSH_REMINDER_TITLE?.trim() || "Time to review"; +const body = + process.env.PUSH_REMINDER_BODY?.trim() || + "Your decks are waiting — a few due cards keep the streak alive."; + +if (!publicKey || !privateKey) { + console.log("[freezmo] VAPID keys are not configured — nothing to send."); + process.exit(0); +} + +const file = path.join(dir, "subscriptions.json"); +let subscriptions = []; +try { + subscriptions = JSON.parse(await readFile(file, "utf8")); +} catch { + subscriptions = []; +} + +if (!Array.isArray(subscriptions) || subscriptions.length === 0) { + console.log("[freezmo] no push subscriptions stored."); + process.exit(0); +} + +webpush.setVapidDetails(subject, publicKey, privateKey); + +let sent = 0; +let removed = 0; +const remaining = []; +for (const entry of subscriptions) { + try { + await webpush.sendNotification( + { endpoint: entry.endpoint, keys: entry.keys }, + JSON.stringify({ title, body, url: "/" }), + ); + sent += 1; + remaining.push(entry); + } catch (error) { + const status = error?.statusCode; + if (status === 404 || status === 410) removed += 1; + else remaining.push(entry); + } +} + +if (removed > 0) { + const temporary = `${file}.${Date.now()}.tmp`; + await writeFile(temporary, JSON.stringify(remaining, null, 2), "utf8"); + await rename(temporary, file); +} + +console.log(`[freezmo] reminders sent: ${sent}, dead subscriptions removed: ${removed}`);