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

@ -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

View file

@ -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 <https://ollama.com/settings/keys> 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 <https://ollama.com/settings>. |
@ -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

131
app/api/push/route.ts Normal file
View file

@ -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" } });
}

View file

@ -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" } });
}

26
app/exam/page.tsx Normal file
View file

@ -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 (
<ApiKeyGate>
<div className="flex min-h-dvh">
<Sidebar />
<SidebarOffset />
<MobileSidebar />
<main className="flex min-h-dvh min-w-0 flex-1 flex-col">
<AppHeader />
<div className="mx-auto w-full max-w-3xl flex-1 px-4 pb-tabbar lg:px-10 lg:pb-16 lg:pt-10">
<ExamScreen />
</div>
</main>
</div>
</ApiKeyGate>
);
}

22
app/review/page.tsx Normal file
View file

@ -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 (
<ApiKeyGate>
<ReviewClient
mistakes={params.mistakes === "1"}
deckId={typeof params.deck === "string" ? params.deck : undefined}
/>
</ApiKeyGate>
);
}

26
app/stats/page.tsx Normal file
View file

@ -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 (
<ApiKeyGate>
<div className="flex min-h-dvh">
<Sidebar />
<SidebarOffset />
<MobileSidebar />
<main className="flex min-h-dvh min-w-0 flex-1 flex-col">
<AppHeader />
<div className="mx-auto w-full max-w-3xl flex-1 px-4 pb-tabbar lg:px-10 lg:pb-16 lg:pt-10">
<StatsScreen />
</div>
</main>
</div>
</ApiKeyGate>
);
}

View file

