diff --git a/README.md b/README.md index 2f536b3..7f33f9d 100644 --- a/README.md +++ b/README.md @@ -1361,6 +1361,19 @@ Things I chose while building, with the reasoning: phase-1 suite (43 checks, including the real route mapping against a stub Ollama returning a monthly-limit body, a two-device sync round-trip, and the generation guards below). +59. **Adding a deck or a homework syncs itself.** Sync used to be push-on-demand only: a deck + frozen on the laptop sat in `dirty` until someone opened Settings and pressed Sync now. The two + stores whose writes mean "the user just made something" (`lib/storage`, `lib/homework`) now + schedule `syncNow("auto")` through `lib/sync-auto` — debounced 2.5 s so a burst of writes is one + push, one run at a time, silent on failure (the data stays dirty and Settings still offers the + manual sync), and retried on the browser's `online` event. Deliberately *not* wired to answers + or chat: those change every few seconds while studying. The module is imported dynamically from + the stores, so the sync code stays out of the first load and no import cycle forms. + `markSyncDirty()` now reports whether it actually marked, which also keeps pull/import paths + quiet while a backup import pushes like any other local change. Verified by the phase-1 suite + (a deck write pushes itself, then Sync now is a no-op) and the phase-3 suite (a check run pushes + the new homework record). + 58. **The smart review follows the deck list.** Deleting a deck used to leave its due schedules and mistakes in the library counts: `dueCount` counted raw SRS rows (no deck lookup) and both counts were memoised on the learning store alone, so a deck change never recomputed them. The diff --git a/lib/homework.ts b/lib/homework.ts index 0554957..1e1e62a 100644 --- a/lib/homework.ts +++ b/lib/homework.ts @@ -64,7 +64,8 @@ function write(next: HomeworkRecord[]): void { } catch (error) { console.warn("[freezmo] could not save the homework history (storage full?)", error); } - markSyncDirty(); + /* A homework result with sync on pushes itself (see lib/sync-auto). */ + if (markSyncDirty()) void import("./sync-auto").then((module) => module.scheduleAutoSync()); listeners.forEach((listener) => listener()); } diff --git a/lib/storage.ts b/lib/storage.ts index a96d419..ac9ffb8 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -54,7 +54,8 @@ function write(next: Deck[]): void { } catch (error) { console.warn("[freezmo] could not save the deck library (storage full?)", error); } - markSyncDirty(); + /* A deck change (add, edit, delete, import) with sync on pushes itself. */ + if (markSyncDirty()) void import("./sync-auto").then((module) => module.scheduleAutoSync()); listeners.forEach((listener) => listener()); } diff --git a/lib/sync-auto.ts b/lib/sync-auto.ts new file mode 100644 index 0000000..004b18b --- /dev/null +++ b/lib/sync-auto.ts @@ -0,0 +1,63 @@ +"use client"; + +/** + * Auto-push for people who already turned sync on: a deck or homework write + * schedules one background `syncNow("auto")` a moment later, so a deck frozen + * on the laptop shows up on the phone without opening Settings. Debounced (a + * burst of writes = one push), one run at a time, silent on failure — the data + * stays dirty and Settings still offers the manual sync. + * + * Deliberately scoped to decks and homework: answers and chat messages change + * constantly while studying, and pushing after every card would be noise. + * A push that fails offline is retried when the browser comes back online. + * + * Imported dynamically from the stores (lib/storage, lib/homework) so the + * sync module itself stays out of the first load and no import cycle forms. + */ + +import { syncNow } from "./sync"; +import { syncStore } from "./sync-state"; + +/** Long enough to swallow a burst (a save plus its follow-up writes). */ +const DELAY_MS = 2500; + +let timer: number | null = null; +let running = false; +let listening = false; + +export function scheduleAutoSync(): void { + if (typeof window === "undefined") return; + const record = syncStore.getSnapshot(); + if (!record.enabled || !record.id) return; + if (timer !== null) window.clearTimeout(timer); + timer = window.setTimeout(() => { + timer = null; + void runAutoSync(); + }, DELAY_MS); + listenForReconnect(); +} + +/** A push that failed while offline is retried when the connection returns. */ +function listenForReconnect(): void { + if (listening) return; + listening = true; + window.addEventListener("online", () => { + const record = syncStore.getSnapshot(); + if (record.enabled && record.dirtyAt !== null) scheduleAutoSync(); + }); +} + +async function runAutoSync(): Promise { + if (running) return; + if (typeof navigator !== "undefined" && navigator.onLine === false) return; + const record = syncStore.getSnapshot(); + if (!record.enabled || record.dirtyAt === null) return; + running = true; + try { + await syncNow("auto"); + } catch { + /* stays dirty on purpose: Settings still shows the change and syncs it */ + } finally { + running = false; + } +} diff --git a/lib/sync-state.ts b/lib/sync-state.ts index e69a385..86db1d4 100644 --- a/lib/sync-state.ts +++ b/lib/sync-state.ts @@ -109,13 +109,18 @@ export function setSyncRecord(next: Partial): void { write({ ...syncStore.getSnapshot(), ...next }); } -/** Called by every data store's write(); no-op when sync is off. */ -export function markSyncDirty(): void { - if (suppressDirty) return; +/** + * Called by every data store's write(); no-op when sync is off (or while a + * pulled payload is being applied). Returns true when the data was actually + * marked dirty, so callers can schedule an immediate sync. + */ +export function markSyncDirty(): boolean { + if (suppressDirty) return false; ensureLoaded(); const current = syncStore.getSnapshot(); - if (!current.enabled || current.dirtyAt !== null) return; + if (!current.enabled || current.dirtyAt !== null) return false; write({ ...current, dirtyAt: Date.now() }); + return true; } /** Applying a pulled payload must not mark the local data dirty again. */ diff --git a/public/sw.js b/public/sw.js index bcde485..83822cb 100644 --- a/public/sw.js +++ b/public/sw.js @@ -9,7 +9,7 @@ * * Bumping CACHE_VERSION is how a new deploy invalidates old assets. */ -const CACHE_VERSION = "freezmo-v45"; +const CACHE_VERSION = "freezmo-v46"; const SHELL_CACHE = `${CACHE_VERSION}-shell`; const ASSET_CACHE = `${CACHE_VERSION}-assets`; diff --git a/tests/phase1-test.mjs b/tests/phase1-test.mjs index bfff75c..f84c5c4 100644 --- a/tests/phase1-test.mjs +++ b/tests/phase1-test.mjs @@ -181,7 +181,7 @@ async function openSettings(page) { !envelope.body.includes("Photosynthesis") && !envelope.body.includes("test-key") && !envelope.body.includes("apiKey"), ); - // a real UI write marks dirty, then push + // a real UI write marks dirty, then pushes itself (no Sync now press) await page.keyboard.press("Escape"); await page.waitForTimeout(500); await page.getByRole("button", { name: "Deck actions" }).first().click(); @@ -191,10 +191,34 @@ async function openSettings(page) { const dirty = await page.evaluate(() => JSON.parse(localStorage.getItem("freezmo.sync.v1")).dirtyAt); check("sync: local change marks dirty", typeof dirty === "number"); const remainingTitle = await page.evaluate(() => JSON.parse(localStorage.getItem("freezmo.decks.v1"))[0].title); + const serverBlob = () => + page.evaluate(async () => { + const record = JSON.parse(localStorage.getItem("freezmo.sync.v1")); + const response = await fetch(`/api/sync?id=${record.id}`, { headers: { "x-ollama-key": "test-key" } }); + const body = response.ok ? await response.json() : null; + return { + dirtyAt: record.dirtyAt, + lastSyncedAt: record.lastSyncedAt, + status: response.status, + updatedAt: body?.updatedAt ?? null, + }; + }); + // Deck writes push on their own within a few seconds (lib/sync-auto). + await page.waitForTimeout(4200); + const auto = await serverBlob(); + check( + "sync: a deck write pushes itself", + auto.status === 200 && + auto.dirtyAt === null && + typeof auto.updatedAt === "number" && + typeof auto.lastSyncedAt === "number", + JSON.stringify(auto), + ); + // The manual button stays: after the auto-push there is nothing left to do. await openSettings(page); await page.getByRole("button", { name: "Sync now" }).click(); - await page.waitForTimeout(2500); - check("sync: push reports uploaded", await page.getByText("Uploaded from this device.").isVisible()); + await page.waitForTimeout(2200); + check("sync: Sync now finds nothing left to push", await page.getByText("Up to date.").isVisible()); const id = syncRecord.id; await context.close(); diff --git a/tests/phase3-test.mjs b/tests/phase3-test.mjs index f0c9f28..cb106ce 100644 --- a/tests/phase3-test.mjs +++ b/tests/phase3-test.mjs @@ -5,6 +5,7 @@ * Runs against a stub Ollama so no model is needed. */ import http from "node:http"; +import { mkdirSync } from "node:fs"; import { launchBrowser, makeChecker, makePage, portInUse, seed, spawnApp, waitForHttp } from "./lib.mjs"; @@ -83,7 +84,14 @@ const stub = http.createServer((request, response) => { await new Promise((resolve) => stub.listen(STUB_PORT, "127.0.0.1", resolve)); check("stub app port is free", !(await portInUse(APP_PORT))); -const app = spawnApp(APP_PORT, { OLLAMA_BASE_URL: STUB_BASE, OLLAMA_AUTH: "local", OLLAMA_VERIFY_KEY: "false" }); +const syncDir = new URL("./.tmp/sync-phase3", import.meta.url).pathname; +mkdirSync(syncDir, { recursive: true }); +const app = spawnApp(APP_PORT, { + OLLAMA_BASE_URL: STUB_BASE, + OLLAMA_AUTH: "local", + OLLAMA_VERIFY_KEY: "false", + SYNC_DIR: syncDir, +}); const browser = await launchBrowser(); @@ -225,6 +233,53 @@ try { check("desktop: AI disclaimer on the result", await desktop.page.getByText("Freezmo uses AI and can make mistakes").isVisible()); await desktop.context.close(); + /* 4b. a new homework pushes itself when sync is on ---------------- */ + const synced = await makePage(browser, { width: 390 }); + await seed(synced.page, { decks: [] }); + await synced.page.goto(`${APP}/`, { waitUntil: "networkidle" }); + await synced.page.waitForTimeout(800); + await synced.page.locator("nav.tabbar").getByRole("button").click(); + await synced.page.getByRole("dialog").waitFor({ timeout: 6000 }); + await synced.page.waitForTimeout(500); + await synced.page.getByRole("button", { name: "Turn on sync" }).click(); + await synced.page.getByPlaceholder("Sync passphrase").fill("correct horse battery"); + await synced.page.getByPlaceholder("Repeat the passphrase").fill("correct horse battery"); + await synced.page.getByRole("button", { name: "Start syncing" }).click(); + await synced.page.waitForTimeout(2500); + await synced.page.keyboard.press("Escape"); + await synced.page.waitForTimeout(500); + + const blob = () => + synced.page.evaluate(async () => { + const record = JSON.parse(localStorage.getItem("freezmo.sync.v1")); + const response = await fetch(`/api/sync?id=${record.id}`, { headers: { "x-ollama-key": "test-key" } }); + const body = response.ok ? await response.json() : null; + return { id: record.id, dirtyAt: record.dirtyAt, updatedAt: body?.updatedAt ?? null }; + }); + const beforeHomework = await blob(); + check("sync: enabled on this device", /^[a-f0-9]{32}$/.test(beforeHomework.id) && beforeHomework.updatedAt !== null, JSON.stringify(beforeHomework)); + + // Run a check: saving the homework record must push itself (no Sync now). + await synced.page.goto(`${APP}/homework`, { waitUntil: "networkidle" }); + await synced.page.waitForTimeout(900); + await synced.page.getByRole("button", { name: "Paste text instead" }).click(); + await synced.page.locator("textarea").first().fill("Solve x + 2 = 4"); + await synced.page.getByRole("button", { name: "Next" }).click(); + await synced.page.waitForTimeout(400); + await synced.page.getByRole("button", { name: /Check my work/ }).click(); + await synced.page.waitForTimeout(400); + await synced.page.locator("#homework-solution").fill("x = 3"); + await synced.page.getByRole("button", { name: "Check my work", exact: true }).click(); + // The check run + the debounced auto-push (see lib/sync-auto, 2.5s). + await synced.page.waitForTimeout(7500); + const pushed = await blob(); + check( + "sync: the new homework pushed itself", + pushed.dirtyAt === null && pushed.updatedAt !== null && pushed.updatedAt !== beforeHomework.updatedAt, + JSON.stringify({ before: beforeHomework.updatedAt, after: pushed.updatedAt }), + ); + await synced.context.close(); + const guide = await makePage(browser, { width: 390 }); await seed(guide.page, { decks: [] }); await guide.page.goto(`${APP}/homework`, { waitUntil: "networkidle" });