Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion content/narrative/modules/figuren-motivation.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
13 changes: 13 additions & 0 deletions convex/progress.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)],
Expand Down Expand Up @@ -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' };
Expand Down
14 changes: 14 additions & 0 deletions convex/progress.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => {
Expand Down
15 changes: 9 additions & 6 deletions docs/CONVEX.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
| --- | --- |
Expand All @@ -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 |
Expand All @@ -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.

Expand All @@ -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:<bereich>:<übung>:<fingerprint>`), 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

Expand Down Expand Up @@ -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
```

Expand Down Expand Up @@ -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. |
Loading
Loading