Phase 3: Check my work, follow-ups and turn-into-deck

Check my work (third homework mode)
- One model call with its own system prompt marks the student's own
  attempt: verdict, what's right, each issue with why + fix, and the
  corrected version that keeps everything the student got right.
  Unasked-for extras are never errors. The attempt is typed/pasted
  (photos of handwriting are documented as unsupported) and stored in
  the record so Redo re-checks the same work.
- Server: mode "check" + solution on POST /api/homework (zod, 400
  server.needSolution without one) and the same NDJSON stream; new
  checkHomework() pipeline with repair-once validation.

Follow-ups
- New POST /api/homework/refine (plain JSON, RATE_LIMIT_REFINE, zod,
  auth before body): shorter / more detail / explain, plain text back,
  language preserved.
- Shorter + More detail + Original rewrite the finished answer and the
  corrected version in place (FollowUpBar); every guide step has
  Explain step N differently; failed calls say so without losing text.

Turn into a study deck
- Any result hands its content (answer, corrected version or assembled
  guide) to New deck through a one-shot sessionStorage seed with the
  options step pre-filled.

UI
- Phone step 2 gains the third card: tapping it reveals the solution
  field + submit; desktop has three cards and the solution field shows
  with the mode. Homework history labels and share/card previews cover
  the new mode.

Verified: new phase-3 suite 20/20 against a stub Ollama (check payload,
missing-solution 400, refine validation, phone + desktop check flows,
shorter follow-up with restore, guide explain, deck hand-off) plus
regression 74/74, mobile 52/52, declutter 116/116, wizard 80/80,
animation 8/8, native 35/35, phase1 38/38, phase2 43/43, i18n 25/25.
Key parity 734/734. Lint + build clean. SW freezmo-v27.
This commit is contained in:
Martin Dvořák 2026-10-10 14:36:04 +00:00
commit 4513364019
22 changed files with 947 additions and 56 deletions

View file

@ -42,6 +42,8 @@ TEXT_MODEL=gpt-oss:120b-cloud
# RATE_LIMIT_GENERATE=12 # RATE_LIMIT_GENERATE=12
# RATE_LIMIT_GRADE=40 # RATE_LIMIT_GRADE=40
# RATE_LIMIT_HOMEWORK=12 # RATE_LIMIT_HOMEWORK=12
# Homework follow-ups (short rewrites).
# RATE_LIMIT_REFINE=30
# RATE_LIMIT_HEALTH=90 # RATE_LIMIT_HEALTH=90
# Encrypted sync (no model calls, but shouldn't be hammered). # Encrypted sync (no model calls, but shouldn't be hammered).
# RATE_LIMIT_SYNC=60 # RATE_LIMIT_SYNC=60

View file

@ -123,20 +123,27 @@ no database, no data leaving your machine except the requests you send to Ollama
- Same intake as a deck — paste the assignment, photograph it, or add a PDF/DOCX/PPTX/ODT/text - Same intake as a deck — paste the assignment, photograph it, or add a PDF/DOCX/PPTX/ODT/text
file; photos go through the same vision model and language guard that notes do file; photos go through the same vision model and language guard that notes do
- Two modes: **Just make it** returns the finished homework (the answer only, ready to copy), and - Three modes: **Just make it** returns the finished homework (the answer only, ready to copy),
**Explain how to make this homework** turns it into a guided walkthrough **Explain how to make this homework** turns it into a guided walkthrough, and **Check my work**
marks the student's own attempt — what's right, what's wrong and why, plus a corrected version
(the attempt is typed or pasted; photos of handwriting are not supported yet, and the UI says so)
- **Follow-ups on a result**: *Shorter*, *More detail* and back to the *Original* rewrite the
finished text in place; inside a guide every step has *Explain step N differently*
- **Turn into a study deck** on any result opens New deck with the content (the answer, the
corrected version or the assembled guide) and the options step pre-filled
- On phones this is a **2-step flow**: the assignment first (same simplified input as the - On phones this is a **2-step flow**: the assignment first (same simplified input as the
deck flow, with its own step headline, formats hint, big add area and empty placeholder), deck flow, with its own step headline, formats hint, big add area and empty placeholder),
then the two mode cards under a short preview — tapping a card starts it immediately, so then the mode cards under a short preview — tapping a card starts it immediately (the check
there is no separate button. Step 1 starts with *Cancel* and the flat action bar matches card first reveals the solution field), so there is no separate button. Step 1 starts with
the deck wizard. Desktop keeps the select-then-confirm layout. *Cancel* and the flat action bar matches the deck wizard. Desktop keeps the select-then-confirm
layout.
- The guide is one step per screen in the same snap-feed feel as the study feed: what to do and - The guide is one step per screen in the same snap-feed feel as the study feed: what to do and
why, **three progressive hints** per step (nudge → hint → the full answer), and a **checkpoint** why, **three progressive hints** per step (nudge → hint → the full answer), and a **checkpoint**
built from the normal activity components and graded through `/api/grade` built from the normal activity components and graded through `/api/grade`
- The last screen assembles the solution from the step answers, with a copy button - The last screen assembles the solution from the step answers, with a copy button
- Results live in `freezmo.homework.v1` — the homework screen's history and the library's - Results live in `freezmo.homework.v1` — the homework screen's history and the library's
**Homework** tab — where you can reopen or delete them; **Make it again** re-runs from the **Homework** tab — where you can reopen or delete them; **Redo** re-runs from the
stored assignment text, and original files are never saved stored assignment text (and the stored attempt for checks), and original files are never saved
- The answer and the guide are always written in the **language of the assignment**, never - The answer and the guide are always written in the **language of the assignment**, never
translated into the interface language translated into the interface language
@ -423,6 +430,7 @@ client IP with a sliding window. Everything is configurable in `.env.local`:
| `POST /api/generate` | 12 per 5 minutes | `RATE_LIMIT_GENERATE` | | `POST /api/generate` | 12 per 5 minutes | `RATE_LIMIT_GENERATE` |
| `POST /api/homework` | 12 per 5 minutes | `RATE_LIMIT_HOMEWORK` | | `POST /api/homework` | 12 per 5 minutes | `RATE_LIMIT_HOMEWORK` |
| `POST /api/grade` | 40 per 5 minutes | `RATE_LIMIT_GRADE` | | `POST /api/grade` | 40 per 5 minutes | `RATE_LIMIT_GRADE` |
| `POST /api/homework/refine` | 30 per 5 minutes | `RATE_LIMIT_REFINE` |
| `GET /api/health` | 90 per 5 minutes | `RATE_LIMIT_HEALTH` | | `GET /api/health` | 90 per 5 minutes | `RATE_LIMIT_HEALTH` |
| `POST/GET/DELETE /api/sync` | 60 per 5 minutes | `RATE_LIMIT_SYNC` | | `POST/GET/DELETE /api/sync` | 60 per 5 minutes | `RATE_LIMIT_SYNC` |
| `GET/POST/DELETE /api/push`, `POST /api/push/test` | 30 per 5 minutes | `RATE_LIMIT_PUSH` | | `GET/POST/DELETE /api/push`, `POST /api/push/test` | 30 per 5 minutes | `RATE_LIMIT_PUSH` |
@ -719,7 +727,8 @@ scripts/
| Route | Body | Returns | | Route | Body | Returns |
| --- | --- | --- | | --- | --- | --- |
| `POST /api/generate` | `{ images?: string[], text?, difficulty, count?, focus?, language?, mode?, existing? }` | NDJSON stream: `{type:"stage"}`, `{type:"deck",deck,meta}`, `{type:"error"}` | | `POST /api/generate` | `{ images?: string[], text?, difficulty, count?, focus?, language?, mode?, existing? }` | NDJSON stream: `{type:"stage"}`, `{type:"deck",deck,meta}`, `{type:"error"}` |
| `POST /api/homework` | `{ mode: "make" \| "guide", images?: string[], text?, language? }` | NDJSON stream: `{type:"stage"}`, `{type:"homework",homework,meta}`, `{type:"error"}` | | `POST /api/homework` | `{ mode: "make" \| "guide" \| "check", images?: string[], text?, solution?, language? }` | NDJSON stream: `{type:"stage"}`, `{type:"homework",homework,meta}`, `{type:"error"}` |
| `POST /api/homework/refine` | `{ instruction: "shorter" \| "more" \| "explain", text, context?, language? }` | `{ text }` — one rewritten piece, plain text |
| `POST /api/grade` | `{ question, referenceAnswer, keyPoints, userAnswer, language? }` | `{ correct, score, feedback, missing }` | | `POST /api/grade` | `{ question, referenceAnswer, keyPoints, userAnswer, language? }` | `{ correct, score, feedback, missing }` |
| `GET /api/health` | – | `{ ok, reachable, mode, baseUrl, hasApiKey, vision, text, latencyMs, modelCount, error }` | | `GET /api/health` | – | `{ ok, reachable, mode, baseUrl, hasApiKey, vision, text, 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` |
@ -993,6 +1002,22 @@ Things I chose while building, with the reasoning:
second copy. Result records store the assignment text — the vision transcription when photos second copy. Result records store the assignment text — the vision transcription when photos
were used — so “Make it again” works without storing a single original file. were used — so “Make it again” works without storing a single original file.
42. **Check my work is a checker, not a second writer.** The third homework mode sends the
assignment and the student's attempt to one model call with its own system prompt
(`HOMEWORK_CHECK_SYSTEM_PROMPT`): the output is a verdict, a list of what is right, a list of
issues with *why* and *fix*, and the corrected version. Unasked-for extras are explicitly
never errors, and the corrected version keeps everything the student got right as they wrote
it. The attempt is typed or pasted — supporting photos of handwriting would need a second
vision pass and a way to separate assignment from attempt in the same image, so the UI says
plainly that it isn't supported yet. Follow-ups are a separate, cheaper route
(`POST /api/homework/refine`, plain JSON, `RATE_LIMIT_REFINE`) shared by the result, the check
and each guide step; they rewrite one piece and never touch the stored record. “Turn into a
study deck” hands the content to the New deck flow through sessionStorage (one-shot, like the
share target), with the options step pre-filled. Verified by the phase-3 suite (20 checks
against a stub Ollama: the check payload, the missing-solution 400, refine validation, the
phone and desktop check flows, the shorter follow-up with restore, the guide's explain action,
and the deck hand-off).
36. **The mobile declutter pass reshapes, it doesn't fork.** Phones (≤639px) get a 3-step deck 36. **The mobile declutter pass reshapes, it doesn't fork.** Phones (≤639px) get a 3-step deck
flow and a 2-step homework flow that share the exact state and handlers with the desktop flow and a 2-step homework flow that share the exact state and handlers with the desktop
cards (one `UploadScreen` / one `HomeworkInput`, two layouts switched by `useIsPhone()`); cards (one `UploadScreen` / one `HomeworkInput`, two layouts switched by `useIsPhone()`);

