3 Commits

Author SHA1 Message Date
hsiegeln
26018eee7f chore: .prettierignore fuer Fixtures, Docs und Templates
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 31s
npm run format hat zuletzt 18k Zeilen HTML-Fixture und alle
Markdown-Plaene angefasst. Ignore-Liste deckt jetzt ab:

- tests/fixtures (byte-exakte HTML-Captures fuer Parser-Tests)
- *.md (hand-aligned Tabellen, historische Plan-Artefakte)
- searxng/settings.yml (Template mit VAR-Platzhaltern)
- data/, build/, .svelte-kit, node_modules, Lockfile

Damit bleibt npm run format auf Code beschraenkt.
2026-04-20 08:45:41 +02:00
hsiegeln
24bd9c1d1b feat(header): Versionsnummer unter dem Logo
Some checks failed
Build & Publish Docker Image / build-and-push (push) Has been cancelled
Liest KOCHWAS_TAG via +layout.server.ts aus $env/dynamic/private
und zeigt den Tag als kleine graue Zeile unter dem Brand-Text auf
der Startseite. Fallback "dev" wenn nicht gesetzt. Auf engen
Screens mit ausgeblendetem Brand verschwindet auch die Version.

docker-compose.prod.yml reicht die Host-Env-Variable jetzt in den
Container durch (vorher nur fuers Image-Tag-Binding interpoliert).
2026-04-20 08:41:18 +02:00
hsiegeln
633e497bdc fix(sw): network-first + 3s timeout statt SWR fuer Daten
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 30s
SWR lieferte bei jedem Cache-Hit sofort die alte Antwort und
aktualisierte das Cache nur fuer den naechsten Request. Folge:
UI zeigte stale Daten, frische Daten erst nach Refresh.

Neu: network-first mit 3 s Timeout-Fallback. Netz gewinnt bei
frischer Antwort; Timeout oder Netzwerk-Fehler fallen auf Cache
zurueck. Pre-Cache-Logik (runSync) bleibt unveraendert, Shell
und Bilder bleiben cache-first.
2026-04-20 08:29:00 +02:00
9 changed files with 101 additions and 25 deletions

24
.prettierignore Normal file
View File

@@ -0,0 +1,24 @@
# Generierte / Build-Artefakte
node_modules
.svelte-kit
build
coverage
.vite
# Lockfiles
package-lock.json
# Lokale Laufzeit-Daten
data
# Test-Fixtures: rohe HTML-Captures muessen byte-exakt bleiben,
# sonst schlaegt die JSON-LD-Extraktion im Parser-Test anders an.
tests/fixtures
# Markdown: Tabellen sind hand-aligned, Code-Bloecke in historischen
# Plaenen sollen nicht nachtraeglich umgebrochen werden.
*.md
# SearXNG-Config ist ein Template mit ${VAR}-Platzhaltern, die der
# Init-Container expandiert.
searxng/settings.yml

View File

@@ -11,6 +11,8 @@ services:
- IMAGE_DIR=/data/images
- SEARXNG_URL=http://searxng:8080
- NODE_ENV=production
# Im Header als kleine Versionsnummer unter dem Logo angezeigt.
- KOCHWAS_TAG=${KOCHWAS_TAG:-dev}
depends_on:
- searxng
restart: unless-stopped

View File

@@ -120,11 +120,11 @@ Bei Schema-Änderung:
- **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.
- **Drei Cache-Strategien** (dispatcht per `resolveStrategy`): Shell = cache-first, Daten = network-first mit 3 s-Timeout-Fallback auf Cache, 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/cache-strategy.ts``resolveStrategy({url, method})``'shell' | 'network-first' | 'images' | 'network-only'`
- `src/lib/sw/diff-manifest.ts``diffManifest(current, cached)``{toAdd, toRemove}`
Client-Stores (SSR-safe via typeof-Guards):

View File

@@ -155,7 +155,7 @@ Kochwas ist eine installierbare PWA. Erkennbar an:
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-data-v1` — Rezept-HTMLs + API-JSON (network-first, 3 s Timeout → Cache-Fallback)
- `kochwas-images-v1` — Bilder (cache-first)
- `kochwas-meta` — Cache-Manifest (Liste der gecachten Rezept-IDs unter `/__cache-manifest__`)

View File

@@ -1,4 +1,4 @@
export type CacheStrategy = 'shell' | 'swr' | 'images' | 'network-only';
export type CacheStrategy = 'shell' | 'network-first' | 'images' | 'network-only';
type RequestShape = { url: string; method: string };
@@ -37,6 +37,7 @@ export function resolveStrategy(req: RequestShape): CacheStrategy {
return 'shell';
}
// Everything else: recipe pages, API reads, lists — all SWR.
return 'swr';
// Everything else: recipe pages, API reads, lists — network-first with
// timeout fallback to cache (handled in service-worker.ts).
return 'network-first';
}

View File

