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.
44 lines
1.3 KiB
TypeScript
44 lines
1.3 KiB
TypeScript
"use client";
|
|
|
|
import { haptic } from "@/lib/haptics";
|
|
import { useI18n } from "@/lib/i18n";
|
|
import { reportQuestion, unreportQuestion } from "@/lib/question-reports";
|
|
import type { Activity, Deck } from "@/lib/schemas";
|
|
import { showToast } from "@/lib/toast";
|
|
|
|
/**
|
|
* The red "!" report control in the feed chrome: records the question, sends
|
|
* the operator a copy and skips past it. Undo lives in the toast.
|
|
*/
|
|
export function ReportQuestionButton({
|
|
deck,
|
|
activity,
|
|
onSkip,
|
|
}: {
|
|
deck: Deck;
|
|
activity: Activity;
|
|
onSkip: () => void;
|
|
}) {
|
|
const { t } = useI18n();
|
|
|
|
return (
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
haptic("tap");
|
|
reportQuestion({ deck, activity });
|
|
showToast({
|
|
message: t("feed.reported"),
|
|
actionLabel: t("toast.undo"),
|
|
onAction: () => unreportQuestion(deck.id, activity.id),
|
|
});
|
|
onSkip();
|
|
}}
|
|
aria-label={t("feed.report")}
|
|
title={t("feed.report")}
|
|
className="grid size-9 shrink-0 place-items-center rounded-xl border-2 border-rose-400/80 text-[15px] font-black leading-none text-rose-600 transition hover:bg-rose-50 dark:border-rose-400/50 dark:text-rose-300 dark:hover:bg-rose-400/10"
|
|
>
|
|
<span aria-hidden>!</span>
|
|
</button>
|
|
);
|
|
}
|