diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css
index e92da47..8a48de3 100644
--- a/apps/web/src/app/globals.css
+++ b/apps/web/src/app/globals.css
@@ -489,6 +489,13 @@ input[type="checkbox"], input[type="radio"] {
animation-delay: var(--enter-delay, 0ms);
}
}
+/* Linux-Desktop-App (WebKitGTK, ohne Grafikbeschleunigung): keine
+ Einblend-Animation und kein Pulsieren der Ladeplatzhalter — beides liess den
+ Aufbau des Dashboards stocken (DesktopPlatformMarker setzt das Attribut). */
+html[data-desktop-os="linux"] .widget-card,
+html[data-desktop-os="linux"] .animate-pulse {
+ animation: none;
+}
@keyframes widget-enter {
from {
opacity: 0;
diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx
index da13f81..57bfdce 100644
--- a/apps/web/src/app/layout.tsx
+++ b/apps/web/src/app/layout.tsx
@@ -4,6 +4,7 @@ import { getLocale, getMessages } from 'next-intl/server';
import { ThemeProvider } from 'next-themes';
import { DesktopContextMenuGuard } from '@/components/desktop/desktop-context-menu-guard';
import { DesktopExternalLinks } from '@/components/desktop/desktop-external-links';
+import { DesktopPlatformMarker } from '@/components/desktop/desktop-platform-marker';
import { DesktopThemeSync } from '@/components/desktop/desktop-theme-sync';
import './globals.css';
@@ -29,6 +30,7 @@ export default async function RootLayout({ children }: { children: React.ReactNo
+
{children}
diff --git a/apps/web/src/components/desktop/desktop-platform-marker.test.tsx b/apps/web/src/components/desktop/desktop-platform-marker.test.tsx
new file mode 100644
index 0000000..a9075db
--- /dev/null
+++ b/apps/web/src/components/desktop/desktop-platform-marker.test.tsx
@@ -0,0 +1,22 @@
+import { cleanup, render } from '@testing-library/react';
+import { afterEach, describe, expect, it } from 'vitest';
+import { DESKTOP_CLIENT_COOKIE_NAME } from '@/lib/desktop-client';
+import { DesktopPlatformMarker } from './desktop-platform-marker';
+
+afterEach(() => {
+ cleanup();
+ document.cookie = `${DESKTOP_CLIENT_COOKIE_NAME}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/`;
+});
+
+describe('DesktopPlatformMarker', () => {
+ it('setzt das Betriebssystem der Desktop-App am html-Element', () => {
+ document.cookie = `${DESKTOP_CLIENT_COOKIE_NAME}=${encodeURIComponent('1.9.2|abc1234|linux')}; path=/`;
+ render();
+ expect(document.documentElement.dataset.desktopOs).toBe('linux');
+ });
+
+ it('setzt im Browser nichts', () => {
+ render();
+ expect(document.documentElement.dataset.desktopOs).toBeUndefined();
+ });
+});
diff --git a/apps/web/src/components/desktop/desktop-platform-marker.tsx b/apps/web/src/components/desktop/desktop-platform-marker.tsx
new file mode 100644
index 0000000..608b77e
--- /dev/null
+++ b/apps/web/src/components/desktop/desktop-platform-marker.tsx
@@ -0,0 +1,25 @@
+'use client';
+
+import { useEffect } from 'react';
+import { getDesktopClientInfo } from '@/lib/desktop-client';
+
+/**
+ * DesktopPlatformMarker (quick-261002) — setzt `data-desktop-os` am
+ * `` (z. B. `linux`), damit CSS die Desktop-App je Betriebssystem
+ * anpassen kann. Anlass: Die Linux-Webview (WebKitGTK) zeichnet ohne
+ * Grafikbeschleunigung; die gestaffelte Einblend-Animation der Kacheln liess
+ * den Aufbau des Dashboards spuerbar stocken (gemessen: Haenger von
+ * 550-1100 ms auf ~230 ms ohne Animation). Im Browser ohne Wirkung.
+ */
+export function DesktopPlatformMarker() {
+ useEffect(() => {
+ const os = getDesktopClientInfo()?.os;
+ if (!os) return;
+ document.documentElement.dataset.desktopOs = os;
+ return () => {
+ delete document.documentElement.dataset.desktopOs;
+ };
+ }, []);
+
+ return null;
+}
diff --git a/apps/web/src/components/nextcloud-status/nextcloud-alert-notifier.tsx b/apps/web/src/components/nextcloud-status/nextcloud-alert-notifier.tsx
index 0f9e202..e83771a 100644
--- a/apps/web/src/components/nextcloud-status/nextcloud-alert-notifier.tsx
+++ b/apps/web/src/components/nextcloud-status/nextcloud-alert-notifier.tsx
@@ -9,6 +9,8 @@ const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
const MODULE_SLUG = 'nextcloud-status';
/** Abstand der Abfrage beim Server. */
const POLL_MS = 60_000;
+/** Mindestabstand fuer Abfragen nach Fokus-/Sichtbarkeitswechsel. */
+export const RESUME_MIN_MS = 10_000;
/** Rote Grundkennung der API -> Schluessel unter `nextcloudStatus.notify.down`. */
const DOWN_KEYS: Record = {
@@ -110,7 +112,14 @@ export function NextcloudAlertNotifier() {
void start();
const interval = setInterval(() => void poll(), POLL_MS);
+ // Fokus/Sichtbarkeit loesen hoechstens alle RESUME_MIN_MS eine Abfrage aus:
+ // In der Linux-Desktop-App kamen diese Ereignisse beim Oeffnen des
+ // Dashboards in Schueben (bis zu 50 Abfragen je Sekunde im API-Log).
+ let lastResume = Date.now();
const resume = () => {
+ const now = Date.now();
+ if (now - lastResume < RESUME_MIN_MS) return;
+ lastResume = now;
pausedRef.current = false;
void start();
};
diff --git a/apps/web/src/components/reminders/reminder-notifier.test.tsx b/apps/web/src/components/reminders/reminder-notifier.test.tsx
index 0205bc0..7731337 100644
--- a/apps/web/src/components/reminders/reminder-notifier.test.tsx
+++ b/apps/web/src/components/reminders/reminder-notifier.test.tsx
@@ -143,4 +143,16 @@ describe('ReminderNotifier', () => {
});
expect(mockList.mock.calls.length).toBe(after401 + 1);
});
+ it('viele Fokus-Ereignisse kurz hintereinander loesen nur eine Abfrage aus', async () => {
+ mockList.mockResolvedValue([]);
+ render();
+ await advance(0);
+ await advance(15_000);
+ const before = mockList.mock.calls.length;
+ await act(async () => {
+ for (let i = 0; i < 50; i++) window.dispatchEvent(new Event('focus'));
+ await vi.advanceTimersByTimeAsync(0);
+ });
+ expect(mockList.mock.calls.length).toBe(before + 1);
+ });
});
diff --git a/apps/web/src/components/reminders/reminder-notifier.tsx b/apps/web/src/components/reminders/reminder-notifier.tsx
index 4642821..676ee35 100644
--- a/apps/web/src/components/reminders/reminder-notifier.tsx
+++ b/apps/web/src/components/reminders/reminder-notifier.tsx
@@ -9,14 +9,16 @@ import {
withNotifyLock,
} from '@/lib/reminder-notify';
import {
+ listReminders,
REMINDERS_CHANGED_EVENT,
type Reminder,
ReminderRequestError,
- listReminders,
} from '@/lib/reminders-api';
/** Abstand der Abfrage beim Server. */
const POLL_MS = 60_000;
+/** Mindestabstand fuer Abfragen nach Fokus-/Sichtbarkeitswechsel. */
+export const RESUME_MIN_MS = 10_000;
/** Abstand der lokalen Faelligkeitspruefung gegen die geladene Liste (ohne Netz). */
const TICK_MS = 10_000;
const BODY_MAX = 200;
@@ -87,7 +89,14 @@ export function ReminderNotifier() {
const poll = setInterval(() => void load(), POLL_MS);
const tick = setInterval(() => void notifyDue(), TICK_MS);
+ // Fokus/Sichtbarkeit loesen hoechstens alle RESUME_MIN_MS eine Abfrage aus:
+ // In der Linux-Desktop-App kamen diese Ereignisse beim Oeffnen des
+ // Dashboards in Schueben (bis zu 50 Abfragen je Sekunde im API-Log).
+ let lastResume = Date.now();
const resume = () => {
+ const now = Date.now();
+ if (now - lastResume < RESUME_MIN_MS) return;
+ lastResume = now;
pausedRef.current = false;
void load();
};