feat(calendar): show recorded deadlines in agenda and month views

This commit is contained in:
cesnimda
2026-11-03 14:51:12 +01:00
parent 7e410e795d
commit 194e01ed7f
72 changed files with 2737 additions and 2 deletions
+2 -2
View File
@@ -52,7 +52,7 @@ const AnalyticsPage = lazy(() => import("./views/AnalyticsPage"));
const JobDiscoveryPage = lazy(() => import("./views/JobDiscoveryPage"));
const CompaniesTable = lazy(() => import("./components/CompaniesTable"));
const SettingsView = lazy(() => import("./components/SettingsView"));
const RemindersView = lazy(() => import("./components/RemindersView"));
const CalendarPage = lazy(() => import("./views/CalendarPage"));
const QuickCommandDialog = lazy(() => import("./components/QuickCommandDialog"));
const ProfilePage = lazy(() => import("./views/ProfilePage"));
const CareerWorkspacePage = lazy(() => import("./views/CareerWorkspacePage"));
@@ -434,7 +434,7 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo
<Route path="/discover" element={<JobDiscoveryPage />} />
<Route path="/jobs" element={<JobTable refreshToken={refreshToken} pageSize={jobPageSize} onPageSizeChange={setAndPersistPageSize} columns={jobColumns} onColumnsChange={setAndPersistColumns} mode="jobs" />} />
<Route path="/jobs/:id" element={<ApplicationWorkspacePage />} />
<Route path="/reminders" element={<RemindersView />} />
<Route path="/reminders" element={<CalendarPage />} />
<Route path="/operations" element={<OperationsPage />} />
<Route path="/kanban" element={<LegacyBoardRedirect />} />
<Route path="/companies" element={<CompaniesTable />} />
@@ -0,0 +1,89 @@
import React from 'react';
import '@testing-library/jest-dom';
import { act, fireEvent, render, screen, within } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import { api } from './api';
import CalendarAgenda from './components/CalendarAgenda';
import { I18nProvider } from './i18n/I18nProvider';
import { translations } from './i18n/translations';
import { setAuthUserKey } from './auth';
jest.mock('./api', () => ({ api: { get: jest.fn() }, getApiErrorMessage: (_: unknown, fallback: string) => fallback }));
const get = api.get as jest.Mock;
const now = new Date();
const month = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`;
const row = { jobApplicationId: 42, jobTitle: 'Recorded teacher role', companyName: 'School', kind: 'deadline', date: `${month}-15`, recordedAt: `${month}-15T23:30:00` };
const mount = () => render(<I18nProvider><MemoryRouter><CalendarAgenda /></MemoryRouter></I18nProvider>);
beforeEach(() => { localStorage.clear(); document.documentElement.lang = 'en'; get.mockReset(); get.mockResolvedValue({ data: [row] }); });
afterEach(() => jest.restoreAllMocks());
test('agenda prioritises upcoming recorded dates while retaining earlier dates in the month', async () => {
jest.spyOn(Date, 'now').mockReturnValue(new Date(now.getFullYear(), now.getMonth(), 16, 12).getTime());
get.mockResolvedValue({ data: [row, { ...row, jobApplicationId: 43, jobTitle: 'Upcoming nursing role', date: `${month}-20`, recordedAt: `${month}-20T00:00:00` }] });
mount(); await screen.findByRole('link', { name: /Upcoming nursing role/ });
expect(screen.getAllByRole('link').map(link => link.getAttribute('href'))).toEqual(['/jobs/43', '/jobs/42']);
});
test('agenda defaults to recorded dates and links exact application without a fabricated appointment time', async () => {
mount();
const link = await screen.findByRole('link', { name: /Recorded teacher role/ });
expect(link).toHaveAttribute('href', '/jobs/42');
expect(screen.getByText(translations.en.calendarDateScope)).toBeVisible();
expect(document.querySelector('time')).toHaveAttribute('datetime', row.recordedAt);
expect(screen.queryByText('23:30')).not.toBeInTheDocument();
expect(get).toHaveBeenCalledWith('/calendar/agenda', { params: { from: `${month}-01`, to: `${month}-${new Date(now.getFullYear(), now.getMonth() + 1, 0).getDate()}` } });
});
test('month toggle persists only view preference and selecting a day keeps linked events available', async () => {
const first = mount(); await screen.findByRole('link', { name: /Recorded teacher role/ });
fireEvent.click(screen.getByRole('button', { name: translations.en.calendarMonth }));
const table = screen.getByRole('table', { name: translations.en.calendarMonth });
fireEvent.click(within(table).getByRole('button', { name: /15.*1/ }));
expect(screen.getByRole('link', { name: /Recorded teacher role/ })).toBeVisible();
expect(get).toHaveBeenCalledTimes(1);
first.unmount(); mount();
expect(await screen.findByRole('table', { name: translations.en.calendarMonth })).toBeVisible();
});
test('locale changes translate controls without repeating the read or translating source text', async () => {
mount(); await screen.findByRole('link', { name: /Recorded teacher role/ });
act(() => window.dispatchEvent(new StorageEvent('storage', { key: 'uiLanguage', newValue: 'nb' })));
expect(screen.getByRole('button', { name: translations.nb.calendarMonth })).toBeVisible();
expect(screen.getByRole('link', { name: /Recorded teacher role/ })).toBeVisible();
expect(get).toHaveBeenCalledTimes(1);
});
test('failed read offers bounded retry and never claims an empty successful calendar', async () => {
get.mockRejectedValueOnce(new Error('offline'));
mount(); await screen.findByRole('alert');
expect(screen.queryByText(translations.en.calendarAgendaEmpty)).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: translations.en.retry }));
expect(await screen.findByRole('link', { name: /Recorded teacher role/ })).toBeVisible();
expect(get).toHaveBeenCalledTimes(2);
});
test('invalid and unknown-kind fixture data is not turned into guessed events', async () => {
get.mockResolvedValue({ data: [{ ...row, date: `${month}-99` }, { ...row, kind: 'interview' }] });
mount(); expect(await screen.findByRole('alert')).toBeVisible();
expect(screen.queryByText(translations.en.calendarAgendaEmpty)).not.toBeInTheDocument();
expect(screen.queryByRole('link', { name: /Recorded teacher role/ })).not.toBeInTheDocument();
});
test('month navigation requests only the selected calendar range', async () => {
mount(); await screen.findByRole('link', { name: /Recorded teacher role/ });
get.mockResolvedValue({ data: [] });
fireEvent.click(screen.getByRole('button', { name: translations.en.calendarNextMonth }));
const next = new Date(now.getFullYear(), now.getMonth() + 1, 1);
const prefix = `${next.getFullYear()}-${String(next.getMonth() + 1).padStart(2, '0')}`;
expect(get).toHaveBeenLastCalledWith('/calendar/agenda', { params: { from: `${prefix}-01`, to: `${prefix}-${new Date(next.getFullYear(), next.getMonth() + 1, 0).getDate()}` } });
expect(await screen.findByText(translations.en.calendarAgendaEmpty)).toBeVisible();
});
test('view preference is scoped to the signed-in owner', async () => {
setAuthUserKey('calendar-owner-one', false);
const first = mount(); await screen.findByRole('link', { name: /Recorded teacher role/ });
fireEvent.click(screen.getByRole('button', { name: translations.en.calendarMonth })); first.unmount();
setAuthUserKey('calendar-owner-two', false); mount();
await screen.findByRole('link', { name: /Recorded teacher role/ });
expect(screen.queryByRole('table', { name: translations.en.calendarMonth })).not.toBeInTheDocument();
});
@@ -0,0 +1,79 @@
import React, { useCallback, useMemo, useState } from 'react';
import { Link as RouterLink } from 'react-router-dom';
import { Box, Button, Chip, Paper, Stack, Table, TableBody, TableCell, TableHead, TableRow, TextField, ToggleButton, ToggleButtonGroup, Typography } from '@mui/material';
import { api } from '../api';
import { getUserScopedStorageKey } from '../auth';
import { useI18n } from '../i18n/I18nProvider';
import { useViewResource } from '../hooks/useViewResource';
import { PageHeader } from '../layout/PageLayout';
import ViewStateNotice from './ViewStateNotice';
type CalendarItem = { jobApplicationId: number; jobTitle: string; companyName: string | null; kind: 'deadline' | 'follow-up'; date: string; recordedAt: string };
const monthValue = (date: Date) => `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`;
function calendarDate(value: string) {
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return null;
const date = new Date(`${value}T12:00:00Z`);
return Number.isFinite(date.getTime()) && date.toISOString().slice(0, 10) === value ? date : null;
}
export default function CalendarAgenda() {
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'; } });
const [month, setMonth] = useState(() => monthValue(new Date()));
const [selectedDate, setSelectedDate] = useState<string | null>(null);
const now = new Date(Date.now());
const today = `${monthValue(now)}-${String(now.getDate()).padStart(2, '0')}`;
const [year, monthNumber] = month.split('-').map(Number);
const days = new Date(year, monthNumber, 0).getDate();
const from = `${month}-01`, to = `${month}-${days}`;
const load = useCallback(async () => {
const response = await api.get<CalendarItem[]>('/calendar/agenda', { params: { from, to } });
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 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(() => {
const result = new Map<string, CalendarItem[]>();
resource.data.forEach(item => result.set(item.date, [...(result.get(item.date) ?? []), item]));
return new Map([...result.entries()].sort(([a], [b]) => Number(a < today) - Number(b < today) || a.localeCompare(b)));
}, [resource.data, today]);
const changeMonth = (value: string) => { if (/^[1-9]\d{3}-(0[1-9]|1[0-2])$/.test(value) && Number(value.slice(0, 4)) <= 9998) { setMonth(value); setSelectedDate(null); } };
const offsetMonth = (offset: number) => changeMonth(monthValue(new Date(year, monthNumber - 1 + offset, 1)));
const firstWeekday = (calendarDate(from)!.getUTCDay() + 6) % 7;
const cells = Array.from({ length: Math.ceil((firstWeekday + days) / 7) * 7 }, (_, index) => {
const day = index - firstWeekday + 1; return day > 0 && day <= days ? `${month}-${String(day).padStart(2, '0')}` : null;
});
const eventList = (date: string, items: CalendarItem[]) => <Stack key={date} component="section" aria-label={formatDate(date)} spacing={1}>
<Typography component="h3" variant="sectionTitle">{formatDate(date)}</Typography>
{items.map(item => <Paper key={`${item.jobApplicationId}-${item.kind}`} component="article" variant="outlined" sx={{ p: 1.5, minWidth: 0, overflowWrap: 'anywhere' }}>
<Stack direction="row" spacing={1} alignItems="center" useFlexGap flexWrap="wrap"><Chip size="small" label={t(item.kind === 'deadline' ? 'calendarDeadline' : 'calendarFollowUp')} /><Typography variant="caption" component="time" dateTime={item.recordedAt}>{formatDate(item.date)}</Typography></Stack>
<Button component={RouterLink} to={`/jobs/${item.jobApplicationId}`} sx={{ mt: 0.5, textAlign: 'left', whiteSpace: 'normal', maxWidth: '100%' }}>{item.jobTitle}{item.companyName ? ` · ${item.companyName}` : ''}</Button>
</Paper>)}
</Stack>;
return <Stack spacing={2} sx={{ minWidth: 0 }}>
<PageHeader level="h2" title={t('calendarAgendaTitle')} description={t('calendarDateScope')} />
<Stack direction="row" gap={1} alignItems="center" useFlexGap flexWrap="wrap">
<ToggleButtonGroup exclusive value={view} size="small" aria-label={t('calendarView')} onChange={(_, next: 'agenda' | 'month' | null) => { if (!next) return; setView(next); try { localStorage.setItem(preferenceKey, next); } catch { /* View remains usable without storage. */ } }}>
<ToggleButton value="agenda">{t('calendarAgenda')}</ToggleButton><ToggleButton value="month">{t('calendarMonth')}</ToggleButton>
</ToggleButtonGroup>
<Button size="small" disabled={month === '1000-01'} onClick={() => offsetMonth(-1)} aria-label={t('calendarPreviousMonth')}>‹</Button>
<TextField type="month" size="small" label={t('calendarSelectedMonth')} value={month} onChange={event => changeMonth(event.target.value)} slotProps={{ inputLabel: { shrink: true }, htmlInput: { min: '1000-01', max: '9998-12' } }} sx={{ minWidth: 150, maxWidth: '100%' }} />
<Button size="small" disabled={month === '9998-12'} onClick={() => offsetMonth(1)} aria-label={t('calendarNextMonth')}>›</Button>
</Stack>
<Box role="region" aria-label={t('calendarAgendaTitle')} aria-busy={resource.loading || resource.refreshing} sx={{ minWidth: 0 }}>
<ViewStateNotice compact title={t('calendarAgendaFailed')} loading={resource.loading || resource.refreshing} error={resource.error ? { ...resource.error, message: t('calendarAgendaFailed') } : null} onRetry={() => void resource.reload()} />
{!resource.loading && !resource.refreshing && !resource.error && <Stack spacing={2}>
{view === 'month' && <Table size="small" aria-label={t('calendarMonth')} sx={{ tableLayout: 'fixed', '& th, & td': { p: 0.25, textAlign: 'center', verticalAlign: 'top' } }}>
<TableHead><TableRow>{Array.from({ length: 7 }, (_, index) => <TableCell component="th" scope="col" key={index} abbr={new Intl.DateTimeFormat(locale, { weekday: 'long', timeZone: 'UTC' }).format(new Date(Date.UTC(2026, 10, 2 + index)))}>{new Intl.DateTimeFormat(locale, { weekday: 'narrow', timeZone: 'UTC' }).format(new Date(Date.UTC(2026, 10, 2 + index)))}</TableCell>)}</TableRow></TableHead>
<TableBody>{Array.from({ length: cells.length / 7 }, (_, week) => <TableRow key={week}>{cells.slice(week * 7, week * 7 + 7).map((date, day) => <TableCell key={day}>{date && <Button size="small" aria-pressed={selectedDate === date} aria-label={t('calendarDayEvents', { date: formatDate(date), count: byDay.get(date)?.length ?? 0 })} onClick={() => setSelectedDate(date)} variant={selectedDate === date ? 'contained' : 'text'} sx={{ minWidth: 0, width: '100%', minHeight: 44, px: 0, flexDirection: 'column' }}>{new Intl.NumberFormat(locale).format(Number(date.slice(-2)))}{byDay.has(date) && <Box component="span" aria-hidden="true" sx={{ width: 5, height: 5, bgcolor: 'currentColor', borderRadius: '50%' }} />}</Button>}</TableCell>)}</TableRow>)}</TableBody>
</Table>}
{view === 'agenda' ? [...byDay].map(([date, items]) => eventList(date, items)) : selectedDate ? (byDay.has(selectedDate) ? eventList(selectedDate, byDay.get(selectedDate)!) : <Typography>{t('calendarDayEmpty')}</Typography>) : <Typography>{t('calendarSelectDay')}</Typography>}
{!resource.data.length && <Typography color="text.secondary">{t('calendarAgendaEmpty')}</Typography>}
</Stack>}
</Box>
</Stack>;
}
+26
View File
@@ -2,6 +2,19 @@ export type UiLanguage = "en" | "nb";
export const translations = {
en: {
calendarAgendaTitle: "Calendar",
calendarDateScope: "Recorded application deadlines and follow-up dates, grouped by their stored calendar date. Times and original timezones are not inferred. Interview appointments are not stored here; undated workflow tasks remain in Reminders below.",
calendarView: "Calendar view",
calendarAgenda: "Agenda",
calendarMonth: "Month",
calendarPreviousMonth: "Previous month",
calendarNextMonth: "Next month",
calendarSelectedMonth: "Selected month",
calendarAgendaFailed: "Could not load recorded calendar dates.",
calendarAgendaEmpty: "No recorded deadlines or follow-up dates in this month.",
calendarDayEvents: "{date}, {count} recorded dates",
calendarDayEmpty: "No recorded dates on this day.",
calendarSelectDay: "Select a day to see linked applications.",
processingTitle: "Processing monitor",
processingSubtitle: "Document and AI operations, their owners and safe processing history.",
processingDetails: "Processing details",
@@ -3178,6 +3191,19 @@ export const translations = {
coverAiReject: "Reject",
},
nb: {
calendarAgendaTitle: "Kalender",
calendarDateScope: "Registrerte søknadsfrister og oppfølgingsdatoer, gruppert etter lagret kalenderdato. Klokkeslett og opprinnelige tidssoner utledes ikke. Intervjuavtaler lagres ikke her; oppgaver uten dato vises i Påminnelser nedenfor.",
calendarView: "Kalendervisning",
calendarAgenda: "Agenda",
calendarMonth: "Måned",
calendarPreviousMonth: "Forrige måned",
calendarNextMonth: "Neste måned",
calendarSelectedMonth: "Valgt måned",
calendarAgendaFailed: "Kunne ikke laste registrerte kalenderdatoer.",
calendarAgendaEmpty: "Ingen registrerte søknadsfrister eller oppfølgingsdatoer denne måneden.",
calendarDayEvents: "{date}, {count} registrerte datoer",
calendarDayEmpty: "Ingen registrerte datoer denne dagen.",
calendarSelectDay: "Velg en dag for å se tilknyttede søknader.",
processingTitle: "Behandlingsovervåking",
processingSubtitle: "Dokument- og KI-oppgaver, eiere og trygg behandlingshistorikk.",
processingDetails: "Behandlingsdetaljer",
@@ -0,0 +1,7 @@
import { Stack } from '@mui/material';
import CalendarAgenda from '../components/CalendarAgenda';
import RemindersView from '../components/RemindersView';
export default function CalendarPage() {
return <Stack spacing={3} sx={{ minWidth: 0 }}><CalendarAgenda /><RemindersView /></Stack>;
}