diff --git a/Doku/architecture.md b/Doku/architecture.md index fe4099a..b949ec6 100644 --- a/Doku/architecture.md +++ b/Doku/architecture.md @@ -102,6 +102,13 @@ Die angezeigte Version (Kopf der App) setzt der Build-Parameter `APP_VERSION`: D Die Tests laufen beim Image-Build (Frontend: `npm run test:run`, Backend: `go test -v ./...`); ein Fehler bricht den Build ab. Frontend-Tests liegen unter `frontend/src/**/__tests__/` (Vitest + jsdom + Testing Library, Setup in `frontend/src/test/setup.ts`). +## Offline (PWA) + +- **Service Worker** ([public/sw.js](../frontend/public/sw.js)): Seitenaufrufe Network-First mit der zuletzt geladenen `index.html` als Rückfall, Bundles unter `/assets/` (Hash im Namen) aus dem Cache, Icons und Manifest Stale-While-Revalidate. API-Anfragen fasst er nie an. Alte Caches verschwinden beim Aktivieren einer neuen `VERSION`, Bundles sind auf 80 Einträge begrenzt. +- **Lese-Cache** ([lib/offline.ts](../frontend/src/lib/offline.ts)): Jede erfolgreiche GET-Antwort landet pro Benutzer in IndexedDB (`mindsift-offline`). Ohne Verbindung liefert `request()` die letzte bekannte Antwort; so startet die App offline angemeldet und zeigt Gedanken, Aufgaben und Listen. Ausgenommen: Streams, Long-Polling, Notiz-Sync (eigener Speicher, [modules/collaboration.md](modules/collaboration.md)), Export, Suche. Abmelden leert Cache und Warteschlange. +- **Warteschlange (Outbox):** Offline erfasste Gedanken (`POST /inbox`) und abgehakte Aufgaben bzw. Listeneinträge (`PATCH`) werden in `localStorage` gesammelt, wiederholtes Umschalten zählt nur mit dem Endstand. Gesendet wird in Reihenfolge beim `online`-Ereignis, beim Start und alle 30 s, solange etwas wartet; vom Server abgelehnte Einträge (4xx) entfallen. Andere Änderungen brauchen weiterhin eine Verbindung. +- **Hinweis:** Ein Banner über der unteren Navigation zeigt „Offline“ und die Zahl wartender Änderungen bzw. „Synchronisiere…“. + ## App-Icons, PWA & Social-Preview (Open Graph) MindSift verwendet plattformübergreifend ein einheitliches App-Icon-Design, das exakt dem Icon im Kopfbereich der mobilen App entspricht: diff --git a/README.md b/README.md index 231efc1..bd1696a 100644 --- a/README.md +++ b/README.md @@ -92,7 +92,7 @@ Technik dahinter: [Doku/modules/collaboration.md](Doku/modules/collaboration.md) * Geführte Ersteinrichtung, Rollen *Admin* und *Mitglied*, Einladungscodes, optional offene Registrierung. * Gruppen mit Gruppen-Admins für Freigaben. * Admin-Bereich: Benutzer, Einladungen, Instanzname, SMTP, Freigabe-Modus. -* Vier Themes (Dark, Light, High-Contrast, Soft), installierbar als PWA. +* Vier Themes (Dark, Light, High-Contrast, Soft), installierbar als PWA. Die App startet auch ohne Netz mit dem zuletzt geladenen Stand; offline erfasste Gedanken und abgehakte Aufgaben oder Listeneinträge werden nachgereicht, sobald die Verbindung wieder da ist. ### 🔌 Integrationen diff --git a/frontend/public/sw.js b/frontend/public/sw.js index 86a830a..1328369 100644 --- a/frontend/public/sw.js +++ b/frontend/public/sw.js @@ -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)); + } }); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index f30c9e1..bd08381 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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 = () => { + {/* Main Container */}
diff --git a/frontend/src/components/OfflineBanner.tsx b/frontend/src/components/OfflineBanner.tsx new file mode 100644 index 0000000..174041f --- /dev/null +++ b/frontend/src/components/OfflineBanner.tsx @@ -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 ( +
+ {online ? ( + + ) : ( + + )} + + {online + ? `Synchronisiere – ${waiting}…` + : pending > 0 + ? `Offline · ${waiting} auf Verbindung` + : 'Offline – Änderungen werden bei Verbindung synchronisiert'} + +
+ ); +}; diff --git a/frontend/src/features/help/HelpView.tsx b/frontend/src/features/help/HelpView.tsx index 99c105a..9c0df9a 100644 --- a/frontend/src/features/help/HelpView.tsx +++ b/frontend/src/features/help/HelpView.tsx @@ -632,6 +632,9 @@ export const HelpView: React.FC = ({ onBack }) => {
  • Eigenes Theme: Vorlage herunterladen oder einfügen, Farben und Rundungen anpassen, als .css-Datei hochladen oder einfügen und „Speichern & aktivieren“. Das Theme greift sofort und steht dir auf allen Geräten zur Verfügung.
  • +
  • + Ohne Internet: 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. +
  • Passwort vergessen: 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.
  • diff --git a/frontend/src/lib/__tests__/offline.test.ts b/frontend/src/lib/__tests__/offline.test.ts new file mode 100644 index 0000000..e62f459 --- /dev/null +++ b/frontend/src/lib/__tests__/offline.test.ts @@ -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); + }); +}); diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 629df98..45bae9c 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -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(val: T[] | null | undefined): T[] { async function request(path: string, options: RequestInit = {}): Promise { 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(userKey, path); + if (cached !== undefined) return cached; + } const headers: Record = { 'Content-Type': 'application/json', 'Cache-Control': 'no-cache', @@ -101,6 +121,13 @@ async function request(path: string, options: RequestInit = {}): Promise { 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(userKey, path); + if (cached !== undefined) return cached; + } + throw new OfflineError(); } finally { window.clearTimeout(timeoutId); } @@ -127,7 +154,20 @@ async function request(path: string, options: RequestInit = {}): Promise { 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 }): Promise { + 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 { + const body = { + content, + priority, + source, + dueDate, + target: options?.target || 'inbox', + targetListId: options?.targetListId || '', + taskListId: options?.taskListId || '', + }; try { - const thought = await request('/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('/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 { - const queue = getOfflineQueue(); - if (queue.length === 0) return 0; - - let synced = 0; - const remaining: Thought[] = []; - - for (const item of queue) { - try { - await request('/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 { - return request(`/api/v1/thoughts/${thoughtId}`, { - method: 'PATCH', - body: JSON.stringify(patch), - }); + try { + return await request(`/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 { @@ -853,10 +873,16 @@ export const api = { }, async toggleListItem(listId: string, itemId: string, isCompleted?: boolean): Promise { - return request(`/api/v1/lists/${listId}/items/${itemId}`, { - method: 'PATCH', - body: JSON.stringify({ isCompleted }), - }); + try { + return await request(`/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'; diff --git a/frontend/src/lib/offline.ts b/frontend/src/lib/offline.ts new file mode 100644 index 0000000..4ad442d --- /dev/null +++ b/frontend/src/lib/offline.ts @@ -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 | null = null; + +function openDB(): Promise { + 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(mode: IDBTransactionMode, fn: (s: IDBObjectStore) => IDBRequest | void): Promise { + 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(userKey: string, path: string): Promise { + return withStore('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 { + 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; + 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): 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 | 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): Promise { + 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; +}