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:
Martin Dvořák 2026-10-12 07:12:55 +00:00
commit 9cea13496a
16 changed files with 496 additions and 15 deletions

View file

@ -41,7 +41,7 @@ Production only ever runs a pushed, verified commit deployed by
5. **The Ollama API key never leaves the browser.** It must never appear in
exports, sync payloads, share links, logs, URLs, `.env` files committed to
the repo, or release directories. Server data (sync blobs, push
subscriptions) lives only under `shared/`.
subscriptions, question reports) lives only under `shared/`.
6. **Both languages, always.** Every user-facing string goes into
`lib/i18n-dict.ts` in **en and cs** (the i18n test enforces key parity).
7. **Phones are first-class**: ≤639px layouts, 44px touch targets, 16px

View file

@ -867,6 +867,7 @@ scripts/
| `POST /api/grade` | `{ question, referenceAnswer, keyPoints, userAnswer, language? }` | `{ correct, score, feedback, missing }` |
| `POST /api/chat` | `{ model?, messages: [{role,content}], context?: [{deckId,deckTitle,section,text}] }` | NDJSON stream: `{type:"token"}`, `{type:"thinking"}`, `{type:"done",model}`, `{type:"error"}` |
| `POST /api/chat/extract` | `{ images?: string[], text? }` | `{ text }` — attachments reduced to plain text via the vision step |
| `POST /api/reports` | `{ deckId, deckTitle?, activityId, type, question, at }` | `{ ok: true }` — appended as one JSON line to `REPORTS_DIR/reports.jsonl` (write-only; see decision 52) |
| `GET /api/health` | – | `{ ok, reachable, mode, baseUrl, hasApiKey, vision, text, chatModels, latencyMs, modelCount, error }` |
| `POST /api/sync` | `{ id, envelope }` (opaque AES-GCM blob) | `{ updatedAt }` — 413 over `SYNC_MAX_BYTES`, 507 over `SYNC_TOTAL_BYTES` |
| `GET /api/sync?id=` | – | `{ updatedAt, envelope }` or 404 |
@ -928,6 +929,11 @@ grading, regeneration and “add more” (and the assignment language for homewo
`SYNC_DIR` (default `.freezmo/sync`), can't decrypt it, and never receives the passphrase or the
key. Deleting the synced copy is one button in Settings (and works even on a device that can't
unlock the data).
- **Question reports** (the red **!** in the study feed) keep the skip state in this browser
(`freezmo.reports.v1`) and append one JSON line per report to `REPORTS_DIR/reports.jsonl`
(default `.freezmo/reports`, `shared/freezmo/reports` in production). Question text is part of
the report on purpose — it is what makes the file useful for tuning the generation prompts — and
the route is deliberately write-only over HTTP.
- **Answer history lives in IndexedDB** (`freezmo-learning`, versioned stores `answers` + `srs`),
because an event log would eventually fill `localStorage`; the log is capped at 20 000 answers
(oldest pruned). Backups and sync include it, so progress survives a device move. Push
@ -1349,6 +1355,17 @@ Things I chose while building, with the reasoning:
phase-1 suite (43 checks, including the real route mapping against a stub Ollama returning a
monthly-limit body, a two-device sync round-trip, and the generation guards below).
52. **Reporting a question is local-first, with a server copy for the operator.** The red "!" in
the feed chrome records the question in this browser — that local list is the skip state, so the
card leaves study, review, mistakes and exam sessions, and Undo in the toast brings it back
(the server file is append-only). It also POSTs a copy to `/api/reports`, which appends one JSON
line to `shared/freezmo/reports/reports.jsonl`. The app has no auth, so the route is write-only:
the question text is readable on the server (that is the point — prompt tuning) but never over
HTTP. A reported card counts as "not attempted" in the results rather than wrong, and a deck
where everything is reported stays studiable so it can be regenerated. Verified by the phase-1
suite (append, validation, no GET) and the phase-2 suite (button, toast, server POST, local
copy, skip, hidden next session, Undo, exam pool).
51. **Cards with repeated wording never ship.** The model sometimes pads a matching card with one
definition copied across several terms (a Czech literature deck arrived on a phone with three
identical definitions, so the card could not be solved by knowing the material), or repeats an

60
app/api/reports/route.ts Normal file
View file

@ -0,0 +1,60 @@
import { promises as fs } from "node:fs";
import path from "node:path";
import { z } from "zod";
import { getServerConfig } from "@/lib/config";
import { rejectCrossOrigin, rejectOversizedBody, requestLocale } from "@/lib/http-guard";
import { clientKey, rateLimit, rateLimitResponse } from "@/lib/rate-limit";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
/**
* Question reports. Write-only on purpose: the app has no auth, so nothing
* here is readable over HTTP. Every report is appended as one JSON line to
* `<reports dir>/reports.jsonl` under the shared data directory, where the
* operator can inspect it:
*
* tail -f ~/freezmo-prod/shared/freezmo/reports/reports.jsonl
*
* The browser keeps its own copy of the reports — it is the source of truth
* for skipping and Undo. This file is the cross-device record for tuning the
* generation prompts.
*/
const reportSchema = 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(),
});
/** A report is tiny; anything bigger is not one. */
const MAX_BODY_BYTES = 4096;
export async function POST(request: Request) {
const locale = requestLocale(request);
const crossOrigin = rejectCrossOrigin(request);
if (crossOrigin) return crossOrigin;
const oversized = rejectOversizedBody(request, MAX_BODY_BYTES);
if (oversized) return oversized;
const config = getServerConfig();
const limit = rateLimit(`reports:${clientKey(request)}`, config.rateLimits.reports, config.rateLimits.windowMs);
if (!limit.allowed) return rateLimitResponse(limit, locale);
const parsed = reportSchema.safeParse(await request.json().catch(() => null));
if (!parsed.success) return Response.json({ error: "invalid report" }, { status: 400 });
const line = JSON.stringify({ ...parsed.data, receivedAt: new Date().toISOString() });
try {
await fs.mkdir(config.reports.dir, { recursive: true });
await fs.appendFile(path.join(config.reports.dir, "reports.jsonl"), `${line}\n`, "utf8");
} catch {
return Response.json({ error: "could not store the report" }, { status: 500 });
}
return Response.json({ ok: true });
}

