feat(pwa): implement real offline mode with IndexedDB sync

Add genuine offline read/write capability for trips:

- Dexie IndexedDB schema (trips, places, packing, todo, budget,
  reservations, files, mutationQueue, syncMeta, blobCache)
- Repo layer for all domains: offline reads from Dexie, writes
  optimistically to Dexie and enqueue mutations for later replay
- Mutation queue with UUID idempotency keys (X-Idempotency-Key),
  FIFO flush, temp-ID reconciliation on 2xx, fail-and-continue on 4xx
- Trip sync manager: caches all trips with end_date >= today or null,
  auto-evicts 7d after end_date, fetches bundle endpoint in one request
- Map tile prefetcher: bbox from place coords, zooms 10-16, 50MB cap,
  warms SW cache via fetch
- Sync triggers: network online → flush + syncAll; WS reconnect →
  flush only (rate-limiter safe); visibilitychange/30s → flush only
- WS remoteEventHandler writes through to Dexie on every event
- Server idempotency middleware + idempotency_keys table (migration 100,
  24h TTL nightly cleanup)
- GET /api/trips/:id/bundle endpoint for efficient single-request sync
- OfflineBanner component: amber (offline) / blue (syncing) / hidden
- OfflineTab in Settings: cached trip list, re-sync and clear actions
- usePendingMutations hook for per-item pending indicators

Closes #505 #541
This commit is contained in:
jubnl
2026-04-14 23:04:13 +02:00
parent 8c7567faf3
commit b194e8317d
64 changed files with 3837 additions and 638 deletions
+156
View File
@@ -0,0 +1,156 @@
import Dexie, { type Table } from 'dexie';
import type { Trip, Day, Place, PackingItem, TodoItem, BudgetItem, Reservation, TripFile } from '../types';
// ── Queue + sync types ────────────────────────────────────────────────────────
export type MutationStatus = 'pending' | 'syncing' | 'failed';
export interface QueuedMutation {
/** UUID — also used as X-Idempotency-Key sent to the server */
id: string;
tripId: number;
method: 'POST' | 'PUT' | 'PATCH' | 'DELETE';
url: string;
body: unknown;
createdAt: number;
status: MutationStatus;
attempts: number;
lastError: string | null;
/** Dexie table name to write the server response into after flush (e.g. 'places') */
resource?: string;
/** For CREATE mutations enqueued offline: the temporary negative id written to Dexie */
tempId?: number;
/** For DELETE mutations: the entity id to remove from Dexie on flush */
entityId?: number;
}
export interface SyncMeta {
tripId: number;
lastSyncedAt: number | null;
status: 'idle' | 'syncing' | 'error';
/** Bounding box [minLng, minLat, maxLng, maxLat] of pre-downloaded map tiles */
tilesBbox: [number, number, number, number] | null;
filesCachedCount: number;
}
export interface BlobCacheEntry {
/** Relative URL, e.g. "/api/files/42/download" */
url: string;
blob: Blob;
mime: string;
cachedAt: number;
}
// ── Dexie class ────────────────────────────────────────────────────────────────
class TrekOfflineDb extends Dexie {
trips!: Table<Trip, number>;
days!: Table<Day, number>;
places!: Table<Place, number>;
packingItems!: Table<PackingItem, number>;
todoItems!: Table<TodoItem, number>;
budgetItems!: Table<BudgetItem, number>;
reservations!: Table<Reservation, number>;
tripFiles!: Table<TripFile, number>;
mutationQueue!: Table<QueuedMutation, string>;
syncMeta!: Table<SyncMeta, number>;
blobCache!: Table<BlobCacheEntry, string>;
constructor() {
super('trek-offline');
this.version(1).stores({
trips: 'id',
days: 'id, trip_id',
places: 'id, trip_id',
packingItems: 'id, trip_id',
todoItems: 'id, trip_id',
budgetItems: 'id, trip_id',
reservations: 'id, trip_id',
tripFiles: 'id, trip_id',
mutationQueue:'id, tripId, status, createdAt',
syncMeta: 'tripId',
blobCache: 'url, cachedAt',
});
}
}
export const offlineDb = new TrekOfflineDb();
// ── Bulk upsert helpers ────────────────────────────────────────────────────────
export async function upsertTrip(trip: Trip): Promise<void> {
await offlineDb.trips.put(trip);
}
export async function upsertDays(days: Day[]): Promise<void> {
await offlineDb.days.bulkPut(days);
}
export async function upsertPlaces(places: Place[]): Promise<void> {
await offlineDb.places.bulkPut(places);
}
export async function upsertPackingItems(items: PackingItem[]): Promise<void> {
await offlineDb.packingItems.bulkPut(items);
}
export async function upsertTodoItems(items: TodoItem[]): Promise<void> {
await offlineDb.todoItems.bulkPut(items);
}
export async function upsertBudgetItems(items: BudgetItem[]): Promise<void> {
await offlineDb.budgetItems.bulkPut(items);
}
export async function upsertReservations(items: Reservation[]): Promise<void> {
await offlineDb.reservations.bulkPut(items);
}
export async function upsertTripFiles(files: TripFile[]): Promise<void> {
await offlineDb.tripFiles.bulkPut(files);
}
export async function upsertSyncMeta(meta: SyncMeta): Promise<void> {
await offlineDb.syncMeta.put(meta);
}
// ── Eviction / cleanup ────────────────────────────────────────────────────────
/** Delete all cached data for one trip (eviction or explicit clear). */
export async function clearTripData(tripId: number): Promise<void> {
await offlineDb.transaction(
'rw',
[
offlineDb.days,
offlineDb.places,
offlineDb.packingItems,
offlineDb.todoItems,
offlineDb.budgetItems,
offlineDb.reservations,
offlineDb.tripFiles,
offlineDb.mutationQueue,
offlineDb.syncMeta,
],
async () => {
await offlineDb.days.where('trip_id').equals(tripId).delete();
await offlineDb.places.where('trip_id').equals(tripId).delete();
await offlineDb.packingItems.where('trip_id').equals(tripId).delete();
await offlineDb.todoItems.where('trip_id').equals(tripId).delete();
await offlineDb.budgetItems.where('trip_id').equals(tripId).delete();
await offlineDb.reservations.where('trip_id').equals(tripId).delete();
await offlineDb.tripFiles.where('trip_id').equals(tripId).delete();
await offlineDb.mutationQueue.where('tripId').equals(tripId).delete();
await offlineDb.syncMeta.where('tripId').equals(tripId).delete();
},
);
// Remove the trip row itself outside the transaction since it's a separate table
await offlineDb.trips.delete(tripId);
}
/** Wipe the entire offline database (called on logout). */
export async function clearAll(): Promise<void> {
await offlineDb.delete();
// Re-open so subsequent operations don't fail
await offlineDb.open();
}