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:
1 parent
5e20710aa7
commit
7bba174454
9 files changed
+472
-76
No files matched your search
+85
-7
@@ -1,18 +1,96 @@
|
||||
// Network-first / cache-clearing service worker to ensure fresh builds across all browsers
|
||||
self.addEventListener('install', () => {
|
||||
self.skipWaiting();
|
||||
// MindSift service worker: keeps the app shell so the PWA also starts without network.
|
||||
// - Page loads (navigation): network first, so a new release is picked up right away; offline the
|
||||
// last loaded index.html is used.
|
||||
// - Built bundles (/assets/*, file names contain a content hash): from the cache, loaded once.
|
||||
// - Icons and manifest: from the cache, refreshed in the background.
|
||||
// - API requests are never touched; offline data comes from the app's own cache (lib/offline.ts).
|
||||
// Raise VERSION when this file changes in a way that needs fresh caches.
|
||||
const VERSION = 'v2';
|
||||
const SHELL_CACHE = `mindsift-shell-${VERSION}`;
|
||||
const ASSET_CACHE = `mindsift-assets-${VERSION}`;
|
||||
const MAX_ASSETS = 80;
|
||||
const SHELL_URLS = [
|
||||
'/',
|
||||
'/index.html',
|
||||
'/manifest.json',
|
||||
'/favicon.ico',
|
||||
'/icon.svg',
|
||||
'/icon-192x192.png',
|
||||
'/icon-512x512.png',
|
||||
'/apple-touch-icon.png',
|
||||
];
|
||||
|
||||
self.addEventListener('install', (event) => {
|
||||
event.waitUntil(
|
||||
caches
|
||||
.open(SHELL_CACHE)
|
||||
.then((cache) => Promise.all(SHELL_URLS.map((url) => cache.add(new Request(url, { cache: 'reload' })).catch(() => {}))))
|
||||
.then(() => self.skipWaiting())
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener('activate', (event) => {
|
||||
event.waitUntil(
|
||||
caches
|
||||
.keys()
|
||||
.then((keys) => Promise.all(keys.map((k) => caches.delete(k))))
|
||||
.then((keys) => Promise.all(keys.filter((k) => k !== SHELL_CACHE && k !== ASSET_CACHE).map((k) => caches.delete(k))))
|
||||
.then(() => self.clients.claim())
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener('fetch', () => {
|
||||
// Always use native browser network fetch so API calls and fresh Vite bundles are never intercepted by stale caches
|
||||
return;
|
||||
// Old bundles pile up with every release; keep only the newest entries
|
||||
async function trimAssets() {
|
||||
const cache = await caches.open(ASSET_CACHE);
|
||||
const keys = await cache.keys();
|
||||
await Promise.all(keys.slice(0, Math.max(0, keys.length - MAX_ASSETS)).map((k) => cache.delete(k)));
|
||||
}
|
||||
|
||||
async function networkFirstPage(request) {
|
||||
const cache = await caches.open(SHELL_CACHE);
|
||||
try {
|
||||
const response = await fetch(request, { cache: 'no-store' });
|
||||
if (response.ok && response.type === 'basic') cache.put('/index.html', response.clone());
|
||||
return response;
|
||||
} catch {
|
||||
return (await cache.match('/index.html')) || (await cache.match('/')) || Response.error();
|
||||
}
|
||||
}
|
||||
|
||||
async function cacheFirstAsset(request) {
|
||||
const cache = await caches.open(ASSET_CACHE);
|
||||
const hit = await cache.match(request);
|
||||
if (hit) return hit;
|
||||
const response = await fetch(request);
|
||||
if (response.ok && response.type === 'basic') {
|
||||
await cache.put(request, response.clone());
|
||||
trimAssets();
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
async function staleWhileRevalidate(request) {
|
||||
const cache = await caches.open(SHELL_CACHE);
|
||||
const hit = await cache.match(request);
|
||||
const refresh = fetch(request)
|
||||
.then((response) => {
|
||||
if (response.ok && response.type === 'basic') cache.put(request, response.clone());
|
||||
return response;
|
||||
})
|
||||
.catch(() => hit || Response.error());
|
||||
return hit || refresh;
|
||||
}
|
||||
|
||||
self.addEventListener('fetch', (event) => {
|
||||
const { request } = event;
|
||||
if (request.method !== 'GET') return;
|
||||
const url = new URL(request.url);
|
||||
if (url.origin !== self.location.origin || url.pathname.startsWith('/api/')) return;
|
||||
|
||||
if (request.mode === 'navigate') {
|
||||
event.respondWith(networkFirstPage(request));
|
||||
} else if (url.pathname.startsWith('/assets/')) {
|
||||
event.respondWith(cacheFirstAsset(request));
|
||||
} else if (SHELL_URLS.includes(url.pathname)) {
|
||||
event.respondWith(staleWhileRevalidate(request));
|
||||
}
|
||||
});
|
||||
@@ -50,6 +50,8 @@ import { TasksView } from './features/triage/TasksView';
|
||||
import { WorkspaceView } from './features/workspace/WorkspaceView';
|
||||
import { SharedListsView } from './features/lists/SharedListsView';
|
||||
import { SetupAndAuthView } from './features/auth/SetupAndAuthView';
|
||||
import { OfflineBanner } from './components/OfflineBanner';
|
||||
import { outboxCount } from './lib/offline';
|
||||
import { UserSettingsView } from './features/settings/UserSettingsView';
|
||||
import { AdminSettingsView } from './features/settings/AdminSettingsView';
|
||||
import { HelpView } from './features/help/HelpView';
|
||||
@@ -525,9 +527,15 @@ export const App: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// Changes still waiting (server was unreachable although the device was online) are retried
|
||||
const retry = window.setInterval(() => {
|
||||
if (navigator.onLine && outboxCount() > 0) onOnline();
|
||||
}, 30000);
|
||||
|
||||
window.addEventListener('mindsift:unauthorized', onUnauthorized);
|
||||
window.addEventListener('online', onOnline);
|
||||
return () => {
|
||||
window.clearInterval(retry);
|
||||
window.removeEventListener('mindsift:unauthorized', onUnauthorized);
|
||||
window.removeEventListener('online', onOnline);
|
||||
};
|
||||
@@ -1043,6 +1051,7 @@ export const App: React.FC = () => {
|
||||
</header>
|
||||
|
||||
<MobileHeaderMenu activeTabLabel={activeTabLabelMap[activeTab]} />
|
||||
<OfflineBanner />
|
||||
|
||||
{/* Main Container */}
|
||||
<main className="flex-1 max-w-6xl w-full mx-auto px-3 sm:px-4 pt-3 pb-24 sm:pb-6">
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { CloudOff, RefreshCw } from 'lucide-react';
|
||||
import { outboxCount } from '../lib/offline';
|
||||
|
||||
// Small notice above the bottom navigation: offline (with the number of changes waiting), or
|
||||
// "synchronisiere" while queued changes are being sent after the connection came back.
|
||||
export const OfflineBanner: React.FC = () => {
|
||||
const [online, setOnline] = useState(() => (typeof navigator === 'undefined' ? true : navigator.onLine));
|
||||
const [pending, setPending] = useState(() => outboxCount());
|
||||
|
||||
useEffect(() => {
|
||||
const update = () => {
|
||||
setOnline(navigator.onLine);
|
||||
setPending(outboxCount());
|
||||
};
|
||||
window.addEventListener('online', update);
|
||||
window.addEventListener('offline', update);
|
||||
window.addEventListener('mindsift:outbox-changed', update);
|
||||
window.addEventListener('storage', update);
|
||||
return () => {
|
||||
window.removeEventListener('online', update);
|
||||
window.removeEventListener('offline', update);
|
||||
window.removeEventListener('mindsift:outbox-changed', update);
|
||||
window.removeEventListener('storage', update);
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (online && pending === 0) return null;
|
||||
|
||||
const waiting = `${pending} ${pending === 1 ? 'Änderung wartet' : 'Änderungen warten'}`;
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
className="fixed left-1/2 -translate-x-1/2 bottom-[calc(5.5rem+env(safe-area-inset-bottom))] sm:bottom-4 z-[60] max-w-[calc(100vw-1.5rem)] px-3 py-1.5 rounded-full border text-[11px] font-medium shadow-panel flex items-center gap-1.5 bg-panel-elevated border-status-warn/40 text-main"
|
||||
>
|
||||
{online ? (
|
||||
<RefreshCw className="w-3.5 h-3.5 text-accent animate-spin shrink-0" />
|
||||
) : (
|
||||
<CloudOff className="w-3.5 h-3.5 text-status-warn shrink-0" />
|
||||
)}
|
||||
<span className="truncate">
|
||||
{online
|
||||
? `Synchronisiere – ${waiting}…`
|
||||
: pending > 0
|
||||
? `Offline · ${waiting} auf Verbindung`
|
||||
: 'Offline – Änderungen werden bei Verbindung synchronisiert'}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -632,6 +632,9 @@ export const HelpView: React.FC<HelpViewProps> = ({ onBack }) => {
|
||||
<li>
|
||||
<strong className="text-main">Eigenes Theme:</strong> Vorlage herunterladen oder einfügen, Farben und Rundungen anpassen, als <code className="text-accent bg-panel px-1 py-0.5 rounded font-mono">.css</code>-Datei hochladen oder einfügen und „Speichern & aktivieren“. Das Theme greift sofort und steht dir auf allen Geräten zur Verfügung.
|
||||
</li>
|
||||
<li>
|
||||
<strong className="text-main">Ohne Internet:</strong> Als App installiert (oder im Browser) startet MindSift auch offline mit dem zuletzt geladenen Stand. Gedanken erfassen sowie Aufgaben und Listeneinträge abhaken geht weiter; ein Hinweis unten zeigt, wie viele Änderungen warten, und sie werden automatisch übertragen, sobald die Verbindung zurück ist.
|
||||
</li>
|
||||
<li>
|
||||
<strong className="text-main">Passwort vergessen:</strong> Hat die Administration den Mailversand eingerichtet, steht im Anmeldefenster „Passwort vergessen?“. Du bekommst einen Link, der eine Stunde gilt; mit dem neuen Passwort bist du auf allen Geräten abgemeldet.
|
||||
</li>
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { enqueue, flushOutbox, getOutbox, isCacheablePath } from '../offline';
|
||||
|
||||
const res = (status: number) => ({ status, ok: status >= 200 && status < 300 }) as Response;
|
||||
|
||||
describe('outbox', () => {
|
||||
beforeEach(() => localStorage.clear());
|
||||
|
||||
it('keeps only the last state of repeated toggles', () => {
|
||||
enqueue('PATCH', '/api/v1/thoughts/t1', { isCompleted: true });
|
||||
enqueue('POST', '/api/v1/inbox', { content: 'Milch' });
|
||||
enqueue('PATCH', '/api/v1/thoughts/t1', { isCompleted: false });
|
||||
const box = getOutbox();
|
||||
expect(box).toHaveLength(2);
|
||||
expect(box[0].body).toEqual({ isCompleted: false });
|
||||
});
|
||||
|
||||
it('takes over thoughts captured offline by older versions', () => {
|
||||
localStorage.setItem(
|
||||
'mindsift_offline_capture_queue',
|
||||
JSON.stringify([{ id: 'th_offline_1', content: 'Alt', priority: 'normal', source: 'manual', createdAt: '2026-10-29T08:00:00Z' }])
|
||||
);
|
||||
const box = getOutbox();
|
||||
expect(box).toHaveLength(1);
|
||||
expect(box[0].path).toBe('/api/v1/inbox');
|
||||
expect(localStorage.getItem('mindsift_offline_capture_queue')).toBeNull();
|
||||
});
|
||||
|
||||
it('sends in order, drops rejected entries and stops while offline', async () => {
|
||||
enqueue('POST', '/api/v1/inbox', { content: 'a' });
|
||||
enqueue('PATCH', '/api/v1/lists/l/items/i', { isCompleted: true });
|
||||
enqueue('POST', '/api/v1/inbox', { content: 'c' });
|
||||
const answers = [res(201), res(404)];
|
||||
const synced = await flushOutbox(async () => {
|
||||
const next = answers.shift();
|
||||
if (!next) throw new TypeError('offline');
|
||||
return next;
|
||||
});
|
||||
expect(synced).toBe(1);
|
||||
expect(getOutbox().map((e) => e.body.content)).toEqual(['c']);
|
||||
});
|
||||
|
||||
it('does not cache streams, note sync or downloads', () => {
|
||||
expect(isCacheablePath('/api/v1/thoughts?status=all')).toBe(true);
|
||||
expect(isCacheablePath('/api/v1/events')).toBe(false);
|
||||
expect(isCacheablePath('/api/v1/docs/d1/changes?since=3')).toBe(false);
|
||||
expect(isCacheablePath('/api/v1/export/markdown')).toBe(false);
|
||||
});
|
||||
});
|
||||
+85
-68
@@ -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';
|
||||
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
// 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;
|
||||
}
|
||||
Reference in new issue
Block a user