diff --git a/content/narrative/modules/figuren-motivation.yaml b/content/narrative/modules/figuren-motivation.yaml index 44866282..89903972 100644 --- a/content/narrative/modules/figuren-motivation.yaml +++ b/content/narrative/modules/figuren-motivation.yaml @@ -28,7 +28,7 @@ lesson: | > [!NOTE] So ist dieser Kurs gebaut > - **Unterwegs oder am PC.** Module mit der Marke „Unterwegs“ gehen auf dem Handy: lesen, analysieren, kurze Schreibaufgaben. „Am PC“ heißt längeres Schreiben oder ein Werkzeug; plan dafür Zeit am Rechner ein. - > - **Offline.** Reißt unterwegs die Verbindung ab, behält die Plattform deine Eingaben und überträgt sie, sobald du wieder Netz hast; schließ die Seite bis dahin nicht. Startest du die App ohne Netz („Offline weiterlesen“), kannst du lesen und üben, aber Antworten und Texte gelten nur bis zum Neuladen. Längere Gedanken schreibst du dann in deine Notizen-App. + > - **Offline.** Reißt unterwegs die Verbindung ab, behält die Plattform deine Eingaben und überträgt sie, sobald du wieder Netz hast; schließ die Seite bis dahin nicht. Startest du die App ohne Netz („Offline weiterlesen“), bleiben deine Texte in den Übungen auf dem Gerät, auch über ein Neuladen. Beim nächsten Start mit Netz kommen sie in deinen Lernstand. Hast du dieselbe Übung inzwischen auf einem anderen Gerät bearbeitet, bleibt der Text von dort, und die Übung bietet dir den offline geschriebenen zum Laden an. Notizen zur Lektion gibt es offline nicht. > - **Du schreibst selbst.** Es gibt keine Mustertexte; Checklisten helfen dir, deinen Text zu prüfen. KI nutzt du hier höchstens für Struktur, Checklisten und, wenn du willst, Rechtschreibung und Grammatik. > - **Du wählst die Beispiele.** Werke für Analysen suchst du dir selbst aus; die Lektionen machen Vorschläge. diff --git a/convex/progress.test.ts b/convex/progress.test.ts index 71623292..adfb324b 100644 --- a/convex/progress.test.ts +++ b/convex/progress.test.ts @@ -24,6 +24,7 @@ const endpoints = [ ['exportDrafts', (t: Client) => t.query(api.progress.exportDrafts, { paginationOpts: { numItems: 10, cursor: null } })], ['set', (t: Client) => t.mutation(api.progress.set, { areaId, generation: 0, changes: [lesson] })], ['saveDraft', (t: Client) => t.mutation(api.progress.saveDraft, { areaId, generation: 0, draft })], + ['adoptDraft', (t: Client) => t.mutation(api.progress.adoptDraft, { areaId, generation: 0, draft })], ['reviewCard', (t: Client) => t.mutation(api.progress.reviewCard, { areaId, generation: 0, cardId: 'first', grade: 'good', today: '2026-09-28' })], ['recordDrill', (t: Client) => t.mutation(api.progress.recordDrill, { areaId, generation: 0, exerciseId: 'intro/first', event: 'fail', review: false, today: '2026-09-28' })], ['importLegacy', (t: Client) => t.mutation(api.progress.importLegacy, imported)], @@ -85,6 +86,18 @@ describe('personal state', () => { expect((await t.query(api.progress.snapshot, {})).entries[0].value).toEqual(completion); expect((await t.query(api.progress.exportDrafts, { paginationOpts: { cursor: null, numItems: 16 } })).page).toHaveLength(1); }); + it('adopts an offline draft only where none is saved and never replaces a different one', async () => { + const t = create().withIdentity(identity); + expect(await t.mutation(api.progress.adoptDraft, { areaId, generation: 0, draft })).toBe('saved'); + expect(await t.mutation(api.progress.adoptDraft, { areaId, generation: 0, draft })).toBe('same'); + expect(await t.mutation(api.progress.adoptDraft, { areaId, generation: 0, draft: { ...draft, json: '"offline"' } })).toBe('different'); + expect(await t.mutation(api.progress.adoptDraft, { areaId, generation: 0, draft: { ...draft, fingerprint: 'def456' } })).toBe('different'); + expect(await t.query(api.progress.draft, { areaId, exerciseId: draft.exerciseId })).toEqual({ json: draft.json, fingerprint: draft.fingerprint }); + expect((await t.query(api.progress.exportDrafts, { paginationOpts: { cursor: null, numItems: 16 } })).page).toHaveLength(1); + await expect(t.mutation(api.progress.adoptDraft, { areaId, generation: 0, draft: { ...draft, json: '{bad' } })).rejects.toThrow(); + await t.mutation(api.progress.resetArea, { areaId, generation: 0 }); + await expect(t.mutation(api.progress.adoptDraft, { areaId, generation: 0, draft })).rejects.toThrow('PROGRESS_WAS_RESET'); + }); it('grades reviews from the latest stored state, rather than a stale client counter', async () => { const t = create(); const request = { areaId, generation: 0, cardId: 'ownership', grade: 'good' as const, today: '2026-09-28' }; diff --git a/convex/progress.ts b/convex/progress.ts index 1c02f329..ec9c5bb0 100644 --- a/convex/progress.ts +++ b/convex/progress.ts @@ -102,6 +102,20 @@ export const saveDraft = mutation({ }, }); +/** A draft typed in offline reading mode: stored only where none is saved, so it never replaces a different one. */ +export const adoptDraft = mutation({ + args: { areaId: v.string(), generation: v.number(), draft: draftInput }, + handler: async (ctx, { areaId, generation, draft }): Promise<'saved' | 'same' | 'different'> => { + await requireOwner(ctx); + await checkGeneration(ctx, areaId, generation); + validateDraft(draft); + const existing = await ctx.db.query('drafts').withIndex('by_area_generation_exercise', (q) => q.eq('areaId', areaId).eq('generation', generation).eq('exerciseId', draft.exerciseId)).unique(); + if (existing) return existing.json === draft.json && existing.fingerprint === draft.fingerprint ? 'same' : 'different'; + await ctx.db.insert('drafts', { areaId, generation, ...draft, updatedAt: Date.now() }); + return 'saved'; + }, +}); + export const exportDrafts = query({ args: { paginationOpts: paginationOptsValidator }, handler: async (ctx, { paginationOpts }) => { diff --git a/docs/CONVEX.md b/docs/CONVEX.md index ad998e8a..20ed3d18 100644 --- a/docs/CONVEX.md +++ b/docs/CONVEX.md @@ -44,7 +44,7 @@ Es gibt absichtlich **einen persönlichen Datenbestand**, keine Daten pro regist **Kartenwiederholung.** Die Regeln stehen allein in `src/engine/review.ts` (`schedule`), das Client (optimistisch) und `reviewCard` (maßgeblich) gemeinsam nutzen: Box 1–5 mit 1, 3, 7, 16 und 35 Tagen; „Vergessen“ → Box 1 und heute fällig, „Schwer“ → gleiche Box, „Okay“ → eine Box höher, „Leicht“ → zwei. Fällige Karten werden schwächste zuerst abgefragt (niedrige Box, häufig vergessen, lange überfällig). `cardReviews` hält jede Bewertung mit Tag fest, sodass sich ein anderer Algorithmus später aus der Historie neu berechnen lässt. Die Historie ist nicht in der JSON-Sicherung enthalten; ein Import übernimmt nur den Kartenstand. -`convex/auth.ts::requireOwner` prüft die von Convex validierte Identität auf **Issuer, Subject und deren tokenIdentifier**. Convex selbst prüft Signatur, Ablauf und Audience anhand von `auth.config.ts`. Alle dreizehn öffentlichen Endpunkte beginnen mit diesem Guard; keiner nimmt eine Eigentümer-ID entgegen: +`convex/auth.ts::requireOwner` prüft die von Convex validierte Identität auf **Issuer, Subject und deren tokenIdentifier**. Convex selbst prüft Signatur, Ablauf und Audience anhand von `auth.config.ts`. Alle vierzehn öffentlichen Endpunkte beginnen mit diesem Guard; keiner nimmt eine Eigentümer-ID entgegen: | Endpunkt | Zweck | | --- | --- | @@ -53,6 +53,7 @@ Es gibt absichtlich **einen persönlichen Datenbestand**, keine Daten pro regist | `exportDrafts` | Begrenzte Seiten für eine JSON-Sicherung | | `set` | Gezielt einzelne Fortschrittsfelder ändern; kein Überschreiben ganzer Bereiche | | `saveDraft` | Einen versionierten Entwurf ersetzen | +| `adoptDraft` | Einen offline geschriebenen Entwurf nur speichern, wenn keiner gespeichert ist; meldet `saved`, `same` oder `different` | | `reviewCard` | Eine Bewertung (`again`, `hard`, `good`, `easy`) atomar auf den letzten Serverstand anwenden und in `cardReviews` protokollieren. Das frühere `knew` wird für vor dem Update geöffnete Tabs noch angenommen | | `recordDrill` | Ein Übungsereignis (`fail`, `hint`, `reveal`, `solve`) atomar auf den letzten Serverstand anwenden und in `drillEvents` protokollieren; `review` kennzeichnet Wiederholungsrunden | | `importLegacy` | Begrenzt große, wiederholbar sichere Import-Chunks | @@ -68,7 +69,7 @@ Die Backend-Validierung prüft Typen, IDs, Datumswerte, JSON-Struktur, Größen ## Frontend, Reaktivität und Konflikte -`src/main.ts` stellt die Clerk-Sitzung her und wartet auf eine erfolgreiche autorisierte Convex-Abfrage, bevor `shell.ts` die Lernoberfläche startet. Abmeldung/Sitzungswechsel schließen den Client und entfernen die Ansicht samt privatem Arbeitsspeicher durch Neuladen. Einzige lokale Kopie privater Daten ist die schreibgeschützte Offline-Kopie des Lernstands (siehe [Offline](#offline-und-installierbare-app)); Entwürfe und Notizen werden nie lokal gespeichert. +`src/main.ts` stellt die Clerk-Sitzung her und wartet auf eine erfolgreiche autorisierte Convex-Abfrage, bevor `shell.ts` die Lernoberfläche startet. Abmeldung/Sitzungswechsel schließen den Client und entfernen die Ansicht samt privatem Arbeitsspeicher durch Neuladen. Lokale Kopien privater Daten sind nur die schreibgeschützte Offline-Kopie des Lernstands und Entwürfe, die im Offline-Lesemodus entstehen, bis zu ihrer Übernahme (siehe [Offline](#offline-und-installierbare-app)). Online geschriebene Entwürfe und Notizen werden nie lokal gespeichert. `src/app.ts` übersetzt Convex-Abonnements in das bestehende `AreaProgress`-Modell. Mutationen nutzen die optimistischen Updates des Convex-JavaScript-Clients; kein eigener Offline-Sync-Dienst. Die geöffneten Editoren und Kartenrunden werden bei Serverupdates nicht neu aufgebaut. Ein fremd geänderter Entwurf wird mit einem kleinen „Laden“-Button angeboten; während des Tippens bleibt der aktuelle Text stehen. @@ -88,9 +89,11 @@ Die Plattform ist eine installierbare PWA (`public/manifest.webmanifest`, Logo ` **Konflikte nach dem Wiederverbinden.** Es gelten die Regeln oben: Einträge werden einzeln geschrieben, beim selben Eintrag gewinnt die zuletzt verarbeitete explizite Änderung. Kartenreviews rechnet der Server aus dem aktuellen Stand. Schreibvorgänge in einen inzwischen zurückgesetzten Bereich lehnt die Reset-Generation ab. Zusätzlich gilt für Entwürfe: Wurde eine Übung geöffnet, bevor ihr gespeicherter Entwurf geladen war, hält die App den getippten Text zurück. Fehlt auf dem Server ein Entwurf oder ist er gleich, wird der Text gespeichert. Liegt ein anderer Entwurf vor, bleibt er erhalten und wird mit „Geänderter Entwurf verfügbar · Laden“ angeboten; erst Weitertippen überschreibt ihn bewusst. Wer die Übung vorher verlässt, speichert den Text wie bisher (letzte Änderung gewinnt). -**Offline starten.** Nach jeder bestätigten Synchronisierung legt die App eine Kopie des Serverstands ohne Entwürfe, ohne Notizen und ohne optimistische Änderungen in LocalStorage ab (`learn-offline:snapshot:v1`). Startet der Browser ohne Netz, zeigt er damit Lerninhalte und den Lernstand zu diesem Zeitpunkt. Clerk und Convex werden dabei nicht kontaktiert. Ist das Netz nur schlecht oder Clerk nicht erreichbar, bietet das Anmeldetor „Offline weiterlesen“ an. Dieser Lesemodus schreibt nichts: Häkchen, Antworten und Karten gelten nur bis zum Neuladen, Daten-Export, Import und Reset sind gesperrt. Ein Hinweis sagt das; „Erneut verbinden“ lädt die Seite mit Anmeldung neu. Ohne Kopie zeigt ein offline gestarteter Browser nur „Keine Verbindung“ und lädt neu, sobald das Netz zurück ist. +**Offline starten.** Nach jeder bestätigten Synchronisierung legt die App eine Kopie des Serverstands ohne Entwürfe, ohne Notizen und ohne optimistische Änderungen in LocalStorage ab (`learn-offline:snapshot:v1`). Startet der Browser ohne Netz, zeigt er damit Lerninhalte und den Lernstand zu diesem Zeitpunkt. Clerk und Convex werden dabei nicht kontaktiert. Ist das Netz nur schlecht oder Clerk nicht erreichbar, bietet das Anmeldetor „Offline weiterlesen“ an. In diesem Lesemodus gelten Häkchen, gelöste Übungen und Karten nur bis zum Neuladen; Daten-Export, Import und Reset sind gesperrt, Notizen nicht verfügbar. Ein Hinweis sagt das; „Erneut verbinden“ lädt die Seite mit Anmeldung neu. Ohne Kopie zeigt ein offline gestarteter Browser nur „Keine Verbindung“ und lädt neu, sobald das Netz zurück ist. -Die Kopie ist keine Autorisierung und keine Datenquelle für Convex. Sie wird gelöscht bei Abmeldung, bei „Kein Zugriff“ und wenn Clerk online bestätigt, dass keine Sitzung besteht. Ist Clerk nicht erreichbar, meldet es ebenfalls keine Sitzung (ohne Client-ID); das löscht die Kopie nicht. Auf fremden Geräten deshalb abmelden. Der Offline-Start mit echter Clerk-Sitzung, eine Unterbrechung über mehr als eine Minute (Token-Ablauf) und die Installation auf Android und iOS lassen sich nur mit dem echten Konto prüfen. Die automatisierten Tests decken den Service Worker im Produktionsbuild (`pnpm test:pwa:e2e`) sowie Unterbrechungen und Entwurfskonflikte gegen lokales Convex ab. +**Entwürfe aus dem Offline-Lesemodus.** Eingaben in Übungen (Texte, Lücken, Auswahl, abgehakte Kriterien, also alles, was online als Entwurf gespeichert wird) legt der Lesemodus zusätzlich in LocalStorage ab: ein Schlüssel je Bereich, Übung und Fingerprint (`learn-offline:draft:v1::<übung>:`), höchstens 60 KB je Entwurf wie online. Nach einem Neuladen ohne Netz stehen sie wieder in der Übung. Beim nächsten autorisierten Start übergibt `src/app.ts` jeden lokalen Entwurf an `adoptDraft`. Fehlt auf dem Server ein Entwurf, wird der lokale gespeichert; ist er gleich, ist nichts zu tun. In beiden Fällen verschwindet die lokale Kopie. Liegt ein anderer Entwurf vor (anderer Text oder anderer Fingerprint), bleibt er gespeichert, und die Übung bietet den lokalen mit „Offline geschriebener Entwurf verfügbar · Laden“ an. Dasselbe gilt, wenn der Bereich nach der Offline-Kopie zurückgesetzt wurde: Der Entwurf trägt die Reset-Generation seiner Kopie und kommt nach einem Reset nicht von selbst zurück. „Laden“ ersetzt nach einer Rückfrage den gespeicherten Entwurf; die lokale Kopie verschwindet, sobald der Server ihn bestätigt hat. „Verwerfen“ löscht sie nach einer Rückfrage. Bis dahin bleibt sie liegen und wird bei jedem Start erneut angeboten. Passt ihr Fingerprint nicht mehr zur Übung, weil sich der Inhalt inzwischen geändert hat, keine Übung würde sie also anzeigen, bleibt sie ungenutzt auf dem Gerät, bis du dich abmeldest. Ob ein Entwurf gespeichert wird, entscheidet der Server in einer Transaktion; eine eigene Sync-Engine gibt es nicht. Der Start wartet bis zu drei Sekunden auf diese Übernahme, damit eine sofort geöffnete Übung den übernommenen Text schon zeigt. Gelöscht wird eine lokale Kopie nur, solange sie noch denselben Text enthält; was ein offener Offline-Tab inzwischen weitergeschrieben hat, bleibt. + +Die Kopie ist keine Autorisierung und keine Datenquelle für Convex. Sie wird gelöscht bei Abmeldung, bei „Kein Zugriff“ und wenn Clerk online bestätigt, dass keine Sitzung besteht. Ist Clerk nicht erreichbar, meldet es ebenfalls keine Sitzung (ohne Client-ID); das löscht die Kopie nicht. Offline geschriebene Entwürfe löscht nur die Abmeldung; sind noch welche nicht übernommen, fragt sie vorher nach. Eine abgelaufene Sitzung oder ein falsches Konto verliert sie also nicht, und sie gehen nur an ein freigeschaltetes Konto, weil `adoptDraft` wie jeder Endpunkt den Eigentümer prüft. Auf fremden Geräten deshalb abmelden. Der Offline-Start mit echter Clerk-Sitzung, eine Unterbrechung über mehr als eine Minute (Token-Ablauf) und die Installation auf Android und iOS lassen sich nur mit dem echten Konto prüfen. Die automatisierten Tests decken den Service Worker im Produktionsbuild (`pnpm test:pwa:e2e`, auch Offline-Entwürfe über ein Neuladen) sowie Unterbrechungen, Entwurfskonflikte und die Übernahme offline geschriebener Entwürfe gegen lokales Convex ab. ## Stabile IDs und Inhaltsänderungen @@ -143,7 +146,7 @@ pnpm content:check pnpm bear:check pnpm verify pnpm test:e2e # Bestehende Lernabläufe mit lokalem Regressionstest-Adapter -pnpm test:pwa:e2e # Produktionsbuild: Manifest, Service Worker, Offline-Lesen +pnpm test:pwa:e2e # Produktionsbuild: Manifest, Service Worker, Offline-Lesen, Offline-Entwürfe pnpm build ``` @@ -174,5 +177,5 @@ Ergebnisse der Implementierungsprüfung, behobene Schwachstellen und verbleibend | Verbindung wird hergestellt | Richtige `.convex.cloud`-URL (nicht `.convex.site`), WebSocket-Verbindung/Firewall, Convex-Prozess und Deploymentstatus prüfen. | | Fortschritt fehlt auf zweitem Gerät | Gleiche Produktions-URL/Deployment und freigeschaltetes Konto, Legacy-Import noch ausstehend, bestätigte Synchronisierung vor Schließen? | | Legacy-Import nach Reset abgelehnt | Beabsichtigter Schutz. Falls gewünscht, JSON-Sicherung explizit wiederherstellen. | -| Entwurf nicht gespeichert | Verbindung und Anmeldung prüfen, bei Größenfehler Text sichern/unter 60 KB reduzieren. | +| Entwurf nicht gespeichert | Verbindung und Anmeldung prüfen, bei Größenfehler Text sichern/unter 60 KB reduzieren. Im Offline-Lesemodus: Speicher des Browsers voll oder gesperrt (privates Fenster)? | | Ungültige Übung nach Curriculumänderung | IDs/Fingerprints prüfen; unveränderte IDs beibehalten, entfernte Inhalte bleiben nur im Backup. | diff --git a/e2e/convex/sync.spec.ts b/e2e/convex/sync.spec.ts index 653836e1..72e9116a 100644 --- a/e2e/convex/sync.spec.ts +++ b/e2e/convex/sync.spec.ts @@ -177,6 +177,40 @@ test('a draft typed offline does not silently replace a different draft saved on await phone.close(); }); +test('drafts kept in offline reading mode are adopted on the next start without replacing a different saved draft', async ({ page }) => { + const [first, other, third] = loadContent(['kotlin']).areas.kotlin.modules['bear-01'].exercises; + const gap = (text: string) => JSON.stringify({ g1: text }); + const generation = (await http.query(api.progress.snapshot, {})).resets.find((r) => r.areaId === 'kotlin')?.generation ?? 0; + await http.mutation(api.progress.saveDraft, { areaId: 'kotlin', generation, draft: { exerciseId: other.id, fingerprint: other.fingerprint, json: gap('"Bo') } }); + const key = (e: typeof first) => `learn-offline:draft:v1:kotlin:${e.id}:${e.fingerprint}`; + const local = (e: typeof first, text: string, since = generation) => [key(e), JSON.stringify({ areaId: 'kotlin', exerciseId: e.id, fingerprint: e.fingerprint, json: gap(text), generation: since, savedAt: 1 })]; + const stored = () => page.evaluate(() => Object.keys(localStorage).filter((k) => k.startsWith('learn-offline:draft:')).sort()); + await page.goto('/'); await synced(page); + await page.evaluate((items) => items.forEach(([k, v]) => localStorage.setItem(k, v)), [local(first, 'val'), local(other, '"Be'), local(third, 'vor dem Reset', generation - 1)]); + // The next authorized start hands them to Convex: the missing one is saved, the different one is only offered, + // and one typed before a reset of the area is offered as well instead of coming back on its own. + await page.goto(`${base}/1`); await synced(page); + expect(await http.query(api.progress.draft, { areaId: 'kotlin', exerciseId: first.id })).toMatchObject({ json: gap('val') }); + expect(await http.query(api.progress.draft, { areaId: 'kotlin', exerciseId: third.id })).toBeNull(); + expect(await stored()).toEqual([key(other), key(third)].sort()); + await page.reload(); await expect(page.locator('#answer-g1')).toHaveValue('val'); + await expect(page.locator('#offline-draft')).toHaveCount(0); + await page.goto(`${base}/2`); await expect(page.locator('#answer-g1')).toHaveValue('"Bo'); + expect(await http.query(api.progress.draft, { areaId: 'kotlin', exerciseId: other.id })).toMatchObject({ json: gap('"Bo') }); + page.once('dialog', (dialog) => dialog.accept()); + await page.getByRole('button', { name: 'Offline geschriebener Entwurf verfügbar · Laden' }).click(); + await expect(page.locator('#answer-g1')).toHaveValue('"Be'); await synced(page); + await expect(page.locator('#offline-draft')).toHaveCount(0); + expect(await http.query(api.progress.draft, { areaId: 'kotlin', exerciseId: other.id })).toMatchObject({ json: gap('"Be') }); + await expect.poll(stored).toEqual([key(third)]); + await page.goto(`${base}/3`); await expect(page.locator('#answer-g1')).toHaveValue(''); + page.once('dialog', (dialog) => dialog.accept()); + await page.getByRole('button', { name: 'Verwerfen' }).click(); + await expect(page.locator('#offline-draft')).toHaveCount(0); + expect(await stored()).toEqual([]); + expect(await http.query(api.progress.draft, { areaId: 'kotlin', exerciseId: third.id })).toBeNull(); +}); + test('lesson notes autosave while typing, reach another device and survive a reset', async ({ page, browser }) => { const phone = await browser.newContext({ viewport: { width: 390, height: 844 } }); await fixture(phone); const other = await phone.newPage(); await page.goto(base); await other.goto(base); diff --git a/e2e/pwa/offline.spec.ts b/e2e/pwa/offline.spec.ts index e9883b84..adcab584 100644 --- a/e2e/pwa/offline.spec.ts +++ b/e2e/pwa/offline.spec.ts @@ -36,7 +36,7 @@ test('a browser that was signed in before reads lessons offline with its last sy await context.setOffline(true); await page.goto(`/python/${first.id}`); await expect(page.locator('#sync-notice')).toContainText('Offline-Modus'); - await expect(page.locator('#sync-notice')).toContainText('Änderungen werden hier nicht gespeichert'); + await expect(page.locator('#sync-notice')).toContainText('Häkchen, gelöste Übungen und Karten gelten nur bis zum Neuladen'); await expect(page.locator('#storage-status')).toContainText('Offline · Lernstand vom'); await expect(page.locator('#sign-out')).toBeHidden(); await expect(page.locator('h1')).toContainText(first.title); @@ -59,6 +59,30 @@ test('a browser that was signed in before reads lessons offline with its last sy expect(errors).toEqual([]); }); +test('texts typed in offline reading mode survive a reload and wait on the device for the next sign-in', async ({ page, context }) => { + const module = loadContent(['narrative']).areas.narrative.modules['figuren-motivation']; + const index = module.exercises.findIndex((e) => e.type === 'practice' && e.lang === 'prose'); + const exercise = module.exercises[index]; + const text = 'Mara will bis Freitag den Mietvertrag unterschreiben.'; + const stored = () => page.evaluate(() => Object.keys(localStorage).filter((key) => key.startsWith('learn-offline:draft:'))); + await installed(page); + await page.evaluate((value) => localStorage.setItem('learn-offline:snapshot:v1', JSON.stringify(value)), copy); + await context.setOffline(true); + await page.goto(`/narrative/${module.id}/${index + 1}`); + await expect(page.locator('#sync-notice')).toContainText('Eingaben in Übungen bleiben auf diesem Gerät, bis du mit Netz startest'); + await page.locator('#editor').fill(text); + await page.reload(); + await expect(page.locator('#sync-notice')).toContainText('Offline-Modus'); + await expect(page.locator('#editor')).toHaveValue(text); + // Back online. The placeholder sign-in fails like an unreachable Clerk; the text must stay for the next authorized start. + await context.setOffline(false); + await page.locator('#reconnect').click(); + await expect(page.getByRole('button', { name: 'Offline weiterlesen' })).toBeVisible(); + expect(await stored()).toEqual([`learn-offline:draft:v1:narrative:${exercise.id}:${exercise.fingerprint}`]); + await page.getByRole('button', { name: 'Offline weiterlesen' }).click(); + await expect(page.locator('#editor')).toHaveValue(text); +}); + test('offline without an earlier sign-in stays closed', async ({ page, context }) => { await installed(page); await context.setOffline(true); diff --git a/src/app.ts b/src/app.ts index 0d39c9ac..ef7806ac 100644 --- a/src/app.ts +++ b/src/app.ts @@ -4,13 +4,13 @@ import loaders from 'virtual:area-loaders'; import type { ConvexClient } from 'convex/browser'; import type { FunctionReturnType } from 'convex/server'; import { api } from '../convex/_generated/api.js'; -import { applyEntries, generationOf, validateDraft, validateEntry, validateNote, type DrillEvent, type DraftInput, type Entry, type Grade, type NoteInput, type Position, type Snapshot } from '../convex/model.ts'; +import { applyEntries, assertId, generationOf, validateDraft, validateEntry, validateNote, type DrillEvent, type DraftInput, type Entry, type Grade, type NoteInput, type Position, type Snapshot } from '../convex/model.ts'; import type { Area, AreaSummary, Exercise } from './content/types.ts'; import { freshProgress, localDay, type AreaProgress, type Backup } from './engine/storage.ts'; import { schedule } from './engine/review.ts'; import { drill, drillBase } from './engine/drill.ts'; import { latestArea, projectSnapshot, safeDraft, sameEntry, toEntries } from './engine/persistence.ts'; -import { browserStorage, readOfflineCopy, writeOfflineCopy } from './engine/offline.ts'; +import { browserStorage, readOfflineCopy, readOfflineDrafts, removeOfflineDraft, writeOfflineCopy, writeOfflineDraft, type OfflineDraft } from './engine/offline.ts'; export { catalog }; const areas = new Map(); @@ -24,7 +24,7 @@ let connected = false; let unreachable = false; let downSince: number | null = null; let copyTimer: ReturnType | undefined; -/** Offline reading mode: time of the shown snapshot copy; nothing is written. */ +/** Offline reading mode: time of the shown snapshot copy; only drafts are kept, on this device (see saveAnswer). */ let offlineSince: number | null = null; let active = false; let pending = 0; @@ -36,6 +36,11 @@ let cancelDraftLoad: (() => void) | undefined; // Until the open exercise's saved draft has arrived, a typed draft is held instead of blindly replacing it. let draftKnown = true; let heldDraft: { json: string; send: () => void } | undefined; +// Drafts typed in offline reading mode that differ from the saved one, by area, exercise and fingerprint. Their exercise +// offers them until they are loaded or discarded; the copy on this device stays until then. +const offered = new Map(); +const offerKey = (areaId: string, exerciseId: string, fingerprint: string) => `${areaId}:${exerciseId}:${fingerprint}`; +const validOfflineDraft = (draft: OfflineDraft) => { assertId(draft.areaId); validateDraft(draft); }; // Lessons with notes by area. Typed note text is sent after a short pause; confirmed text reaches the open lesson // only once none of its own writes is outstanding, so server echoes never replace newer typing. let noted = new Map>(); @@ -67,7 +72,7 @@ export const getSyncStatus = () => offlineSince !== null ? `Offline · Lernstand : waiting() ? `${changes(waiting())} ${waiting() === 1 ? 'wird' : 'werden'} gespeichert …` : !connected ? 'Verbindung wird wiederhergestellt …' : 'Lernstand synchronisiert'; /** Explains a lasting connection problem above the page; null while everything is (or is about to be) synchronized. */ export function getSyncNotice(): string | null { - if (offlineSince !== null) return `Offline-Modus: Lerninhalte und dein Lernstand vom ${stamp(offlineSince)}. Änderungen werden hier nicht gespeichert.`; + if (offlineSince !== null) return `Offline-Modus, Lernstand vom ${stamp(offlineSince)}. Eingaben in Übungen bleiben auf diesem Gerät, bis du mit Netz startest. Häkchen, gelöste Übungen und Karten gelten nur bis zum Neuladen.`; if (!unreachable) return null; const n = waiting(); return n ? `Keine Verbindung. ${changes(n)} ${n === 1 ? 'ist' : 'sind'} noch nicht synchronisiert und ${n === 1 ? 'wird' : 'werden'} automatisch gespeichert, sobald die Verbindung zurück ist. Lass diese Seite bis dahin geöffnet.` @@ -152,13 +157,55 @@ export async function connectProgress(convex: ConvexClient, denied: () => void): noted = next; if (!same) progressListeners.forEach((fn) => fn(false)); }, () => {}); + // A page opened right away should already see adopted drafts; a slow server does not hold up the start for long. + await Promise.race([adoptOfflineDrafts(), new Promise((resolve) => setTimeout(resolve, 3000))]); return () => { active = false; ++draftToken; stopSnapshot(); stopNotes(); stopConnection(); stopDraft?.(); clearTimeout(copyTimer); clearTimeout(queuedNote?.timer); queuedNote = undefined; openNote = undefined; - progress.clear(); snapshot = { entries: [], resets: [] }; noted = new Map(); + progress.clear(); snapshot = { entries: [], resets: [] }; noted = new Map(); offered.clear(); }; } +/** + * Hands drafts typed in offline reading mode to Convex after an authorized start. The server stores one only where no + * draft is saved; an identical one just clears the copy here, a different one stays saved and the offline one is offered. + */ +async function adoptOfflineDrafts(): Promise { + const store = browserStorage(); + await Promise.allSettled(readOfflineDrafts(store, validOfflineDraft).map(async (local) => { + const { areaId, exerciseId, fingerprint, json } = local; + // Saved for changed content, no exercise would show it; it stays on this device instead (see docs/CONVEX.md). + if (summaryOf(areaId)?.modules.flatMap((m) => m.exercises).find((e) => e.id === exerciseId)?.fingerprint !== fingerprint) return; + // The area was reset after the offline copy, which deleted its drafts on purpose: offer, never save on its own. + const generation = generationOf(snapshot, areaId); + const result = local.generation === generation ? await track(client.mutation(api.progress.adoptDraft, { areaId, generation, draft: { exerciseId, fingerprint, json } })) : 'different'; + if (!active) return; + if (result === 'different') offered.set(offerKey(areaId, exerciseId, fingerprint), local); + else removeOfflineDraft(store, local); + })); + if (active) progressListeners.forEach((fn) => fn(false)); +} +/** The offline draft offered for this exercise, if any (see adoptOfflineDrafts). */ +export function offlineDraftFor(areaId: string, exercise: Exercise): unknown { + const local = offered.get(offerKey(areaId, exercise.id, exercise.fingerprint)); + return local && safeDraft(exercise, JSON.parse(local.json)); +} +/** Saves the offered offline draft as this exercise's draft (or drops it) and then forgets the copy on this device. */ +export function settleOfflineDraft(areaId: string, exercise: Exercise, use: boolean): void { + const key = offerKey(areaId, exercise.id, exercise.fingerprint); + const local = offered.get(key); + if (!local) return; + const value = offlineDraftFor(areaId, exercise); + offered.delete(key); + const store = browserStorage(); + if (!use || value === undefined) { removeOfflineDraft(store, local); return; } + // Loading is an explicit choice: text typed before the saved draft arrived must not replace it on leaving. + heldDraft = undefined; statusChanged(); + progressOf(areaId).drafts[exercise.id] = value; + // The copy here goes only once the server has the text; otherwise the next start offers it again. + void sendDraft(areaId, generationOf(snapshot, areaId), { exerciseId: local.exerciseId, fingerprint: local.fingerprint, json: local.json }).then(() => removeOfflineDraft(store, local), () => {}); +} + function track(work: Promise): Promise { pending++; inflight.add(work); statusChanged(); return work.catch((error: unknown) => { @@ -221,6 +268,12 @@ export function closeDraft(): void { } export async function prepareDraft(areaId: string, exercise: Exercise): Promise { closeDraft(); + if (offlineSince !== null) { + // Reading mode: a text typed here before the last reload waits on this device. + const local = readOfflineDrafts(browserStorage(), validOfflineDraft).find((d) => d.areaId === areaId && d.exerciseId === exercise.id && d.fingerprint === exercise.fingerprint); + const value = local && safeDraft(exercise, JSON.parse(local.json)); + if (value !== undefined) progressOf(areaId).drafts[exercise.id] = value; + } if (!active) return; const token = draftToken; draftArea = areaId; @@ -252,18 +305,23 @@ export function saveAnswer(areaId: string, exercise: Exercise, value: unknown): try { validateDraft(draft); } catch { warning = 'Dieser Entwurf ist zu groß oder ungültig. Maximal 60 KB pro Übung; bitte kopiere deinen Text, bevor du die Seite verlässt.'; statusChanged(); return; } progressOf(areaId).drafts[exercise.id] = structuredClone(value); + if (offlineSince !== null && !writeOfflineDraft(browserStorage(), { areaId, ...draft, generation: generationOf(snapshot, areaId), savedAt: Date.now() })) { + warning = 'Dieser Entwurf konnte auf diesem Gerät nicht gespeichert werden. Bitte kopiere deinen Text, bevor du die Seite verlässt.'; statusChanged(); + } if (!active) return; const generation = generationOf(snapshot, areaId); - const send = () => void track(client.mutation(api.progress.saveDraft, { areaId, generation, draft }, { - optimisticUpdate(store) { - const current = store.getQuery(api.progress.snapshot, {}); - if (current && generationOf(current, areaId) === generation) store.setQuery(api.progress.draft, { areaId, exerciseId: exercise.id }, { json: draft.json, fingerprint: draft.fingerprint }); - }, - })).catch(() => {}); + const send = () => void sendDraft(areaId, generation, draft).catch(() => {}); if (draftKnown) send(); else { heldDraft = { json: draft.json, send }; statusChanged(); } } +const sendDraft = (areaId: string, generation: number, draft: DraftInput) => track(client.mutation(api.progress.saveDraft, { areaId, generation, draft }, { + optimisticUpdate(store) { + const current = store.getQuery(api.progress.snapshot, {}); + if (current && generationOf(current, areaId) === generation) store.setQuery(api.progress.draft, { areaId, exerciseId: draft.exerciseId }, { json: draft.json, fingerprint: draft.fingerprint }); + }, +})); + /** Shows the saved note of one lesson, first on arrival and later for changes made elsewhere. */ export function watchNote(areaId: string, moduleId: string, show: (text: string) => void): () => void { if (!active) return () => {}; diff --git a/src/engine/offline.test.ts b/src/engine/offline.test.ts index c9bc0721..93a0b639 100644 --- a/src/engine/offline.test.ts +++ b/src/engine/offline.test.ts @@ -1,10 +1,13 @@ import { describe, expect, it } from 'vitest'; -import { validateEntry, type Snapshot } from '../../convex/model.ts'; -import { OFFLINE_KEY, clearOfflineCopy, hasOfflineCopy, readOfflineCopy, waitForToken, writeOfflineCopy } from './offline.ts'; +import { assertId, validateDraft, validateEntry, type Snapshot } from '../../convex/model.ts'; +import { OFFLINE_DRAFT_PREFIX, OFFLINE_KEY, clearOfflineCopy, clearOfflineDrafts, hasOfflineCopy, readOfflineCopy, readOfflineDrafts, removeOfflineDraft, waitForToken, writeOfflineCopy, writeOfflineDraft, type OfflineDraft } from './offline.ts'; function memoryStorage() { const data = new Map(); - return { data, getItem: (key: string) => data.get(key) ?? null, setItem: (key: string, value: string) => { data.set(key, value); }, removeItem: (key: string) => { data.delete(key); } } as unknown as Storage & { data: Map }; + return { + data, getItem: (key: string) => data.get(key) ?? null, setItem: (key: string, value: string) => { data.set(key, value); }, removeItem: (key: string) => { data.delete(key); }, + key: (i: number) => [...data.keys()][i] ?? null, get length() { return data.size; }, + } as unknown as Storage & { data: Map }; } describe('offline copy', () => { @@ -43,6 +46,42 @@ describe('offline copy', () => { }); }); +describe('offline drafts', () => { + const validate = (d: OfflineDraft) => { assertId(d.areaId); validateDraft(d); }; + const text: OfflineDraft = { areaId: 'narrative', exerciseId: 'figuren-motivation/eigene-figur', fingerprint: 'abc123', json: '{"code":"Im Bus geschrieben","checked":[]}', generation: 0, savedAt: 5 }; + it('keeps one draft per area, exercise and fingerprint next to the offline copy and outside the legacy pattern', () => { + const store = memoryStorage(); + writeOfflineCopy(store, { entries: [], resets: [] }); + expect(writeOfflineDraft(store, text)).toBe(true); + expect(writeOfflineDraft(store, { ...text, json: '{"code":"weiter","checked":[]}', savedAt: 6 })).toBe(true); + expect(writeOfflineDraft(store, { ...text, fingerprint: 'def456' })).toBe(true); + expect(readOfflineDrafts(store, validate)).toEqual([{ ...text, json: '{"code":"weiter","checked":[]}', savedAt: 6 }, { ...text, fingerprint: 'def456' }]); + expect([...store.data.keys()].filter((key) => /^learn:[a-z0-9-]+:v1$/.test(key))).toEqual([]); + // Only the text that was handed over is forgotten, never newer text typed meanwhile. + removeOfflineDraft(store, text); + expect(readOfflineDrafts(store, validate)).toHaveLength(2); + removeOfflineDraft(store, { ...text, json: '{"code":"weiter","checked":[]}', savedAt: 6 }); + expect(readOfflineDrafts(store, validate).map((d) => d.fingerprint)).toEqual(['def456']); + clearOfflineDrafts(store); + expect(readOfflineDrafts(store, validate)).toEqual([]); + expect(hasOfflineCopy(store)).toBe(true); + }); + it('skips unreadable, oversized or misplaced drafts and ignores blocked storage', () => { + const store = memoryStorage(); + store.setItem(`${OFFLINE_DRAFT_PREFIX}bad`, '{bad'); + writeOfflineDraft(store, { ...text, json: '"' + 'x'.repeat(60_000) + '"' }); + writeOfflineDraft(store, { ...text, areaId: 'Bad Area' }); + writeOfflineDraft(store, { ...text, exerciseId: 'figuren-motivation/ohne-generation', generation: 1.5 }); + store.setItem(`${OFFLINE_DRAFT_PREFIX}narrative:other/id:abc123`, JSON.stringify(text)); + expect(readOfflineDrafts(store, validate)).toEqual([]); + const blocked = { get length(): number { throw new Error('blocked'); }, key: () => null, getItem: () => { throw new Error('blocked'); }, setItem: () => { throw new Error('quota'); }, removeItem: () => { throw new Error('blocked'); } } as unknown as Storage; + expect(writeOfflineDraft(blocked, text)).toBe(false); + expect(readOfflineDrafts(blocked, validate)).toEqual([]); + expect(() => clearOfflineDrafts(blocked)).not.toThrow(); + expect(writeOfflineDraft(null, text)).toBe(false); + }); +}); + describe('token for Convex', () => { const noWait = async () => {}; it('waits through offline nulls and network errors instead of reporting a signed-out user', async () => { diff --git a/src/engine/offline.ts b/src/engine/offline.ts index fec6df7e..ba93f416 100644 --- a/src/engine/offline.ts +++ b/src/engine/offline.ts @@ -38,6 +38,53 @@ export function clearOfflineCopy(store: Storage | null): void { try { store?.removeItem(OFFLINE_KEY); } catch { /* nothing to forget */ } } +// Drafts typed in offline reading mode, one key per area, exercise and fingerprint. They wait on this device until the +// next authorized start adopts them (see adoptOfflineDrafts in src/app.ts); signing out forgets them. +export const OFFLINE_DRAFT_PREFIX = 'learn-offline:draft:v1:'; +/** `generation` is the area's reset generation in the offline copy the draft was typed on. */ +export type OfflineDraft = { areaId: string; exerciseId: string; fingerprint: string; json: string; generation: number; savedAt: number }; +const draftKey = (d: Pick) => `${OFFLINE_DRAFT_PREFIX}${d.areaId}:${d.exerciseId}:${d.fingerprint}`; + +export function writeOfflineDraft(store: Storage | null, draft: OfflineDraft): boolean { + try { store?.setItem(draftKey(draft), JSON.stringify(draft)); return !!store; } catch { return false; } +} + +/** Drafts failing `validate` or not matching their key are skipped (and kept, like any unreadable entry). */ +export function readOfflineDrafts(store: Storage | null, validate: (draft: OfflineDraft) => void): OfflineDraft[] { + const drafts: OfflineDraft[] = []; + try { + if (!store) return drafts; + for (let i = 0; i < store.length; i++) { + const key = store.key(i); + if (!key?.startsWith(OFFLINE_DRAFT_PREFIX)) continue; + try { + const value = JSON.parse(store.getItem(key) ?? '') as OfflineDraft; + if (typeof value?.areaId !== 'string' || typeof value.exerciseId !== 'string' || typeof value.fingerprint !== 'string' || typeof value.json !== 'string' || !Number.isSafeInteger(value.generation) || !Number.isFinite(value.savedAt) || draftKey(value) !== key) continue; + validate(value); + drafts.push({ areaId: value.areaId, exerciseId: value.exerciseId, fingerprint: value.fingerprint, json: value.json, generation: value.generation, savedAt: value.savedAt }); + } catch { /* skip this one */ } + } + } catch { /* blocked storage holds nothing */ } + return drafts; +} + +/** Forgets this draft unless the key holds newer text meanwhile, for example from an offline tab still open. */ +export function removeOfflineDraft(store: Storage | null, draft: OfflineDraft): void { + try { + const current = JSON.parse(store?.getItem(draftKey(draft)) ?? 'null') as OfflineDraft | null; + if (current?.savedAt === draft.savedAt && current.json === draft.json) store?.removeItem(draftKey(draft)); + } catch { /* nothing to forget */ } +} + +export function clearOfflineDrafts(store: Storage | null): void { + try { + if (!store) return; + const keys: string[] = []; + for (let i = 0; i < store.length; i++) { const key = store.key(i); if (key?.startsWith(OFFLINE_DRAFT_PREFIX)) keys.push(key); } + keys.forEach((key) => store.removeItem(key)); + } catch { /* nothing to forget */ } +} + /** * Clerk answers null while the browser is offline and throws on a flaky connection. Returning null would make Convex * drop the authentication and fail every queued write after reconnecting, so keep waiting while the session exists. diff --git a/src/main.ts b/src/main.ts index d84ab31b..3e599d4d 100644 --- a/src/main.ts +++ b/src/main.ts @@ -3,7 +3,7 @@ import './styles/layout.css'; import './styles/content.css'; import './styles/exercise.css'; import './styles/pages.css'; -import { browserStorage, clearOfflineCopy, hasOfflineCopy, waitForToken } from './engine/offline.ts'; +import { browserStorage, clearOfflineCopy, clearOfflineDrafts, hasOfflineCopy, readOfflineDrafts, waitForToken } from './engine/offline.ts'; import { html } from './ui/dom.ts'; // Caches the public app shell and learning content for offline use; personal progress never passes through it. @@ -113,7 +113,11 @@ async function connect(url: string, key: string): Promise { alert('Änderungen werden noch gespeichert. Bitte warte vor dem Abmelden auf „Lernstand synchronisiert“.'); return; } + // Offline drafts still here differ from the saved ones (offered on their exercise) or could not be handed over. + const left = readOfflineDrafts(browserStorage(), () => {}).length; + if (left && !confirm(`${left === 1 ? 'Ein offline geschriebener Entwurf ist' : `${left} offline geschriebene Entwürfe sind`} noch nicht übernommen. Abmelden löscht ${left === 1 ? 'ihn' : 'sie'} von diesem Gerät. Trotzdem abmelden?`)) return; clearOfflineCopy(browserStorage()); + clearOfflineDrafts(browserStorage()); await clerk.signOut({ redirectUrl: location.origin }); }); } diff --git a/src/pages/exercise.ts b/src/pages/exercise.ts index f6f4dcac..cf6d1b22 100644 --- a/src/pages/exercise.ts +++ b/src/pages/exercise.ts @@ -1,4 +1,4 @@ -import { loadArea, onProgressChange, progressOf, recordDrill, saveAnswer, setEntry, summaryOf } from '../app.ts'; +import { loadArea, offlineDraftFor, onProgressChange, progressOf, recordDrill, saveAnswer, settleOfflineDraft, setEntry, summaryOf } from '../app.ts'; import type { DrillEvent } from '../../convex/model.ts'; import { isDone } from '../engine/storage.ts'; import { navigate } from '../router.ts'; @@ -99,12 +99,34 @@ const exercisePage: Page<{ name: 'exercise'; area: string; module: string; index navigate(`${base}/${target + 1}`); requestAnimationFrame(() => document.querySelector('.step.active')?.focus()); }); + // A text typed offline that differs from the saved draft: the saved one stays until the offline one is loaded. + const reload = () => navigate(location.pathname, { replace: true, keepScroll: true }); + const offerOfflineDraft = () => { + const offer = main.querySelector('#offline-draft'); + if (offlineDraftFor(summary.id, exercise) === undefined) { offer?.remove(); return; } + if (offer) return; + const box = document.createElement('div'); + box.id = 'offline-draft'; box.className = 'draft-offer'; + box.innerHTML = ''; + const [load, drop] = box.querySelectorAll('button'); + load.addEventListener('click', () => { + if (!confirm('Den offline geschriebenen Entwurf laden? Er ersetzt den gespeicherten Entwurf dieser Übung.')) return; + settleOfflineDraft(summary.id, exercise, true); reload(); + }); + drop.addEventListener('click', () => { + if (!confirm('Den offline geschriebenen Entwurf von diesem Gerät löschen? Der gespeicherte Entwurf bleibt.')) return; + settleOfflineDraft(summary.id, exercise, false); box.remove(); + }); + $('#feedback', main).after(box); + }; + offerOfflineDraft(); cleanups.push(onProgressChange((draft) => { + offerOfflineDraft(); if (draft && !main.querySelector('#refresh-draft')) { const button = document.createElement('button'); button.id = 'refresh-draft'; button.className = 'btn small'; button.textContent = 'Geänderter Entwurf verfügbar · Laden'; - button.addEventListener('click', () => navigate(location.pathname, { replace: true, keepScroll: true })); + button.addEventListener('click', reload); $('#feedback', main).after(button); } const nextSolved = isDone(progress, exercise); diff --git a/src/styles/exercise.css b/src/styles/exercise.css index e4eb1142..fcb9a84c 100644 --- a/src/styles/exercise.css +++ b/src/styles/exercise.css @@ -150,6 +150,7 @@ body.vm-dock-open { padding-bottom: min(46vh, 420px); } .feedback.bad { background: var(--bad-bg); border-color: var(--bad); } .feedback.partial { background: var(--note); } .feedback.info { font-weight: 500; color: var(--ink-2); } +.draft-offer { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; } .feedback .icon { margin-top: 3px; } .explanation { margin-top: 18px; border: var(--border); border-left-width: 12px; padding: 14px 18px; background: var(--white); } .solution { margin-top: 16px; border: 3px dashed var(--ink); padding: 14px 18px; background: var(--paper-2); } diff --git a/src/testing/local-app.ts b/src/testing/local-app.ts index 54ddd8ae..a1742f49 100644 --- a/src/testing/local-app.ts +++ b/src/testing/local-app.ts @@ -95,6 +95,8 @@ export function getContinueArea(): string | null { export const onProgressChange = (_fn: (draft: boolean) => void) => () => {}; export const closeDraft = () => {}; export const prepareDraft = async (_area: string, _exercise: Exercise) => {}; +export const offlineDraftFor = (_area: string, _exercise: Exercise): unknown => undefined; +export const settleOfflineDraft = (_area: string, _exercise: Exercise, _use: boolean) => {}; export function saveAnswer(area: string, exercise: Exercise, draft: unknown) { progressOf(area).drafts[exercise.id] = structuredClone(draft); save(area); } export function setEntry(area: string, entry: Entry) { const p = progressOf(area);