Files
MindSift/frontend/src/lib/offline.ts
T
sandrodachandClaude Opus 5.5 7bba174454 feat(pwa): start and keep working offline
The service worker now caches the app shell: page loads are network
first with the last index.html as fallback, hashed bundles come from the
cache, icons and manifest are refreshed in the background; API calls are
never intercepted. GET answers are kept per user in IndexedDB and served
when the server cannot be reached, so the app starts logged in with the
last known thoughts, tasks and lists. Captured thoughts and checked tasks
or list items go to an outbox that is sent in order when the connection
returns (also at start and every 30 s). A banner shows offline state and
waiting changes. Logging out clears cache and outbox.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 08:11:47 +01:00

184 lines
6.1 KiB
TypeScript

// Offline support for the REST layer (lib/api.ts):
// - Read cache: successful GET responses are kept in IndexedDB per user. Without network the last
// known answer is returned, so thoughts, tasks and lists stay visible offline.
// - Outbox: a few mutations (capturing a thought, checking off a task or list item) are queued in
// localStorage while offline and sent in order once the connection is back.
// Notes have their own offline store in lib/docSync.
/** Thrown by request() when the server could not be reached at all (no HTTP answer). */
export class OfflineError extends Error {
constructor() {
super('Keine Verbindung zum Server');
this.name = 'OfflineError';
}
}
export const isOffline = () => typeof navigator !== 'undefined' && navigator.onLine === false;
// --- Read cache (IndexedDB) ---
const DB_NAME = 'mindsift-offline';
const STORE = 'responses';
// Endpoints whose answers are worth keeping; streams, long polls, note sync and downloads are not
const NOT_CACHED = [/^\/api\/v1\/(events|ws)/, /\/changes/, /\/sync/, /^\/api\/v1\/export/, /^\/api\/v1\/search/];
export const isCacheablePath = (path: string) => path.startsWith('/api/v1/') && !NOT_CACHED.some((re) => re.test(path));
let dbPromise: Promise<IDBDatabase | null> | null = null;
function openDB(): Promise<IDBDatabase | null> {
if (dbPromise) return dbPromise;
dbPromise = new Promise((resolve) => {
try {
if (typeof indexedDB === 'undefined') return resolve(null);
const req = indexedDB.open(DB_NAME, 1);
req.onupgradeneeded = () => req.result.createObjectStore(STORE);
req.onsuccess = () => resolve(req.result);
req.onerror = () => resolve(null);
} catch {
resolve(null);
}
});
return dbPromise;
}
async function withStore<T>(mode: IDBTransactionMode, fn: (s: IDBObjectStore) => IDBRequest<T> | void): Promise<T | undefined> {
const db = await openDB();
if (!db) return undefined;
return new Promise((resolve) => {
try {
const tx = db.transaction(STORE, mode);
const req = fn(tx.objectStore(STORE));
tx.oncomplete = () => resolve(req ? (req.result as T) : undefined);
tx.onerror = () => resolve(undefined);
tx.onabort = () => resolve(undefined);
} catch {
resolve(undefined);
}
});
}
// Cached answers belong to one account: the key starts with the user ID from the token
function cacheKey(userKey: string, path: string) {
return `${userKey}|${path}`;
}
export async function readCached<T>(userKey: string, path: string): Promise<T | undefined> {
return withStore<T>('readonly', (s) => s.get(cacheKey(userKey, path)));
}
export function writeCached(userKey: string, path: string, value: unknown): void {
void withStore('readwrite', (s) => {
s.put(value, cacheKey(userKey, path));
});
}
export async function clearOfflineData(): Promise<void> {
try {
localStorage.removeItem(OUTBOX_KEY);
localStorage.removeItem(LEGACY_QUEUE_KEY);
} catch {}
notifyOutbox();
await withStore('readwrite', (s) => {
s.clear();
});
}
// --- Outbox (localStorage) ---
export interface OutboxEntry {
id: string;
method: 'POST' | 'PATCH';
path: string;
body: Record<string, unknown>;
createdAt: string;
}
const OUTBOX_KEY = 'mindsift_outbox';
const LEGACY_QUEUE_KEY = 'mindsift_offline_capture_queue'; // thoughts captured offline before the outbox existed
function notifyOutbox() {
if (typeof window !== 'undefined') window.dispatchEvent(new CustomEvent('mindsift:outbox-changed'));
}
export function getOutbox(): OutboxEntry[] {
try {
const entries: OutboxEntry[] = JSON.parse(localStorage.getItem(OUTBOX_KEY) || '[]');
const legacy = JSON.parse(localStorage.getItem(LEGACY_QUEUE_KEY) || '[]');
if (Array.isArray(legacy) && legacy.length > 0) {
for (const t of legacy) {
entries.push({
id: t.id,
method: 'POST',
path: '/api/v1/inbox',
body: { content: t.content, priority: t.priority, source: t.source, dueDate: t.dueDate || '', taskListId: t.taskListId || '' },
createdAt: t.createdAt,
});
}
localStorage.setItem(OUTBOX_KEY, JSON.stringify(entries));
localStorage.removeItem(LEGACY_QUEUE_KEY);
}
return Array.isArray(entries) ? entries : [];
} catch {
return [];
}
}
function saveOutbox(entries: OutboxEntry[]) {
try {
localStorage.setItem(OUTBOX_KEY, JSON.stringify(entries));
} catch {}
notifyOutbox();
}
/**
* Queues a request. A PATCH to the same path replaces the earlier one (checking a task off and on
* again while offline sends only the final state).
*/
export function enqueue(method: OutboxEntry['method'], path: string, body: Record<string, unknown>): OutboxEntry {
const entries = getOutbox();
const entry: OutboxEntry = {
id: `ob_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 7)}`,
method,
path,
body,
createdAt: new Date().toISOString(),
};
const same = method === 'PATCH' ? entries.findIndex((e) => e.method === 'PATCH' && e.path === path) : -1;
if (same >= 0) entries[same] = { ...entry, body: { ...entries[same].body, ...body } };
else entries.push(entry);
saveOutbox(entries);
return entry;
}
export const outboxCount = () => getOutbox().length;
let flushing: Promise<number> | null = null;
/**
* Sends the queued requests in order. Stops at the first network failure and keeps the rest; entries
* the server rejects (4xx, e.g. the task was deleted meanwhile) are dropped. Returns how many went through.
*/
export function flushOutbox(send: (e: OutboxEntry) => Promise<Response>): Promise<number> {
if (flushing) return flushing;
flushing = (async () => {
let synced = 0;
for (const entry of getOutbox()) {
let res: Response;
try {
res = await send(entry);
} catch {
break; // still offline
}
if (res.status === 401 || res.status === 408 || res.status === 429 || res.status >= 500) break;
saveOutbox(getOutbox().filter((e) => e.id !== entry.id));
if (res.ok) synced++;
}
return synced;
})().finally(() => {
flushing = null;
});
return flushing;
}