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.
This commit is contained in:
parent
f202653017
commit
9cea13496a
16 changed files with 496 additions and 15 deletions
169
lib/question-reports.ts
Normal file
169
lib/question-reports.ts
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
"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)),
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue