diff --git a/Doku/architecture.md b/Doku/architecture.md
index 070f2bf..e73f14d 100644
--- a/Doku/architecture.md
+++ b/Doku/architecture.md
@@ -173,7 +173,7 @@ Die Tests laufen beim Image-Build (Frontend: `npm run test:run`, Backend: `go te
- **Neue Version:** Jeder Build legt `version.json` (Version und Build-Kennung) neben `index.html`, nie gecacht. Die App vergleicht sie mit ihrer eigenen Kennung beim Zurückkehren in Tab oder App, alle 15 Minuten und nach einem Neustart des Servers (Echtzeit-Verbindung wieder da) und zeigt dann „Neue Version verfügbar“ mit „Jetzt aktualisieren“ ([lib/useUpdateCheck.ts](../frontend/src/lib/useUpdateCheck.ts)).
- **Zustand pro Tab:** Der offene Bereich (Triage, Workspace, Aufgaben …), die offene Notiz und die gewählte Liste stehen in `sessionStorage` ([lib/viewSession.ts](../frontend/src/lib/viewSession.ts)) und bleiben beim Neuladen erhalten.
- **Hinweis:** Ein Banner über der unteren Navigation zeigt „Offline“ und die Zahl wartender Änderungen bzw. „Synchronisiere…“.
-- **Verbindungsstatus im Kopf** ([lib/useServerOnline.ts](../frontend/src/lib/useServerOnline.ts)): Maßgeblich ist die app-weite Echtzeit-Verbindung (WebSocket/SSE), nicht `navigator.onLine`; so fällt auch ein ausgefallener Server auf. Offline gilt erst nach 4 s ohne Verbindung (Start, kurze Reconnects), ohne Gerätenetz sofort. Desktop: „● offline“ unter dem Namen, nur ohne Verbindung; Handy: Punkt am Profilbild, grün oder rot.
+- **Verbindungsstatus im Kopf** ([lib/useConnectionState.ts](../frontend/src/lib/useConnectionState.ts)): Maßgeblich ist die app-weite Echtzeit-Verbindung (WebSocket/SSE), nicht `navigator.onLine`; so fällt auch ein ausgefallener Server auf. Fehlt sie länger als 4 s (Start, kurze Reconnects überbrückt), fragt die App `/api/v1/health` (danach alle 15 s): Antwortet der Server, ist die Verbindung **eingeschränkt** (z. B. Proxy, der SSE puffert; Notizen laufen dann über Long Polling, andere Änderungen erst beim Zurückkehren in den Tab oder Neuladen), sonst **offline**. Ohne Gerätenetz sofort offline. Anzeige als Punkt grün/gelb/rot: Desktop mit Text unter dem Namen, Handy am Profilbild.
## App-Icons, PWA & Social-Preview (Open Graph)
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 0e0d469..9fffed8 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -60,7 +60,7 @@ import { SharedListsView } from './features/lists/SharedListsView';
import { SetupAndAuthView } from './features/auth/SetupAndAuthView';
import { OAuthConsentView } from './features/auth/OAuthConsentView';
import { OfflineBanner } from './components/OfflineBanner';
-import { useServerOnline } from './lib/useServerOnline';
+import { useConnectionState } from './lib/useConnectionState';
import { SharedImportToast } from './components/SharedImportToast';
import { outboxCount, clearOfflineData } from './lib/offline';
import { DemoCountdown } from './components/DemoCountdown';
@@ -83,6 +83,17 @@ import { APP_VERSION } from './version';
import { readViewSession, writeViewSession } from './lib/viewSession';
import { setVoicePreference } from './lib/voice';
+// Connection status at the profile (useConnectionState); full class names for Tailwind
+const CONNECTION_STATUS = {
+ online: { label: 'online', dot: 'bg-status-ok', title: 'Online – Änderungen kommen sofort an' },
+ limited: {
+ label: 'eingeschränkt',
+ dot: 'bg-status-warn',
+ title: 'Eingeschränkt – der Server ist erreichbar, aber keine Live-Verbindung (z. B. Firmen-Proxy); Notizen gleichen sich langsamer ab, andere Änderungen erscheinen beim Zurückkehren in den Tab oder nach dem Neuladen',
+ },
+ offline: { label: 'offline', dot: 'bg-status-err', title: 'Offline – keine Verbindung zum Server; Änderungen werden später synchronisiert' },
+} as const;
+
const APP_TABS = ['triage', 'tasks', 'workspace', 'lists', 'groups', 'user_settings', 'admin_settings', 'help'] as const;
type ActiveTab = (typeof APP_TABS)[number];
@@ -192,7 +203,7 @@ export const App: React.FC = () => {
// Profile Dropdown State
const [showProfileMenu, setShowProfileMenu] = useState(false);
- const serverOnline = useServerOnline();
+ const connection = useConnectionState();
const [showAbout, setShowAbout] = useState(false);
const [showTriageTrash, setShowTriageTrash] = useState(false);
const { update: availableUpdate, dismiss: dismissUpdate } = useUpdateCheck();
@@ -986,13 +997,11 @@ export const App: React.FC = () => {
>
- {/* Connection status like in a chat app: green online, red without server */}
+ {/* Connection status like in a chat app: green online, yellow limited, red without server */}
@@ -1064,16 +1073,14 @@ export const App: React.FC = () => {
{user.displayName}
- {!serverOnline && (
-
-
- offline
-
- )}
+
+
+ {CONNECTION_STATUS[connection].label}
+
diff --git a/frontend/src/lib/__tests__/useConnectionState.test.tsx b/frontend/src/lib/__tests__/useConnectionState.test.tsx
new file mode 100644
index 0000000..346f1bf
--- /dev/null
+++ b/frontend/src/lib/__tests__/useConnectionState.test.tsx
@@ -0,0 +1,108 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { act, renderHook } from '@testing-library/react';
+
+let statusListener: ((connected: boolean) => void) | null = null;
+let currentConnected = false;
+vi.mock('../api', () => ({
+ onRealtimeStatus: (cb: (connected: boolean) => void) => {
+ statusListener = cb;
+ cb(currentConnected);
+ return () => (statusListener = null);
+ },
+}));
+
+import { useConnectionState, SERVER_OFFLINE_GRACE_MS, SERVER_PROBE_INTERVAL_MS } from '../useConnectionState';
+
+function setDeviceOnline(value: boolean) {
+ Object.defineProperty(navigator, 'onLine', { value, configurable: true });
+}
+
+let serverAnswers = false;
+const fetchMock = vi.fn(async () => {
+ if (!serverAnswers) throw new TypeError('Failed to fetch');
+ return { ok: true } as Response;
+});
+
+// Runs the timers and lets the health request settle
+async function wait(ms: number) {
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(ms);
+ });
+}
+
+describe('useConnectionState', () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ vi.stubGlobal('fetch', fetchMock);
+ fetchMock.mockClear();
+ currentConnected = false;
+ serverAnswers = false;
+ setDeviceOnline(true);
+ });
+ afterEach(() => {
+ vi.useRealTimers();
+ vi.unstubAllGlobals();
+ });
+
+ it('stays online during the grace period at app start', async () => {
+ const { result } = renderHook(() => useConnectionState());
+ expect(result.current).toBe('online');
+ act(() => statusListener?.(true));
+ await wait(SERVER_OFFLINE_GRACE_MS + 100);
+ expect(result.current).toBe('online');
+ expect(fetchMock).not.toHaveBeenCalled();
+ });
+
+ it('turns offline once the server stays unreachable and back online on reconnect', async () => {
+ currentConnected = true;
+ const { result } = renderHook(() => useConnectionState());
+ act(() => statusListener?.(false));
+ expect(result.current).toBe('online');
+ await wait(SERVER_OFFLINE_GRACE_MS + 100);
+ expect(result.current).toBe('offline');
+ act(() => statusListener?.(true));
+ expect(result.current).toBe('online');
+ });
+
+ it('is limited when the server answers but no realtime connection gets through', async () => {
+ serverAnswers = true;
+ const { result } = renderHook(() => useConnectionState());
+ await wait(SERVER_OFFLINE_GRACE_MS + 100);
+ expect(result.current).toBe('limited');
+ expect(fetchMock).toHaveBeenCalledWith('/api/v1/health', expect.anything());
+
+ // Asked again while the connection is missing
+ serverAnswers = false;
+ await wait(SERVER_PROBE_INTERVAL_MS + 100);
+ expect(result.current).toBe('offline');
+ serverAnswers = true;
+ await wait(SERVER_PROBE_INTERVAL_MS + 100);
+ expect(result.current).toBe('limited');
+
+ act(() => statusListener?.(true));
+ expect(result.current).toBe('online');
+ const calls = fetchMock.mock.calls.length;
+ await wait(SERVER_PROBE_INTERVAL_MS * 2);
+ expect(fetchMock.mock.calls.length).toBe(calls);
+ });
+
+ it('repeated failed reconnects do not postpone the check', async () => {
+ currentConnected = true;
+ const { result } = renderHook(() => useConnectionState());
+ for (let i = 0; i < 4; i++) {
+ act(() => statusListener?.(false));
+ await wait(SERVER_OFFLINE_GRACE_MS / 2);
+ }
+ expect(result.current).toBe('offline');
+ });
+
+ it('is offline right away without device network', () => {
+ currentConnected = true;
+ const { result } = renderHook(() => useConnectionState());
+ act(() => {
+ setDeviceOnline(false);
+ statusListener?.(false);
+ });
+ expect(result.current).toBe('offline');
+ });
+});
diff --git a/frontend/src/lib/__tests__/useServerOnline.test.tsx b/frontend/src/lib/__tests__/useServerOnline.test.tsx
deleted file mode 100644
index 87764f8..0000000
--- a/frontend/src/lib/__tests__/useServerOnline.test.tsx
+++ /dev/null
@@ -1,66 +0,0 @@
-import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
-import { act, renderHook } from '@testing-library/react';
-
-let statusListener: ((connected: boolean) => void) | null = null;
-let currentConnected = false;
-vi.mock('../api', () => ({
- onRealtimeStatus: (cb: (connected: boolean) => void) => {
- statusListener = cb;
- cb(currentConnected);
- return () => (statusListener = null);
- },
-}));
-
-import { useServerOnline, SERVER_OFFLINE_GRACE_MS } from '../useServerOnline';
-
-function setDeviceOnline(value: boolean) {
- Object.defineProperty(navigator, 'onLine', { value, configurable: true });
-}
-
-describe('useServerOnline', () => {
- beforeEach(() => {
- vi.useFakeTimers();
- currentConnected = false;
- setDeviceOnline(true);
- });
- afterEach(() => vi.useRealTimers());
-
- it('stays online during the grace period at app start', () => {
- const { result } = renderHook(() => useServerOnline());
- expect(result.current).toBe(true);
- act(() => statusListener?.(true));
- act(() => vi.advanceTimersByTime(SERVER_OFFLINE_GRACE_MS + 100));
- expect(result.current).toBe(true);
- });
-
- it('turns offline once the server stays unreachable and back online on reconnect', () => {
- currentConnected = true;
- const { result } = renderHook(() => useServerOnline());
- act(() => statusListener?.(false));
- expect(result.current).toBe(true);
- act(() => vi.advanceTimersByTime(SERVER_OFFLINE_GRACE_MS + 100));
- expect(result.current).toBe(false);
- act(() => statusListener?.(true));
- expect(result.current).toBe(true);
- });
-
- it('repeated failed reconnects do not postpone the offline state', () => {
- currentConnected = true;
- const { result } = renderHook(() => useServerOnline());
- for (let i = 0; i < 4; i++) {
- act(() => statusListener?.(false));
- act(() => vi.advanceTimersByTime(SERVER_OFFLINE_GRACE_MS / 2));
- }
- expect(result.current).toBe(false);
- });
-
- it('is offline right away without device network', () => {
- currentConnected = true;
- const { result } = renderHook(() => useServerOnline());
- act(() => {
- setDeviceOnline(false);
- statusListener?.(false);
- });
- expect(result.current).toBe(false);
- });
-});
diff --git a/frontend/src/lib/useConnectionState.ts b/frontend/src/lib/useConnectionState.ts
new file mode 100644
index 0000000..94268ef
--- /dev/null
+++ b/frontend/src/lib/useConnectionState.ts
@@ -0,0 +1,72 @@
+import { useEffect, useState } from 'react';
+import { onRealtimeStatus } from './api';
+
+// Grace period before a missing realtime connection counts as offline (app start, short reconnects)
+export const SERVER_OFFLINE_GRACE_MS = 4000;
+// How often the server is asked while the realtime connection is missing
+export const SERVER_PROBE_INTERVAL_MS = 15000;
+
+// online: realtime connection (WebSocket/SSE) is up; limited: the server answers plain requests, but no
+// realtime connection gets through (e.g. a proxy that buffers event streams; notes fall back to long
+// polling); offline: no server at all
+export type ConnectionState = 'online' | 'limited' | 'offline';
+
+async function serverReachable(): Promise {
+ try {
+ const res = await fetch('/api/v1/health', { cache: 'no-store' });
+ return res.ok;
+ } catch {
+ return false;
+ }
+}
+
+// Whether the server is reachable, judged by the app-wide realtime connection. Unlike navigator.onLine it
+// also notices a server that is down while the device still has network. Without device network it is
+// offline right away; otherwise, once the connection stayed away for a moment, a request to the server
+// decides between limited and offline (repeated while the connection is missing).
+export function useConnectionState(): ConnectionState {
+ const [state, setState] = useState('online');
+
+ useEffect(() => {
+ let connected = false;
+ let disposed = false;
+ let timer: number | undefined;
+ const stopProbing = () => {
+ window.clearTimeout(timer);
+ timer = undefined;
+ };
+ const probe = async () => {
+ const reachable = await serverReachable();
+ if (disposed || connected) return;
+ setState(reachable && navigator.onLine !== false ? 'limited' : 'offline');
+ timer = window.setTimeout(probe, SERVER_PROBE_INTERVAL_MS);
+ };
+ // Reconnect attempts report "not connected" every few seconds; they must not restart the grace period
+ const update = () => {
+ if (connected) {
+ stopProbing();
+ setState('online');
+ } else if (navigator.onLine === false) {
+ stopProbing();
+ setState('offline');
+ } else if (timer === undefined) {
+ timer = window.setTimeout(probe, SERVER_OFFLINE_GRACE_MS);
+ }
+ };
+ const unsubscribe = onRealtimeStatus((isConnected) => {
+ connected = isConnected;
+ update();
+ });
+ window.addEventListener('online', update);
+ window.addEventListener('offline', update);
+ return () => {
+ disposed = true;
+ unsubscribe();
+ stopProbing();
+ window.removeEventListener('online', update);
+ window.removeEventListener('offline', update);
+ };
+ }, []);
+
+ return state;
+}
diff --git a/frontend/src/lib/useServerOnline.ts b/frontend/src/lib/useServerOnline.ts
deleted file mode 100644
index 16d710b..0000000
--- a/frontend/src/lib/useServerOnline.ts
+++ /dev/null
@@ -1,45 +0,0 @@
-import { useEffect, useState } from 'react';
-import { onRealtimeStatus } from './api';
-
-// Grace period before a missing realtime connection counts as offline (app start, short reconnects)
-export const SERVER_OFFLINE_GRACE_MS = 4000;
-
-// Whether the server is reachable, judged by the app-wide realtime connection (WebSocket/SSE). Unlike
-// navigator.onLine it also notices a server that is down while the device still has network. Without
-// device network it is offline right away; otherwise only after the connection stayed away for a moment.
-export function useServerOnline(): boolean {
- const [online, setOnline] = useState(true);
-
- useEffect(() => {
- let connected = false;
- let timer: number | undefined;
- // Reconnect attempts report "not connected" every few seconds; they must not restart the grace period
- const update = () => {
- if (connected) {
- window.clearTimeout(timer);
- timer = undefined;
- setOnline(true);
- } else if (navigator.onLine === false) {
- window.clearTimeout(timer);
- timer = undefined;
- setOnline(false);
- } else if (timer === undefined) {
- timer = window.setTimeout(() => setOnline(false), SERVER_OFFLINE_GRACE_MS);
- }
- };
- const unsubscribe = onRealtimeStatus((isConnected) => {
- connected = isConnected;
- update();
- });
- window.addEventListener('online', update);
- window.addEventListener('offline', update);
- return () => {
- unsubscribe();
- window.clearTimeout(timer);
- window.removeEventListener('online', update);
- window.removeEventListener('offline', update);
- };
- }, []);
-
- return online;
-}