From e7d5db3e24488f58075566e0a2a6eb134837797d Mon Sep 17 00:00:00 2001 From: Sandro Date: Mon, 2 Nov 2026 11:17:50 +0100 Subject: [PATCH 1/6] fix(auth): tab from the user name straight into the password field "Passwort vergessen?" came first in the markup; it now follows the field and keeps its place next to the label. Co-Authored-By: Claude Opus 5.5 --- .../src/features/auth/SetupAndAuthView.tsx | 38 +++++++++---------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/frontend/src/features/auth/SetupAndAuthView.tsx b/frontend/src/features/auth/SetupAndAuthView.tsx index ab43865..f79ca8e 100644 --- a/frontend/src/features/auth/SetupAndAuthView.tsx +++ b/frontend/src/features/auth/SetupAndAuthView.tsx @@ -245,7 +245,7 @@ export const SetupAndAuthView: React.FC = ({ // ============================================================================ if (authStatus.needsSetup) { return ( -
+
{/* Top Brand Bar */}
@@ -472,7 +472,7 @@ export const SetupAndAuthView: React.FC = ({ // STANDARD LOGIN / INVITE CODE / REGISTRATION VIEW // ============================================================================ return ( -
+
{/* Top Header */}
@@ -706,23 +706,8 @@ export const SetupAndAuthView: React.FC = ({ />
-
-
- - {authStatus.passwordReset && ( - - )} -
+
+ = ({ required className="w-full h-8 px-2.5 input-field" /> + {/* After the field in the markup, so Tab goes from the name straight to the password; + shown next to the label */} + {authStatus.passwordReset && ( + + )}
From 7fcd5a258cf1a76f9134e3b3ca3328220a7eb99d Mon Sep 17 00:00:00 2001 From: Sandro Date: Mon, 2 Nov 2026 11:17:50 +0100 Subject: [PATCH 2/6] fix(ui): size the page to the visible screen on phones min-h-screen is 100vh, which mobile browsers measure without the address bar, so short pages could be scrolled into empty space. 100dvh follows the visible height. Co-Authored-By: Claude Opus 5.5 --- frontend/src/App.tsx | 4 ++-- frontend/src/features/auth/OAuthConsentView.tsx | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index b06a49b..0e0d469 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -846,7 +846,7 @@ export const App: React.FC = () => { if (bootLoading) { return ( -
+
Verbinde mit MindSift…
); @@ -893,7 +893,7 @@ export const App: React.FC = () => { return (
{/* Top Header Bar: Floating on mobile, solid panel on desktop */} diff --git a/frontend/src/features/auth/OAuthConsentView.tsx b/frontend/src/features/auth/OAuthConsentView.tsx index e14481a..8d89a24 100644 --- a/frontend/src/features/auth/OAuthConsentView.tsx +++ b/frontend/src/features/auth/OAuthConsentView.tsx @@ -56,7 +56,7 @@ export const OAuthConsentView: React.FC = ({ user, instan const groups = SCOPE_GROUPS.filter(([group]) => SCOPES.some((sc) => sc.group === group && offered.includes(sc.id))); return ( -
+
From 282a9af5579ad3ddf54a9df5b73478c78e5d3ee3 Mon Sep 17 00:00:00 2001 From: Sandro Date: Mon, 2 Nov 2026 11:21:14 +0100 Subject: [PATCH 3/6] 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; -} From 08d35a4a816002bfe8ae8e434fd67310ed762a0d Mon Sep 17 00:00:00 2001 From: Sandro Date: Mon, 2 Nov 2026 11:30:08 +0100 Subject: [PATCH 4/6] test(e2e): pick the lock notice among the status elements The connection status at the profile is always shown now and also has role=status. Co-Authored-By: Claude Opus 5.5 --- e2e/lock.e2e.mjs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/e2e/lock.e2e.mjs b/e2e/lock.e2e.mjs index 22e0772..5b8d7be 100644 --- a/e2e/lock.e2e.mjs +++ b/e2e/lock.e2e.mjs @@ -99,7 +99,7 @@ const main = async () => { await B.page.getByText('Darunter', { exact: true }).first().click(); await B.page.keyboard.press('Home'); await B.page.keyboard.press('Backspace'); - const notice = await waitFor(async () => B.page.getByRole('status').innerText(), 3000); + const notice = await waitFor(async () => B.page.getByRole('status').filter({ hasText: /bearbeitet/ }).first().innerText(), 3000); check(!!notice && /bearbeitet diesen Punkt gerade/.test(notice), `Hinweis statt halber Aktion: ${notice}`); await sleep(1500); const afterMerge = (await serverBlocks(admin, doc.id)).map((b) => b.content).sort(); @@ -133,7 +133,7 @@ const main = async () => { check(!!conflictShown, 'Konfliktkopie wird markiert angezeigt'); // The user's cursor is still in the original bullet (it holds the lock), so the admin may not overwrite it await A.page.getByRole('button', { name: 'Übernehmen' }).first().click(); - const refused = await waitFor(async () => A.page.getByRole('status').innerText(), 3000); + const refused = await waitFor(async () => A.page.getByRole('status').filter({ hasText: /bearbeitet/ }).first().innerText(), 3000); check(!!refused && /bearbeitet/.test(refused), `Übernehmen in einen fremd gesperrten Punkt wird verweigert: ${refused}`); // The user holding the original can accept once the copy's author (here a separate API client that never // renews its lease) no longer holds the copy, i.e. after at most 20 s From c021946c7f2a353733e72366f7d1e2b048657eef Mon Sep 17 00:00:00 2001 From: Sandro Date: Mon, 2 Nov 2026 11:30:08 +0100 Subject: [PATCH 5/6] fix(sync): no conflicts with one's own text, one copy per conflict - Leaving the tab sent pending text with keepalive but kept it queued; typing on folded it with newer keystrokes under the old base version, which then conflicted with the user's own text. The flushed batch now becomes the batch in flight. - After a conflict, the focused field still showed the losing text and every further batch made another copy. Typing on goes into the one copy until the cursor leaves; on leaving, the field shows the block's current text, and entering a block bases edits on what it shows. Co-Authored-By: Claude Opus 5.5 --- .../src/features/workspace/OutlinerBlock.tsx | 10 ++- .../src/lib/docSync/__tests__/engine.test.ts | 52 ++++++++++++++ frontend/src/lib/docSync/engine.ts | 68 ++++++++++++++++--- 3 files changed, 118 insertions(+), 12 deletions(-) diff --git a/frontend/src/features/workspace/OutlinerBlock.tsx b/frontend/src/features/workspace/OutlinerBlock.tsx index 5c3158a..d013c27 100644 --- a/frontend/src/features/workspace/OutlinerBlock.tsx +++ b/frontend/src/features/workspace/OutlinerBlock.tsx @@ -270,11 +270,17 @@ export const OutlinerBlock: React.FC = ({ document.activeElement === textareaRef.current; const acceptAfterUndo = Date.now() < acceptRemoteUntilRef.current; - if ((!isActivelyEditing || acceptAfterUndo) && block.content !== localContentRef.current) { + // Also when the cursor leaves: after a conflict the field held the user's own text, which now stands + // in the conflict copy, while the block itself keeps the other version + if ( + (!isActivelyEditing || acceptAfterUndo) && + debounceTimerRef.current === null && + block.content !== localContentRef.current + ) { setLocalContent(block.content || ''); localContentRef.current = block.content || ''; } - }, [block.content]); + }, [block.content, isLocallyFocused]); // Handle window blur / focus to instantly flush debounced input and clear zombie focus useEffect(() => { diff --git a/frontend/src/lib/docSync/__tests__/engine.test.ts b/frontend/src/lib/docSync/__tests__/engine.test.ts index d69d83d..6b1bee6 100644 --- a/frontend/src/lib/docSync/__tests__/engine.test.ts +++ b/frontend/src/lib/docSync/__tests__/engine.test.ts @@ -358,6 +358,58 @@ describe('DocSyncEngine', () => { expect(contents(engine)).toEqual(['Schreibe']); }); + it('typing on after leaving the tab causes no conflict with oneself', async () => { + const server = new FakeServer(); + const engine = await startEngine(new FakeNet(server)); + engine.focusBlock('b1'); + edit(engine, (b) => [{ ...b[0], content: 'Start und' }]); + // Tab hidden (window switch, app switch on the phone): pending text goes out with keepalive + (engine as any).flushOnExit(); + await new Promise((r) => setTimeout(r, 0)); + expect(serverContents(server)).toEqual(['Start und']); + + // Back in the tab, typing on in the same block + edit(engine, (b) => [{ ...b[0], content: 'Start und weiter' }]); + await engine.syncNow(); + edit(engine, (b) => [{ ...b[0], content: 'Start und weiter geht es' }]); + await engine.syncNow(); + + expect(engine.getState().conflicts).toEqual([]); + expect(serverContents(server)).toEqual(['Start und weiter geht es']); + }); + + it('typing on in a block after a conflict updates the one conflict copy', async () => { + const server = new FakeServer(); + const a = await startEngine(new FakeNet(server)); + const b = await startEngine(new FakeNet(server)); + + a.focusBlock('b1'); + edit(b, (blocks) => [{ ...blocks[0], content: 'B hat geändert' }]); + await b.syncNow(); + await a.syncNow(); // the focused field keeps showing A's own text + + a.applyFlat([{ id: 'b1', type: 'bullet', content: 'Start + A', level: 0 }]); + await a.syncNow(); // conflict: A's text becomes a copy + // A keeps typing in the same field, which still shows A's text + a.applyFlat([{ ...flatOf(a)[0], content: 'Start + A tippt' }, flatOf(a)[1]]); + await a.syncNow(); + a.applyFlat([{ ...flatOf(a)[0], content: 'Start + A tippt weiter' }, flatOf(a)[1]]); + await a.syncNow(); + await b.syncNow(); + + expect(a.getState().conflicts).toHaveLength(1); + expect(serverContents(server)).toEqual(['B hat geändert', 'Start + A tippt weiter']); + expect(contents(b)).toEqual(['B hat geändert', 'Start + A tippt weiter']); + + // Once the cursor left, the field shows B's version again: editing it changes the block itself + a.focusBlock(null); + a.focusBlock('b1'); + edit(a, (blocks) => [{ ...blocks[0], content: 'B hat geändert, A ergänzt' }, blocks[1]]); + await a.syncNow(); + expect(a.getState().conflicts).toHaveLength(1); + expect(serverContents(server)).toEqual(['B hat geändert, A ergänzt', 'Start + A tippt weiter']); + }); + it('viewers cannot change anything', async () => { const server = new FakeServer(); const viewer = await startEngine(new FakeNet(server, 'viewer')); diff --git a/frontend/src/lib/docSync/engine.ts b/frontend/src/lib/docSync/engine.ts index c32ffbf..ae85552 100644 --- a/frontend/src/lib/docSync/engine.ts +++ b/frontend/src/lib/docSync/engine.ts @@ -140,6 +140,10 @@ export class DocSyncEngine { // Version of each block the editor last showed in agreement with the engine. The focused text field does // not take over remote changes while typing, so its edits are based on this version, not the newest one. private uiSeen = new Map(); + // Focused block -> its conflict copy: the field still shows the user's losing text, so typing on there + // goes into that copy instead of making a new copy per keystroke batch. Ends when the field shows the + // engine's text again or the cursor leaves the block. + private conflictRedirects = new Map(); private undoStack: UndoStep[] = []; private redoStack: UndoStep[] = []; @@ -267,15 +271,19 @@ export class DocSyncEngine { } for (const b of blocks) { const cur = this.local.blocks.get(b.id); - if (cur && !cur.deleted && cur.content === (b.content || '')) this.uiSeen.set(b.id, cur.version); + if (cur && !cur.deleted && cur.content === (b.content || '')) { + this.uiSeen.set(b.id, cur.version); + this.conflictRedirects.delete(b.id); + } } const ops = diffFlat(this.local, blocks, () => this.newOpId()); if (ops.length === 0) return; const before = this.local; const t = this.now(); for (const op of ops) { - if ((op.kind === 'insert' || op.kind === 'set_content') && op.blockId) this.activity.set(op.blockId, t); if (op.kind === 'set_content' && this.uiSeen.has(op.blockId!)) op.baseVersion = this.uiSeen.get(op.blockId!); + if (op.kind === 'set_content') this.redirectToConflictCopy(op, this.local); + if ((op.kind === 'insert' || op.kind === 'set_content') && op.blockId) this.activity.set(op.blockId, t); } this.enqueue(ops); this.record(before, ops); @@ -350,7 +358,12 @@ export class DocSyncEngine { const t = this.now(); const changed = blockId !== this.focused; if (this.focused) this.activity.set(this.focused, t); + if (changed && this.focused) this.conflictRedirects.delete(this.focused); this.focused = blockId; + // Entering a block, its field shows the engine's text (unfocused fields take it over), so edits from + // here on build on that version, not on one an earlier visit of the field saw + const entered = changed && blockId ? this.local.blocks.get(blockId) : undefined; + if (entered && !entered.deleted) this.uiSeen.set(entered.id, entered.version); if (blockId) this.activity.set(blockId, t); // Announce the lease right away: with push the next regular sync may be up to 30 s away if (changed && blockId && this.canEdit && this.collaborative()) { @@ -387,7 +400,7 @@ export class DocSyncEngine { ? this.status === 'error' ? this.pollDelay() : this.nextBackoff() - : this.syncAgain || this.pending.length > 0 + : this.syncAgain || this.pending.length > 0 || this.inflight ? 0 : this.pollDelay(); this.syncAgain = false; @@ -462,6 +475,19 @@ export class DocSyncEngine { return { ok: true }; } + /** Sends a text change into the conflict copy of its block (see conflictRedirects). */ + private redirectToConflictCopy(op: Op, doc: Doc, copyId = this.conflictRedirects.get(op.blockId!)): void { + if (!copyId) return; + const copy = doc.blocks.get(copyId); + if (!copy || copy.deleted || copy.props.conflictOf !== op.blockId) { + // Resolved or discarded meanwhile: a new change is a new conflict + this.conflictRedirects.delete(op.blockId!); + return; + } + op.blockId = copyId; + op.baseVersion = copy.version; + } + private recompute(): void { const v = cloneDoc(this.confirmed); // Pending ops count the way they will be sent: formBatch folds all text changes of a block into one, @@ -537,20 +563,27 @@ export class DocSyncEngine { this.pending = []; await this.persist(); } - const ops = this.canEdit ? this.inflight || [] : []; - if (ops.length > 0) this.status = 'saving'; + // flushOnExit may start a new batch while this request is on the wire; only this one is answered here + let batch = this.canEdit ? this.inflight : null; + if (batch && batch.length > 0) this.status = 'saving'; for (;;) { const res = await this.transport.sync(this.docId, { clientId: this.clientId, lastSeq: this.lastSeq, - ops: this.inflight ? ops : [], + ops: batch || [], leases: this.canEdit && this.collaborative() ? { hold: this.holds(), release: [] } : undefined, }); - if (this.inflight) { - const sent = new Map(this.inflight.map((op) => [op.opId, op])); + const lostTo = new Map(); // block -> conflict copy of its losing text + if (batch) { + const sent = new Map(batch.map((op) => [op.opId, op])); for (const r of res.results) { if (r.status === 'conflict') this.conflicts = [...this.conflicts, r]; + const conflicted = sent.get(r.opId); + if (r.status === 'conflict' && r.conflictBlockId && conflicted?.kind === 'set_content') { + lostTo.set(conflicted.blockId!, r.conflictBlockId); + if (this.focused === conflicted.blockId) this.conflictRedirects.set(conflicted.blockId!, r.conflictBlockId); + } // Someone else created a block with the same ID first (e.g. the first block of an empty note): // keep the typed text by sending it as a regular text change of that block const op = sent.get(r.opId); @@ -559,7 +592,8 @@ export class DocSyncEngine { this.pending.unshift({ opId: this.newOpId(), kind: 'set_content', blockId: op.blockId, content: op.content, baseVersion: 0 }); } } - this.inflight = null; + if (this.inflight === batch) this.inflight = null; + batch = null; } this.role = res.role; this.shared = res.shared; @@ -581,6 +615,11 @@ export class DocSyncEngine { this.lastSeq = entry.seq; } if (gap) await this.loadSnapshot(); + // Text typed into the same field while the losing batch was on the wire builds on the losing text + // and belongs into the copy too + for (const op of this.pending) { + if (op.kind === 'set_content' && lostTo.has(op.blockId!)) this.redirectToConflictCopy(op, this.confirmed, lostTo.get(op.blockId!)); + } await this.persist(); if (!res.hasMore || gap) break; } @@ -690,7 +729,16 @@ export class DocSyncEngine { /** Sends what is pending with keepalive (survives page unload) and releases leases. */ private flushOnExit(): void { if (this.canEdit && (this.inflight || this.pending.length > 0)) { - const ops = this.inflight || formBatch(this.pending); + // The batch becomes the one in flight: the next sync resends exactly these ops (same IDs, applied + // once), and text typed afterwards starts a new batch on top instead of being folded into this one + // with its older base version, which would conflict with the user's own text + if (!this.inflight) { + this.inflight = formBatch(this.pending); + this.pending = []; + this.recompute(); + void this.persist(); + } + const ops = this.inflight; this.transport .sync(this.docId, { clientId: this.clientId, lastSeq: this.lastSeq, ops }, { keepalive: true }) .catch(() => { From c19d8fd03e6c7527bb3f4fe6ba7a9b7135e18215 Mon Sep 17 00:00:00 2001 From: Sandro Date: Mon, 2 Nov 2026 11:32:32 +0100 Subject: [PATCH 6/6] chore: release v0.55.0 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 15 ++++++++++++++- README.md | 2 +- frontend/package-lock.json | 4 ++-- frontend/package.json | 2 +- 4 files changed, 18 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 234ccb7..5d76956 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,18 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V ## [Unreleased] +## [0.55.0] - 2026-11-02 + +### Added +- Verbindungsstatus am Profil mit drei Zuständen: grün „online“ (Live-Verbindung), gelb „eingeschränkt“ (Server erreichbar, aber keine Live-Verbindung, z. B. hinter einem Firmen-Proxy; Notizen gleichen sich langsamer ab, andere Änderungen erscheinen beim Zurückkehren in den Tab) und rot „offline“. Am Desktop steht der Status jetzt immer unter dem Namen, nicht nur ohne Verbindung. + +### Fixed +- Hinter einem Firmen-Proxy stand am Profil dauerhaft „offline“, obwohl die App funktionierte. +- Notizen: Wer nach einem Wechsel in eine andere App oder ein anderes Fenster im selben Punkt weitertippte, bekam eine Konfliktmeldung gegen den eigenen Text. +- Notizen: Nach einem echten Konflikt entstand bei jedem Weitertippen eine weitere Konfliktkopie. Jetzt gibt es eine Kopie mit dem eigenen Text; der Punkt selbst zeigt nach dem Verlassen die andere Version. +- Anmeldung: Tab springt vom Benutzernamen direkt ins Passwortfeld, „Passwort vergessen?“ kommt danach. +- Handy: Seiten lassen sich nicht mehr ins Leere scrollen, wenn der Inhalt auf den Bildschirm passt (die Höhe richtet sich nach dem sichtbaren Bereich statt nach der Höhe ohne Adressleiste). + ## [0.54.0] - 2026-11-02 ### Added @@ -807,7 +819,8 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V - Geteilte Listen (Einkaufs-, Pack- und Checklisten) mit `#Kategorie`-Gruppierung, Mengenangaben und Echtzeit-Abhaken. - Benutzer-, Gruppen- und Admin-Verwaltung (Ersteinrichtungs-Assistent, Einladungscodes, Profilbilder, persönliche API-Keys & Webhook-Ingest `/api/v1/webhooks/ingest`, JSON-Datenexport) sowie CSS-Theme-Engine mit 4 Themes (`Dark`, `Light`, `High Contrast`, `Soft`) und PWA-Unterstützung. -[Unreleased]: https://git.dsit.me/sandrodach/MindSift/compare/v0.54.0...dev +[Unreleased]: https://git.dsit.me/sandrodach/MindSift/compare/v0.55.0...dev +[0.55.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.54.0...v0.55.0 [0.54.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.53.0...v0.54.0 [0.53.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.52.0...v0.53.0 [0.52.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.51.0...v0.52.0 diff --git a/README.md b/README.md index 2ab8789..416ae13 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ [![Self-Hosted](https://img.shields.io/badge/100%25-Self--Hosted-2563eb?style=flat-square)](#) [![Docker](https://img.shields.io/badge/Deployment-Single%20Container-2496ed?style=flat-square&logo=docker&logoColor=white)](#-schnellstart) [![SQLite](https://img.shields.io/badge/Daten-SQLite-003b57?style=flat-square&logo=sqlite&logoColor=white)](#-deine-daten-gehören-dir) -[![Version](https://img.shields.io/badge/Version-v0.54.0-16a34a?style=flat-square)](CHANGELOG.md) +[![Version](https://img.shields.io/badge/Version-v0.55.0-16a34a?style=flat-square)](CHANGELOG.md) **MindSift** ist eine selbst gehostete Schaltstelle für spontane Gedanken, Aufgaben mit Kanban-Board, hierarchische Outliner-Notizen, die mehrere Personen gleichzeitig bearbeiten können, und gemeinsame Listen. Alles läuft als ein einzelner Container mit eingebetteter SQLite-Datenbank, auf deinem eigenen Server. diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 589419e..0bb3270 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "mindsift-frontend", - "version": "0.54.0", + "version": "0.55.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "mindsift-frontend", - "version": "0.54.0", + "version": "0.55.0", "license": "AGPL-3.0-only", "dependencies": { "@fontsource-variable/inter": "5.3.0", diff --git a/frontend/package.json b/frontend/package.json index 26d5290..13b4e4e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "mindsift-frontend", "private": true, - "version": "0.54.0", + "version": "0.55.0", "license": "AGPL-3.0-only", "type": "module", "scripts": {