"use client"; /** * Reported questions. Two copies: one in this browser — the source of truth * for skipping, works offline and Undo can take it back — and one appended to * the server's `reports.jsonl` (see /api/reports) so the operator can inspect * what was flagged. * * Reports are deliberately not part of the backup/sync payload: the server * file is the cross-device record, the local list is just this browser's skip * state. */ import { z } from "zod"; import type { Activity } from "./schemas"; const STORAGE_KEY = "freezmo.reports.v1"; const LIMIT = 200; const EMPTY: QuestionReport[] = []; export const questionReportSchema = z.object({ deckId: z.string().min(1).max(80), deckTitle: z.string().max(200).optional(), activityId: z.string().min(1).max(80), type: z.string().min(1).max(40), question: z.string().min(1).max(600), at: z.number(), }); export type QuestionReport = z.infer; type Listener = () => void; let reports: QuestionReport[] | null = null; let cached: QuestionReport[] = EMPTY; const listeners = new Set(); function read(): QuestionReport[] { 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: QuestionReport[] = []; for (const entry of parsed) { const result = questionReportSchema.safeParse(entry); if (result.success) valid.push(result.data); else console.warn("[freezmo] skipping invalid report", result.error.issues); } return valid; } catch (error) { console.warn("[freezmo] could not read reports", error); return EMPTY; } } function ensureLoaded(): void { if (reports !== null) return; reports = read(); cached = reports; } function write(next: QuestionReport[]): void { reports = next; cached = next; try { window.localStorage.setItem(STORAGE_KEY, JSON.stringify(next)); } catch (error) { console.warn("[freezmo] could not save reports", error); } listeners.forEach((listener) => listener()); } function onStorage(event: StorageEvent): void { if (event.key === STORAGE_KEY) { reports = null; ensureLoaded(); listeners.forEach((l) => l()); } } export const reportStore = { 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(): QuestionReport[] { ensureLoaded(); return cached; }, getServerSnapshot(): QuestionReport[] { return EMPTY; }, }; /** Short text of an activity, for the report payload. */ export function activityText(activity: Activity): string { switch (activity.type) { case "flashcard": return activity.front; case "multiple_choice": return activity.question; case "true_false": return activity.statement; case "fill_blank": return activity.sentence; case "matching": return activity.instruction; case "ordering": return activity.instruction; case "short_answer": return activity.question; } } export function isReported(deckId: string, activityId: string): boolean { return reportStore.getSnapshot().some((report) => report.deckId === deckId && report.activityId === activityId); } /** * The deck's activities minus the reported ones. Never returns an empty list: * a deck where everything was reported stays studiable (and regenerable). */ export function withoutReported(deckId: string, activities: Activity[]): Activity[] { const kept = activities.filter((activity) => !isReported(deckId, activity.id)); return kept.length > 0 ? kept : activities; } /** Adds (or refreshes) a report and sends a copy to the server, best effort. */ /** * A pseudo-activity so a homework result can use the same report flow as a * study card (the report only keeps the flattened question/type/id). */ export function homeworkReportActivity(input: { id: string; title: string; answer: string }): Activity { const body = input.answer.trim().slice(0, 1200) || input.title; return { id: input.id, xp: 0, type: "short_answer", question: input.title.slice(0, 600) || "Homework result", referenceAnswer: body, keyPoints: [body.slice(0, 200) || "Result"], explanation: input.title.slice(0, 600) || "Homework result", }; } export function reportQuestion(input: { /** A deck id, or the homework record's id for a homework exercise. */ deckId: string; deckTitle?: string; activity: Activity; }): QuestionReport { const entry: QuestionReport = { deckId: input.deckId, deckTitle: input.deckTitle, activityId: input.activity.id, type: input.activity.type, question: activityText(input.activity).slice(0, 600), at: Date.now(), }; ensureLoaded(); const others = reportStore .getSnapshot() .filter((report) => !(report.deckId === entry.deckId && report.activityId === entry.activityId)); write([entry, ...others].slice(0, LIMIT)); // The operator's copy: appended under the shared data dir by /api/reports. // Offline or a server hiccup never blocks the skip. void fetch("/api/reports", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(entry), }).catch(() => {}); return entry; } /** Undo: forget the report locally (the server file is append-only). */ export function unreportQuestion(deckId: string, activityId: string): void { ensureLoaded(); write( reportStore .getSnapshot() .filter((report) => !(report.deckId === deckId && report.activityId === activityId)), ); }