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); + }); +});