View file

@ -0,0 +1,44 @@
"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>
);
}

View file

@ -4,6 +4,7 @@ import { useEffect, useMemo, useState, useSyncExternalStore } from "react";
import { useRouter } from "next/navigation";
import { SessionFeed, type SessionItem } from "@/components/SessionFeed";
import { isReported } from "@/lib/question-reports";
import { RouteLoading } from "@/components/RouteLoading";
import { Button } from "@/components/ui";
import { haptic } from "@/lib/haptics";
@ -38,7 +39,11 @@ export function ReviewClient({ mistakes, deckId }: { mistakes: boolean; deckId?:
}, []);
const items = useMemo(
() => (mistakes ? mistakeItems(answers, deckId) : dueItems(srs)),
// Reported questions are out of rotation (see lib/question-reports).
() =>
(mistakes ? mistakeItems(answers, deckId) : dueItems(srs)).filter(
(item) => !isReported(item.deck.id, item.activity.id),
),
[mistakes, deckId, answers, srs],
);

View file

@ -4,6 +4,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
import { ActivityRenderer } from "@/components/activities";
import { XIcon } from "@/components/icons";
import { ReportQuestionButton } from "@/components/ReportQuestionButton";
import { ProgressBar } from "@/components/ui";
import type { Activity, Deck } from "@/lib/schemas";
import { useI18n } from "@/lib/i18n";
@ -48,6 +49,8 @@ export function SessionFeed({
useWakeLock(true);
const [index, setIndex] = useState(0);
const [results, setResults] = useState<Record<string, SessionResult>>({});
/** Ids reported during this run: skipped, so they are not "missed". */
const [skipped, setSkipped] = useState<string[]>([]);
const [runId] = useState(0);
const [remaining, setRemaining] = useState(timerSeconds ?? 0);
const scrollRef = useRef<HTMLDivElement>(null);
@ -135,7 +138,9 @@ export function SessionFeed({
const finished = index >= total;
const correct = items.filter((item) => results[item.activity.id]?.correct).length;
const score = total > 0 ? Math.round((correct / total) * 100) : 0;
/** What the results slide counts: reported cards were never attempted. */
const attemptedTotal = Math.max(0, total - skipped.length);
const score = attemptedTotal > 0 ? Math.round((correct / attemptedTotal) * 100) : 0;
return (
<div className="frost-bg fixed inset-0 flex flex-col fade-in-mobile">
@ -168,6 +173,16 @@ export function SessionFeed({
{formatClock(remaining)}
</span>
) : null}
{!finished && items[index] ? (
<ReportQuestionButton
deck={items[index].deck}
activity={items[index].activity}
onSkip={() => {
setSkipped((current) => [...current, items[index].activity.id]);
goTo(index + 1);
}}
/>
) : null}
</div>
<div className="flex items-center justify-between gap-2 text-[11px] font-semibold text-[var(--text-muted)]">
<span className="min-w-0 truncate">{title}</span>
@ -211,7 +226,7 @@ export function SessionFeed({
>
<div className="mx-auto flex min-h-full w-full max-w-xl flex-col gap-4 px-4 pb-24 pt-24 [justify-content:safe_center]">
{finished
? renderResults({ results, items, correct, total, score })
? renderResults({ results, items, correct, total: attemptedTotal, score })
: (
<div className="card space-y-2 p-5 text-center">
<p className="text-sm font-semibold text-[var(--text-muted)]">{t("review.results")}</p>

View file

@ -11,11 +11,13 @@ import { materialiseActivities } from "@/lib/deck-ops";
import { haptic } from "@/lib/haptics";
import { recordAnswer } from "@/lib/history";
import { useI18n } from "@/lib/i18n";
import { withoutReported } from "@/lib/question-reports";
import type { Activity, Deck } from "@/lib/schemas";
import { recordAttempt, upsertDeck } from "@/lib/storage";
import { useWakeLock } from "@/lib/use-wake-lock";
import { cn } from "@/lib/utils";
import { BoltIcon, FlameIcon, XIcon } from "@/components/icons";
import { ReportQuestionButton } from "@/components/ReportQuestionButton";
import { Button, ProgressBar } from "@/components/ui";
import { ResultsScreen } from "./ResultsScreen";
@ -27,7 +29,11 @@ export function StudyFeed({ deck }: { deck: Deck }) {
/* Keep the screen awake for the whole study session (feature-detected). */
useWakeLock(true);
const [currentDeck, setCurrentDeck] = useState(deck);
const [queue, setQueue] = useState<Activity[]>(deck.activities);
// Reported questions never enter a session; a fully reported deck stays
// studiable (withoutReported falls back to the full list).
const [queue, setQueue] = useState<Activity[]>(() => withoutReported(deck.id, deck.activities));
/** Ids reported during this run: skipped, so they are not "missed". */
const [skipped, setSkipped] = useState<string[]>([]);
const [index, setIndex] = useState(0);
const [results, setResults] = useState<Record<string, AttemptResult>>({});
const [xp, setXp] = useState(0);
@ -107,7 +113,8 @@ export function StudyFeed({ deck }: { deck: Deck }) {
recordedRef.current = true;
const answered = Object.values(results);
const correct = answered.filter((entry) => entry.correct).length;
const score = total > 0 ? (correct / total) * 100 : 0;
const attempted = Math.max(0, total - skipped.length);
const score = attempted > 0 ? (correct / attempted) * 100 : 0;
// Only a run with at least one answer counts. Without this, the scroll
// anchoring shift when a regenerated deck swaps in could fire a bogus
// 0% attempt before the feed had snapped back to the first slide.
@ -117,7 +124,7 @@ export function StudyFeed({ deck }: { deck: Deck }) {
void celebrate("big");
}
}
}, [finished, results, total, currentDeck.id, retryMode]);
}, [finished, results, total, skipped.length, currentDeck.id, retryMode]);
const handleAnswered = useCallback(
(activity: Activity, correct: boolean, meta?: { userAnswer?: string }) => {
@ -150,10 +157,13 @@ export function StudyFeed({ deck }: { deck: Deck }) {
);
const missed = useMemo(
() => queue.filter((activity) => !results[activity.id]?.correct),
[queue, results],
() => queue.filter((activity) => !skipped.includes(activity.id) && !results[activity.id]?.correct),
[queue, results, skipped],
);
/** What the results screen counts: reported cards were never attempted. */
const attemptedTotal = Math.max(0, total - skipped.length);
const correctCount = useMemo(
() => queue.filter((activity) => results[activity.id]?.correct).length,
[queue, results],
@ -163,6 +173,7 @@ export function StudyFeed({ deck }: { deck: Deck }) {
(activities: Activity[], retry: boolean) => {
setQueue(activities);
setResults({});
setSkipped([]);
setStreak(0);
setRetryMode(retry);
recordedRef.current = retry; // don't overwrite the best score with a retry run
@ -267,6 +278,16 @@ export function StudyFeed({ deck }: { deck: Deck }) {
<BoltIcon className="size-3.5" />
{xp}
</span>
{!finished && queue[index] ? (
<ReportQuestionButton
deck={currentDeck}
activity={queue[index]}
onSkip={() => {
setSkipped((current) => [...current, queue[index].id]);
goTo(index + 1);
}}
/>
) : null}
</div>
</div>
<div className="flex items-center justify-between gap-2 text-[11px] font-semibold text-[var(--text-muted)]">
@ -318,13 +339,13 @@ export function StudyFeed({ deck }: { deck: Deck }) {
deck={currentDeck}
xp={xp}
bestStreak={bestStreak}
total={total}
total={attemptedTotal}
correct={correctCount}
missed={missed}
results={results}
retryMode={retryMode}
onRetryMissed={() => restart(missed, true)}
onRestart={() => restart(currentDeck.activities, false)}
onRestart={() => restart(withoutReported(currentDeck.id, currentDeck.activities), false)}
onRegenerate={regenerate}
regenerating={regenerating}
regenerateError={regenerateError}

View file

@ -4,6 +4,7 @@ import { useMemo, useState, useSyncExternalStore } from "react";
import { useRouter } from "next/navigation";
import { SessionFeed, type SessionItem } from "@/components/SessionFeed";
import { isReported } from "@/lib/question-reports";
import { Button } from "@/components/ui";
import { haptic } from "@/lib/haptics";
import { recordAnswer } from "@/lib/history";
@ -33,7 +34,9 @@ export function ExamScreen() {
() =>
decks
.filter((deck) => selected.has(deck.id))
.flatMap((deck) => deck.activities.map((activity) => ({ deck, activity }))),
.flatMap((deck) => deck.activities.map((activity) => ({ deck, activity })))
// Reported questions are out of rotation (see lib/question-reports).
.filter(({ deck, activity }) => !isReported(deck.id, activity.id)),
[decks, selected],
);
const effectiveCount = Math.min(count, pool.length);

View file

@ -30,6 +30,7 @@ Environment=NEXT_DIST_DIR=.next-dev
Environment=NEXT_PUBLIC_FREEZMO_DEV=1
Environment=SYNC_DIR=%h/freezmo/.freezmo-dev/sync
Environment=PUSH_DIR=%h/freezmo/.freezmo-dev/push
Environment=REPORTS_DIR=%h/freezmo/.freezmo-dev/reports
Environment=PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
ExecStart=/bin/node %h/freezmo/node_modules/next/dist/bin/next dev -H 0.0.0.0 -p 3217
Restart=always

View file

@ -36,6 +36,7 @@ export type ServerConfig = {
sync: number;
push: number;
chat: number;
reports: number;
};
sync: {
/** Where opaque encrypted sync blobs live (SYNC_DIR). */
@ -53,6 +54,10 @@ export type ServerConfig = {
/** Where push subscriptions live (PUSH_DIR). */
dir: string;
};
reports: {
/** Where reported questions are appended, one JSON line each (REPORTS_DIR). */
dir: string;
};
};
/** What a single request should use to talk to Ollama. */
@ -149,6 +154,8 @@ export function getServerConfig(): ServerConfig {
push: positiveInt(process.env.RATE_LIMIT_PUSH, 30),
// Chat turns are one model call each, but users send them in bursts.
chat: positiveInt(process.env.RATE_LIMIT_CHAT, 60),
// Question reports are cheap file appends.
reports: positiveInt(process.env.RATE_LIMIT_REPORTS, 20),
},
sync: {
dir: process.env.SYNC_DIR?.trim() || path.join(process.cwd(), ".freezmo", "sync"),
@ -161,6 +168,9 @@ export function getServerConfig(): ServerConfig {
subject: process.env.VAPID_SUBJECT?.trim() || "mailto:freezmo@localhost",
dir: process.env.PUSH_DIR?.trim() || path.join(process.cwd(), ".freezmo", "push"),
},
reports: {
dir: process.env.REPORTS_DIR?.trim() || path.join(process.cwd(), ".freezmo", "reports"),
},
};
}

View file

@ -530,6 +530,8 @@ const en: Record<string, string> = {
"feed.prev": "Previous activity",
"feed.next": "Next activity",
"feed.results": "Results",
"feed.report": "Report question",
"feed.reported": "Question reported — it won't show up again.",
/* results --------------------------------------------------------- */
"results.perfect": "Perfect freeze! 🧊",
@ -1464,6 +1466,8 @@ const cs: Record<string, string> = {
"feed.prev": "Předchozí aktivita",
"feed.next": "Další aktivita",
"feed.results": "Výsledky",
"feed.report": "Nahlásit otázku",
"feed.reported": "Otázka nahlášena — už se neukáže.",
/* results --------------------------------------------------------- */
"results.perfect": "Perfektní zmrazení! 🧊",

169
lib/question-reports.ts Normal file
View 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)),
);
}

View file

@ -9,7 +9,7 @@
*
* Bumping CACHE_VERSION is how a new deploy invalidates old assets.
*/
const CACHE_VERSION = "freezmo-v37";
const CACHE_VERSION = "freezmo-v38";
const SHELL_CACHE = `${CACHE_VERSION}-shell`;
const ASSET_CACHE = `${CACHE_VERSION}-assets`;

View file

@ -72,7 +72,7 @@ RELEASE="$(date +%Y%m%d-%H%M%S)-$SHORT"
DIR="$PROD/releases/$RELEASE"
say "deploying $SHORT as $RELEASE"
mkdir -p "$PROD/releases" "$PROD/shared/freezmo/sync" "$PROD/shared/freezmo/push"
mkdir -p "$PROD/releases" "$PROD/shared/freezmo/sync" "$PROD/shared/freezmo/push" "$PROD/shared/freezmo/reports"
# ------------------------------------------------------------------ #
# 2. Export + build the release #

View file

@ -4,7 +4,7 @@
* and usage-limit handling (client wiring + real server mapping against a stub
* Ollama).
*/
import { readFileSync } from "node:fs";
import { mkdirSync, readFileSync, rmSync } from "node:fs";
import http from "node:http";
import {
@ -476,5 +476,59 @@ async function openSettings(page) {
}
}
/* 10. reports: appended as JSON lines, write-only over HTTP --------- */
{
check("reports: app port free", !(await portInUse(3211)));
const reportsDir = new URL("./.tmp/reports-api", import.meta.url).pathname;
rmSync(reportsDir, { recursive: true, force: true });
mkdirSync(reportsDir, { recursive: true });
const app = spawnApp(3211, {
OLLAMA_BASE_URL: "http://127.0.0.1:11999",
OLLAMA_AUTH: "local",
OLLAMA_VERIFY_KEY: "false",
REPORTS_DIR: reportsDir,
});
try {
const up = await waitForHttp("http://127.0.0.1:3211/api/health");
check("reports: stub app is up", up);
if (up) {
const report = {
deckId: "seed_deck",
deckTitle: "Lidové čtení",
activityId: "a1",
type: "matching",
question: "Přiřaď postavy k jejich zařazení.",
at: Date.now(),
};
const accepted = await fetch("http://127.0.0.1:3211/api/reports", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(report),
});
check("reports: accepted", accepted.status === 200 && (await accepted.json()).ok === true, String(accepted.status));
const lines = readFileSync(`${reportsDir}/reports.jsonl`, "utf8").trim().split("\n");
const stored = JSON.parse(lines[lines.length - 1]);
check(
"reports: one JSON line with a server timestamp",
stored.activityId === "a1" && stored.question === report.question && typeof stored.receivedAt === "string",
);
const invalid = await fetch("http://127.0.0.1:3211/api/reports", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ deckId: "x" }),
});
check("reports: invalid body rejected", invalid.status === 400, String(invalid.status));
const readBack = await fetch("http://127.0.0.1:3211/api/reports");
check("reports: write-only over HTTP (no GET)", readBack.status === 405, String(readBack.status));
}
} finally {
app.stop();
}
}
await browser.close();
finish();

View file

@ -123,6 +123,58 @@ const browser = await launchBrowser();
await context.close();
}
/* 1c. report question: skips, records, hides, undo ------------------- */
{
const { context, page } = await makePage(browser);
const posts = [];
await page.route("**/api/reports", (route) => {
posts.push(JSON.parse(route.request().postData() ?? "{}"));
return route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ ok: true }) });
});
await seed(page, { decks: [DECK] });
await page.goto(`${BASE}/deck/seed_deck`, { waitUntil: "networkidle" });
await page.waitForTimeout(1000);
const report = page.getByRole("button", { name: "Report question" });
check("report: button sits in the feed chrome", await report.isVisible());
await report.click();
await page.waitForTimeout(700);
check("report: toast with Undo", await page.getByText("Question reported").isVisible());
check(
"report: sent to the server",
posts.length === 1 && posts[0].activityId === "a1" && posts[0].question.includes("What colour"),
JSON.stringify(posts[0] ?? {}).slice(0, 80),
);
const stored = await page.evaluate(() => JSON.parse(localStorage.getItem("freezmo.reports.v1") ?? "[]"));
check("report: kept locally", stored.length === 1 && stored[0].activityId === "a1");
check("report: skips to the next activity", await page.getByText("2/2").isVisible());
// A fresh session leaves the reported question out.
await page.goto(`${BASE}/deck/seed_deck`, { waitUntil: "networkidle" });
await page.waitForTimeout(1000);
check(
"report: hidden in the next session",
(await page.getByText("What colour is a clear daytime sky?").count()) === 0 &&
(await page.getByText("What makes the sky blue?").count()) >= 1,
);
// Undo takes a report back (the earlier one stays).
await page.goto(`${BASE}/deck/seed_deck`, { waitUntil: "networkidle" });
await page.waitForTimeout(900);
await page.getByRole("button", { name: "Report question" }).click();
await page.waitForTimeout(400);
const before = await page.evaluate(() =>
JSON.parse(localStorage.getItem("freezmo.reports.v1") ?? "[]").map((report) => report.activityId),
);
await page.getByRole("button", { name: "Undo" }).click();
await page.waitForTimeout(300);
const after = await page.evaluate(() =>
JSON.parse(localStorage.getItem("freezmo.reports.v1") ?? "[]").map((report) => report.activityId),
);
check("report: Undo takes the report back", before.includes("a2") && !after.includes("a2") && after.includes("a1"), JSON.stringify({ before, after }));
await context.close();
}
/* 2. SRS due → review session; stats screen -------------------------- */
{
const now = Date.now();
@ -164,6 +216,32 @@ const browser = await launchBrowser();
await context.close();
}
/* 2b. review and exam leave reported questions out ------------------- */
{
const { context, page } = await makePage(browser);
await page.addInitScript(() => {
localStorage.setItem(
"freezmo.reports.v1",
JSON.stringify([
{ deckId: "seed_deck", activityId: "a1", type: "multiple_choice", question: "What colour is a clear daytime sky?", at: Date.now() },
]),
);
});
await seed(page, { decks: [DECK] });
await page.goto(`${BASE}/exam`, { waitUntil: "networkidle" });
await page.waitForTimeout(900);
await page.getByRole("button", { name: /Sky deck/ }).click();
await page.waitForTimeout(200);
await page.getByRole("button", { name: /Start exam/ }).click();
await page.waitForTimeout(1000);
check(
"exam: reported question is out of the pool",
(await page.getByText("What colour is a clear daytime sky?").count()) === 0 &&
(await page.getByText("What makes the sky blue?").count()) >= 1,
);
await context.close();
}
/* 3. exam mode: setup, no hints, timer, breakdown -------------------- */
{
const { context, page } = await makePage(browser);