freezmo/lib/question-reports.ts
Martin Dvorak 9cea13496a Report question: skip a broken card and tell the server
A generated matching card arrived on a phone with three identical
definitions (the generation guards now drop those), but there was no way
to get past a bad card from inside a session. The feed chrome (deck
study, review, mistakes and exam) now has the red "!" control: one tap
records the question, skips to the next card and shows an Undo toast.

- lib/question-reports.ts: the local list (freezmo.reports.v1, zod on
  read, capped 200) is the skip state — reported cards leave every
  session type, count as "not attempted" in the results instead of
  wrong, and Undo takes a report back. A deck where everything is
  reported stays studiable so it can be regenerated.
- POST /api/reports appends one JSON line per report to
  REPORTS_DIR/reports.jsonl (shared/freezmo/reports in production, the
  dev data dir in dev). Deliberately write-only: the question text is
  readable on the server (that is the point — prompt tuning) but never
  over HTTP, since the app has no auth. Best effort: offline reports stay
  local.
- The button: a red outlined rounded square with "!", the chrome's
  control size, in both feed headers (hidden on the results slide).
- config: REPORTS_DIR + RATE_LIMIT_REPORTS (20); the deploy creates the
  shared reports dir; the dev unit and AGENTS.md note the new server
  data location.
- Suites: phase-1 (append + validation + no GET), phase-2 (button, toast,
  server POST, local copy, skip counter, hidden next session, Undo, exam
  pool). SW -> freezmo-v38.
2026-10-12 07:12:55 +00:00

169 lines
5.1 KiB
TypeScript

"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<typeof questionReportSchema>;
type Listener = () => void;
let reports: QuestionReport[] | null = null;
let cached: QuestionReport[] = EMPTY;
const listeners = new Set<Listener>();
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());
}
export const reportStore = {
subscribe(listener: Listener): () => void {
ensureLoaded();
listeners.add(listener);
const onStorage = (event: StorageEvent) => {
if (event.key === STORAGE_KEY) {
reports = null;
ensureLoaded();
listeners.forEach((l) => l());
}
};
window.addEventListener("storage", onStorage);
return () => {
listeners.delete(listener);
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. */
export function reportQuestion(input: { deck: { id: string; title: string }; activity: Activity }): QuestionReport {
const entry: QuestionReport = {
deckId: input.deck.id,
deckTitle: input.deck.title,
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)),
);
}