From c0ef4171d5451abfeba09321990380c395728c20 Mon Sep 17 00:00:00 2001 From: cesnimda Date: Wed, 4 Nov 2026 13:34:16 +0100 Subject: [PATCH] fix(calendar): refresh recorded dates after follow-up changes --- .../src/calendar-reminder-refresh.test.tsx | 41 +++++++++++++++++++ .../src/components/CalendarAgenda.tsx | 4 +- .../src/components/RemindersView.tsx | 3 +- job-tracker-ui/src/views/CalendarPage.tsx | 4 +- 4 files changed, 48 insertions(+), 4 deletions(-) create mode 100644 job-tracker-ui/src/calendar-reminder-refresh.test.tsx diff --git a/job-tracker-ui/src/calendar-reminder-refresh.test.tsx b/job-tracker-ui/src/calendar-reminder-refresh.test.tsx new file mode 100644 index 00000000..a1b90827 --- /dev/null +++ b/job-tracker-ui/src/calendar-reminder-refresh.test.tsx @@ -0,0 +1,41 @@ +import React from 'react'; +import '@testing-library/jest-dom'; +import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import CalendarPage from './views/CalendarPage'; +import { I18nProvider } from './i18n/I18nProvider'; +import { ToastProvider } from './toast'; +import { api } from './api'; + +jest.mock('./api', () => ({ api: { get: jest.fn(), patch: jest.fn() }, getApiErrorMessage: (_error: unknown, fallback: string) => fallback })); +const mocked = api as jest.Mocked; +const calendarReads = () => mocked.get.mock.calls.filter(([url]) => url === '/calendar/agenda').length; + +beforeEach(() => { localStorage.clear(); jest.clearAllMocks(); jest.useFakeTimers({ now: new Date('2026-11-04T12:00:00Z') }); }); +afterEach(() => jest.useRealTimers()); + +test('successful scheduling and clearing refresh the adjacent calendar without resetting its view; failed writes do not refresh it', async () => { + let followUpAt: string | null = null; + const job = () => ({ id: 42, jobTitle: 'Synthetic nurse', company: { id: 1, name: 'Synthetic employer' }, status: 'Waiting', needsFollowUp: true, followUpAt }); + mocked.get.mockImplementation(url => Promise.resolve({ data: url === '/jobapplications/reminders' ? [job()] : url === '/calendar/agenda' && followUpAt ? [{ jobApplicationId: 42, jobTitle: job().jobTitle, companyName: job().company.name, kind: 'follow-up', date: followUpAt, recordedAt: followUpAt }] : [] } as never)); + mocked.patch.mockImplementation((_url, body) => { followUpAt = (body as { followUpAt: string | null }).followUpAt; return Promise.resolve({ data: {} } as never); }); + render(); + const reminder = await screen.findByRole('article', { name: 'Synthetic nurse' }); + expect(calendarReads()).toBe(1); + fireEvent.click(within(reminder).getByRole('button', { name: '+3 days: set follow-up for Synthetic nurse' })); + expect(await screen.findByRole('link', { name: /Synthetic nurse/ })).toBeVisible(); + expect(mocked.patch).toHaveBeenCalledWith('/jobapplications/42/followup', { followUpAt: '2026-11-07' }); + expect(calendarReads()).toBe(2); + fireEvent.click(screen.getByRole('button', { name: 'Month' })); + expect(screen.getByRole('table', { name: 'Month' })).toBeVisible(); + fireEvent.click(within(reminder).getByRole('button', { name: 'Clear follow-up: Synthetic nurse' })); + await waitFor(() => expect(calendarReads()).toBe(3)); + await waitFor(() => expect(screen.getByRole('table', { name: 'Month' })).toBeVisible()); + expect(screen.getByRole('button', { name: 'Month' })).toHaveAttribute('aria-pressed', 'true'); + expect(screen.getByRole('combobox', { name: /^Selected month/ })).toHaveTextContent('November 2026'); + mocked.patch.mockRejectedValueOnce({ response: { status: 503 } }); + fireEvent.click(within(reminder).getByRole('button', { name: '+3 days: set follow-up for Synthetic nurse' })); + await waitFor(() => expect(mocked.patch).toHaveBeenCalledTimes(3)); + await waitFor(() => expect(within(reminder).getByRole('button', { name: '+3 days: set follow-up for Synthetic nurse' })).toBeEnabled()); + expect(calendarReads()).toBe(3); +}); diff --git a/job-tracker-ui/src/components/CalendarAgenda.tsx b/job-tracker-ui/src/components/CalendarAgenda.tsx index d153201c..af65ff4c 100644 --- a/job-tracker-ui/src/components/CalendarAgenda.tsx +++ b/job-tracker-ui/src/components/CalendarAgenda.tsx @@ -16,7 +16,7 @@ function calendarDate(value: string) { return Number.isFinite(date.getTime()) && date.toISOString().slice(0, 10) === value ? date : null; } -export default function CalendarAgenda() { +export default function CalendarAgenda({ refreshToken = 0 }: { refreshToken?: number }) { const { language, t } = useI18n(); const preferenceKey = getUserScopedStorageKey('jobtracker.calendarView'); const [view, setView] = useState<'agenda' | 'month'>(() => { try { return localStorage.getItem(preferenceKey) === 'month' ? 'month' : 'agenda'; } catch { return 'agenda'; } }); @@ -32,7 +32,7 @@ export default function CalendarAgenda() { if (!Array.isArray(response.data) || response.data.some(item => !Number.isSafeInteger(item.jobApplicationId) || item.jobApplicationId <= 0 || typeof item.jobTitle !== 'string' || !['deadline', 'follow-up'].includes(item.kind) || typeof item.recordedAt !== 'string' || !calendarDate(item.date) || item.date < from || item.date > to)) throw new Error('Invalid calendar response'); return response.data; }, [from, to]); - const resource = useViewResource(load, { initialData: [] as CalendarItem[], errorMessage: '', deps: [from, to] }); + const resource = useViewResource(load, { initialData: [] as CalendarItem[], errorMessage: '', deps: [from, to, refreshToken] }); const locale = language === 'nb' ? 'nb-NO' : 'en-GB'; const formatDate = (date: string) => new Intl.DateTimeFormat(locale, { dateStyle: 'medium', timeZone: 'UTC' }).format(calendarDate(date)!); const byDay = useMemo(() => { diff --git a/job-tracker-ui/src/components/RemindersView.tsx b/job-tracker-ui/src/components/RemindersView.tsx index 47959230..89c77aa8 100644 --- a/job-tracker-ui/src/components/RemindersView.tsx +++ b/job-tracker-ui/src/components/RemindersView.tsx @@ -106,7 +106,7 @@ function ReminderSection({ title, items, disabled, onOpen, onSetFollowUp }: { ti ); } -export default function RemindersView() { +export default function RemindersView({ onDatesChanged }: { onDatesChanged?: () => void }) { const navigate = useNavigate(); const { toast } = useToast(); const { t } = useI18n(); @@ -145,6 +145,7 @@ export default function RemindersView() { setFailure(null); try { await api.patch(`/jobapplications/${job.id}/followup`, { followUpAt: date }); + onDatesChanged?.(); toast(daysFromNow === null ? t("remindersFollowUpCleared") : t("remindersFollowUpSet"), "success"); await remindersResource.reload(); } catch (error) { diff --git a/job-tracker-ui/src/views/CalendarPage.tsx b/job-tracker-ui/src/views/CalendarPage.tsx index c2f9948d..bdbd49a9 100644 --- a/job-tracker-ui/src/views/CalendarPage.tsx +++ b/job-tracker-ui/src/views/CalendarPage.tsx @@ -1,7 +1,9 @@ import { Stack } from '@mui/material'; +import { useState } from 'react'; import CalendarAgenda from '../components/CalendarAgenda'; import RemindersView from '../components/RemindersView'; export default function CalendarPage() { - return ; + const [calendarRefresh, setCalendarRefresh] = useState(0); + return setCalendarRefresh(value => value + 1)} />; }