feat(ui): connection status in the header; README screenshots as WebP
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m38s
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m38s
- Desktop shows "offline" with a red dot under the user name when the server is unreachable; phones show a green/red dot on the avatar. Based on the app-wide realtime connection, so a server outage is noticed even while the device has network (4 s grace period). - Screenshots converted to WebP (1280 px desktop, 480 px phone, q90), e2e/screenshots.sh converts with cwebp; new journal picture with round corners. Rules in AGENTS.md. - Release v0.26.0. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
c1adb10b5e
commit
fdd6e38065
34 files changed
+186
-21
No files matched your search
+27
-3
@@ -56,6 +56,7 @@ import { WorkspaceView } from './features/workspace/WorkspaceView';
|
||||
import { SharedListsView } from './features/lists/SharedListsView';
|
||||
import { SetupAndAuthView } from './features/auth/SetupAndAuthView';
|
||||
import { OfflineBanner } from './components/OfflineBanner';
|
||||
import { useServerOnline } from './lib/useServerOnline';
|
||||
import { SharedImportToast } from './components/SharedImportToast';
|
||||
import { outboxCount, clearOfflineData } from './lib/offline';
|
||||
import { DemoCountdown } from './components/DemoCountdown';
|
||||
@@ -179,6 +180,7 @@ export const App: React.FC = () => {
|
||||
|
||||
// Profile Dropdown State
|
||||
const [showProfileMenu, setShowProfileMenu] = useState(false);
|
||||
const serverOnline = useServerOnline();
|
||||
const [showAbout, setShowAbout] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
@@ -947,7 +949,17 @@ export const App: React.FC = () => {
|
||||
}`}
|
||||
title="Profil & Einstellungen"
|
||||
>
|
||||
<UserAvatar user={user} size="md" className="shadow-sm" />
|
||||
<span className="relative">
|
||||
<UserAvatar user={user} size="md" className="shadow-sm" />
|
||||
{/* Connection status like in a chat app: green online, 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'
|
||||
}`}
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1013,8 +1025,20 @@ export const App: React.FC = () => {
|
||||
title="Benutzermenü öffnen"
|
||||
>
|
||||
<UserAvatar user={user} size="sm" />
|
||||
<span className="max-w-[130px] truncate hidden sm:inline text-main font-medium">
|
||||
{user.displayName}
|
||||
<span className="flex flex-col items-start leading-tight">
|
||||
<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>
|
||||
<ChevronDown className="w-3.5 h-3.5 text-muted group-hover:text-main transition-colors" />
|
||||
</button>
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
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;
|
||||
}
|
||||
Reference in new issue
Block a user