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;
+}