View file

@ -0,0 +1,90 @@
import { z } from "zod";
import { refineHomeworkText } from "@/lib/ai";
import { authErrorKind, authErrorMessage, getServerConfig, resolveAuth, resolveModelName } from "@/lib/config";
import { friendlyErrorMessage, OllamaError } from "@/lib/ollama";
import { rejectCrossOrigin, rejectOversizedBody, requestLocale } from "@/lib/http-guard";
import { translate } from "@/lib/i18n-dict";
import { clientKey, rateLimit, rateLimitResponse } from "@/lib/rate-limit";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
/**
* Homework follow-ups: rewrite one finished piece — shorter, more detail, or
* a step explained differently. One short model call, plain JSON back.
*/
const requestSchema = z.object({
instruction: z.enum(["shorter", "more", "explain"]),
text: z.string().min(1).max(20_000),
/** The assignment, for context (optional). */
context: z.string().max(8_000).optional(),
language: z.string().max(60).optional(),
});
export async function POST(request: Request) {
const locale = requestLocale(request);
const crossOrigin = rejectCrossOrigin(request);
if (crossOrigin) return crossOrigin;
const oversized = rejectOversizedBody(request, 128 * 1024);
if (oversized) return oversized;
const config = getServerConfig();
const limit = rateLimit(`refine:${clientKey(request)}`, config.rateLimits.refine, config.rateLimits.windowMs);
if (!limit.allowed) return rateLimitResponse(limit, locale);
const auth = resolveAuth(config, request);
if ("error" in auth) {
return Response.json(
{ error: authErrorMessage(locale, auth.error), kind: authErrorKind(auth.error) },
{ status: 401 },
);
}
let payload: unknown;
try {
payload = await request.json();
} catch {
return Response.json({ error: translate(locale, "server.bodyNotJson") }, { status: 400 });
}
const parsed = requestSchema.safeParse(payload);
if (!parsed.success) {
return Response.json(
{
error: translate(locale, "server.invalidRequest", {
detail: parsed.error.issues
.slice(0, 3)
.map((issue) => `${issue.path.join(".") || "body"} ${issue.message}`)
.join("; "),
}),
},
{ status: 400 },
);
}
try {
const text = await refineHomeworkText({
model: config.textModel,
auth: auth.auth,
instruction: parsed.data.instruction,
text: parsed.data.text,
context: parsed.data.context,
});
return Response.json(
{ text, model: resolveModelName(config.textModel, auth.auth.directCloud) },
{ headers: { "Cache-Control": "no-store" } },
);
} catch (error) {
// Never log the text itself: it may contain the student's homework.
console.error("[api/homework/refine]", error instanceof OllamaError ? `${error.kind}: ${error.message}` : error);
return Response.json(
{
error: friendlyErrorMessage(error, auth.auth.baseUrl, locale),
kind: error instanceof OllamaError ? error.kind : "unknown",
},
{ status: 502 },
);
}
}

View file

