Buttons: the recipe's thin veils let btn-secondary/btn-ghost/glass-pill blend into the grass and sky - they now carry a stronger pale-glass veil (the Ask-pill look) over the blurred scene, ghosts plus a rim and full-contrast text. Homework make/check results gained the same red "!" report control as the feeds and the guide (a synthetic short-answer activity maps into the report flow; pinned in phase3). Reduced motion: the manual toggle only fed framer's MotionConfig, which strips transform animations - while components branched on the OS-only useReducedMotion - so translucent sheets still spring-faded their opacity in over the page (the "ugly visual glitch with the settings panel"). All 16 components now read useAppReducedMotion() (OS or toggle), and the settings sheet/scrim and the library's phone sheet go instant. Verified frame by frame: opacity 1.00 from the first frame. Contrast 0 findings, axe clean; mobile 126/126, chat 102/102, providers 20/20, wizard 95/95, phase3 31/31, bubbles 34/34, i18n 13/13, native 33/33. Version 0.5.4, SW -> freezmo-v76.
191 lines
5.7 KiB
TypeScript
191 lines
5.7 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. */
|
|
/**
|
|
* 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)),
|
|
);
|
|
}
|