freezmo/lib/homework.ts
Martin Dvorak abacfb6018 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.
2026-10-12 16:40:49 +00:00

198 lines
6.3 KiB
TypeScript

"use client";
/**
* Homework results backed by localStorage. Same tiny observable store as the
* deck library, but deliberately smaller: no folders, no attempts — just the
* assignment text (so “Make it again” works) and the result.
*
* Original photos/documents are never stored; they were reduced to text on the
* client before this point.
*/
import {
homeworkRecordSchema,
type HomeworkGuideRecord,
type HomeworkMode,
type HomeworkRecord,
type HomeworkStep,
} from "./schemas";
import { markSyncDirty } from "./sync-state";
import { uid } from "./utils";
const STORAGE_KEY = "freezmo.homework.v1";
const EMPTY: HomeworkRecord[] = [];
/** Enough to regenerate from, while staying well under the storage quota. */
export const MAX_STORED_SOURCE = 24_000;
type Listener = () => void;
let records: HomeworkRecord[] | null = null;
let cached: HomeworkRecord[] = EMPTY;
const listeners = new Set<Listener>();
function read(): HomeworkRecord[] {
if (typeof window === "undefined") return EMPTY;
try {
const raw = window.localStorage.getItem(STORAGE_KEY);
if (!raw) return EMPTY;
const parsed: unknown = JSON.parse(raw);
if (!Array.isArray(parsed)) return EMPTY;
const valid: HomeworkRecord[] = [];
for (const entry of parsed) {
const result = homeworkRecordSchema.safeParse(entry);
if (result.success) valid.push(result.data as HomeworkRecord);
else console.warn("[freezmo] skipping invalid homework record", result.error.issues);
}
return valid;
} catch (error) {
console.warn("[freezmo] could not read the homework history", error);
return EMPTY;
}
}
function ensureLoaded(): void {
if (records !== null) return;
records = read();
cached = records;
}
function write(next: HomeworkRecord[]): void {
records = next;
cached = next;
try {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
} catch (error) {
console.warn("[freezmo] could not save the homework history (storage full?)", error);
}
/* 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());
}
/** Used by backup import and sync: wholesale replacement of the history. */
export function replaceHomework(next: HomeworkRecord[]): void {
const valid: HomeworkRecord[] = [];
for (const entry of next) {
const result = homeworkRecordSchema.safeParse(entry);
if (result.success) valid.push(result.data as HomeworkRecord);
else console.warn("[freezmo] skipping invalid homework record in import", result.error.issues);
}
write(valid);
}
export const homeworkStore = {
subscribe(listener: Listener): () => void {
ensureLoaded();
listeners.add(listener);
const onStorage = (event: StorageEvent) => {
if (event.key === STORAGE_KEY) {
records = null;
ensureLoaded();
listeners.forEach((l) => l());
}
};
window.addEventListener("storage", onStorage);
return () => {
listeners.delete(listener);
window.removeEventListener("storage", onStorage);
};
},
getSnapshot(): HomeworkRecord[] {
ensureLoaded();
return cached;
},
getServerSnapshot(): HomeworkRecord[] {
return EMPTY;
},
};
/** Newest first. */
export function sortHomework(list: HomeworkRecord[]): HomeworkRecord[] {
return [...list].sort((a, b) => b.updatedAt - a.updatedAt);
}
export function getHomework(id: string): HomeworkRecord | undefined {
return homeworkStore.getSnapshot().find((record) => record.id === id);
}
/** Everything needed to store a result (the route's payload plus the source). */
export type NewHomeworkResult =
| { mode: "make"; title: string; language?: string; sourceText: string; answer: string }
| { mode: "guide"; title: string; language?: string; sourceText: string; steps: HomeworkStep[] }
| {
mode: "check";
title: string;
language?: string;
sourceText: string;
verdict: string;
correct: string[];
wrong: Array<{ issue: string; why: string; fix: string }>;
corrected: string;
attempt?: string;
};
function buildRecord(
base: { id: string; createdAt: number; updatedAt: number },
input: NewHomeworkResult,
): HomeworkRecord {
const shared = {
...base,
title: input.title.slice(0, 200),
language: input.language,
sourceText: input.sourceText.slice(0, MAX_STORED_SOURCE),
};
if (input.mode === "make") return { ...shared, mode: "make", answer: input.answer };
if (input.mode === "check") {
return {
...shared,
mode: "check",
verdict: input.verdict,
correct: input.correct,
wrong: input.wrong,
corrected: input.corrected,
attempt: input.attempt?.slice(0, 20_000),
};
}
return { ...shared, mode: "guide", steps: input.steps };
}
export function saveHomework(input: NewHomeworkResult): HomeworkRecord {
ensureLoaded();
const now = Date.now();
const record = buildRecord({ id: uid("hw"), createdAt: now, updatedAt: now }, input);
write([record, ...homeworkStore.getSnapshot()]);
return record;
}
/** “Make it again” keeps the same history row. */
export function updateHomework(id: string, input: NewHomeworkResult): HomeworkRecord | undefined {
ensureLoaded();
const existing = homeworkStore.getSnapshot().find((record) => record.id === id);
if (!existing) return undefined;
const updated = buildRecord(
{ id: existing.id, createdAt: existing.createdAt, updatedAt: Date.now() },
input,
);
write(homeworkStore.getSnapshot().map((record) => (record.id === id ? updated : record)));
return updated;
}
export function deleteHomework(id: string): void {
ensureLoaded();
write(homeworkStore.getSnapshot().filter((record) => record.id !== id));
}
/** The final screen's text: the step answers assembled into one solution. */
export function assembleSolution(record: HomeworkGuideRecord): string {
const parts = record.steps.map((step, index) => `${index + 1}. ${step.title}\n${step.answer}`);
return [record.title, ...parts].join("\n\n");
}
/** Label for a row in the history list. */
export function homeworkModeLabelKey(mode: HomeworkMode): string {
if (mode === "make") return "homework.history.made";
if (mode === "check") return "homework.history.checked";
return "homework.history.guide";
}
export type { HomeworkGuideRecord };