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>
This commit is contained in:
sandrodachandClaude Opus 5.5 committed 2026-10-29 08:11:47 +01:00
1 parent 5e20710aa7
commit 7bba174454
9 files changed
+472 -76

No files matched your search

+85 -68
View File
@@ -35,9 +35,18 @@ import {
WSMessage,
} from './types';
import { extractHashtags } from './hashtags';
import {
OfflineError,
isOffline,
isCacheablePath,
readCached,
writeCached,
clearOfflineData,
enqueue,
flushOutbox,
} from './offline';
const TOKEN_STORAGE_KEY = 'mindsift_jwt_token';
const OFFLINE_QUEUE_KEY = 'mindsift_offline_capture_queue';
export function getToken(): string | null {
return localStorage.getItem(TOKEN_STORAGE_KEY);
@@ -62,6 +71,8 @@ export function setToken(token: string): void {
export function clearToken(): void {
localStorage.removeItem(TOKEN_STORAGE_KEY);
// Cached answers and unsent changes belong to the account that just left
void clearOfflineData();
}
export function triggerHaptic(pattern: number | number[] = 15): void {
@@ -80,6 +91,15 @@ function ensureArray<T>(val: T[] | null | undefined): T[] {
async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
const token = getToken();
const method = (options.method || 'GET').toUpperCase();
const cacheable = method === 'GET' && isCacheablePath(path);
const userKey = getCurrentUserId() || 'anon';
// Known to be offline: answer from the cache right away instead of waiting for the timeout
if (cacheable && isOffline()) {
const cached = await readCached<T>(userKey, path);
if (cached !== undefined) return cached;
}
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'Cache-Control': 'no-cache',
@@ -101,6 +121,13 @@ async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
cache: 'no-store',
signal: controller.signal,
});
} catch {
// No HTTP answer at all (offline, server down, timeout): fall back to the last known answer
if (cacheable) {
const cached = await readCached<T>(userKey, path);
if (cached !== undefined) return cached;
}
throw new OfflineError();
} finally {
window.clearTimeout(timeoutId);
}
@@ -127,7 +154,20 @@ async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
throw new Error(errMsg);
}
return res.json();
const data = await res.json();
if (cacheable) writeCached(userKey, path, data);
return data;
}
// Sends a queued outbox entry with the current token (no timeout, no cache)
function sendOutboxEntry(entry: { method: string; path: string; body: Record<string, unknown> }): Promise<Response> {
const token = getToken();
return fetch(entry.path, {
method: entry.method,
headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) },
body: JSON.stringify(entry.body),
cache: 'no-store',
});
}
// Server-wide sharing mode from /auth/status; in private mode the UI hides sharing and groups
@@ -272,26 +312,24 @@ export const api = {
taskListId?: string;
}
): Promise<Thought> {
const body = {
content,
priority,
source,
dueDate,
target: options?.target || 'inbox',
targetListId: options?.targetListId || '',
taskListId: options?.taskListId || '',
};
try {
const thought = await request<Thought>('/api/v1/inbox', {
method: 'POST',
body: JSON.stringify({
content,
priority,
source,
dueDate,
target: options?.target || 'inbox',
targetListId: options?.targetListId || '',
taskListId: options?.taskListId || '',
}),
});
return thought;
} catch {
// Queue locally if offline
const queue = getOfflineQueue();
const offlineThought: Thought = {
id: `th_offline_${Date.now()}`,
userId: 'offline',
return await request<Thought>('/api/v1/inbox', { method: 'POST', body: JSON.stringify(body) });
} catch (err) {
if (!(err instanceof OfflineError)) throw err;
// Kept in the outbox and sent when the connection is back
const entry = enqueue('POST', '/api/v1/inbox', body);
return {
id: `th_offline_${entry.id}`,
userId: getCurrentUserId() || 'offline',
content,
tags: Array.from(new Set(extractHashtags(content))),
priority,
@@ -300,41 +338,15 @@ export const api = {
isCompleted: false,
dueDate,
taskListId: options?.taskListId || '',
createdAt: new Date().toISOString(),
createdAt: entry.createdAt,
};
queue.push(offlineThought);
localStorage.setItem(OFFLINE_QUEUE_KEY, JSON.stringify(queue));
return offlineThought;
}
},
/** Sends what was captured or checked off while offline; returns how many changes went through. */
async flushOfflineQueue(): Promise<number> {
const queue = getOfflineQueue();
if (queue.length === 0) return 0;
let synced = 0;
const remaining: Thought[] = [];
for (const item of queue) {
try {
await request<Thought>('/api/v1/inbox', {
method: 'POST',
body: JSON.stringify({
content: item.content,
priority: item.priority,
source: item.source,
dueDate: item.dueDate || '',
taskListId: item.taskListId || '',
}),
});
synced++;
} catch {
remaining.push(item);
}
}
localStorage.setItem(OFFLINE_QUEUE_KEY, JSON.stringify(remaining));
return synced;
if (!getToken()) return 0;
return flushOutbox(sendOutboxEntry);
},
// --- THOUGHTS, TRIAGE & TASKS ---
@@ -404,10 +416,18 @@ export const api = {
recurrence?: string;
}
): Promise<Thought> {
return request<Thought>(`/api/v1/thoughts/${thoughtId}`, {
method: 'PATCH',
body: JSON.stringify(patch),
});
try {
return await request<Thought>(`/api/v1/thoughts/${thoughtId}`, {
method: 'PATCH',
body: JSON.stringify(patch),
});
} catch (err) {
// Checking a task off (or on) also works offline; other edits need the server
const onlyStatus = Object.keys(patch).every((k) => k === 'isCompleted' || k === 'kanbanStatus');
if (!(err instanceof OfflineError) || !onlyStatus) throw err;
enqueue('PATCH', `/api/v1/thoughts/${thoughtId}`, patch);
return { id: thoughtId, ...patch } as Thought;
}
},
async deleteThought(thoughtId: string): Promise<void> {
@@ -853,10 +873,16 @@ export const api = {
},
async toggleListItem(listId: string, itemId: string, isCompleted?: boolean): Promise<ListItem> {
return request<ListItem>(`/api/v1/lists/${listId}/items/${itemId}`, {
method: 'PATCH',
body: JSON.stringify({ isCompleted }),
});
try {
return await request<ListItem>(`/api/v1/lists/${listId}/items/${itemId}`, {
method: 'PATCH',
body: JSON.stringify({ isCompleted }),
});
} catch (err) {
if (!(err instanceof OfflineError) || isCompleted === undefined) throw err;
enqueue('PATCH', `/api/v1/lists/${listId}/items/${itemId}`, { isCompleted });
return { id: itemId, listId, isCompleted } as ListItem;
}
},
async updateListItem(
@@ -1163,15 +1189,6 @@ export const api = {
},
};
export function getOfflineQueue(): Thought[] {
try {
const raw = localStorage.getItem(OFFLINE_QUEUE_KEY);
if (!raw) return [];
return JSON.parse(raw);
} catch {
return [];
}
}
export type RealtimeTransport = 'ws' | 'sse' | 'none';