From 9e57f7a567338d608499bbc6fe8f2b0721cc241c Mon Sep 17 00:00:00 2001 From: Dicken Date: Tue, 2 Jun 2026 22:14:02 +0200 Subject: [PATCH] PWA-Reload sauber: nur bei App-Oeffnen oder Dashboard-Klick, niemals bei Eingaben; version.txt im Build, SW v4, Carousel 4 Icons, Kalender ohne Komma --- Dockerfile | 4 +- backend/src/index.js | 11 ++--- frontend/public/sw.js | 41 ++++++++--------- frontend/src/App.jsx | 4 +- frontend/src/main.jsx | 100 ++++++++++++++++++++++++++++++------------ 5 files changed, 100 insertions(+), 60 deletions(-) diff --git a/Dockerfile b/Dockerfile index 1929169..104bd8d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,7 +13,9 @@ WORKDIR /app COPY backend/package.json ./ RUN npm install --production COPY backend/ ./ -COPY version.txt ./version.txt +# version.txt bei jedem Build mit aktuellem Zeitstempel überschreiben +# → /api/build-time liefert neuen Wert → Frontend erkennt neuen Build → Auto-Reload +RUN date -u +"build-%Y%m%d-%H%M%S" > ./version.txt COPY --from=builder /build/dist ./public EXPOSE 4000 CMD ["node", "src/index.js"] diff --git a/backend/src/index.js b/backend/src/index.js index ca628d3..fbaf709 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -78,7 +78,9 @@ app.get('/manifest.json', (_req, res) => { app.get('/sw.js', (_req, res) => { res.setHeader('Content-Type', 'application/javascript'); res.setHeader('Service-Worker-Allowed', '/'); - res.setHeader('Cache-Control', 'no-cache'); + res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate'); + res.setHeader('Pragma', 'no-cache'); + res.setHeader('Expires', '0'); res.sendFile(path.join(PUBLIC, 'sw.js')); }); @@ -88,12 +90,6 @@ app.use(express.static(PUBLIC, { if (filePath.match(/\.(js|css)$/) && !filePath.includes('sw.js')) { res.setHeader('Cache-Control', 'public, max-age=31536000, immutable'); } - // index.html niemals cachen – auch nicht über express.static - if (filePath.endsWith('index.html')) { - res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate'); - res.setHeader('Pragma', 'no-cache'); - res.setHeader('Expires', '0'); - } } })); @@ -101,7 +97,6 @@ app.use(express.static(PUBLIC, { app.get('*', (_req, res) => { res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate'); res.setHeader('Pragma', 'no-cache'); - res.setHeader('Expires', '0'); res.sendFile(path.join(PUBLIC, 'index.html')); }); diff --git a/frontend/public/sw.js b/frontend/public/sw.js index 0f019ec..8309685 100644 --- a/frontend/public/sw.js +++ b/frontend/public/sw.js @@ -1,47 +1,44 @@ -// Service Worker – index.html immer frisch vom Server, Assets gecacht -const CACHE_NAME = 'dickendock-v3'; +// Service Worker v4 – garantiert frische index.html, Auto-Reload bei neuem Build +const VERSION = 'v4'; +const CACHE_NAME = 'dickendock-' + VERSION; self.addEventListener('install', () => { - self.skipWaiting(); + self.skipWaiting(); // neuen SW sofort übernehmen }); self.addEventListener('activate', e => { - e.waitUntil( - caches.keys() - .then(keys => Promise.all(keys.map(k => caches.delete(k)))) - .then(() => self.clients.claim()) - ); + e.waitUntil((async () => { + // alte Caches löschen + const keys = await caches.keys(); + await Promise.all(keys.map(k => caches.delete(k))); + await self.clients.claim(); + // allen offenen Clients sagen: neue Version aktiv → reload + const clients = await self.clients.matchAll({ type: 'window' }); + clients.forEach(c => c.postMessage({ type: 'SW_ACTIVATED', version: VERSION })); + })()); }); self.addEventListener('fetch', e => { const url = new URL(e.request.url); - // API: immer Netzwerk + // API immer direkt ans Netz if (url.pathname.startsWith('/api/')) { e.respondWith(fetch(e.request)); return; } - // HTML-Navigation: immer frisch vom Server, nie aus Cache + // HTML-Navigation: IMMER frisch vom Netzwerk, niemals aus Cache + // (Vite-Bundles haben Hash im Namen → neue index.html lädt neue Bundles) if (e.request.mode === 'navigate') { e.respondWith( - fetch(e.request, { cache: 'no-store' }) + fetch(e.request, { cache: 'reload' }) // 'reload' = HTTP-Cache komplett umgehen .catch(() => caches.match('/index.html')) ); return; } - // Statische Assets (JS/CSS mit Hash): Network-first, Cache als Fallback - e.respondWith( - fetch(e.request) - .then(res => { - if (res.ok) { - caches.open(CACHE_NAME).then(c => c.put(e.request, res.clone())); - } - return res; - }) - .catch(() => caches.match(e.request)) - ); + // Restliche Assets: einfach durchreichen + e.respondWith(fetch(e.request).catch(() => caches.match(e.request))); }); self.addEventListener('message', e => { diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 4472f12..6bd3234 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -2379,7 +2379,9 @@ export default function App() { const [user,setUser]=useState(null); const [active,setActiveRaw]=useState('dashboard'); const setActive = (page) => { - if (window.__newBuildAvailable) { window.location.reload(); return; } + // Reload nur beim Klick auf Dashboard und nur wenn ein neuer Build vorliegt + // und keine Eingabe aktiv ist (schützt ungespeicherte Eingaben) + if (page === 'dashboard' && window.__newBuildAvailable && window.__safeReloadIfNewBuild?.()) return; setActiveRaw(page); }; const [toastMsg,setToastMsg]=useState({msg:'',type:'success'}); diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx index 80066fd..4517b46 100644 --- a/frontend/src/main.jsx +++ b/frontend/src/main.jsx @@ -2,46 +2,93 @@ import React from 'react' import ReactDOM from 'react-dom/client' import App from './App.jsx' -// ── Build-Zeit Check & Auto-Reload ─────────────────────────────────────────── +// ── Auto-Reload bei neuem Build ─────────────────────────────────────────────── +// Regeln: +// - Neuer Build wird erkannt → nur Flag setzen (window.__newBuildAvailable) +// - Reload passiert NUR: +// a) beim Öffnen der App (App kommt aus Hintergrund in den Vordergrund) +// b) beim Klick auf "Dashboard" (in App.jsx geprüft) +// - NIEMALS während einer Eingabe oder bei ungespeicherten Feldern window.__newBuildAvailable = false; const STORED_KEY = 'dd_build_time'; -async function checkBuildTime({ reloadIfChanged = false } = {}) { +// Läuft gerade eine Eingabe? (Fokus auf Eingabefeld) +function isUserTyping() { + const el = document.activeElement; + if (!el) return false; + const tag = el.tagName; + return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || el.isContentEditable; +} + +// Stehen irgendwo ungespeicherte Eingaben in Textfeldern? +function hasUnsavedInput() { + const fields = document.querySelectorAll('input, textarea'); + for (const f of fields) { + const isText = f.tagName === 'TEXTAREA' || + ['text','search','url','email','number','password','tel'].includes(f.type); + if (isText && f.value && f.value.trim() !== '') return true; + } + return false; +} + +async function getBuildTime() { try { const r = await fetch('/api/build-time', { cache: 'no-store' }); const { buildTime } = await r.json(); - const stored = localStorage.getItem(STORED_KEY); - if (!stored) { - localStorage.setItem(STORED_KEY, buildTime); - } else if (stored !== buildTime) { - localStorage.setItem(STORED_KEY, buildTime); - window.__newBuildAvailable = true; - // Wenn explizit gewünscht (App-Fokus): sofort neu laden - if (reloadIfChanged) window.location.reload(); - } - } catch {} + return buildTime; + } catch { return null; } } -// Beim Start prüfen -checkBuildTime(); -// Alle 60s im Hintergrund -setInterval(() => checkBuildTime(), 60_000); - -// Wenn die App/der Tab wieder in den Vordergrund kommt → prüfen und ggf. sofort neu laden -// Das deckt den Fall ab: PWA vom Homescreen öffnen -document.addEventListener('visibilitychange', () => { - if (document.visibilityState === 'visible') { - checkBuildTime({ reloadIfChanged: true }); +// Prüft die Build-Zeit und setzt nur das Flag (lädt nie von selbst) +async function checkBuild() { + const bt = await getBuildTime(); + if (!bt) return; + const stored = localStorage.getItem(STORED_KEY); + if (!stored) { + localStorage.setItem(STORED_KEY, bt); + } else if (stored !== bt) { + localStorage.setItem(STORED_KEY, bt); + window.__newBuildAvailable = true; + } +} + +// Sicherer Reload: nur wenn neuer Build da UND keine Eingabe aktiv/ungespeichert +// Wird von App.jsx beim Dashboard-Klick aufgerufen. Gibt true zurück wenn neu geladen wird. +window.__safeReloadIfNewBuild = function () { + if (!window.__newBuildAvailable) return false; + if (isUserTyping() || hasUnsavedInput()) return false; + window.location.reload(); + return true; +}; + +// Beim Start einmal prüfen (App-Öffnen Fall 1: Kaltstart) +checkBuild(); +// Im Hintergrund alle 60s nur das Flag aktualisieren +setInterval(() => checkBuild(), 60_000); + +// App-Öffnen Fall 2: App kommt aus dem Hintergrund zurück in den Vordergrund +document.addEventListener('visibilitychange', async () => { + if (document.visibilityState === 'visible') { + await checkBuild(); + // Nur laden wenn der User nicht gerade tippt und nichts ungespeichert ist + if (!isUserTyping() && !hasUnsavedInput()) { + window.__safeReloadIfNewBuild(); + } } -}); -window.addEventListener('focus', () => { - checkBuildTime({ reloadIfChanged: true }); }); // ── Service Worker ──────────────────────────────────────────────────────────── if ('serviceWorker' in navigator) { + navigator.serviceWorker.addEventListener('message', (e) => { + if (e.data?.type === 'SW_ACTIVATED') { + window.__newBuildAvailable = true; // nur merken + } + }); + navigator.serviceWorker.register('/sw.js').then(reg => { + // Browser regelmäßig nach neuem SW suchen lassen + setInterval(() => reg.update().catch(()=>{}), 60_000); if (reg.waiting) reg.waiting.postMessage('SKIP_WAITING'); reg.addEventListener('updatefound', () => { const nw = reg.installing; @@ -49,9 +96,6 @@ if ('serviceWorker' in navigator) { if (nw.state === 'installed') nw.postMessage('SKIP_WAITING'); }); }); - navigator.serviceWorker.addEventListener('controllerchange', () => { - window.__newBuildAvailable = true; - }); }).catch(()=>{}); }