feat(m5): PWA service worker, offline Dexie store, outbox, sync endpoints

This commit is contained in:
2026-04-30 16:47:27 +02:00
parent df04d9d7a9
commit 4a328ad6cc
12 changed files with 4995 additions and 6 deletions

4602
web/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -21,6 +21,9 @@
"vite": "^8.0.7"
},
"dependencies": {
"@sveltejs/adapter-static": "^3.0.10"
"@sveltejs/adapter-static": "^3.0.10",
"dexie": "^4.4.2",
"vite-plugin-pwa": "^1.2.0",
"workbox-window": "^7.4.0"
}
}

43
web/src/lib/stores/db.ts Normal file
View File

@@ -0,0 +1,43 @@
import Dexie, { type Table } from 'dexie';
import type { Entry, ClosedDay, ClosedWeek, Settings } from '$lib/api/client';
export interface OutboxItem {
id?: number; // auto-increment
entity: string; // 'entries' | 'closed_days' | 'closed_weeks' | 'settings'
entity_id: string;
op: 'upsert' | 'delete';
payload: string; // JSON
created_at: number;
}
export class WotraDB extends Dexie {
entries!: Table<Entry, string>;
closed_days!: Table<ClosedDay, string>;
closed_weeks!: Table<ClosedWeek, string>;
settings_history!: Table<Settings, number>;
outbox!: Table<OutboxItem, number>;
meta!: Table<{ key: string; value: string }, string>;
constructor() {
super('wotra');
this.version(1).stores({
entries: 'id, day_key, start_time, updated_at',
closed_days: 'day_key, updated_at',
closed_weeks: 'week_key, updated_at',
settings_history: '++id, effective_from',
outbox: '++id, entity, entity_id',
meta: 'key'
});
}
}
export const db = new WotraDB();
/** Get/set last pulled server version */
export async function getLastVersion(): Promise<number> {
const row = await db.meta.get('last_version');
return row ? Number(row.value) : 0;
}
export async function setLastVersion(v: number) {
await db.meta.put({ key: 'last_version', value: String(v) });
}

104
web/src/lib/stores/sync.ts Normal file
View File

@@ -0,0 +1,104 @@
/**
* Sync layer: push local outbox items to server, pull server changes.
* Uses last-write-wins based on updated_at.
*/
import { db, getLastVersion, setLastVersion } from './db';
import type { OutboxItem } from './db';
import { setToken, hasToken } from '$lib/api/client';
const API = '/api';
function headers() {
const token = localStorage.getItem('auth_token') ?? '';
return {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
};
}
export async function pushOutbox(): Promise<void> {
if (!hasToken()) return;
const items = await db.outbox.toArray();
if (items.length === 0) return;
const res = await fetch(`${API}/sync/push`, {
method: 'POST',
headers: headers(),
body: JSON.stringify({ changes: items.map((i) => ({ ...JSON.parse(i.payload), _op: i.op, _entity: i.entity })) })
});
if (!res.ok) return; // will retry on next sync
const { applied } = await res.json() as { applied: string[]; conflicts: string[] };
// Remove applied items from outbox
const appliedIds = new Set(applied);
const toDelete = items.filter((i) => i.entity_id && appliedIds.has(i.entity_id)).map((i) => i.id!);
if (toDelete.length > 0) await db.outbox.bulkDelete(toDelete);
}
export async function pullChanges(): Promise<void> {
if (!hasToken()) return;
const since = await getLastVersion();
const res = await fetch(`${API}/sync/pull`, {
method: 'POST',
headers: headers(),
body: JSON.stringify({ since_version: since })
});
if (!res.ok) return;
const { changes, server_version } = await res.json() as {
changes: Array<{ entity: string; entity_id: string; op: string; payload: string }>;
server_version: number;
};
for (const change of changes) {
const data = JSON.parse(change.payload);
if (change.op === 'delete') {
await applyDelete(change.entity, change.entity_id);
} else {
await applyUpsert(change.entity, data);
}
}
await setLastVersion(server_version);
}
async function applyUpsert(entity: string, data: unknown) {
switch (entity) {
case 'entries': await db.entries.put(data as any); break;
case 'closed_days': await db.closed_days.put(data as any); break;
case 'closed_weeks': await db.closed_weeks.put(data as any); break;
case 'settings_history': await db.settings_history.put(data as any); break;
}
}
async function applyDelete(entity: string, id: string) {
switch (entity) {
case 'entries': await db.entries.delete(id); break;
case 'closed_days': await db.closed_days.delete(id); break;
case 'closed_weeks': await db.closed_weeks.delete(id); break;
}
}
let syncInterval: ReturnType<typeof setInterval> | null = null;
/** Start background sync loop (every 30 seconds). */
export function startSync() {
if (syncInterval) return;
sync(); // immediate
syncInterval = setInterval(sync, 30_000);
}
export function stopSync() {
if (syncInterval) {
clearInterval(syncInterval);
syncInterval = null;
}
}
async function sync() {
try {
await pushOutbox();
await pullChanges();
} catch {
// Network unavailable — will retry
}
}

View File

@@ -1,8 +1,9 @@
<script lang="ts">
import { page } from '$app/state';
import { hasToken } from '$lib/api/client';
import { onMount } from 'svelte';
import { onMount, onDestroy } from 'svelte';
import { goto } from '$app/navigation';
import { startSync, stopSync } from '$lib/stores/sync';
let { children } = $props();
@@ -10,8 +11,11 @@
if (!hasToken() && page.url.pathname !== '/settings') {
goto('/settings');
}
startSync();
});
onDestroy(stopSync);
const navItems = [
{ href: '/today', label: 'Today' },
{ href: '/week', label: 'Week' },

BIN
web/static/icon-192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 954 B

BIN
web/static/icon-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

View File

@@ -1,8 +1,45 @@
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
import { VitePWA } from 'vite-plugin-pwa';
export default defineConfig({
plugins: [sveltekit()],
plugins: [
sveltekit(),
VitePWA({
registerType: 'autoUpdate',
strategies: 'generateSW',
injectRegister: 'auto',
workbox: {
globPatterns: ['**/*.{js,css,html,svg,png,ico,woff,woff2}'],
navigateFallback: 'index.html',
navigateFallbackDenylist: [/^\/api/, /^\/healthz/],
runtimeCaching: [
{
urlPattern: /^\/api\//,
handler: 'NetworkFirst',
options: {
cacheName: 'api-cache',
networkTimeoutSeconds: 5,
cacheableResponse: { statuses: [0, 200] }
}
}
]
},
manifest: {
name: 'Wotra — Working Time Tracker',
short_name: 'Wotra',
description: 'Track your working hours, close days and weeks, compute overtime.',
theme_color: '#1a1a2e',
background_color: '#f8f9fa',
display: 'standalone',
start_url: '/',
icons: [
{ src: '/icon-192.png', sizes: '192x192', type: 'image/png' },
{ src: '/icon-512.png', sizes: '512x512', type: 'image/png' }
]
}
})
],
server: {
proxy: {
'/api': 'http://localhost:8080',