feat(todo): add PWA support with offline capabilities

- Add web app manifest with app metadata and shortcuts
- Add service worker with network-first, cache-first, and network-only strategies
- Add offline fallback page with auto-reload on reconnection
- Add SVG placeholder icon for PWA
- Add PWA meta tags for iOS, Android, and Windows support
- Add comprehensive PWA guide documentation (docs/PWA_GUIDE.md)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Till-JS 2025-12-09 14:40:42 +01:00
parent d45a9db3fc
commit 1ac74c9bf5
6 changed files with 1292 additions and 2 deletions

View file

@ -1,9 +1,29 @@
<!doctype html>
<html lang="en">
<html lang="de">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<!-- Favicon -->
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<link rel="icon" type="image/svg+xml" href="/icons/icon.svg" />
<!-- PWA Manifest -->
<link rel="manifest" href="/manifest.json" />
<!-- Theme Color -->
<meta name="theme-color" content="#8b5cf6" />
<meta name="msapplication-TileColor" content="#8b5cf6" />
<!-- Apple iOS PWA -->
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Todo" />
<link rel="apple-touch-icon" href="/icons/icon.svg" />
<!-- Microsoft Tiles -->
<meta name="msapplication-config" content="none" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">

View file

@ -0,0 +1,25 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<defs>
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#8b5cf6"/>
<stop offset="100%" style="stop-color:#7c3aed"/>
</linearGradient>
</defs>
<!-- Background -->
<rect width="512" height="512" rx="96" fill="url(#bg)"/>
<!-- Checkbox icon -->
<g fill="none" stroke="white" stroke-width="28" stroke-linecap="round" stroke-linejoin="round">
<!-- Checkbox outline -->
<rect x="120" y="140" width="120" height="120" rx="24" fill="none"/>
<!-- Checkmark -->
<path d="M150 200 L175 225 L220 170"/>
<!-- Lines (task text) -->
<line x1="280" y1="170" x2="392" y2="170"/>
<line x1="280" y1="230" x2="350" y2="230"/>
<!-- Second checkbox (unchecked) -->
<rect x="120" y="300" width="120" height="120" rx="24" fill="none"/>
<!-- Lines for second task -->
<line x1="280" y1="330" x2="392" y2="330"/>
<line x1="280" y1="390" x2="320" y2="390"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1 KiB

View file

@ -0,0 +1,71 @@
{
"name": "Todo - Aufgabenverwaltung",
"short_name": "Todo",
"description": "Aufgaben und Projekte verwalten mit Kanban-Board, Subtasks und mehr",
"start_url": "/",
"scope": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#8b5cf6",
"orientation": "any",
"categories": ["productivity", "utilities"],
"lang": "de",
"dir": "ltr",
"icons": [
{
"src": "/icons/icon.svg",
"sizes": "any",
"type": "image/svg+xml",
"purpose": "any"
},
{
"src": "/icons/icon.svg",
"sizes": "any",
"type": "image/svg+xml",
"purpose": "maskable"
}
],
"shortcuts": [
{
"name": "Neue Aufgabe",
"short_name": "Neu",
"description": "Neue Aufgabe erstellen",
"url": "/?action=new",
"icons": [
{
"src": "/icons/icon.svg",
"sizes": "any",
"type": "image/svg+xml"
}
]
},
{
"name": "Kanban Board",
"short_name": "Kanban",
"description": "Kanban-Ansicht öffnen",
"url": "/kanban",
"icons": [
{
"src": "/icons/icon.svg",
"sizes": "any",
"type": "image/svg+xml"
}
]
},
{
"name": "Einstellungen",
"short_name": "Settings",
"description": "App-Einstellungen öffnen",
"url": "/settings",
"icons": [
{
"src": "/icons/icon.svg",
"sizes": "any",
"type": "image/svg+xml"
}
]
}
],
"screenshots": [],
"prefer_related_applications": false
}