@@ -0,0 +1,8 @@
import type { LayoutServerLoad } from './$types';
import { env } from '$env/dynamic/private';
export const load: LayoutServerLoad = () => {
return {
version: env.KOCHWAS_TAG ?? 'dev'
};
};

View File

@@ -19,7 +19,7 @@
import { registerServiceWorker } from '$lib/client/sw-register';
import { SearchStore } from '$lib/client/search.svelte';
let { children } = $props();
let { data, children } = $props();
const navStore = new SearchStore({
pageSize: 30,
@@ -115,7 +115,10 @@
<header class="bar">
<div class="bar-inner">
{#if $page.url.pathname === '/'}
<div class="brand-stack">
<a href="/" class="brand">Kochwas</a>
<span class="version" title="Deployment-Tag">{data.version}</span>
</div>
{:else}
<a href="/" class="home-back" aria-label="Zurück zur Startseite">
<ArrowLeft size={22} strokeWidth={2} />
@@ -307,6 +310,13 @@
padding: 0.6rem 1rem;
position: relative;
}
.brand-stack {
display: flex;
flex-direction: column;
align-items: flex-start;
line-height: 1;
flex-shrink: 0;
}
.brand {
font-size: 1.15rem;
font-weight: 700;
@@ -314,6 +324,13 @@
color: #2b6a3d;
flex-shrink: 0;
}
.version {
margin-top: 2px;
font-size: 0.65rem;
color: #9aa8a0;
font-variant-numeric: tabular-nums;
letter-spacing: 0.02em;
}
.home-back {
display: inline-flex;
align-items: center;
@@ -544,7 +561,7 @@
}
@media (max-width: 520px) {
/* App-Icon auf engen Screens komplett aus — die Suche bekommt den Platz. */
.brand {
.brand-stack {
display: none;
}
.nav-link {

View File

@@ -56,11 +56,13 @@ self.addEventListener('fetch', (event) => {
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));
} else if (strategy === 'network-first') {
event.respondWith(networkFirstWithTimeout(req, DATA_CACHE, NETWORK_TIMEOUT_MS));
}
});
const NETWORK_TIMEOUT_MS = 3000;
async function cacheFirst(req: Request, cacheName: string): Promise<Response> {
const cache = await caches.open(cacheName);
const hit = await cache.match(req);
@@ -70,16 +72,36 @@ async function cacheFirst(req: Request, cacheName: string): Promise<Response> {
return fresh;
}
async function staleWhileRevalidate(req: Request, cacheName: string): Promise<Response> {
// Network-first mit Timeout-Fallback: frische Daten gewinnen, wenn das Netz
// innerhalb von NETWORK_TIMEOUT_MS antwortet. Sonst wird der Cache geliefert
// (falls vorhanden), während der Netz-Fetch noch im Hintergrund weiterläuft
// und den Cache für den nächsten Request aktualisiert. Ohne Cache wartet der
// Client trotzdem aufs Netz, weil ein Error-Response hier nichts nützt.
async function networkFirstWithTimeout(
req: Request,
cacheName: string,
timeoutMs: number
): Promise<Response> {
const cache = await caches.open(cacheName);
const hit = await cache.match(req);
const fetchPromise = fetch(req)
const networkPromise: Promise<Response | null> = fetch(req)
.then((res) => {
if (res.ok) cache.put(req, res.clone()).catch(() => {});
return res;
})
.catch(() => hit ?? Response.error());
return hit ?? fetchPromise;
.catch(() => null);
const timeoutPromise = new Promise<'timeout'>((resolve) =>
setTimeout(() => resolve('timeout'), timeoutMs)
);
const winner = await Promise.race([networkPromise, timeoutPromise]);
if (winner instanceof Response) return winner;
// Timeout oder Netzwerk-Fehler: Cache bevorzugen, sonst auf Netz warten.
const hit = await cache.match(req);
if (hit) return hit;
const late = await networkPromise;
return late ?? Response.error();
}
const META_CACHE = 'kochwas-meta';

View File

@@ -6,14 +6,16 @@ describe('resolveStrategy', () => {
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('network-first for recipe HTML pages', () => {
expect(resolveStrategy({ url: '/recipes/42', method: 'GET' })).toBe('network-first');
});
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-first for recipe API reads', () => {
expect(resolveStrategy({ url: '/api/recipes/42', method: 'GET' })).toBe('network-first');
expect(resolveStrategy({ url: '/api/recipes/all?sort=name', method: 'GET' })).toBe(
'network-first'
);
expect(resolveStrategy({ url: '/api/wishlist', method: 'GET' })).toBe('network-first');
});
it('network-only for write methods', () => {
@@ -34,8 +36,8 @@ describe('resolveStrategy', () => {
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');
it('falls through to network-first for other same-origin GETs (e.g. root page)', () => {
expect(resolveStrategy({ url: '/', method: 'GET' })).toBe('network-first');
expect(resolveStrategy({ url: '/wishlist', method: 'GET' })).toBe('network-first');
});
});