@ -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() {
<p className="text-[11.5px] leading-relaxed text-[var(--text-subtle)]">{t("settings.dataNote")}</p>
<div className="grid gap-2">
<Button size="sm" variant="secondary" className="touch-target w-full" onClick={() => downloadBackup()}>
<Button size="sm" variant="secondary" className="touch-target w-full" onClick={() => void downloadBackup()}>
{t("settings.exportAll")}
</Button>
<Button size="sm" variant="secondary" className="touch-target w-full" onClick={() => fileRef.current?.click()}>
@ -97,10 +97,10 @@ export function DataSection() {
<p className="text-[11px] leading-relaxed text-[var(--text-subtle)]">{t("settings.importMergeNote")}</p>
<p className="text-[11px] leading-relaxed text-[var(--text-subtle)]">{t("settings.importReplaceNote")}</p>
<div className="flex flex-wrap gap-2">
<Button size="sm" onClick={() => apply("merge")}>
<Button size="sm" onClick={() => void apply("merge")}>
{t("settings.importMerge")}
</Button>
<Button size="sm" variant="danger" onClick={() => apply("replace")}>
<Button size="sm" variant="danger" onClick={() => void apply("replace")}>
{t("settings.importReplace")}
</Button>
<Button size="sm" variant="ghost" onClick={reset}>

View file

@ -0,0 +1,209 @@
"use client";
import { useEffect, useState, useSyncExternalStore } from "react";
import { Button } from "@/components/ui";
import { haptic } from "@/lib/haptics";
import { localeHeader, useI18n } from "@/lib/i18n";
import { authHeaders, saveSettings, settingsStore } from "@/lib/settings";
const GOALS = [10, 20, 30, 50];
type PushStatus = { enabled: boolean; publicKey?: string };
function urlBase64ToUint8Array(base64: string): Uint8Array {
const padding = "=".repeat((4 - (base64.length % 4)) % 4);
const normalized = (base64 + padding).replace(/-/g, "+").replace(/_/g, "/");
const raw = atob(normalized);
const bytes = new Uint8Array(raw.length);
for (let index = 0; index < raw.length; index++) bytes[index] = raw.charCodeAt(index);
return bytes;
}
/**
* Settings → Learning: the daily goal, plus push reminders — but only when
* the server has VAPID keys configured. Without them the reminder block is
* hidden completely, exactly as if the feature did not exist.
*/
export function LearningSection() {
const { t } = useI18n();
const dailyGoal = useSyncExternalStore(
settingsStore.subscribe,
() => settingsStore.getSnapshot().dailyGoal,
() => 20,
);
const [status, setStatus] = useState<PushStatus | null>(null);
const [subscribed, setSubscribed] = useState(false);
const [endpoint, setEndpoint] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [notice, setNotice] = useState<string | null>(null);
const pushSupported =
typeof window !== "undefined" &&
"serviceWorker" in navigator &&
"PushManager" in window &&
"Notification" in window;
useEffect(() => {
if (!pushSupported) return;
let cancelled = false;
void (async () => {
try {
const response = await fetch("/api/push", {
headers: { ...authHeaders(), ...localeHeader() },
cache: "no-store",
});
const body = (await response.json()) as PushStatus;
if (cancelled) return;
setStatus(body);
if (body.enabled) {
const registration = await navigator.serviceWorker.ready.catch(() => null);
const subscription = await registration?.pushManager.getSubscription();
if (!cancelled && subscription) {
setSubscribed(true);
setEndpoint(subscription.endpoint);
}
}
} catch {
if (!cancelled) setStatus({ enabled: false });
}
})();
return () => {
cancelled = true;
};
}, [pushSupported]);
const enable = async () => {
if (!status?.publicKey) return;
setBusy(true);
setNotice(null);
try {
const permission = await Notification.requestPermission();
if (permission !== "granted") {
setNotice(t("settings.reminderDenied"));
return;
}
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(status.publicKey) as unknown as BufferSource,
});
const response = await fetch("/api/push", {
method: "POST",
headers: { "Content-Type": "application/json", ...authHeaders(), ...localeHeader() },
body: JSON.stringify(subscription.toJSON()),
});
if (!response.ok) throw new Error("subscribe failed");
setSubscribed(true);
setEndpoint(subscription.endpoint);
setNotice(t("settings.reminderOn"));
haptic("toggle");
} catch {
setNotice(t("settings.reminderError"));
} finally {
setBusy(false);
}
};
const disable = async () => {
setBusy(true);
setNotice(null);
try {
const registration = await navigator.serviceWorker.ready.catch(() => null);
const subscription = await registration?.pushManager.getSubscription();
if (subscription) {
await fetch(`/api/push?endpoint=${encodeURIComponent(subscription.endpoint)}`, {
method: "DELETE",
headers: { ...authHeaders(), ...localeHeader() },
});
await subscription.unsubscribe();
}
setSubscribed(false);
setEndpoint(null);
setNotice(t("settings.reminderOff"));
} catch {
setNotice(t("settings.reminderError"));
} finally {
setBusy(false);
}
};
const test = async () => {
if (!endpoint) return;
setBusy(true);
setNotice(null);
try {
const response = await fetch("/api/push/test", {
method: "POST",
headers: { "Content-Type": "application/json", ...authHeaders(), ...localeHeader() },
body: JSON.stringify({ endpoint }),
});
if (!response.ok) throw new Error("test failed");
setNotice(t("settings.reminderOn"));
} catch {
setNotice(t("settings.reminderError"));
} finally {
setBusy(false);
}
};
const iosNeedsInstall =
typeof navigator !== "undefined" &&
/iPhone|iPad|iPod/.test(navigator.userAgent) &&
!(navigator as Navigator & { standalone?: boolean }).standalone;
return (
<section className="mb-4 space-y-3 rounded-2xl border border-[var(--hairline)] bg-[var(--surface-sunken)] p-3">
<h3 className="text-sm font-semibold text-frost-950 dark:text-frost-50">{t("settings.learning")}</h3>
<div className="space-y-1.5">
<p className="text-[12px] font-semibold text-[var(--text)]">{t("settings.dailyGoal")}</p>
<p className="text-[11px] leading-snug text-[var(--text-subtle)]">{t("settings.dailyGoalNote")}</p>
<div className="segmented" role="group" aria-label={t("settings.dailyGoal")}>
{GOALS.map((goal) => (
<button
key={goal}
type="button"
aria-pressed={dailyGoal === goal}
onClick={() => {
haptic("toggle");
saveSettings({ dailyGoal: goal });
}}
>
<span className="seg-label">{goal}</span>
</button>
))}
</div>
</div>
{pushSupported && status?.enabled ? (
<div className="space-y-2 border-t border-[var(--hairline-soft)] pt-2.5">
<p className="text-[12px] font-semibold text-[var(--text)]">{t("settings.reminders")}</p>
<p className="text-[11px] leading-snug text-[var(--text-subtle)]">{t("settings.remindersNote")}</p>
{iosNeedsInstall ? (
<p className="text-[11px] font-medium leading-snug text-amber-700 dark:text-amber-300">
{t("settings.remindersIos")}
</p>
) : null}
<div className="flex flex-wrap gap-2">
{subscribed ? (
<>
<Button size="sm" variant="secondary" loading={busy} onClick={() => void test()}>
{t("settings.reminderTest")}
</Button>
<Button size="sm" variant="ghost" loading={busy} onClick={() => void disable()}>
{t("settings.reminderDisable")}
</Button>
</>
) : (
<Button size="sm" loading={busy} onClick={() => void enable()}>
{t("settings.reminderEnable")}
</Button>
)}
</div>
{notice ? <p className="text-[12px] font-medium text-[var(--text-muted)]">{notice}</p> : null}
</div>
) : null}
</section>
);
}

View file

@ -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<HTMLInputElement>(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 ---------------- */}
<section className="card mb-5 space-y-3 p-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="text-[11px] font-semibold uppercase tracking-wide text-[var(--text-subtle)]">
{t("review.cardTitle")}
</p>
<p className="text-[11.5px] font-medium text-[var(--text-muted)]">
{t("library.learningToday", { n: todayCount, goal: dailyGoal })}
</p>
</div>
<ProgressBar value={Math.min(todayCount, dailyGoal)} max={dailyGoal} label={t("stats.today")} />
<div className="flex flex-wrap gap-2">
<Link
href="/review"
aria-disabled={due === 0}
className={cn(
"touch-target inline-flex h-10 items-center rounded-xl px-3.5 text-[13px] font-semibold transition",
due > 0 ? "btn-primary" : "btn-secondary pointer-events-none opacity-60",
)}
>
{t("review.title")} · {plural(due, "review.due")}
</Link>
<Link
href="/review?mistakes=1"
aria-disabled={mistakes === 0}
className={cn(
"touch-target inline-flex h-10 items-center rounded-xl px-3.5 text-[13px] font-semibold transition",
mistakes > 0 ? "btn-secondary" : "btn-secondary pointer-events-none opacity-60",
)}
>
{t("review.mistakesTitle")} · {plural(mistakes, "review.mistakes")}
</Link>
<Link
href="/exam"
className="btn-ghost touch-target inline-flex h-10 items-center rounded-xl px-3.5 text-[13px] font-semibold transition"
>
{t("exam.title")}
</Link>
</div>
</section>
{/* gentle backup nudge — dismissible, never blocks ---------------- */}
{nudgeDue ? (
<div className="mb-5 flex flex-wrap items-center gap-3 rounded-2xl border border-[var(--hairline)] bg-[var(--surface-sunken)] px-3.5 py-3">

117
components/ReviewClient.tsx Normal file
View file

@ -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<ReviewItem[] | null>(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 (
<main className="frost-bg grid min-h-dvh place-items-center px-4">
<div className="w-full max-w-xl space-y-4" aria-busy="true" aria-label={title}>
<div className="skeleton h-24 rounded-3xl" />
<div className="skeleton h-12 rounded-2xl" />
</div>
</main>
);
}
if (!session) {
return (
<main className="frost-bg grid min-h-dvh place-items-center px-4">
<div className="card max-w-md space-y-3 p-6 text-center">
<h1 className="text-xl font-extrabold text-[var(--text)]">{title}</h1>
<p className="text-sm leading-relaxed text-[var(--text-muted)]">
{mistakes ? t("review.noMistakes") : t("review.nothingDue")}
</p>
<Button size="lg" className="touch-target" onClick={() => router.push("/")}>
{t("review.done")}
</Button>
</div>
</main>
);
}
const sessionItems: SessionItem[] = session.map(({ deck, activity }) => ({ deck, activity }));
return (
<SessionFeed
title={title}
items={sessionItems}
testId="review-feed"
onExit={() => 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 }) => (
<div className="card space-y-4 p-5 text-center">
<p className="text-[11px] font-semibold uppercase tracking-wider text-[var(--text-subtle)]">{title}</p>
<p className="text-[42px] font-extrabold leading-none text-[var(--text)]">{score}%</p>
<p className="text-sm font-medium text-[var(--text-muted)]">
{t("results.gotOf", { correct, total, retry: "" })}
</p>
<div className="flex flex-wrap justify-center gap-2">
<Button size="lg" className="touch-target" onClick={() => router.push("/")}>
{t("review.done")}
</Button>
</div>
</div>
)}
/>
);
}

232
components/SessionFeed.tsx Normal file
View file

@ -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<string, SessionResult>;
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<Record<string, SessionResult>>({});
const [runId] = useState(0);
const [remaining, setRemaining] = useState(timerSeconds ?? 0);
const scrollRef = useRef<HTMLDivElement>(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 (
<div className="frost-bg fixed inset-0 flex flex-col fade-in-mobile">
<header className="pt-safe absolute inset-x-0 top-0 z-30 px-3">
<div className="study-chrome mx-auto max-w-xl space-y-1.5 rounded-full px-3 py-2">
<div className="flex items-center gap-3">
<button
type="button"
onClick={onExit}
aria-label={t("feed.back")}
className="grid size-9 shrink-0 place-items-center rounded-xl bg-[var(--surface-sunken)] text-[var(--text)] transition hover:bg-[var(--hover)]"
>
<XIcon className="size-4" />
</button>
<div className="min-w-0 flex-1">
<ProgressBar
value={Math.min(index + 1, slideCount)}
max={slideCount}
label={t("feed.activityOf", { n: Math.min(index + 1, total), total })}
/>
</div>
{timerSeconds ? (
<span
className={cn(
"shrink-0 rounded-xl px-2 py-1 text-xs font-extrabold tabular-nums",
remaining <= 30 ? "tint-peach" : "bg-[var(--surface-sunken)] text-[var(--text-muted)]",
)}
aria-label={t("exam.timeLeft", { time: formatClock(remaining) })}
>
{formatClock(remaining)}
</span>
) : null}
</div>
<div className="flex items-center justify-between gap-2 text-[11px] font-semibold text-[var(--text-muted)]">
<span className="min-w-0 truncate">{title}</span>
<span className="shrink-0 tabular-nums">
{Math.min(index + 1, total)}/{total}
</span>
</div>
</div>
</header>
<div
key={runId}
ref={scrollRef}
data-testid={testId ?? "session-feed"}
className="no-scrollbar h-full snap-y snap-mandatory overflow-y-scroll overscroll-y-contain [overflow-anchor:none]"
>
{items.map((item, itemIndex) => (
<section
key={`${item.deck.id}-${item.activity.id}-${runId}`}
aria-label={t("feed.activityOf", { n: itemIndex + 1, total })}
className="flex h-full snap-start snap-always items-stretch justify-center overflow-y-auto overscroll-y-contain"
>
<div className="mx-auto flex min-h-full w-full max-w-xl flex-col gap-4 px-4 pb-24 pt-24 [justify-content:safe_center]">
<ActivityRenderer
activity={item.activity}
active={index === itemIndex}
language={item.deck.language}
noHints={noHints}
onAnswered={(correct, meta) => handleAnswered(item, correct, meta)}
onNext={next}
isLast={itemIndex === total - 1}
nextLabel={itemIndex === total - 1 ? t("review.seeResults") : undefined}
/>
</div>
</section>
))}
<section
aria-label={t("review.results")}
className="flex h-full snap-start snap-always items-stretch justify-center overflow-y-auto overscroll-y-contain"
>
<div className="mx-auto flex min-h-full w-full max-w-xl flex-col gap-4 px-4 pb-24 pt-24 [justify-content:safe_center]">
{finished
? renderResults({ results, items, correct, total, score })
: (
<div className="card space-y-2 p-5 text-center">
<p className="text-sm font-semibold text-[var(--text-muted)]">{t("review.results")}</p>
<p className="text-[13px] text-[var(--text-subtle)]">{t("review.finishFirst")}</p>
</div>
)}
</div>
</section>
</div>
</div>
);
}
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")}`;
}

View file

