(null);
const [menuOpen, setMenuOpen] = useState(false);
const [renaming, setRenaming] = useState(false);
diff --git a/components/FolderBar.tsx b/components/FolderBar.tsx
index 7dbff44..2abd256 100644
--- a/components/FolderBar.tsx
+++ b/components/FolderBar.tsx
@@ -5,10 +5,11 @@ import { useRef, useState } from "react";
import { FolderIcon, PlusIcon } from "@/components/icons";
import { createFolder } from "@/lib/folders";
import { useI18n } from "@/lib/i18n";
+import { ALL_FOLDERS } from "@/lib/nav";
import type { Folder } from "@/lib/schemas";
import { cn } from "@/lib/utils";
-export const ALL_FOLDERS = "all";
+export { ALL_FOLDERS };
/**
* Folder switcher. Reads like a row of quiet pills (ChatGPT sidebar items):
diff --git a/components/HomeworkCard.tsx b/components/HomeworkCard.tsx
index c085181..9255897 100644
--- a/components/HomeworkCard.tsx
+++ b/components/HomeworkCard.tsx
@@ -4,8 +4,9 @@ import { motion } from "framer-motion";
import Link from "next/link";
import { useState } from "react";
-import { BoltIcon, ChevronDownIcon, PlayIcon, XIcon } from "@/components/icons";
+import { BoltIcon, CheckCircleIcon, ChevronDownIcon, PlayIcon, XIcon } from "@/components/icons";
import { Button } from "@/components/ui";
+import { homeworkModeLabelKey } from "@/lib/homework";
import { useI18n } from "@/lib/i18n";
import { springGentle } from "@/lib/motion";
import type { HomeworkRecord } from "@/lib/schemas";
@@ -70,8 +71,14 @@ export function HomeworkCard({
- {record.mode === "make" ? : }
- {t(record.mode === "make" ? "homework.history.made" : "homework.history.guide")}
+ {record.mode === "make" ? (
+
+ ) : record.mode === "check" ? (
+
+ ) : (
+
+ )}
+ {t(homeworkModeLabelKey(record.mode))}
·
{formatDate(record.updatedAt)}
diff --git a/components/LibraryScreen.tsx b/components/LibraryScreen.tsx
index ddcf17f..ce4203c 100644
--- a/components/LibraryScreen.tsx
+++ b/components/LibraryScreen.tsx
@@ -16,7 +16,8 @@ import { createFolder, folderStore, renameFolder } from "@/lib/folders";
import { deleteHomework, homeworkStore, sortHomework } from "@/lib/homework";
import { learningStore } from "@/lib/history";
import { useI18n } from "@/lib/i18n";
-import { computeStats, dueItems, mistakeItems } from "@/lib/learning";
+import { dueItems, mistakeItems, todayAnswerCount } from "@/lib/learning";
+import { isReported, reportStore } from "@/lib/question-reports";
import { settingsStore } from "@/lib/settings";
import { ALL_FOLDERS, folderSelection, useSelectedFolder } from "@/lib/nav";
import { springGentle, springSheet } from "@/lib/motion";
@@ -110,17 +111,30 @@ export function LibraryScreen({ initialTab = "decks" }: { initialTab?: "decks" |
() => settingsStore.getSnapshot().dailyGoal,
() => 20,
);
- /* Smart-review counts resolve against the live deck list: deleting a deck
- must drop its due schedules and its mistakes right away. `dueCount` is not
- used directly because it counts raw schedules, including decks that no
- longer exist. */
- const deckIds = useMemo(() => new Set(decks.map((deck) => deck.id)), [decks]);
- const due = useMemo(() => dueItems(srs).filter((item) => deckIds.has(item.deck.id)).length, [srs, deckIds]);
- const mistakes = useMemo(
- () => mistakeItems(answers).filter((item) => deckIds.has(item.deck.id)).length,
- [answers, deckIds],
+ const reports = useSyncExternalStore(
+ reportStore.subscribe,
+ reportStore.getSnapshot,
+ reportStore.getServerSnapshot,
);
- const todayCount = useMemo(() => computeStats(answers).todayCount, [answers]);
+ /* Smart-review counts resolve against the live deck list and reported set:
+ deleting a deck or reporting a question must drop its due schedules and
+ its mistakes right away, matching ReviewClient. */
+ const deckIds = useMemo(() => new Set(decks.map((deck) => deck.id)), [decks]);
+ const due = useMemo(
+ () => {
+ void reports;
+ return dueItems(srs).filter((item) => deckIds.has(item.deck.id) && !isReported(item.deck.id, item.activity.id)).length;
+ },
+ [srs, deckIds, reports],
+ );
+ const mistakes = useMemo(
+ () => {
+ void reports;
+ return mistakeItems(answers).filter((item) => deckIds.has(item.deck.id) && !isReported(item.deck.id, item.activity.id)).length;
+ },
+ [answers, deckIds, reports],
+ );
+ const todayCount = useMemo(() => todayAnswerCount(answers), [answers]);
const importRef = useRef(null);
const sorted = useMemo(() => sortDecks(decks), [decks]);
@@ -512,6 +526,7 @@ export function LibraryScreen({ initialTab = "decks" }: { initialTab?: "decks" |
{
upsertDeck({ ...deck, folderId });
diff --git a/components/RouteTransition.tsx b/components/RouteTransition.tsx
index b8a3209..1f2b5c2 100644
--- a/components/RouteTransition.tsx
+++ b/components/RouteTransition.tsx
@@ -3,6 +3,7 @@
import { motion } from "framer-motion";
import { useAppReducedMotion as useReducedMotion } from "@/lib/motion-pref";
import { usePathname } from "next/navigation";
+import { useEffect, useState } from "react";
import { screenVariants, tabVariants } from "@/lib/motion";
@@ -31,12 +32,18 @@ export function RouteTransition({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
const reduceMotion = useReducedMotion();
- const previous = lastPath;
- lastPath = pathname;
+ const [{ isTabSwitch, direction }] = useState(() => {
+ const previous = typeof window === "undefined" ? null : lastPath;
+ return {
+ isTabSwitch: previous !== null && depth(previous) === depth(pathname) && previous !== pathname,
+ direction: (previous === null || depth(pathname) >= depth(previous) ? 1 : -1) as 1 | -1,
+ };
+ });
+
+ useEffect(() => {
+ lastPath = pathname;
+ }, [pathname]);
- const isTabSwitch = previous !== null && depth(previous) === depth(pathname) && previous !== pathname;
- const direction: 1 | -1 =
- previous === null || depth(pathname) >= depth(previous) ? 1 : -1;
const variants = isTabSwitch ? tabVariants(!!reduceMotion) : screenVariants(direction, !!reduceMotion);
return (
diff --git a/components/SessionFeed.tsx b/components/SessionFeed.tsx
index 8e8d5c7..8f5dd1f 100644
--- a/components/SessionFeed.tsx
+++ b/components/SessionFeed.tsx
@@ -138,7 +138,7 @@ export function SessionFeed({
);
const finished = index >= total;
- const correct = items.filter((item) => results[item.activity.id]?.correct).length;
+ const correct = items.filter((item) => !skipped.includes(item.activity.id) && results[item.activity.id]?.correct).length;
/** 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;
diff --git a/components/SettingsDialog.tsx b/components/SettingsDialog.tsx
index 9ca04d0..ebdf60b 100644
--- a/components/SettingsDialog.tsx
+++ b/components/SettingsDialog.tsx
@@ -19,6 +19,7 @@ import { ProvidersSection } from "@/components/ProvidersSection";
import { SyncSection } from "@/components/SyncSection";
import { clearApiKey, saveSettings, settingsStore } from "@/lib/settings";
import { useBackClose } from "@/lib/use-back-close";
+import { useIsPhone } from "@/lib/use-phone";
import { cn } from "@/lib/utils";
import { openInstallSheet, useInstallAvailable } from "@/lib/install";
import { useHealth } from "@/lib/use-health";
@@ -84,21 +85,13 @@ export function SettingsDialog({
const [saved, setSaved] = useState(false);
const [confirmRemove, setConfirmRemove] = useState(false);
const [canScroll, setCanScroll] = useState(false);
- const [phone, setPhone] = useState(false);
+ // Swipe-to-dismiss is a phone gesture; the desktop dialog is unchanged.
+ const phone = useIsPhone();
const dragControls = useDragControls();
const reduceMotion = useReducedMotion();
const scrollRef = useRef(null);
const contentRef = useRef(null);
- // Swipe-to-dismiss is a phone gesture; the desktop dialog is unchanged.
- useEffect(() => {
- const query = window.matchMedia("(max-width: 639px)");
- const sync = () => setPhone(query.matches);
- sync();
- query.addEventListener("change", sync);
- return () => query.removeEventListener("change", sync);
- }, []);
-
useEffect(() => {
if (!open) return;
setKeyDraft(apiKey);
diff --git a/components/StudyFeed.tsx b/components/StudyFeed.tsx
index 5493dee..9ca8b2e 100644
--- a/components/StudyFeed.tsx
+++ b/components/StudyFeed.tsx
@@ -113,19 +113,25 @@ export function StudyFeed({ deck }: { deck: Deck }) {
if (!retryMode && !recordedRef.current) {
recordedRef.current = true;
const answered = Object.values(results);
- const correct = answered.filter((entry) => entry.correct).length;
+ const correct = queue.filter((activity) => !skipped.includes(activity.id) && results[activity.id]?.correct).length;
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.
if (answered.length > 0) {
- recordAttempt(currentDeck.id, { scorePercent: score, correct, total });
+ recordAttempt(currentDeck.id, { scorePercent: score, correct, total: attempted });
+ setCurrentDeck((prev) => ({
+ ...prev,
+ attempts: prev.attempts + 1,
+ bestScore: Math.max(prev.bestScore ?? 0, Math.round(score)),
+ updatedAt: Date.now(),
+ }));
haptic("celebrate");
void celebrate("big");
}
}
- }, [finished, results, total, skipped.length, currentDeck.id, retryMode]);
+ }, [finished, queue, results, total, skipped, currentDeck.id, retryMode]);
const handleAnswered = useCallback(
(activity: Activity, correct: boolean, meta?: { userAnswer?: string }) => {
@@ -166,8 +172,8 @@ export function StudyFeed({ deck }: { deck: Deck }) {
const attemptedTotal = Math.max(0, total - skipped.length);
const correctCount = useMemo(
- () => queue.filter((activity) => results[activity.id]?.correct).length,
- [queue, results],
+ () => queue.filter((activity) => !skipped.includes(activity.id) && results[activity.id]?.correct).length,
+ [queue, results, skipped],
);
const restart = useCallback(
diff --git a/components/UploadScreen.tsx b/components/UploadScreen.tsx
index 5d5ac66..59b4730 100644
--- a/components/UploadScreen.tsx
+++ b/components/UploadScreen.tsx
@@ -11,6 +11,7 @@ import {
DOCUMENT_ACCEPT_ATTR,
KIND_LABELS,
describeDocumentError,
+ describeExtraction,
documentKindOf,
documentsToText,
extractDocument,
@@ -71,20 +72,6 @@ const MAX_IMAGES_TO_SEND = 12;
/** Cap for the merged text sent to the API. */
const MAX_TEXT_TO_SEND = 100_000;
-function describeExtraction(
- document: ExtractedDocument,
- tr: (key: string, vars?: Record) => string,
- pl: (n: number, base: string, vars?: Record) => string,
-): string {
- const parts = [
- pl(document.words, "upload.words", { n: document.words.toLocaleString() }),
- document.pages ? pl(document.pages, "upload.pages") : "",
- document.images.length ? pl(document.images.length, "upload.pageImages") : "",
- document.truncated ? tr("upload.truncated") : "",
- ];
- return parts.filter(Boolean).join(" · ");
-}
-
const DIFFICULTIES: Array<{ value: Difficulty; labelKey: string; icon: React.ReactNode; blurbKey: string }> = [
{ value: "easy", labelKey: "upload.easy", icon: , blurbKey: "upload.easyBlurb" },
{
@@ -1106,7 +1093,8 @@ export function UploadScreen() {
onDrop={(event) => {
event.preventDefault();
setDragging(false);
- void addFiles(imageFilesFromDataTransfer(event.dataTransfer));
+ const dropped = Array.from(event.dataTransfer?.files ?? []);
+ void addFiles(dropped.length > 0 ? dropped : imageFilesFromDataTransfer(event.dataTransfer));
}}
className={cn(
"card relative overflow-hidden p-4 transition-all sm:p-5",
diff --git a/components/activities/FillBlank.tsx b/components/activities/FillBlank.tsx
index 8332d4f..d71d4db 100644
--- a/components/activities/FillBlank.tsx
+++ b/components/activities/FillBlank.tsx
@@ -25,7 +25,7 @@ function normalise(value: string): string {
.toLowerCase()
.normalize("NFKD")
.replace(/[\u0300-\u036f]/g, "")
- .replace(/[^a-z0-9\s]/g, " ")
+ .replace(/[^\p{L}\p{N}\s]/gu, " ")
.replace(/\s+/g, " ")
.trim();
}
@@ -89,7 +89,7 @@ export function FillBlank({ activity, active, onAnswered, onNext, isLast, nextLa
onAnswered(correct, { userAnswer: answerValue });
};
- const displayAnswer = chosen ?? "";
+ const displayAnswer = chosen ?? value;
return (
<>
diff --git a/components/activities/Ordering.tsx b/components/activities/Ordering.tsx
index e256ff4..b56b2ae 100644
--- a/components/activities/Ordering.tsx
+++ b/components/activities/Ordering.tsx
@@ -20,7 +20,13 @@ type OrderingActivity = Extract;
export function Ordering({ activity, onAnswered, onNext, isLast, nextLabel }: ActivityComponentProps) {
const { t } = useI18n();
- const initial = useMemo(() => shuffle(activity.items), [activity]);
+ const initial = useMemo(() => {
+ const out = shuffle(activity.items);
+ if (out.length > 1 && out.every((item, index) => item === activity.items[index])) {
+ [out[0], out[1]] = [out[1], out[0]];
+ }
+ return out;
+ }, [activity]);
const [items, setItems] = useState(initial);
const [checked, setChecked] = useState(false);
const [correct, setCorrect] = useState(false);
diff --git a/components/chat/ChatScreen.tsx b/components/chat/ChatScreen.tsx
index 4bc6c33..7e7a01b 100644
--- a/components/chat/ChatScreen.tsx
+++ b/components/chat/ChatScreen.tsx
@@ -116,6 +116,10 @@ export function ChatScreen() {
/* History: hydrate once, then open ?c= conversations. */
useEffect(() => {
void ensureChatsHydrated();
+ return () => {
+ abortRef.current?.abort();
+ chatActive.set(null);
+ };
}, []);
const loadedRef = useRef(null);
@@ -129,7 +133,7 @@ export function ChatScreen() {
setDraft("");
setError(null);
if (found.model) setModel(found.model);
- if ((found as { provider?: string }).provider) setProvider((found as { provider?: string }).provider ?? "ollama");
+ if (found.provider) setProvider(found.provider);
loadedRef.current = found.id;
chatActive.set(found.id);
}, []);
@@ -188,15 +192,23 @@ export function ChatScreen() {
return [...ollama, ...cloud];
}, [health, providerState]);
+ const activeProvider = provider !== "ollama" && !providerState.keys[provider] ? "ollama" : provider;
+
const activeModel = useMemo(() => {
- if (model && chatModels.some((entry) => entry.name === model && (entry.provider ?? "ollama") === provider)) return model;
- const sameProvider = chatModels.find((entry) => (entry.provider ?? "ollama") === provider && entry.available !== false);
- if (model && sameProvider?.name === model) return model;
+ if (model && chatModels.some((entry) => entry.name === model && (entry.provider ?? "ollama") === activeProvider)) {
+ return model;
+ }
// The server puts the default first, but a different server may not have
// it — fall back to the first model that is actually available.
- const firstAvailable = chatModels.find((entry) => (entry.provider ?? "ollama") === provider && entry.available !== false);
- return (firstAvailable ?? chatModels.find((entry) => (entry.provider ?? "ollama") === provider) ?? chatModels[0])?.name ?? model;
- }, [model, chatModels, provider]);
+ const firstAvailable = chatModels.find(
+ (entry) => (entry.provider ?? "ollama") === activeProvider && entry.available !== false,
+ );
+ return (
+ firstAvailable ??
+ chatModels.find((entry) => (entry.provider ?? "ollama") === activeProvider) ??
+ chatModels[0]
+ )?.name ?? model;
+ }, [model, chatModels, activeProvider]);
const scopeText = useMemo(() => {
if (scope.kind === "all") return t("chat.scope.all");
@@ -266,7 +278,10 @@ export function ChatScreen() {
};
const persist = useCallback(
- (nextMessages: ChatMessage[], options: { title?: string; model?: string; scope?: ChatScope } = {}) => {
+ (
+ nextMessages: ChatMessage[],
+ options: { title?: string; model?: string; provider?: string; scope?: ChatScope } = {},
+ ) => {
const id = conversationId ?? chatMessageId("chat");
if (!conversationId) {
setConversationId(id);
@@ -285,12 +300,13 @@ export function ChatScreen() {
messages: nextMessages,
scope: options.scope ?? scope,
model: options.model ?? (activeModel || undefined),
+ provider: options.provider ?? activeProvider,
createdAt: existing?.createdAt ?? Date.now(),
updatedAt: Date.now(),
});
if (nextTitle && nextTitle !== title) setTitle(nextTitle);
},
- [conversationId, title, scope, activeModel],
+ [conversationId, title, scope, activeModel, activeProvider],
);
const run = async (history: ChatMessage[], extraChunks: ExtraChunk[]) => {
@@ -321,7 +337,7 @@ export function ChatScreen() {
try {
await streamChat({
model: activeModel || undefined,
- provider,
+ provider: activeProvider,
messages: history.map((message) => ({ role: message.role, content: message.content })),
context: context.chunks.map(({ deckId, deckTitle, section, text }) => ({ deckId, deckTitle, section, text })),
signal: controller.signal,
@@ -412,28 +428,24 @@ export function ChatScreen() {
void run(history, extras);
};
- const retryLast = () => {
+ const retryLast = useCallback(() => {
+ if (busy) return;
const lastUserIndex = messages.map((message) => message.role).lastIndexOf("user");
if (lastUserIndex < 0) return;
const history = messages.slice(0, lastUserIndex + 1);
+ setError(null);
setMessages(history);
void run(history, extrasRef.current[history[lastUserIndex].id] ?? []);
- };
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [busy, messages]);
- const regenerate = () => {
- const lastUserIndex = messages.map((message) => message.role).lastIndexOf("user");
- if (lastUserIndex < 0) return;
- const history = messages.slice(0, lastUserIndex + 1);
- setMessages(history);
- void run(history, extrasRef.current[history[lastUserIndex].id] ?? []);
- };
-
- const editLast = () => {
+ const editLast = useCallback(() => {
+ if (busy) return;
const lastUserIndex = messages.map((message) => message.role).lastIndexOf("user");
if (lastUserIndex < 0) return;
setDraft(messages[lastUserIndex].content);
setMessages(messages.slice(0, lastUserIndex));
- };
+ }, [busy, messages]);
const resetView = useCallback(() => {
abortRef.current?.abort();
@@ -498,28 +510,34 @@ export function ChatScreen() {
return undefined;
}, [scope, seed, decks]);
- const saveToNotes = (message: ChatMessage) => {
- if (!noteTargetDeck) return;
- const current = noteTargetDeck.sourceText?.trim() ?? "";
- const addition = `\n\n— ${t("chat.savedFromChat")} —\n${message.content.trim()}`;
- let next = `${current}${addition}`;
- let trimmed = false;
- if (next.length > 40_000) {
- next = next.slice(-40_000);
- trimmed = true;
- }
- upsertDeck({ ...noteTargetDeck, sourceText: next, updatedAt: Date.now() });
- showToast({
- message: trimmed
- ? t("chat.savedToNotesTrimmed", { deck: noteTargetDeck.title })
- : t("chat.savedToNotes", { deck: noteTargetDeck.title }),
- });
- };
+ const saveToNotes = useCallback(
+ (message: ChatMessage) => {
+ if (!noteTargetDeck) return;
+ const current = noteTargetDeck.sourceText?.trim() ?? "";
+ const addition = `\n\n— ${t("chat.savedFromChat")} —\n${message.content.trim()}`;
+ let next = `${current}${addition}`;
+ let trimmed = false;
+ if (next.length > 40_000) {
+ next = next.slice(-40_000);
+ trimmed = true;
+ }
+ upsertDeck({ ...noteTargetDeck, sourceText: next, updatedAt: Date.now() });
+ showToast({
+ message: trimmed
+ ? t("chat.savedToNotesTrimmed", { deck: noteTargetDeck.title })
+ : t("chat.savedToNotes", { deck: noteTargetDeck.title }),
+ });
+ },
+ [noteTargetDeck, t],
+ );
- const toFlashcards = (message: ChatMessage) => {
- setDeckSeed({ text: message.content.slice(0, 40_000), step: 2 });
- router.push("/create");
- };
+ const toFlashcards = useCallback(
+ (message: ChatMessage) => {
+ setDeckSeed({ text: message.content.slice(0, 40_000), step: 2 });
+ router.push("/create");
+ },
+ [router],
+ );
const handleAttach = async (file: File) => {
setAttachBusy(true);
@@ -545,9 +563,19 @@ export function ChatScreen() {
}
};
- const lastAssistantId = [...messages].reverse().find((message) => message.role === "assistant")?.id;
- const lastUserId = [...messages].reverse().find((message) => message.role === "user")?.id;
- const lastFailedAssistant = [...messages].reverse().find((message) => message.role === "assistant" && message.error)?.id;
+ let lastAssistantId: string | undefined;
+ let lastUserId: string | undefined;
+ let lastFailedAssistant: string | undefined;
+ for (let i = messages.length - 1; i >= 0; i -= 1) {
+ const message = messages[i];
+ if (message.role === "assistant") {
+ if (!lastAssistantId) lastAssistantId = message.id;
+ if (!lastFailedAssistant && message.error) lastFailedAssistant = message.id;
+ } else if (!lastUserId) {
+ lastUserId = message.id;
+ }
+ if (lastAssistantId && lastUserId && lastFailedAssistant) break;
+ }
return (
@@ -659,11 +687,11 @@ export function ChatScreen() {
isLastAssistant={message.id === lastAssistantId}
isLastUser={message.id === lastUserId}
busy={busy}
- onRegenerate={regenerate}
+ onRegenerate={retryLast}
onEdit={editLast}
onRetry={message.id === lastFailedAssistant ? retryLast : undefined}
- onToFlashcards={() => toFlashcards(message)}
- onSaveNotes={noteTargetDeck ? () => saveToNotes(message) : undefined}
+ onToFlashcards={toFlashcards}
+ onSaveNotes={noteTargetDeck ? saveToNotes : undefined}
/>
))}
@@ -718,12 +746,12 @@ export function ChatScreen() {
scopeLabel={scopeText}
modelLabel={
activeModel
- ? provider !== "ollama"
- ? `${providerInfo(provider)?.label ?? provider} · ${prettyModelName(activeModel)}`
+ ? activeProvider !== "ollama"
+ ? `${providerInfo(activeProvider)?.label ?? activeProvider} · ${prettyModelName(activeModel)}`
: prettyModelName(activeModel)
: t("chat.modelTitle")
}
- modelTitle={activeModel ? `${activeModel} · ${providerInfo(provider)?.label ?? provider}` : undefined}
+ modelTitle={activeModel ? `${activeModel} · ${providerInfo(activeProvider)?.label ?? activeProvider}` : undefined}
scopeOpen={scopeOpen}
modelOpen={modelOpen}
// The chips toggle their own popover (and open one at a time), so a
@@ -746,13 +774,14 @@ export function ChatScreen() {
onClose={() => setModelOpen(false)}
models={chatModels}
value={activeModel}
- valueProvider={provider}
+ valueProvider={activeProvider}
onChange={(next, nextProvider) => {
- setProvider(nextProvider ?? "ollama");
- storeChatProvider(nextProvider ?? "ollama");
+ const chosenProvider = nextProvider ?? "ollama";
+ setProvider(chosenProvider);
+ storeChatProvider(chosenProvider);
setModel(next);
storeChatModel(next);
- if (messages.length > 0) persist(messages, { model: next });
+ if (messages.length > 0) persist(messages, { model: next, provider: chosenProvider });
}}
/>
void;
onEdit?: () => void;
onRetry?: () => void;
- onToFlashcards?: () => void;
- onSaveNotes?: () => void;
+ onToFlashcards?: (message: ChatMessage) => void;
+ onSaveNotes?: (message: ChatMessage) => void;
}) {
const { t } = useI18n();
@@ -117,7 +118,7 @@ export function MessageRow({
{onToFlashcards ? (
);
-}
+});
diff --git a/components/chat/ModelPicker.tsx b/components/chat/ModelPicker.tsx
index 496f5a5..4395dce 100644
--- a/components/chat/ModelPicker.tsx
+++ b/components/chat/ModelPicker.tsx
@@ -97,7 +97,7 @@ export function ModelPicker({
const selected = model.name === value && (model.provider ?? "ollama") === (valueProvider ?? "ollama");
const unavailable = model.available === false;
return (
-
+
) => string,
- pl: (n: number, base: string, vars?: Record) => string,
-): string {
- const parts = [
- pl(document.words, "upload.words", { n: document.words.toLocaleString() }),
- document.pages ? pl(document.pages, "upload.pages") : "",
- document.images.length ? pl(document.images.length, "upload.pageImages") : "",
- document.truncated ? tr("upload.truncated") : "",
- ];
- return parts.filter(Boolean).join(" · ");
-}
-
const MODES: Array<{ id: HomeworkMode; icon: React.ReactNode; titleKey: string; subtitleKey: string }> = [
{ id: "make", icon: , titleKey: "homework.mode.make.title", subtitleKey: "homework.mode.make.subtitle" },
{ id: "guide", icon: , titleKey: "homework.mode.guide.title", subtitleKey: "homework.mode.guide.subtitle" },
@@ -95,6 +80,7 @@ export function HomeworkInput({
const [pasteOpen, setPasteOpen] = useState(false);
/** Phones: the check card reveals the solution field instead of starting. */
const [checkOpen, setCheckOpen] = useState(false);
+ const [dragging, setDragging] = useState(false);
const isPhone = useIsPhone();
const reduceMotion = useReducedMotion();
const online = useOnline();
@@ -130,10 +116,9 @@ export function HomeworkInput({
/* Persist the typed assignment while the user works (debounced). */
useEffect(() => {
- if (!isPhone) return;
const timer = window.setTimeout(() => saveWizardDraft("homework", { text }), 400);
return () => window.clearTimeout(timer);
- }, [text, isPhone]);
+ }, [text]);
// Clipboard: screenshots become photos, long copied text becomes the assignment.
useEffect(() => {
@@ -744,7 +729,23 @@ export function HomeworkInput({
{errorCard}
{/* ------------------------------------------------ the assignment */}
-
+ {
+ event.preventDefault();
+ setDragging(true);
+ }}
+ onDragLeave={() => setDragging(false)}
+ onDrop={(event) => {
+ event.preventDefault();
+ setDragging(false);
+ const dropped = Array.from(event.dataTransfer?.files ?? []);
+ void intake.addFiles(dropped.length > 0 ? dropped : imageFilesFromDataTransfer(event.dataTransfer));
+ }}
+ className={cn(
+ "card p-4 transition-all sm:p-5",
+ dragging && "border-frost-400 bg-frost-50 ring-4 ring-frost-200 dark:bg-frost-500/10 dark:ring-frost-500/30",
+ )}
+ >
diff --git a/components/homework/HomeworkScreen.tsx b/components/homework/HomeworkScreen.tsx
index 7aa14a0..ce67ad0 100644
--- a/components/homework/HomeworkScreen.tsx
+++ b/components/homework/HomeworkScreen.tsx
@@ -259,10 +259,12 @@ export function HomeworkScreen({ initialRecordId }: { initialRecordId?: string }
}
if (view.kind === "record") {
+ const recordKey = `${view.record.id}-${view.record.updatedAt}`;
return (
{view.record.mode === "make" ? (
again(view.record)}
onNew={newAssignment}
@@ -271,6 +273,7 @@ export function HomeworkScreen({ initialRecordId }: { initialRecordId?: string }
/>
) : view.record.mode === "check" ? (
again(view.record)}
onNew={newAssignment}
@@ -279,6 +282,7 @@ export function HomeworkScreen({ initialRecordId }: { initialRecordId?: string }
/>
) : (
setView({ kind: "start" })}
onAgain={() => again(view.record)}
diff --git a/lib/backup.ts b/lib/backup.ts
index e2493ce..6d74535 100644
--- a/lib/backup.ts
+++ b/lib/backup.ts
@@ -11,7 +11,7 @@
import { z } from "zod";
-import { applyTheme } from "./theme";
+import { applyTheme, type Theme } from "./theme";
import { chatHistoryStore, ensureChatsHydrated, replaceChats } from "./chat-store";
import { replaceFolders, folderStore } from "./folders";
import { homeworkStore, replaceHomework } from "./homework";
@@ -26,7 +26,7 @@ export const BACKUP_VERSION = 1;
const settingsSchema = z.object({
haptics: z.boolean().optional(),
locale: z.enum(["en", "cs"]).optional(),
- theme: z.enum(["light", "dark"]).optional(),
+ theme: z.enum(["light", "dark", "aero"]).optional(),
dailyGoal: z.number().int().min(1).max(500).optional(),
});
@@ -74,7 +74,7 @@ export async function buildBackup(): Promise {
haptics: settingsStore.getSnapshot().haptics,
dailyGoal: settingsStore.getSnapshot().dailyGoal,
locale: (window.localStorage.getItem("freezmo.locale.v1") as "en" | "cs" | null) ?? undefined,
- theme: (window.localStorage.getItem("freezmo.theme") as "light" | "dark" | null) ?? undefined,
+ theme: (window.localStorage.getItem("freezmo.theme") as Theme | null) ?? undefined,
},
},
};
@@ -174,15 +174,18 @@ export async function applyImport(
replaceDecks(decks);
replaceHomework(homework);
- // Answer log + schedule: merge by id (answers) and by key (schedules).
+ // Answer log + schedule: merge by id (answers) and by key (schedules), or
+ // replace wholesale when mode === "replace".
await ensureLearningHydrated();
const incomingAnswers = data.history ?? [];
const incomingSrs = data.srs ?? [];
- if (incomingAnswers.length > 0 || incomingSrs.length > 0) {
+ if (mode === "replace") {
+ const srsByKey = new Map(incomingSrs.map((entry) => [entry.key, entry]));
+ await replaceLearning(incomingAnswers, [...srsByKey.values()]);
+ } else if (incomingAnswers.length > 0 || incomingSrs.length > 0) {
const currentAnswers = learningStore.getAnswersSnapshot();
const currentSrs = learningStore.getSrsSnapshot();
- const answers =
- mode === "replace" ? incomingAnswers : mergeById(currentAnswers, incomingAnswers);
+ const answers = mergeById(currentAnswers, incomingAnswers);
const srsByKey = new Map(currentSrs.map((entry) => [entry.key, entry]));
for (const entry of incomingSrs) {
const current = srsByKey.get(entry.key);
@@ -194,8 +197,10 @@ export async function applyImport(
// Chat conversations: merge by id (newest wins) or replace wholesale.
await ensureChatsHydrated();
const incomingChats = data.chats ?? [];
- if (incomingChats.length > 0) {
- replaceChats(mode === "replace" ? incomingChats : mergeById(chatHistoryStore.getSnapshot(), incomingChats));
+ if (mode === "replace") {
+ replaceChats(incomingChats);
+ } else if (incomingChats.length > 0) {
+ replaceChats(mergeById(chatHistoryStore.getSnapshot(), incomingChats));
}
const settings = data.settings;
diff --git a/lib/chat-store.ts b/lib/chat-store.ts
index 0790276..04178d5 100644
--- a/lib/chat-store.ts
+++ b/lib/chat-store.ts
@@ -56,10 +56,10 @@ function openDb(): Promise {
});
}
-async function withDb(mode: IDBTransactionMode, run: (db: IDBDatabase) => T): Promise {
+async function withDb(_mode: IDBTransactionMode, run: (db: IDBDatabase) => T | Promise): Promise {
const db = await openDb();
try {
- return run(db);
+ return await run(db);
} finally {
window.setTimeout(() => db.close(), 1000);
}
diff --git a/lib/chat.ts b/lib/chat.ts
index 36f7abd..f2fb37d 100644
--- a/lib/chat.ts
+++ b/lib/chat.ts
@@ -7,6 +7,7 @@
*/
import type { ChatAttachment, ChatMessage, ChatScope, ChatSource } from "./schemas";
+import { uid } from "./utils";
export type { ChatAttachment, ChatMessage, ChatScope, ChatSource };
@@ -59,11 +60,7 @@ export function storeChatProvider(provider: string): void {
}
export function chatMessageId(prefix: string): string {
- const rand =
- typeof crypto !== "undefined" && "randomUUID" in crypto
- ? crypto.randomUUID().slice(0, 8)
- : Math.random().toString(36).slice(2, 10);
- return `${prefix}_${Date.now().toString(36)}_${rand}`;
+ return uid(prefix);
}
/** Auto title from the first user turn (markdown stripped, one line). */
diff --git a/lib/confetti.ts b/lib/confetti.ts
index b5ceeae..2defd29 100644
--- a/lib/confetti.ts
+++ b/lib/confetti.ts
@@ -1,9 +1,11 @@
"use client";
-/** Confetti helper that respects prefers-reduced-motion. */
+/** Confetti helper that respects prefers-reduced-motion and the in-app toggle. */
export function prefersReducedMotion(): boolean {
- if (typeof window === "undefined" || !window.matchMedia) return false;
+ if (typeof window === "undefined") return false;
+ if (document.documentElement.classList.contains("reduce-motion")) return true;
+ if (!window.matchMedia) return false;
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
@@ -35,22 +37,3 @@ export async function celebrate(intensity: "normal" | "big" = "normal"): Promise
/* confetti is a nice-to-have */
}
}
-
-export function burstAt(x: number, y: number): void {
- if (prefersReducedMotion()) return;
- void (async () => {
- try {
- const confetti = (await import("canvas-confetti")).default;
- confetti({
- particleCount: 40,
- spread: 55,
- startVelocity: 26,
- origin: { x: x / window.innerWidth, y: y / window.innerHeight },
- colors: ["#7ea6c2", "#aac6da", "#cfd8e3", "#ffffff"],
- disableForReducedMotion: true,
- });
- } catch {
- /* ignore */
- }
- })();
-}
diff --git a/lib/documents.ts b/lib/documents.ts
index f827989..7505768 100644
--- a/lib/documents.ts
+++ b/lib/documents.ts
@@ -217,58 +217,65 @@ async function extractPdf(
const images: PreparedImage[] = [];
const warnings: string[] = [];
let imagesTruncated = false;
+ const numPages = document.numPages;
- const totalPages = Math.min(document.numPages, 80);
- if (document.numPages > totalPages) {
- warnings.push(`Only the first ${totalPages} of ${document.numPages} pages were read.`);
- }
-
- for (let pageNumber = 1; pageNumber <= totalPages; pageNumber++) {
- onProgress?.({
- message: t("doc.readingPage", { n: pageNumber, total: totalPages }),
- progress: pageNumber / totalPages,
- });
-
- const page = (await document.getPage(pageNumber)) as unknown as PdfPage;
- let pageText = "";
- try {
- const content = await page.getTextContent();
- pageText = textFromPdfItems(content.items);
- } catch {
- warnings.push(`Page ${pageNumber} text could not be extracted.`);
+ try {
+ const totalPages = Math.min(numPages, 80);
+ if (numPages > totalPages) {
+ warnings.push(`Only the first ${totalPages} of ${numPages} pages were read.`);
}
- if (pageText.trim().length >= SCANNED_PAGE_CHAR_THRESHOLD) {
- pages.push(`--- page ${pageNumber} ---\n${pageText.trim()}`);
- } else if (images.length < MAX_DOCUMENT_IMAGES) {
- // Scanned or image-only page: render it so the vision model can read it.
+ for (let pageNumber = 1; pageNumber <= totalPages; pageNumber++) {
+ onProgress?.({
+ message: t("doc.readingPage", { n: pageNumber, total: totalPages }),
+ progress: pageNumber / totalPages,
+ });
+
+ const page = (await document.getPage(pageNumber)) as unknown as PdfPage;
+ let pageText = "";
try {
- const viewport = page.getViewport({ scale: PDF_RENDER_SCALE });
- const canvas = window.document.createElement("canvas");
- canvas.width = Math.floor(viewport.width);
- canvas.height = Math.floor(viewport.height);
- const context = canvas.getContext("2d");
- if (!context) throw new Error("no 2d context");
- context.fillStyle = "#ffffff";
- context.fillRect(0, 0, canvas.width, canvas.height);
- await page.render({ canvasContext: context, viewport }).promise;
- const blob = await new Promise((resolve) => canvas.toBlob(resolve, "image/jpeg", 0.9));
- if (blob) {
- images.push(await prepareImageFromBlob(blob, `${file.name} p${pageNumber}`));
- }
+ const content = await page.getTextContent();
+ pageText = textFromPdfItems(content.items);
} catch {
- warnings.push(`Page ${pageNumber} looks scanned but could not be rendered.`);
+ warnings.push(`Page ${pageNumber} text could not be extracted.`);
}
- } else {
- imagesTruncated = true;
- }
- if (pageText.trim().length < SCANNED_PAGE_CHAR_THRESHOLD) {
- // Nothing to add to the text; the image carries the content.
- imagesTruncated = images.length >= MAX_DOCUMENT_IMAGES;
- }
+ if (pageText.trim().length >= SCANNED_PAGE_CHAR_THRESHOLD) {
+ pages.push(`--- page ${pageNumber} ---\n${pageText.trim()}`);
+ } else if (images.length < MAX_DOCUMENT_IMAGES) {
+ // Scanned or image-only page: render it so the vision model can read it.
+ try {
+ const viewport = page.getViewport({ scale: PDF_RENDER_SCALE });
+ const canvas = window.document.createElement("canvas");
+ canvas.width = Math.floor(viewport.width);
+ canvas.height = Math.floor(viewport.height);
+ const context = canvas.getContext("2d");
+ if (!context) throw new Error("no 2d context");
+ context.fillStyle = "#ffffff";
+ context.fillRect(0, 0, canvas.width, canvas.height);
+ await page.render({ canvasContext: context, viewport }).promise;
+ const blob = await new Promise((resolve) => canvas.toBlob(resolve, "image/jpeg", 0.9));
+ canvas.width = 0;
+ canvas.height = 0;
+ if (blob) {
+ images.push(await prepareImageFromBlob(blob, `${file.name} p${pageNumber}`));
+ }
+ } catch {
+ warnings.push(`Page ${pageNumber} looks scanned but could not be rendered.`);
+ }
+ } else {
+ imagesTruncated = true;
+ }
- page.cleanup?.();
+ if (pageText.trim().length < SCANNED_PAGE_CHAR_THRESHOLD) {
+ // Nothing to add to the text; the image carries the content.
+ imagesTruncated = images.length >= MAX_DOCUMENT_IMAGES;
+ }
+
+ page.cleanup?.();
+ }
+ } finally {
+ await (document as { destroy?: () => Promise }).destroy?.().catch(() => {});
}
if (images.length > 0) {
@@ -280,7 +287,7 @@ async function extractPdf(
}
return withLimits(file.name, "pdf", pages.join("\n\n"), images, {
- pages: document.numPages,
+ pages: numPages,
warnings,
imagesTruncated,
});
@@ -342,14 +349,18 @@ async function extractDocx(file: File): Promise {
const XML_TEXT_RUN = /]*>([\s\S]*?)<\/a:t>/g;
const XML_PARAGRAPH = /<\/a:p>/g;
+function safeCodePoint(cp: number): string {
+ return Number.isFinite(cp) && cp > 0 && cp <= 0x10ffff ? String.fromCodePoint(cp) : "";
+}
+
function decodeXmlEntities(value: string): string {
return value
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, '"')
.replace(/'/g, "'")
- .replace(/(\d+);/g, (_, code: string) => String.fromCodePoint(Number(code)))
- .replace(/([0-9a-f]+);/gi, (_, code: string) => String.fromCodePoint(Number.parseInt(code, 16)))
+ .replace(/(\d+);/g, (_, code: string) => safeCodePoint(Number(code)))
+ .replace(/([0-9a-f]+);/gi, (_, code: string) => safeCodePoint(Number.parseInt(code, 16)))
.replace(/&/g, "&");
}
@@ -475,3 +486,18 @@ export function documentsToText(documents: ExtractedDocument[]): string {
.join("\n\n")
.slice(0, MAX_DOCUMENT_CHARS * 2);
}
+
+/** Human-readable summary line for an extracted document (words · pages · images). */
+export function describeExtraction(
+ document: ExtractedDocument,
+ tr: (key: string, vars?: Record) => string,
+ pl: (n: number, base: string, vars?: Record) => string,
+): string {
+ const parts = [
+ pl(document.words, "upload.words", { n: document.words.toLocaleString() }),
+ document.pages ? pl(document.pages, "upload.pages") : "",
+ document.images.length ? pl(document.images.length, "upload.pageImages") : "",
+ document.truncated ? tr("upload.truncated") : "",
+ ];
+ return parts.filter(Boolean).join(" · ");
+}
diff --git a/lib/folders.ts b/lib/folders.ts
index 277d69f..af9d229 100644
--- a/lib/folders.ts
+++ b/lib/folders.ts
@@ -71,21 +71,22 @@ export function replaceFolders(next: Folder[]): void {
write(valid);
}
+function onStorage(event: StorageEvent): void {
+ if (event.key === STORAGE_KEY) {
+ folders = null;
+ ensureLoaded();
+ listeners.forEach((l) => l());
+ }
+}
+
export const folderStore = {
subscribe(listener: Listener): () => void {
ensureLoaded();
+ if (listeners.size === 0) window.addEventListener("storage", onStorage);
listeners.add(listener);
- const onStorage = (event: StorageEvent) => {
- if (event.key === STORAGE_KEY) {
- folders = null;
- ensureLoaded();
- listeners.forEach((l) => l());
- }
- };
- window.addEventListener("storage", onStorage);
return () => {
listeners.delete(listener);
- window.removeEventListener("storage", onStorage);
+ if (listeners.size === 0) window.removeEventListener("storage", onStorage);
};
},
getSnapshot(): Folder[] {
@@ -155,13 +156,3 @@ export function removeFolder(id: string): void {
ensureLoaded();
write(folderStore.getSnapshot().filter((folder) => folder.id !== id));
}
-
-/** Deleting a folder must never delete decks: callers pair this with unfileDecks(). */
-export function hasFolder(id: string): boolean {
- return Boolean(getFolder(id));
-}
-
-export function emptyFolder(): void {
- ensureLoaded();
- write([]);
-}
diff --git a/lib/history.ts b/lib/history.ts
index 742e8ab..a66adce 100644
--- a/lib/history.ts
+++ b/lib/history.ts
@@ -64,10 +64,10 @@ function openDb(): Promise {
});
}
-async function withDb(mode: IDBTransactionMode, run: (db: IDBDatabase) => T): Promise {
+async function withDb(_mode: IDBTransactionMode, run: (db: IDBDatabase) => T | Promise): Promise {
const db = await openDb();
try {
- return run(db);
+ return await run(db);
} finally {
// Transactions keep the connection alive until they settle; close on the
// next task so pending requests finish first.
diff --git a/lib/homework.ts b/lib/homework.ts
index 1e1e62a..ca537e5 100644
--- a/lib/homework.ts
+++ b/lib/homework.ts
@@ -80,21 +80,22 @@ export function replaceHomework(next: HomeworkRecord[]): void {
write(valid);
}
+function onStorage(event: StorageEvent): void {
+ if (event.key === STORAGE_KEY) {
+ records = null;
+ ensureLoaded();
+ listeners.forEach((l) => l());
+ }
+}
+
export const homeworkStore = {
subscribe(listener: Listener): () => void {
ensureLoaded();
+ if (listeners.size === 0) window.addEventListener("storage", onStorage);
listeners.add(listener);
- const onStorage = (event: StorageEvent) => {
- if (event.key === STORAGE_KEY) {
- records = null;
- ensureLoaded();
- listeners.forEach((l) => l());
- }
- };
- window.addEventListener("storage", onStorage);
return () => {
listeners.delete(listener);
- window.removeEventListener("storage", onStorage);
+ if (listeners.size === 0) window.removeEventListener("storage", onStorage);
};
},
getSnapshot(): HomeworkRecord[] {
diff --git a/lib/i18n.ts b/lib/i18n.ts
index 1f1b0d6..f4b4cbd 100644
--- a/lib/i18n.ts
+++ b/lib/i18n.ts
@@ -45,19 +45,20 @@ function emit(): void {
listeners.forEach((listener) => listener());
}
+function onStorage(event: StorageEvent): void {
+ if (event.key === STORAGE_KEY) {
+ cached = read();
+ emit();
+ }
+}
+
export const localeStore = {
subscribe(listener: () => void): () => void {
+ if (listeners.size === 0) window.addEventListener("storage", onStorage);
listeners.add(listener);
- const onStorage = (event: StorageEvent) => {
- if (event.key === STORAGE_KEY) {
- cached = read();
- emit();
- }
- };
- window.addEventListener("storage", onStorage);
return () => {
listeners.delete(listener);
- window.removeEventListener("storage", onStorage);
+ if (listeners.size === 0) window.removeEventListener("storage", onStorage);
};
},
getSnapshot(): Locale {
diff --git a/lib/image.ts b/lib/image.ts
index 199154e..c8d7571 100644
--- a/lib/image.ts
+++ b/lib/image.ts
@@ -45,7 +45,10 @@ async function loadBitmap(blob: Blob, fallbackName: string): Promise<{ width: nu
return {
width: bitmap.width,
height: bitmap.height,
- draw: (ctx, w, h) => ctx.drawImage(bitmap, 0, 0, w, h),
+ draw: (ctx, w, h) => {
+ ctx.drawImage(bitmap, 0, 0, w, h);
+ bitmap.close();
+ },
};
} catch {
/* fall through to the
path (Safari/HEIC edge cases) */
@@ -91,6 +94,8 @@ export async function prepareImageFromBlob(blob: Blob, name = "image.jpg"): Prom
bitmap.draw(ctx, width, height);
const dataUrl = canvas.toDataURL("image/jpeg", JPEG_QUALITY);
+ canvas.width = 0;
+ canvas.height = 0;
return {
id: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`,
diff --git a/lib/learning.ts b/lib/learning.ts
index c50371e..a158113 100644
--- a/lib/learning.ts
+++ b/lib/learning.ts
@@ -94,6 +94,16 @@ export function dayKey(at: number): string {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
}
+/** Fast count of answers recorded today (avoids building the full stats buckets). */
+export function todayAnswerCount(answers: AnswerRecord[], now = Date.now()): number {
+ const today = dayKey(now);
+ let count = 0;
+ for (const answer of answers) {
+ if (dayKey(answer.at) === today) count += 1;
+ }
+ return count;
+}
+
export function computeStats(answers: AnswerRecord[], now = Date.now()): LearningStats {
const total = answers.length;
const correct = answers.filter((entry) => entry.correct).length;
diff --git a/lib/motion.ts b/lib/motion.ts
index 5a96f7c..19cf552 100644
--- a/lib/motion.ts
+++ b/lib/motion.ts
@@ -36,14 +36,6 @@ export const fade: Transition = { duration: 0.18, ease: "easeOut" };
/** Plain ease for fades and progress bars. */
export const EASE_OUT = [0.22, 1, 0.36, 1] as const;
-/** Durations (seconds) used across the pass. */
-export const DURATION = {
- press: 0.1,
- small: 0.2,
- screen: 0.3,
- sheet: 0.34,
-} as const;
-
/** A screen entering from the right (drill-in) or left (back). */
export function screenVariants(direction: 1 | -1, reduceMotion: boolean): Variants {
if (reduceMotion) {
@@ -83,17 +75,6 @@ export function stepVariants(direction: 1 | -1, reduceMotion: boolean): Variants
};
}
-/** Capped stagger for a list's first paint. */
-export const listContainer: Variants = {
- hidden: {},
- show: { transition: { staggerChildren: 0.045, delayChildren: 0.02 } },
-};
-
-export const listItem: Variants = {
- hidden: { opacity: 0, y: 12 },
- show: { opacity: 1, y: 0, transition: springGentle },
-};
-
/**
* Counts a number up over ~600ms with an ease-out curve (score, XP). Pass
* `disabled` (desktop, reduced motion) to jump straight to the value.
diff --git a/lib/ollama.ts b/lib/ollama.ts
index bf4a5e6..8b28840 100644
--- a/lib/ollama.ts
+++ b/lib/ollama.ts
@@ -120,7 +120,10 @@ async function fetchWithTimeout(
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
const onAbort = () => controller.abort();
- external?.addEventListener("abort", onAbort);
+ if (external) {
+ if (external.aborted) controller.abort();
+ else external.addEventListener("abort", onAbort, { once: true });
+ }
try {
return await fetch(url, { ...init, signal: controller.signal });
@@ -273,89 +276,104 @@ export async function ollamaChatStream(options: StreamOptions): Promise controller.abort(), options.timeoutMs ?? config.timeoutMs);
+ const onAbort = () => controller.abort();
+ if (options.signal) {
+ if (options.signal.aborted) controller.abort();
+ else options.signal.addEventListener("abort", onAbort, { once: true });
+ }
+
try {
- response = await fetchWithTimeout(
- `${baseUrl}/api/chat`,
- { method: "POST", headers, body: JSON.stringify(body), cache: "no-store" },
- options.timeoutMs ?? config.timeoutMs,
- options.signal,
- );
- } catch (error) {
- if (error instanceof Error && error.name === "AbortError") {
- throw new OllamaError("timeout", "The request to Ollama timed out.");
- }
- throw new OllamaError("unreachable", `Could not connect to Ollama at ${baseUrl}.`, {
- detail: error instanceof Error ? error.message : String(error),
- });
- }
-
- if (!response.ok) {
- const text = await response.text().catch(() => "");
- throwForStatus(response.status, text, model);
- }
- if (!response.body) {
- throw new OllamaError("bad_response", "Ollama sent an empty stream.");
- }
-
- const reader = response.body.getReader();
- const decoder = new TextDecoder();
- let buffer = "";
- let content = "";
- let thinking = "";
-
- const handleLine = (line: string) => {
- const trimmed = line.trim();
- if (!trimmed) return;
- let event: unknown;
+ let response: Response;
try {
- event = JSON.parse(trimmed);
- } catch {
- return; // a half-written line must never break the stream
- }
- if (!event || typeof event !== "object") return;
- const record = event as Record;
- if (typeof record.error === "string" && record.error) {
- throw new OllamaError("http", record.error.slice(0, 300));
- }
- const message = record.message as Record | undefined;
- const delta = typeof message?.content === "string" ? message.content : "";
- const reason = typeof message?.thinking === "string" ? message.thinking : "";
- if (reason) {
- thinking += reason;
- options.onThinking?.(reason);
- }
- if (delta) {
- content += delta;
- options.onToken?.(delta);
- }
- };
-
- try {
- while (true) {
- const { done, value } = await reader.read();
- if (done) break;
- buffer += decoder.decode(value, { stream: true });
- let newline = buffer.indexOf("\n");
- while (newline !== -1) {
- handleLine(buffer.slice(0, newline));
- buffer = buffer.slice(newline + 1);
- newline = buffer.indexOf("\n");
+ response = await fetch(`${baseUrl}/api/chat`, {
+ method: "POST",
+ headers,
+ body: JSON.stringify(body),
+ cache: "no-store",
+ signal: controller.signal,
+ });
+ } catch (error) {
+ if (error instanceof Error && error.name === "AbortError") {
+ throw new OllamaError("timeout", "The request to Ollama timed out.");
}
+ throw new OllamaError("unreachable", `Could not connect to Ollama at ${baseUrl}.`, {
+ detail: error instanceof Error ? error.message : String(error),
+ });
}
- handleLine(buffer);
- } catch (error) {
- if (error instanceof Error && error.name === "AbortError") {
- throw new OllamaError("timeout", "The request to Ollama timed out.");
+
+ if (!response.ok) {
+ const text = await response.text().catch(() => "");
+ throwForStatus(response.status, text, model);
+ }
+ if (!response.body) {
+ throw new OllamaError("bad_response", "Ollama sent an empty stream.");
}
- throw error;
- }
- if (!content.trim()) {
- throw new OllamaError("bad_response", "Ollama finished the stream without any answer text.");
- }
+ const reader = response.body.getReader();
+ const decoder = new TextDecoder();
+ let buffer = "";
+ let content = "";
+ let thinking = "";
- return { content, thinking, model };
+ const handleLine = (line: string) => {
+ const trimmed = line.trim();
+ if (!trimmed) return;
+ let event: unknown;
+ try {
+ event = JSON.parse(trimmed);
+ } catch {
+ return; // a half-written line must never break the stream
+ }
+ if (!event || typeof event !== "object") return;
+ const record = event as Record;
+ if (typeof record.error === "string" && record.error) {
+ throw new OllamaError("http", record.error.slice(0, 300));
+ }
+ const message = record.message as Record | undefined;
+ const delta = typeof message?.content === "string" ? message.content : "";
+ const reason = typeof message?.thinking === "string" ? message.thinking : "";
+ if (reason) {
+ thinking += reason;
+ options.onThinking?.(reason);
+ }
+ if (delta) {
+ content += delta;
+ options.onToken?.(delta);
+ }
+ };
+
+ try {
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) break;
+ buffer += decoder.decode(value, { stream: true });
+ let newline = buffer.indexOf("\n");
+ while (newline !== -1) {
+ handleLine(buffer.slice(0, newline));
+ buffer = buffer.slice(newline + 1);
+ newline = buffer.indexOf("\n");
+ }
+ }
+ handleLine(buffer);
+ } catch (error) {
+ void reader.cancel().catch(() => {});
+ if (error instanceof Error && error.name === "AbortError") {
+ throw new OllamaError("timeout", "The request to Ollama timed out.");
+ }
+ throw error;
+ }
+
+ if (!content.trim()) {
+ throw new OllamaError("bad_response", "Ollama finished the stream without any answer text.");
+ }
+
+ return { content, thinking, model };
+ } finally {
+ clearTimeout(timer);
+ options.signal?.removeEventListener("abort", onAbort);
+ }
}
function extractContent(payload: unknown): string | null {
@@ -493,6 +511,16 @@ export async function ollamaVerifyKey(auth: OllamaAuth, model: string, timeoutMs
: { valid: null, reason: "throttled", detail };
} else probe = { valid: null, detail: `HTTP ${response.status}` };
+ if (probeCache.size >= 500) {
+ const now = Date.now();
+ for (const [k, entry] of probeCache) {
+ if (now - entry.at >= PROBE_TTL_MS) probeCache.delete(k);
+ }
+ if (probeCache.size >= 500) {
+ const oldest = probeCache.keys().next().value;
+ if (oldest) probeCache.delete(oldest);
+ }
+ }
probeCache.set(cacheKey, { ...probe, at: Date.now() });
return probe;
}
diff --git a/lib/openai-compat.ts b/lib/openai-compat.ts
index 8ae2830..1bbbaf8 100644
--- a/lib/openai-compat.ts
+++ b/lib/openai-compat.ts
@@ -125,7 +125,8 @@ function contentFor(user: string, images?: string[]): string | ChatPart[] {
if (!images || images.length === 0) return user;
const parts: ChatPart[] = [{ type: "text", text: user }];
for (const image of images.slice(0, 8)) {
- parts.push({ type: "image_url", image_url: { url: image } });
+ const url = image.startsWith("data:") || /^https?:\/\//i.test(image) ? image : `data:image/jpeg;base64,${image}`;
+ parts.push({ type: "image_url", image_url: { url } });
}
return parts;
}
@@ -188,72 +189,103 @@ export async function providerChatStream(input: {
})),
stream: true,
};
- const response = await runFetch(
- `${trimBase(input.target.baseUrl)}/chat/completions`,
- { method: "POST", headers: requestHeaders(input.target), body: JSON.stringify(body), signal: input.signal, cache: "no-store" },
- input.model,
- input.target,
- 300_000,
- );
- if (!response.ok) {
- const text = await response.text().catch(() => "");
- throw failure(response.status, text, input.model, input.target);
- }
- if (!response.body) {
- throw new ProviderError("http", `${input.target.label} sent an empty stream.`, input.target.label);
+ const controller = new AbortController();
+ const timer = setTimeout(() => controller.abort(), 300_000);
+ const onAbort = () => controller.abort();
+ if (input.signal) {
+ if (input.signal.aborted) controller.abort();
+ else input.signal.addEventListener("abort", onAbort, { once: true });
}
- const reader = response.body.getReader();
- const decoder = new TextDecoder();
- let buffer = "";
- let model = input.model;
- let sawDone = false;
-
- const handleLine = (line: string) => {
- const trimmed = line.trim();
- if (!trimmed.startsWith("data:")) return;
- const data = trimmed.slice(5).trim();
- if (!data || data === "[DONE]") {
- if (data === "[DONE]") sawDone = true;
- return;
- }
- let event: {
- model?: string;
- choices?: Array<{ delta?: { content?: unknown; reasoning?: unknown; reasoning_content?: unknown } }>;
- error?: { message?: string };
- };
+ try {
+ let response: Response;
try {
- event = JSON.parse(data);
- } catch {
- return;
+ response = await fetch(`${trimBase(input.target.baseUrl)}/chat/completions`, {
+ method: "POST",
+ headers: requestHeaders(input.target),
+ body: JSON.stringify(body),
+ signal: controller.signal,
+ cache: "no-store",
+ });
+ } catch (error) {
+ if (input.signal?.aborted) throw error;
+ if (error instanceof Error && error.name === "AbortError") {
+ throw new ProviderError("timeout", `${input.target.label} took too long to answer.`, input.target.label);
+ }
+ throw new ProviderError("unreachable", `Could not reach ${input.target.label}. Check your connection.`, input.target.label);
+ }
+ if (!response.ok) {
+ const text = await response.text().catch(() => "");
+ throw failure(response.status, text, input.model, input.target);
+ }
+ if (!response.body) {
+ throw new ProviderError("http", `${input.target.label} sent an empty stream.`, input.target.label);
}
- if (event.error?.message) throw new ProviderError("http", `${input.target.label}: ${event.error.message}`, input.target.label);
- if (event.model) model = event.model;
- const delta = event.choices?.[0]?.delta;
- if (!delta) return;
- const thinking = delta.reasoning_content ?? delta.reasoning;
- if (typeof thinking === "string" && thinking) input.onThinking?.(thinking);
- if (typeof delta.content === "string" && delta.content) input.onToken?.(delta.content);
- };
- while (true) {
- const { done, value } = await reader.read();
- if (done) break;
- buffer += decoder.decode(value, { stream: true });
- let newline = buffer.indexOf("\n");
- while (newline !== -1) {
- handleLine(buffer.slice(0, newline));
- buffer = buffer.slice(newline + 1);
- newline = buffer.indexOf("\n");
+ const reader = response.body.getReader();
+ const decoder = new TextDecoder();
+ let buffer = "";
+ let model = input.model;
+ let sawDone = false;
+
+ const handleLine = (line: string) => {
+ const trimmed = line.trim();
+ if (!trimmed.startsWith("data:")) return;
+ const data = trimmed.slice(5).trim();
+ if (!data || data === "[DONE]") {
+ if (data === "[DONE]") sawDone = true;
+ return;
+ }
+ let event: {
+ model?: string;
+ choices?: Array<{ delta?: { content?: unknown; reasoning?: unknown; reasoning_content?: unknown } }>;
+ error?: { message?: string };
+ };
+ try {
+ event = JSON.parse(data);
+ } catch {
+ return;
+ }
+ if (event.error?.message) throw new ProviderError("http", `${input.target.label}: ${event.error.message}`, input.target.label);
+ if (event.model) model = event.model;
+ const delta = event.choices?.[0]?.delta;
+ if (!delta) return;
+ const thinking = delta.reasoning_content ?? delta.reasoning;
+ if (typeof thinking === "string" && thinking) input.onThinking?.(thinking);
+ if (typeof delta.content === "string" && delta.content) input.onToken?.(delta.content);
+ };
+
+ try {
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) break;
+ buffer += decoder.decode(value, { stream: true });
+ let newline = buffer.indexOf("\n");
+ while (newline !== -1) {
+ handleLine(buffer.slice(0, newline));
+ buffer = buffer.slice(newline + 1);
+ newline = buffer.indexOf("\n");
+ }
+ }
+ handleLine(buffer);
+ } catch (error) {
+ void reader.cancel().catch(() => {});
+ if (input.signal?.aborted) throw error;
+ if (error instanceof Error && error.name === "AbortError") {
+ throw new ProviderError("timeout", `${input.target.label} took too long to answer.`, input.target.label);
+ }
+ throw error;
}
+ if (!sawDone) {
+ // Some gateways close the stream without [DONE]; tokens already arrived,
+ // so this is only worth a debug note.
+ console.warn(`[freezmo] ${input.target.label} stream ended without [DONE]`);
+ }
+ return { model };
+ } finally {
+ clearTimeout(timer);
+ input.signal?.removeEventListener("abort", onAbort);
}
- handleLine(buffer);
- if (!sawDone) {
- // Some gateways close the stream without [DONE]; tokens already arrived,
- // so this is only worth a debug note.
- console.warn(`[freezmo] ${input.target.label} stream ended without [DONE]`);
- }
- return { model };
}
diff --git a/lib/providers-client.ts b/lib/providers-client.ts
index 70e8413..5e16e5d 100644
--- a/lib/providers-client.ts
+++ b/lib/providers-client.ts
@@ -117,11 +117,13 @@ export function saveProviderKey(id: string, key: string) {
}
export function clearProviderKey(id: string) {
- const keys = { ...providersStore.getSnapshot().keys };
+ const current = providersStore.getSnapshot();
+ const keys = { ...current.keys };
delete keys[id];
- const models = { ...providersStore.getSnapshot().models };
+ const models = { ...current.models };
delete models[id];
- update({ keys, models });
+ const generation = current.generation?.provider === id ? null : current.generation;
+ update({ keys, models, generation });
}
export function setProviderModels(id: string, models: string[]) {
diff --git a/lib/question-reports.ts b/lib/question-reports.ts
index 6735c02..aceb212 100644
--- a/lib/question-reports.ts
+++ b/lib/question-reports.ts
@@ -72,21 +72,22 @@ function write(next: QuestionReport[]): void {
listeners.forEach((listener) => listener());
}
+function onStorage(event: StorageEvent): void {
+ if (event.key === STORAGE_KEY) {
+ reports = null;
+ ensureLoaded();
+ listeners.forEach((l) => l());
+ }
+}
+
export const reportStore = {
subscribe(listener: Listener): () => void {
ensureLoaded();
+ if (listeners.size === 0) window.addEventListener("storage", onStorage);
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);
+ if (listeners.size === 0) window.removeEventListener("storage", onStorage);
};
},
getSnapshot(): QuestionReport[] {
diff --git a/lib/schemas.ts b/lib/schemas.ts
index 7f43aa8..8aedb1b 100644
--- a/lib/schemas.ts
+++ b/lib/schemas.ts
@@ -105,16 +105,6 @@ export const generatedActivitySchema = z.discriminatedUnion("type", [
]);
export type GeneratedActivity = z.infer;
-export const generatedDeckSchema = z.object({
- title: z.string().min(1).max(120),
- subject: z.string().max(80).optional(),
- summary: z.string().max(400).optional(),
- /** Language of the generated content, e.g. "Czech" or "cs". */
- language: z.string().max(60).optional(),
- activities: z.array(generatedActivitySchema).min(4).max(20),
-});
-export type GeneratedDeck = z.infer;
-
/** What the vision step must return. */
export const extractedNotesSchema = z.object({
title: z.string().min(1).max(120),
@@ -197,12 +187,11 @@ export const homeworkStepSchema = z.object({
checkpoint: generatedActivitySchema.optional(),
});
-export const homeworkGuideSchema = z.object({
- title: z.string().min(1).max(200),
- language: z.string().max(60).optional(),
- steps: z.array(homeworkStepSchema).min(2).max(10),
-});
-export type HomeworkGuide = z.infer;
+export type HomeworkGuide = {
+ title: string;
+ language?: string;
+ steps: z.infer[];
+};
/* ------------------------------------------------------------------ */
/* Client-side homework types (checkpoints carry id/xp, plus storage) */
@@ -397,6 +386,7 @@ export const chatConversationSchema = z.object({
messages: z.array(chatMessageSchema).max(300),
scope: chatScopeSchema,
model: z.string().max(120).optional(),
+ provider: z.string().max(60).optional(),
createdAt: z.number(),
updatedAt: z.number(),
});
diff --git a/lib/share.ts b/lib/share.ts
index ca0398c..9685445 100644
--- a/lib/share.ts
+++ b/lib/share.ts
@@ -1,5 +1,6 @@
"use client";
+import { copyText } from "./clipboard";
import { exportDeck } from "./storage";
import { showToast } from "./toast";
import type { Deck, HomeworkRecord } from "./schemas";
@@ -21,18 +22,6 @@ function deckFile(deck: Deck): File {
});
}
-async function copyText(text: string): Promise {
- try {
- if (navigator.clipboard?.writeText) {
- await navigator.clipboard.writeText(text);
- return true;
- }
- } catch {
- /* fall through */
- }
- return false;
-}
-
export async function shareDeck(deck: Deck): Promise {
const nav = navigator as ShareNavigator;
const file = deckFile(deck);
diff --git a/lib/storage.ts b/lib/storage.ts
index ac9ffb8..0da1170 100644
--- a/lib/storage.ts
+++ b/lib/storage.ts
@@ -70,22 +70,22 @@ export function replaceDecks(next: Deck[]): void {
write(valid);
}
+function onStorage(event: StorageEvent): void {
+ if (event.key === STORAGE_KEY) {
+ decks = null;
+ ensureLoaded();
+ listeners.forEach((l) => l());
+ }
+}
+
export const deckStore = {
subscribe(listener: Listener): () => void {
ensureLoaded();
+ if (listeners.size === 0) window.addEventListener("storage", onStorage);
listeners.add(listener);
- // Another tab may have changed the library.
- const onStorage = (event: StorageEvent) => {
- if (event.key === STORAGE_KEY) {
- decks = null;
- ensureLoaded();
- listeners.forEach((l) => l());
- }
- };
- window.addEventListener("storage", onStorage);
return () => {
listeners.delete(listener);
- window.removeEventListener("storage", onStorage);
+ if (listeners.size === 0) window.removeEventListener("storage", onStorage);
};
},
getSnapshot(): Deck[] {
diff --git a/lib/sync-state.ts b/lib/sync-state.ts
index 86db1d4..7e8cb8e 100644
--- a/lib/sync-state.ts
+++ b/lib/sync-state.ts
@@ -78,21 +78,22 @@ function write(next: SyncRecord): void {
listeners.forEach((listener) => listener());
}
+function onStorage(event: StorageEvent): void {
+ if (event.key === STORAGE_KEY) {
+ record = null;
+ ensureLoaded();
+ listeners.forEach((l) => l());
+ }
+}
+
export const syncStore = {
subscribe(listener: Listener): () => void {
ensureLoaded();
+ if (listeners.size === 0) window.addEventListener("storage", onStorage);
listeners.add(listener);
- const onStorage = (event: StorageEvent) => {
- if (event.key === STORAGE_KEY) {
- record = null;
- ensureLoaded();
- listeners.forEach((l) => l());
- }
- };
- window.addEventListener("storage", onStorage);
return () => {
listeners.delete(listener);
- window.removeEventListener("storage", onStorage);
+ if (listeners.size === 0) window.removeEventListener("storage", onStorage);
};
},
getSnapshot(): SyncRecord {
diff --git a/lib/use-health.ts b/lib/use-health.ts
index d90bb49..b7ddd02 100644
--- a/lib/use-health.ts
+++ b/lib/use-health.ts
@@ -55,15 +55,20 @@ export function useHealth() {
const run = useCallback(async (force: boolean) => {
setLoading(true);
setError(null);
+ const key = settingsStore.getSnapshot().apiKey;
try {
- const key = settingsStore.getSnapshot().apiKey;
- setHealth(await loadHealth(key, force));
+ const result = await loadHealth(key, force);
+ if (settingsStore.getSnapshot().apiKey !== key) return;
+ setHealth(result);
} catch (loadError) {
+ if (settingsStore.getSnapshot().apiKey !== key) return;
setError(loadError instanceof Error ? loadError.message : t("api.unreachableShort"));
} finally {
- // Even a failed check is a check: the panel can say when it last ran.
- setCheckedAt(Date.now());
- setLoading(false);
+ if (settingsStore.getSnapshot().apiKey === key) {
+ // Even a failed check is a check: the panel can say when it last ran.
+ setCheckedAt(Date.now());
+ setLoading(false);
+ }
}
}, []);
diff --git a/lib/use-wake-lock.ts b/lib/use-wake-lock.ts
index c02b9a3..169ddfc 100644
--- a/lib/use-wake-lock.ts
+++ b/lib/use-wake-lock.ts
@@ -27,9 +27,14 @@ export function useWakeLock(active = true): void {
const acquire = async () => {
try {
- sentinel = await wakeLock.request("screen");
- sentinel.addEventListener("release", () => {
- sentinel = null;
+ const acquired = await wakeLock.request("screen");
+ if (disposed) {
+ void acquired.release().catch(() => {});
+ return;
+ }
+ sentinel = acquired;
+ acquired.addEventListener("release", () => {
+ if (sentinel === acquired) sentinel = null;
});
} catch {
/* denied or not allowed without a gesture — fine */
diff --git a/lib/utils.ts b/lib/utils.ts
index ce4787f..586a4f3 100644
--- a/lib/utils.ts
+++ b/lib/utils.ts
@@ -37,7 +37,9 @@ export function stripFences(text: string): string {
let value = text.trim();
const fence = value.match(/```(?:json)?\s*([\s\S]*?)```/i);
if (fence) value = fence[1].trim();
- if (value.startsWith("{") || value.startsWith("[")) return value;
+ if ((value.startsWith("{") && value.endsWith("}")) || (value.startsWith("[") && value.endsWith("]"))) {
+ return value;
+ }
const start = value.indexOf("{");
const end = value.lastIndexOf("}");
if (start !== -1 && end > start) return value.slice(start, end + 1);
diff --git a/lib/wizard-draft.ts b/lib/wizard-draft.ts
index 59b6418..e6a026c 100644
--- a/lib/wizard-draft.ts
+++ b/lib/wizard-draft.ts
@@ -27,7 +27,7 @@ function keyFor(scope: "deck" | "homework"): string {
export function saveWizardDraft(scope: "deck" | "homework", draft: Omit): void {
try {
- const images = (draft.images ?? []).filter(() => true);
+ const images = draft.images ?? [];
const size = images.reduce((sum, image) => sum + image.dataUrl.length, 0);
const payload: WizardDraft = {
v: 1,
diff --git a/package.json b/package.json
index 8d9ec00..1f16c39 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "freezmo",
- "version": "0.5.7",
+ "version": "0.5.8",
"private": true,
"license": "GPL-3.0-only",
"scripts": {
diff --git a/public/file.svg b/public/file.svg
deleted file mode 100644
index 004145c..0000000
--- a/public/file.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/public/globe.svg b/public/globe.svg
deleted file mode 100644
index 567f17b..0000000
--- a/public/globe.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/public/next.svg b/public/next.svg
deleted file mode 100644
index 5174b28..0000000
--- a/public/next.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/public/sw.js b/public/sw.js
index b2f3991..40afa76 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-v79";
+const CACHE_VERSION = "freezmo-v80";
const SHELL_CACHE = `${CACHE_VERSION}-shell`;
const ASSET_CACHE = `${CACHE_VERSION}-assets`;
@@ -131,7 +131,7 @@ self.addEventListener("fetch", (event) => {
if (response.ok) cache.put(request, response.clone()).catch(() => {});
return response;
})
- .catch(() => cached);
+ .catch(() => cached ?? Response.error());
return cached ?? network;
})(),
);
diff --git a/public/vercel.svg b/public/vercel.svg
deleted file mode 100644
index 7705396..0000000
--- a/public/vercel.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/public/window.svg b/public/window.svg
deleted file mode 100644
index b2b2a44..0000000
--- a/public/window.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/tests/regression.mjs b/tests/regression.mjs
index 8f57969..436161e 100644
--- a/tests/regression.mjs
+++ b/tests/regression.mjs
@@ -160,5 +160,54 @@ const browser = await launchBrowser();
await cs.context.close();
}
+/* 7. typed fill-blank + checked homework card ------------------------ */
+{
+ const typedDeck = {
+ ...DECK,
+ id: "typed_fill_deck",
+ activities: [
+ {
+ id: "act_typed_fill",
+ type: "fill_blank",
+ topic: "Optics",
+ sentence: "Short wavelengths scatter ____ than long ones.",
+ answer: "more",
+ explanation: "Blue light scatters more strongly.",
+ xp: 15,
+ },
+ ],
+ };
+ const { context, page } = await makePage(browser);
+ await seed(page, {
+ decks: [typedDeck],
+ extra: {
+ "freezmo.homework.v1": JSON.stringify([
+ {
+ id: "hw_check_1",
+ mode: "check",
+ title: "Linear equations check",
+ sourceText: "2x + 2 = 6",
+ verdict: "One sign error.",
+ correct: ["Good setup"],
+ wrong: [],
+ corrected: "x = 2",
+ createdAt: 1,
+ updatedAt: 2,
+ },
+ ]),
+ },
+ });
+ await page.goto(`${BASE}/deck/typed_fill_deck`, { waitUntil: "networkidle" });
+ await page.waitForTimeout(700);
+ const input = page.getByLabel("Type the missing word");
+ await input.fill("more");
+ check("fill-blank: typed input displays the value", (await input.inputValue()) === "more");
+
+ await page.goto(`${BASE}/?tab=homework`, { waitUntil: "networkidle" });
+ await page.waitForTimeout(700);
+ check("homework card: check mode shows Checked badge", await page.getByText("Checked").first().isVisible());
+ await context.close();
+}
+
await browser.close();
finish();