Compare commits
28 Commits
8e33b52f66
...
v1.1.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
854af2fc34 | ||
|
|
1bec054ec6 | ||
|
|
c2074c9768 | ||
|
|
858d4c1622 | ||
|
|
42f79f122b | ||
|
|
3d6f6393b3 | ||
|
|
0ede62dc8a | ||
|
|
1a4f7b5f20 | ||
|
|
528508a304 | ||
|
|
8bb208a613 | ||
|
|
3906781c4e | ||
|
|
447ff2be32 | ||
|
|
51a88a4c58 | ||
|
|
582d902c62 | ||
|
|
7c8edb9b92 | ||
|
|
d38992661c | ||
|
|
02df0331b7 | ||
|
|
d08cefa5c9 | ||
|
|
0c66bd677e | ||
|
|
04641355df | ||
|
|
0b12aa027f | ||
|
|
60f6db9091 | ||
|
|
303939a6ff | ||
|
|
2807dd1cab | ||
|
|
7233cc3a13 | ||
|
|
297281e201 | ||
|
|
194aee269e | ||
|
|
361164febd |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -5,3 +5,6 @@ data/
|
||||
.env
|
||||
.env.local
|
||||
*.log
|
||||
test-results/
|
||||
playwright-report/
|
||||
.playwright-mcp/
|
||||
|
||||
@@ -17,6 +17,8 @@ Selbstgehostete Rezept-PWA für die Familie Siegeln. Erreichbar unter `https://k
|
||||
| **Migrations** | Werden via Vite `import.meta.glob('./migrations/*.sql', {eager, query:'?raw'})` gebundelt. Neue Migration einfach als `00N_name.sql` ablegen, kein Copy-in-Dockerfile nötig. |
|
||||
| **$lib/server in Client** | Svelte-Import aus `$lib/server/*` in einem `.svelte`-Komponenten-Script bricht den Build. Pures JS/TS, das beidseitig funktioniert (z. B. Portionen-Scaler), gehört nach `$lib/`, nicht `$lib/server/`. |
|
||||
| **Preview-Bilder** | `recipe.image_path` kann **absolute URL** (Preview-Modus) oder **lokaler Filename** sein. `RecipeView.svelte` prüft mit `/^https?:\/\//i`. |
|
||||
| **Service Worker nur ab HTTPS** | `npm run dev` liefert HTTP → SW registriert nicht. Für PWA-Tests `npm run build && npm run preview` (localhost) oder Prod-Docker. |
|
||||
| **Icon-Rendering** | `npm run render:icons` rendert `icon-192.png` + `icon-512.png` aus `static/icon.svg`. Nur nach SVG-Änderung erneut ausführen + committen. |
|
||||
|
||||
## Dateien, die man typischerweise anfasst
|
||||
|
||||
@@ -27,6 +29,9 @@ Selbstgehostete Rezept-PWA für die Familie Siegeln. Erreichbar unter `https://k
|
||||
- `src/lib/server/search/searxng.ts` — Web-Suche + Thumbnail-Enrichment + SQLite-Cache
|
||||
- `src/lib/server/recipes/importer.ts` — JSON-LD → Recipe, orchestriert Bild-Download
|
||||
- `src/lib/server/db/migrations/*.sql` — Schema; bei Änderung immer **neue** Migration statt bestehende bearbeiten
|
||||
- `src/service-worker.ts` — Service-Worker-Orchestrator (Shell-Cache + Pre-Cache + SWR)
|
||||
- `src/lib/sw/` — reine Logik (Cache-Strategy-Entscheider, Diff-Manifest) für Unit-Tests
|
||||
- `src/lib/client/*.svelte.ts` — Frontend-Stores (Network, Sync-Status, Toast, Install-Prompt)
|
||||
|
||||
## Arbeitsweise (wie wir es machen)
|
||||
|
||||
|
||||
@@ -106,6 +106,35 @@ Bei Schema-Änderung:
|
||||
- **Keine Svelte-Component-Tests** (bewusst, Aufwand/Nutzen stimmt nicht; UI wird manuell getestet)
|
||||
- **Vor Commit**: `npm test && npm run check` muss grün sein.
|
||||
|
||||
### Service Worker (PWA)
|
||||
|
||||
`src/service-worker.ts` ist SvelteKits eingebauter SW-Slot. Er nutzt `$service-worker` (`build`, `files`, `version`) für den App-Shell-Cache und implementiert eigene Logik für:
|
||||
|
||||
- **Pre-Cache** (alle Rezepte + Bilder beim Initial-Sync), über paginierten Fetch von `/api/recipes/all`.
|
||||
- **Delta-Sync** beim App-Start (diff vs. Cache-Manifest, nur Delta laden).
|
||||
- **Drei Cache-Strategien** (dispatcht per `resolveStrategy`): Shell = cache-first, Daten = SWR, Bilder = cache-first.
|
||||
- **Message-Protokoll** (`sync-start`, `sync-progress`, `sync-done`, `sync-error`) zwischen SW und Client.
|
||||
|
||||
Reine Logik-Einheiten (testbar, Unit-Tests in `tests/unit/`):
|
||||
- `src/lib/sw/cache-strategy.ts` — `resolveStrategy({url, method})` → `'shell' | 'swr' | 'images' | 'network-only'`
|
||||
- `src/lib/sw/diff-manifest.ts` — `diffManifest(current, cached)` → `{toAdd, toRemove}`
|
||||
|
||||
Client-Stores (SSR-safe via typeof-Guards):
|
||||
- `src/lib/client/network.svelte.ts` — `navigator.onLine` + Events.
|
||||
- `src/lib/client/sync-status.svelte.ts` — SW-Message-Spiegel, `lastSynced` in localStorage.
|
||||
- `src/lib/client/toast.svelte.ts` — Toast-Queue für Offline-Fehler + Sync-Meldungen.
|
||||
- `src/lib/client/install-prompt.svelte.ts` — fängt `beforeinstallprompt`, erkennt Plattform.
|
||||
- `src/lib/client/sw-register.ts` — registriert den SW, leitet Messages an den Sync-Status-Store.
|
||||
- `src/lib/client/require-online.ts` — Helper für Schreib-Aktionen (Toast statt stillem Fail).
|
||||
|
||||
UI-Komponenten:
|
||||
- `src/lib/components/SyncIndicator.svelte` — Pill unten rechts (Sync-Fortschritt / Offline-Status).
|
||||
- `src/lib/components/Toast.svelte` — Top-Center-Toast-Renderer.
|
||||
|
||||
Admin-UI: `src/routes/admin/app/+page.svelte` mit Install-Button, manuellem Sync-Trigger, Cache-Reset.
|
||||
|
||||
E2E-Tests: `tests/e2e/offline.spec.ts` — Playwright setzt das Netzwerk offline und prüft Navigation/Toast/Indikator-Verhalten.
|
||||
|
||||
## Was später kommt (laut Spec, aktuell nicht implementiert)
|
||||
|
||||
- LLM-Fallback für nicht-JSON-LD-Seiten
|
||||
|
||||
@@ -146,3 +146,28 @@ Siehe `.env.example` im Repo.
|
||||
- **Thumbnail-Cache in SQLite** → `003_thumbnail_cache.sql` + `searxng.ts`
|
||||
|
||||
Git-Log ist die Wahrheit; diese Datei ist eine Orientierung.
|
||||
|
||||
## PWA / Offline-Modus
|
||||
|
||||
Kochwas ist eine installierbare PWA. Erkennbar an:
|
||||
- `static/manifest.webmanifest` (Manifest + Icons: SVG + 192×192 + 512×512, alle maskable)
|
||||
- `src/service-worker.ts` (Cache + Sync)
|
||||
|
||||
Caches im Browser (siehe DevTools → Application → Cache Storage):
|
||||
- `kochwas-shell-<version>` — App-Shell (JS/CSS/Static-Icons), cache-first
|
||||
- `kochwas-data-v1` — Rezept-HTMLs + API-JSON (SWR)
|
||||
- `kochwas-images-v1` — Bilder (cache-first)
|
||||
- `kochwas-meta` — Cache-Manifest (Liste der gecachten Rezept-IDs unter `/__cache-manifest__`)
|
||||
|
||||
Sync-Verhalten:
|
||||
- **Initial-Sync** (nach erstem Install): SW lädt alle Rezepte + Bilder im Hintergrund. Fortschritt im `SyncIndicator`-Pill unten rechts.
|
||||
- **Update-Sync** (bei jedem App-Start online): Diff gegen Cache-Manifest, nur Delta nachladen, gelöschte IDs räumen.
|
||||
- **Storage-Quota-Check**: < 100 MB frei → abbrechen mit Fehler-Toast.
|
||||
|
||||
Bei SW-Problemen Debug-Pfad:
|
||||
1. Admin → „App"-Tab → „Offline-Cache leeren" (destructive, zweistufig bestätigt)
|
||||
2. Alternative: DevTools → Application → Service Workers → Unregister, dann Seite neu laden.
|
||||
|
||||
E2E-Tests (Playwright): `npm run test:e2e`. Setzt `npm run build` voraus (Playwright startet automatisch `npm run preview`).
|
||||
|
||||
Icons einmalig rendern: `npm run render:icons` (schreibt nach `static/icon-*.png`, committen).
|
||||
|
||||
1982
docs/superpowers/plans/2026-04-18-offline-pwa.md
Normal file
1982
docs/superpowers/plans/2026-04-18-offline-pwa.md
Normal file
File diff suppressed because it is too large
Load Diff
235
docs/superpowers/specs/2026-04-18-offline-pwa-design.md
Normal file
235
docs/superpowers/specs/2026-04-18-offline-pwa-design.md
Normal file
@@ -0,0 +1,235 @@
|
||||
# Offline-PWA v1.1 — Design-Spec
|
||||
|
||||
> **Stand**: 2026-04-18 — Brainstorming-Ergebnis. Vor der Plan-Erstellung vom Nutzer zu bestätigen.
|
||||
|
||||
## Ziel
|
||||
|
||||
Kochwas als installierbare PWA mit vollständigem Lese-Offline-Modus. Alle Rezepte (bei ~200 erwartet: ca. 60 MB inkl. Bilder) werden automatisch lokal synchronisiert. Schreib-Aktionen bleiben online-only. Keine Backend-Änderungen.
|
||||
|
||||
## Design-Entscheidungen (aus Brainstorming)
|
||||
|
||||
| Entscheidung | Gewähltes Vorgehen |
|
||||
|---|---|
|
||||
| Sync-Umfang | **Alle Rezepte + alle Bilder** (nicht nur Favoriten/Wunschliste). Einheitliches Mental-Modell "alles da". |
|
||||
| Installierbarkeit | **Volles PWA-Manifest + Icons** — Home-Screen-App auf Android/iOS. |
|
||||
| Offline-Indikator | **Dezent**, fix unten rechts als Pill. Schreib-Buttons zeigen Toast bei Fehler. |
|
||||
| Pre-Cache-Timing | **Im Hintergrund** nach erstem Besuch. Kein blockierender Ladescreen. Sichtbarer Fortschritt. |
|
||||
| Update-Strategie | **Bei jedem App-Start wenn online** — diff gegen Cache-Manifest, Delta nachladen. |
|
||||
| SW-Technologie | **SvelteKits eingebauter Service Worker** (`src/service-worker.ts`, `$service-worker`-Modul). Kein `vite-plugin-pwa`. |
|
||||
| Offline-Schreib-Queue | **Nicht Teil dieser Version**. Offline-Klicks zeigen Toast und bleiben ohne Wirkung. Komplexität verschoben auf v1.2+. |
|
||||
|
||||
## Architektur
|
||||
|
||||
### Cache-Buckets
|
||||
|
||||
Drei Buckets, drei Strategien:
|
||||
|
||||
1. **`kochwas-shell-v{hash}`** — App-Shell (Build-Output: JS, CSS, Static-Icons aus `$service-worker`'s `build` + `files`). **Cache-First**. Bei Deploy neue Version → alter Cache wird in `activate` gelöscht.
|
||||
|
||||
2. **`kochwas-data-v1`** — Rezept-HTMLs (`/recipes/[id]`) + API-Reads (`/api/recipes/*`, `/api/wishlist`, `/api/domains`). **Stale-While-Revalidate**. Cache-Antwort sofort, Netz-Fetch parallel für nächsten Besuch.
|
||||
|
||||
3. **`kochwas-images-v1`** — `/images/*`. **Cache-First**. Filenames sind SHA-256-Hashes → ändert sich das Bild, ändert sich die URL, neue Einträge, alte räumt der Diff-Sync weg.
|
||||
|
||||
### Network-Only (nie cachen)
|
||||
|
||||
- Alle `POST/PUT/PATCH/DELETE` Requests
|
||||
- `GET /api/recipes/import`, `/api/recipes/preview`, `/api/recipes/search/web` — reine Netz-Features, offline sinnfrei
|
||||
- `GET /api/recipes/blank` gibt es nicht (Blank ist POST)
|
||||
|
||||
### Pre-Cache-Flow (Initial + Update)
|
||||
|
||||
**Initial (nach SW-Activate, einmalig)**:
|
||||
|
||||
1. Client postet `{ type: 'sync-start' }` an SW.
|
||||
2. SW fetcht `/api/recipes/all?sort=name&limit=50&offset=N` seitenweise bis weniger als 50 Treffer kommen (Endpoint cappt aktuell auf 50 pro Request, siehe `/api/recipes/all/+server.ts`).
|
||||
3. Alle IDs in Cache-Manifest-Entry schreiben (`kochwas-meta` cache, key `/cache-manifest`).
|
||||
4. Für jede ID: parallel (max. 4 gleichzeitig) cachen:
|
||||
- `GET /recipes/{id}` → `data`-Bucket
|
||||
- `GET /api/recipes/{id}` → `data`-Bucket
|
||||
- Aus der JSON-Response `image_path` extrahieren, wenn vorhanden `GET /images/{image_path}` → `images`-Bucket
|
||||
5. Nach jedem erfolgreichen Eintrag: `postMessage({ type: 'sync-progress', current, total })` an alle Clients.
|
||||
6. Am Ende: `postMessage({ type: 'sync-done', lastSynced: Date.now() })`.
|
||||
|
||||
**Update (bei jedem App-Start online)**:
|
||||
|
||||
1. Client postet `{ type: 'sync-check' }` an SW.
|
||||
2. SW fetcht `/api/recipes/all` frisch.
|
||||
3. Diff gegen Cache-Manifest:
|
||||
- Neue IDs → cachen wie oben (nur Delta).
|
||||
- Gelöschte IDs → aus `data`- und `images`-Bucket räumen.
|
||||
4. Wenn Delta leer → `sync-done` mit unverändertem Zähler.
|
||||
|
||||
**Abbruch-Resilienz**: SW hält State in Cache-Manifest; abgebrochen mittendrin → nächster Start sieht unvollständiges Manifest und holt das Fehlende nach. Idempotent.
|
||||
|
||||
**Editierte Rezepte (gleiche ID, neuer Inhalt)**: Der Diff-Sync sieht keine Änderung (ID existiert ja). Der Refresh passiert stattdessen über Stale-While-Revalidate: wenn der User das Rezept online öffnet, liefert der Cache zuerst, der parallele Netz-Fetch aktualisiert den Cache-Eintrag. Der User sieht die Änderung also **beim übernächsten Öffnen**. Akzeptabel für eine Familien-App — wenn jemand „Salz auf 5 g" editiert, ist das nicht zeitkritisch. Bilder-Updates (neuer Image-Path durch andere Hash-URL) funktionieren automatisch: API-JSON aktualisiert sich per SWR, neue URL wird beim nächsten Bildrequest vom SW gecacht; alter Image-Cache-Entry bleibt als Orphan bis zum nächsten `diffManifest`-Lauf, der auch nach Orphan-Images schaut.
|
||||
|
||||
**Concurrency**: 4 parallele Requests max — schont den Raspberry Pi unter Last.
|
||||
|
||||
**Storage-Check**: Vor dem Initial-Sync `navigator.storage.estimate()`. Bei verfügbarem Quota < 100 MB → Toast: "Nicht genug Speicher für Offline-Modus". Hintergrund-Sync läuft trotzdem, bricht bei Quota-Fehler einfach ab.
|
||||
|
||||
### Sync-Status-Store
|
||||
|
||||
`src/lib/client/sync-status.svelte.ts`:
|
||||
|
||||
```ts
|
||||
type SyncState =
|
||||
| { kind: 'idle' }
|
||||
| { kind: 'syncing'; current: number; total: number }
|
||||
| { kind: 'error'; message: string };
|
||||
|
||||
export const syncStatus = {
|
||||
state: $state<SyncState>({ kind: 'idle' }),
|
||||
lastSynced: $state<number | null>(null),
|
||||
// Abonniert SW-Messages, dispatcht State
|
||||
};
|
||||
```
|
||||
|
||||
Gefüllt über `navigator.serviceWorker.addEventListener('message', ...)`. Persistiert `lastSynced` in localStorage (`kochwas.sw.lastSynced`).
|
||||
|
||||
### Online-Status-Store
|
||||
|
||||
`src/lib/client/network.svelte.ts`:
|
||||
|
||||
```ts
|
||||
export const network = {
|
||||
online: $state(navigator.onLine),
|
||||
// initialisiert Listener auf window 'online'/'offline'
|
||||
};
|
||||
```
|
||||
|
||||
Keine heuristischen Fetches — `navigator.onLine` ist für unsere Zwecke gut genug.
|
||||
|
||||
### UI-Komponenten
|
||||
|
||||
**`<SyncIndicator />`** — fix positioniert unten rechts, ~90×30 px Pill. Drei States:
|
||||
|
||||
- Sync läuft: grüner Spinner + `Sync 47/200`
|
||||
- Offline: grauer Pill mit `Offline`
|
||||
- Online, alles synchron: `display: none`
|
||||
|
||||
Tap/Klick öffnet kleine Overlay-Karte:
|
||||
- "Zuletzt synchronisiert: vor 3 Min · 200 Rezepte im Cache"
|
||||
- "Jetzt aktualisieren"-Button (triggert `sync-check`)
|
||||
|
||||
**`<Toast />`** — in `+layout.svelte` am Top eingehängt. Kurze, nicht-blockierende Meldungen. Store-API:
|
||||
```ts
|
||||
toastStore.error('Nicht verbunden');
|
||||
toastStore.info('Synchronisiert — 200 Rezepte');
|
||||
```
|
||||
Auto-Dismiss nach 3 s, manuell ×-klickbar.
|
||||
|
||||
**Admin-Tab „App"** (`/admin/app`) — vierter Tab im Admin-Layout:
|
||||
|
||||
- Install-Button: feuert das gespeicherte `beforeinstallprompt`-Event. Auf iOS (UA-Detect): Info-Text „Teilen → Zum Home-Bildschirm hinzufügen".
|
||||
- Sync-Status: `Synchronisiert 200/200 Rezepte (zuletzt 15:42)`.
|
||||
- „Jetzt aktualisieren"-Button.
|
||||
- „Offline-Cache leeren"-Button (destructive, zweistufig bestätigt) — für Debugging/Reset.
|
||||
|
||||
### Schreib-Aktionen-Verhalten
|
||||
|
||||
Betroffene Buttons in:
|
||||
- `/recipes/[id]/+page.svelte`: Rating, Favorit, Wunschliste, Cooked, Kommentar, Titel, Edit-Save, Löschen, Bildschirm-Wake-Lock
|
||||
- `/recipes/+page.svelte` (Register): Import, Blank-Create
|
||||
- `/wishlist/+page.svelte`: Wunschliste-Toggle, Für-alle-entfernen
|
||||
- `/admin/*/+page.svelte`: Domain-CRUD, Profile-CRUD, Backup
|
||||
|
||||
Pattern pro Klick:
|
||||
|
||||
```ts
|
||||
if (!network.online) {
|
||||
toastStore.error('Nicht verbunden — die Aktion speichert nicht.');
|
||||
return;
|
||||
}
|
||||
// ... dann normal fetch ...
|
||||
```
|
||||
|
||||
Alternative: Fetch versuchen, bei `TypeError: Failed to fetch` im catch toasten. Beides ist OK. Design-Entscheidung: **proaktiver Check** — klarere UX, keine falschen optimistischen UI-Updates.
|
||||
|
||||
### PWA-Manifest-Ergänzungen
|
||||
|
||||
`static/manifest.webmanifest`:
|
||||
|
||||
```json
|
||||
{
|
||||
"icons": [
|
||||
{ "src": "/icon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any maskable" },
|
||||
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any maskable" },
|
||||
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any maskable" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Icons werden lokal einmalig aus `static/icon.svg` gerendert (Inkscape oder `rsvg-convert`) und committed. Keine CI-Abhängigkeit.
|
||||
|
||||
## Dateien
|
||||
|
||||
### Neu
|
||||
|
||||
- `src/service-worker.ts` — SW-Hauptdatei (install/activate/fetch/message-Handler, Pre-Cache-Orchestrator)
|
||||
- `src/lib/client/sync-status.svelte.ts` — Sync-Status-Store
|
||||
- `src/lib/client/network.svelte.ts` — Online-Status-Store
|
||||
- `src/lib/client/toast.svelte.ts` — Toast-Store
|
||||
- `src/lib/components/SyncIndicator.svelte` — bottom-right Pill + Overlay-Karte
|
||||
- `src/lib/components/Toast.svelte` — Toast-Renderer
|
||||
- `src/routes/admin/app/+page.svelte` — Admin-Tab „App"
|
||||
- `static/icon-192.png`, `static/icon-512.png` — PWA-Icons (einmal gerendert, committed)
|
||||
- `tests/integration/sw-cache-strategy.test.ts` — Unit-Tests für die Cache-Strategy-Entscheider + Diff-Logik
|
||||
- `tests/e2e/offline.spec.ts` — Playwright: Offline-Navigation, Sync-Indikator, Schreib-Aktion-Toast
|
||||
|
||||
### Geändert
|
||||
|
||||
- `static/manifest.webmanifest` — PNG-Icons ergänzen, `purpose: "any maskable"`
|
||||
- `src/routes/+layout.svelte` — SW registrieren, `<SyncIndicator />` + `<Toast />` einbinden, Network-Store initialisieren
|
||||
- `src/routes/admin/+layout.svelte` — vierten Tab „App" mit Smartphone-Icon
|
||||
- Alle Seiten mit Schreib-Buttons — proaktiver `network.online`-Check
|
||||
|
||||
### Nicht angefasst
|
||||
|
||||
- Backend (`src/lib/server/**`, `src/routes/api/**`) — reines Frontend-Feature
|
||||
- Datenbank-Schema
|
||||
- Deployment (Dockerfile, compose-Dateien)
|
||||
|
||||
## Test-Strategie
|
||||
|
||||
### Unit-Tests (vitest)
|
||||
|
||||
- `sync-status.svelte.ts`: State-Übergänge bei Messages
|
||||
- `toast.svelte.ts`: Store-API, Auto-Dismiss
|
||||
- `sw-cache-strategy.test.ts`:
|
||||
- `resolveStrategy(url)` → gibt Strategy-Namen zurück (cache-first, swr, network-only)
|
||||
- `diffManifest(currentIds, cachedIds)` → `{ toAdd, toRemove }`
|
||||
- Concurrency-Queue: vier parallel, Gesamt-Reihenfolge idempotent
|
||||
|
||||
### E2E-Tests (Playwright, lokales Docker)
|
||||
|
||||
- **Install + Sync**: Seite öffnen, warten bis `sync-done`, Cache-Einträge überprüfen.
|
||||
- **Offline-Lesen**: Netz aus (Playwright-API), Navigation `/` → `/recipes/[id]` → zurück, Rezept ist sichtbar.
|
||||
- **Offline-Schreiben**: Netz aus, Favorit-Toggle klicken, Toast erscheint, Herz nicht gefüllt.
|
||||
- **Update-Sync**: Im Browser ein neues Rezept via Register importieren, Tab neu laden, `sync-check` feuert, Rezept-ID-Liste gewachsen.
|
||||
- **Sync-Indikator-Zustände**: Manuell getriggert, alle drei States visuell überprüfen.
|
||||
|
||||
### Manuelle Tests
|
||||
|
||||
- Android Chrome: beforeinstallprompt → Install-Button → Home-Screen-App startet
|
||||
- Safari iOS: Teilen → Zum Home-Bildschirm, Start der App, Offline-Navigation
|
||||
- Chrome DevTools → Application → Storage → Clear Site Data → Re-Load → Initial-Sync läuft durch
|
||||
|
||||
## Out of Scope (v1.1)
|
||||
|
||||
Bewusst raus, mögliche v1.2-Themen:
|
||||
|
||||
- **Background Sync für Schreib-Aktionen** — Rating/Kommentare offline speichern und später syncen. Braucht Konflikt-Resolution, schedule.sync-API, Duplikat-Erkennung.
|
||||
- **Push-Benachrichtigungen** — "Jemand hat ein neues Rezept hinzugefügt". Viel Infrastruktur für wenig Nutzen.
|
||||
- **Offline-Web-Suche** — nicht sinnvoll, braucht SearXNG.
|
||||
- **Partial-Sync nach Profil** — alle Rezepte bleiben synchronisiert, keine Profil-spezifische Teilmenge.
|
||||
|
||||
## Risiken + Mitigation
|
||||
|
||||
| Risiko | Mitigation |
|
||||
|---|---|
|
||||
| Storage-Quota erschöpft | `navigator.storage.estimate()` vor Sync, Toast bei < 100 MB frei |
|
||||
| SW-Deploy: alte Clients sehen alten Cache | Cache-Name inkl. Build-Hash, `activate` räumt alte Versionen |
|
||||
| Alter SW blockiert Update | `skipWaiting()` + `clients.claim()` — neuer SW übernimmt sofort |
|
||||
| Fetch-Loop (SW ruft sich selbst) | Exakte URL-Muster-Matching, keine Wildcards auf `/api/**` |
|
||||
| iOS Safari vergisst Cache | Bekanntes iOS-Verhalten bei langer Inaktivität; Akzeptieren, nächster Start synct nach |
|
||||
| SW nur auf HTTPS oder localhost | Produktion läuft unter `https://kochwas.siegeln.net` ✓. Dev-Server läuft auf HTTP — für SW-Tests braucht's entweder `npm run build && npm run preview` (baut auf localhost, SW registrierbar) oder die lokale Docker-Compose-Prod-Variante |
|
||||
1152
package-lock.json
generated
1152
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -11,16 +11,22 @@
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"lint": "eslint .",
|
||||
"format": "prettier --write ."
|
||||
"format": "prettier --write .",
|
||||
"render:icons": "node scripts/render-icons.mjs",
|
||||
"test:e2e": "playwright test",
|
||||
"test:e2e:ui": "playwright test --ui"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.59.1",
|
||||
"@sveltejs/adapter-node": "^5.2.0",
|
||||
"@sveltejs/kit": "^2.8.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^4.0.0",
|
||||
"@types/better-sqlite3": "^7.6.11",
|
||||
"@types/node": "^22.9.0",
|
||||
"jsdom": "^29.0.2",
|
||||
"prettier": "^3.3.3",
|
||||
"prettier-plugin-svelte": "^3.2.7",
|
||||
"sharp": "^0.34.5",
|
||||
"svelte": "^5.1.0",
|
||||
"svelte-check": "^4.0.5",
|
||||
"typescript": "^5.6.3",
|
||||
|
||||
21
playwright.config.ts
Normal file
21
playwright.config.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { defineConfig } from '@playwright/test';
|
||||
|
||||
// E2E-Tests nutzen den SvelteKit-Preview-Build. `npm run build` muss
|
||||
// vor den Tests gelaufen sein — Playwright startet dann nur den
|
||||
// Preview-Server (kein Dev-Server, damit der SW registrierbar ist).
|
||||
export default defineConfig({
|
||||
testDir: 'tests/e2e',
|
||||
fullyParallel: false,
|
||||
reporter: 'list',
|
||||
use: {
|
||||
baseURL: 'http://localhost:4173',
|
||||
headless: true,
|
||||
serviceWorkers: 'allow'
|
||||
},
|
||||
webServer: {
|
||||
command: 'npm run preview',
|
||||
url: 'http://localhost:4173',
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 30_000
|
||||
}
|
||||
});
|
||||
19
scripts/render-icons.mjs
Normal file
19
scripts/render-icons.mjs
Normal file
@@ -0,0 +1,19 @@
|
||||
// Rendert PWA-Icons aus static/icon.svg in die Größen, die Android/iOS
|
||||
// für Home-Screen-Icons bevorzugen. Einmal lokal ausführen und die
|
||||
// PNGs committen — keine CI-Abhängigkeit.
|
||||
import sharp from 'sharp';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, join } from 'node:path';
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const root = join(here, '..');
|
||||
const src = await readFile(join(root, 'static/icon.svg'));
|
||||
|
||||
for (const size of [192, 512]) {
|
||||
await sharp(src, { density: 400 })
|
||||
.resize(size, size, { fit: 'contain', background: { r: 248, g: 250, b: 248, alpha: 1 } })
|
||||
.png()
|
||||
.toFile(join(root, `static/icon-${size}.png`));
|
||||
console.log(`wrote static/icon-${size}.png`);
|
||||
}
|
||||
44
src/lib/client/install-prompt.svelte.ts
Normal file
44
src/lib/client/install-prompt.svelte.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
// Captures the beforeinstallprompt event (Android Chrome) and holds it for
|
||||
// manual triggering by the user. On iOS Safari this event does not exist —
|
||||
// we detect the browser via UserAgent and show an info hint instead.
|
||||
class InstallPromptStore {
|
||||
available = $state(false);
|
||||
platform = $state<'android' | 'ios' | 'other'>('other');
|
||||
private deferred: BeforeInstallPromptEvent | null = null;
|
||||
|
||||
init(): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
this.platform = detectPlatform();
|
||||
window.addEventListener('beforeinstallprompt', (e) => {
|
||||
e.preventDefault();
|
||||
this.deferred = e as BeforeInstallPromptEvent;
|
||||
this.available = true;
|
||||
});
|
||||
window.addEventListener('appinstalled', () => {
|
||||
this.deferred = null;
|
||||
this.available = false;
|
||||
});
|
||||
}
|
||||
|
||||
async prompt(): Promise<void> {
|
||||
if (!this.deferred) return;
|
||||
await this.deferred.prompt();
|
||||
this.deferred = null;
|
||||
this.available = false;
|
||||
}
|
||||
}
|
||||
|
||||
function detectPlatform(): 'android' | 'ios' | 'other' {
|
||||
const ua = navigator.userAgent;
|
||||
if (/iPhone|iPad|iPod/i.test(ua)) return 'ios';
|
||||
if (/Android/i.test(ua)) return 'android';
|
||||
return 'other';
|
||||
}
|
||||
|
||||
// Minimal type for the Chrome-specific event
|
||||
type BeforeInstallPromptEvent = Event & {
|
||||
prompt: () => Promise<void>;
|
||||
userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>;
|
||||
};
|
||||
|
||||
export const installPrompt = new InstallPromptStore();
|
||||
14
src/lib/client/network.svelte.ts
Normal file
14
src/lib/client/network.svelte.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
// Reaktiver Online-Status, basierend auf navigator.onLine + events.
|
||||
// Bewusst kein aktives Heuristik-Probing (Test-Fetches) — für unsere
|
||||
// Zwecke reicht der Browser-Status.
|
||||
class NetworkStore {
|
||||
online = $state(typeof navigator === 'undefined' ? true : navigator.onLine);
|
||||
|
||||
init(): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
window.addEventListener('online', () => (this.online = true));
|
||||
window.addEventListener('offline', () => (this.online = false));
|
||||
}
|
||||
}
|
||||
|
||||
export const network = new NetworkStore();
|
||||
@@ -1,3 +1,19 @@
|
||||
// Service-Worker-Update-Pattern: Workbox-Style Handshake (kein
|
||||
// skipWaiting im install-Handler, User bestätigt via Toast) mit
|
||||
// zusätzlichem Zombie-Schutz.
|
||||
//
|
||||
// Warum der Zombie-Schutz nötig ist: Chromium hält auf diesem Deploy
|
||||
// reproduzierbar nach einem SKIP_WAITING+Reload einen bit-identischen
|
||||
// waiting-SW im Registration-Slot — wohl durch einen Race zwischen
|
||||
// SW-Update-Check und activate. Der reine Workbox-Standard würde den
|
||||
// als „neues Update" interpretieren und den Toast bei jedem Reload
|
||||
// erneut zeigen. Wir fragen darum per MessageChannel GET_VERSION an
|
||||
// beiden SWs, vergleichen und räumen identische Bytes still auf.
|
||||
//
|
||||
// Kritisch: Der Reload beim controllerchange darf NUR durch User-Klick
|
||||
// passieren, nicht automatisch beim silent Cleanup — sonst ergibt der
|
||||
// Zombie-Refresh einen Endlos-Reload-Loop, weil der Browser jede neue
|
||||
// Seite wieder mit frischem Zombie ausstattet.
|
||||
class PwaStore {
|
||||
updateAvailable = $state(false);
|
||||
private registration: ServiceWorkerRegistration | null = null;
|
||||
@@ -5,6 +21,7 @@ class PwaStore {
|
||||
|
||||
async init(): Promise<void> {
|
||||
if (typeof navigator === 'undefined' || !('serviceWorker' in navigator)) return;
|
||||
|
||||
try {
|
||||
this.registration = await navigator.serviceWorker.ready;
|
||||
} catch {
|
||||
@@ -12,10 +29,8 @@ class PwaStore {
|
||||
}
|
||||
if (!this.registration) return;
|
||||
|
||||
// Wenn beim Mount schon ein neuer SW installiert und aktiv wartet,
|
||||
// zeigen wir den Toast direkt an.
|
||||
if (this.registration.waiting) {
|
||||
this.updateAvailable = true;
|
||||
if (this.registration.waiting && this.registration.active) {
|
||||
await this.evaluateWaiting(this.registration.waiting, this.registration.active);
|
||||
}
|
||||
|
||||
this.registration.addEventListener('updatefound', () => this.onUpdateFound());
|
||||
@@ -31,17 +46,47 @@ class PwaStore {
|
||||
const installing = this.registration?.installing;
|
||||
if (!installing) return;
|
||||
installing.addEventListener('statechange', () => {
|
||||
// 'installed' UND ein laufender controller = Update für bestehenden Tab.
|
||||
// (Ohne controller wäre das die erste Installation, kein Update.)
|
||||
if (installing.state === 'installed' && navigator.serviceWorker.controller) {
|
||||
if (installing.state !== 'installed' || !navigator.serviceWorker.controller) return;
|
||||
const active = this.registration?.active;
|
||||
if (active && active !== installing) {
|
||||
void this.evaluateWaiting(installing, active);
|
||||
} else {
|
||||
this.updateAvailable = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async evaluateWaiting(waiting: ServiceWorker, active: ServiceWorker): Promise<void> {
|
||||
const [waitingVersion, activeVersion] = await Promise.all([
|
||||
queryVersion(waiting),
|
||||
queryVersion(active)
|
||||
]);
|
||||
if (waitingVersion && activeVersion && waitingVersion === activeVersion) {
|
||||
// Bit-identischer Zombie: silent aufräumen, KEIN reload — die Seite
|
||||
// läuft nahtlos unter dem neuen SW weiter (funktional identisch).
|
||||
waiting.postMessage({ type: 'SKIP_WAITING' });
|
||||
return;
|
||||
}
|
||||
// Versions-Unterschied oder unbekannt: User entscheidet via Toast.
|
||||
this.updateAvailable = true;
|
||||
}
|
||||
|
||||
reload(): void {
|
||||
this.updateAvailable = false;
|
||||
const waiting = this.registration?.waiting;
|
||||
if (!waiting) {
|
||||
// Kein wartender SW — reicht ein normaler Reload.
|
||||
location.reload();
|
||||
return;
|
||||
}
|
||||
// Klassisches Pattern: User-Klick → SKIP_WAITING → controllerchange
|
||||
// feuert, wenn der neue SW übernimmt → dann reloaden wir einmalig.
|
||||
navigator.serviceWorker.addEventListener(
|
||||
'controllerchange',
|
||||
() => location.reload(),
|
||||
{ once: true }
|
||||
);
|
||||
waiting.postMessage({ type: 'SKIP_WAITING' });
|
||||
}
|
||||
|
||||
dismiss(): void {
|
||||
@@ -49,4 +94,22 @@ class PwaStore {
|
||||
}
|
||||
}
|
||||
|
||||
function queryVersion(sw: ServiceWorker): Promise<string | null> {
|
||||
return new Promise((resolve) => {
|
||||
const channel = new MessageChannel();
|
||||
const timer = setTimeout(() => resolve(null), 1500);
|
||||
channel.port1.onmessage = (e) => {
|
||||
clearTimeout(timer);
|
||||
const v = (e.data as { version?: unknown } | null)?.version;
|
||||
resolve(typeof v === 'string' ? v : null);
|
||||
};
|
||||
try {
|
||||
sw.postMessage({ type: 'GET_VERSION' }, [channel.port2]);
|
||||
} catch {
|
||||
clearTimeout(timer);
|
||||
resolve(null);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export const pwaStore = new PwaStore();
|
||||
|
||||
10
src/lib/client/require-online.ts
Normal file
10
src/lib/client/require-online.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { network } from './network.svelte';
|
||||
import { toastStore } from './toast.svelte';
|
||||
|
||||
// Soll vor jedem Schreib-Fetch aufgerufen werden. Liefert true wenn
|
||||
// online (User darf weitermachen) oder false + Toast wenn offline.
|
||||
export function requireOnline(action = 'Die Aktion'): boolean {
|
||||
if (network.online) return true;
|
||||
toastStore.error(`${action} braucht eine Internet-Verbindung.`);
|
||||
return false;
|
||||
}
|
||||
33
src/lib/client/sw-register.ts
Normal file
33
src/lib/client/sw-register.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
// Registriert den Service-Worker und verdrahtet ihn mit dem
|
||||
// Sync-Status-Store. Im Dev-Modus läuft Kochwas über HTTP; die
|
||||
// SW-API ist da nur auf localhost verfügbar. SvelteKit liefert den
|
||||
// SW unter /service-worker.js im Production-Build.
|
||||
import { syncStatus, type SWMessage } from '$lib/client/sync-status.svelte';
|
||||
|
||||
export async function registerServiceWorker(): Promise<void> {
|
||||
if (typeof navigator === 'undefined' || !('serviceWorker' in navigator)) return;
|
||||
try {
|
||||
await navigator.serviceWorker.register('/service-worker.js', { type: 'module' });
|
||||
} catch (e) {
|
||||
console.warn('SW-Registrierung fehlgeschlagen', e);
|
||||
return;
|
||||
}
|
||||
navigator.serviceWorker.addEventListener('message', (event) => {
|
||||
const data = event.data as SWMessage | undefined;
|
||||
if (data && typeof data === 'object' && 'type' in data) {
|
||||
syncStatus.handle(data);
|
||||
}
|
||||
});
|
||||
|
||||
// Beim App-Start: wenn wir einen aktiven SW haben, frage ihn, ob er
|
||||
// neu synct (initial oder Delta).
|
||||
if (navigator.serviceWorker.controller) {
|
||||
navigator.serviceWorker.controller.postMessage({ type: 'sync-check' });
|
||||
} else {
|
||||
// Erste Session: SW kommt erst mit dem nächsten Reload zum Einsatz.
|
||||
// Beim nächsten Start triggert sync-check dann den Initial-Sync.
|
||||
navigator.serviceWorker.ready.then((reg) => {
|
||||
reg.active?.postMessage({ type: 'sync-start' });
|
||||
});
|
||||
}
|
||||
}
|
||||
53
src/lib/client/sync-status.svelte.ts
Normal file
53
src/lib/client/sync-status.svelte.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
// State, den der Service-Worker per postMessage befüllt. Die App
|
||||
// spiegelt den Sync-Fortschritt im SyncIndicator.
|
||||
export type SyncState =
|
||||
| { kind: 'idle' }
|
||||
| { kind: 'syncing'; current: number; total: number }
|
||||
| { kind: 'error'; message: string };
|
||||
|
||||
export type SWMessage =
|
||||
| { type: 'sync-start'; total: number }
|
||||
| { type: 'sync-progress'; current: number; total: number }
|
||||
| { type: 'sync-done'; lastSynced: number }
|
||||
| { type: 'sync-error'; message: string };
|
||||
|
||||
const STORAGE_KEY = 'kochwas.sw.lastSynced';
|
||||
|
||||
function loadLastSynced(): number | null {
|
||||
if (typeof localStorage === 'undefined') return null;
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return null;
|
||||
const n = Number(raw);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
function saveLastSynced(ts: number): void {
|
||||
if (typeof localStorage === 'undefined') return;
|
||||
localStorage.setItem(STORAGE_KEY, String(ts));
|
||||
}
|
||||
|
||||
class SyncStatusStore {
|
||||
state = $state<SyncState>({ kind: 'idle' });
|
||||
lastSynced = $state<number | null>(loadLastSynced());
|
||||
|
||||
handle(msg: SWMessage): void {
|
||||
switch (msg.type) {
|
||||
case 'sync-start':
|
||||
this.state = { kind: 'syncing', current: 0, total: msg.total };
|
||||
break;
|
||||
case 'sync-progress':
|
||||
this.state = { kind: 'syncing', current: msg.current, total: msg.total };
|
||||
break;
|
||||
case 'sync-done':
|
||||
this.state = { kind: 'idle' };
|
||||
this.lastSynced = msg.lastSynced;
|
||||
saveLastSynced(msg.lastSynced);
|
||||
break;
|
||||
case 'sync-error':
|
||||
this.state = { kind: 'error', message: msg.message };
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const syncStatus = new SyncStatusStore();
|
||||
25
src/lib/client/toast.svelte.ts
Normal file
25
src/lib/client/toast.svelte.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
export type ToastKind = 'info' | 'error' | 'success';
|
||||
export type Toast = { id: number; kind: ToastKind; message: string };
|
||||
|
||||
class ToastStore {
|
||||
toasts = $state<Toast[]>([]);
|
||||
private nextId = 1;
|
||||
private readonly dismissMs = 3000;
|
||||
|
||||
private push(kind: ToastKind, message: string): number {
|
||||
const id = this.nextId++;
|
||||
this.toasts = [...this.toasts, { id, kind, message }];
|
||||
setTimeout(() => this.dismiss(id), this.dismissMs);
|
||||
return id;
|
||||
}
|
||||
|
||||
info(message: string): number { return this.push('info', message); }
|
||||
error(message: string): number { return this.push('error', message); }
|
||||
success(message: string): number { return this.push('success', message); }
|
||||
|
||||
dismiss(id: number): void {
|
||||
this.toasts = this.toasts.filter((t) => t.id !== id);
|
||||
}
|
||||
}
|
||||
|
||||
export const toastStore = new ToastStore();
|
||||
129
src/lib/components/SyncIndicator.svelte
Normal file
129
src/lib/components/SyncIndicator.svelte
Normal file
@@ -0,0 +1,129 @@
|
||||
<script lang="ts">
|
||||
import { RefreshCw, WifiOff } from 'lucide-svelte';
|
||||
import { network } from '$lib/client/network.svelte';
|
||||
import { syncStatus } from '$lib/client/sync-status.svelte';
|
||||
|
||||
let expanded = $state(false);
|
||||
|
||||
const label = $derived.by(() => {
|
||||
if (syncStatus.state.kind === 'syncing') {
|
||||
return `Sync ${syncStatus.state.current}/${syncStatus.state.total}`;
|
||||
}
|
||||
if (!network.online) return 'Offline';
|
||||
return null;
|
||||
});
|
||||
|
||||
function formatRelative(ts: number | null): string {
|
||||
if (ts === null) return 'noch nicht synchronisiert';
|
||||
const diffMs = Date.now() - ts;
|
||||
const min = Math.round(diffMs / 60_000);
|
||||
if (min < 1) return 'gerade eben';
|
||||
if (min < 60) return `vor ${min} Min`;
|
||||
const h = Math.round(min / 60);
|
||||
if (h < 24) return `vor ${h} Std`;
|
||||
const d = Math.round(h / 24);
|
||||
return `vor ${d} Tag${d === 1 ? '' : 'en'}`;
|
||||
}
|
||||
|
||||
function requestRefresh() {
|
||||
navigator.serviceWorker?.controller?.postMessage({ type: 'sync-check' });
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if label}
|
||||
<div class="wrap">
|
||||
<button
|
||||
type="button"
|
||||
class="pill"
|
||||
class:offline={!network.online}
|
||||
class:syncing={syncStatus.state.kind === 'syncing'}
|
||||
aria-label={label}
|
||||
aria-expanded={expanded}
|
||||
onclick={() => (expanded = !expanded)}
|
||||
>
|
||||
{#if !network.online}
|
||||
<WifiOff size={14} strokeWidth={2} />
|
||||
{:else}
|
||||
<RefreshCw size={14} strokeWidth={2} class="spin" />
|
||||
{/if}
|
||||
<span>{label}</span>
|
||||
</button>
|
||||
{#if expanded}
|
||||
<div class="card" role="dialog">
|
||||
<p class="when">Zuletzt synchronisiert: {formatRelative(syncStatus.lastSynced)}</p>
|
||||
<button class="refresh" type="button" onclick={requestRefresh} disabled={!network.online}>
|
||||
Jetzt aktualisieren
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.wrap {
|
||||
position: fixed;
|
||||
right: 0.75rem;
|
||||
bottom: 0.75rem;
|
||||
z-index: 50;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
padding: 0.3rem 0.65rem;
|
||||
background: white;
|
||||
border: 1px solid #cfd9d1;
|
||||
border-radius: 999px;
|
||||
color: #555;
|
||||
font-size: 0.78rem;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.06);
|
||||
font-family: inherit;
|
||||
}
|
||||
.pill.offline {
|
||||
color: #666;
|
||||
background: #f1f3f1;
|
||||
}
|
||||
.pill.syncing {
|
||||
color: #2b6a3d;
|
||||
border-color: #b7d6c2;
|
||||
background: #eaf4ed;
|
||||
}
|
||||
.pill :global(.spin) {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
.card {
|
||||
background: white;
|
||||
border: 1px solid #e4eae7;
|
||||
border-radius: 10px;
|
||||
padding: 0.6rem 0.75rem;
|
||||
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.1);
|
||||
font-size: 0.82rem;
|
||||
min-width: 220px;
|
||||
}
|
||||
.when {
|
||||
margin: 0 0 0.4rem;
|
||||
color: #555;
|
||||
}
|
||||
.refresh {
|
||||
padding: 0.4rem 0.7rem;
|
||||
background: #2b6a3d;
|
||||
color: white;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
font-size: 0.82rem;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
}
|
||||
.refresh:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
55
src/lib/components/Toast.svelte
Normal file
55
src/lib/components/Toast.svelte
Normal file
@@ -0,0 +1,55 @@
|
||||
<script lang="ts">
|
||||
import { X } from 'lucide-svelte';
|
||||
import { toastStore } from '$lib/client/toast.svelte';
|
||||
</script>
|
||||
|
||||
<div class="toasts" aria-live="polite" aria-atomic="true">
|
||||
{#each toastStore.toasts as t (t.id)}
|
||||
<div class="toast" class:error={t.kind === 'error'} class:success={t.kind === 'success'}>
|
||||
<span class="msg">{t.message}</span>
|
||||
<button class="close" aria-label="Schließen" onclick={() => toastStore.dismiss(t.id)}>
|
||||
<X size={14} strokeWidth={2} />
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.toasts {
|
||||
position: fixed;
|
||||
top: 0.75rem;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 200;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
pointer-events: none;
|
||||
}
|
||||
.toast {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
padding: 0.6rem 0.75rem;
|
||||
background: #2b6a3d;
|
||||
color: white;
|
||||
border-radius: 10px;
|
||||
font-size: 0.9rem;
|
||||
pointer-events: auto;
|
||||
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.15);
|
||||
max-width: min(92vw, 480px);
|
||||
}
|
||||
.toast.error { background: #c53030; }
|
||||
.toast.success { background: #2b6a3d; }
|
||||
.close {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.15rem;
|
||||
opacity: 0.85;
|
||||
}
|
||||
.close:hover { opacity: 1; }
|
||||
</style>
|
||||
@@ -1,16 +0,0 @@
|
||||
import type Database from 'better-sqlite3';
|
||||
import { normalizeDomain } from './repository';
|
||||
|
||||
export function isDomainAllowed(db: Database.Database, urlString: string): boolean {
|
||||
let host: string;
|
||||
try {
|
||||
host = new URL(urlString).hostname;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
const normalized = normalizeDomain(host);
|
||||
const row = db
|
||||
.prepare('SELECT 1 AS ok FROM allowed_domain WHERE domain = ? LIMIT 1')
|
||||
.get(normalized);
|
||||
return row !== undefined;
|
||||
}
|
||||
@@ -4,10 +4,8 @@ import { ImporterError } from './recipes/importer';
|
||||
export function mapImporterError(e: unknown): never {
|
||||
if (e instanceof ImporterError) {
|
||||
const status =
|
||||
e.code === 'INVALID_URL' || e.code === 'DOMAIN_BLOCKED'
|
||||
? e.code === 'DOMAIN_BLOCKED'
|
||||
? 403
|
||||
: 400
|
||||
e.code === 'INVALID_URL'
|
||||
? 400
|
||||
: e.code === 'NO_RECIPE_FOUND'
|
||||
? 422
|
||||
: 502; // FETCH_FAILED
|
||||
|
||||
@@ -2,7 +2,6 @@ import type Database from 'better-sqlite3';
|
||||
import type { Recipe } from '$lib/types';
|
||||
import { fetchText } from '../http';
|
||||
import { extractRecipeFromHtml } from '../parsers/json-ld-recipe';
|
||||
import { isDomainAllowed } from '../domains/whitelist';
|
||||
import { downloadImage } from '../images/image-downloader';
|
||||
import {
|
||||
getRecipeById,
|
||||
@@ -14,7 +13,6 @@ export class ImporterError extends Error {
|
||||
constructor(
|
||||
public readonly code:
|
||||
| 'INVALID_URL'
|
||||
| 'DOMAIN_BLOCKED'
|
||||
| 'FETCH_FAILED'
|
||||
| 'NO_RECIPE_FOUND',
|
||||
message: string
|
||||
@@ -32,11 +30,12 @@ function hostnameOrThrow(url: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
export async function previewRecipe(db: Database.Database, url: string): Promise<Recipe> {
|
||||
// Manuelle URL-Importe sind absichtlich NICHT mehr auf die allowed_domain-
|
||||
// Whitelist beschränkt — der User pastet bewusst eine URL und erwartet,
|
||||
// dass der Import klappt. Die Whitelist bleibt für die Web-Suche (searxng)
|
||||
// relevant, weil dort ein breites Crawl-Feld eingeschränkt werden soll.
|
||||
export async function previewRecipe(_db: Database.Database, url: string): Promise<Recipe> {
|
||||
const host = hostnameOrThrow(url);
|
||||
if (!isDomainAllowed(db, url)) {
|
||||
throw new ImporterError('DOMAIN_BLOCKED', `Domain not allowed: ${host}`);
|
||||
}
|
||||
let html: string;
|
||||
try {
|
||||
html = await fetchText(url);
|
||||
|
||||
42
src/lib/sw/cache-strategy.ts
Normal file
42
src/lib/sw/cache-strategy.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
export type CacheStrategy = 'shell' | 'swr' | 'images' | 'network-only';
|
||||
|
||||
export type RequestShape = { url: string; method: string };
|
||||
|
||||
// Pure function — sole decision-maker for "which strategy for this request?".
|
||||
// Called by the service worker for every fetch event.
|
||||
export function resolveStrategy(req: RequestShape): CacheStrategy {
|
||||
// All write methods: never cache.
|
||||
if (req.method !== 'GET' && req.method !== 'HEAD') return 'network-only';
|
||||
|
||||
// Reduce URL to pathname — query string not needed for matching
|
||||
// except that online-only endpoints need no special handling here.
|
||||
const path = req.url.startsWith('http') ? new URL(req.url).pathname : req.url.split('?')[0];
|
||||
|
||||
// Explicitly online-only GETs
|
||||
if (
|
||||
path === '/api/recipes/import' ||
|
||||
path === '/api/recipes/preview' ||
|
||||
path.startsWith('/api/recipes/search/web')
|
||||
) {
|
||||
return 'network-only';
|
||||
}
|
||||
|
||||
// Images
|
||||
if (path.startsWith('/images/')) return 'images';
|
||||
|
||||
// App-shell: build assets and known static files
|
||||
if (
|
||||
path.startsWith('/_app/') ||
|
||||
path === '/manifest.webmanifest' ||
|
||||
path === '/icon.svg' ||
|
||||
path === '/icon-192.png' ||
|
||||
path === '/icon-512.png' ||
|
||||
path === '/favicon.ico' ||
|
||||
path === '/robots.txt'
|
||||
) {
|
||||
return 'shell';
|
||||
}
|
||||
|
||||
// Everything else: recipe pages, API reads, lists — all SWR.
|
||||
return 'swr';
|
||||
}
|
||||
14
src/lib/sw/diff-manifest.ts
Normal file
14
src/lib/sw/diff-manifest.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
// Vergleicht die aktuelle Rezept-ID-Liste (vom Server) mit dem, was
|
||||
// der Cache schon hat. Der SW nutzt das Delta, um nur Neue zu laden
|
||||
// und Gelöschte abzuräumen.
|
||||
export type ManifestDiff = { toAdd: number[]; toRemove: number[] };
|
||||
|
||||
export function diffManifest(currentIds: number[], cachedIds: number[]): ManifestDiff {
|
||||
const current = new Set(currentIds);
|
||||
const cached = new Set(cachedIds);
|
||||
const toAdd: number[] = [];
|
||||
const toRemove: number[] = [];
|
||||
for (const id of current) if (!cached.has(id)) toAdd.push(id);
|
||||
for (const id of cached) if (!current.has(id)) toRemove.push(id);
|
||||
return { toAdd, toRemove };
|
||||
}
|
||||
@@ -12,6 +12,11 @@
|
||||
import SearchLoader from '$lib/components/SearchLoader.svelte';
|
||||
import SearchFilter from '$lib/components/SearchFilter.svelte';
|
||||
import UpdateToast from '$lib/components/UpdateToast.svelte';
|
||||
import Toast from '$lib/components/Toast.svelte';
|
||||
import SyncIndicator from '$lib/components/SyncIndicator.svelte';
|
||||
import { network } from '$lib/client/network.svelte';
|
||||
import { installPrompt } from '$lib/client/install-prompt.svelte';
|
||||
import { registerServiceWorker } from '$lib/client/sw-register';
|
||||
import type { SearchHit } from '$lib/server/recipes/search-local';
|
||||
import type { WebHit } from '$lib/server/search/searxng';
|
||||
|
||||
@@ -202,6 +207,9 @@
|
||||
void wishlistStore.refresh();
|
||||
void searchFilterStore.load();
|
||||
void pwaStore.init();
|
||||
network.init();
|
||||
installPrompt.init();
|
||||
void registerServiceWorker();
|
||||
document.addEventListener('click', handleClickOutside);
|
||||
document.addEventListener('keydown', handleKey);
|
||||
return () => {
|
||||
@@ -211,6 +219,8 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<Toast />
|
||||
<SyncIndicator />
|
||||
<ConfirmDialog />
|
||||
<UpdateToast />
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
import SearchFilter from '$lib/components/SearchFilter.svelte';
|
||||
import { profileStore } from '$lib/client/profile.svelte';
|
||||
import { searchFilterStore } from '$lib/client/search-filter.svelte';
|
||||
import { requireOnline } from '$lib/client/require-online';
|
||||
|
||||
const LOCAL_PAGE = 30;
|
||||
|
||||
@@ -357,6 +358,7 @@
|
||||
async function dismissFromRecent(recipeId: number, e: MouseEvent) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!requireOnline('Das Entfernen')) return;
|
||||
recent = recent.filter((r) => r.id !== recipeId);
|
||||
await fetch(`/api/recipes/${recipeId}`, {
|
||||
method: 'PATCH',
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import { Globe, Users, DatabaseBackup, type Icon } from 'lucide-svelte';
|
||||
import { Globe, Users, DatabaseBackup, Smartphone, type Icon } from 'lucide-svelte';
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
const items: { href: string; label: string; icon: typeof Icon }[] = [
|
||||
{ href: '/admin/domains', label: 'Domains', icon: Globe },
|
||||
{ href: '/admin/profiles', label: 'Profile', icon: Users },
|
||||
{ href: '/admin/backup', label: 'Backup', icon: DatabaseBackup }
|
||||
{ href: '/admin/backup', label: 'Backup', icon: DatabaseBackup },
|
||||
{ href: '/admin/app', label: 'App', icon: Smartphone }
|
||||
];
|
||||
</script>
|
||||
|
||||
|
||||
142
src/routes/admin/app/+page.svelte
Normal file
142
src/routes/admin/app/+page.svelte
Normal file
@@ -0,0 +1,142 @@
|
||||
<script lang="ts">
|
||||
import { Download, RefreshCw, Trash2 } from 'lucide-svelte';
|
||||
import { installPrompt } from '$lib/client/install-prompt.svelte';
|
||||
import { syncStatus } from '$lib/client/sync-status.svelte';
|
||||
import { network } from '$lib/client/network.svelte';
|
||||
import { confirmAction } from '$lib/client/confirm.svelte';
|
||||
import { toastStore } from '$lib/client/toast.svelte';
|
||||
import { requireOnline } from '$lib/client/require-online';
|
||||
|
||||
function triggerInstall() {
|
||||
void installPrompt.prompt();
|
||||
}
|
||||
|
||||
function triggerSync() {
|
||||
if (!requireOnline('Das Synchronisieren')) return;
|
||||
navigator.serviceWorker?.controller?.postMessage({ type: 'sync-check' });
|
||||
}
|
||||
|
||||
async function clearCache() {
|
||||
const ok = await confirmAction({
|
||||
title: 'Offline-Cache leeren?',
|
||||
message:
|
||||
'Alle lokal gespeicherten Rezepte und Bilder werden entfernt. Beim nächsten Online-Start werden sie neu geladen.',
|
||||
confirmLabel: 'Leeren',
|
||||
destructive: true
|
||||
});
|
||||
if (!ok) return;
|
||||
const keys = await caches.keys();
|
||||
await Promise.all(keys.filter((k) => k.startsWith('kochwas-')).map((k) => caches.delete(k)));
|
||||
toastStore.success('Cache geleert. Lade jetzt neu.');
|
||||
}
|
||||
|
||||
function formatTime(ts: number | null): string {
|
||||
if (ts === null) return 'noch nicht';
|
||||
return new Date(ts).toLocaleString('de-DE');
|
||||
}
|
||||
</script>
|
||||
|
||||
<h1>App</h1>
|
||||
<p class="intro">Einstellungen für die Installation und den Offline-Cache.</p>
|
||||
|
||||
<section class="card">
|
||||
<h2>Installieren</h2>
|
||||
{#if installPrompt.platform === 'ios'}
|
||||
<p>
|
||||
Öffne das Teilen-Menü in Safari und wähle <strong
|
||||
>„Zum Home-Bildschirm hinzufügen"</strong
|
||||
>.
|
||||
</p>
|
||||
{:else if installPrompt.available}
|
||||
<button type="button" class="btn primary" onclick={triggerInstall}>
|
||||
<Download size={16} strokeWidth={2} /> Als App installieren
|
||||
</button>
|
||||
{:else}
|
||||
<p class="muted">
|
||||
Installation aktuell nicht möglich (entweder schon installiert oder Browser unterstützt es
|
||||
nicht).
|
||||
</p>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>Offline-Synchronisation</h2>
|
||||
{#if syncStatus.state.kind === 'syncing'}
|
||||
<p>Lädt gerade: {syncStatus.state.current}/{syncStatus.state.total} Rezepte.</p>
|
||||
{:else if syncStatus.state.kind === 'error'}
|
||||
<p class="error">Fehler: {syncStatus.state.message}</p>
|
||||
{:else}
|
||||
<p>Zuletzt synchronisiert: {formatTime(syncStatus.lastSynced)}</p>
|
||||
{/if}
|
||||
<button type="button" class="btn" onclick={triggerSync} disabled={!network.online}>
|
||||
<RefreshCw size={16} strokeWidth={2} /> Jetzt synchronisieren
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>Cache</h2>
|
||||
<p class="muted">Nur bei Problemen: entfernt alle Offline-Daten.</p>
|
||||
<button type="button" class="btn danger" onclick={clearCache}>
|
||||
<Trash2 size={16} strokeWidth={2} /> Offline-Cache leeren
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
h1 {
|
||||
font-size: 1.3rem;
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
.intro {
|
||||
color: #666;
|
||||
margin: 0 0 1rem;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.card {
|
||||
background: white;
|
||||
border: 1px solid #e4eae7;
|
||||
border-radius: 12px;
|
||||
padding: 1rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
.card h2 {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 1rem;
|
||||
color: #2b6a3d;
|
||||
}
|
||||
.card p {
|
||||
margin: 0 0 0.6rem;
|
||||
font-size: 0.93rem;
|
||||
}
|
||||
.muted {
|
||||
color: #888;
|
||||
}
|
||||
.error {
|
||||
color: #c53030;
|
||||
}
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.55rem 0.9rem;
|
||||
border: 1px solid #cfd9d1;
|
||||
background: white;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
font-size: 0.92rem;
|
||||
min-height: 40px;
|
||||
font-family: inherit;
|
||||
}
|
||||
.btn.primary {
|
||||
background: #2b6a3d;
|
||||
color: white;
|
||||
border: 0;
|
||||
}
|
||||
.btn.danger {
|
||||
color: #c53030;
|
||||
border-color: #f1b4b4;
|
||||
}
|
||||
.btn:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
@@ -3,6 +3,7 @@
|
||||
import { Pencil, Check, X, Globe } from 'lucide-svelte';
|
||||
import type { AllowedDomain } from '$lib/types';
|
||||
import { confirmAction, alertAction } from '$lib/client/confirm.svelte';
|
||||
import { requireOnline } from '$lib/client/require-online';
|
||||
|
||||
let domains = $state<AllowedDomain[]>([]);
|
||||
let loading = $state(true);
|
||||
@@ -25,6 +26,7 @@
|
||||
async function add() {
|
||||
errored = null;
|
||||
if (!newDomain.trim()) return;
|
||||
if (!requireOnline('Das Hinzufügen')) return;
|
||||
adding = true;
|
||||
const res = await fetch('/api/domains', {
|
||||
method: 'POST',
|
||||
@@ -59,6 +61,7 @@
|
||||
|
||||
async function saveEdit(d: AllowedDomain) {
|
||||
if (!editDomain.trim()) return;
|
||||
if (!requireOnline('Das Speichern')) return;
|
||||
saving = true;
|
||||
try {
|
||||
const res = await fetch(`/api/domains/${d.id}`, {
|
||||
@@ -92,6 +95,7 @@
|
||||
destructive: true
|
||||
});
|
||||
if (!ok) return;
|
||||
if (!requireOnline('Das Entfernen')) return;
|
||||
await fetch(`/api/domains/${d.id}`, { method: 'DELETE' });
|
||||
await load();
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { profileStore } from '$lib/client/profile.svelte';
|
||||
import { confirmAction, alertAction } from '$lib/client/confirm.svelte';
|
||||
import { requireOnline } from '$lib/client/require-online';
|
||||
|
||||
let newName = $state('');
|
||||
let newEmoji = $state('🍳');
|
||||
@@ -10,6 +11,7 @@
|
||||
async function add() {
|
||||
errored = null;
|
||||
if (!newName.trim()) return;
|
||||
if (!requireOnline('Das Anlegen')) return;
|
||||
adding = true;
|
||||
try {
|
||||
await profileStore.create(newName.trim(), newEmoji || null);
|
||||
@@ -24,6 +26,7 @@
|
||||
async function rename(id: number, currentName: string) {
|
||||
const next = prompt('Neuer Name:', currentName);
|
||||
if (!next || next === currentName) return;
|
||||
if (!requireOnline('Das Umbenennen')) return;
|
||||
const res = await fetch(`/api/profiles/${id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
@@ -49,6 +52,7 @@
|
||||
destructive: true
|
||||
});
|
||||
if (!ok) return;
|
||||
if (!requireOnline('Das Löschen')) return;
|
||||
await fetch(`/api/profiles/${id}`, { method: 'DELETE' });
|
||||
if (profileStore.activeId === id) profileStore.clear();
|
||||
await profileStore.load();
|
||||
|
||||
@@ -35,7 +35,7 @@ const PatchSchema = z
|
||||
.object({
|
||||
title: z.string().min(1).max(200).optional(),
|
||||
description: z.string().max(2000).nullable().optional(),
|
||||
servings_default: z.number().int().positive().nullable().optional(),
|
||||
servings_default: z.number().int().nonnegative().nullable().optional(),
|
||||
servings_unit: z.string().max(30).nullable().optional(),
|
||||
prep_time_min: z.number().int().nonnegative().nullable().optional(),
|
||||
cook_time_min: z.number().int().nonnegative().nullable().optional(),
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { CookingPot, Link, Plus, ChevronDown, Pencil } from 'lucide-svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { alertAction } from '$lib/client/confirm.svelte';
|
||||
import { requireOnline } from '$lib/client/require-online';
|
||||
import type { PageData } from './$types';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
@@ -35,12 +36,14 @@
|
||||
e.preventDefault();
|
||||
const url = importUrl.trim();
|
||||
if (!url) return;
|
||||
if (!requireOnline('Der URL-Import')) return;
|
||||
importOpen = false;
|
||||
goto(`/preview?url=${encodeURIComponent(url)}`);
|
||||
}
|
||||
|
||||
async function createBlank() {
|
||||
if (creatingBlank) return;
|
||||
if (!requireOnline('Das Anlegen')) return;
|
||||
menuOpen = false;
|
||||
creatingBlank = true;
|
||||
try {
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
import { profileStore } from '$lib/client/profile.svelte';
|
||||
import { wishlistStore } from '$lib/client/wishlist.svelte';
|
||||
import { confirmAction, alertAction } from '$lib/client/confirm.svelte';
|
||||
import { requireOnline } from '$lib/client/require-online';
|
||||
import type { CommentRow } from '$lib/server/recipes/actions';
|
||||
|
||||
let { data } = $props();
|
||||
@@ -41,6 +42,24 @@
|
||||
let saving = $state(false);
|
||||
let recipeState = $state(data.recipe);
|
||||
|
||||
// Einmalige Pulse-Animation beim Aktivieren (nicht beim Wieder-Abwählen).
|
||||
// Per tick()-Zwischenschritt "aus → an" erzwingen, damit die Animation
|
||||
// auch bei mehrmaligem Klick innerhalb weniger hundert ms neu startet.
|
||||
let pulseFav = $state(false);
|
||||
let pulseWish = $state(false);
|
||||
|
||||
async function firePulse(which: 'fav' | 'wish') {
|
||||
if (which === 'fav') {
|
||||
pulseFav = false;
|
||||
await tick();
|
||||
pulseFav = true;
|
||||
} else {
|
||||
pulseWish = false;
|
||||
await tick();
|
||||
pulseWish = true;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveRecipe(patch: {
|
||||
title: string;
|
||||
description: string | null;
|
||||
@@ -51,6 +70,7 @@
|
||||
ingredients: typeof data.recipe.ingredients;
|
||||
steps: typeof data.recipe.steps;
|
||||
}) {
|
||||
if (!requireOnline('Das Speichern')) return;
|
||||
saving = true;
|
||||
try {
|
||||
const res = await fetch(`/api/recipes/${data.recipe.id}`, {
|
||||
@@ -109,6 +129,7 @@
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!requireOnline('Das Rating')) return;
|
||||
await fetch(`/api/recipes/${data.recipe.id}/rating`, {
|
||||
method: 'PUT',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
@@ -127,16 +148,19 @@
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!requireOnline('Das Favorit-Setzen')) return;
|
||||
const profileId = profileStore.active.id;
|
||||
const method = isFav ? 'DELETE' : 'PUT';
|
||||
const wasFav = isFav;
|
||||
const method = wasFav ? 'DELETE' : 'PUT';
|
||||
await fetch(`/api/recipes/${data.recipe.id}/favorite`, {
|
||||
method,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ profile_id: profileId })
|
||||
});
|
||||
favoriteProfileIds = isFav
|
||||
favoriteProfileIds = wasFav
|
||||
? favoriteProfileIds.filter((id) => id !== profileId)
|
||||
: [...favoriteProfileIds, profileId];
|
||||
if (!wasFav) void firePulse('fav');
|
||||
}
|
||||
|
||||
async function logCooked() {
|
||||
@@ -147,6 +171,7 @@
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!requireOnline('Der Kochjournal-Eintrag')) return;
|
||||
const res = await fetch(`/api/recipes/${data.recipe.id}/cooked`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
@@ -168,6 +193,7 @@
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!requireOnline('Das Speichern des Kommentars')) return;
|
||||
const text = newComment.trim();
|
||||
if (!text) return;
|
||||
const res = await fetch(`/api/recipes/${data.recipe.id}/comments`, {
|
||||
@@ -199,6 +225,7 @@
|
||||
destructive: true
|
||||
});
|
||||
if (!ok) return;
|
||||
if (!requireOnline('Das Löschen')) return;
|
||||
await fetch(`/api/recipes/${data.recipe.id}`, { method: 'DELETE' });
|
||||
goto('/');
|
||||
}
|
||||
@@ -222,6 +249,7 @@
|
||||
editingTitle = false;
|
||||
return;
|
||||
}
|
||||
if (!requireOnline('Das Umbenennen')) return;
|
||||
const res = await fetch(`/api/recipes/${data.recipe.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
@@ -257,8 +285,10 @@
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!requireOnline('Das Wunschlisten-Setzen')) return;
|
||||
const profileId = profileStore.active.id;
|
||||
if (onMyWishlist) {
|
||||
const wasOn = onMyWishlist;
|
||||
if (wasOn) {
|
||||
await fetch(`/api/wishlist/${data.recipe.id}?profile_id=${profileId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
@@ -272,6 +302,7 @@
|
||||
wishlistProfileIds = [...wishlistProfileIds, profileId];
|
||||
}
|
||||
void wishlistStore.refresh();
|
||||
if (!wasOn) void firePulse('wish');
|
||||
}
|
||||
|
||||
// Wake-Lock — Bildschirm beim Kochen nicht dimmen lassen.
|
||||
@@ -387,11 +418,23 @@
|
||||
{/if}
|
||||
</div>
|
||||
<div class="btn-row">
|
||||
<button class="btn" class:heart={isFav} onclick={toggleFavorite}>
|
||||
<button
|
||||
class="btn"
|
||||
class:heart={isFav}
|
||||
class:pulse={pulseFav}
|
||||
onclick={toggleFavorite}
|
||||
onanimationend={() => (pulseFav = false)}
|
||||
>
|
||||
<Heart size={18} strokeWidth={2} fill={isFav ? 'currentColor' : 'none'} />
|
||||
<span>Favorit</span>
|
||||
</button>
|
||||
<button class="btn" class:wish={onMyWishlist} onclick={toggleWishlist}>
|
||||
<button
|
||||
class="btn"
|
||||
class:wish={onMyWishlist}
|
||||
class:pulse={pulseWish}
|
||||
onclick={toggleWishlist}
|
||||
onanimationend={() => (pulseWish = false)}
|
||||
>
|
||||
{#if onMyWishlist}
|
||||
<Check size={18} strokeWidth={2.5} />
|
||||
<span>Auf Wunschliste</span>
|
||||
@@ -590,11 +633,38 @@
|
||||
color: #c53030;
|
||||
border-color: #f1b4b4;
|
||||
background: #fdf3f3;
|
||||
--pulse-color: rgba(197, 48, 48, 0.45);
|
||||
}
|
||||
.btn.wish {
|
||||
color: #2b6a3d;
|
||||
border-color: #b7d6c2;
|
||||
background: #eaf4ed;
|
||||
--pulse-color: rgba(43, 106, 61, 0.45);
|
||||
}
|
||||
/* Einmalige Bestätigung beim Aktivieren der Aktion — kurzer Scale-Bounce
|
||||
plus ausklingender Ring in der Aktionsfarbe (siehe --pulse-color).
|
||||
prefers-reduced-motion: Ring aus, kein Scale. */
|
||||
.btn.pulse {
|
||||
animation: btnPulse 0.5s ease-out;
|
||||
}
|
||||
@keyframes btnPulse {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
box-shadow: 0 0 0 0 var(--pulse-color, rgba(43, 106, 61, 0.45));
|
||||
}
|
||||
55% {
|
||||
transform: scale(1.07);
|
||||
box-shadow: 0 0 0 10px rgba(0, 0, 0, 0);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
box-shadow: 0 0 0 0 rgba(0, 0, 0, 0);
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.btn.pulse {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
.btn.screen-on {
|
||||
color: #b07e00;
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { Heart, Trash2, CookingPot } from 'lucide-svelte';
|
||||
import { Utensils, Trash2, CookingPot } from 'lucide-svelte';
|
||||
import { profileStore } from '$lib/client/profile.svelte';
|
||||
import { wishlistStore } from '$lib/client/wishlist.svelte';
|
||||
import { alertAction, confirmAction } from '$lib/client/confirm.svelte';
|
||||
import { requireOnline } from '$lib/client/require-online';
|
||||
import type { WishlistEntry, SortKey } from '$lib/server/wishlist/repository';
|
||||
|
||||
const SORT_OPTIONS: { value: SortKey; label: string }[] = [
|
||||
{ value: 'popular', label: 'Meist gewünscht' },
|
||||
{ value: 'newest', label: 'Neueste' },
|
||||
{ value: 'oldest', label: 'Älteste' }
|
||||
];
|
||||
|
||||
let entries = $state<WishlistEntry[]>([]);
|
||||
let loading = $state(true);
|
||||
let sort = $state<SortKey>('popular');
|
||||
@@ -35,6 +42,7 @@
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!requireOnline('Die Wunschlisten-Aktion')) return;
|
||||
const profileId = profileStore.active.id;
|
||||
if (entry.on_my_wishlist) {
|
||||
await fetch(`/api/wishlist/${entry.recipe_id}?profile_id=${profileId}`, {
|
||||
@@ -59,6 +67,7 @@
|
||||
destructive: true
|
||||
});
|
||||
if (!ok) return;
|
||||
if (!requireOnline('Das Entfernen')) return;
|
||||
await fetch(`/api/wishlist/${entry.recipe_id}?all=true`, { method: 'DELETE' });
|
||||
await load();
|
||||
void wishlistStore.refresh();
|
||||
@@ -80,15 +89,19 @@
|
||||
<p class="sub">Das wollen wir bald mal essen.</p>
|
||||
</header>
|
||||
|
||||
<div class="controls">
|
||||
<label>
|
||||
Sortieren:
|
||||
<select bind:value={sort}>
|
||||
<option value="popular">Am meisten gewünscht</option>
|
||||
<option value="newest">Neueste zuerst</option>
|
||||
<option value="oldest">Älteste zuerst</option>
|
||||
</select>
|
||||
</label>
|
||||
<div class="sort-chips" role="tablist" aria-label="Sortierung">
|
||||
{#each SORT_OPTIONS as s (s.value)}
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={sort === s.value}
|
||||
class="chip"
|
||||
class:active={sort === s.value}
|
||||
onclick={() => (sort = s.value)}
|
||||
>
|
||||
{s.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
@@ -131,7 +144,7 @@
|
||||
aria-label={e.on_my_wishlist ? 'Ich will das nicht mehr' : 'Ich will das auch'}
|
||||
onclick={() => toggleMine(e)}
|
||||
>
|
||||
<Heart size={18} strokeWidth={2} fill={e.on_my_wishlist ? 'currentColor' : 'none'} />
|
||||
<Utensils size={18} strokeWidth={2} />
|
||||
{#if e.wanted_by_count > 0}
|
||||
<span class="count">{e.wanted_by_count}</span>
|
||||
{/if}
|
||||
@@ -162,24 +175,32 @@
|
||||
margin: 0.2rem 0 0;
|
||||
color: #666;
|
||||
}
|
||||
.controls {
|
||||
.sort-chips {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding: 0.5rem 0 1rem;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
margin: 0.5rem 0 1rem;
|
||||
}
|
||||
.controls label {
|
||||
display: inline-flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
font-size: 0.9rem;
|
||||
color: #555;
|
||||
}
|
||||
.controls select {
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid #cfd9d1;
|
||||
border-radius: 10px;
|
||||
min-height: 40px;
|
||||
.chip {
|
||||
padding: 0.4rem 0.85rem;
|
||||
background: white;
|
||||
border: 1px solid #cfd9d1;
|
||||
border-radius: 999px;
|
||||
color: #2b6a3d;
|
||||
font-size: 0.88rem;
|
||||
cursor: pointer;
|
||||
min-height: 36px;
|
||||
font-family: inherit;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.chip:hover {
|
||||
background: #f4f8f5;
|
||||
}
|
||||
.chip.active {
|
||||
background: #2b6a3d;
|
||||
color: white;
|
||||
border-color: #2b6a3d;
|
||||
font-weight: 600;
|
||||
}
|
||||
.muted {
|
||||
color: #888;
|
||||
@@ -284,9 +305,9 @@
|
||||
color: #444;
|
||||
}
|
||||
.like.active {
|
||||
color: #c53030;
|
||||
background: #fdf3f3;
|
||||
border-color: #f1b4b4;
|
||||
color: #2b6a3d;
|
||||
background: #eaf4ed;
|
||||
border-color: #b7d6c2;
|
||||
}
|
||||
.del:hover {
|
||||
color: #c53030;
|
||||
|
||||
@@ -2,88 +2,258 @@
|
||||
/// <reference no-default-lib="true"/>
|
||||
/// <reference lib="esnext" />
|
||||
/// <reference lib="webworker" />
|
||||
|
||||
import { build, files, version } from '$service-worker';
|
||||
import { resolveStrategy } from '$lib/sw/cache-strategy';
|
||||
import { diffManifest } from '$lib/sw/diff-manifest';
|
||||
|
||||
const sw = self as unknown as ServiceWorkerGlobalScope;
|
||||
declare const self: ServiceWorkerGlobalScope;
|
||||
|
||||
const APP_CACHE = `kochwas-app-${version}`;
|
||||
const IMAGE_CACHE = `kochwas-images-v1`;
|
||||
const APP_ASSETS = [...build, ...files];
|
||||
const SHELL_CACHE = `kochwas-shell-${version}`;
|
||||
const DATA_CACHE = 'kochwas-data-v1';
|
||||
const IMAGES_CACHE = 'kochwas-images-v1';
|
||||
|
||||
sw.addEventListener('install', (event) => {
|
||||
event.waitUntil(
|
||||
caches.open(APP_CACHE).then((cache) => cache.addAll(APP_ASSETS))
|
||||
);
|
||||
// Activate new worker without waiting for old clients to close.
|
||||
void sw.skipWaiting();
|
||||
});
|
||||
// App-Shell-Assets (Build-Output + statische Dateien, die SvelteKit kennt)
|
||||
const SHELL_ASSETS = [...build, ...files];
|
||||
|
||||
sw.addEventListener('activate', (event) => {
|
||||
self.addEventListener('install', (event) => {
|
||||
event.waitUntil(
|
||||
(async () => {
|
||||
const cache = await caches.open(SHELL_CACHE);
|
||||
await cache.addAll(SHELL_ASSETS);
|
||||
// Kein self.skipWaiting() hier — der Client (pwaStore) fragt den
|
||||
// User via UpdateToast, ob der neue SW sofort übernehmen soll, und
|
||||
// schickt dann eine SKIP_WAITING-Message. Ohne diese Trennung
|
||||
// würde pwaStore beim Install-Event fälschlich "Neue Version"
|
||||
// zeigen (weil statechange='installed' + controller=alter SW), und
|
||||
// der neue SW würde einen Tick später ungefragt übernehmen.
|
||||
})()
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener('activate', (event) => {
|
||||
event.waitUntil(
|
||||
(async () => {
|
||||
// Alte Shell-Caches (vorherige Versionen) räumen
|
||||
const keys = await caches.keys();
|
||||
await Promise.all(
|
||||
keys
|
||||
.filter((k) => k.startsWith('kochwas-app-') && k !== APP_CACHE)
|
||||
.filter((k) => k.startsWith('kochwas-shell-') && k !== SHELL_CACHE)
|
||||
.map((k) => caches.delete(k))
|
||||
);
|
||||
await sw.clients.claim();
|
||||
await self.clients.claim();
|
||||
})()
|
||||
);
|
||||
});
|
||||
|
||||
sw.addEventListener('fetch', (event) => {
|
||||
self.addEventListener('fetch', (event) => {
|
||||
const req = event.request;
|
||||
if (req.method !== 'GET') return;
|
||||
if (new URL(req.url).origin !== self.location.origin) return; // Cross-Origin unangetastet
|
||||
|
||||
const url = new URL(req.url);
|
||||
if (url.origin !== location.origin) return;
|
||||
const strategy = resolveStrategy({ url: req.url, method: req.method });
|
||||
if (strategy === 'network-only') return;
|
||||
|
||||
// Images served from /images/* — cache-first with background update
|
||||
if (url.pathname.startsWith('/images/')) {
|
||||
event.respondWith(
|
||||
(async () => {
|
||||
const cache = await caches.open(IMAGE_CACHE);
|
||||
const cached = await cache.match(req);
|
||||
const network = fetch(req)
|
||||
if (strategy === 'shell') {
|
||||
event.respondWith(cacheFirst(req, SHELL_CACHE));
|
||||
} else if (strategy === 'images') {
|
||||
event.respondWith(cacheFirst(req, IMAGES_CACHE));
|
||||
} else if (strategy === 'swr') {
|
||||
event.respondWith(staleWhileRevalidate(req, DATA_CACHE));
|
||||
}
|
||||
});
|
||||
|
||||
async function cacheFirst(req: Request, cacheName: string): Promise<Response> {
|
||||
const cache = await caches.open(cacheName);
|
||||
const hit = await cache.match(req);
|
||||
if (hit) return hit;
|
||||
const fresh = await fetch(req);
|
||||
if (fresh.ok) cache.put(req, fresh.clone()).catch(() => {});
|
||||
return fresh;
|
||||
}
|
||||
|
||||
async function staleWhileRevalidate(req: Request, cacheName: string): Promise<Response> {
|
||||
const cache = await caches.open(cacheName);
|
||||
const hit = await cache.match(req);
|
||||
const fetchPromise = fetch(req)
|
||||
.then((res) => {
|
||||
if (res.ok) void cache.put(req, res.clone());
|
||||
if (res.ok) cache.put(req, res.clone()).catch(() => {});
|
||||
return res;
|
||||
})
|
||||
.catch(() => undefined);
|
||||
return cached ?? (await network) ?? new Response('Offline', { status: 503 });
|
||||
})()
|
||||
);
|
||||
return;
|
||||
.catch(() => hit ?? Response.error());
|
||||
return hit ?? fetchPromise;
|
||||
}
|
||||
|
||||
// App shell assets (build/* and static files) — cache-first
|
||||
if (APP_ASSETS.includes(url.pathname)) {
|
||||
event.respondWith(
|
||||
(async () => {
|
||||
const cache = await caches.open(APP_CACHE);
|
||||
const cached = await cache.match(req);
|
||||
return cached ?? fetch(req);
|
||||
})()
|
||||
);
|
||||
return;
|
||||
}
|
||||
const META_CACHE = 'kochwas-meta';
|
||||
const MANIFEST_KEY = '/__cache-manifest__';
|
||||
const PAGE_SIZE = 50; // /api/recipes/all limitiert auf 50
|
||||
const CONCURRENCY = 4;
|
||||
|
||||
// API and HTML pages — network-first, fall back to cache for HTML
|
||||
if (req.destination === 'document') {
|
||||
event.respondWith(
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch(req);
|
||||
const cache = await caches.open(APP_CACHE);
|
||||
if (res.ok) void cache.put(req, res.clone());
|
||||
return res;
|
||||
} catch {
|
||||
const cached = await caches.match(req);
|
||||
return cached ?? new Response('Offline', { status: 503 });
|
||||
}
|
||||
})()
|
||||
);
|
||||
type RecipeSummary = { id: number; image_path: string | null };
|
||||
|
||||
self.addEventListener('message', (event) => {
|
||||
const data = event.data as { type?: string } | undefined;
|
||||
if (!data) return;
|
||||
if (data.type === 'sync-start') {
|
||||
event.waitUntil(runSync(false));
|
||||
} else if (data.type === 'sync-check') {
|
||||
event.waitUntil(runSync(true));
|
||||
} else if (data.type === 'SKIP_WAITING') {
|
||||
// Wird vom pwaStore nach User-Klick auf "Neu laden" geschickt.
|
||||
void self.skipWaiting();
|
||||
} else if (data.type === 'GET_VERSION') {
|
||||
// Zombie-Schutz: Chromium hält nach einem SKIP_WAITING-Zyklus
|
||||
// mitunter einen bit-identischen waiting-SW im Registration-Slot
|
||||
// (Race zwischen SW-Update-Check während activate). Ohne diesen
|
||||
// Version-Handshake zeigt init() den „Neue Version"-Toast bei jedem
|
||||
// Reload erneut, obwohl es nichts zu aktualisieren gibt.
|
||||
const port = event.ports[0] as MessagePort | undefined;
|
||||
port?.postMessage({ version });
|
||||
}
|
||||
});
|
||||
|
||||
async function runSync(isUpdate: boolean): Promise<void> {
|
||||
try {
|
||||
// Storage-Quota-Check vor dem Pre-Cache
|
||||
if (navigator.storage?.estimate) {
|
||||
const est = await navigator.storage.estimate();
|
||||
const freeBytes = (est.quota ?? 0) - (est.usage ?? 0);
|
||||
if (freeBytes < 100 * 1024 * 1024) {
|
||||
await broadcast({
|
||||
type: 'sync-error',
|
||||
message: `Nicht genug Speicher für Offline-Modus (${Math.round(freeBytes / 1024 / 1024)} MB frei)`
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const summaries = await fetchAllSummaries();
|
||||
const currentIds = summaries.map((s) => s.id);
|
||||
const cachedIds = await loadCachedIds();
|
||||
const { toAdd, toRemove } = diffManifest(currentIds, cachedIds);
|
||||
const worklist = isUpdate ? toAdd : currentIds; // initial: alles laden
|
||||
|
||||
await broadcast({ type: 'sync-start', total: worklist.length });
|
||||
|
||||
const successful = new Set<number>();
|
||||
let done = 0;
|
||||
const tasks = worklist.map((id) => async () => {
|
||||
const summary = summaries.find((s) => s.id === id);
|
||||
const ok = await cacheRecipe(id, summary?.image_path ?? null);
|
||||
if (ok) successful.add(id);
|
||||
done += 1;
|
||||
await broadcast({ type: 'sync-progress', current: done, total: worklist.length });
|
||||
});
|
||||
await runPool(tasks, CONCURRENCY);
|
||||
|
||||
if (isUpdate && toRemove.length > 0) {
|
||||
await removeRecipes(toRemove);
|
||||
}
|
||||
|
||||
// Manifest: für Update = (cached - toRemove) + neue successes
|
||||
// Für Initial = nur die diesmal erfolgreich gecachten
|
||||
const finalManifest = isUpdate
|
||||
? Array.from(
|
||||
new Set([...cachedIds.filter((id) => !toRemove.includes(id)), ...successful])
|
||||
)
|
||||
: Array.from(successful);
|
||||
|
||||
await saveCachedIds(finalManifest);
|
||||
await broadcast({ type: 'sync-done', lastSynced: Date.now() });
|
||||
} catch (e) {
|
||||
await broadcast({
|
||||
type: 'sync-error',
|
||||
message: (e as Error).message ?? 'Unbekannter Sync-Fehler'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchAllSummaries(): Promise<RecipeSummary[]> {
|
||||
const result: RecipeSummary[] = [];
|
||||
let offset = 0;
|
||||
for (;;) {
|
||||
const res = await fetch(`/api/recipes/all?sort=name&limit=${PAGE_SIZE}&offset=${offset}`);
|
||||
if (!res.ok) throw new Error(`/api/recipes/all HTTP ${res.status}`);
|
||||
const body = (await res.json()) as { hits: { id: number; image_path: string | null }[] };
|
||||
result.push(...body.hits.map((h) => ({ id: h.id, image_path: h.image_path })));
|
||||
if (body.hits.length < PAGE_SIZE) break;
|
||||
offset += PAGE_SIZE;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async function cacheRecipe(id: number, imagePath: string | null): Promise<boolean> {
|
||||
const data = await caches.open(DATA_CACHE);
|
||||
const images = await caches.open(IMAGES_CACHE);
|
||||
const [htmlOk, apiOk] = await Promise.all([
|
||||
addToCache(data, `/recipes/${id}`),
|
||||
addToCache(data, `/api/recipes/${id}`)
|
||||
]);
|
||||
if (imagePath && !/^https?:\/\//i.test(imagePath)) {
|
||||
// Image-Fehler soll den Recipe-Eintrag nicht invalidieren (bei
|
||||
// manchen Rezepten gibt es schlicht kein Bild)
|
||||
await addToCache(images, `/images/${imagePath}`);
|
||||
}
|
||||
return htmlOk && apiOk;
|
||||
}
|
||||
|
||||
async function addToCache(cache: Cache, url: string): Promise<boolean> {
|
||||
try {
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) {
|
||||
console.warn(`[sw] cache miss ${url}: HTTP ${res.status}`);
|
||||
return false;
|
||||
}
|
||||
await cache.put(url, res);
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.warn(`[sw] cache error ${url}:`, e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function removeRecipes(ids: number[]): Promise<void> {
|
||||
const data = await caches.open(DATA_CACHE);
|
||||
for (const id of ids) {
|
||||
await data.delete(`/recipes/${id}`);
|
||||
await data.delete(`/api/recipes/${id}`);
|
||||
}
|
||||
// Orphan-Bilder: wir räumen nicht aktiv — neuer Hash = neuer Entry,
|
||||
// alte Einträge stören nicht.
|
||||
}
|
||||
|
||||
async function loadCachedIds(): Promise<number[]> {
|
||||
const meta = await caches.open(META_CACHE);
|
||||
const res = await meta.match(MANIFEST_KEY);
|
||||
if (!res) return [];
|
||||
try {
|
||||
return (await res.json()) as number[];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function saveCachedIds(ids: number[]): Promise<void> {
|
||||
const meta = await caches.open(META_CACHE);
|
||||
await meta.put(
|
||||
MANIFEST_KEY,
|
||||
new Response(JSON.stringify(ids), { headers: { 'content-type': 'application/json' } })
|
||||
);
|
||||
}
|
||||
|
||||
async function runPool<T>(tasks: (() => Promise<T>)[], limit: number): Promise<void> {
|
||||
const executing: Promise<void>[] = [];
|
||||
for (const task of tasks) {
|
||||
const p: Promise<void> = task().then(() => {
|
||||
executing.splice(executing.indexOf(p), 1);
|
||||
});
|
||||
executing.push(p);
|
||||
if (executing.length >= limit) await Promise.race(executing);
|
||||
}
|
||||
await Promise.all(executing);
|
||||
}
|
||||
|
||||
async function broadcast(msg: unknown): Promise<void> {
|
||||
const clients = await self.clients.matchAll();
|
||||
for (const client of clients) client.postMessage(msg);
|
||||
}
|
||||
|
||||
export {};
|
||||
|
||||
BIN
static/icon-192.png
Normal file
BIN
static/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.0 KiB |
BIN
static/icon-512.png
Normal file
BIN
static/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 21 KiB |
@@ -13,7 +13,19 @@
|
||||
"src": "/icon.svg",
|
||||
"sizes": "any",
|
||||
"type": "image/svg+xml",
|
||||
"purpose": "any"
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "/icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
122
tests/e2e/offline.spec.ts
Normal file
122
tests/e2e/offline.spec.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import { test as base, expect, request as apiRequest, type Page } from '@playwright/test';
|
||||
|
||||
// Seed-Fixture: die Tests brauchen mindestens ein Rezept in der DB,
|
||||
// sonst gibt es nichts zu cachen/navigieren. Beim ersten Worker-Run
|
||||
// schauen wir in /api/recipes/all nach — wenn leer, legen wir ein
|
||||
// leeres Rezept per /api/recipes/blank an.
|
||||
//
|
||||
// Außerdem stellen wir sicher, dass ein Profil existiert (nötig für
|
||||
// den Favorit-Button-Test). Das Profil-ID wird als Fixture-Wert
|
||||
// weitergegeben, damit die Tests es in localStorage setzen können.
|
||||
const test = base.extend<{ profileId: number }, { seeded: void; workerProfileId: number }>({
|
||||
seeded: [
|
||||
async ({}, use) => {
|
||||
const ctx = await apiRequest.newContext({ baseURL: 'http://localhost:4173' });
|
||||
try {
|
||||
const res = await ctx.get('/api/recipes/all?sort=name&limit=1&offset=0');
|
||||
const body = await res.json();
|
||||
if (body.hits.length === 0) {
|
||||
await ctx.post('/api/recipes/blank');
|
||||
}
|
||||
} finally {
|
||||
await ctx.dispose();
|
||||
}
|
||||
await use();
|
||||
},
|
||||
{ scope: 'worker', auto: true }
|
||||
],
|
||||
|
||||
workerProfileId: [
|
||||
async ({}, use) => {
|
||||
const ctx = await apiRequest.newContext({ baseURL: 'http://localhost:4173' });
|
||||
let id: number;
|
||||
try {
|
||||
const listRes = await ctx.get('/api/profiles');
|
||||
const profiles = await listRes.json();
|
||||
if (profiles.length > 0) {
|
||||
id = profiles[0].id;
|
||||
} else {
|
||||
const createRes = await ctx.post('/api/profiles', {
|
||||
data: { name: 'Test', avatar_emoji: null }
|
||||
});
|
||||
const p = await createRes.json();
|
||||
id = p.id;
|
||||
}
|
||||
} finally {
|
||||
await ctx.dispose();
|
||||
}
|
||||
await use(id);
|
||||
},
|
||||
{ scope: 'worker', auto: false }
|
||||
],
|
||||
|
||||
// Test-scoped Alias — wird von Tests direkt per Destrukturierung genutzt
|
||||
profileId: async ({ workerProfileId }, use) => {
|
||||
await use(workerProfileId);
|
||||
}
|
||||
});
|
||||
|
||||
// Wartet, bis der Service Worker aktiv ist und der initiale Sync
|
||||
// wahrscheinlich durchgelaufen ist. Wir pollen den Status.
|
||||
async function waitForSync(page: Page) {
|
||||
await page.waitForFunction(
|
||||
async () => {
|
||||
const r = await navigator.serviceWorker.ready;
|
||||
return !!r.active;
|
||||
},
|
||||
null,
|
||||
{ timeout: 10_000 }
|
||||
);
|
||||
// Heuristik: 3 s reichen für den Pre-Cache eines einzelnen Seed-Rezepts.
|
||||
// Falls flaky, auf 5000 erhöhen oder .pill.syncing wegwarten.
|
||||
await page.waitForTimeout(3000);
|
||||
}
|
||||
|
||||
test('offline navigation zeigt Rezept-Detail aus dem Cache', async ({ page, context }) => {
|
||||
await page.goto('/');
|
||||
await waitForSync(page);
|
||||
// Einen existierenden Rezept-Link finden — Seed-Fixture garantiert mindestens einen.
|
||||
await page.goto('/recipes');
|
||||
const firstLink = page.locator('a[href^="/recipes/"]').first();
|
||||
const href = await firstLink.getAttribute('href');
|
||||
expect(href).toBeTruthy();
|
||||
|
||||
await context.setOffline(true);
|
||||
await page.goto(href!);
|
||||
await expect(page.locator('h1')).toBeVisible();
|
||||
});
|
||||
|
||||
test('Offline-Schreib-Aktion zeigt Toast', async ({ page, context, profileId }) => {
|
||||
// Profil-ID vor dem ersten Navigieren setzen, damit profileStore.load()
|
||||
// das Profil aus localStorage liest und active != null ist.
|
||||
await page.addInitScript((id: number) => {
|
||||
localStorage.setItem('kochwas.activeProfileId', String(id));
|
||||
}, profileId);
|
||||
|
||||
await page.goto('/');
|
||||
await waitForSync(page);
|
||||
|
||||
// Rezept-Detail-Seite vorab besuchen, damit der SW sie cacht.
|
||||
await page.goto('/recipes');
|
||||
const firstLink = page.locator('a[href^="/recipes/"]').first();
|
||||
const href = await firstLink.getAttribute('href');
|
||||
await page.goto(href!);
|
||||
// Kurz warten damit die Detail-Seite im SW-Cache landet.
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
await context.setOffline(true);
|
||||
// Neu navigieren zur gecachten Detail-Seite — SW liefert aus dem Cache.
|
||||
await page.goto(href!, { waitUntil: 'commit' });
|
||||
await expect(page.locator('h1')).toBeVisible();
|
||||
await page.getByRole('button', { name: /Favorit/ }).first().click();
|
||||
await expect(page.locator('.toast.error')).toContainText(/Internet-Verbindung/);
|
||||
});
|
||||
|
||||
test('SyncIndicator zeigt Offline-Status', async ({ page, context }) => {
|
||||
await page.goto('/');
|
||||
await waitForSync(page);
|
||||
// Kein Reload nötig: network.svelte.ts lauscht auf den 'offline'-Browser-
|
||||
// Event, der sofort feuert wenn context.setOffline(true) gesetzt wird.
|
||||
await context.setOffline(true);
|
||||
await expect(page.locator('.wrap .pill.offline')).toContainText('Offline');
|
||||
});
|
||||
6
tests/e2e/smoke.spec.ts
Normal file
6
tests/e2e/smoke.spec.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test('home loads', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await expect(page.locator('h1')).toContainText('Kochwas');
|
||||
});
|
||||
@@ -7,7 +7,6 @@ import { tmpdir } from 'node:os';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { openInMemoryForTest } from '../../src/lib/server/db';
|
||||
import { addDomain } from '../../src/lib/server/domains/repository';
|
||||
import { importRecipe, previewRecipe, ImporterError } from '../../src/lib/server/recipes/importer';
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
@@ -61,17 +60,9 @@ afterEach(async () => {
|
||||
});
|
||||
|
||||
describe('previewRecipe', () => {
|
||||
it('throws DOMAIN_BLOCKED if host not whitelisted', async () => {
|
||||
it('accepts any domain — manuelle URL-Importe sind nicht auf die Whitelist beschränkt', async () => {
|
||||
const db = openInMemoryForTest();
|
||||
// note: no domain added
|
||||
await expect(previewRecipe(db, `${baseUrl}/recipe`)).rejects.toMatchObject({
|
||||
code: 'DOMAIN_BLOCKED'
|
||||
});
|
||||
});
|
||||
|
||||
it('returns parsed recipe for whitelisted domain', async () => {
|
||||
const db = openInMemoryForTest();
|
||||
addDomain(db, '127.0.0.1');
|
||||
// keine Domain in der Whitelist — preview muss trotzdem klappen
|
||||
const r = await previewRecipe(db, `${baseUrl}/recipe`);
|
||||
expect(r.title.toLowerCase()).toContain('schupfnudel');
|
||||
expect(r.source_url).toBe(`${baseUrl}/recipe`);
|
||||
@@ -80,17 +71,22 @@ describe('previewRecipe', () => {
|
||||
|
||||
it('throws NO_RECIPE_FOUND when HTML has no Recipe JSON-LD', async () => {
|
||||
const db = openInMemoryForTest();
|
||||
addDomain(db, '127.0.0.1');
|
||||
await expect(previewRecipe(db, `${baseUrl}/bare`)).rejects.toMatchObject({
|
||||
code: 'NO_RECIPE_FOUND'
|
||||
});
|
||||
});
|
||||
|
||||
it('throws INVALID_URL for malformed input', async () => {
|
||||
const db = openInMemoryForTest();
|
||||
await expect(previewRecipe(db, 'not a url')).rejects.toMatchObject({
|
||||
code: 'INVALID_URL'
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('importRecipe', () => {
|
||||
it('imports, persists, and is idempotent', async () => {
|
||||
const db = openInMemoryForTest();
|
||||
addDomain(db, '127.0.0.1');
|
||||
const first = await importRecipe(db, imgDir, `${baseUrl}/recipe`);
|
||||
expect(first.duplicate).toBe(false);
|
||||
expect(first.id).toBeGreaterThan(0);
|
||||
@@ -104,9 +100,9 @@ describe('importRecipe', () => {
|
||||
expect(second.id).toBe(first.id);
|
||||
});
|
||||
|
||||
it('surfaces ImporterError type', async () => {
|
||||
it('surfaces ImporterError type when no recipe on page', async () => {
|
||||
const db = openInMemoryForTest();
|
||||
await expect(importRecipe(db, imgDir, `${baseUrl}/recipe`)).rejects.toBeInstanceOf(
|
||||
await expect(importRecipe(db, imgDir, `${baseUrl}/bare`)).rejects.toBeInstanceOf(
|
||||
ImporterError
|
||||
);
|
||||
});
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
updateDomain,
|
||||
getDomainById
|
||||
} from '../../src/lib/server/domains/repository';
|
||||
import { isDomainAllowed } from '../../src/lib/server/domains/whitelist';
|
||||
|
||||
describe('allowed domains', () => {
|
||||
it('round-trips domains', () => {
|
||||
@@ -19,18 +18,10 @@ describe('allowed domains', () => {
|
||||
expect(all.map((d) => d.domain).sort()).toEqual(['chefkoch.de', 'emmikochteinfach.de']);
|
||||
});
|
||||
|
||||
it('normalizes www. and case', () => {
|
||||
it('normalizes www. and case via addDomain', () => {
|
||||
const db = openInMemoryForTest();
|
||||
addDomain(db, 'WWW.Chefkoch.DE');
|
||||
expect(isDomainAllowed(db, 'https://chefkoch.de/abc')).toBe(true);
|
||||
expect(isDomainAllowed(db, 'https://www.chefkoch.de/abc')).toBe(true);
|
||||
expect(isDomainAllowed(db, 'https://fake.de/abc')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects invalid urls', () => {
|
||||
const db = openInMemoryForTest();
|
||||
addDomain(db, 'chefkoch.de');
|
||||
expect(isDomainAllowed(db, 'not a url')).toBe(false);
|
||||
expect(listDomains(db)[0].domain).toBe('chefkoch.de');
|
||||
});
|
||||
|
||||
it('removes domains', () => {
|
||||
|
||||
41
tests/unit/cache-strategy.test.ts
Normal file
41
tests/unit/cache-strategy.test.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { resolveStrategy } from '../../src/lib/sw/cache-strategy';
|
||||
|
||||
describe('resolveStrategy', () => {
|
||||
it('images bucket for /images/*', () => {
|
||||
expect(resolveStrategy({ url: '/images/favicon-abc.png', method: 'GET' })).toBe('images');
|
||||
});
|
||||
|
||||
it('swr for recipe HTML pages', () => {
|
||||
expect(resolveStrategy({ url: '/recipes/42', method: 'GET' })).toBe('swr');
|
||||
});
|
||||
|
||||
it('swr for recipe API reads', () => {
|
||||
expect(resolveStrategy({ url: '/api/recipes/42', method: 'GET' })).toBe('swr');
|
||||
expect(resolveStrategy({ url: '/api/recipes/all?sort=name', method: 'GET' })).toBe('swr');
|
||||
expect(resolveStrategy({ url: '/api/wishlist', method: 'GET' })).toBe('swr');
|
||||
});
|
||||
|
||||
it('network-only for write methods', () => {
|
||||
expect(resolveStrategy({ url: '/api/recipes/42', method: 'PATCH' })).toBe('network-only');
|
||||
expect(resolveStrategy({ url: '/api/recipes/42/favorite', method: 'PUT' })).toBe('network-only');
|
||||
expect(resolveStrategy({ url: '/api/wishlist', method: 'POST' })).toBe('network-only');
|
||||
});
|
||||
|
||||
it('network-only for online-only endpoints even on GET', () => {
|
||||
expect(resolveStrategy({ url: '/api/recipes/import', method: 'GET' })).toBe('network-only');
|
||||
expect(resolveStrategy({ url: '/api/recipes/preview?url=x', method: 'GET' })).toBe('network-only');
|
||||
expect(resolveStrategy({ url: '/api/recipes/search/web?q=x', method: 'GET' })).toBe('network-only');
|
||||
});
|
||||
|
||||
it('shell bucket for build/static assets', () => {
|
||||
expect(resolveStrategy({ url: '/_app/immutable/chunks/x.js', method: 'GET' })).toBe('shell');
|
||||
expect(resolveStrategy({ url: '/icon-192.png', method: 'GET' })).toBe('shell');
|
||||
expect(resolveStrategy({ url: '/manifest.webmanifest', method: 'GET' })).toBe('shell');
|
||||
});
|
||||
|
||||
it('falls through to swr for other same-origin GETs (e.g. root page)', () => {
|
||||
expect(resolveStrategy({ url: '/', method: 'GET' })).toBe('swr');
|
||||
expect(resolveStrategy({ url: '/wishlist', method: 'GET' })).toBe('swr');
|
||||
});
|
||||
});
|
||||
34
tests/unit/diff-manifest.test.ts
Normal file
34
tests/unit/diff-manifest.test.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { diffManifest } from '../../src/lib/sw/diff-manifest';
|
||||
|
||||
describe('diffManifest', () => {
|
||||
it('detects new IDs to add', () => {
|
||||
const result = diffManifest([1, 2, 3, 4], [1, 2]);
|
||||
expect(result.toAdd.sort()).toEqual([3, 4]);
|
||||
expect(result.toRemove).toEqual([]);
|
||||
});
|
||||
|
||||
it('detects removed IDs', () => {
|
||||
const result = diffManifest([1, 2], [1, 2, 3, 4]);
|
||||
expect(result.toAdd).toEqual([]);
|
||||
expect(result.toRemove.sort()).toEqual([3, 4]);
|
||||
});
|
||||
|
||||
it('detects both add and remove in one diff', () => {
|
||||
const result = diffManifest([1, 3, 5], [1, 2, 3]);
|
||||
expect(result.toAdd).toEqual([5]);
|
||||
expect(result.toRemove).toEqual([2]);
|
||||
});
|
||||
|
||||
it('returns empty arrays when identical', () => {
|
||||
const result = diffManifest([1, 2, 3], [3, 2, 1]);
|
||||
expect(result.toAdd).toEqual([]);
|
||||
expect(result.toRemove).toEqual([]);
|
||||
});
|
||||
|
||||
it('handles empty caches (first sync)', () => {
|
||||
const result = diffManifest([1, 2], []);
|
||||
expect(result.toAdd.sort()).toEqual([1, 2]);
|
||||
expect(result.toRemove).toEqual([]);
|
||||
});
|
||||
});
|
||||
23
tests/unit/network-store.test.ts
Normal file
23
tests/unit/network-store.test.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
|
||||
describe('network store', () => {
|
||||
beforeEach(() => {
|
||||
// Reset module state for each test
|
||||
Object.defineProperty(navigator, 'onLine', { value: true, configurable: true });
|
||||
});
|
||||
|
||||
it('reflects initial navigator.onLine and reacts to events', async () => {
|
||||
const { network } = await import('../../src/lib/client/network.svelte');
|
||||
network.init();
|
||||
expect(network.online).toBe(true);
|
||||
|
||||
Object.defineProperty(navigator, 'onLine', { value: false, configurable: true });
|
||||
window.dispatchEvent(new Event('offline'));
|
||||
expect(network.online).toBe(false);
|
||||
|
||||
Object.defineProperty(navigator, 'onLine', { value: true, configurable: true });
|
||||
window.dispatchEvent(new Event('online'));
|
||||
expect(network.online).toBe(true);
|
||||
});
|
||||
});
|
||||
176
tests/unit/pwa-store.test.ts
Normal file
176
tests/unit/pwa-store.test.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
|
||||
class FakeSW extends EventTarget {
|
||||
scriptURL = '/service-worker.js';
|
||||
state: 'installed' | 'activated' = 'activated';
|
||||
version: string | null;
|
||||
postMessage = vi.fn((msg: unknown, transfer?: Transferable[]) => {
|
||||
if ((msg as { type?: string } | null)?.type === 'GET_VERSION') {
|
||||
const port = transfer?.[0] as MessagePort | undefined;
|
||||
if (port && this.version !== null) port.postMessage({ version: this.version });
|
||||
}
|
||||
});
|
||||
constructor(version: string | null = null) {
|
||||
super();
|
||||
this.version = version;
|
||||
}
|
||||
}
|
||||
|
||||
type Reg = {
|
||||
active: FakeSW | null;
|
||||
waiting: FakeSW | null;
|
||||
installing: FakeSW | null;
|
||||
addEventListener: ReturnType<typeof vi.fn>;
|
||||
update: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
function mountFakeSW(init: Partial<Reg>): {
|
||||
registration: Reg;
|
||||
fireControllerChange: () => void;
|
||||
} {
|
||||
const registration: Reg = {
|
||||
active: init.active ?? null,
|
||||
waiting: init.waiting ?? null,
|
||||
installing: init.installing ?? null,
|
||||
addEventListener: vi.fn(),
|
||||
update: vi.fn().mockResolvedValue(undefined)
|
||||
};
|
||||
type Entry = { fn: (e: Event) => void; once: boolean };
|
||||
const listeners: Entry[] = [];
|
||||
Object.defineProperty(navigator, 'serviceWorker', {
|
||||
configurable: true,
|
||||
value: {
|
||||
ready: Promise.resolve(registration),
|
||||
controller: registration.active,
|
||||
addEventListener: vi.fn(
|
||||
(type: string, fn: (e: Event) => void, opts?: AddEventListenerOptions | boolean) => {
|
||||
if (type !== 'controllerchange') return;
|
||||
const once = typeof opts === 'object' ? !!opts.once : false;
|
||||
listeners.push({ fn, once });
|
||||
}
|
||||
)
|
||||
}
|
||||
});
|
||||
const fireControllerChange = () => {
|
||||
const snap = [...listeners];
|
||||
for (const e of snap) {
|
||||
e.fn(new Event('controllerchange'));
|
||||
if (e.once) {
|
||||
const i = listeners.indexOf(e);
|
||||
if (i >= 0) listeners.splice(i, 1);
|
||||
}
|
||||
}
|
||||
};
|
||||
return { registration, fireControllerChange };
|
||||
}
|
||||
|
||||
async function flush(ms = 20): Promise<void> {
|
||||
await new Promise((r) => setTimeout(r, ms));
|
||||
}
|
||||
|
||||
function stubLocationReload(): ReturnType<typeof vi.fn> {
|
||||
const reload = vi.fn();
|
||||
Object.defineProperty(window, 'location', {
|
||||
configurable: true,
|
||||
value: { ...window.location, reload }
|
||||
});
|
||||
return reload;
|
||||
}
|
||||
|
||||
describe('pwa store', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it('zombie-waiter (gleiche Version): kein Toast, silent SKIP_WAITING, KEIN Reload', async () => {
|
||||
const active = new FakeSW('1776527907402');
|
||||
const waiting = new FakeSW('1776527907402');
|
||||
waiting.state = 'installed';
|
||||
const { fireControllerChange } = mountFakeSW({ active, waiting });
|
||||
const reload = stubLocationReload();
|
||||
|
||||
const { pwaStore } = await import('../../src/lib/client/pwa.svelte');
|
||||
await pwaStore.init();
|
||||
await flush();
|
||||
|
||||
expect(pwaStore.updateAvailable).toBe(false);
|
||||
expect(waiting.postMessage).toHaveBeenCalledWith({ type: 'SKIP_WAITING' });
|
||||
// Kritisch: selbst wenn der Browser nach dem silent SKIP_WAITING
|
||||
// controllerchange feuert, darf kein Auto-Reload passieren — sonst
|
||||
// Endlos-Loop, weil der nächste Seitenaufruf erneut einen Zombie
|
||||
// bekommt.
|
||||
fireControllerChange();
|
||||
expect(reload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('echtes Update (unterschiedliche Version): Toast', async () => {
|
||||
const active = new FakeSW('1776526292782');
|
||||
const waiting = new FakeSW('1776527907402');
|
||||
waiting.state = 'installed';
|
||||
mountFakeSW({ active, waiting });
|
||||
|
||||
const { pwaStore } = await import('../../src/lib/client/pwa.svelte');
|
||||
await pwaStore.init();
|
||||
await flush();
|
||||
|
||||
expect(pwaStore.updateAvailable).toBe(true);
|
||||
expect(waiting.postMessage).not.toHaveBeenCalledWith({ type: 'SKIP_WAITING' });
|
||||
});
|
||||
|
||||
it('alter active-SW ohne GET_VERSION (Fallback): Toast', async () => {
|
||||
const active = new FakeSW(null);
|
||||
const waiting = new FakeSW('1776527907402');
|
||||
waiting.state = 'installed';
|
||||
mountFakeSW({ active, waiting });
|
||||
|
||||
const { pwaStore } = await import('../../src/lib/client/pwa.svelte');
|
||||
await pwaStore.init();
|
||||
await flush();
|
||||
|
||||
expect(pwaStore.updateAvailable).toBe(true);
|
||||
});
|
||||
|
||||
it('kein waiting-SW: kein Toast', async () => {
|
||||
mountFakeSW({ active: new FakeSW('1776527907402') });
|
||||
const { pwaStore } = await import('../../src/lib/client/pwa.svelte');
|
||||
await pwaStore.init();
|
||||
await flush();
|
||||
expect(pwaStore.updateAvailable).toBe(false);
|
||||
});
|
||||
|
||||
it('reload() postet SKIP_WAITING, reload einmalig bei controllerchange', async () => {
|
||||
const active = new FakeSW('v1');
|
||||
const waiting = new FakeSW('v2');
|
||||
waiting.state = 'installed';
|
||||
const { fireControllerChange } = mountFakeSW({ active, waiting });
|
||||
const reload = stubLocationReload();
|
||||
|
||||
const { pwaStore } = await import('../../src/lib/client/pwa.svelte');
|
||||
await pwaStore.init();
|
||||
await flush();
|
||||
|
||||
pwaStore.reload();
|
||||
expect(waiting.postMessage).toHaveBeenCalledWith({ type: 'SKIP_WAITING' });
|
||||
expect(reload).not.toHaveBeenCalled();
|
||||
|
||||
fireControllerChange();
|
||||
expect(reload).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Nochmal controllerchange → wegen { once: true } kein zweiter Reload.
|
||||
fireControllerChange();
|
||||
expect(reload).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('reload() ohne waiting-SW ruft location.reload() sofort auf', async () => {
|
||||
mountFakeSW({ active: new FakeSW('v1') });
|
||||
const reload = stubLocationReload();
|
||||
|
||||
const { pwaStore } = await import('../../src/lib/client/pwa.svelte');
|
||||
await pwaStore.init();
|
||||
await flush();
|
||||
|
||||
pwaStore.reload();
|
||||
expect(reload).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
30
tests/unit/sync-status-store.test.ts
Normal file
30
tests/unit/sync-status-store.test.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
|
||||
describe('sync-status store', () => {
|
||||
beforeEach(async () => {
|
||||
const mod = await import('../../src/lib/client/sync-status.svelte');
|
||||
mod.syncStatus.state = { kind: 'idle' };
|
||||
mod.syncStatus.lastSynced = null;
|
||||
});
|
||||
|
||||
it('processes progress messages', async () => {
|
||||
const { syncStatus } = await import('../../src/lib/client/sync-status.svelte');
|
||||
syncStatus.handle({ type: 'sync-progress', current: 3, total: 10 });
|
||||
expect(syncStatus.state).toEqual({ kind: 'syncing', current: 3, total: 10 });
|
||||
});
|
||||
|
||||
it('transitions to idle on sync-done and records timestamp', async () => {
|
||||
const { syncStatus } = await import('../../src/lib/client/sync-status.svelte');
|
||||
syncStatus.handle({ type: 'sync-start', total: 5 });
|
||||
expect(syncStatus.state.kind).toBe('syncing');
|
||||
syncStatus.handle({ type: 'sync-done', lastSynced: 1700000000000 });
|
||||
expect(syncStatus.state).toEqual({ kind: 'idle' });
|
||||
expect(syncStatus.lastSynced).toBe(1700000000000);
|
||||
});
|
||||
|
||||
it('sets error state on sync-error', async () => {
|
||||
const { syncStatus } = await import('../../src/lib/client/sync-status.svelte');
|
||||
syncStatus.handle({ type: 'sync-error', message: 'Quota exceeded' });
|
||||
expect(syncStatus.state).toEqual({ kind: 'error', message: 'Quota exceeded' });
|
||||
});
|
||||
});
|
||||
35
tests/unit/toast-store.test.ts
Normal file
35
tests/unit/toast-store.test.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
|
||||
describe('toast store', () => {
|
||||
beforeEach(async () => {
|
||||
vi.useFakeTimers();
|
||||
const mod = await import('../../src/lib/client/toast.svelte');
|
||||
mod.toastStore.toasts = [];
|
||||
});
|
||||
|
||||
it('queues toasts with auto-dismiss', async () => {
|
||||
const { toastStore } = await import('../../src/lib/client/toast.svelte');
|
||||
toastStore.info('Hello');
|
||||
expect(toastStore.toasts.length).toBe(1);
|
||||
expect(toastStore.toasts[0].message).toBe('Hello');
|
||||
expect(toastStore.toasts[0].kind).toBe('info');
|
||||
|
||||
vi.advanceTimersByTime(3000);
|
||||
expect(toastStore.toasts.length).toBe(0);
|
||||
});
|
||||
|
||||
it('supports error kind and manual dismiss', async () => {
|
||||
const { toastStore } = await import('../../src/lib/client/toast.svelte');
|
||||
const id = toastStore.error('Boom');
|
||||
expect(toastStore.toasts[0].kind).toBe('error');
|
||||
toastStore.dismiss(id);
|
||||
expect(toastStore.toasts.length).toBe(0);
|
||||
});
|
||||
|
||||
it('allows multiple concurrent toasts', async () => {
|
||||
const { toastStore } = await import('../../src/lib/client/toast.svelte');
|
||||
toastStore.info('A');
|
||||
toastStore.info('B');
|
||||
expect(toastStore.toasts.length).toBe(2);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user