@ -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({
</section>
{/* ------------------------------------------- data & sync */}
<LearningSection />
<DataSection />
<SyncSection />
@ -708,6 +710,7 @@ export function SettingsDialog({
</section>
{/* ------------------------------------------- data & sync */}
<LearningSection />
<DataSection />
<SyncSection />

View file

@ -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 ---------------------------------------------------------- */}
<div className="mt-2 space-y-0.5 border-t border-[var(--hairline)] pt-2">
<Link
href="/stats"
onClick={onNavigate}
title={t("nav.stats")}
className={cn("nav-row", collapsed && "justify-center px-2")}
>
<FlameIcon className="size-4 shrink-0 opacity-70" />
{!collapsed ? <span className="flex-1 truncate text-left">{t("nav.stats")}</span> : null}
</Link>
<Link
href="/exam"
onClick={onNavigate}
title={t("nav.exam")}
className={cn("nav-row", collapsed && "justify-center px-2")}
>
<CompassIcon className="size-4 shrink-0 opacity-70" />
{!collapsed ? <span className="flex-1 truncate text-left">{t("nav.exam")}</span> : null}
</Link>
<button
type="button"
onClick={() => openSettings()}

214
components/StatsScreen.tsx Normal file
View file

@ -0,0 +1,214 @@
"use client";
import Link from "next/link";
import { useEffect, useMemo, useState, useSyncExternalStore } from "react";
import { Button, ProgressBar } from "@/components/ui";
import { ensureLearningHydrated, learningStore } from "@/lib/history";
import { useI18n } from "@/lib/i18n";
import { computeStats, dayKey } from "@/lib/learning";
import { settingsStore } from "@/lib/settings";
import { cn } from "@/lib/utils";
/**
* Progress: day streak, daily-goal progress, a five-week calendar, accuracy
* per subject and per deck, and the weakest topics (at least three answers).
*/
export function StatsScreen() {
const { t, plural, locale } = useI18n();
const answers = useSyncExternalStore(
learningStore.subscribe,
learningStore.getAnswersSnapshot,
learningStore.getServerAnswers,
);
const dailyGoal = useSyncExternalStore(
settingsStore.subscribe,
() => settingsStore.getSnapshot().dailyGoal,
() => 20,
);
const [ready, setReady] = useState(false);
useEffect(() => {
void ensureLearningHydrated().then(() => setReady(true));
}, []);
const stats = useMemo(() => computeStats(answers), [answers]);
if (!ready) {
return (
<div className="space-y-4" aria-busy="true">
<div className="skeleton h-24 rounded-3xl" />
<div className="skeleton h-40 rounded-3xl" />
</div>
);
}
if (stats.total === 0) {
return (
<div className="card mx-auto max-w-md space-y-3 p-6 text-center">
<p className="text-3xl" aria-hidden>
📈
</p>
<h1 className="text-xl font-extrabold text-[var(--text)]">{t("stats.empty")}</h1>
<p className="text-sm leading-relaxed text-[var(--text-muted)]">{t("stats.emptyBody")}</p>
<Button className="touch-target" onClick={() => window.history.back()}>
{t("stats.start")}
</Button>
</div>
);
}
// 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 (
<div className="space-y-5">
<section className="space-y-1.5">
<h1 className="text-[26px] font-semibold leading-tight tracking-[-0.02em] text-[var(--text)]">
{t("stats.title")}
</h1>
<p className="text-[14px] text-frost-600 dark:text-frost-300">{t("stats.subtitle")}</p>
</section>
<section className="grid gap-3 sm:grid-cols-3">
<div className="card space-y-1 p-4">
<p className="text-[11px] font-semibold uppercase tracking-wide text-[var(--text-subtle)]">{t("stats.streak")}</p>
<p className="text-[28px] font-extrabold leading-none text-[var(--text)]">{stats.streak}</p>
<p className="text-[11.5px] text-[var(--text-muted)]">{plural(stats.total, "stats.answers")}</p>
</div>
<div className="card space-y-2 p-4">
<p className="text-[11px] font-semibold uppercase tracking-wide text-[var(--text-subtle)]">{t("stats.today")}</p>
<p className="text-[15px] font-bold text-[var(--text)]">
{t("stats.todayOf", { n: stats.todayCount, goal: dailyGoal })}
</p>
<ProgressBar value={Math.min(stats.todayCount, dailyGoal)} max={dailyGoal} label={t("stats.today")} />
{stats.todayCount >= dailyGoal ? (
<p className="text-[11.5px] font-semibold text-emerald-700 dark:text-emerald-300">{t("stats.goalDone")}</p>
) : null}
</div>
<div className="card space-y-1 p-4">
<p className="text-[11px] font-semibold uppercase tracking-wide text-[var(--text-subtle)]">{t("stats.accuracy")}</p>
<p className="text-[28px] font-extrabold leading-none text-[var(--text)]">
{Math.round(stats.accuracy * 100)}%
</p>
<p className="text-[11.5px] text-[var(--text-muted)]">
{t("stats.correctOf", { correct: stats.correct, total: stats.total })}
</p>
</div>
</section>
<section className="card space-y-3 p-4">
<div className="flex items-center justify-between gap-2">
<p className="text-[11px] font-semibold uppercase tracking-wide text-[var(--text-subtle)]">{t("stats.calendar")}</p>
<p className="flex items-center gap-1.5 text-[10.5px] text-[var(--text-subtle)]">
{t("stats.legendLess")}
<span className="size-3 rounded-[4px] bg-[var(--surface-sunken)]" />
<span className="size-3 rounded-[4px] bg-sky-200/80 dark:bg-sky-400/20" />
<span className="size-3 rounded-[4px] bg-sky-400/70 dark:bg-sky-400/40" />
<span className="size-3 rounded-[4px] grad-ice" />
{t("stats.legendMore")}
</p>
</div>
<div className="grid grid-cols-7 gap-1.5">
{weekdayLabels.map((label, index) => (
<span key={index} className="text-center text-[10px] font-semibold text-[var(--text-subtle)]">
{label}
</span>
))}
{cells.map((cell) => (
<span
key={cell.key}
title={`${cell.key}: ${cell.entry?.count ?? 0}`}
aria-label={`${cell.key}: ${cell.entry?.count ?? 0}`}
className={cn(
"aspect-square rounded-[6px]",
cell.future ? "opacity-30" : "",
intensity(cell.entry?.count ?? 0),
)}
/>
))}
</div>
</section>
{stats.subjects.length > 0 ? (
<section className="card space-y-2 p-4">
<p className="text-[11px] font-semibold uppercase tracking-wide text-[var(--text-subtle)]">{t("stats.bySubject")}</p>
{stats.subjects.map((entry) => (
<div key={entry.label} className="flex items-center justify-between gap-3 text-[13px]">
<span className="min-w-0 truncate font-medium text-[var(--text)]">{entry.label}</span>
<span className="shrink-0 tabular-nums text-[var(--text-muted)]">
{Math.round((entry.correct / Math.max(1, entry.answered)) * 100)}% ·{" "}
{t("stats.correctOf", { correct: entry.correct, total: entry.answered })}
</span>
</div>
))}
</section>
) : null}
<section className="card space-y-2 p-4">
<p className="text-[11px] font-semibold uppercase tracking-wide text-[var(--text-subtle)]">{t("stats.byDeck")}</p>
{stats.decks.map((entry) => (
<div key={entry.label} className="flex items-center justify-between gap-3 text-[13px]">
<span className="min-w-0 truncate font-medium text-[var(--text)]">{entry.label}</span>
<span className="shrink-0 tabular-nums text-[var(--text-muted)]">
{Math.round((entry.correct / Math.max(1, entry.answered)) * 100)}% ·{" "}
{t("stats.correctOf", { correct: entry.correct, total: entry.answered })}
</span>
</div>
))}
</section>
{stats.weakestTopics.length > 0 ? (
<section className="card space-y-2 p-4">
<p className="text-[11px] font-semibold uppercase tracking-wide text-[var(--text-subtle)]">{t("stats.weakest")}</p>
{stats.weakestTopics.map((entry) => (
<div key={entry.label} className="flex items-center justify-between gap-3 text-[13px]">
<span className="min-w-0 truncate font-medium text-[var(--text)]">{entry.label}</span>
<span className="shrink-0 tabular-nums text-[var(--text-muted)]">
{Math.round((entry.correct / Math.max(1, entry.answered)) * 100)}%
</span>
</div>
))}
</section>
) : null}
<div className="flex flex-wrap gap-2">
<Link
href="/review"
className="inline-flex h-11 items-center justify-center rounded-[14px] btn-secondary px-5 text-[15px] font-medium touch-target"
>
{t("review.title")}
</Link>
<Link
href="/exam"
className="inline-flex h-11 items-center justify-center rounded-[14px] btn-ghost px-5 text-[15px] font-medium touch-target"
>
{t("exam.title")}
</Link>
</div>
</div>
);
}

View file

@ -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(

View file

@ -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<ShortAnswerActivity>) {
const { t } = useI18n();
const noHints = useNoHints();
const [value, setValue] = useState("");
const [state, setState] = useState<GradeState>({ status: "idle" });
const textareaRef = useRef<HTMLTextAreaElement>(null);
@ -129,9 +131,11 @@ export function ShortAnswer({ activity, active, language, onAnswered, onNext, is
{t("common.retry")}
</Button>
<ModelErrorActions kind={state.kind} />
<Button size="sm" variant="secondary" onClick={() => setState({ status: "self-check" })}>
{t("short.showAnswer")}
</Button>
{!noHints ? (
<Button size="sm" variant="secondary" onClick={() => setState({ status: "self-check" })}>
{t("short.showAnswer")}
</Button>
) : null}
</div>
</div>
) : null}

View file

@ -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 <Flashcard {...props} activity={activity} />;
case "multiple_choice":
return <MultipleChoice {...props} activity={activity} />;
case "true_false":
return <TrueFalse {...props} activity={activity} />;
case "fill_blank":
return <FillBlank {...props} activity={activity} />;
case "matching":
return <Matching {...props} activity={activity} />;
case "ordering":
return <Ordering {...props} activity={activity} />;
case "short_answer":
return <ShortAnswer {...props} activity={activity} />;
default: {
// Exhaustiveness guard: adding a new activity type without a component
// becomes a TypeScript error.
const never: never = activity;
return <p className="text-sm">{t("activity.unsupported", { json: JSON.stringify(never) })}</p>;
const rendered = (() => {
switch (activity.type) {
case "flashcard":
return <Flashcard {...props} activity={activity} />;
case "multiple_choice":
return <MultipleChoice {...props} activity={activity} />;
case "true_false":
return <TrueFalse {...props} activity={activity} />;
case "fill_blank":
return <FillBlank {...props} activity={activity} />;
case "matching":
return <Matching {...props} activity={activity} />;
case "ordering":
return <Ordering {...props} activity={activity} />;
case "short_answer":
return <ShortAnswer {...props} activity={activity} />;
default: {
// Exhaustiveness guard: adding a new activity type without a component
// becomes a TypeScript error.
const never: never = activity;
return <p className="text-sm">{t("activity.unsupported", { json: JSON.stringify(never) })}</p>;
}
}
}
})();
return <NoHintsProvider value={Boolean(noHints)}>{rendered}</NoHintsProvider>;
}

View file

@ -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<A extends Activity = Activity> = {
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<Activity["type"], { labelKey: string; icon: string; tint: string }> = {
@ -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 <p className="text-sm text-frost-600 dark:text-frost-300">{children}</p>;
}
/**
* 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 <NoHintsContext.Provider value={value}>{children}</NoHintsContext.Provider>;
}
export function useNoHints(): boolean {
return useContext(NoHintsContext);
}

View file

@ -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<Set<string>>(new Set());
const [count, setCount] = useState(10);
const [timer, setTimer] = useState(0);
const [running, setRunning] = useState<SessionItem[] | null>(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 (
<SessionFeed
title={t("exam.title")}
items={running}
timerSeconds={timer || undefined}
noHints
testId="exam-feed"
onExit={() => 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<string, { title: string; answered: number; correct: number }>();
const byTopic = new Map<string, { answered: number; correct: number }>();
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 (
<div className="card space-y-4 p-5">
<div className="text-center">
<p className="text-[11px] font-semibold uppercase tracking-wider text-[var(--text-subtle)]">
{t("exam.results")}
</p>
<p className="mt-2 text-[42px] font-extrabold leading-none text-[var(--text)]">{score}%</p>
<p className="mt-1 text-sm font-medium text-[var(--text-muted)]">
{t("results.gotOf", { correct, total, retry: "" })}
</p>
</div>
<div className="space-y-1.5">
<p className="text-[11px] font-semibold uppercase tracking-wide text-[var(--text-subtle)]">
{t("exam.byDeck")}
</p>
{[...byDeck.entries()].map(([id, entry]) => (
<div key={id} className="flex items-center justify-between gap-3 text-[13px]">
<span className="min-w-0 truncate font-medium text-[var(--text)]">{entry.title}</span>
<span className="shrink-0 tabular-nums text-[var(--text-muted)]">
{entry.correct}/{entry.answered}
</span>
</div>
))}
</div>
{byTopic.size > 0 ? (
<div className="space-y-1.5">
<p className="text-[11px] font-semibold uppercase tracking-wide text-[var(--text-subtle)]">
{t("exam.byTopic")}
</p>
{[...byTopic.entries()].map(([topic, entry]) => (
<div key={topic} className="flex items-center justify-between gap-3 text-[13px]">
<span className="min-w-0 truncate font-medium text-[var(--text)]">{topic}</span>
<span className="shrink-0 tabular-nums text-[var(--text-muted)]">
{entry.correct}/{entry.answered}
</span>
</div>
))}
</div>
) : null}
<div className="flex flex-wrap justify-center gap-2">
<Button size="lg" className="touch-target" onClick={() => router.push("/")}>
{t("review.done")}
</Button>
{correct < total ? (
<Button size="lg" variant="secondary" className="touch-target" onClick={() => router.push("/review?mistakes=1")}>
{t("exam.studyMistakes")}
</Button>
) : null}
</div>
</div>
);
}}
/>
);
}
return (
<div className="mx-auto max-w-2xl space-y-5">
<section className="space-y-1.5">
<h1 className="text-[26px] font-semibold leading-tight tracking-[-0.02em] text-[var(--text)]">
{t("exam.title")}
</h1>
<p className="text-[14px] text-frost-600 dark:text-frost-300">{t("exam.subtitle")}</p>
</section>
<section className="card space-y-2 p-4">
<p className="text-[11px] font-semibold uppercase tracking-wide text-[var(--text-subtle)]">{t("exam.pickDecks")}</p>
{decks.length === 0 ? (
<p className="text-[13px] text-[var(--text-muted)]">{t("exam.noDecks")}</p>
) : (
<ul className="space-y-1.5">
{decks.map((deck) => {
const active = selected.has(deck.id);
return (
<li key={deck.id}>
<button
type="button"
aria-pressed={active}
onClick={() => toggle(deck.id)}
className={cn(
"touch-target flex w-full items-center gap-3 rounded-xl border px-3 py-2 text-left transition",
active
? "border-accent-400 bg-[var(--surface-strong)]"
: "border-[var(--hairline)] bg-[var(--surface)] hover:bg-[var(--hover)]",
)}
>
<span
aria-hidden
className={cn(
"grid size-5 shrink-0 place-items-center rounded-md border text-[11px] font-bold",
active ? "grad-ice border-transparent text-[#06111f]" : "border-[var(--hairline)]",
)}
>
{active ? "✓" : ""}
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-[14px] font-semibold text-[var(--text)]">{deck.title}</span>
<span className="block text-[11.5px] text-[var(--text-subtle)]">
{t("deck.activities", { n: deck.activities.length })}
</span>
</span>
</button>
</li>
);
})}
</ul>
)}
</section>
<section className="card space-y-3 p-4">
<p className="text-[11px] font-semibold uppercase tracking-wide text-[var(--text-subtle)]">{t("exam.questions")}</p>
<div className="segmented" role="group" aria-label={t("exam.questions")}>
{COUNTS.map((option) => (
<button
key={option}
type="button"
aria-pressed={count === option}
onClick={() => {
haptic("toggle");
setCount(option);
}}
>
<span className="seg-label">{option}</span>
</button>
))}
</div>
<p className="text-[11px] font-semibold uppercase tracking-wide text-[var(--text-subtle)]">{t("exam.timer")}</p>
<div className="segmented" role="group" aria-label={t("exam.timer")}>
{TIMERS.map((option) => (
<button
key={option}
type="button"
aria-pressed={timer === option}
onClick={() => {
haptic("toggle");
setTimer(option);
}}
>
<span className="seg-label">{option === 0 ? t("exam.timerOff") : `${option / 60} min`}</span>
</button>
))}
</div>
</section>
<Button
size="lg"
className="touch-target w-full"
disabled={pool.length === 0}
onClick={start}
>
{t("exam.start", { n: effectiveCount })}
</Button>
</div>
);
}

View file

@ -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<typeof backupSchema>;
/* Building and downloading */
/* ------------------------------------------------------------------ */
export function buildBackup(): BackupFile {
export async function buildBackup(): Promise<BackupFile> {
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<BackupFile> {
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);
}

View file

@ -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"),
},
};
}

252
lib/history.ts Normal file
View file

@ -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<string, SrsRecord>();
let cachedAnswers: AnswerRecord[] = EMPTY_ANSWERS;
let cachedSrs: SrsRecord[] = EMPTY_SRS;
const listeners = new Set<Listener>();
let hydrated = false;
let hydration: Promise<void> | null = null;
function notify(): void {
cachedAnswers = answers;
cachedSrs = [...schedules.values()];
listeners.forEach((listener) => listener());
}
function openDb(): Promise<IDBDatabase> {
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<T>(mode: IDBTransactionMode, run: (db: IDBDatabase) => T): Promise<T> {
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<T>(request: IDBRequest<T>): Promise<T> {
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<void> {
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<AnswerRecord[]>),
requestToPromise(transaction.objectStore(SRS).getAll() as IDBRequest<SrsRecord[]>),
]);
// 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<void> {
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<void> {
await replaceLearning([], []);
}

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…",

173
lib/learning.ts Normal file
View file

@ -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<string, Deck> {
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<string, AnswerRecord>();
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<string, DayStat>();
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<string, BucketStat>();
const subjects = new Map<string, BucketStat>();
const topics = new Map<string, BucketStat>();
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),
};
}

64
lib/push-store.ts Normal file
View file

@ -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<PushSubscriptionRecord[]> {
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<void> {
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<number> {
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<number> {
const current = await readSubscriptions(dir);
const next = current.filter((entry) => entry.endpoint !== endpoint);
await writeSubscriptions(dir, next);
return next.length;
}

View file

@ -280,3 +280,33 @@ export const deckSchema: z.ZodType<Deck> = 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<typeof answerRecordSchema>;
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<typeof srsRecordSchema>;

View file

@ -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<BrowserSettings>;
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);
}

View file

@ -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<void> {
/* Payload */
/* ------------------------------------------------------------------ */
function buildPayload(): SyncPayload {
async function buildPayload(): Promise<SyncPayload> {
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,

131
package-lock.json generated
View file

@ -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",

View file

@ -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",

View file

@ -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;

View file

@ -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}`);