diff --git a/apps/web/src/components/dashboard/widgets/calendar-widget.test.tsx b/apps/web/src/components/dashboard/widgets/calendar-widget.test.tsx
index ea968bd..f3e9de3 100644
--- a/apps/web/src/components/dashboard/widgets/calendar-widget.test.tsx
+++ b/apps/web/src/components/dashboard/widgets/calendar-widget.test.tsx
@@ -1,4 +1,4 @@
-import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
+import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { CalendarEvent } from '@/lib/calendar-api';
@@ -598,4 +598,114 @@ describe('CalendarWidget', () => {
expect(document.activeElement).toBe(day20);
});
+
+ // Restposten 3a/3b (quick-260921-jt4): Quellenliste und Termin-Ladefenster
+ // werden nicht mehr blind bei jedem Monatswechsel neu geholt.
+ it('Test 16: Aufbau holt Quellenliste und Termine je genau einmal', async () => {
+ mockFetchEvents.mockResolvedValue([]);
+
+ const { CalendarWidget } = await import('./calendar-widget');
+ render();
+
+ await waitFor(() => expect(mockFetchEvents).toHaveBeenCalledTimes(1));
+ expect(mockFetchSources).toHaveBeenCalledTimes(1);
+ });
+
+ it('Test 17: Monatswechsel mit abweichendem Ladefenster holt die Termine erneut (+1), die Quellenliste NICHT (+0)', async () => {
+ mockFetchEvents.mockResolvedValue([]);
+
+ const { CalendarWidget } = await import('./calendar-widget');
+ render();
+
+ await waitFor(() => expect(mockFetchEvents).toHaveBeenCalledTimes(1));
+ expect(mockFetchSources).toHaveBeenCalledTimes(1);
+
+ fireEvent.click(screen.getByRole('button', { name: 'Weiter' }));
+
+ await waitFor(() => expect(mockFetchEvents).toHaveBeenCalledTimes(2));
+ expect(mockFetchSources).toHaveBeenCalledTimes(1);
+ });
+
+ it('Test 18: Monatswechsel mit identischem Ladefenster loest weder Termin- noch Quellen-Abruf aus (beide +0/+0)', async () => {
+ mockFetchEvents.mockResolvedValue([]);
+
+ const { CalendarWidget } = await import('./calendar-widget');
+ render();
+
+ await waitFor(() => expect(screen.getByText('Juli 2026')).toBeInTheDocument());
+ await waitFor(() => expect(mockFetchEvents).toHaveBeenCalledTimes(1));
+
+ // Juli -> August: das Ladefenster aendert sich wirklich (die 27.07.-
+ // Rastergrenze liegt jetzt NACH "heute", "from" wechselt vom Rasterstart
+ // auf den heutigen Tagesbeginn).
+ fireEvent.click(screen.getByRole('button', { name: 'Weiter' }));
+ await waitFor(() => expect(screen.getByText('August 2026')).toBeInTheDocument());
+ await waitFor(() => expect(mockFetchEvents).toHaveBeenCalledTimes(2));
+ const augustWindow = mockFetchEvents.mock.calls[1];
+
+ // August -> September: bei 90 Tagen Vorschau deckt der (auf "heute" +
+ // 90 Tage eingefrorene) Vorschauhorizont beide Rasterenden — das
+ // errechnete Fenster ist Zeichen fuer Zeichen dasselbe wie in August.
+ // Abweichung vom im PLAN genannten Monatspaar Oktober/November, siehe
+ // SUMMARY: bei DIESEM eingefrorenen "heute" (15.07.2026) kollidieren
+ // nachweislich August und September, nicht Oktober/November — derselbe
+ // Mechanismus, ein anderes Monatspaar (vgl. Test 8, dieselbe Art
+ // Abweichung fuer den analogen 30-Tage-Fall).
+ fireEvent.click(screen.getByRole('button', { name: 'Weiter' }));
+ await waitFor(() => expect(screen.getByText('September 2026')).toBeInTheDocument());
+
+ // Zeit fuer einen faelschlich uebersprungenen-und-doch-feuernden Abruf geben.
+ await act(async () => {});
+
+ expect(mockFetchEvents).toHaveBeenCalledTimes(2);
+ expect(mockFetchEvents.mock.calls[1]).toEqual(augustWindow);
+ expect(mockFetchSources).toHaveBeenCalledTimes(1);
+ });
+
+ it('Test 19: der 5-Minuten-Auffrischer holt immer beide, auch bei unveraendertem Fenster (beide +1/+1)', async () => {
+ vi.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout', 'setInterval', 'clearInterval'] });
+ vi.setSystemTime(new Date(2026, 6, 15, 10, 0, 0));
+ mockFetchEvents.mockResolvedValue([]);
+
+ const { CalendarWidget } = await import('./calendar-widget');
+ render();
+
+ await act(async () => {});
+ expect(mockFetchEvents).toHaveBeenCalledTimes(1);
+ expect(mockFetchSources).toHaveBeenCalledTimes(1);
+
+ await act(async () => {
+ vi.advanceTimersByTime(300_000);
+ });
+
+ expect(mockFetchEvents).toHaveBeenCalledTimes(2);
+ expect(mockFetchSources).toHaveBeenCalledTimes(2);
+ });
+
+ it('Test 20: ein uebersprungener Lauf beendet den Ladezustand trotzdem sauber und leert die bereits geladenen Termine nicht', async () => {
+ mockFetchEvents.mockResolvedValue([
+ ev('Team Meeting', new Date(2026, 6, 20, 9, 0), new Date(2026, 6, 20, 10, 0)),
+ ]);
+
+ const { CalendarWidget } = await import('./calendar-widget');
+ render();
+
+ await waitFor(() => expect(screen.getByText('Juli 2026')).toBeInTheDocument());
+ await waitFor(() => expect(mockFetchEvents).toHaveBeenCalledTimes(1));
+
+ fireEvent.click(screen.getByRole('button', { name: 'Weiter' })); // Juli -> August
+ await waitFor(() => expect(screen.getByText('August 2026')).toBeInTheDocument());
+ await waitFor(() => expect(mockFetchEvents).toHaveBeenCalledTimes(2));
+
+ fireEvent.click(screen.getByRole('button', { name: 'Weiter' })); // August -> September, uebersprungen
+ await waitFor(() => expect(screen.getByText('September 2026')).toBeInTheDocument());
+ await act(async () => {});
+
+ // Der uebersprungene Lauf darf weder haengen bleiben (kein "Laden..."
+ // mehr sichtbar) noch die zuvor geladenen Termine leeren — "Naechste
+ // Termine" haengt an "heute", nicht am angezeigten Monat, und zeigt das
+ // beim Aufbau geladene Team Meeting deshalb weiterhin.
+ expect(screen.queryByText('Laden...')).not.toBeInTheDocument();
+ expect(within(screen.getByTestId('calendar-upcoming')).getByText('Team Meeting')).toBeInTheDocument();
+ });
});
diff --git a/apps/web/src/components/dashboard/widgets/calendar-widget.tsx b/apps/web/src/components/dashboard/widgets/calendar-widget.tsx
index 6554767..fb4a596 100644
--- a/apps/web/src/components/dashboard/widgets/calendar-widget.tsx
+++ b/apps/web/src/components/dashboard/widgets/calendar-widget.tsx
@@ -73,6 +73,18 @@ export function CalendarWidget({ config }: WidgetProps) {
} | null>(null);
const intervalRef = useRef | undefined>(undefined);
+ // Restposten 3a/3b (quick-260921-jt4): zwei Merkungen ausserhalb des
+ // Render-Zustands, damit ein Monatswechsel, der weder die Quellenliste noch
+ // das Termin-Ladefenster tatsaechlich aendert, keinen erneuten Abruf
+ // ausloest — der 5-Minuten-Auffrischer (siehe `force` unten) ruft
+ // unabhaengig davon immer beide ab, sonst veraltet die Anzeige still.
+ // hasSourcesRef: `null` heisst "noch nie erfolgreich geholt".
+ const hasSourcesRef = useRef(null);
+ // lastFetchWindowRef: die beiden ISO-Zeichenketten, die zuletzt
+ // TATSAECHLICH an fetchEvents gingen (nicht nur berechnet) — derselbe
+ // Vergleichswert, den auch die API sieht.
+ const lastFetchWindowRef = useRef<{ from: string; to: string } | null>(null);
+
// Befund 1/2 (quick-260921-gof): keine Merkung noetig — resolveCalendarConfig
// ist eine reine Funktion, die nur drei einfache Werte liefert; eine
// Merkung um das ganze config-Objekt hat hier nie etwas gespart.
@@ -81,13 +93,23 @@ export function CalendarWidget({ config }: WidgetProps) {
useEffect(() => {
let cancelled = false;
- async function loadData() {
+ // `force`: true nur fuer den 5-Minuten-Auffrischer. Ein durch
+ // Monatswechsel ausgeloester Lauf (force=false) darf beide Sperrgriffe
+ // unten greifen lassen; der Auffrischer muss sie UNBEDINGT umgehen,
+ // sonst friert die Anzeige ein.
+ async function loadData(force: boolean) {
try {
- // Check if user has any sources configured
- const sources = await fetchSources();
- if (cancelled) return;
+ // Restposten 3b: die Quellenliste haengt nicht vom angezeigten Monat
+ // ab — nur holen, wenn sie noch unbekannt ist ODER der Lauf
+ // erzwungen wurde (Aufbau: hasSourcesRef ist null, greift also
+ // ohnehin; Auffrischer: force=true).
+ if (hasSourcesRef.current === null || force) {
+ const sources = await fetchSources();
+ if (cancelled) return;
+ hasSourcesRef.current = sources.length > 0;
+ }
- if (sources.length === 0) {
+ if (!hasSourcesRef.current) {
setHasSources(false);
setEvents([]);
setIsLoading(false);
@@ -99,8 +121,26 @@ export function CalendarWidget({ config }: WidgetProps) {
// Fetch aggregated events for the current grid/lookahead window —
// ALWAYS with both day-boundary ISO strings (T-HTC-03).
const { from, to } = computeFetchWindow(monthDate, lookaheadDays);
- const eventData = await fetchEvents(from.toISOString(), to.toISOString());
+ const fromIso = from.toISOString();
+ const toIso = to.toISOString();
+
+ // Restposten 3a: das neu berechnete Fenster deckt sich bei einem
+ // Monatswechsel manchmal mit dem zuletzt tatsaechlich geholten
+ // (siehe Kopfkommentar computeFetchWindow/SUMMARY) — dann ist der
+ // Abruf verschwendet und faellt bis zum naechsten Server durch.
+ const windowUnchanged =
+ !force &&
+ lastFetchWindowRef.current !== null &&
+ lastFetchWindowRef.current.from === fromIso &&
+ lastFetchWindowRef.current.to === toIso;
+ if (windowUnchanged) {
+ setIsLoading(false);
+ return;
+ }
+
+ const eventData = await fetchEvents(fromIso, toIso);
if (cancelled) return;
+ lastFetchWindowRef.current = { from: fromIso, to: toIso };
setEvents(eventData);
} catch {
// Silent fail — show empty state
@@ -114,11 +154,16 @@ export function CalendarWidget({ config }: WidgetProps) {
}
}
- loadData();
+ // Aufbau bzw. Monatswechsel: nicht erzwungen — die beiden Sperrgriffe
+ // oben entscheiden selbst, ob wirklich etwas Neues zu holen ist (beim
+ // allerersten Aufbau sind beide Merkungen leer, holen also ohnehin).
+ loadData(false);
- // Refresh every 5 minutes (300000ms) to match backend cache TTL
+ // Refresh every 5 minutes (300000ms) to match backend cache TTL — IMMER
+ // erzwungen, sonst bemerkt die Kachel weder eine neue Quelle noch neue
+ // Termine innerhalb des Fensters.
intervalRef.current = setInterval(() => {
- loadData();
+ loadData(true);
}, 300_000);
return () => {
diff --git a/apps/web/src/lib/translations-identity.test.tsx b/apps/web/src/lib/translations-identity.test.tsx
new file mode 100644
index 0000000..57009dc
--- /dev/null
+++ b/apps/web/src/lib/translations-identity.test.tsx
@@ -0,0 +1,72 @@
+import { act, cleanup, render } from '@testing-library/react';
+import { NextIntlClientProvider, useTranslations } from 'next-intl';
+import { useState } from 'react';
+import { afterEach, describe, expect, it } from 'vitest';
+
+/**
+ * translations-identity.test.tsx (Restposten 4, quick-260921-jt4)
+ *
+ * Prueft am ECHTEN `NextIntlClientProvider` — nicht behauptet, sondern
+ * gemessen —, ob `t` aus `useTranslations` bei einem Zustandswechsel EINER
+ * Komponente seine Identitaet behaelt. Der Quelltext der eingesetzten
+ * `use-intl`-Fassung (4.13.0, `react.js`) zeigt: `translate` entsteht in
+ * einem `useMemo`, dessen Abhaengigkeiten ausschliesslich aus dem
+ * Intl-Kontext stammen (`cache, formatters, getMessageFallback, allMessages,
+ * namespace, onError, globalFormats, locale, timeZone`) — keine davon haengt
+ * vom lokalen Zustand einer Komponente ab. Der Anbieter (`NextIntlClientProvider`)
+ * steht in `app/layout.tsx`, also oberhalb aller Komponenten dieses Projekts;
+ * ein Zustandswechsel in einer Kindkomponente rendert den Anbieter nicht neu.
+ *
+ * Dieser eine Test entscheidet die Annahme aus quick-260921-gof
+ * ("`t` ist bei jedem Render eine frische Funktion") an der Wurzel statt an
+ * vier einzelnen Symptomstellen.
+ */
+
+const messages = {
+ widgets: {
+ calendar: {
+ name: 'Kalender',
+ },
+ },
+};
+
+function ProbeComponent({ onRender }: { onRender: (t: unknown) => void }) {
+ const t = useTranslations('widgets');
+ const [, forceRerender] = useState(0);
+ onRender(t);
+
+ return (
+
+ );
+}
+
+afterEach(() => {
+ cleanup();
+});
+
+describe('t aus useTranslations — Identitaet ueber einen Zustandswechsel (Restposten 4)', () => {
+ it('bleibt beim erneuten Render derselben Komponente dasselbe Funktionsobjekt, wenn NUR lokaler Zustand wechselt', () => {
+ const seenTranslators: unknown[] = [];
+ const { getByRole } = render(
+
+ seenTranslators.push(t)} />
+ ,
+ );
+
+ expect(seenTranslators).toHaveLength(1);
+ const tBeforeRerender = seenTranslators[0];
+
+ act(() => {
+ getByRole('button').click();
+ });
+
+ expect(seenTranslators).toHaveLength(2);
+ const tAfterRerender = seenTranslators[1];
+
+ // Der gemessene Kern der Annahme: dasselbe Objekt, nicht nur denselben
+ // Rueckgabewert bei Aufruf — ein `toBe` (Referenzgleichheit), kein `toEqual`.
+ expect(tAfterRerender).toBe(tBeforeRerender);
+ });
+});