Deck/homework writes sync themselves when sync is on

Sync was push-on-demand only: a deck frozen on the laptop stayed in dirty
until someone opened Settings and pressed Sync now. The two stores whose
writes mean "the user just made something" now schedule one debounced
background syncNow("auto") through lib/sync-auto: debounced 2.5s 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), retried on the browser's
online event, and imported dynamically so the sync code stays out of the
first load with no import cycle.

markSyncDirty() now reports whether it actually marked, which keeps pull and
backup-import paths quiet while a real import pushes like any other local
change. Answers and chat keep the existing manual behaviour on purpose —
they change every few seconds while studying.

Tests: phase-1 (a deck write pushes itself, then Sync now is a no-op — 50
checks) and phase-3 (a check run pushes the new homework record — 30 checks).
README: decision 59. SW -> freezmo-v46.
This commit is contained in:
Martin Dvořák 2026-10-12 16:40:49 +00:00
commit abacfb6018
8 changed files with 173 additions and 11 deletions

View file

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

View file

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

View file

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

63
lib/sync-auto.ts Normal file
View file

@ -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<void> {
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;
}
}

View file

@ -109,13 +109,18 @@ export function setSyncRecord(next: Partial<SyncRecord>): 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. */

View file

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

View file

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

View file

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