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
@@ -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>;
}