Files
MindSift/frontend/src/lib/offline.ts
T
sandrodachandClaude Opus 5.5 28ebbe7263
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m34s
fix(lists): add items offline via the outbox; recognisable input field
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 15:07:19 +01:00

221 lines
7.6 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;
/** Changes the body of a queued entry (e.g. the quantity of a list item added offline). */
export function updateQueued(entryId: string, body: Record<string, unknown>): boolean {
const entries = getOutbox();
const entry = entries.find((e) => e.id === entryId);
if (!entry) return false;
entry.body = { ...entry.body, ...body };
saveOutbox(entries);
return true;
}
/** Removes a queued entry that is no longer wanted (e.g. a list item added and deleted while offline). */
export function dropQueued(entryId: string): boolean {
const entries = getOutbox();
const rest = entries.filter((e) => e.id !== entryId);
if (rest.length === entries.length) return false;
saveOutbox(rest);
return true;
}
// List items added offline: a stand-in id until the server knows them, derived from the outbox entry
export const OFFLINE_ITEM_PREFIX = 'itm_offline_';
const LIST_ITEMS_PATH = /^\/api\/v1\/lists\/([^/]+)\/items$/;
/** List items still waiting in the outbox, as stand-ins for the list view (also after a reload). */
export function pendingListItems(): { listId: string; id: string; content: string; category: string; quantity: string; createdAt: string }[] {
return getOutbox()
.filter((e) => e.method === 'POST' && LIST_ITEMS_PATH.test(e.path))
.map((e) => ({
listId: (e.path.match(LIST_ITEMS_PATH) as RegExpMatchArray)[1],
id: OFFLINE_ITEM_PREFIX + e.id,
content: String(e.body.content || ''),
category: String(e.body.category || ''),
quantity: String(e.body.quantity || ''),
createdAt: e.createdAt,
}));
}
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;
}