freezmo/lib/question-reports.ts
Martin Dvorak 7da308fa1e Aero button glass + homework report control; reduced-motion sheet glitch
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.
2026-10-14 09:19:54 +00:00

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)),
);
}