fix(web): Kalender-Ladeeffekt an monthDate statt getTime(), Stoppuhr-Takt liest nur noch Einzelwerte
Befunde 1-6 der Biome-Regel useExhaustiveDependencies (quick-260921-gof): - calendar-widget.tsx: useMemo um resolveCalendarConfig() entfernt (reine Funktion, spart nichts). showToday setzt monthDate jetzt identitaetserhaltend, wenn der aktuelle Monat schon angezeigt wird - erst danach durfte der Ladeeffekt von monthDate.getTime() auf monthDate umgestellt werden, sonst haette jeder Druck auf den Monatsknopf im laufenden Monat einen Termin-Abruf bis zum Exchange-Server ausgeloest (D-04). - stopwatch-widget.tsx: neue reine Hilfsfunktion computeElapsedFrom() fuer den Takt-Effekt, der jetzt nur noch drei Einzelwerte statt des ganzen sw-Objekts liest - eine sw-Abhaengigkeit haette den 100-ms-Takt bei jeder aufgezeichneten Runde ab- und wiederaufgebaut. - Testerweiterungen als Rueckfallsicherungen: 2x weiterblaettern -> 3 Abrufe, 3x Monatsknopf im laufenden Monat -> kein Zusatzabruf; Runde waehrend die Stoppuhr laeuft unterbricht den Takt nicht, genau 1 PATCH je Klick. - Wirkungslose eslint-disable-Zeilen fuer diese Regel entfallen (kein ESLint mehr im Projekt). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TPPB4ApQxzSU1rwV2Ffj9J
This commit is contained in:
@@ -482,4 +482,36 @@ describe('CalendarWidget', () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Zurück' }));
|
||||
await waitFor(() => expect(screen.getByText('Juni 2026')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('Test 9: zweimal weiterblaettern loest zwei zusaetzliche Abrufe aus (insgesamt drei)', async () => {
|
||||
mockFetchEvents.mockResolvedValue([]);
|
||||
|
||||
const { CalendarWidget } = await import('./calendar-widget');
|
||||
render(<CalendarWidget instanceId="cal-9" config={{}} isEditMode={false} />);
|
||||
|
||||
await waitFor(() => expect(mockFetchEvents).toHaveBeenCalledTimes(1));
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Weiter' }));
|
||||
await waitFor(() => expect(mockFetchEvents).toHaveBeenCalledTimes(2));
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Weiter' }));
|
||||
await waitFor(() => expect(mockFetchEvents).toHaveBeenCalledTimes(3));
|
||||
});
|
||||
|
||||
it('Test 10: Rueckfallsicherung — dreimaliger Druck auf den Monatsknopf im laufenden Monat loest KEINEN zusaetzlichen Abruf aus (waere monthDate statt der stabilisierten showToday direkt in die Abhaengigkeitsliste eingetragen worden, wuerde jeder Druck hier feuern)', async () => {
|
||||
mockFetchEvents.mockResolvedValue([]);
|
||||
|
||||
const { CalendarWidget } = await import('./calendar-widget');
|
||||
render(<CalendarWidget instanceId="cal-10" config={{}} isEditMode={false} />);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Juli 2026')).toBeInTheDocument());
|
||||
await waitFor(() => expect(mockFetchEvents).toHaveBeenCalledTimes(1));
|
||||
|
||||
const todayButton = screen.getByRole('button', { name: 'Juli 2026' });
|
||||
fireEvent.click(todayButton);
|
||||
fireEvent.click(todayButton);
|
||||
fireEvent.click(todayButton);
|
||||
|
||||
await waitFor(() => expect(mockFetchEvents).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -73,11 +73,10 @@ export function CalendarWidget({ config }: WidgetProps) {
|
||||
} | null>(null);
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval> | undefined>(undefined);
|
||||
|
||||
const { showMonth, maxEvents, lookaheadDays } = useMemo(
|
||||
() => resolveCalendarConfig(config),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[config.showMonth, config.maxEvents, config.lookaheadDays],
|
||||
);
|
||||
// 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.
|
||||
const { showMonth, maxEvents, lookaheadDays } = resolveCalendarConfig(config);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -128,8 +127,15 @@ export function CalendarWidget({ config }: WidgetProps) {
|
||||
clearInterval(intervalRef.current);
|
||||
}
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [monthDate.getTime(), lookaheadDays]);
|
||||
// Befund 3/4 (quick-260921-gof): haengt jetzt direkt an monthDate statt an
|
||||
// monthDate.getTime(). Das ist nur deshalb gefahrlos, weil showToday
|
||||
// (unten) das Datum identitaetserhaltend setzt, wenn der gewuenschte
|
||||
// Monat bereits angezeigt wird — sonst wuerde jeder Druck auf den
|
||||
// Monatsknopf im laufenden Monat einen neuen Termin-Abruf ausloesen, der
|
||||
// ueber die API bis zum Exchange-Server durchschlaegt (D-04). Das
|
||||
// Ladefenster bleibt weiterhin auf lokale Tagesgrenzen gerundet, der
|
||||
// Backend-Cache-Schluessel aendert sich dadurch nicht.
|
||||
}, [monthDate, lookaheadDays]);
|
||||
|
||||
const showPrev = useCallback(() => {
|
||||
setHover(null);
|
||||
@@ -141,8 +147,18 @@ export function CalendarWidget({ config }: WidgetProps) {
|
||||
}, []);
|
||||
const showToday = useCallback(() => {
|
||||
setHover(null);
|
||||
const now = new Date();
|
||||
setMonthDate(new Date(now.getFullYear(), now.getMonth(), 1));
|
||||
// Identitaetserhaltend: wird der aktuelle Monat bereits angezeigt, bleibt
|
||||
// dieselbe monthDate-Referenz stehen, statt ein frisches Date-Objekt zu
|
||||
// erzeugen. Der Ladeeffekt haengt jetzt direkt an monthDate — ohne diese
|
||||
// Stabilisierung wuerde jeder Druck auf den Knopf einen neuen
|
||||
// Termin-Abruf ausloesen (Befund 3/4, D-04).
|
||||
setMonthDate((d) => {
|
||||
const now = new Date();
|
||||
if (d.getFullYear() === now.getFullYear() && d.getMonth() === now.getMonth()) {
|
||||
return d;
|
||||
}
|
||||
return new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const eventsByDate = useMemo(() => groupEventsByDate(events), [events]);
|
||||
|
||||
@@ -196,6 +196,39 @@ describe('StopwatchWidget', () => {
|
||||
expect(totalSeconds).toBeGreaterThanOrEqual(5);
|
||||
});
|
||||
|
||||
it('quick-260921-gof: Runde unterbricht den Takt nicht — Anzeige laeuft danach weiter statt zurueckzufallen, und pro Klick geht genau ein PATCH (Rueckfallsicherung gegen sw als Effekt-Abhaengigkeit)', async () => {
|
||||
render(<StopwatchWidget instanceId="sw-lap-tick" config={{}} isEditMode={false} />);
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: /stopwatch\.start/i }));
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(2000);
|
||||
});
|
||||
|
||||
const beforeLap = screen.getByTestId('stopwatch-display').textContent;
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: /stopwatch\.lap/i }));
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(2000);
|
||||
});
|
||||
|
||||
const afterLap = screen.getByTestId('stopwatch-display').textContent;
|
||||
|
||||
// Anzeige muss ueber die Runde hinweg weiterlaufen, nicht auf 00:00
|
||||
// zurueckfallen und nicht stehen bleiben.
|
||||
expect(afterLap).not.toBe('00:00');
|
||||
expect(afterLap).not.toBe(beforeLap);
|
||||
|
||||
// Genau ein PATCH je Klick: Start + Runde = 2, kein Dauerfeuer durch ein
|
||||
// Ab- und Wiederaufbauen des Takt-Intervalls beim Aufzeichnen der Runde.
|
||||
expect(updateWidgetConfig).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('quick-260916-bwo: die Anzeige skaliert per Container-Query-Klasse, keine Fensterbreiten-Formel im Style', () => {
|
||||
render(<StopwatchWidget instanceId="sw-cq" config={{}} isEditMode={false} />);
|
||||
|
||||
|
||||
@@ -50,11 +50,27 @@ function formatMs(ms: number): string {
|
||||
* If running: Date.now() - startedAt + accumulated elapsed (Pitfall 2 — reload reconstruction).
|
||||
* If paused/stopped: just the accumulated elapsed.
|
||||
*/
|
||||
function computeElapsed(sw: StopwatchConfig): number {
|
||||
if (sw.state === 'running' && sw.startedAt) {
|
||||
return Date.now() - new Date(sw.startedAt).getTime() + sw.elapsed;
|
||||
/**
|
||||
* Reine Berechnung aus Einzelwerten statt aus dem ganzen Config-Objekt
|
||||
* (Befund 5/6, quick-260921-gof): der Takt-Effekt unten liest darueber nur
|
||||
* noch drei Primitiven statt `sw` als Ganzes — sonst wuerde eine
|
||||
* eingetragene `sw`-Abhaengigkeit den 100-ms-Takt bei jeder aufgezeichneten
|
||||
* Runde (die `sw` per Spread neu entstehen laesst) ab- und wiederaufbauen,
|
||||
* ohne Not und mit Taktversatz.
|
||||
*/
|
||||
function computeElapsedFrom(
|
||||
state: StopwatchConfig['state'],
|
||||
startedAt: string | null,
|
||||
elapsed: number,
|
||||
): number {
|
||||
if (state === 'running' && startedAt) {
|
||||
return Date.now() - new Date(startedAt).getTime() + elapsed;
|
||||
}
|
||||
return sw.elapsed;
|
||||
return elapsed;
|
||||
}
|
||||
|
||||
function computeElapsed(sw: StopwatchConfig): number {
|
||||
return computeElapsedFrom(sw.state, sw.startedAt, sw.elapsed);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -77,14 +93,22 @@ export function StopwatchWidget({ instanceId, config, isEditMode: _isEditMode }:
|
||||
const abortRef = useRef<AbortController | undefined>(undefined);
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval> | undefined>(undefined);
|
||||
|
||||
// Tick interval — only active while running
|
||||
// Tick interval — only active while running.
|
||||
// Liest bewusst nur die drei Einzelwerte, nicht `sw` als Ganzes (Befund
|
||||
// 5/6): so bleibt die Abhaengigkeitsliste ehrlich und der Takt wird nur
|
||||
// beim Wechsel laufend/angehalten oder bei einer Zeitkorrektur neu
|
||||
// aufgesetzt, nicht beim Aufzeichnen einer Runde.
|
||||
useEffect(() => {
|
||||
if (sw.state === 'running' && sw.startedAt) {
|
||||
const state = sw.state;
|
||||
const startedAt = sw.startedAt;
|
||||
const elapsed = sw.elapsed;
|
||||
|
||||
if (state === 'running' && startedAt) {
|
||||
intervalRef.current = setInterval(() => {
|
||||
setDisplayMs(computeElapsed(sw));
|
||||
setDisplayMs(computeElapsedFrom(state, startedAt, elapsed));
|
||||
}, 100);
|
||||
} else {
|
||||
setDisplayMs(sw.elapsed);
|
||||
setDisplayMs(elapsed);
|
||||
}
|
||||
|
||||
return () => {
|
||||
@@ -93,8 +117,6 @@ export function StopwatchWidget({ instanceId, config, isEditMode: _isEditMode }:
|
||||
intervalRef.current = undefined;
|
||||
}
|
||||
};
|
||||
// Re-run when state/startedAt/elapsed changes (handles reload reconstruction)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [sw.state, sw.startedAt, sw.elapsed]);
|
||||
|
||||
// Cleanup on unmount
|
||||
|
||||
Reference in New Issue
Block a user