View file

@ -0,0 +1,228 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Offline - Todo</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background: linear-gradient(135deg, #f5f3ff 0%, #ede9fe 100%);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
}
@media (prefers-color-scheme: dark) {
body {
background: linear-gradient(135deg, #1e1b2e 0%, #2d2640 100%);
color: #f3f4f6;
}
}
.container {
text-align: center;
max-width: 400px;
padding: 2rem;
background: rgba(255, 255, 255, 0.9);
border-radius: 1.5rem;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.15);
backdrop-filter: blur(20px);
}
@media (prefers-color-scheme: dark) {
.container {
background: rgba(30, 27, 46, 0.9);
border: 1px solid rgba(255, 255, 255, 0.1);
}
}
.icon {
width: 80px;
height: 80px;
margin: 0 auto 1.5rem;
background: linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%);
border-radius: 1rem;
display: flex;
align-items: center;
justify-content: center;
}
.icon svg {
width: 48px;
height: 48px;
color: white;
}
h1 {
font-size: 1.5rem;
font-weight: 600;
color: #1f2937;
margin-bottom: 0.75rem;
}
@media (prefers-color-scheme: dark) {
h1 {
color: #f3f4f6;
}
}
p {
color: #6b7280;
margin-bottom: 1.5rem;
line-height: 1.6;
}
@media (prefers-color-scheme: dark) {
p {
color: #9ca3af;
}
}
.status {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.75rem 1rem;
background: rgba(239, 68, 68, 0.1);
border-radius: 9999px;
color: #ef4444;
font-size: 0.875rem;
margin-bottom: 1.5rem;
}
.status-dot {
width: 8px;
height: 8px;
background: #ef4444;
border-radius: 50%;
animation: pulse 2s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
button {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1.5rem;
background: linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%);
color: white;
border: none;
border-radius: 9999px;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: transform 0.15s, box-shadow 0.15s;
}
button:hover {
transform: translateY(-2px);
box-shadow: 0 10px 20px -10px rgba(139, 92, 246, 0.5);
}
button:active {
transform: translateY(0);
}
.tips {
margin-top: 2rem;
padding-top: 1.5rem;
border-top: 1px solid rgba(0, 0, 0, 0.1);
text-align: left;
}
@media (prefers-color-scheme: dark) {
.tips {
border-top-color: rgba(255, 255, 255, 0.1);
}
}
.tips h2 {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #9ca3af;
margin-bottom: 0.75rem;
}
.tips ul {
list-style: none;
font-size: 0.875rem;
color: #6b7280;
}
@media (prefers-color-scheme: dark) {
.tips ul {
color: #9ca3af;
}
}
.tips li {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.tips li::before {
content: '•';
color: #8b5cf6;
}
</style>
</head>
<body>
<div class="container">
<div class="icon">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" />
</svg>
</div>
<h1>Du bist offline</h1>
<p>Keine Internetverbindung. Sobald du wieder online bist, kannst du deine Aufgaben verwalten.</p>
<div class="status">
<span class="status-dot"></span>
<span>Verbindung wird gesucht...</span>
</div>
<button onclick="location.reload()">
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
Erneut versuchen
</button>
<div class="tips">
<h2>Was du tun kannst</h2>
<ul>
<li>Überprüfe deine WLAN-Verbindung</li>
<li>Prüfe deine mobilen Daten</li>
<li>Versuche es in einigen Sekunden erneut</li>
</ul>
</div>
</div>
<script>
// Automatisch neu laden, wenn wieder online
window.addEventListener('online', () => {
location.reload();
});
</script>
</body>
</html>

View file

@ -0,0 +1,154 @@
const CACHE_NAME = 'todo-v1';
const OFFLINE_URL = '/offline.html';
// Assets, die immer gecacht werden sollen
const STATIC_CACHE_URLS = ['/', '/offline.html', '/icons/icon.svg', '/manifest.json'];
// Cache-Strategien für verschiedene Ressourcen
const CACHE_STRATEGIES = {
// Netzwerk zuerst, dann Cache (für HTML/Navigation)
networkFirst: [/\/$/, /\.html$/, /^\/kanban/, /^\/settings/, /^\/mana/, /^\/feedback/],
// Cache zuerst, dann Netzwerk (für Assets)
cacheFirst: [
/\.css$/,
/\.js$/,
/\.woff2?$/,
/\.ttf$/,
/\.otf$/,
/\.svg$/,
/\.png$/,
/\.jpg$/,
/\.jpeg$/,
/\.webp$/,
/\.ico$/,
/\/_app\//,
],
// Nur Netzwerk (für API-Calls)
networkOnly: [/\/api\//, /localhost:3018/],
};
// Service Worker Installation
self.addEventListener('install', (event) => {
event.waitUntil(
caches
.open(CACHE_NAME)
.then((cache) => {
console.log('Todo Service Worker: Caching static assets');
return cache.addAll(STATIC_CACHE_URLS);
})
.then(() => self.skipWaiting())
);
});
// Service Worker Aktivierung
self.addEventListener('activate', (event) => {
event.waitUntil(
caches
.keys()
.then((cacheNames) => {
return Promise.all(
cacheNames
.filter((cacheName) => cacheName.startsWith('todo-') && cacheName !== CACHE_NAME)
.map((cacheName) => caches.delete(cacheName))
);
})
.then(() => self.clients.claim())
);
});
// Fetch-Event Handler
self.addEventListener('fetch', (event) => {
const { request } = event;
const url = new URL(request.url);
// Ignoriere Chrome Extension Requests
if (url.protocol === 'chrome-extension:') {
return;
}
// Ignoriere Cross-Origin Requests (z.B. Backend API)
if (url.origin !== self.location.origin) {
return;
}
// Bestimme die Cache-Strategie
const strategy = getStrategy(url.pathname);
if (strategy === 'networkFirst') {
event.respondWith(networkFirst(request));
} else if (strategy === 'cacheFirst') {
event.respondWith(cacheFirst(request));
} else if (strategy === 'networkOnly') {
event.respondWith(networkOnly(request));
} else {
// Standard: Network First
event.respondWith(networkFirst(request));
}
});
// Cache-Strategien Implementierung
async function networkFirst(request) {
try {
const networkResponse = await fetch(request);
if (networkResponse.ok) {
const cache = await caches.open(CACHE_NAME);
cache.put(request, networkResponse.clone());
}
return networkResponse;
} catch (error) {
const cachedResponse = await caches.match(request);
if (cachedResponse) {
return cachedResponse;
}
// Wenn es eine Navigation ist und wir offline sind, zeige die Offline-Seite
if (request.mode === 'navigate') {
const offlineResponse = await caches.match(OFFLINE_URL);
if (offlineResponse) {
return offlineResponse;
}
}
throw error;
}
}
async function cacheFirst(request) {
const cachedResponse = await caches.match(request);
if (cachedResponse) {
return cachedResponse;
}
try {
const networkResponse = await fetch(request);
if (networkResponse.ok) {
const cache = await caches.open(CACHE_NAME);
cache.put(request, networkResponse.clone());
}
return networkResponse;
} catch (error) {
console.error('Todo SW: Fetch failed:', error);
throw error;
}
}
async function networkOnly(request) {
return fetch(request);
}
// Hilfsfunktion zur Bestimmung der Cache-Strategie
function getStrategy(pathname) {
for (const [strategy, patterns] of Object.entries(CACHE_STRATEGIES)) {
if (patterns.some((pattern) => pattern.test(pathname))) {
return strategy;
}
}
return 'networkFirst';
}
// Message Handler für Updates
self.addEventListener('message', (event) => {
if (event.data && event.data.type === 'SKIP_WAITING') {
self.skipWaiting();
}
});