diff --git a/AGENTS.md b/AGENTS.md index f68a00d..3195cbd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/README.md b/README.md index a0ed736..29d754d 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/app/api/reports/route.ts b/app/api/reports/route.ts new file mode 100644 index 0000000..263d246 --- /dev/null +++ b/app/api/reports/route.ts @@ -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.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 }); +} diff --git a/components/ReportQuestionButton.tsx b/components/ReportQuestionButton.tsx new file mode 100644 index 0000000..cd29538 --- /dev/null +++ b/components/ReportQuestionButton.tsx @@ -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 ( + + ); +} diff --git a/components/ReviewClient.tsx b/components/ReviewClient.tsx index 86b4e04..aabd2e6 100644 --- a/components/ReviewClient.tsx +++ b/components/ReviewClient.tsx @@ -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], ); diff --git a/components/SessionFeed.tsx b/components/SessionFeed.tsx index ab5afcb..cd5ddd7 100644 --- a/components/SessionFeed.tsx +++ b/components/SessionFeed.tsx @@ -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>({}); + /** Ids reported during this run: skipped, so they are not "missed". */ + const [skipped, setSkipped] = useState([]); const [runId] = useState(0); const [remaining, setRemaining] = useState(timerSeconds ?? 0); const scrollRef = useRef(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 (
@@ -168,6 +173,16 @@ export function SessionFeed({ {formatClock(remaining)} ) : null} + {!finished && items[index] ? ( + { + setSkipped((current) => [...current, items[index].activity.id]); + goTo(index + 1); + }} + /> + ) : null}
{title} @@ -211,7 +226,7 @@ export function SessionFeed({ >
{finished - ? renderResults({ results, items, correct, total, score }) + ? renderResults({ results, items, correct, total: attemptedTotal, score }) : (

{t("review.results")}

diff --git a/components/StudyFeed.tsx b/components/StudyFeed.tsx index f2766cb..eb50174 100644 --- a/components/StudyFeed.tsx +++ b/components/StudyFeed.tsx @@ -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(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(() => withoutReported(deck.id, deck.activities)); + /** Ids reported during this run: skipped, so they are not "missed". */ + const [skipped, setSkipped] = useState([]); const [index, setIndex] = useState(0); const [results, setResults] = useState>({}); 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 }) { {xp} + {!finished && queue[index] ? ( + { + setSkipped((current) => [...current, queue[index].id]); + goTo(index + 1); + }} + /> + ) : null}
@@ -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} diff --git a/components/exam/ExamScreen.tsx b/components/exam/ExamScreen.tsx index c35a84f..dacb37f 100644 --- a/components/exam/ExamScreen.tsx +++ b/components/exam/ExamScreen.tsx @@ -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); diff --git a/deploy/freezmo-dev.service b/deploy/freezmo-dev.service index bc05891..6b7e923 100644 --- a/deploy/freezmo-dev.service +++ b/deploy/freezmo-dev.service @@ -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 diff --git a/lib/config.ts b/lib/config.ts index 6b5674f..1e4468a 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -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"), + }, }; } diff --git a/lib/i18n-dict.ts b/lib/i18n-dict.ts index c186de8..842040c 100644 --- a/lib/i18n-dict.ts +++ b/lib/i18n-dict.ts @@ -530,6 +530,8 @@ const en: Record = { "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 = { "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í! 🧊", diff --git a/lib/question-reports.ts b/lib/question-reports.ts new file mode 100644 index 0000000..c162fbb --- /dev/null +++ b/lib/question-reports.ts @@ -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; + +type Listener = () => void; + +let reports: QuestionReport[] | null = null; +let cached: QuestionReport[] = EMPTY; +const listeners = new Set(); + +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)), + ); +} diff --git a/public/sw.js b/public/sw.js index 1e9f01e..1e29876 100644 --- a/public/sw.js +++ b/public/sw.js @@ -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`; diff --git a/scripts/deploy.sh b/scripts/deploy.sh index 7db0a49..0e4382d 100755 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -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 # diff --git a/tests/phase1-test.mjs b/tests/phase1-test.mjs index 2c77fbf..bfff75c 100644 --- a/tests/phase1-test.mjs +++ b/tests/phase1-test.mjs @@ -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(); diff --git a/tests/phase2-test.mjs b/tests/phase2-test.mjs index be2f49e..d048c87 100644 --- a/tests/phase2-test.mjs +++ b/tests/phase2-test.mjs @@ -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);