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:
parent
ead6bf94db
commit
e4a8e0ba01
33 changed files with 2504 additions and 47 deletions
15
.env.example
15
.env.example
|
|
@ -45,6 +45,21 @@ TEXT_MODEL=gpt-oss:120b-cloud
|
||||||
# RATE_LIMIT_HEALTH=90
|
# RATE_LIMIT_HEALTH=90
|
||||||
# Encrypted sync (no model calls, but shouldn't be hammered).
|
# Encrypted sync (no model calls, but shouldn't be hammered).
|
||||||
# RATE_LIMIT_SYNC=60
|
# 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 -------------------------------------------------------
|
# ENCRYPTED SYNC STORAGE -------------------------------------------------------
|
||||||
# Opt-in sync uploads an opaque AES-GCM blob; the server can't read it and
|
# Opt-in sync uploads an opaque AES-GCM blob; the server can't read it and
|
||||||
|
|
|
||||||
38
README.md
38
README.md
|
|
@ -62,6 +62,17 @@ no database, no data leaving your machine except the requests you send to Ollama
|
||||||
study sessions and homework guides; wizard drafts survive a reload or an OS kill; re-tapping
|
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
|
the active tab scrolls to the top; an offline pill appears and generation is disabled with
|
||||||
a reason until the connection is back.
|
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,
|
- **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
|
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
|
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` |
|
| `POST /api/grade` | 40 per 5 minutes | `RATE_LIMIT_GRADE` |
|
||||||
| `GET /api/health` | 90 per 5 minutes | `RATE_LIMIT_HEALTH` |
|
| `GET /api/health` | 90 per 5 minutes | `RATE_LIMIT_HEALTH` |
|
||||||
| `POST/GET/DELETE /api/sync` | 60 per 5 minutes | `RATE_LIMIT_SYNC` |
|
| `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` |
|
| 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
|
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` |
|
| `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 |
|
| `GET /api/sync?id=` | – | `{ updatedAt, envelope }` or 404 |
|
||||||
| `DELETE /api/sync?id=` | – | `{ ok: true }` (revokes the synced copy) |
|
| `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
|
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
|
`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
|
`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
|
key. Deleting the synced copy is one button in Settings (and works even on a device that can't
|
||||||
unlock the data).
|
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. |
|
| 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`. |
|
| 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. |
|
| “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. |
|
| “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. |
|
| “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>. |
|
| “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
|
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).
|
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
|
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`).
|
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
|
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
131
app/api/push/route.ts
Normal 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" } });
|
||||||
|
}
|
||||||
89
app/api/push/test/route.ts
Normal file
89
app/api/push/test/route.ts
Normal 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
26
app/exam/page.tsx
Normal 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
22
app/review/page.tsx
Normal 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
26
app/stats/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -50,9 +50,9 @@ export function DataSection() {
|
||||||
if (fileRef.current) fileRef.current.value = "";
|
if (fileRef.current) fileRef.current.value = "";
|
||||||
};
|
};
|
||||||
|
|
||||||
const apply = (mode: "merge" | "replace") => {
|
const apply = async (mode: "merge" | "replace") => {
|
||||||
if (!pending) return;
|
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 }));
|
setNotice(t("settings.importDone", { decks: done.decks, homework: done.homework }));
|
||||||
reset();
|
reset();
|
||||||
};
|
};
|
||||||
|
|
@ -69,7 +69,7 @@ export function DataSection() {
|
||||||
<p className="text-[11.5px] leading-relaxed text-[var(--text-subtle)]">{t("settings.dataNote")}</p>
|
<p className="text-[11.5px] leading-relaxed text-[var(--text-subtle)]">{t("settings.dataNote")}</p>
|
||||||
|
|
||||||
<div className="grid gap-2">
|
<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")}
|
{t("settings.exportAll")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" variant="secondary" className="touch-target w-full" onClick={() => fileRef.current?.click()}>
|
<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.importMergeNote")}</p>
|
||||||
<p className="text-[11px] leading-relaxed text-[var(--text-subtle)]">{t("settings.importReplaceNote")}</p>
|
<p className="text-[11px] leading-relaxed text-[var(--text-subtle)]">{t("settings.importReplaceNote")}</p>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<Button size="sm" onClick={() => apply("merge")}>
|
<Button size="sm" onClick={() => void apply("merge")}>
|
||||||
{t("settings.importMerge")}
|
{t("settings.importMerge")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" variant="danger" onClick={() => apply("replace")}>
|
<Button size="sm" variant="danger" onClick={() => void apply("replace")}>
|
||||||
{t("settings.importReplace")}
|
{t("settings.importReplace")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" variant="ghost" onClick={reset}>
|
<Button size="sm" variant="ghost" onClick={reset}>
|
||||||
|
|
|
||||||
209
components/LearningSection.tsx
Normal file
209
components/LearningSection.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -9,10 +9,13 @@ import { FolderBar } from "@/components/FolderBar";
|
||||||
import { HomeworkCard } from "@/components/HomeworkCard";
|
import { HomeworkCard } from "@/components/HomeworkCard";
|
||||||
import { FolderIcon, UploadIcon } from "@/components/icons";
|
import { FolderIcon, UploadIcon } from "@/components/icons";
|
||||||
import { SegmentedPill } from "@/components/SegmentedPill";
|
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 { folderStore, renameFolder } from "@/lib/folders";
|
||||||
import { deleteHomework, homeworkStore, sortHomework } from "@/lib/homework";
|
import { deleteHomework, homeworkStore, sortHomework } from "@/lib/homework";
|
||||||
|
import { learningStore } from "@/lib/history";
|
||||||
import { useI18n } from "@/lib/i18n";
|
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 { ALL_FOLDERS, folderSelection, useSelectedFolder } from "@/lib/nav";
|
||||||
import { springGentle } from "@/lib/motion";
|
import { springGentle } from "@/lib/motion";
|
||||||
import { UNORGANIZED_ID, type Deck } from "@/lib/schemas";
|
import { UNORGANIZED_ID, type Deck } from "@/lib/schemas";
|
||||||
|
|
@ -52,6 +55,26 @@ export function LibraryScreen({ initialTab = "decks" }: { initialTab?: "decks" |
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setNudgeDue(backupNudgeDue(decks.length));
|
setNudgeDue(backupNudgeDue(decks.length));
|
||||||
}, [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 importRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const sorted = useMemo(() => sortDecks(decks), [decks]);
|
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}
|
exit={isPhone ? { opacity: 0, y: -6, transition: { duration: 0.16 } } : undefined}
|
||||||
transition={springGentle}
|
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 ---------------- */}
|
{/* gentle backup nudge — dismissible, never blocks ---------------- */}
|
||||||
{nudgeDue ? (
|
{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">
|
<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
117
components/ReviewClient.tsx
Normal 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
232
components/SessionFeed.tsx
Normal 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")}`;
|
||||||
|
}
|
||||||
|
|
@ -9,6 +9,7 @@ import { modelErrorCopy } from "@/lib/model-errors";
|
||||||
import { springSheet } from "@/lib/motion";
|
import { springSheet } from "@/lib/motion";
|
||||||
import { ModelErrorActions } from "@/components/ModelErrorActions";
|
import { ModelErrorActions } from "@/components/ModelErrorActions";
|
||||||
import { DataSection } from "@/components/DataSection";
|
import { DataSection } from "@/components/DataSection";
|
||||||
|
import { LearningSection } from "@/components/LearningSection";
|
||||||
import { SyncSection } from "@/components/SyncSection";
|
import { SyncSection } from "@/components/SyncSection";
|
||||||
import { clearApiKey, saveSettings, settingsStore } from "@/lib/settings";
|
import { clearApiKey, saveSettings, settingsStore } from "@/lib/settings";
|
||||||
import { useBackClose } from "@/lib/use-back-close";
|
import { useBackClose } from "@/lib/use-back-close";
|
||||||
|
|
@ -393,6 +394,7 @@ export function SettingsDialog({
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* ------------------------------------------- data & sync */}
|
{/* ------------------------------------------- data & sync */}
|
||||||
|
<LearningSection />
|
||||||
<DataSection />
|
<DataSection />
|
||||||
<SyncSection />
|
<SyncSection />
|
||||||
|
|
||||||
|
|
@ -708,6 +710,7 @@ export function SettingsDialog({
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* ------------------------------------------- data & sync */}
|
{/* ------------------------------------------- data & sync */}
|
||||||
|
<LearningSection />
|
||||||
<DataSection />
|
<DataSection />
|
||||||
<SyncSection />
|
<SyncSection />
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,8 @@ import { AnimatePresence, motion } from "framer-motion";
|
||||||
import {
|
import {
|
||||||
ChevronLeftIcon,
|
ChevronLeftIcon,
|
||||||
ChevronRightIcon,
|
ChevronRightIcon,
|
||||||
|
CompassIcon,
|
||||||
|
FlameIcon,
|
||||||
FolderIcon,
|
FolderIcon,
|
||||||
GearIcon,
|
GearIcon,
|
||||||
LayersIcon,
|
LayersIcon,
|
||||||
|
|
@ -294,6 +296,24 @@ function NavContent({
|
||||||
|
|
||||||
{/* footer ---------------------------------------------------------- */}
|
{/* footer ---------------------------------------------------------- */}
|
||||||
<div className="mt-2 space-y-0.5 border-t border-[var(--hairline)] pt-2">
|
<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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => openSettings()}
|
onClick={() => openSettings()}
|
||||||
|
|
|
||||||
214
components/StatsScreen.tsx
Normal file
214
components/StatsScreen.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -9,6 +9,7 @@ import { celebrate } from "@/lib/confetti";
|
||||||
import { ApiError, generateDeck } from "@/lib/client-api";
|
import { ApiError, generateDeck } from "@/lib/client-api";
|
||||||
import { materialiseActivities } from "@/lib/deck-ops";
|
import { materialiseActivities } from "@/lib/deck-ops";
|
||||||
import { haptic } from "@/lib/haptics";
|
import { haptic } from "@/lib/haptics";
|
||||||
|
import { recordAnswer } from "@/lib/history";
|
||||||
import { useI18n } from "@/lib/i18n";
|
import { useI18n } from "@/lib/i18n";
|
||||||
import type { Activity, Deck } from "@/lib/schemas";
|
import type { Activity, Deck } from "@/lib/schemas";
|
||||||
import { recordAttempt, upsertDeck } from "@/lib/storage";
|
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
|
if (current[activity.id]) return current; // first answer counts
|
||||||
return { ...current, [activity.id]: { correct, userAnswer: meta?.userAnswer } };
|
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) {
|
if (correct) {
|
||||||
haptic("success");
|
haptic("success");
|
||||||
const nextStreak = streak + 1;
|
const nextStreak = streak + 1;
|
||||||
|
|
@ -136,7 +146,7 @@ export function StudyFeed({ deck }: { deck: Deck }) {
|
||||||
setStreak(0);
|
setStreak(0);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[streak],
|
[currentDeck.id, currentDeck.title, streak],
|
||||||
);
|
);
|
||||||
|
|
||||||
const missed = useMemo(
|
const missed = useMemo(
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import {
|
||||||
Hint,
|
Hint,
|
||||||
PromptCard,
|
PromptCard,
|
||||||
QuestionText,
|
QuestionText,
|
||||||
|
useNoHints,
|
||||||
type ActivityComponentProps,
|
type ActivityComponentProps,
|
||||||
} from "./shared";
|
} from "./shared";
|
||||||
|
|
||||||
|
|
@ -30,6 +31,7 @@ type GradeState =
|
||||||
|
|
||||||
export function ShortAnswer({ activity, active, language, onAnswered, onNext, isLast, nextLabel }: ActivityComponentProps<ShortAnswerActivity>) {
|
export function ShortAnswer({ activity, active, language, onAnswered, onNext, isLast, nextLabel }: ActivityComponentProps<ShortAnswerActivity>) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
const noHints = useNoHints();
|
||||||
const [value, setValue] = useState("");
|
const [value, setValue] = useState("");
|
||||||
const [state, setState] = useState<GradeState>({ status: "idle" });
|
const [state, setState] = useState<GradeState>({ status: "idle" });
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
|
@ -129,9 +131,11 @@ export function ShortAnswer({ activity, active, language, onAnswered, onNext, is
|
||||||
{t("common.retry")}
|
{t("common.retry")}
|
||||||
</Button>
|
</Button>
|
||||||
<ModelErrorActions kind={state.kind} />
|
<ModelErrorActions kind={state.kind} />
|
||||||
<Button size="sm" variant="secondary" onClick={() => setState({ status: "self-check" })}>
|
{!noHints ? (
|
||||||
{t("short.showAnswer")}
|
<Button size="sm" variant="secondary" onClick={() => setState({ status: "self-check" })}>
|
||||||
</Button>
|
{t("short.showAnswer")}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ import { Ordering } from "./Ordering";
|
||||||
import { ShortAnswer } from "./ShortAnswer";
|
import { ShortAnswer } from "./ShortAnswer";
|
||||||
import { TrueFalse } from "./TrueFalse";
|
import { TrueFalse } from "./TrueFalse";
|
||||||
import { t } from "@/lib/i18n";
|
import { t } from "@/lib/i18n";
|
||||||
import { type ActivityComponentProps } from "./shared";
|
import { NoHintsProvider, type ActivityComponentProps } from "./shared";
|
||||||
|
|
||||||
export type { ActivityComponentProps } from "./shared";
|
export type { ActivityComponentProps } from "./shared";
|
||||||
export { TYPE_META } 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.
|
* Every activity type is a self-contained React component.
|
||||||
*/
|
*/
|
||||||
export function ActivityRenderer(props: ActivityComponentProps) {
|
export function ActivityRenderer(props: ActivityComponentProps) {
|
||||||
const { activity } = props;
|
const { activity, noHints } = props;
|
||||||
|
|
||||||
switch (activity.type) {
|
const rendered = (() => {
|
||||||
case "flashcard":
|
switch (activity.type) {
|
||||||
return <Flashcard {...props} activity={activity} />;
|
case "flashcard":
|
||||||
case "multiple_choice":
|
return <Flashcard {...props} activity={activity} />;
|
||||||
return <MultipleChoice {...props} activity={activity} />;
|
case "multiple_choice":
|
||||||
case "true_false":
|
return <MultipleChoice {...props} activity={activity} />;
|
||||||
return <TrueFalse {...props} activity={activity} />;
|
case "true_false":
|
||||||
case "fill_blank":
|
return <TrueFalse {...props} activity={activity} />;
|
||||||
return <FillBlank {...props} activity={activity} />;
|
case "fill_blank":
|
||||||
case "matching":
|
return <FillBlank {...props} activity={activity} />;
|
||||||
return <Matching {...props} activity={activity} />;
|
case "matching":
|
||||||
case "ordering":
|
return <Matching {...props} activity={activity} />;
|
||||||
return <Ordering {...props} activity={activity} />;
|
case "ordering":
|
||||||
case "short_answer":
|
return <Ordering {...props} activity={activity} />;
|
||||||
return <ShortAnswer {...props} activity={activity} />;
|
case "short_answer":
|
||||||
default: {
|
return <ShortAnswer {...props} activity={activity} />;
|
||||||
// Exhaustiveness guard: adding a new activity type without a component
|
default: {
|
||||||
// becomes a TypeScript error.
|
// Exhaustiveness guard: adding a new activity type without a component
|
||||||
const never: never = activity;
|
// becomes a TypeScript error.
|
||||||
return <p className="text-sm">{t("activity.unsupported", { json: JSON.stringify(never) })}</p>;
|
const never: never = activity;
|
||||||
|
return <p className="text-sm">{t("activity.unsupported", { json: JSON.stringify(never) })}</p>;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
})();
|
||||||
|
|
||||||
|
return <NoHintsProvider value={Boolean(noHints)}>{rendered}</NoHintsProvider>;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { motion, useReducedMotion } from "framer-motion";
|
import { motion, useReducedMotion } from "framer-motion";
|
||||||
|
import { createContext, useContext } from "react";
|
||||||
|
|
||||||
import { useI18n } from "@/lib/i18n";
|
import { useI18n } from "@/lib/i18n";
|
||||||
import type { Activity } from "@/lib/schemas";
|
import type { Activity } from "@/lib/schemas";
|
||||||
|
|
@ -21,6 +22,8 @@ export type ActivityComponentProps<A extends Activity = Activity> = {
|
||||||
isLast: boolean;
|
isLast: boolean;
|
||||||
/** Overrides the feedback button label (the homework guide uses this). */
|
/** Overrides the feedback button label (the homework guide uses this). */
|
||||||
nextLabel?: string;
|
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 }> = {
|
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". */
|
/** Small hint row under a question, e.g. "Tap a term, then its definition". */
|
||||||
export function Hint({ children }: { children: React.ReactNode }) {
|
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>;
|
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);
|
||||||
|
}
|
||||||
|
|
|
||||||
254
components/exam/ExamScreen.tsx
Normal file
254
components/exam/ExamScreen.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -14,8 +14,9 @@ import { z } from "zod";
|
||||||
import { applyTheme } from "@/components/ThemeToggle";
|
import { applyTheme } from "@/components/ThemeToggle";
|
||||||
import { replaceFolders, folderStore } from "./folders";
|
import { replaceFolders, folderStore } from "./folders";
|
||||||
import { homeworkStore, replaceHomework } from "./homework";
|
import { homeworkStore, replaceHomework } from "./homework";
|
||||||
|
import { ensureLearningHydrated, learningStore, replaceLearning } from "./history";
|
||||||
import { setLocale, t } from "./i18n";
|
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 { saveSettings, settingsStore } from "./settings";
|
||||||
import { deckStore, replaceDecks } from "./storage";
|
import { deckStore, replaceDecks } from "./storage";
|
||||||
|
|
||||||
|
|
@ -25,12 +26,16 @@ const settingsSchema = z.object({
|
||||||
haptics: z.boolean().optional(),
|
haptics: z.boolean().optional(),
|
||||||
locale: z.enum(["en", "cs"]).optional(),
|
locale: z.enum(["en", "cs"]).optional(),
|
||||||
theme: z.enum(["light", "dark"]).optional(),
|
theme: z.enum(["light", "dark"]).optional(),
|
||||||
|
dailyGoal: z.number().int().min(1).max(500).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const backupDataSchema = z.object({
|
export const backupDataSchema = z.object({
|
||||||
decks: z.array(deckSchema).max(2000),
|
decks: z.array(deckSchema).max(2000),
|
||||||
folders: z.array(folderSchema).max(500),
|
folders: z.array(folderSchema).max(500),
|
||||||
homework: z.array(homeworkRecordSchema).max(2000),
|
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({
|
export const backupSchema = z.object({
|
||||||
|
|
@ -47,7 +52,8 @@ export type BackupFile = z.infer<typeof backupSchema>;
|
||||||
/* Building and downloading */
|
/* Building and downloading */
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
export function buildBackup(): BackupFile {
|
export async function buildBackup(): Promise<BackupFile> {
|
||||||
|
await ensureLearningHydrated();
|
||||||
return {
|
return {
|
||||||
app: "freezmo",
|
app: "freezmo",
|
||||||
kind: "backup",
|
kind: "backup",
|
||||||
|
|
@ -57,8 +63,11 @@ export function buildBackup(): BackupFile {
|
||||||
decks: deckStore.getSnapshot(),
|
decks: deckStore.getSnapshot(),
|
||||||
folders: folderStore.getSnapshot(),
|
folders: folderStore.getSnapshot(),
|
||||||
homework: homeworkStore.getSnapshot(),
|
homework: homeworkStore.getSnapshot(),
|
||||||
|
history: learningStore.getAnswersSnapshot(),
|
||||||
|
srs: learningStore.getSrsSnapshot(),
|
||||||
settings: {
|
settings: {
|
||||||
haptics: settingsStore.getSnapshot().haptics,
|
haptics: settingsStore.getSnapshot().haptics,
|
||||||
|
dailyGoal: settingsStore.getSnapshot().dailyGoal,
|
||||||
locale: (window.localStorage.getItem("freezmo.locale.v1") as "en" | "cs" | null) ?? undefined,
|
locale: (window.localStorage.getItem("freezmo.locale.v1") as "en" | "cs" | null) ?? undefined,
|
||||||
theme: (window.localStorage.getItem("freezmo.theme") as "light" | "dark" | 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 {
|
export async function downloadBackup(): Promise<BackupFile> {
|
||||||
const backup = buildBackup();
|
const backup = await buildBackup();
|
||||||
const blob = new Blob([JSON.stringify(backup, null, 2)], { type: "application/json" });
|
const blob = new Blob([JSON.stringify(backup, null, 2)], { type: "application/json" });
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
const anchor = document.createElement("a");
|
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). */
|
/** 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 data = backup.data;
|
||||||
const decks =
|
const decks =
|
||||||
mode === "replace" ? data.decks : mergeById(deckStore.getSnapshot(), data.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);
|
replaceDecks(decks);
|
||||||
replaceHomework(homework);
|
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;
|
const settings = data.settings;
|
||||||
if (settings) {
|
if (settings) {
|
||||||
if (typeof settings.haptics === "boolean") saveSettings({ haptics: settings.haptics });
|
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.locale) setLocale(settings.locale);
|
||||||
if (settings.theme) applyTheme(settings.theme);
|
if (settings.theme) applyTheme(settings.theme);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ export type ServerConfig = {
|
||||||
homework: number;
|
homework: number;
|
||||||
health: number;
|
health: number;
|
||||||
sync: number;
|
sync: number;
|
||||||
|
push: number;
|
||||||
};
|
};
|
||||||
sync: {
|
sync: {
|
||||||
/** Where opaque encrypted sync blobs live (SYNC_DIR). */
|
/** 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). */
|
/** Total cap for the whole directory in bytes (SYNC_TOTAL_BYTES). */
|
||||||
totalBytes: number;
|
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. */
|
/** 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),
|
health: positiveInt(process.env.RATE_LIMIT_HEALTH, 90),
|
||||||
// Sync is cheap (no model calls) but shouldn't be hammered.
|
// Sync is cheap (no model calls) but shouldn't be hammered.
|
||||||
sync: positiveInt(process.env.RATE_LIMIT_SYNC, 60),
|
sync: positiveInt(process.env.RATE_LIMIT_SYNC, 60),
|
||||||
|
// Push subscription changes and test sends.
|
||||||
|
push: positiveInt(process.env.RATE_LIMIT_PUSH, 30),
|
||||||
},
|
},
|
||||||
sync: {
|
sync: {
|
||||||
dir: process.env.SYNC_DIR?.trim() || path.join(process.cwd(), ".freezmo", "sync"),
|
dir: process.env.SYNC_DIR?.trim() || path.join(process.cwd(), ".freezmo", "sync"),
|
||||||
maxBytes: positiveInt(process.env.SYNC_MAX_BYTES, 12 * 1024 * 1024),
|
maxBytes: positiveInt(process.env.SYNC_MAX_BYTES, 12 * 1024 * 1024),
|
||||||
totalBytes: positiveInt(process.env.SYNC_TOTAL_BYTES, 1024 * 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
252
lib/history.ts
Normal 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([], []);
|
||||||
|
}
|
||||||
148
lib/i18n-dict.ts
148
lib/i18n-dict.ts
|
|
@ -764,6 +764,12 @@ const en: Record<string, string> = {
|
||||||
"server.syncTooLarge": "This sync upload is too large.",
|
"server.syncTooLarge": "This sync upload is too large.",
|
||||||
"server.syncStorageFull": "The sync storage is full. Delete some synced data and try again.",
|
"server.syncStorageFull": "The sync storage is full. Delete some synced data and try again.",
|
||||||
"server.syncNotFound": "No synced data found for that ID.",
|
"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 -------------------------------------------------- */
|
/* folders / files -------------------------------------------------- */
|
||||||
"folder.nameRequired": "Give the folder a name.",
|
"folder.nameRequired": "Give the folder a name.",
|
||||||
|
|
@ -782,6 +788,74 @@ const en: Record<string, string> = {
|
||||||
"error.openOllama": "Open Ollama",
|
"error.openOllama": "Open Ollama",
|
||||||
"error.throttledTitle": "Ollama is busy",
|
"error.throttledTitle": "Ollama is busy",
|
||||||
"error.throttledBody": "Ollama is temporarily rate limiting requests. Try again in a moment.",
|
"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.readingPage": "Reading page {n} of {total}…",
|
||||||
"doc.readingWord": "Reading the Word document…",
|
"doc.readingWord": "Reading the Word document…",
|
||||||
"doc.readingSlides": "Reading the slides…",
|
"doc.readingSlides": "Reading the slides…",
|
||||||
|
|
@ -1492,6 +1566,12 @@ const cs: Record<string, string> = {
|
||||||
"server.syncTooLarge": "Tahle synchronizace je příliš velká.",
|
"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.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.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 -------------------------------------------------- */
|
/* folders / files -------------------------------------------------- */
|
||||||
"folder.nameRequired": "Dej složce název.",
|
"folder.nameRequired": "Dej složce název.",
|
||||||
|
|
@ -1510,6 +1590,74 @@ const cs: Record<string, string> = {
|
||||||
"error.openOllama": "Otevřít Ollamu",
|
"error.openOllama": "Otevřít Ollamu",
|
||||||
"error.throttledTitle": "Ollama je zaneprázdněná",
|
"error.throttledTitle": "Ollama je zaneprázdněná",
|
||||||
"error.throttledBody": "Ollama dočasně omezuje požadavky. Zkus to za chvíli znovu.",
|
"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.readingPage": "Čtu stranu {n} z {total}…",
|
||||||
"doc.readingWord": "Čtu dokument Word…",
|
"doc.readingWord": "Čtu dokument Word…",
|
||||||
"doc.readingSlides": "Čtu snímky…",
|
"doc.readingSlides": "Čtu snímky…",
|
||||||
|
|
|
||||||
173
lib/learning.ts
Normal file
173
lib/learning.ts
Normal 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
64
lib/push-store.ts
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -280,3 +280,33 @@ export const deckSchema: z.ZodType<Deck> = z.object({
|
||||||
attempts: z.number(),
|
attempts: z.number(),
|
||||||
bestScore: z.number().optional(),
|
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>;
|
||||||
|
|
|
||||||
|
|
@ -22,9 +22,13 @@ export type BrowserSettings = {
|
||||||
apiKey: string;
|
apiKey: string;
|
||||||
/** Best-effort haptics on supported devices (phones). On by default. */
|
/** Best-effort haptics on supported devices (phones). On by default. */
|
||||||
haptics: boolean;
|
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;
|
let cached: BrowserSettings | null = null;
|
||||||
const listeners = new Set<() => void>();
|
const listeners = new Set<() => void>();
|
||||||
|
|
@ -35,9 +39,11 @@ function read(): BrowserSettings {
|
||||||
const raw = window.localStorage.getItem(STORAGE_KEY);
|
const raw = window.localStorage.getItem(STORAGE_KEY);
|
||||||
if (!raw) return EMPTY;
|
if (!raw) return EMPTY;
|
||||||
const parsed = JSON.parse(raw) as Partial<BrowserSettings>;
|
const parsed = JSON.parse(raw) as Partial<BrowserSettings>;
|
||||||
|
const goal = Number.parseInt(String(parsed.dailyGoal ?? DEFAULT_GOAL), 10);
|
||||||
return {
|
return {
|
||||||
apiKey: typeof parsed.apiKey === "string" ? parsed.apiKey.slice(0, 512) : "",
|
apiKey: typeof parsed.apiKey === "string" ? parsed.apiKey.slice(0, 512) : "",
|
||||||
haptics: parsed.haptics !== false,
|
haptics: parsed.haptics !== false,
|
||||||
|
dailyGoal: Number.isFinite(goal) ? Math.min(500, Math.max(1, goal)) : DEFAULT_GOAL,
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
return EMPTY;
|
return EMPTY;
|
||||||
|
|
@ -47,8 +53,11 @@ function read(): BrowserSettings {
|
||||||
function write(next: BrowserSettings): void {
|
function write(next: BrowserSettings): void {
|
||||||
cached = next;
|
cached = next;
|
||||||
try {
|
try {
|
||||||
if (next.apiKey || next.haptics === false) {
|
if (next.apiKey || next.haptics === false || next.dailyGoal !== DEFAULT_GOAL) {
|
||||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify({ apiKey: next.apiKey, haptics: next.haptics }));
|
window.localStorage.setItem(
|
||||||
|
STORAGE_KEY,
|
||||||
|
JSON.stringify({ apiKey: next.apiKey, haptics: next.haptics, dailyGoal: next.dailyGoal }),
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
window.localStorage.removeItem(STORAGE_KEY);
|
window.localStorage.removeItem(STORAGE_KEY);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import { backupDataSchema } from "./backup";
|
||||||
import { ApiError } from "./client-api";
|
import { ApiError } from "./client-api";
|
||||||
import { replaceFolders } from "./folders";
|
import { replaceFolders } from "./folders";
|
||||||
import { replaceHomework } from "./homework";
|
import { replaceHomework } from "./homework";
|
||||||
|
import { ensureLearningHydrated, learningStore, replaceLearning } from "./history";
|
||||||
import { localeHeader, t } from "./i18n";
|
import { localeHeader, t } from "./i18n";
|
||||||
import { authHeaders } from "./settings";
|
import { authHeaders } from "./settings";
|
||||||
import { deckStore, replaceDecks } from "./storage";
|
import { deckStore, replaceDecks } from "./storage";
|
||||||
|
|
@ -159,7 +160,8 @@ async function apiDelete(id: string): Promise<void> {
|
||||||
/* Payload */
|
/* Payload */
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
function buildPayload(): SyncPayload {
|
async function buildPayload(): Promise<SyncPayload> {
|
||||||
|
await ensureLearningHydrated();
|
||||||
return {
|
return {
|
||||||
app: "freezmo",
|
app: "freezmo",
|
||||||
kind: "sync",
|
kind: "sync",
|
||||||
|
|
@ -169,6 +171,8 @@ function buildPayload(): SyncPayload {
|
||||||
decks: deckStore.getSnapshot(),
|
decks: deckStore.getSnapshot(),
|
||||||
folders: folderStore.getSnapshot(),
|
folders: folderStore.getSnapshot(),
|
||||||
homework: homeworkStore.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);
|
replaceDecks(parsed.data.decks);
|
||||||
replaceHomework(parsed.data.homework);
|
replaceHomework(parsed.data.homework);
|
||||||
});
|
});
|
||||||
|
await replaceLearning(parsed.data.history ?? [], parsed.data.srs ?? []);
|
||||||
setSyncRecord({ lastSyncedAt: remote.updatedAt, dirtyAt: null, conflict: false });
|
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" };
|
return { status: "up-to-date" };
|
||||||
}
|
}
|
||||||
|
|
||||||
const { iv, ciphertext } = await encryptWithKey(key, buildPayload());
|
const { iv, ciphertext } = await encryptWithKey(key, await buildPayload());
|
||||||
const envelope: SyncEnvelope = {
|
const envelope: SyncEnvelope = {
|
||||||
v: 1,
|
v: 1,
|
||||||
kdf: KDF,
|
kdf: KDF,
|
||||||
|
|
|
||||||
131
package-lock.json
generated
131
package-lock.json
generated
|
|
@ -16,6 +16,7 @@
|
||||||
"pdfjs-dist": "^6.4.299",
|
"pdfjs-dist": "^6.4.299",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
"react-dom": "19.1.0",
|
"react-dom": "19.1.0",
|
||||||
|
"web-push": "^3.6.7",
|
||||||
"zod": "^4.1.13"
|
"zod": "^4.1.13"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
@ -25,6 +26,7 @@
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
|
"@types/web-push": "^3.6.4",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "15.5.27",
|
"eslint-config-next": "15.5.27",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
|
|
@ -1752,6 +1754,16 @@
|
||||||
"@types/react": "^19.3.0"
|
"@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": {
|
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||||
"version": "8.71.0",
|
"version": "8.71.0",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.71.0.tgz",
|
"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"
|
"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": {
|
"node_modules/ajv": {
|
||||||
"version": "6.15.0",
|
"version": "6.15.0",
|
||||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
|
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
|
||||||
|
|
@ -2630,6 +2651,18 @@
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/ast-types-flow": {
|
||||||
"version": "0.0.8",
|
"version": "0.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz",
|
||||||
|
|
@ -2710,6 +2743,12 @@
|
||||||
],
|
],
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/brace-expansion": {
|
||||||
"version": "1.1.21",
|
"version": "1.1.21",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz",
|
||||||
|
|
@ -2734,6 +2773,12 @@
|
||||||
"node": ">=8"
|
"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": {
|
"node_modules/call-bind": {
|
||||||
"version": "1.0.9",
|
"version": "1.0.9",
|
||||||
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz",
|
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz",
|
||||||
|
|
@ -2967,7 +3012,6 @@
|
||||||
"version": "4.4.3",
|
"version": "4.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ms": "^2.1.3"
|
"ms": "^2.1.3"
|
||||||
|
|
@ -3077,6 +3121,15 @@
|
||||||
"node": ">= 0.4"
|
"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": {
|
"node_modules/emoji-regex": {
|
||||||
"version": "9.2.2",
|
"version": "9.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
|
||||||
|
|
@ -4167,6 +4220,28 @@
|
||||||
"node": ">= 0.4"
|
"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": {
|
"node_modules/ignore": {
|
||||||
"version": "5.3.2",
|
"version": "5.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
||||||
|
|
@ -4783,6 +4858,27 @@
|
||||||
"setimmediate": "^1.0.5"
|
"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": {
|
"node_modules/keyv": {
|
||||||
"version": "4.5.4",
|
"version": "4.5.4",
|
||||||
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
||||||
|
|
@ -5231,6 +5327,12 @@
|
||||||
"node": ">=8.6"
|
"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": {
|
"node_modules/minimatch": {
|
||||||
"version": "3.1.5",
|
"version": "3.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
||||||
|
|
@ -5248,7 +5350,6 @@
|
||||||
"version": "1.2.8",
|
"version": "1.2.8",
|
||||||
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
||||||
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
|
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
|
@ -5273,7 +5374,6 @@
|
||||||
"version": "2.1.3",
|
"version": "2.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/nanoid": {
|
"node_modules/nanoid": {
|
||||||
|
|
@ -6032,6 +6132,12 @@
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/scheduler": {
|
||||||
"version": "0.26.0",
|
"version": "0.26.0",
|
||||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz",
|
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz",
|
||||||
|
|
@ -6790,6 +6896,25 @@
|
||||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/which": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@
|
||||||
"pdfjs-dist": "^6.4.299",
|
"pdfjs-dist": "^6.4.299",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
"react-dom": "19.1.0",
|
"react-dom": "19.1.0",
|
||||||
|
"web-push": "^3.6.7",
|
||||||
"zod": "^4.1.13"
|
"zod": "^4.1.13"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
@ -28,6 +29,7 @@
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
|
"@types/web-push": "^3.6.4",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "15.5.27",
|
"eslint-config-next": "15.5.27",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
|
|
|
||||||
39
public/sw.js
39
public/sw.js
|
|
@ -9,7 +9,7 @@
|
||||||
*
|
*
|
||||||
* Bumping CACHE_VERSION is how a new deploy invalidates old assets.
|
* 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 SHELL_CACHE = `${CACHE_VERSION}-shell`;
|
||||||
const ASSET_CACHE = `${CACHE_VERSION}-assets`;
|
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) => {
|
self.addEventListener("fetch", (event) => {
|
||||||
const request = event.request;
|
const request = event.request;
|
||||||
if (request.method !== "GET") return;
|
if (request.method !== "GET") return;
|
||||||
|
|
|
||||||
67
scripts/send-reminders.mjs
Normal file
67
scripts/send-reminders.mjs
Normal 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}`);
|
||||||
Loading…
Add table
Add a link
Reference in a new issue