From 90d48dbccdbf67facfea40e48186581604d464d5 Mon Sep 17 00:00:00 2001 From: Steven Hernandez Date: Wed, 21 Oct 2026 23:55:35 +0200 Subject: [PATCH 1/4] Keep drafts typed in offline reading mode on the device (LRN-30) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Offline reading mode now stores exercise drafts in LocalStorage, one key per area, exercise and fingerprint (60 KB limit as online). The next authorized start hands them to the new adoptDraft mutation, which saves a draft only where none exists; a different saved draft stays and the exercise offers the offline one ("Offline geschriebener Entwurf verfügbar · Laden"/"Verwerfen"). Signing out deletes the local drafts and asks first if some are not adopted. Updates the offline notice, docs/CONVEX.md and the callout in the narrative course. Notes stay unavailable offline. Co-Authored-By: Claude Opus 5.5 --- .../narrative/modules/figuren-motivation.yaml | 2 +- convex/progress.test.ts | 13 +++++ convex/progress.ts | 14 +++++ docs/CONVEX.md | 15 +++-- e2e/convex/sync.spec.ts | 25 ++++++++ e2e/pwa/offline.spec.ts | 26 ++++++++- src/app.ts | 57 +++++++++++++++++-- src/engine/offline.test.ts | 41 ++++++++++++- src/engine/offline.ts | 42 ++++++++++++++ src/main.ts | 6 +- src/pages/exercise.ts | 23 +++++++- src/styles/exercise.css | 1 + src/testing/local-app.ts | 2 + 13 files changed, 248 insertions(+), 19 deletions(-) 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..dd1374ce 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. „Laden“ speichert ihn als Entwurf der Übung, „Verwerfen“ löscht ihn; erst dann verschwindet die lokale Kopie. Bis dahin bleibt sie liegen und wird bei jedem Start erneut angeboten. Passt ihr Fingerprint nicht mehr zur Übung, weil sich der Inhalt geändert hat, wird sie nur gespeichert, wenn auf dem Server nichts liegt, sonst bleibt sie bis zur Abmeldung ungenutzt liegen. Die Entscheidung trifft der Server in einer Transaktion; eine eigene Sync-Engine gibt es nicht. + +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..d383983c 100644 --- a/e2e/convex/sync.spec.ts +++ b/e2e/convex/sync.spec.ts @@ -177,6 +177,31 @@ 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] = 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) => [key(e), JSON.stringify({ areaId: 'kotlin', exerciseId: e.id, fingerprint: e.fingerprint, json: gap(text), 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')]); + // The next authorized start hands both to Convex: the missing one is saved, the different one is only offered. + 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 stored()).toEqual([key(other)]); + 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') }); + 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') }); + expect(await stored()).toEqual([]); +}); + 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..628f436b 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 werden hier nicht gespeichert'); 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('Deine Eingaben in Übungen bleiben auf diesem Gerät'); + 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..af551d32 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: Lerninhalte und dein Lernstand vom ${stamp(offlineSince)}. Deine Eingaben in Übungen bleiben auf diesem Gerät und werden beim nächsten Start mit Verbindung übernommen. Häkchen, gelöste Übungen und Karten werden hier nicht gespeichert.`; 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,46 @@ export async function connectProgress(convex: ConvexClient, denied: () => void): noted = next; if (!same) progressListeners.forEach((fn) => fn(false)); }, () => {}); + adoptOfflineDrafts(); 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. + */ +function adoptOfflineDrafts(): void { + const store = browserStorage(); + for (const local of readOfflineDrafts(store, validOfflineDraft)) { + const { areaId, exerciseId, fingerprint, json } = local; + void track(client.mutation(api.progress.adoptDraft, { areaId, generation: generationOf(snapshot, areaId), draft: { exerciseId, fingerprint, json } })).then((result) => { + if (!active) return; + if (result === 'different') offered.set(offerKey(areaId, exerciseId, fingerprint), local); + else removeOfflineDraft(store, local); + 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 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); + removeOfflineDraft(browserStorage(), local); + if (use && value !== undefined) saveAnswer(areaId, exercise, value); +} + function track(work: Promise): Promise { pending++; inflight.add(work); statusChanged(); return work.catch((error: unknown) => { @@ -221,6 +259,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,6 +296,9 @@ 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, 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 }, { diff --git a/src/engine/offline.test.ts b/src/engine/offline.test.ts index c9bc0721..892f8c2a 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,38 @@ 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":[]}', 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([]); + removeOfflineDraft(store, text); + 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' }); + 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..a82975a7 100644 --- a/src/engine/offline.ts +++ b/src/engine/offline.ts @@ -38,6 +38,48 @@ 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:'; +export type OfflineDraft = { areaId: string; exerciseId: string; fingerprint: string; json: string; 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.isFinite(value.savedAt) || draftKey(value) !== key) continue; + validate(value); + drafts.push({ areaId: value.areaId, exerciseId: value.exerciseId, fingerprint: value.fingerprint, json: value.json, savedAt: value.savedAt }); + } catch { /* skip this one */ } + } + } catch { /* blocked storage holds nothing */ } + return drafts; +} + +export function removeOfflineDraft(store: Storage | null, draft: Pick): void { + try { 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..22c5f3f9 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,31 @@ 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', () => { 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); From 709fcc4ccff0c362f86614ad89d9552de77c9002 Mon Sep 17 00:00:00 2001 From: Steven Hernandez Date: Thu, 22 Oct 2026 00:02:01 +0200 Subject: [PATCH 2/4] Harden offline draft adoption after review (LRN-30) - Offline drafts carry the reset generation of their offline copy; after a reset they are offered instead of coming back on their own. - Drafts whose fingerprint no longer matches the exercise stay on the device instead of being saved where no exercise shows them. - "Laden" asks first and forgets the local copy only once the server has confirmed the draft; a held draft no longer overwrites it. - A local copy is removed only while it still holds the adopted text. - The start waits up to three seconds for adoption, so an exercise opened right away already shows the adopted text. - Shorter offline notice; it took half a phone screen. Co-Authored-By: Claude Opus 5.5 --- docs/CONVEX.md | 2 +- e2e/convex/sync.spec.ts | 19 ++++++++++---- e2e/pwa/offline.spec.ts | 4 +-- src/app.ts | 53 +++++++++++++++++++++++--------------- src/engine/offline.test.ts | 6 ++++- src/engine/offline.ts | 15 +++++++---- src/pages/exercise.ts | 5 +++- 7 files changed, 68 insertions(+), 36 deletions(-) diff --git a/docs/CONVEX.md b/docs/CONVEX.md index dd1374ce..20ed3d18 100644 --- a/docs/CONVEX.md +++ b/docs/CONVEX.md @@ -91,7 +91,7 @@ Die Plattform ist eine installierbare PWA (`public/manifest.webmanifest`, Logo ` **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. -**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. „Laden“ speichert ihn als Entwurf der Übung, „Verwerfen“ löscht ihn; erst dann verschwindet die lokale Kopie. Bis dahin bleibt sie liegen und wird bei jedem Start erneut angeboten. Passt ihr Fingerprint nicht mehr zur Übung, weil sich der Inhalt geändert hat, wird sie nur gespeichert, wenn auf dem Server nichts liegt, sonst bleibt sie bis zur Abmeldung ungenutzt liegen. Die Entscheidung trifft der Server in einer Transaktion; eine eigene Sync-Engine gibt es nicht. +**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. diff --git a/e2e/convex/sync.spec.ts b/e2e/convex/sync.spec.ts index d383983c..72e9116a 100644 --- a/e2e/convex/sync.spec.ts +++ b/e2e/convex/sync.spec.ts @@ -178,28 +178,37 @@ test('a draft typed offline does not silently replace a different draft saved on }); test('drafts kept in offline reading mode are adopted on the next start without replacing a different saved draft', async ({ page }) => { - const [first, other] = loadContent(['kotlin']).areas.kotlin.modules['bear-01'].exercises; + 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) => [key(e), JSON.stringify({ areaId: 'kotlin', exerciseId: e.id, fingerprint: e.fingerprint, json: gap(text), savedAt: 1 })]; + 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')]); - // The next authorized start hands both to Convex: the missing one is saved, the different one is only offered. + 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 stored()).toEqual([key(other)]); + 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 }) => { diff --git a/e2e/pwa/offline.spec.ts b/e2e/pwa/offline.spec.ts index 628f436b..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('Häkchen, gelöste Übungen und Karten 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); @@ -69,7 +69,7 @@ test('texts typed in offline reading mode survive a reload and wait on the devic 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('Deine Eingaben in Übungen bleiben auf diesem Gerät'); + 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'); diff --git a/src/app.ts b/src/app.ts index af551d32..ef7806ac 100644 --- a/src/app.ts +++ b/src/app.ts @@ -72,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)}. Deine Eingaben in Übungen bleiben auf diesem Gerät und werden beim nächsten Start mit Verbindung übernommen. Häkchen, gelöste Übungen und Karten 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.` @@ -157,7 +157,8 @@ export async function connectProgress(convex: ConvexClient, denied: () => void): noted = next; if (!same) progressListeners.forEach((fn) => fn(false)); }, () => {}); - adoptOfflineDrafts(); + // 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; @@ -169,32 +170,40 @@ export async function connectProgress(convex: ConvexClient, denied: () => void): * 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. */ -function adoptOfflineDrafts(): void { +async function adoptOfflineDrafts(): Promise { const store = browserStorage(); - for (const local of readOfflineDrafts(store, validOfflineDraft)) { + await Promise.allSettled(readOfflineDrafts(store, validOfflineDraft).map(async (local) => { const { areaId, exerciseId, fingerprint, json } = local; - void track(client.mutation(api.progress.adoptDraft, { areaId, generation: generationOf(snapshot, areaId), draft: { exerciseId, fingerprint, json } })).then((result) => { - if (!active) return; - if (result === 'different') offered.set(offerKey(areaId, exerciseId, fingerprint), local); - else removeOfflineDraft(store, local); - progressListeners.forEach((fn) => fn(false)); - }, () => {}); - } + // 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 forgets the copy on this device. */ +/** 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); - removeOfflineDraft(browserStorage(), local); - if (use && value !== undefined) saveAnswer(areaId, exercise, value); + 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 { @@ -296,21 +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, savedAt: Date.now() })) { + 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 892f8c2a..93a0b639 100644 --- a/src/engine/offline.test.ts +++ b/src/engine/offline.test.ts @@ -48,7 +48,7 @@ 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":[]}', savedAt: 5 }; + 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: [] }); @@ -57,7 +57,10 @@ describe('offline drafts', () => { 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([]); @@ -68,6 +71,7 @@ describe('offline drafts', () => { 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; diff --git a/src/engine/offline.ts b/src/engine/offline.ts index a82975a7..ba93f416 100644 --- a/src/engine/offline.ts +++ b/src/engine/offline.ts @@ -41,7 +41,8 @@ export function clearOfflineCopy(store: Storage | null): void { // 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:'; -export type OfflineDraft = { areaId: string; exerciseId: string; fingerprint: string; json: string; savedAt: number }; +/** `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 { @@ -58,17 +59,21 @@ export function readOfflineDrafts(store: Storage | null, validate: (draft: Offli 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.isFinite(value.savedAt) || draftKey(value) !== key) continue; + 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, savedAt: value.savedAt }); + 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; } -export function removeOfflineDraft(store: Storage | null, draft: Pick): void { - try { store?.removeItem(draftKey(draft)); } catch { /* nothing to forget */ } +/** 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 { diff --git a/src/pages/exercise.ts b/src/pages/exercise.ts index 22c5f3f9..cf6d1b22 100644 --- a/src/pages/exercise.ts +++ b/src/pages/exercise.ts @@ -109,7 +109,10 @@ const exercisePage: Page<{ name: 'exercise'; area: string; module: string; index box.id = 'offline-draft'; box.className = 'draft-offer'; box.innerHTML = ''; const [load, drop] = box.querySelectorAll('button'); - load.addEventListener('click', () => { settleOfflineDraft(summary.id, exercise, true); reload(); }); + 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(); From 7a87626c9e85b1fab4804444d80739d3b3cb353c Mon Sep 17 00:00:00 2001 From: Steven Hernandez Date: Thu, 22 Oct 2026 00:31:28 +0200 Subject: [PATCH 3/4] Stabilize the viewport e2e test on busy CI runners Some 300 dev-server navigations in one tab ran the CI browser out of resources (net::ERR_INSUFFICIENT_RESOURCES, then a page module failed to load); the nightly run on main failed the same way. Each width now gets a fresh tab, and a page that did not load is tried once more. The test checks layout, not loading. Co-Authored-By: Claude Opus 5.5 --- e2e/learning-paths.spec.ts | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/e2e/learning-paths.spec.ts b/e2e/learning-paths.spec.ts index f8779fe7..8e5bf030 100644 --- a/e2e/learning-paths.spec.ts +++ b/e2e/learning-paths.spec.ts @@ -209,7 +209,7 @@ for (const area of loaded.catalog.areas) { }); } -test('real content fits phone, tablet and desktop viewports', async ({ page }) => { +test('real content fits phone, tablet and desktop viewports', async ({ context }) => { test.setTimeout(180_000); // Weak spots in every area, so the review pages show topic cards, the topic map and a running round. const day = (offset: number) => new Date(Date.now() + offset * 86_400_000).toISOString().slice(0, 10); @@ -220,9 +220,14 @@ test('real content fits phone, tablet and desktop viewports', async ({ page }) = const card = area.topics.find((t) => t.cards.length)!.cards[0]; return [area.id, { version: 1, done, drafts: {}, revealed: {}, read: {}, cards: { [card]: { box: 1, due: day(0), seen: 2, grade: 'again', lapses: 2 } }, projects: {}, drills, topics: {}, last: null }]; })); - await page.goto('/'); - await page.evaluate((areas) => { for (const [id, value] of Object.entries(areas)) localStorage.setItem(`learn:${id}:v1`, JSON.stringify(value)); }, seeded); + const seed = await context.newPage(); + await seed.goto('/'); + await seed.evaluate((areas) => { for (const [id, value] of Object.entries(areas)) localStorage.setItem(`learn:${id}:v1`, JSON.stringify(value)); }, seeded); + await seed.close(); for (const width of [360, 768, 1440]) { + // Some 300 dev-server navigations in one tab exhaust a busy CI runner (net::ERR_INSUFFICIENT_RESOURCES, then a page + // module fails to load): a fresh tab per width, and a second try for a page that did not load. Layout is what counts. + const page = await context.newPage(); await page.setViewportSize({ width, height: 900 }); const paths = ['/', '/daten', '/wiederholen']; for (const area of loaded.catalog.areas) { @@ -236,10 +241,13 @@ test('real content fits phone, tablet and desktop viewports', async ({ page }) = } } for (const path of paths) { - await page.goto(path); - await expect(page.locator('h1')).toBeVisible(); + await expect(async () => { + await page.goto(path); + await expect(page.locator('h1')).toBeVisible(); + }).toPass({ timeout: 30_000 }); await page.evaluate(() => document.fonts.ready); expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), `${width}px ${path}`).toBe(true); } + await page.close(); } }); From e72f331843a53251a9596321fedb9f0f94ed7284 Mon Sep 17 00:00:00 2001 From: Steven Hernandez Date: Thu, 22 Oct 2026 00:47:27 +0200 Subject: [PATCH 4/4] Revert "Stabilize the viewport e2e test on busy CI runners" The fresh tab and retry did not help on CI: the runner was short of resources for the PostgreSQL, pytest and VM tests in the same run too, and the retries only used up the test's time budget. The flakiness exists on main as well and gets its own issue instead of riding along with LRN-30. Co-Authored-By: Claude Opus 5.5 --- e2e/learning-paths.spec.ts | 18 +++++------------- 1 file changed, 5 insertions(+), 13 deletions(-) diff --git a/e2e/learning-paths.spec.ts b/e2e/learning-paths.spec.ts index 8e5bf030..f8779fe7 100644 --- a/e2e/learning-paths.spec.ts +++ b/e2e/learning-paths.spec.ts @@ -209,7 +209,7 @@ for (const area of loaded.catalog.areas) { }); } -test('real content fits phone, tablet and desktop viewports', async ({ context }) => { +test('real content fits phone, tablet and desktop viewports', async ({ page }) => { test.setTimeout(180_000); // Weak spots in every area, so the review pages show topic cards, the topic map and a running round. const day = (offset: number) => new Date(Date.now() + offset * 86_400_000).toISOString().slice(0, 10); @@ -220,14 +220,9 @@ test('real content fits phone, tablet and desktop viewports', async ({ context } const card = area.topics.find((t) => t.cards.length)!.cards[0]; return [area.id, { version: 1, done, drafts: {}, revealed: {}, read: {}, cards: { [card]: { box: 1, due: day(0), seen: 2, grade: 'again', lapses: 2 } }, projects: {}, drills, topics: {}, last: null }]; })); - const seed = await context.newPage(); - await seed.goto('/'); - await seed.evaluate((areas) => { for (const [id, value] of Object.entries(areas)) localStorage.setItem(`learn:${id}:v1`, JSON.stringify(value)); }, seeded); - await seed.close(); + await page.goto('/'); + await page.evaluate((areas) => { for (const [id, value] of Object.entries(areas)) localStorage.setItem(`learn:${id}:v1`, JSON.stringify(value)); }, seeded); for (const width of [360, 768, 1440]) { - // Some 300 dev-server navigations in one tab exhaust a busy CI runner (net::ERR_INSUFFICIENT_RESOURCES, then a page - // module fails to load): a fresh tab per width, and a second try for a page that did not load. Layout is what counts. - const page = await context.newPage(); await page.setViewportSize({ width, height: 900 }); const paths = ['/', '/daten', '/wiederholen']; for (const area of loaded.catalog.areas) { @@ -241,13 +236,10 @@ test('real content fits phone, tablet and desktop viewports', async ({ context } } } for (const path of paths) { - await expect(async () => { - await page.goto(path); - await expect(page.locator('h1')).toBeVisible(); - }).toPass({ timeout: 30_000 }); + await page.goto(path); + await expect(page.locator('h1')).toBeVisible(); await page.evaluate(() => document.fonts.ready); expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), `${width}px ${path}`).toBe(true); } - await page.close(); } });