feat(ui): show a limited connection instead of offline behind proxies

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 <noreply@anthropic.com>
This commit is contained in:
sandrodachandClaude Opus 5.5 committed 2026-11-02 11:21:14 +01:00
1 parent 7fcd5a258c
commit 282a9af557
6 files changed
+205 -129

No files matched your search

+24 -17
View File
@@ -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 = () => {
>
<span className="relative">
<UserAvatar user={user} size="md" className="shadow-sm" />
{/* 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 */}
<span
role="status"
aria-label={serverOnline ? 'Online' : 'Offline – keine Verbindung zum Server'}
className={`absolute bottom-0 right-0 w-2.5 h-2.5 rounded-full ring-2 ring-canvas ${
serverOnline ? 'bg-status-ok' : 'bg-status-err'
}`}
aria-label={CONNECTION_STATUS[connection].title}
className={`absolute bottom-0 right-0 w-2.5 h-2.5 rounded-full ring-2 ring-canvas ${CONNECTION_STATUS[connection].dot}`}
/>
</span>
</button>
@@ -1064,16 +1073,14 @@ export const App: React.FC = () => {
<span className="max-w-[130px] truncate hidden sm:inline text-main font-medium">
{user.displayName}
</span>
{!serverOnline && (
<span
role="status"
title="Keine Verbindung zum Server – Änderungen werden später synchronisiert"
className="hidden sm:inline-flex items-center gap-1 text-[10px] font-normal text-muted"
>
<span className="w-1.5 h-1.5 rounded-full bg-status-err" />
offline
</span>
)}
<span
role="status"
title={CONNECTION_STATUS[connection].title}
className="hidden sm:inline-flex items-center gap-1 text-[10px] font-normal text-muted"
>
<span className={`w-1.5 h-1.5 rounded-full ${CONNECTION_STATUS[connection].dot}`} />
{CONNECTION_STATUS[connection].label}
</span>
</span>
<ChevronDown className="w-3.5 h-3.5 text-muted group-hover:text-main transition-colors" />
</button>
@@ -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');
});
});
@@ -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);
});
});
+72
View File
@@ -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<boolean> {
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<ConnectionState>('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;
}
-45
View File
@@ -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;
}