@ -1,6 +1,6 @@
import { z } from "zod"; import { z } from "zod";
import { buildHomeworkGuide, extractNotes, makeHomework, notesToText, type PipelineStage } from "@/lib/ai"; import { buildHomeworkGuide, checkHomework, extractNotes, makeHomework, notesToText, type PipelineStage } from "@/lib/ai";
import { authErrorMessage, authErrorKind, getServerConfig, resolveAuth, resolveModelName } from "@/lib/config"; import { authErrorMessage, authErrorKind, getServerConfig, resolveAuth, resolveModelName } from "@/lib/config";
import { friendlyErrorMessage, OllamaError } from "@/lib/ollama"; import { friendlyErrorMessage, OllamaError } from "@/lib/ollama";
import { rejectCrossOrigin, rejectOversizedBody, requestLocale } from "@/lib/http-guard"; import { rejectCrossOrigin, rejectOversizedBody, requestLocale } from "@/lib/http-guard";
@ -22,6 +22,8 @@ const requestSchema = z.object({
mode: homeworkModeSchema, mode: homeworkModeSchema,
images: z.array(z.string().min(32).max(MAX_IMAGE_CHARS)).max(16).optional(), images: z.array(z.string().min(32).max(MAX_IMAGE_CHARS)).max(16).optional(),
text: z.string().max(120_000).optional(), text: z.string().max(120_000).optional(),
/** Mode "check": the student's own attempt at the assignment. */
solution: z.string().max(120_000).optional(),
/** Language of the assignment (usually detected by the vision step). */ /** Language of the assignment (usually detected by the vision step). */
language: z.string().max(60).optional(), language: z.string().max(60).optional(),
}); });
@ -80,12 +82,15 @@ export async function POST(request: Request) {
); );
} }
const { mode, images, text, language } = parsed.data; const { mode, images, text, solution, language } = parsed.data;
const imageList = (images ?? []).map(stripDataUrlPrefix).filter(Boolean); const imageList = (images ?? []).map(stripDataUrlPrefix).filter(Boolean);
if (imageList.length === 0 && !text?.trim()) { if (imageList.length === 0 && !text?.trim()) {
return Response.json({ error: translate(locale, "server.needAssignment") }, { status: 400 }); return Response.json({ error: translate(locale, "server.needAssignment") }, { status: 400 });
} }
if (mode === "check" && !solution?.trim()) {
return Response.json({ error: translate(locale, "server.needSolution") }, { status: 400 });
}
const encode = encoder(); const encode = encoder();
const abort = new AbortController(); const abort = new AbortController();
@ -128,7 +133,14 @@ export async function POST(request: Request) {
send({ send({
type: "stage", type: "stage",
stage: "generation", stage: "generation",
message: translate(locale, mode === "make" ? "homework.loading.writing" : "homework.loading.planning"), message: translate(
locale,
mode === "make"
? "homework.loading.writing"
: mode === "check"
? "homework.loading.checking"
: "homework.loading.planning",
),
}); });
const meta = { const meta = {
@ -138,7 +150,30 @@ export async function POST(request: Request) {
textModel: resolveModelName(config.textModel, auth.auth.directCloud), textModel: resolveModelName(config.textModel, auth.auth.directCloud),
}; };
if (mode === "make") { if (mode === "check") {
const result = await checkHomework({
model: config.textModel,
auth: auth.auth,
assignment: assignmentText,
solution: solution!.trim(),
language: detectedLanguage,
signal: abort.signal,
});
send({
type: "homework",
homework: {
mode: "check",
title: result.title,
language: result.language || detectedLanguage,
verdict: result.verdict,
correct: result.correct,
wrong: result.wrong,
corrected: result.corrected,
sourceText: assignmentText,
},
meta,
});
} else if (mode === "make") {
const result = await makeHomework({ const result = await makeHomework({
model: config.textModel, model: config.textModel,
auth: auth.auth, auth: auth.auth,

View file

@ -41,10 +41,12 @@ export function HomeworkCard({
const preview = const preview =
record.mode === "make" record.mode === "make"
? truncate(record.answer.replace(/\s+/g, " ").trim(), 150) ? truncate(record.answer.replace(/\s+/g, " ").trim(), 150)
: truncate( : record.mode === "check"
record.steps.map((step) => step.title).join(" · "), ? truncate(record.verdict.replace(/\s+/g, " ").trim(), 150)
150, : truncate(
); record.steps.map((step) => step.title).join(" · "),
150,
);
return ( return (
<motion.li <motion.li

View file

@ -32,6 +32,7 @@ import { useI18n } from "@/lib/i18n";
import { stepVariants } from "@/lib/motion"; import { stepVariants } from "@/lib/motion";
import { UNORGANIZED_ID, type Difficulty } from "@/lib/schemas"; import { UNORGANIZED_ID, type Difficulty } from "@/lib/schemas";
import { takePendingShare } from "@/lib/share-pending"; import { takePendingShare } from "@/lib/share-pending";
import { takeDeckSeed } from "@/lib/deck-seed";
import { upsertDeck } from "@/lib/storage"; import { upsertDeck } from "@/lib/storage";
import { showToast } from "@/lib/toast"; import { showToast } from "@/lib/toast";
import { openSettings } from "@/lib/ui-events"; import { openSettings } from "@/lib/ui-events";
@ -308,6 +309,15 @@ export function UploadScreen() {
if (pending.files.length > 0) void addFiles(pending.files); if (pending.files.length > 0) void addFiles(pending.files);
}, [addFiles]); }, [addFiles]);
/* Homework → "Turn into a study deck": content + options pre-filled. */
useEffect(() => {
const seed = takeDeckSeed();
if (!seed) return;
setText(seed.text);
if (seed.difficulty) setDifficulty(seed.difficulty);
if (seed.count) setCount(seed.count);
}, []);
/* Persist the draft while the user works (debounced, best-effort). */ /* Persist the draft while the user works (debounced, best-effort). */
useEffect(() => { useEffect(() => {
if (phase === "loading") return; if (phase === "loading") return;

View file

@ -0,0 +1,76 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/ui";
import { refineHomework } from "@/lib/client-api";
import { useI18n } from "@/lib/i18n";
/**
* Follow-up actions for one finished piece of homework: shorter, more detail,
* or back to the original. Used on the make result and the corrected version
* of a check.
*/
export function FollowUpBar({
text,
context,
onReplace,
onRestore,
canRestore,
}: {
text: string;
context?: string;
onReplace: (next: string) => void;
onRestore?: () => void;
canRestore?: boolean;
}) {
const { t } = useI18n();
const [busy, setBusy] = useState<"shorter" | "more" | null>(null);
const [failed, setFailed] = useState(false);
const run = async (instruction: "shorter" | "more") => {
if (busy) return;
setBusy(instruction);
setFailed(false);
try {
const next = await refineHomework({ instruction, text, context });
onReplace(next);
} catch {
setFailed(true);
} finally {
setBusy(null);
}
};
return (
<div className="flex flex-wrap items-center gap-2">
<Button
size="sm"
variant="secondary"
className="touch-target"
loading={busy === "shorter"}
disabled={Boolean(busy)}
onClick={() => void run("shorter")}
>
{t("homework.followup.shorter")}
</Button>
<Button
size="sm"
variant="secondary"
className="touch-target"
loading={busy === "more"}
disabled={Boolean(busy)}
onClick={() => void run("more")}
>
{t("homework.followup.more")}
</Button>
{busy ? <span className="text-[12px] text-[var(--text-subtle)]">{t("homework.followup.working")}</span> : null}
{canRestore && onRestore ? (
<Button size="sm" variant="ghost" className="touch-target" disabled={Boolean(busy)} onClick={onRestore}>
{t("homework.followup.restore")}
</Button>
) : null}
{failed ? <span className="text-[12px] font-medium text-rose-600 dark:text-rose-300">{t("homework.followup.failed")}</span> : null}
</div>
);
}

View file

@ -0,0 +1,113 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/ui";
import { useI18n } from "@/lib/i18n";
import type { HomeworkCheckRecord } from "@/lib/schemas";
import { CopyButton } from "./CopyButton";
import { FollowUpBar } from "./FollowUpBar";
/** Mode 3 result: verdict, what's right, what needs fixing, corrected version. */
export function HomeworkCheckResult({
record,
onAgain,
onNew,
onToDeck,
againLoading,
}: {
record: HomeworkCheckRecord;
onAgain: () => void;
onNew: () => void;
onToDeck: () => void;
againLoading: boolean;
}) {
const { t } = useI18n();
const [corrected, setCorrected] = useState(record.corrected);
return (
<div className="space-y-5">
<header className="space-y-1">
<p className="text-[11px] font-semibold uppercase tracking-wider text-[var(--text-subtle)]">
{t("homework.check.title")}
</p>
<h1 className="text-[22px] font-semibold leading-tight tracking-[-0.02em] text-[var(--text)] text-balance">
{record.title}
</h1>
</header>
<section className="card space-y-1.5 p-4">
<p className="text-[11px] font-semibold uppercase tracking-wide text-[var(--text-subtle)]">
{t("homework.check.verdict")}
</p>
<p className="text-[15px] leading-relaxed text-[var(--text)]">{record.verdict}</p>
</section>
{record.correct.length > 0 ? (
<section className="rounded-2xl border border-emerald-200/80 bg-emerald-50/90 p-4 dark:border-emerald-400/30 dark:bg-emerald-500/10">
<p className="text-[11px] font-semibold uppercase tracking-wide text-emerald-800 dark:text-emerald-200">
{t("homework.check.correct")}
</p>
<ul className="mt-2 list-inside list-disc space-y-1 text-[14px] leading-relaxed text-emerald-900 dark:text-emerald-100">
{record.correct.map((item, index) => (
<li key={index}>{item}</li>
))}
</ul>
</section>
) : null}
{record.wrong.length > 0 ? (
<section className="rounded-2xl border border-rose-200/80 bg-rose-50/90 p-4 dark:border-rose-400/30 dark:bg-rose-500/10">
<p className="text-[11px] font-semibold uppercase tracking-wide text-rose-800 dark:text-rose-200">
{t("homework.check.wrong")}
</p>
<ul className="mt-2 space-y-3">
{record.wrong.map((entry, index) => (
<li key={index} className="space-y-0.5 text-[14px] leading-relaxed text-rose-900 dark:text-rose-100">
<p className="font-semibold">{entry.issue}</p>
<p>{entry.why}</p>
{entry.fix ? (
<p>
<span className="font-bold">{t("homework.check.fix")}: </span>
{entry.fix}
</p>
) : null}
</li>
))}
</ul>
</section>
) : null}
<article className="card space-y-3 p-4 sm:p-5">
<p className="text-[11px] font-semibold uppercase tracking-wide text-[var(--text-subtle)]">
{t("homework.check.corrected")}
</p>
<p className="whitespace-pre-wrap break-words text-[15px] leading-relaxed text-[var(--text)]">{corrected}</p>
<div className="flex flex-wrap items-center gap-2">
<CopyButton text={corrected} />
<FollowUpBar
text={corrected}
context={record.sourceText}
onReplace={setCorrected}
onRestore={() => setCorrected(record.corrected)}
canRestore={corrected !== record.corrected}
/>
</div>
</article>
<div className="flex flex-wrap items-start gap-2">
<Button variant="secondary" className="touch-target" onClick={onAgain} loading={againLoading}>
{t("homework.followup.redo")}
</Button>
<Button variant="secondary" className="touch-target" onClick={onToDeck}>
{t("homework.toDeck")}
</Button>
<Button variant="ghost" className="touch-target" onClick={onNew}>
{t("homework.result.new")}
</Button>
</div>
<p className="text-[11.5px] text-[var(--text-subtle)]">{t("homework.toDeckNote")}</p>
</div>
);
}

View file

@ -11,6 +11,7 @@ import { assembleSolution } from "@/lib/homework";
import { useI18n } from "@/lib/i18n"; import { useI18n } from "@/lib/i18n";
import type { HomeworkGuideRecord, HomeworkStep } from "@/lib/schemas"; import type { HomeworkGuideRecord, HomeworkStep } from "@/lib/schemas";
import { useWakeLock } from "@/lib/use-wake-lock"; import { useWakeLock } from "@/lib/use-wake-lock";
import { refineHomework } from "@/lib/client-api";
import { CopyButton } from "./CopyButton"; import { CopyButton } from "./CopyButton";
@ -25,12 +26,14 @@ export function HomeworkGuide({
onClose, onClose,
onAgain, onAgain,
onNew, onNew,
onToDeck,
againLoading, againLoading,
}: { }: {
record: HomeworkGuideRecord; record: HomeworkGuideRecord;
onClose: () => void; onClose: () => void;
onAgain: () => void; onAgain: () => void;
onNew: () => void; onNew: () => void;
onToDeck: () => void;
againLoading: boolean; againLoading: boolean;
}) { }) {
const { t } = useI18n(); const { t } = useI18n();
@ -40,8 +43,30 @@ export function HomeworkGuide({
const slideCount = steps.length + 1; // + the solution slide const slideCount = steps.length + 1; // + the solution slide
const [index, setIndex] = useState(0); const [index, setIndex] = useState(0);
const [revealed, setRevealed] = useState<Record<number, number>>({}); const [revealed, setRevealed] = useState<Record<number, number>>({});
/** "Explain this step differently" overrides, per step index. */
const [explained, setExplained] = useState<Record<number, string>>({});
const [explaining, setExplaining] = useState<number | null>(null);
const [explainFailed, setExplainFailed] = useState<number | null>(null);
const scrollRef = useRef<HTMLDivElement>(null); const scrollRef = useRef<HTMLDivElement>(null);
const explainStep = async (stepIndex: number) => {
if (explaining !== null) return;
setExplaining(stepIndex);
setExplainFailed(null);
try {
const next = await refineHomework({
instruction: "explain",
text: steps[stepIndex].explanation,
context: record.sourceText,
});
setExplained((current) => ({ ...current, [stepIndex]: next }));
} catch {
setExplainFailed(stepIndex);
} finally {
setExplaining(null);
}
};
const goTo = useCallback( const goTo = useCallback(
(target: number) => { (target: number) => {
const container = scrollRef.current; const container = scrollRef.current;
@ -164,8 +189,25 @@ export function HomeworkGuide({
<PromptCard> <PromptCard>
<QuestionText>{step.title}</QuestionText> <QuestionText>{step.title}</QuestionText>
<p className="mt-2 text-[15px] leading-relaxed text-frost-700 dark:text-frost-200"> <p className="mt-2 text-[15px] leading-relaxed text-frost-700 dark:text-frost-200">
{step.explanation} {explained[stepIndex] ?? step.explanation}
</p> </p>
<div className="mt-2 flex flex-wrap items-center gap-2">
<Button
size="sm"
variant="ghost"
className="touch-target"
loading={explaining === stepIndex}
disabled={explaining !== null}
onClick={() => void explainStep(stepIndex)}
>
{t("homework.followup.step", { n: stepIndex + 1 })}
</Button>
{explainFailed === stepIndex ? (
<span className="text-[12px] font-medium text-rose-600 dark:text-rose-300">
{t("homework.followup.failed")}
</span>
) : null}
</div>
</PromptCard> </PromptCard>
{/* progressive hints */} {/* progressive hints */}
@ -242,12 +284,16 @@ export function HomeworkGuide({
<div className="flex flex-wrap items-start gap-2"> <div className="flex flex-wrap items-start gap-2">
<CopyButton text={solution} /> <CopyButton text={solution} />
<Button variant="secondary" className="touch-target" onClick={onAgain} loading={againLoading}> <Button variant="secondary" className="touch-target" onClick={onAgain} loading={againLoading}>
{t("homework.result.again")} {t("homework.followup.redo")}
</Button>
<Button variant="secondary" className="touch-target" onClick={onToDeck}>
{t("homework.toDeck")}
</Button> </Button>
<Button variant="ghost" className="touch-target" onClick={onNew}> <Button variant="ghost" className="touch-target" onClick={onNew}>
{t("homework.result.new")} {t("homework.result.new")}
</Button> </Button>
</div> </div>
<p className="text-[11.5px] text-[var(--text-subtle)]">{t("homework.toDeckNote")}</p>
</div> </div>
</section> </section>
</div> </div>

View file

@ -4,7 +4,7 @@ import { motion, useReducedMotion } from "framer-motion";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { CameraIcon, FolderIcon, PlayIcon, PlusIcon, SparklesIcon } from "@/components/icons"; import { CameraIcon, CheckCircleIcon, FolderIcon, PlayIcon, PlusIcon, SparklesIcon } from "@/components/icons";
import { Button } from "@/components/ui"; import { Button } from "@/components/ui";
import { WizardActionBar } from "@/components/WizardActionBar"; import { WizardActionBar } from "@/components/WizardActionBar";
import { import {
@ -47,6 +47,7 @@ function describeExtraction(
const MODES: Array<{ id: HomeworkMode; icon: React.ReactNode; titleKey: string; subtitleKey: string }> = [ const MODES: Array<{ id: HomeworkMode; icon: React.ReactNode; titleKey: string; subtitleKey: string }> = [
{ id: "make", icon: <SparklesIcon className="size-5" />, titleKey: "homework.mode.make.title", subtitleKey: "homework.mode.make.subtitle" }, { id: "make", icon: <SparklesIcon className="size-5" />, titleKey: "homework.mode.make.title", subtitleKey: "homework.mode.make.subtitle" },
{ id: "guide", icon: <PlayIcon className="size-5" />, titleKey: "homework.mode.guide.title", subtitleKey: "homework.mode.guide.subtitle" }, { id: "guide", icon: <PlayIcon className="size-5" />, titleKey: "homework.mode.guide.title", subtitleKey: "homework.mode.guide.subtitle" },
{ id: "check", icon: <CheckCircleIcon className="size-5" />, titleKey: "homework.mode.check", subtitleKey: "homework.mode.checkDesc" },
]; ];
export function HomeworkInput({ export function HomeworkInput({
@ -54,6 +55,8 @@ export function HomeworkInput({
onModeChange, onModeChange,
text, text,
onTextChange, onTextChange,
solution,
onSolutionChange,
intake, intake,
onSubmit, onSubmit,
error, error,
@ -65,6 +68,9 @@ export function HomeworkInput({
onModeChange: (mode: HomeworkMode) => void; onModeChange: (mode: HomeworkMode) => void;
text: string; text: string;
onTextChange: (text: string) => void; onTextChange: (text: string) => void;
/** Mode "check": the student's own attempt. */
solution: string;
onSolutionChange: (solution: string) => void;
intake: ReturnType<typeof useNotesIntake>; intake: ReturnType<typeof useNotesIntake>;
/** `modeOverride` lets the phone mode cards start the run they tapped. */ /** `modeOverride` lets the phone mode cards start the run they tapped. */
onSubmit: (modeOverride?: HomeworkMode) => void; onSubmit: (modeOverride?: HomeworkMode) => void;
@ -80,6 +86,8 @@ export function HomeworkInput({
const [step, setStep] = useState(1); const [step, setStep] = useState(1);
const [stepDir, setStepDir] = useState<1 | -1>(1); const [stepDir, setStepDir] = useState<1 | -1>(1);
const [pasteOpen, setPasteOpen] = useState(false); const [pasteOpen, setPasteOpen] = useState(false);
/** Phones: the check card reveals the solution field instead of starting. */
const [checkOpen, setCheckOpen] = useState(false);
const isPhone = useIsPhone(); const isPhone = useIsPhone();
const reduceMotion = useReducedMotion(); const reduceMotion = useReducedMotion();
const online = useOnline(); const online = useOnline();
@ -176,6 +184,29 @@ export function HomeworkInput({
</div> </div>
) : null; ) : null;
/* Shared solution field: typed/pasted only (documented in the note). */
const solutionField = (
<div className="space-y-1.5">
<label className="text-[12px] font-semibold text-[var(--text)]" htmlFor="homework-solution">
{t("homework.solution.label")}
</label>
<textarea
id="homework-solution"
value={solution}
onChange={(event) => onSolutionChange(event.target.value)}
rows={5}
placeholder={t("homework.solution.placeholder")}
inputMode="text"
enterKeyHint="done"
autoComplete="off"
autoCapitalize="sentences"
spellCheck
className="w-full resize-y rounded-2xl border border-white/60 bg-white/55 px-4 py-3 text-sm text-frost-950 outline-none backdrop-blur-xl transition placeholder:text-frost-400 focus:border-accent-400 dark:border-white/12 dark:bg-white/[0.07] dark:text-frost-50"
/>
<p className="text-[11px] leading-snug text-[var(--text-subtle)]">{t("homework.solution.note")}</p>
</div>
);
/* Shared mode card: desktop toggles the selection, the phone flow starts it. */ /* Shared mode card: desktop toggles the selection, the phone flow starts it. */
const modeCard = (option: (typeof MODES)[number], onPick: () => void) => { const modeCard = (option: (typeof MODES)[number], onPick: () => void) => {
const selected = mode === option.id; const selected = mode === option.id;
@ -479,12 +510,35 @@ export function HomeworkInput({
{MODES.map((option) => {MODES.map((option) =>
modeCard(option, () => { modeCard(option, () => {
if (!canSubmit || !online) return; if (!canSubmit || !online) return;
if (option.id === "check") {
// Needs the solution first: reveal the field instead of
// starting the run.
onModeChange("check");
setCheckOpen(true);
return;
}
clearWizardDraft("homework"); clearWizardDraft("homework");
onModeChange(option.id); onModeChange(option.id);
onSubmit(option.id); onSubmit(option.id);
}), }),
)} )}
</div> </div>
{checkOpen ? (
<div className="space-y-3">
{solutionField}
<Button
size="lg"
className="touch-target w-full"
disabled={!canSubmit || !solution.trim()}
onClick={() => {
clearWizardDraft("homework");
onSubmit("check");
}}
>
{t("homework.mode.check")}
</Button>
</div>
) : null}
<p className="text-center text-[12px] text-[var(--text-subtle)]"> <p className="text-center text-[12px] text-[var(--text-subtle)]">
{online ? t("homework.phone.startHint") : t("offline.generate")} {online ? t("homework.phone.startHint") : t("offline.generate")}
</p> </p>
@ -704,16 +758,23 @@ export function HomeworkInput({
{/* ------------------------------------------------------- mode */} {/* ------------------------------------------------------- mode */}
<section className="space-y-3"> <section className="space-y-3">
<h2 className="text-[15px] font-semibold tracking-[-0.01em] text-[var(--text)]">{t("homework.mode.title")}</h2> <h2 className="text-[15px] font-semibold tracking-[-0.01em] text-[var(--text)]">{t("homework.mode.title")}</h2>
<div className="grid gap-3 sm:grid-cols-2"> <div className="grid gap-3 sm:grid-cols-3">
{MODES.map((option) => modeCard(option, () => onModeChange(option.id)))} {MODES.map((option) => modeCard(option, () => onModeChange(option.id)))}
</div> </div>
{mode === "check" ? solutionField : null}
</section> </section>
{/* -------------------------------------------------------- CTA */} {/* -------------------------------------------------------- CTA */}
<div className="space-y-2"> <div className="space-y-2">
<Button size="lg" className="touch-target w-full" onClick={() => onSubmit()} disabled={!canSubmit} loading={intake.busy}> <Button
size="lg"
className="touch-target w-full"
onClick={() => onSubmit()}
disabled={!canSubmit || (mode === "check" && !solution.trim())}
loading={intake.busy}
>
<SparklesIcon className="size-5" /> <SparklesIcon className="size-5" />
{mode === "make" ? t("homework.start.make") : t("homework.start.guide")} {mode === "make" ? t("homework.start.make") : mode === "guide" ? t("homework.start.guide") : t("homework.mode.check")}
</Button> </Button>
<p className="text-center text-[12px] text-[var(--text-subtle)]"> <p className="text-center text-[12px] text-[var(--text-subtle)]">
{canSubmit ? t("homework.source.ready") : t("homework.source.need")} {canSubmit ? t("homework.source.ready") : t("homework.source.need")}

View file

@ -1,25 +1,31 @@
"use client"; "use client";
import { useState } from "react";
import { Button } from "@/components/ui"; import { Button } from "@/components/ui";
import { useI18n } from "@/lib/i18n"; import { useI18n } from "@/lib/i18n";
import type { HomeworkMakeRecord } from "@/lib/schemas"; import type { HomeworkMakeRecord } from "@/lib/schemas";
import { shareHomework } from "@/lib/share"; import { shareHomework } from "@/lib/share";
import { CopyButton } from "./CopyButton"; import { CopyButton } from "./CopyButton";
import { FollowUpBar } from "./FollowUpBar";
/** Mode 1 result: the finished homework, nothing else. */ /** Mode 1 result: the finished homework, nothing else. */
export function HomeworkResult({ export function HomeworkResult({
record, record,
onAgain, onAgain,
onNew, onNew,
onToDeck,
againLoading, againLoading,
}: { }: {
record: HomeworkMakeRecord; record: HomeworkMakeRecord;
onAgain: () => void; onAgain: () => void;
onNew: () => void; onNew: () => void;
onToDeck: () => void;
againLoading: boolean; againLoading: boolean;
}) { }) {
const { t } = useI18n(); const { t } = useI18n();
const [answer, setAnswer] = useState(record.answer);
return ( return (
<div className="space-y-5"> <div className="space-y-5">
@ -32,24 +38,33 @@ export function HomeworkResult({
</h1> </h1>
</header> </header>
<article className="card p-4 sm:p-5"> <article className="card space-y-3 p-4 sm:p-5">
<p className="whitespace-pre-wrap break-words text-[15px] leading-relaxed text-[var(--text)]"> <p className="whitespace-pre-wrap break-words text-[15px] leading-relaxed text-[var(--text)]">{answer}</p>
{record.answer} <FollowUpBar
</p> text={answer}
context={record.sourceText}
onReplace={setAnswer}
onRestore={() => setAnswer(record.answer)}
canRestore={answer !== record.answer}
/>
</article> </article>
<div className="flex flex-wrap items-start gap-2"> <div className="flex flex-wrap items-start gap-2">
<CopyButton text={record.answer} /> <CopyButton text={answer} />
<Button variant="secondary" className="touch-target" onClick={() => void shareHomework(record)}> <Button variant="secondary" className="touch-target" onClick={() => void shareHomework({ ...record, answer })}>
{t("deck.share")} {t("deck.share")}
</Button> </Button>
<Button variant="secondary" className="touch-target" onClick={onAgain} loading={againLoading}> <Button variant="secondary" className="touch-target" onClick={onAgain} loading={againLoading}>
{t("homework.result.again")} {t("homework.followup.redo")}
</Button>
<Button variant="secondary" className="touch-target" onClick={onToDeck}>
{t("homework.toDeck")}
</Button> </Button>
<Button variant="ghost" className="touch-target" onClick={onNew}> <Button variant="ghost" className="touch-target" onClick={onNew}>
{t("homework.result.new")} {t("homework.result.new")}
</Button> </Button>
</div> </div>
<p className="text-[11.5px] text-[var(--text-subtle)]">{t("homework.toDeckNote")}</p>
</div> </div>
); );
} }

View file

@ -1,10 +1,13 @@
"use client"; "use client";
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"; import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
import { useRouter } from "next/navigation";
import { documentsToText } from "@/lib/documents"; import { documentsToText } from "@/lib/documents";
import { ApiError, generateHomework, type HomeworkProgress } from "@/lib/client-api"; import { ApiError, generateHomework, type HomeworkProgress } from "@/lib/client-api";
import { setDeckSeed } from "@/lib/deck-seed";
import { import {
assembleSolution,
deleteHomework, deleteHomework,
getHomework, getHomework,
homeworkStore, homeworkStore,
@ -18,6 +21,7 @@ import type { HomeworkMode, HomeworkRecord } from "@/lib/schemas";
import { useNotesIntake } from "@/lib/use-notes-intake"; import { useNotesIntake } from "@/lib/use-notes-intake";
import { clearWizardDraft } from "@/lib/wizard-draft"; import { clearWizardDraft } from "@/lib/wizard-draft";
import { HomeworkCheckResult } from "./HomeworkCheckResult";
import { HomeworkGuide } from "./HomeworkGuide"; import { HomeworkGuide } from "./HomeworkGuide";
import { HomeworkInput } from "./HomeworkInput"; import { HomeworkInput } from "./HomeworkInput";
import { HomeworkLoading } from "./HomeworkLoading"; import { HomeworkLoading } from "./HomeworkLoading";
@ -32,6 +36,7 @@ type View = { kind: "start" } | { kind: "loading" } | { kind: "record"; record:
/** The /homework screen: give it an assignment, pick a mode, get a result. */ /** The /homework screen: give it an assignment, pick a mode, get a result. */
export function HomeworkScreen({ initialRecordId }: { initialRecordId?: string }) { export function HomeworkScreen({ initialRecordId }: { initialRecordId?: string }) {
const { t } = useI18n(); const { t } = useI18n();
const router = useRouter();
const records = useSyncExternalStore( const records = useSyncExternalStore(
homeworkStore.subscribe, homeworkStore.subscribe,
homeworkStore.getSnapshot, homeworkStore.getSnapshot,
@ -45,6 +50,8 @@ export function HomeworkScreen({ initialRecordId }: { initialRecordId?: string }
const [mode, setMode] = useState<HomeworkMode>("make"); const [mode, setMode] = useState<HomeworkMode>("make");
const [text, setText] = useState(""); const [text, setText] = useState("");
/** Mode "check": the student's own attempt at the assignment. */
const [solution, setSolution] = useState("");
const [view, setView] = useState<View>({ kind: "start" }); const [view, setView] = useState<View>({ kind: "start" });
const [progress, setProgress] = useState<HomeworkProgress>({ stage: "vision", message: "" }); const [progress, setProgress] = useState<HomeworkProgress>({ stage: "vision", message: "" });
const [error, setError] = useState<{ message: string; kind?: string } | null>(null); const [error, setError] = useState<{ message: string; kind?: string } | null>(null);
@ -68,11 +75,16 @@ export function HomeworkScreen({ initialRecordId }: { initialRecordId?: string }
...intake.images.map((image) => image.dataUrl), ...intake.images.map((image) => image.dataUrl),
...documentImages.map((image) => image.dataUrl), ...documentImages.map((image) => image.dataUrl),
].slice(0, MAX_IMAGES_TO_SEND); ].slice(0, MAX_IMAGES_TO_SEND);
return { mode: modeValue, images, text: mergedText }; return {
mode: modeValue,
images,
text: mergedText,
solution: modeValue === "check" ? solution.trim() : undefined,
};
}; };
const run = useCallback( const run = useCallback(
async (input: { mode: HomeworkMode; images: string[]; text: string; recordId?: string }) => { async (input: { mode: HomeworkMode; images: string[]; text: string; solution?: string; recordId?: string }) => {
setError(null); setError(null);
setView({ kind: "loading" }); setView({ kind: "loading" });
const vision = input.images.length > 0; const vision = input.images.length > 0;
@ -82,7 +94,9 @@ export function HomeworkScreen({ initialRecordId }: { initialRecordId?: string }
? t("homework.loading.reading") ? t("homework.loading.reading")
: input.mode === "make" : input.mode === "make"
? t("homework.loading.writing") ? t("homework.loading.writing")
: t("homework.loading.planning"), : input.mode === "check"
? t("homework.loading.checking")
: t("homework.loading.planning"),
}); });
const controller = new AbortController(); const controller = new AbortController();
@ -94,12 +108,13 @@ export function HomeworkScreen({ initialRecordId }: { initialRecordId?: string }
mode: input.mode, mode: input.mode,
images: input.images.length > 0 ? input.images : undefined, images: input.images.length > 0 ? input.images : undefined,
text: input.text || undefined, text: input.text || undefined,
solution: input.solution,
}, },
{ onProgress: setProgress, signal: controller.signal }, { onProgress: setProgress, signal: controller.signal },
); );
// The server hands back the assignment as text (the transcription when // The server hands back the assignment as text (the transcription when
// photos were used), so “Make it again” works without the files. // photos were used), so “Redo” works without the files.
const sourceText = homework.sourceText ?? input.text; const sourceText = homework.sourceText ?? input.text;
const result: NewHomeworkResult = const result: NewHomeworkResult =
homework.mode === "make" homework.mode === "make"
@ -110,13 +125,25 @@ export function HomeworkScreen({ initialRecordId }: { initialRecordId?: string }
sourceText, sourceText,
answer: homework.answer, answer: homework.answer,
} }
: { : homework.mode === "check"
mode: "guide", ? {
title: homework.title, mode: "check",
language: homework.language, title: homework.title,
sourceText, language: homework.language,
steps: homework.steps, sourceText,
}; verdict: homework.verdict,
correct: homework.correct,
wrong: homework.wrong,
corrected: homework.corrected,
attempt: input.solution,
}
: {
mode: "guide",
title: homework.title,
language: homework.language,
sourceText,
steps: homework.steps,
};
const record = input.recordId ? updateHomework(input.recordId, result) : saveHomework(result); const record = input.recordId ? updateHomework(input.recordId, result) : saveHomework(result);
if (!record) throw new ApiError(t("homework.error.body"), "bad_response"); if (!record) throw new ApiError(t("homework.error.body"), "bad_response");
@ -141,19 +168,43 @@ export function HomeworkScreen({ initialRecordId }: { initialRecordId?: string }
/** `modeOverride` lets the phone flow start straight from a tapped mode card. */ /** `modeOverride` lets the phone flow start straight from a tapped mode card. */
const submit = (modeOverride?: HomeworkMode) => { const submit = (modeOverride?: HomeworkMode) => {
const payload = buildPayload(modeOverride ?? mode); const target = modeOverride ?? mode;
if (target === "check" && !solution.trim()) {
setError({ message: t("homework.solution.required") });
return;
}
const payload = buildPayload(target);
void run(payload); void run(payload);
}; };
/** Re-run from the stored assignment text (files were never stored). */ /** Re-run from the stored assignment text (files were never stored). */
const again = (record: HomeworkRecord) => { const again = (record: HomeworkRecord) => {
setAgainLoading(true); setAgainLoading(true);
void run({ mode: record.mode, images: [], text: record.sourceText, recordId: record.id }); void run({
mode: record.mode,
images: [],
text: record.sourceText,
solution: record.mode === "check" ? record.attempt : undefined,
recordId: record.id,
});
};
/** Hand the finished content to the New deck flow with the options pre-filled. */
const turnIntoDeck = (record: HomeworkRecord) => {
const content =
record.mode === "make"
? record.answer
: record.mode === "check"
? record.corrected
: assembleSolution(record);
setDeckSeed({ text: content, difficulty: "medium", count: 10 });
router.push("/create");
}; };
const newAssignment = () => { const newAssignment = () => {
intake.clear(); intake.clear();
setText(""); setText("");
setSolution("");
setError(null); setError(null);
clearWizardDraft("homework"); clearWizardDraft("homework");
setView({ kind: "start" }); setView({ kind: "start" });
@ -164,19 +215,35 @@ export function HomeworkScreen({ initialRecordId }: { initialRecordId?: string }
} }
if (view.kind === "record") { if (view.kind === "record") {
return view.record.mode === "make" ? ( if (view.record.mode === "make") {
<HomeworkResult return (
record={view.record} <HomeworkResult
onAgain={() => again(view.record)} record={view.record}
onNew={newAssignment} onAgain={() => again(view.record)}
againLoading={againLoading} onNew={newAssignment}
/> onToDeck={() => turnIntoDeck(view.record)}
) : ( againLoading={againLoading}
/>
);
}
if (view.record.mode === "check") {
return (
<HomeworkCheckResult
record={view.record}
onAgain={() => again(view.record)}
onNew={newAssignment}
onToDeck={() => turnIntoDeck(view.record)}
againLoading={againLoading}
/>
);
}
return (
<HomeworkGuide <HomeworkGuide
record={view.record} record={view.record}
onClose={() => setView({ kind: "start" })} onClose={() => setView({ kind: "start" })}
onAgain={() => again(view.record)} onAgain={() => again(view.record)}
onNew={newAssignment} onNew={newAssignment}
onToDeck={() => turnIntoDeck(view.record)}
againLoading={againLoading} againLoading={againLoading}
/> />
); );
@ -188,6 +255,8 @@ export function HomeworkScreen({ initialRecordId }: { initialRecordId?: string }
onModeChange={setMode} onModeChange={setMode}
text={text} text={text}
onTextChange={setText} onTextChange={setText}
solution={solution}
onSolutionChange={setSolution}
intake={intake} intake={intake}
onSubmit={submit} onSubmit={submit}
error={error} error={error}

View file

@ -9,13 +9,17 @@ import { needsLanguageRepair } from "./language";
import { import {
buildGenerationUserPrompt, buildGenerationUserPrompt,
buildGradingUserPrompt, buildGradingUserPrompt,
buildHomeworkCheckUserPrompt,
buildHomeworkGuideUserPrompt, buildHomeworkGuideUserPrompt,
buildHomeworkMakeUserPrompt, buildHomeworkMakeUserPrompt,
buildRefineUserPrompt,
buildRepairPrompt, buildRepairPrompt,
GENERATION_SYSTEM_PROMPT, GENERATION_SYSTEM_PROMPT,
GRADING_SYSTEM_PROMPT, GRADING_SYSTEM_PROMPT,
HOMEWORK_CHECK_SYSTEM_PROMPT,
HOMEWORK_GUIDE_SYSTEM_PROMPT, HOMEWORK_GUIDE_SYSTEM_PROMPT,
HOMEWORK_MAKE_SYSTEM_PROMPT, HOMEWORK_MAKE_SYSTEM_PROMPT,
REFINE_SYSTEM_PROMPT,
VISION_SYSTEM_PROMPT, VISION_SYSTEM_PROMPT,
VISION_USER_INSTRUCTION, VISION_USER_INSTRUCTION,
} from "./prompts"; } from "./prompts";
@ -23,12 +27,14 @@ import {
extractedNotesSchema, extractedNotesSchema,
generatedActivitySchema, generatedActivitySchema,
gradeResultSchema, gradeResultSchema,
homeworkCheckSchema,
homeworkMakeSchema, homeworkMakeSchema,
homeworkStepSchema, homeworkStepSchema,
type Difficulty, type Difficulty,
type ExtractedNotes, type ExtractedNotes,
type GeneratedActivity, type GeneratedActivity,
type GradeResult, type GradeResult,
type HomeworkCheck,
type HomeworkGuide, type HomeworkGuide,
type HomeworkMake, type HomeworkMake,
} from "./schemas"; } from "./schemas";
@ -502,6 +508,74 @@ export async function buildHomeworkGuide(input: {
return value; return value;
} }
/* ------------------------------------------------------------------ */
/* Check my work */
/* ------------------------------------------------------------------ */
const HOMEWORK_CHECK_SHAPE_HINT = `{"title":"...","language":"English name of the language used","verdict":"1-2 sentences","correct":["what is right"],"wrong":[{"issue":"...","why":"...","fix":"..."}],"corrected":"the student's solution with every mistake fixed"}`;
/** Mode 3: mark the student's own attempt and hand back a corrected version. */
export async function checkHomework(input: {
model: string;
auth: OllamaAuth;
assignment: string;
solution: string;
language?: string;
signal?: AbortSignal;
}): Promise<HomeworkCheck> {
const { value } = await runJsonStep<HomeworkCheck>({
model: input.model,
auth: input.auth,
system: HOMEWORK_CHECK_SYSTEM_PROMPT,
user: buildHomeworkCheckUserPrompt({
assignment: input.assignment,
solution: input.solution,
language: input.language,
}),
validate: (parsed) => {
const result = homeworkCheckSchema.safeParse(parsed);
if (!result.success) return { ok: false, error: summariseZodError(result.error) };
if (!result.data.corrected.trim()) return { ok: false, error: "The corrected field is empty." };
return { ok: true, value: result.data };
},
shapeHint: HOMEWORK_CHECK_SHAPE_HINT,
label: "Checking the attempt",
signal: input.signal,
temperature: 0.2,
});
return value;
}
/** Follow-ups: rewrite one finished piece (shorter / more detail / explain). */
export async function refineHomeworkText(input: {
model: string;
auth: OllamaAuth;
instruction: "shorter" | "more" | "explain";
text: string;
context?: string;
signal?: AbortSignal;
}): Promise<string> {
const result = await ollamaChat({
model: input.model,
auth: input.auth,
json: false,
signal: input.signal,
temperature: 0.4,
messages: [
{ role: "system", content: REFINE_SYSTEM_PROMPT },
{
role: "user",
content: buildRefineUserPrompt({
instruction: input.instruction,
text: input.text,
context: input.context,
}),
},
],
});
return result.content.trim();
}
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
/* Helpers */ /* Helpers */
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */

View file

@ -154,6 +154,8 @@ export type HomeworkInput = {
mode: HomeworkMode; mode: HomeworkMode;
images?: string[]; images?: string[];
text?: string; text?: string;
/** Mode "check": the student's own attempt at the assignment. */
solution?: string;
language?: string; language?: string;
}; };
@ -164,7 +166,17 @@ export type HomeworkProgress = {
export type StreamedHomework = export type StreamedHomework =
| { mode: "make"; title: string; language?: string; answer: string; sourceText?: string } | { mode: "make"; title: string; language?: string; answer: string; sourceText?: string }
| { mode: "guide"; title: string; language?: string; steps: HomeworkStep[]; sourceText?: string }; | { mode: "guide"; title: string; language?: string; steps: HomeworkStep[]; sourceText?: string }
| {
mode: "check";
title: string;
language?: string;
verdict: string;
correct: string[];
wrong: Array<{ issue: string; why: string; fix: string }>;
corrected: string;
sourceText?: string;
};
type HomeworkStreamEvent = type HomeworkStreamEvent =
| { type: "stage"; stage: "vision" | "generation" | "done"; message: string } | { type: "stage"; stage: "vision" | "generation" | "done"; message: string }
@ -224,6 +236,10 @@ export async function generateHomework(
const result = homework as StreamedHomework; const result = homework as StreamedHomework;
if (result.mode === "make") { if (result.mode === "make") {
if (!result.answer?.trim()) throw new ApiError(t("api.noHomework"), "bad_response"); if (!result.answer?.trim()) throw new ApiError(t("api.noHomework"), "bad_response");
} else if (result.mode === "check") {
if (!result.corrected?.trim() || !result.verdict?.trim()) {
throw new ApiError(t("api.noHomework"), "bad_response");
}
} else { } else {
const steps = result.steps?.filter((step) => step && typeof step.title === "string"); const steps = result.steps?.filter((step) => step && typeof step.title === "string");
if (!steps || steps.length < 2) throw new ApiError(t("api.noHomework"), "bad_response"); if (!steps || steps.length < 2) throw new ApiError(t("api.noHomework"), "bad_response");
@ -242,6 +258,35 @@ export async function generateHomework(
return { homework: result, meta }; return { homework: result, meta };
} }
/** Follow-up on a finished result: shorter, more detail, or explain again. */
export async function refineHomework(input: {
instruction: "shorter" | "more" | "explain";
text: string;
context?: string;
signal?: AbortSignal;
}): Promise<string> {
let response: Response;
try {
response = await fetch("/api/homework/refine", {
method: "POST",
headers: { "Content-Type": "application/json", ...authHeaders(), ...localeHeader() },
body: JSON.stringify({ instruction: input.instruction, text: input.text, context: input.context }),
signal: input.signal,
});
} catch (error) {
if (error instanceof Error && error.name === "AbortError") {
throw new ApiError(t("api.cancelled"), "aborted");
}
throw new ApiError(t("api.unreachable"), "network");
}
const body = (await response.json().catch(() => null)) as { text?: string; error?: string; kind?: string } | null;
if (!response.ok) {
throw new ApiError(body?.error ?? t("api.httpError", { status: response.status }), body?.kind);
}
if (!body?.text?.trim()) throw new ApiError(t("api.noHomework"), "bad_response");
return body.text.trim();
}
export async function gradeAnswer(input: { export async function gradeAnswer(input: {
question: string; question: string;
referenceAnswer: string; referenceAnswer: string;

View file

@ -30,6 +30,7 @@ export type ServerConfig = {
grade: number; grade: number;
homework: number; homework: number;
health: number; health: number;
refine: number;
sync: number; sync: number;
push: number; push: number;
}; };
@ -118,6 +119,8 @@ export function getServerConfig(): ServerConfig {
// Homework is one to two model calls, like a deck. // Homework is one to two model calls, like a deck.
homework: positiveInt(process.env.RATE_LIMIT_HOMEWORK, 12), homework: positiveInt(process.env.RATE_LIMIT_HOMEWORK, 12),
health: positiveInt(process.env.RATE_LIMIT_HEALTH, 90), health: positiveInt(process.env.RATE_LIMIT_HEALTH, 90),
// Homework follow-ups (short rewrites) can be used more freely.
refine: positiveInt(process.env.RATE_LIMIT_REFINE, 30),
// Sync is cheap (no model calls) but shouldn't be hammered. // Sync is cheap (no model calls) but shouldn't be hammered.
sync: positiveInt(process.env.RATE_LIMIT_SYNC, 60), sync: positiveInt(process.env.RATE_LIMIT_SYNC, 60),
// Push subscription changes and test sends. // Push subscription changes and test sends.

36
lib/deck-seed.ts Normal file
View file

@ -0,0 +1,36 @@
"use client";
/**
* Hand-off from a homework result to the New deck flow: the content travels in
* sessionStorage and the wizard picks it up on mount, with the options step
* pre-filled. One-shot, like the share-target payload.
*/
export type DeckSeed = {
text: string;
difficulty?: "easy" | "medium" | "hard";
count?: number;
};
const KEY = "freezmo.deck.seed.v1";
export function setDeckSeed(seed: DeckSeed): void {
try {
window.sessionStorage.setItem(KEY, JSON.stringify(seed));
} catch {
/* private mode */
}
}
export function takeDeckSeed(): DeckSeed | null {
try {
const raw = window.sessionStorage.getItem(KEY);
if (!raw) return null;
window.sessionStorage.removeItem(KEY);
const parsed = JSON.parse(raw) as DeckSeed;
if (typeof parsed.text !== "string" || !parsed.text.trim()) return null;
return parsed;
} catch {
return null;
}
}

View file

@ -117,7 +117,18 @@ export function getHomework(id: string): HomeworkRecord | undefined {
/** Everything needed to store a result (the route's payload plus the source). */ /** Everything needed to store a result (the route's payload plus the source). */
export type NewHomeworkResult = export type NewHomeworkResult =
| { mode: "make"; title: string; language?: string; sourceText: string; answer: string } | { mode: "make"; title: string; language?: string; sourceText: string; answer: string }
| { mode: "guide"; title: string; language?: string; sourceText: string; steps: HomeworkStep[] }; | { mode: "guide"; title: string; language?: string; sourceText: string; steps: HomeworkStep[] }
| {
mode: "check";
title: string;
language?: string;
sourceText: string;
verdict: string;
correct: string[];
wrong: Array<{ issue: string; why: string; fix: string }>;
corrected: string;
attempt?: string;
};
function buildRecord( function buildRecord(
base: { id: string; createdAt: number; updatedAt: number }, base: { id: string; createdAt: number; updatedAt: number },
@ -130,6 +141,17 @@ function buildRecord(
sourceText: input.sourceText.slice(0, MAX_STORED_SOURCE), sourceText: input.sourceText.slice(0, MAX_STORED_SOURCE),
}; };
if (input.mode === "make") return { ...shared, mode: "make", answer: input.answer }; if (input.mode === "make") return { ...shared, mode: "make", answer: input.answer };
if (input.mode === "check") {
return {
...shared,
mode: "check",
verdict: input.verdict,
correct: input.correct,
wrong: input.wrong,
corrected: input.corrected,
attempt: input.attempt?.slice(0, 20_000),
};
}
return { ...shared, mode: "guide", steps: input.steps }; return { ...shared, mode: "guide", steps: input.steps };
} }
@ -167,7 +189,9 @@ export function assembleSolution(record: HomeworkGuideRecord): string {
/** Label for a row in the history list. */ /** Label for a row in the history list. */
export function homeworkModeLabelKey(mode: HomeworkMode): string { export function homeworkModeLabelKey(mode: HomeworkMode): string {
return mode === "make" ? "homework.history.made" : "homework.history.guide"; if (mode === "make") return "homework.history.made";
if (mode === "check") return "homework.history.checked";
return "homework.history.guide";
} }
export type { HomeworkGuideRecord }; export type { HomeworkGuideRecord };

View file

@ -856,6 +856,33 @@ const en: Record<string, string> = {
"settings.reminderDenied": "Notifications are blocked in your browser settings.", "settings.reminderDenied": "Notifications are blocked in your browser settings.",
"settings.reminderError": "Could not set up reminders.", "settings.reminderError": "Could not set up reminders.",
"library.learningToday": "Today: {n} / {goal}", "library.learningToday": "Today: {n} / {goal}",
/* homework: check my work + follow-ups ----------------------------- */
"homework.mode.check": "Check my work",
"homework.mode.checkDesc": "Mark your own attempt: what's right, what's wrong, and the corrected version.",
"homework.solution.label": "Your solution",
"homework.solution.placeholder": "Paste your own attempt here — the assignment is above.",
"homework.solution.note": "Type or paste your solution as text (photos of handwritten work aren't supported yet).",
"homework.solution.required": "Add your solution so there is something to check.",
"homework.check.title": "Checked",
"homework.check.verdict": "Verdict",
"homework.check.correct": "What's right",
"homework.check.wrong": "What needs fixing",
"homework.check.fix": "Fix",
"homework.check.corrected": "Corrected version",
"homework.history.checked": "Checked",
"homework.loading.checking": "Checking your attempt…",
"homework.followup.shorter": "Shorter",
"homework.followup.more": "More detail",
"homework.followup.explain": "Explain differently",
"homework.followup.redo": "Redo",
"homework.followup.step": "Explain step {n} differently",
"homework.followup.working": "Rewriting…",
"homework.followup.failed": "That didn't work. Try again.",
"homework.followup.restore": "Original",
"homework.toDeck": "Turn into a study deck",
"homework.toDeckNote": "Opens New deck with this content and the options pre-filled.",
"server.needSolution": "Add your own solution so it can be checked.",
"doc.readingPage": "Reading page {n} of {total}…", "doc.readingPage": "Reading page {n} of {total}…",
"doc.readingWord": "Reading the Word document…", "doc.readingWord": "Reading the Word document…",
"doc.readingSlides": "Reading the slides…", "doc.readingSlides": "Reading the slides…",
@ -1658,6 +1685,33 @@ const cs: Record<string, string> = {
"settings.reminderDenied": "Oznámení jsou v nastavení prohlížeče zablokovaná.", "settings.reminderDenied": "Oznámení jsou v nastavení prohlížeče zablokovaná.",
"settings.reminderError": "Připomínky se nepodařilo nastavit.", "settings.reminderError": "Připomínky se nepodařilo nastavit.",
"library.learningToday": "Dnes: {n} / {goal}", "library.learningToday": "Dnes: {n} / {goal}",
/* homework: check my work + follow-ups ----------------------------- */
"homework.mode.check": "Zkontrolovat moje řešení",
"homework.mode.checkDesc": "Oznámkuj svůj postup: co je správně, co špatně a opravená verze.",
"homework.solution.label": "Tvoje řešení",
"homework.solution.placeholder": "Sem vlož svůj postup — zadání je nahoře.",
"homework.solution.note": "Řešení napiš nebo vlož jako text (fotky ručně psaného postupu zatím nejdou).",
"homework.solution.required": "Přidej svoje řešení, ať je co zkontrolovat.",
"homework.check.title": "Zkontrolováno",
"homework.check.verdict": "Hodnocení",
"homework.check.correct": "Co je správně",
"homework.check.wrong": "Co opravit",
"homework.check.fix": "Oprava",
"homework.check.corrected": "Opravená verze",
"homework.history.checked": "Zkontrolováno",
"homework.loading.checking": "Kontroluji tvůj postup…",
"homework.followup.shorter": "Kratší",
"homework.followup.more": "Více detailů",
"homework.followup.explain": "Vysvětlit jinak",
"homework.followup.redo": "Znovu",
"homework.followup.step": "Vysvětlit krok {n} jinak",
"homework.followup.working": "Přepisuji…",
"homework.followup.failed": "Nepovedlo se. Zkus to znovu.",
"homework.followup.restore": "Původní",
"homework.toDeck": "Převést na studijní sadu",
"homework.toDeckNote": "Otevře Novou sadu s tímto obsahem a předvyplněnými možnostmi.",
"server.needSolution": "Přidej svoje řešení, ať se dá zkontrolovat.",
"doc.readingPage": "Čtu stranu {n} z {total}…", "doc.readingPage": "Čtu stranu {n} z {total}…",
"doc.readingWord": "Čtu dokument Word…", "doc.readingWord": "Čtu dokument Word…",
"doc.readingSlides": "Čtu snímky…", "doc.readingSlides": "Čtu snímky…",

View file

@ -287,6 +287,86 @@ export function buildHomeworkGuideUserPrompt(input: { assignment: string; langua
].join("\n"); ].join("\n");
} }
export const HOMEWORK_CHECK_SYSTEM_PROMPT = `You are Freezmo's homework checker. The student gives you an assignment and their OWN attempt at solving it. You mark it like a fair, encouraging teacher: say what is right, what is wrong and why, and give the corrected version.
${HOMEWORK_BASE_RULES}
Checking rules:
- Judge ONLY against the assignment. An unasked-for extra (a date, a comment, an alternative method) is never an error — leave it alone.
- "verdict": 1-2 sentences, the overall picture (e.g. "Two of the three equations are right; the sign flips in the second one.").
- "correct": short bullet points for what is genuinely right, each naming the part of the assignment it refers to. Empty array when nothing is right.
- "wrong": one entry per actual mistake: "issue" names the mistake briefly, "why" explains what goes wrong, "fix" says what to do instead. Do not repeat the same mistake twice.
- "corrected": the student's solution with every mistake fixed, as the finished homework (plain text, numbered like the assignment). Keep everything the student got right as they wrote it.
- If the attempt is empty or does not address the assignment at all, say that plainly in the verdict, leave "wrong" empty and put the full solution in "corrected".
${LANGUAGE_RULES}
Required JSON shape:
{
"title": "short title of the assignment, in the assignment's language, max 10 words",
"language": "English name of the language you wrote in, e.g. Czech",
"verdict": "1-2 sentences",
"correct": ["what is right"],
"wrong": [{ "issue": "the mistake", "why": "why it is wrong", "fix": "what to do instead" }],
"corrected": "the student's solution with every mistake fixed"
}`;
export function buildHomeworkCheckUserPrompt(input: {
assignment: string;
solution: string;
language?: string;
}): string {
return [
"Check this attempt against the assignment.",
input.language
? `LANGUAGE: write everything in ${input.language}. Do not translate into English.`
: "LANGUAGE: detect the language of the assignment below and write everything in that same language.",
"",
"=== THE ASSIGNMENT (the only source of what has to be done) ===",
input.assignment.slice(0, 32_000),
"=== END OF ASSIGNMENT ===",
"",
"=== THE STUDENT'S ATTEMPT (this is what you are marking) ===",
input.solution.slice(0, 32_000),
"=== END OF ATTEMPT ===",
"",
"Respond with the JSON object only.",
].join("\n");
}
export const REFINE_SYSTEM_PROMPT = `You are Freezmo's homework assistant. You rewrite one piece of already-finished homework exactly as asked — shorter, more detailed, or a step explained differently.
Rules:
- Keep the language of the text you are given. Never translate.
- Keep every fact, number and answer correct and consistent with the text; never invent new content that changes the answer.
- Plain text only (short lines, numbered or dashed lists; no markdown symbols).
- Output the rewritten text only: no greeting, no commentary, no "here is", no mention of these instructions.`;
export function buildRefineUserPrompt(input: {
instruction: "shorter" | "more" | "explain";
text: string;
context?: string;
}): string {
const task =
input.instruction === "shorter"
? "Rewrite it SHORTER: keep every answer but cut the wording to the essentials."
: input.instruction === "more"
? "Rewrite it with MORE DETAIL: keep the same answers but explain the reasoning a little more, so the student understands why."
: "Explain this step DIFFERENTLY: a fresh, simpler explanation of the same step, with a small concrete example, so it clicks this time.";
return [
task,
input.context ? `CONTEXT (the assignment): ${input.context.slice(0, 2000)}` : "",
"",
"=== THE TEXT ===",
input.text.slice(0, 16_000),
"=== END OF TEXT ===",
"",
"Reply with the rewritten text only.",
]
.filter(Boolean)
.join("\n");
}
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
/* Retry helper */ /* Retry helper */
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */

View file

@ -139,9 +139,27 @@ export const gradeResultSchema = z.object({
/* Make my homework */ /* Make my homework */
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
export const homeworkModeSchema = z.enum(["make", "guide"]); export const homeworkModeSchema = z.enum(["make", "guide", "check"]);
export type HomeworkMode = z.infer<typeof homeworkModeSchema>; export type HomeworkMode = z.infer<typeof homeworkModeSchema>;
export const checkIssueSchema = z.object({
issue: z.string().min(1).max(600),
why: z.string().min(1).max(600),
fix: z.string().max(600).default(""),
});
export type CheckIssue = z.infer<typeof checkIssueSchema>;
/** Mode 3: feedback on the student's own solution. */
export const homeworkCheckSchema = z.object({
title: z.string().min(1).max(200),
language: z.string().max(60).optional(),
verdict: z.string().min(1).max(600),
correct: z.array(z.string().max(600)).max(12).default([]),
wrong: z.array(checkIssueSchema).max(12).default([]),
corrected: z.string().min(1).max(20_000),
});
export type HomeworkCheck = z.infer<typeof homeworkCheckSchema>;
/** Mode 1 — the finished homework, nothing else. */ /** Mode 1 — the finished homework, nothing else. */
export const homeworkMakeSchema = z.object({ export const homeworkMakeSchema = z.object({
title: z.string().min(1).max(200), title: z.string().min(1).max(200),
@ -219,10 +237,21 @@ export const homeworkRecordSchema = z.discriminatedUnion("mode", [
mode: z.literal("guide"), mode: z.literal("guide"),
steps: z.array(homeworkStepStoredSchema).min(2).max(10), steps: z.array(homeworkStepStoredSchema).min(2).max(10),
}), }),
z.object({
...homeworkBaseFields,
mode: z.literal("check"),
verdict: z.string().min(1).max(600),
correct: z.array(z.string().max(600)).max(12),
wrong: z.array(checkIssueSchema).max(12),
corrected: z.string().min(1).max(20_000),
/** The student's own attempt, so "Redo" can re-check the same work. */
attempt: z.string().max(20_000).optional(),
}),
]); ]);
export type HomeworkRecord = z.infer<typeof homeworkRecordSchema>; export type HomeworkRecord = z.infer<typeof homeworkRecordSchema>;
export type HomeworkMakeRecord = Extract<HomeworkRecord, { mode: "make" }>; export type HomeworkMakeRecord = Extract<HomeworkRecord, { mode: "make" }>;
export type HomeworkGuideRecord = Extract<HomeworkRecord, { mode: "guide" }>; export type HomeworkGuideRecord = Extract<HomeworkRecord, { mode: "guide" }>;
export type HomeworkCheckRecord = Extract<HomeworkRecord, { mode: "check" }>;
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
/* Client-side deck types (activity + id/xp, plus storage metadata) */ /* Client-side deck types (activity + id/xp, plus storage metadata) */

View file

@ -58,7 +58,9 @@ export async function shareHomework(record: HomeworkRecord): Promise<void> {
const text = const text =
record.mode === "make" record.mode === "make"
? record.answer ? record.answer
: record.steps.map((step, index) => `${index + 1}. ${step.title}`).join("\n"); : record.mode === "check"
? record.corrected
: record.steps.map((step, index) => `${index + 1}. ${step.title}`).join("\n");
try { try {
if (nav.share) { if (nav.share) {
await nav.share({ title: record.title, text }); await nav.share({ title: record.title, text });

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-v26"; const CACHE_VERSION = "freezmo-v27";
const SHELL_CACHE = `${CACHE_VERSION}-shell`; const SHELL_CACHE = `${CACHE_VERSION}-shell`;
const ASSET_CACHE = `${CACHE_VERSION}-assets`; const ASSET_CACHE = `${CACHE_VERSION}-assets`;