feat(ui): connection status in the header; README screenshots as WebP
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:
sandrodachandClaude Opus 5.5 committed 2026-10-31 19:17:51 +01:00
1 parent c1adb10b5e
commit fdd6e38065
34 files changed
+186 -21

No files matched your search

+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "mindsift-frontend",
"version": "0.25.1",
"version": "0.26.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "mindsift-frontend",
"version": "0.25.1",
"version": "0.26.0",
"license": "Unlicense",
"dependencies": {
"@fontsource-variable/inter": "5.3.0",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "mindsift-frontend",
"private": true,
"version": "0.25.1",
"version": "0.26.0",
"license": "Unlicense",
"type": "module",
"scripts": {
+27 -3
View File
@@ -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);
});
});
+45
View File
@@ -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;
}