Merge dev into main (v0.26.0)
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 27s

This commit is contained in:
sandrodach committed 2026-10-31 19:19:44 +01:00
commit ae986171bf
34 files changed
+186 -21

No files matched your search

+6
View File
@@ -38,6 +38,12 @@ Die Linter-Warnungen `react-hooks/exhaustive-deps` gibt es schon länger; neuer
``` ```
- **Zeitabhängiger Code** (Alarme, Tageszusammenfassung, Begrüßungen, Termine): nie `time.Now()` dort, wo eine übergebene Zeit gemeint ist. Die betroffenen Go-Tests zusätzlich mit verschiedenen `TZ`-Werten laufen lassen (z. B. `Europe/Berlin`, `America/Los_Angeles`, `Asia/Tokyo`). Ein uhrzeitabhängiger Test hat schon einmal einen Release verhindert, weil er nur vormittags grün war. - **Zeitabhängiger Code** (Alarme, Tageszusammenfassung, Begrüßungen, Termine): nie `time.Now()` dort, wo eine übergebene Zeit gemeint ist. Die betroffenen Go-Tests zusätzlich mit verschiedenen `TZ`-Werten laufen lassen (z. B. `Europe/Berlin`, `America/Los_Angeles`, `Asia/Tokyo`). Ein uhrzeitabhängiger Test hat schon einmal einen Release verhindert, weil er nur vormittags grün war.
## Screenshots
- Bilder in `screenshots/` (README) sind **WebP**, keine PNG: Desktop und Theme-Raster 1280 px breit, Handy 480 px, Qualität 90. Das bringt rund ein Fünftel der PNG-Größe bei sauberer Schrift.
- Erzeugt werden sie mit `e2e/screenshots.sh`; das Skript nimmt PNGs auf und wandelt sie am Ende mit `cwebp` (Alpine-Container) um. Einzelne Bilder neu aufnehmen: denselben Ablauf aus `screenshots.mjs` verwenden und mit dem Umwandlungsschritt aus `screenshots.sh` konvertieren.
- Chromiums eigenes WebP (`canvas.toDataURL('image/webp')`) nicht verwenden: verlustfrei wird es größer als die PNG.
## Release-Ablauf ## Release-Ablauf
**Committen** ist jederzeit erlaubt. **Jeder Push ist ein Release**, also mit Version und CHANGELOG-Eintrag. **Committen** ist jederzeit erlaubt. **Jeder Push ist ein Release**, also mit Version und CHANGELOG-Eintrag.
+10 -1
View File
@@ -6,6 +6,14 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V
## [Unreleased] ## [Unreleased]
## [0.26.0] - 2026-10-31
### Added
- Verbindungsstatus im Kopf: Ist der Server nicht erreichbar, steht am Desktop unter deinem Namen klein „● offline“ mit rotem Punkt; die Seite bleibt bedienbar. Am Handy zeigt ein kleiner Punkt unten rechts am Profilbild den Status (grün online, rot offline). Das funktioniert auch, wenn nur der Server ausgefallen ist und das Gerät selbst noch Netz hat – bisher war das nur in den Listen zu sehen.
### Changed
- Screenshots im README sind jetzt kleinere WebP-Bilder (zusammen etwa 0,5 statt 2,7 MB); das Journal-Bild zeigt die aktuelle Ansicht.
## [0.25.1] - 2026-10-31 ## [0.25.1] - 2026-10-31
### Fixed ### Fixed
@@ -519,7 +527,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. - 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. - 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.25.1...dev [Unreleased]: https://git.dsit.me/sandrodach/MindSift/compare/v0.26.0...dev
[0.26.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.25.1...v0.26.0
[0.25.1]: https://git.dsit.me/sandrodach/MindSift/compare/v0.25.0...v0.25.1 [0.25.1]: https://git.dsit.me/sandrodach/MindSift/compare/v0.25.0...v0.25.1
[0.25.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.24.2...v0.25.0 [0.25.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.24.2...v0.25.0
[0.24.2]: https://git.dsit.me/sandrodach/MindSift/compare/v0.24.1...v0.24.2 [0.24.2]: https://git.dsit.me/sandrodach/MindSift/compare/v0.24.1...v0.24.2
+1
View File
@@ -162,6 +162,7 @@ Die Tests laufen beim Image-Build (Frontend: `npm run test:run`, Backend: `go te
- **Lese-Cache** ([lib/offline.ts](../frontend/src/lib/offline.ts)): Jede erfolgreiche GET-Antwort landet pro Benutzer in IndexedDB (`mindsift-offline`). Ohne Verbindung liefert `request()` die letzte bekannte Antwort; so startet die App offline angemeldet und zeigt Gedanken, Aufgaben und Listen. Ausgenommen: Streams, Long-Polling, Notiz-Sync (eigener Speicher, [modules/collaboration.md](modules/collaboration.md)), Export, Suche. Abmelden leert Cache und Warteschlange. - **Lese-Cache** ([lib/offline.ts](../frontend/src/lib/offline.ts)): Jede erfolgreiche GET-Antwort landet pro Benutzer in IndexedDB (`mindsift-offline`). Ohne Verbindung liefert `request()` die letzte bekannte Antwort; so startet die App offline angemeldet und zeigt Gedanken, Aufgaben und Listen. Ausgenommen: Streams, Long-Polling, Notiz-Sync (eigener Speicher, [modules/collaboration.md](modules/collaboration.md)), Export, Suche. Abmelden leert Cache und Warteschlange.
- **Warteschlange (Outbox):** Offline erfasste Gedanken (`POST /inbox`) und abgehakte Aufgaben bzw. Listeneinträge (`PATCH`) werden in `localStorage` gesammelt, wiederholtes Umschalten zählt nur mit dem Endstand. Gesendet wird in Reihenfolge beim `online`-Ereignis, beim Start und alle 30 s, solange etwas wartet; vom Server abgelehnte Einträge (4xx) entfallen. Andere Änderungen brauchen weiterhin eine Verbindung. - **Warteschlange (Outbox):** Offline erfasste Gedanken (`POST /inbox`) und abgehakte Aufgaben bzw. Listeneinträge (`PATCH`) werden in `localStorage` gesammelt, wiederholtes Umschalten zählt nur mit dem Endstand. Gesendet wird in Reihenfolge beim `online`-Ereignis, beim Start und alle 30 s, solange etwas wartet; vom Server abgelehnte Einträge (4xx) entfallen. Andere Änderungen brauchen weiterhin eine Verbindung.
- **Hinweis:** Ein Banner über der unteren Navigation zeigt „Offline“ und die Zahl wartender Änderungen bzw. „Synchronisiere…“. - **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.
## App-Icons, PWA & Social-Preview (Open Graph) ## App-Icons, PWA & Social-Preview (Open Graph)
+12 -12
View File
@@ -6,12 +6,12 @@
[![Self-Hosted](https://img.shields.io/badge/100%25-Self--Hosted-2563eb?style=flat-square)](#) [![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) [![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) [![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.25.1-16a34a?style=flat-square)](CHANGELOG.md) [![Version](https://img.shields.io/badge/Version-v0.26.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. **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.
<p align="center"> <p align="center">
<img src="screenshots/Triage_desktop.png" alt="MindSift Triage am Desktop, links hell, rechts dunkel" width="85%" /> <img src="screenshots/Triage_desktop.webp" alt="MindSift Triage am Desktop, links hell, rechts dunkel" width="85%" />
</p> </p>
<sub>Links das helle, rechts das dunkle Theme.</sub> <sub>Links das helle, rechts das dunkle Theme.</sub>
@@ -43,9 +43,9 @@ Die meisten Notiz- und Aufgaben-Apps verlangen Struktur im falschen Moment: Wenn
* Papierkorb mit Wiederherstellen. * Papierkorb mit Wiederherstellen.
<p align="center"> <p align="center">
<img src="screenshots/Mobile_Triage.png" alt="MindSift Triage am Smartphone, helles Theme" width="280px" /> <img src="screenshots/Mobile_Triage.webp" alt="MindSift Triage am Smartphone, helles Theme" width="280px" />
&nbsp;&nbsp; &nbsp;&nbsp;
<img src="screenshots/Mobile_Workspace.png" alt="MindSift Notiz am Smartphone, dunkles Theme" width="280px" /> <img src="screenshots/Mobile_Workspace.webp" alt="MindSift Notiz am Smartphone, dunkles Theme" width="280px" />
</p> </p>
### ✅ Aufgaben & Kanban ### ✅ Aufgaben & Kanban
@@ -61,7 +61,7 @@ Die meisten Notiz- und Aufgaben-Apps verlangen Struktur im falschen Moment: Wenn
* **Alarme & Benachrichtigungen:** Fälligkeits-Alarme sofort, alle übrigen Ereignisse (Freigaben, erledigte geteilte Aufgaben, beobachtete Aufgaben) gebündelt; pro Ereignis wählbar über E-Mail, Discord, eigenen Webhook und Push. * **Alarme & Benachrichtigungen:** Fälligkeits-Alarme sofort, alle übrigen Ereignisse (Freigaben, erledigte geteilte Aufgaben, beobachtete Aufgaben) gebündelt; pro Ereignis wählbar über E-Mail, Discord, eigenen Webhook und Push.
<p align="center"> <p align="center">
<img src="screenshots/aufgaben_desktop.png" alt="MindSift Aufgaben und Kanban-Board" width="90%" /> <img src="screenshots/aufgaben_desktop.webp" alt="MindSift Aufgaben und Kanban-Board" width="90%" />
</p> </p>
### 📝 Workspace: Outliner-Notizen ### 📝 Workspace: Outliner-Notizen
@@ -88,11 +88,11 @@ Die meisten Notiz- und Aufgaben-Apps verlangen Struktur im falschen Moment: Wenn
Technik dahinter: [Doku/modules/collaboration.md](Doku/modules/collaboration.md). Technik dahinter: [Doku/modules/collaboration.md](Doku/modules/collaboration.md).
<p align="center"> <p align="center">
<img src="screenshots/workspace_desktop.png" alt="MindSift Workspace: zwei Personen in derselben Notiz" width="90%" /> <img src="screenshots/workspace_desktop.webp" alt="MindSift Workspace: zwei Personen in derselben Notiz" width="90%" />
</p> </p>
<p align="center"> <p align="center">
<img src="screenshots/journal_desktop.png" alt="MindSift Journal mit Kalender" width="90%" /> <img src="screenshots/journal_desktop.webp" alt="MindSift Journal mit Kalender" width="90%" />
</p> </p>
### 🛒 Geteilte Listen ### 🛒 Geteilte Listen
@@ -101,7 +101,7 @@ Technik dahinter: [Doku/modules/collaboration.md](Doku/modules/collaboration.md)
* Freigabe an Benutzer und Gruppen (Bearbeiter oder Leser); abgehakte Einträge erscheinen bei allen in Echtzeit. * Freigabe an Benutzer und Gruppen (Bearbeiter oder Leser); abgehakte Einträge erscheinen bei allen in Echtzeit.
<p align="center"> <p align="center">
<img src="screenshots/Listen_desktop.png" alt="MindSift Geteilte Listen" width="90%" /> <img src="screenshots/Listen_desktop.webp" alt="MindSift Geteilte Listen" width="90%" />
</p> </p>
### 🏢 Benutzer, Gruppen, Administration ### 🏢 Benutzer, Gruppen, Administration
@@ -117,12 +117,12 @@ Technik dahinter: [Doku/modules/collaboration.md](Doku/modules/collaboration.md)
<table> <table>
<tr> <tr>
<td align="center"><img src="screenshots/Theme_fjord.png" alt="Farbfamilie Fjord, links hell, rechts dunkel" /><br /><sub><b>Fjord</b></sub></td> <td align="center"><img src="screenshots/Theme_fjord.webp" alt="Farbfamilie Fjord, links hell, rechts dunkel" /><br /><sub><b>Fjord</b></sub></td>
<td align="center"><img src="screenshots/Theme_terracotta.png" alt="Farbfamilie Terrakotta, links hell, rechts dunkel" /><br /><sub><b>Terrakotta</b></sub></td> <td align="center"><img src="screenshots/Theme_terracotta.webp" alt="Farbfamilie Terrakotta, links hell, rechts dunkel" /><br /><sub><b>Terrakotta</b></sub></td>
</tr> </tr>
<tr> <tr>
<td align="center"><img src="screenshots/Theme_polarized.png" alt="Farbfamilie Polarized, links hell, rechts dunkel" /><br /><sub><b>Polarized</b></sub></td> <td align="center"><img src="screenshots/Theme_polarized.webp" alt="Farbfamilie Polarized, links hell, rechts dunkel" /><br /><sub><b>Polarized</b></sub></td>
<td align="center"><img src="screenshots/Theme_suri.png" alt="Farbfamilie Suri, links hell, rechts dunkel" /><br /><sub><b>Suri</b></sub></td> <td align="center"><img src="screenshots/Theme_suri.webp" alt="Farbfamilie Suri, links hell, rechts dunkel" /><br /><sub><b>Suri</b></sub></td>
</tr> </tr>
</table> </table>
+1 -1
View File
@@ -40,4 +40,4 @@ Der Eigentümer tippt mit normaler Verbindung, der Bearbeiter sitzt hinter einem
## Screenshots ## Screenshots
`e2e/screenshots.sh` baut das Image, startet es im Demo-Modus mit frischer Datenbank und erzeugt die Bilder in `screenshots/` neu (`screenshots.mjs`). Für die Versionsangabe im Kopf: `APP_VERSION=v0.2 e2e/screenshots.sh`. In der Workspace-Aufnahme bearbeitet der Demo-Benutzer `user` gleichzeitig einen Bullet, damit Sperre und Präsenz zu sehen sind. `e2e/screenshots.sh` baut das Image, startet es im Demo-Modus mit frischer Datenbank und erzeugt die Bilder in `screenshots/` neu (`screenshots.mjs`). Danach werden sie verkleinert (Desktop 1280 px, Handy 480 px breit) und als WebP mit Qualität 90 gespeichert (`cwebp` in einem Alpine-Container). Für die Versionsangabe im Kopf: `APP_VERSION=v0.2 e2e/screenshots.sh`. In der Workspace-Aufnahme bearbeitet der Demo-Benutzer `user` gleichzeitig einen Bullet, damit Sperre und Präsenz zu sehen sind.
+3
View File
@@ -196,7 +196,10 @@ const main = async () => {
await page.getByRole('button', { name: 'Heute' }).click().catch(() => {}); await page.getByRole('button', { name: 'Heute' }).click().catch(() => {});
await page.mouse.click(1000, 860); await page.mouse.click(1000, 860);
await sleep(2500); await sleep(2500);
// Round corners (setting "Ecken: rund") for this picture only
await page.evaluate(() => document.documentElement.setAttribute('data-corners', 'round'));
await plain(page, 'journal_desktop.png', 'dark'); await plain(page, 'journal_desktop.png', 'dark');
await page.evaluate(() => document.documentElement.removeAttribute('data-corners'));
await page.getByRole('button', { name: 'Journal', exact: true }).click(); // close it again await page.getByRole('button', { name: 'Journal', exact: true }).click(); // close it again
await tab(page, 'Listen'); await tab(page, 'Listen');
+12 -1
View File
@@ -1,6 +1,7 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Builds MindSift, starts it in demo mode with a fresh database and renews the README screenshots # Builds MindSift, starts it in demo mode with a fresh database and renews the README screenshots
# in screenshots/ (title picture and colour-family grid: light and dark joined along a diagonal). # in screenshots/ (title picture and colour-family grid: light and dark joined along a diagonal).
# The PNGs from the browser are scaled down (desktop 1280 px, phone 480 px wide) and saved as WebP.
# Requires podman (or docker via ENGINE=docker). Usage: e2e/screenshots.sh, other theme pair: SHOT_LIGHT=fjord SHOT_DARK=fjord-night # Requires podman (or docker via ENGINE=docker). Usage: e2e/screenshots.sh, other theme pair: SHOT_LIGHT=fjord SHOT_DARK=fjord-night
set -euo pipefail set -euo pipefail
ENGINE="${ENGINE:-podman}" ENGINE="${ENGINE:-podman}"
@@ -21,6 +22,7 @@ cleanup() {
rm -rf "$WORK" rm -rf "$WORK"
} }
trap cleanup EXIT trap cleanup EXIT
mkdir -p "$WORK/png"
echo "Baue Image …" echo "Baue Image …"
# Version in the header, e.g. APP_VERSION=v0.2 e2e/screenshots.sh # Version in the header, e.g. APP_VERSION=v0.2 e2e/screenshots.sh
@@ -32,5 +34,14 @@ for _ in $(seq 1 60); do curl -s -m 1 "http://localhost:$PORT/api/v1/health" >/d
echo "Erstelle Screenshots …" echo "Erstelle Screenshots …"
"$ENGINE" run --rm --network host -e MINDSIFT_URL="http://localhost:$PORT" -e SHOTS_DIR=/shots \ "$ENGINE" run --rm --network host -e MINDSIFT_URL="http://localhost:$PORT" -e SHOTS_DIR=/shots \
-e SHOT_LIGHT="${SHOT_LIGHT:-light}" -e SHOT_DARK="${SHOT_DARK:-dark}" -e LANG=de_DE.UTF-8 -e TZ=Europe/Berlin \ -e SHOT_LIGHT="${SHOT_LIGHT:-light}" -e SHOT_DARK="${SHOT_DARK:-dark}" -e LANG=de_DE.UTF-8 -e TZ=Europe/Berlin \
-v "$ROOT/e2e":/e2e:Z -v "$ROOT/screenshots":/shots:Z -w /e2e mcr.microsoft.com/playwright:v1.48.2-jammy \ -v "$ROOT/e2e":/e2e:Z -v "$WORK/png":/shots:Z -w /e2e mcr.microsoft.com/playwright:v1.48.2-jammy \
sh -c 'npm install --no-audit --no-fund >/dev/null 2>&1 && node screenshots.mjs' sh -c 'npm install --no-audit --no-fund >/dev/null 2>&1 && node screenshots.mjs'
echo "Wandle in WebP um …"
"$ENGINE" run --rm -v "$WORK/png":/png:Z -v "$ROOT/screenshots":/shots:Z docker.io/library/alpine:3.20 sh -c '
apk add -q libwebp-tools >/dev/null
for f in /png/*.png; do
name=$(basename "$f" .png)
case "$name" in Mobile_*) width=480 ;; *) width=1280 ;; esac
cwebp -quiet -resize "$width" 0 -q 90 -m 6 -metadata none "$f" -o "/shots/$name.webp"
done'
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "mindsift-frontend", "name": "mindsift-frontend",
"version": "0.25.1", "version": "0.26.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "mindsift-frontend", "name": "mindsift-frontend",
"version": "0.25.1", "version": "0.26.0",
"license": "Unlicense", "license": "Unlicense",
"dependencies": { "dependencies": {
"@fontsource-variable/inter": "5.3.0", "@fontsource-variable/inter": "5.3.0",
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "mindsift-frontend", "name": "mindsift-frontend",
"private": true, "private": true,
"version": "0.25.1", "version": "0.26.0",
"license": "Unlicense", "license": "Unlicense",
"type": "module", "type": "module",
"scripts": { "scripts": {
+27 -3
View File
@@ -56,6 +56,7 @@ import { WorkspaceView } from './features/workspace/WorkspaceView';
import { SharedListsView } from './features/lists/SharedListsView'; import { SharedListsView } from './features/lists/SharedListsView';
import { SetupAndAuthView } from './features/auth/SetupAndAuthView'; import { SetupAndAuthView } from './features/auth/SetupAndAuthView';
import { OfflineBanner } from './components/OfflineBanner'; import { OfflineBanner } from './components/OfflineBanner';
import { useServerOnline } from './lib/useServerOnline';
import { SharedImportToast } from './components/SharedImportToast'; import { SharedImportToast } from './components/SharedImportToast';
import { outboxCount, clearOfflineData } from './lib/offline'; import { outboxCount, clearOfflineData } from './lib/offline';
import { DemoCountdown } from './components/DemoCountdown'; import { DemoCountdown } from './components/DemoCountdown';
@@ -179,6 +180,7 @@ export const App: React.FC = () => {
// Profile Dropdown State // Profile Dropdown State
const [showProfileMenu, setShowProfileMenu] = useState(false); const [showProfileMenu, setShowProfileMenu] = useState(false);
const serverOnline = useServerOnline();
const [showAbout, setShowAbout] = useState(false); const [showAbout, setShowAbout] = useState(false);
const dropdownRef = useRef<HTMLDivElement | null>(null); const dropdownRef = useRef<HTMLDivElement | null>(null);
@@ -947,7 +949,17 @@ export const App: React.FC = () => {
}`} }`}
title="Profil & Einstellungen" 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> </button>
</div> </div>
</div> </div>
@@ -1013,8 +1025,20 @@ export const App: React.FC = () => {
title="Benutzermenü öffnen" title="Benutzermenü öffnen"
> >
<UserAvatar user={user} size="sm" /> <UserAvatar user={user} size="sm" />
<span className="max-w-[130px] truncate hidden sm:inline text-main font-medium"> <span className="flex flex-col items-start leading-tight">
{user.displayName} <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> </span>
<ChevronDown className="w-3.5 h-3.5 text-muted group-hover:text-main transition-colors" /> <ChevronDown className="w-3.5 h-3.5 text-muted group-hover:text-main transition-colors" />
</button> </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;
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 213 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 231 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 214 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 228 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 232 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 452 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 278 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 266 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 294 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB