From 282a9af5579ad3ddf54a9df5b73478c78e5d3ee3 Mon Sep 17 00:00:00 2001 From: Sandro Date: Mon, 2 Nov 2026 11:21:14 +0100 Subject: [PATCH] feat(ui): show a limited connection instead of offline behind proxies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When no realtime connection gets through (proxy buffering SSE, notes on long polling) but the server answers, the profile shows a yellow "eingeschränkt" instead of "offline". A full connection now shows a green "online" on the desktop too. Co-Authored-By: Claude Opus 5.5 --- Doku/architecture.md | 2 +- frontend/src/App.tsx | 41 ++++--- .../lib/__tests__/useConnectionState.test.tsx | 108 ++++++++++++++++++ .../lib/__tests__/useServerOnline.test.tsx | 66 ----------- frontend/src/lib/useConnectionState.ts | 72 ++++++++++++ frontend/src/lib/useServerOnline.ts | 45 -------- 6 files changed, 205 insertions(+), 129 deletions(-) create mode 100644 frontend/src/lib/__tests__/useConnectionState.test.tsx delete mode 100644 frontend/src/lib/__tests__/useServerOnline.test.tsx create mode 100644 frontend/src/lib/useConnectionState.ts delete mode 100644 frontend/src/lib/useServerOnline.ts 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; -}