feat(m5): PWA service worker, offline Dexie store, outbox, sync endpoints
This commit is contained in:
43
web/src/lib/stores/db.ts
Normal file
43
web/src/lib/stores/db.ts
Normal 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
104
web/src/lib/stores/sync.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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' },
|
||||
|
||||
Reference in New Issue
Block a user