"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(); 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); } function onStorage(event: StorageEvent): void { if (event.key === STORAGE_KEY) { records = null; ensureLoaded(); listeners.forEach((l) => l()); } } export const homeworkStore = { subscribe(listener: Listener): () => void { ensureLoaded(); if (listeners.size === 0) window.addEventListener("storage", onStorage); listeners.add(listener); return () => { listeners.delete(listener); if (listeners.size === 0) 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 };