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
|
|
@ -14,8 +14,9 @@ import { z } from "zod";
|
|||
import { applyTheme } from "@/components/ThemeToggle";
|
||||
import { replaceFolders, folderStore } from "./folders";
|
||||
import { homeworkStore, replaceHomework } from "./homework";
|
||||
import { ensureLearningHydrated, learningStore, replaceLearning } from "./history";
|
||||
import { setLocale, t } from "./i18n";
|
||||
import { folderSchema, deckSchema, homeworkRecordSchema } from "./schemas";
|
||||
import { folderSchema, deckSchema, homeworkRecordSchema, answerRecordSchema, srsRecordSchema } from "./schemas";
|
||||
import { saveSettings, settingsStore } from "./settings";
|
||||
import { deckStore, replaceDecks } from "./storage";
|
||||
|
||||
|
|
@ -25,12 +26,16 @@ const settingsSchema = z.object({
|
|||
haptics: z.boolean().optional(),
|
||||
locale: z.enum(["en", "cs"]).optional(),
|
||||
theme: z.enum(["light", "dark"]).optional(),
|
||||
dailyGoal: z.number().int().min(1).max(500).optional(),
|
||||
});
|
||||
|
||||
export const backupDataSchema = z.object({
|
||||
decks: z.array(deckSchema).max(2000),
|
||||
folders: z.array(folderSchema).max(500),
|
||||
homework: z.array(homeworkRecordSchema).max(2000),
|
||||
/** Answer log and SRS schedule (IndexedDB). Optional for old backups. */
|
||||
history: z.array(answerRecordSchema).max(20_000).optional(),
|
||||
srs: z.array(srsRecordSchema).max(50_000).optional(),
|
||||
});
|
||||
|
||||
export const backupSchema = z.object({
|
||||
|
|
@ -47,7 +52,8 @@ export type BackupFile = z.infer<typeof backupSchema>;
|
|||
/* Building and downloading */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export function buildBackup(): BackupFile {
|
||||
export async function buildBackup(): Promise<BackupFile> {
|
||||
await ensureLearningHydrated();
|
||||
return {
|
||||
app: "freezmo",
|
||||
kind: "backup",
|
||||
|
|
@ -57,8 +63,11 @@ export function buildBackup(): BackupFile {
|
|||
decks: deckStore.getSnapshot(),
|
||||
folders: folderStore.getSnapshot(),
|
||||
homework: homeworkStore.getSnapshot(),
|
||||
history: learningStore.getAnswersSnapshot(),
|
||||
srs: learningStore.getSrsSnapshot(),
|
||||
settings: {
|
||||
haptics: settingsStore.getSnapshot().haptics,
|
||||
dailyGoal: settingsStore.getSnapshot().dailyGoal,
|
||||
locale: (window.localStorage.getItem("freezmo.locale.v1") as "en" | "cs" | null) ?? undefined,
|
||||
theme: (window.localStorage.getItem("freezmo.theme") as "light" | "dark" | null) ?? undefined,
|
||||
},
|
||||
|
|
@ -66,8 +75,8 @@ export function buildBackup(): BackupFile {
|
|||
};
|
||||
}
|
||||
|
||||
export function downloadBackup(): BackupFile {
|
||||
const backup = buildBackup();
|
||||
export async function downloadBackup(): Promise<BackupFile> {
|
||||
const backup = await buildBackup();
|
||||
const blob = new Blob([JSON.stringify(backup, null, 2)], { type: "application/json" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement("a");
|
||||
|
|
@ -140,7 +149,10 @@ export function previewImport(backup: BackupFile): ImportPreview {
|
|||
}
|
||||
|
||||
/** Applies a backup; the API key is never touched (it is not in the file). */
|
||||
export function applyImport(backup: BackupFile, mode: "merge" | "replace"): { decks: number; homework: number } {
|
||||
export async function applyImport(
|
||||
backup: BackupFile,
|
||||
mode: "merge" | "replace",
|
||||
): Promise<{ decks: number; homework: number }> {
|
||||
const data = backup.data;
|
||||
const decks =
|
||||
mode === "replace" ? data.decks : mergeById(deckStore.getSnapshot(), data.decks);
|
||||
|
|
@ -155,9 +167,27 @@ export function applyImport(backup: BackupFile, mode: "merge" | "replace"): { de
|
|||
replaceDecks(decks);
|
||||
replaceHomework(homework);
|
||||
|
||||
// Answer log + schedule: merge by id (answers) and by key (schedules).
|
||||
await ensureLearningHydrated();
|
||||
const incomingAnswers = data.history ?? [];
|
||||
const incomingSrs = data.srs ?? [];
|
||||
if (incomingAnswers.length > 0 || incomingSrs.length > 0) {
|
||||
const currentAnswers = learningStore.getAnswersSnapshot();
|
||||
const currentSrs = learningStore.getSrsSnapshot();
|
||||
const answers =
|
||||
mode === "replace" ? incomingAnswers : mergeById(currentAnswers, incomingAnswers);
|
||||
const srsByKey = new Map(currentSrs.map((entry) => [entry.key, entry]));
|
||||
for (const entry of incomingSrs) {
|
||||
const current = srsByKey.get(entry.key);
|
||||
if (!current || current.lastAt < entry.lastAt) srsByKey.set(entry.key, entry);
|
||||
}
|
||||
await replaceLearning(answers, [...srsByKey.values()]);
|
||||
}
|
||||
|
||||
const settings = data.settings;
|
||||
if (settings) {
|
||||
if (typeof settings.haptics === "boolean") saveSettings({ haptics: settings.haptics });
|
||||
if (typeof settings.dailyGoal === "number") saveSettings({ dailyGoal: settings.dailyGoal });
|
||||
if (settings.locale) setLocale(settings.locale);
|
||||
if (settings.theme) applyTheme(settings.theme);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ export type ServerConfig = {
|
|||
homework: number;
|
||||
health: number;
|
||||
sync: number;
|
||||
push: number;
|
||||
};
|
||||
sync: {
|
||||
/** Where opaque encrypted sync blobs live (SYNC_DIR). */
|
||||
|
|
@ -40,6 +41,14 @@ export type ServerConfig = {
|
|||
/** Total cap for the whole directory in bytes (SYNC_TOTAL_BYTES). */
|
||||
totalBytes: number;
|
||||
};
|
||||
push: {
|
||||
/** VAPID keys — reminders are hidden entirely when either is empty. */
|
||||
publicKey: string;
|
||||
privateKey: string;
|
||||
subject: string;
|
||||
/** Where push subscriptions live (PUSH_DIR). */
|
||||
dir: string;
|
||||
};
|
||||
};
|
||||
|
||||
/** What a single request should use to talk to Ollama. */
|
||||
|
|
@ -111,12 +120,20 @@ export function getServerConfig(): ServerConfig {
|
|||
health: positiveInt(process.env.RATE_LIMIT_HEALTH, 90),
|
||||
// Sync is cheap (no model calls) but shouldn't be hammered.
|
||||
sync: positiveInt(process.env.RATE_LIMIT_SYNC, 60),
|
||||
// Push subscription changes and test sends.
|
||||
push: positiveInt(process.env.RATE_LIMIT_PUSH, 30),
|
||||
},
|
||||
sync: {
|
||||
dir: process.env.SYNC_DIR?.trim() || path.join(process.cwd(), ".freezmo", "sync"),
|
||||
maxBytes: positiveInt(process.env.SYNC_MAX_BYTES, 12 * 1024 * 1024),
|
||||
totalBytes: positiveInt(process.env.SYNC_TOTAL_BYTES, 1024 * 1024 * 1024),
|
||||
},
|
||||
push: {
|
||||
publicKey: process.env.VAPID_PUBLIC_KEY?.trim() || "",
|
||||
privateKey: process.env.VAPID_PRIVATE_KEY?.trim() || "",
|
||||
subject: process.env.VAPID_SUBJECT?.trim() || "mailto:freezmo@localhost",
|
||||
dir: process.env.PUSH_DIR?.trim() || path.join(process.cwd(), ".freezmo", "push"),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
252
lib/history.ts
Normal file
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.syncStorageFull": "The sync storage is full. Delete some synced data and try again.",
|
||||
"server.syncNotFound": "No synced data found for that ID.",
|
||||
"server.pushDisabled": "Push reminders are not configured on this server (set VAPID_PUBLIC_KEY and VAPID_PRIVATE_KEY).",
|
||||
"server.pushNotSubscribed": "This browser is not subscribed to reminders.",
|
||||
"server.pushGone": "That subscription is no longer valid — reminders were turned off for it.",
|
||||
"server.pushSendFailed": "The reminder could not be sent.",
|
||||
"server.pushReminderTitle": "Time to review",
|
||||
"server.pushReminderBody": "Your decks are waiting — a few due cards keep the streak alive.",
|
||||
|
||||
/* folders / files -------------------------------------------------- */
|
||||
"folder.nameRequired": "Give the folder a name.",
|
||||
|
|
@ -782,6 +788,74 @@ const en: Record<string, string> = {
|
|||
"error.openOllama": "Open Ollama",
|
||||
"error.throttledTitle": "Ollama is busy",
|
||||
"error.throttledBody": "Ollama is temporarily rate limiting requests. Try again in a moment.",
|
||||
|
||||
/* learning: review, mistakes, stats, exam -------------------------- */
|
||||
"review.cardTitle": "Smart review",
|
||||
"review.title": "Review due",
|
||||
"review.mistakesTitle": "Mistakes",
|
||||
"review.mistakesDeck": "Mistakes in this deck",
|
||||
"review.nothingDue": "Nothing is due right now. Study a deck and the schedule fills up on its own.",
|
||||
"review.noMistakes": "No open mistakes — everything you got wrong has since been answered correctly.",
|
||||
"review.done": "Back to the Library",
|
||||
"review.seeResults": "See results",
|
||||
"review.finishFirst": "Finish the last activity to see your results.",
|
||||
"review.results": "Results",
|
||||
"review.due.one": "{n} due",
|
||||
"review.due.few": "{n} due",
|
||||
"review.due.many": "{n} due",
|
||||
"review.mistakes.one": "{n} mistake",
|
||||
"review.mistakes.few": "{n} mistakes",
|
||||
"review.mistakes.many": "{n} mistakes",
|
||||
"exam.title": "Exam mode",
|
||||
"exam.subtitle": "Pick one or more decks, set a timer, no hints — see how you'd do.",
|
||||
"exam.pickDecks": "Decks",
|
||||
"exam.noDecks": "Create a deck first.",
|
||||
"exam.questions": "Questions",
|
||||
"exam.timer": "Timer",
|
||||
"exam.timerOff": "Off",
|
||||
"exam.start": "Start exam ({n} questions)",
|
||||
"exam.results": "Exam results",
|
||||
"exam.byDeck": "By deck",
|
||||
"exam.byTopic": "By topic",
|
||||
"exam.studyMistakes": "Study mistakes",
|
||||
"exam.timeLeft": "{time} left",
|
||||
"stats.title": "Your progress",
|
||||
"stats.subtitle": "Streaks, accuracy and the topics that deserve another pass.",
|
||||
"stats.streak": "Day streak",
|
||||
"stats.today": "Today",
|
||||
"stats.todayOf": "{n} / {goal} answers",
|
||||
"stats.goalDone": "Daily goal done 🎉",
|
||||
"stats.accuracy": "Accuracy",
|
||||
"stats.answers.one": "{n} answer",
|
||||
"stats.answers.few": "{n} answers",
|
||||
"stats.answers.many": "{n} answers",
|
||||
"stats.calendar": "Last 5 weeks",
|
||||
"stats.bySubject": "By subject",
|
||||
"stats.byDeck": "By deck",
|
||||
"stats.weakest": "Weakest topics",
|
||||
"stats.noSubject": "No subject",
|
||||
"stats.correctOf": "{correct} / {total} correct",
|
||||
"stats.empty": "No answers yet",
|
||||
"stats.emptyBody": "Study a deck and your progress shows up here.",
|
||||
"stats.start": "Study a deck",
|
||||
"stats.legendLess": "Less",
|
||||
"stats.legendMore": "More",
|
||||
"nav.stats": "Progress",
|
||||
"nav.exam": "Exam mode",
|
||||
"settings.learning": "Learning",
|
||||
"settings.dailyGoal": "Daily goal",
|
||||
"settings.dailyGoalNote": "Answers per day that count as done.",
|
||||
"settings.reminders": "Daily reminder",
|
||||
"settings.remindersNote":
|
||||
"A gentle nudge when your review is due. Needs the installed app — on iPhone that is required.",
|
||||
"settings.reminderEnable": "Turn on reminders",
|
||||
"settings.reminderDisable": "Turn off",
|
||||
"settings.reminderTest": "Send a test",
|
||||
"settings.reminderOn": "Reminders are on.",
|
||||
"settings.reminderOff": "Reminders are off.",
|
||||
"settings.reminderDenied": "Notifications are blocked in your browser settings.",
|
||||
"settings.reminderError": "Could not set up reminders.",
|
||||
"library.learningToday": "Today: {n} / {goal}",
|
||||
"doc.readingPage": "Reading page {n} of {total}…",
|
||||
"doc.readingWord": "Reading the Word document…",
|
||||
"doc.readingSlides": "Reading the slides…",
|
||||
|
|
@ -1492,6 +1566,12 @@ const cs: Record<string, string> = {
|
|||
"server.syncTooLarge": "Tahle synchronizace je příliš velká.",
|
||||
"server.syncStorageFull": "Úložiště synchronizace je plné. Smaž nějaká synchronizovaná data a zkus to znovu.",
|
||||
"server.syncNotFound": "Pro tenhle ID nejsou žádná synchronizovaná data.",
|
||||
"server.pushDisabled": "Push připomínky nejsou na tomto serveru nastavené (nastav VAPID_PUBLIC_KEY a VAPID_PRIVATE_KEY).",
|
||||
"server.pushNotSubscribed": "Tento prohlížeč není k připomínkám přihlášený.",
|
||||
"server.pushGone": "Ta přihláška už není platná — připomínky pro ni byly vypnuté.",
|
||||
"server.pushSendFailed": "Připomínku se nepodařilo poslat.",
|
||||
"server.pushReminderTitle": "Čas na opakování",
|
||||
"server.pushReminderBody": "Sady na tebe čekají — pár kartiček udrží sérii.",
|
||||
|
||||
/* folders / files -------------------------------------------------- */
|
||||
"folder.nameRequired": "Dej složce název.",
|
||||
|
|
@ -1510,6 +1590,74 @@ const cs: Record<string, string> = {
|
|||
"error.openOllama": "Otevřít Ollamu",
|
||||
"error.throttledTitle": "Ollama je zaneprázdněná",
|
||||
"error.throttledBody": "Ollama dočasně omezuje požadavky. Zkus to za chvíli znovu.",
|
||||
|
||||
/* learning: review, mistakes, stats, exam -------------------------- */
|
||||
"review.cardTitle": "Chytré opakování",
|
||||
"review.title": "K opakování",
|
||||
"review.mistakesTitle": "Chyby",
|
||||
"review.mistakesDeck": "Chyby v této sadě",
|
||||
"review.nothingDue": "Teď není co opakovat. Zauč se do sady a rozvrh se sám naplní.",
|
||||
"review.noMistakes": "Žádné otevřené chyby — co jsi měl špatně, jsi od té doby zodpověděl správně.",
|
||||
"review.done": "Zpět do knihovny",
|
||||
"review.seeResults": "Zobrazit výsledky",
|
||||
"review.finishFirst": "Dokonči poslední aktivitu a uvidíš výsledky.",
|
||||
"review.results": "Výsledky",
|
||||
"review.due.one": "{n} k opakování",
|
||||
"review.due.few": "{n} k opakování",
|
||||
"review.due.many": "{n} k opakování",
|
||||
"review.mistakes.one": "{n} chyba",
|
||||
"review.mistakes.few": "{n} chyby",
|
||||
"review.mistakes.many": "{n} chyb",
|
||||
"exam.title": "Zkouškové režim",
|
||||
"exam.subtitle": "Vyber sady, nastav časovač, žádné nápovědy — uvidíš, jak bys dopadl.",
|
||||
"exam.pickDecks": "Sady",
|
||||
"exam.noDecks": "Nejdřív si vytvoř sadu.",
|
||||
"exam.questions": "Otázky",
|
||||
"exam.timer": "Časovač",
|
||||
"exam.timerOff": "Vypnuto",
|
||||
"exam.start": "Spustit zkoušku ({n} otázek)",
|
||||
"exam.results": "Výsledky zkoušky",
|
||||
"exam.byDeck": "Podle sady",
|
||||
"exam.byTopic": "Podle tématu",
|
||||
"exam.studyMistakes": "Procvičit chyby",
|
||||
"exam.timeLeft": "zbývá {time}",
|
||||
"stats.title": "Tvůj pokrok",
|
||||
"stats.subtitle": "Série, úspěšnost a témata, která si zaslouží další kolo.",
|
||||
"stats.streak": "Série dní",
|
||||
"stats.today": "Dnes",
|
||||
"stats.todayOf": "{n} / {goal} odpovědí",
|
||||
"stats.goalDone": "Denní cíl splněn 🎉",
|
||||
"stats.accuracy": "Úspěšnost",
|
||||
"stats.answers.one": "{n} odpověď",
|
||||
"stats.answers.few": "{n} odpovědi",
|
||||
"stats.answers.many": "{n} odpovědí",
|
||||
"stats.calendar": "Posledních 5 týdnů",
|
||||
"stats.bySubject": "Podle předmětu",
|
||||
"stats.byDeck": "Podle sady",
|
||||
"stats.weakest": "Nejslabší témata",
|
||||
"stats.noSubject": "Bez předmětu",
|
||||
"stats.correctOf": "{correct} / {total} správně",
|
||||
"stats.empty": "Zatím žádné odpovědi",
|
||||
"stats.emptyBody": "Zauč se do sady a tvůj pokrok se objeví tady.",
|
||||
"stats.start": "Jít studovat",
|
||||
"stats.legendLess": "Méně",
|
||||
"stats.legendMore": "Více",
|
||||
"nav.stats": "Pokrok",
|
||||
"nav.exam": "Zkouškový režim",
|
||||
"settings.learning": "Učení",
|
||||
"settings.dailyGoal": "Denní cíl",
|
||||
"settings.dailyGoalNote": "Kolik odpovědí za den se počítá jako splněno.",
|
||||
"settings.reminders": "Denní připomínka",
|
||||
"settings.remindersNote":
|
||||
"Jemné připomenutí, když je co opakovat. Potřebuje nainstalovanou aplikaci — na iPhonu je to podmínka.",
|
||||
"settings.reminderEnable": "Zapnout připomínky",
|
||||
"settings.reminderDisable": "Vypnout",
|
||||
"settings.reminderTest": "Poslat zkušební",
|
||||
"settings.reminderOn": "Připomínky jsou zapnuté.",
|
||||
"settings.reminderOff": "Připomínky jsou vypnuté.",
|
||||
"settings.reminderDenied": "Oznámení jsou v nastavení prohlížeče zablokovaná.",
|
||||
"settings.reminderError": "Připomínky se nepodařilo nastavit.",
|
||||
"library.learningToday": "Dnes: {n} / {goal}",
|
||||
"doc.readingPage": "Čtu stranu {n} z {total}…",
|
||||
"doc.readingWord": "Čtu dokument Word…",
|
||||
"doc.readingSlides": "Čtu snímky…",
|
||||
|
|
|
|||
173
lib/learning.ts
Normal file
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(),
|
||||
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;
|
||||
/** Best-effort haptics on supported devices (phones). On by default. */
|
||||
haptics: boolean;
|
||||
/** How many answers count as a finished day in the Library/stats. */
|
||||
dailyGoal: number;
|
||||
};
|
||||
|
||||
const EMPTY: BrowserSettings = { apiKey: "", haptics: true };
|
||||
const DEFAULT_GOAL = 20;
|
||||
|
||||
const EMPTY: BrowserSettings = { apiKey: "", haptics: true, dailyGoal: DEFAULT_GOAL };
|
||||
|
||||
let cached: BrowserSettings | null = null;
|
||||
const listeners = new Set<() => void>();
|
||||
|
|
@ -35,9 +39,11 @@ function read(): BrowserSettings {
|
|||
const raw = window.localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return EMPTY;
|
||||
const parsed = JSON.parse(raw) as Partial<BrowserSettings>;
|
||||
const goal = Number.parseInt(String(parsed.dailyGoal ?? DEFAULT_GOAL), 10);
|
||||
return {
|
||||
apiKey: typeof parsed.apiKey === "string" ? parsed.apiKey.slice(0, 512) : "",
|
||||
haptics: parsed.haptics !== false,
|
||||
dailyGoal: Number.isFinite(goal) ? Math.min(500, Math.max(1, goal)) : DEFAULT_GOAL,
|
||||
};
|
||||
} catch {
|
||||
return EMPTY;
|
||||
|
|
@ -47,8 +53,11 @@ function read(): BrowserSettings {
|
|||
function write(next: BrowserSettings): void {
|
||||
cached = next;
|
||||
try {
|
||||
if (next.apiKey || next.haptics === false) {
|
||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify({ apiKey: next.apiKey, haptics: next.haptics }));
|
||||
if (next.apiKey || next.haptics === false || next.dailyGoal !== DEFAULT_GOAL) {
|
||||
window.localStorage.setItem(
|
||||
STORAGE_KEY,
|
||||
JSON.stringify({ apiKey: next.apiKey, haptics: next.haptics, dailyGoal: next.dailyGoal }),
|
||||
);
|
||||
} else {
|
||||
window.localStorage.removeItem(STORAGE_KEY);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import { backupDataSchema } from "./backup";
|
|||
import { ApiError } from "./client-api";
|
||||
import { replaceFolders } from "./folders";
|
||||
import { replaceHomework } from "./homework";
|
||||
import { ensureLearningHydrated, learningStore, replaceLearning } from "./history";
|
||||
import { localeHeader, t } from "./i18n";
|
||||
import { authHeaders } from "./settings";
|
||||
import { deckStore, replaceDecks } from "./storage";
|
||||
|
|
@ -159,7 +160,8 @@ async function apiDelete(id: string): Promise<void> {
|
|||
/* Payload */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
function buildPayload(): SyncPayload {
|
||||
async function buildPayload(): Promise<SyncPayload> {
|
||||
await ensureLearningHydrated();
|
||||
return {
|
||||
app: "freezmo",
|
||||
kind: "sync",
|
||||
|
|
@ -169,6 +171,8 @@ function buildPayload(): SyncPayload {
|
|||
decks: deckStore.getSnapshot(),
|
||||
folders: folderStore.getSnapshot(),
|
||||
homework: homeworkStore.getSnapshot(),
|
||||
history: learningStore.getAnswersSnapshot(),
|
||||
srs: learningStore.getSrsSnapshot(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -182,6 +186,7 @@ async function applyRemote(key: CryptoKey, remote: { updatedAt: number; envelope
|
|||
replaceDecks(parsed.data.decks);
|
||||
replaceHomework(parsed.data.homework);
|
||||
});
|
||||
await replaceLearning(parsed.data.history ?? [], parsed.data.srs ?? []);
|
||||
setSyncRecord({ lastSyncedAt: remote.updatedAt, dirtyAt: null, conflict: false });
|
||||
}
|
||||
|
||||
|
|
@ -218,7 +223,7 @@ export async function syncNow(strategy: "auto" | "push" | "pull" = "auto"): Prom
|
|||
return { status: "up-to-date" };
|
||||
}
|
||||
|
||||
const { iv, ciphertext } = await encryptWithKey(key, buildPayload());
|
||||
const { iv, ciphertext } = await encryptWithKey(key, await buildPayload());
|
||||
const envelope: SyncEnvelope = {
|
||||
v: 1,
|
||||
kdf: KDF,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue