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 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 exports, sync payloads, share links, logs, URLs, `.env` files committed to
the repo, or release directories. Server data (sync blobs, push 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 6. **Both languages, always.** Every user-facing string goes into
`lib/i18n-dict.ts` in **en and cs** (the i18n test enforces key parity). `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 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/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` | `{ 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/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 }` | | `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` | | `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 | | `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 `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 key. Deleting the synced copy is one button in Settings (and works even on a device that can't
unlock the data). 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`), - **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 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 (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 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). 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 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 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 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 { useRouter } from "next/navigation";
import { SessionFeed, type SessionItem } from "@/components/SessionFeed"; import { SessionFeed, type SessionItem } from "@/components/SessionFeed";
import { isReported } from "@/lib/question-reports";
import { RouteLoading } from "@/components/RouteLoading"; import { RouteLoading } from "@/components/RouteLoading";
import { Button } from "@/components/ui"; import { Button } from "@/components/ui";
import { haptic } from "@/lib/haptics"; import { haptic } from "@/lib/haptics";
@ -38,7 +39,11 @@ export function ReviewClient({ mistakes, deckId }: { mistakes: boolean; deckId?:
}, []); }, []);
const items = useMemo( 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], [mistakes, deckId, answers, srs],
); );

View file

@ -4,6 +4,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
import { ActivityRenderer } from "@/components/activities"; import { ActivityRenderer } from "@/components/activities";
import { XIcon } from "@/components/icons"; import { XIcon } from "@/components/icons";
import { ReportQuestionButton } from "@/components/ReportQuestionButton";
import { ProgressBar } from "@/components/ui"; import { ProgressBar } from "@/components/ui";
import type { Activity, Deck } from "@/lib/schemas"; import type { Activity, Deck } from "@/lib/schemas";
import { useI18n } from "@/lib/i18n"; import { useI18n } from "@/lib/i18n";
@ -48,6 +49,8 @@ export function SessionFeed({
useWakeLock(true); useWakeLock(true);
const [index, setIndex] = useState(0); const [index, setIndex] = useState(0);
const [results, setResults] = useState<Record<string, SessionResult>>({}); 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 [runId] = useState(0);
const [remaining, setRemaining] = useState(timerSeconds ?? 0); const [remaining, setRemaining] = useState(timerSeconds ?? 0);
const scrollRef = useRef<HTMLDivElement>(null); const scrollRef = useRef<HTMLDivElement>(null);
@ -135,7 +138,9 @@ export function SessionFeed({
const finished = index >= total; const finished = index >= total;
const correct = items.filter((item) => results[item.activity.id]?.correct).length; 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 ( return (
<div className="frost-bg fixed inset-0 flex flex-col fade-in-mobile"> <div className="frost-bg fixed inset-0 flex flex-col fade-in-mobile">
@ -168,6 +173,16 @@ export function SessionFeed({
{formatClock(remaining)} {formatClock(remaining)}
</span> </span>
) : null} ) : 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>
<div className="flex items-center justify-between gap-2 text-[11px] font-semibold text-[var(--text-muted)]"> <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> <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]"> <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 {finished
? renderResults({ results, items, correct, total, score }) ? renderResults({ results, items, correct, total: attemptedTotal, score })
: ( : (
<div className="card space-y-2 p-5 text-center"> <div className="card space-y-2 p-5 text-center">
<p className="text-sm font-semibold text-[var(--text-muted)]">{t("review.results")}</p> <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 { haptic } from "@/lib/haptics";
import { recordAnswer } from "@/lib/history"; import { recordAnswer } from "@/lib/history";
import { useI18n } from "@/lib/i18n"; import { useI18n } from "@/lib/i18n";
import { withoutReported } from "@/lib/question-reports";
import type { Activity, Deck } from "@/lib/schemas"; import type { Activity, Deck } from "@/lib/schemas";
import { recordAttempt, upsertDeck } from "@/lib/storage"; import { recordAttempt, upsertDeck } from "@/lib/storage";
import { useWakeLock } from "@/lib/use-wake-lock"; import { useWakeLock } from "@/lib/use-wake-lock";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { BoltIcon, FlameIcon, XIcon } from "@/components/icons"; import { BoltIcon, FlameIcon, XIcon } from "@/components/icons";
import { ReportQuestionButton } from "@/components/ReportQuestionButton";
import { Button, ProgressBar } from "@/components/ui"; import { Button, ProgressBar } from "@/components/ui";
import { ResultsScreen } from "./ResultsScreen"; 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). */ /* Keep the screen awake for the whole study session (feature-detected). */
useWakeLock(true); useWakeLock(true);
const [currentDeck, setCurrentDeck] = useState(deck); 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 [index, setIndex] = useState(0);
const [results, setResults] = useState<Record<string, AttemptResult>>({}); const [results, setResults] = useState<Record<string, AttemptResult>>({});
const [xp, setXp] = useState(0); const [xp, setXp] = useState(0);
@ -107,7 +113,8 @@ export function StudyFeed({ deck }: { deck: Deck }) {
recordedRef.current = true; recordedRef.current = true;
const answered = Object.values(results); const answered = Object.values(results);
const correct = answered.filter((entry) => entry.correct).length; 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 // 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 // anchoring shift when a regenerated deck swaps in could fire a bogus
// 0% attempt before the feed had snapped back to the first slide. // 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"); void celebrate("big");
} }
} }
}, [finished, results, total, currentDeck.id, retryMode]); }, [finished, results, total, skipped.length, currentDeck.id, retryMode]);
const handleAnswered = useCallback( const handleAnswered = useCallback(
(activity: Activity, correct: boolean, meta?: { userAnswer?: string }) => { (activity: Activity, correct: boolean, meta?: { userAnswer?: string }) => {
@ -150,10 +157,13 @@ export function StudyFeed({ deck }: { deck: Deck }) {
); );
const missed = useMemo( const missed = useMemo(
() => queue.filter((activity) => !results[activity.id]?.correct), () => queue.filter((activity) => !skipped.includes(activity.id) && !results[activity.id]?.correct),
[queue, results], [queue, results, skipped],
); );
/** What the results screen counts: reported cards were never attempted. */
const attemptedTotal = Math.max(0, total - skipped.length);
const correctCount = useMemo( const correctCount = useMemo(
() => queue.filter((activity) => results[activity.id]?.correct).length, () => queue.filter((activity) => results[activity.id]?.correct).length,
[queue, results], [queue, results],
@ -163,6 +173,7 @@ export function StudyFeed({ deck }: { deck: Deck }) {
(activities: Activity[], retry: boolean) => { (activities: Activity[], retry: boolean) => {
setQueue(activities); setQueue(activities);
setResults({}); setResults({});
setSkipped([]);
setStreak(0); setStreak(0);
setRetryMode(retry); setRetryMode(retry);
recordedRef.current = retry; // don't overwrite the best score with a retry run 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" /> <BoltIcon className="size-3.5" />
{xp} {xp}
</span> </span>
{!finished && queue[index] ? (
<ReportQuestionButton
deck={currentDeck}
activity={queue[index]}
onSkip={() => {
setSkipped((current) => [...current, queue[index].id]);
goTo(index + 1);
}}
/>
) : null}
</div> </div>
</div> </div>
<div className="flex items-center justify-between gap-2 text-[11px] font-semibold text-[var(--text-muted)]"> <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} deck={currentDeck}
xp={xp} xp={xp}
bestStreak={bestStreak} bestStreak={bestStreak}
total={total} total={attemptedTotal}
correct={correctCount} correct={correctCount}
missed={missed} missed={missed}
results={results} results={results}
retryMode={retryMode} retryMode={retryMode}
onRetryMissed={() => restart(missed, true)} onRetryMissed={() => restart(missed, true)}
onRestart={() => restart(currentDeck.activities, false)} onRestart={() => restart(withoutReported(currentDeck.id, currentDeck.activities), false)}
onRegenerate={regenerate} onRegenerate={regenerate}
regenerating={regenerating} regenerating={regenerating}
regenerateError={regenerateError} regenerateError={regenerateError}

View file

@ -4,6 +4,7 @@ import { useMemo, useState, useSyncExternalStore } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { SessionFeed, type SessionItem } from "@/components/SessionFeed"; import { SessionFeed, type SessionItem } from "@/components/SessionFeed";
import { isReported } from "@/lib/question-reports";
import { Button } from "@/components/ui"; import { Button } from "@/components/ui";
import { haptic } from "@/lib/haptics"; import { haptic } from "@/lib/haptics";
import { recordAnswer } from "@/lib/history"; import { recordAnswer } from "@/lib/history";
@ -33,7 +34,9 @@ export function ExamScreen() {
() => () =>
decks decks
.filter((deck) => selected.has(deck.id)) .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], [decks, selected],
); );
const effectiveCount = Math.min(count, pool.length); 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=NEXT_PUBLIC_FREEZMO_DEV=1
Environment=SYNC_DIR=%h/freezmo/.freezmo-dev/sync Environment=SYNC_DIR=%h/freezmo/.freezmo-dev/sync
Environment=PUSH_DIR=%h/freezmo/.freezmo-dev/push 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 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 ExecStart=/bin/node %h/freezmo/node_modules/next/dist/bin/next dev -H 0.0.0.0 -p 3217
Restart=always Restart=always

View file

@ -36,6 +36,7 @@ export type ServerConfig = {
sync: number; sync: number;
push: number; push: number;
chat: number; chat: number;
reports: number;
}; };
sync: { sync: {
/** Where opaque encrypted sync blobs live (SYNC_DIR). */ /** Where opaque encrypted sync blobs live (SYNC_DIR). */
@ -53,6 +54,10 @@ export type ServerConfig = {
/** Where push subscriptions live (PUSH_DIR). */ /** Where push subscriptions live (PUSH_DIR). */
dir: string; 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. */ /** 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), push: positiveInt(process.env.RATE_LIMIT_PUSH, 30),
// Chat turns are one model call each, but users send them in bursts. // Chat turns are one model call each, but users send them in bursts.
chat: positiveInt(process.env.RATE_LIMIT_CHAT, 60), chat: positiveInt(process.env.RATE_LIMIT_CHAT, 60),
// Question reports are cheap file appends.
reports: positiveInt(process.env.RATE_LIMIT_REPORTS, 20),
}, },
sync: { sync: {
dir: process.env.SYNC_DIR?.trim() || path.join(process.cwd(), ".freezmo", "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", subject: process.env.VAPID_SUBJECT?.trim() || "mailto:freezmo@localhost",
dir: process.env.PUSH_DIR?.trim() || path.join(process.cwd(), ".freezmo", "push"), 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.prev": "Previous activity",
"feed.next": "Next activity", "feed.next": "Next activity",
"feed.results": "Results", "feed.results": "Results",
"feed.report": "Report question",
"feed.reported": "Question reported — it won't show up again.",
/* results --------------------------------------------------------- */ /* results --------------------------------------------------------- */
"results.perfect": "Perfect freeze! 🧊", "results.perfect": "Perfect freeze! 🧊",
@ -1464,6 +1466,8 @@ const cs: Record<string, string> = {
"feed.prev": "Předchozí aktivita", "feed.prev": "Předchozí aktivita",
"feed.next": "Další aktivita", "feed.next": "Další aktivita",
"feed.results": "Výsledky", "feed.results": "Výsledky",
"feed.report": "Nahlásit otázku",
"feed.reported": "Otázka nahlášena — už se neukáže.",
/* results --------------------------------------------------------- */ /* results --------------------------------------------------------- */
"results.perfect": "Perfektní zmrazení! 🧊", "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. * 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 SHELL_CACHE = `${CACHE_VERSION}-shell`;
const ASSET_CACHE = `${CACHE_VERSION}-assets`; 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" DIR="$PROD/releases/$RELEASE"
say "deploying $SHORT as $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 # # 2. Export + build the release #

View file

@ -4,7 +4,7 @@
* and usage-limit handling (client wiring + real server mapping against a stub * and usage-limit handling (client wiring + real server mapping against a stub
* Ollama). * Ollama).
*/ */
import { readFileSync } from "node:fs"; import { mkdirSync, readFileSync, rmSync } from "node:fs";
import http from "node:http"; import http from "node:http";
import { 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(); await browser.close();
finish(); finish();

View file

@ -123,6 +123,58 @@ const browser = await launchBrowser();
await context.close(); 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 -------------------------- */ /* 2. SRS due → review session; stats screen -------------------------- */
{ {
const now = Date.now(); const now = Date.now();
@ -164,6 +216,32 @@ const browser = await launchBrowser();
await context.close(); 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 -------------------- */ /* 3. exam mode: setup, no hints, timer, breakdown -------------------- */
{ {
const { context, page } = await makePage(browser); const { context, page } = await makePage(browser);