feat(ui): qualify calendar documents and correspondence workflows

This commit is contained in:
cesnimda
2026-11-03 15:11:53 +01:00
parent 194e01ed7f
commit fba80ef346
158 changed files with 1961 additions and 14 deletions
@@ -43,6 +43,7 @@ try {
};
await page.goto('http://localhost:4338/reminders');
const calendar = page.getByRole('region', { name: t('calendarAgendaTitle'), exact: true }); await calendar.getByRole('link', { name: /Electrical engineer/ }).waitFor();
await expect(page.getByRole('combobox', { name: new RegExp(`^${t('calendarSelectedMonth')}`) })).toContainText(new Intl.DateTimeFormat(language === 'nb' ? 'nb-NO' : 'en-GB', { month: 'long', year: 'numeric', timeZone: 'UTC' }).format(new Date(`${month}-01T12:00:00Z`)));
await expect(page.getByRole('button', { name: t('calendarAgenda'), exact: true })).toHaveAttribute('aria-pressed', 'true');
assert.equal(await calendar.getByRole('link').count(), 3); assert.equal(await calendar.locator('time').first().getAttribute('datetime'), events[0].recordedAt); await scan('agenda');
await page.getByRole('button', { name: t('calendarMonth'), exact: true }).click();
@@ -0,0 +1,74 @@
// Built application, synthetic document/mail/provider fixtures; no delivery or real storage.
import { chromium, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
import ts from 'typescript';
import fs from 'node:fs';
import path from 'node:path';
import assert from 'node:assert/strict';
const compiled = ts.transpileModule(fs.readFileSync(new URL('../src/i18n/translations.ts', import.meta.url), 'utf8'), { compilerOptions: { module: ts.ModuleKind.ESNext } }).outputText;
const { translations } = await import(`data:text/javascript;base64,${Buffer.from(compiled).toString('base64')}`);
const out = path.resolve(import.meta.dirname, '../../docs/ui-modernisation/evidence/documents-inbox/browser'); fs.mkdirSync(out, { recursive: true });
const report = { screens: [], journeys: [], errors: [], unexpectedWrites: [], mockedWrites: [], unavailableReads: [], blockedExternal: [] };
const browser = await chromium.launch();
try {
for (const width of (process.env.A3_PILOT ? [320] : [320, 390, 768, 1440])) for (const language of (process.env.A3_PILOT ? ['en'] : ['en', 'nb'])) for (const mode of (process.env.A3_PILOT ? ['light'] : ['light', 'dark'])) {
console.log(`Checking ${width}/${language}/${mode}`);
const t = key => { assert.ok(translations[language][key], key); return translations[language][key]; };
const context = await browser.newContext({ viewport: { width, height: 1000 }, reducedMotion: 'reduce', acceptDownloads: true }); const page = await context.newPage();
await page.addInitScript(({ language, mode }) => { localStorage.setItem('uiLanguage', language); localStorage.setItem('jobtracker.themeMode', mode); }, { language, mode });
page.on('pageerror', error => report.errors.push(error.message));
const document = { id: 'attachment:9', resourceId: '9', type: 'attachment', name: 'First aid certificate.txt', state: 'current', visibility: 'private', jobApplicationId: 42, jobTitle: 'Primary teacher', companyName: 'Synthetic school', jobIsDeleted: false, recordedAt: '2026-02-12T09:15:00.1234567', dateKind: 'uploaded-local', version: null, historyCount: null, attachmentCount: null, cvVariantId: null, fileType: 'text/plain', fileSize: 2048, purpose: 'certificate', useForAi: false, sourceHref: '/jobs/42?section=documents', downloadHref: '/api/attachments/download/9', publicHref: null, hasCurrentContent: true };
const cv = { ...document, id: 'cv:3', resourceId: '3', type: 'cv', name: 'Teacher CV', dateKind: 'updated-utc', recordedAt: '2026-11-03T10:00:00Z', visibility: 'private', version: 4, sourceHref: '/career/builder/3', downloadHref: null };
const mail = { id: 1, jobApplicationId: 42, companyName: 'Synthetic school', jobTitle: 'Primary teacher', from: 'Recruiter', direction: 'inbound', subject: 'Interview invitation', channel: 'Email', date: '2026-11-03T10:00:00Z', contentPreview: 'Recorded invitation', externalThreadId: 'thread-1', externalMessageId: 'message-1', provider: 'gmail', externalFrom: 'recruiter@example.test', externalTo: 'alex@example.test', labelCount: 1, attachmentCount: 0 };
let draft = null, sends = 0, documentReads = 0, mailReads = 0;
await page.route('**/*', route => {
const req = route.request(), url = new URL(req.url()), p = url.pathname;
if (!['localhost', '127.0.0.1'].includes(url.hostname)) { report.blockedExternal.push(url.origin); return route.abort(); }
if (!p.startsWith('/api/')) return route.continue();
const answer = data => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(data) });
if (req.method() === 'POST' && p === '/api/email/drafts') {
const body = req.postDataJSON(); assert.equal(body.jobApplicationId, 42); assert.equal(body.bodyText, 'Thank you. Please confirm the available interview times.');
draft = { ...body, id: '00000000-0000-4000-8000-000000000101', revision: 4, clientRequestId: '00000000-0000-4000-8000-000000000202', createdAtUtc: '2026-11-03T10:00:00Z', updatedAtUtc: '2026-11-03T10:00:00Z' };
report.mockedWrites.push({ width, language, mode, action: 'saveDraft' }); return answer(draft);
}
if (req.method() === 'POST' && p === '/api/email/send') {
const body = req.postDataJSON(); assert.equal(body.confirmed, true); assert.equal(body.clientRequestId, draft.clientRequestId); assert.equal(body.bodyText, draft.bodyText); sends++;
report.mockedWrites.push({ width, language, mode, action: 'confirmedSendFixtureUncertain' }); return answer({ attemptId: body.clientRequestId, status: 'uncertain', duplicate: false, failureCategory: 'controlled-transport-interruption' });
}
if (req.method() !== 'GET') { report.unexpectedWrites.push(p); return route.abort(); }
if (p === '/api/attachments/download/9') return route.fulfill({ status: 200, contentType: 'text/plain', body: 'Controlled certificate fixture' });
if (p === '/api/documents') { documentReads++; const rows = [document, cv].filter(item => !url.searchParams.get('q') || item.name.toLowerCase().includes(url.searchParams.get('q').toLowerCase())); return answer({ items: rows, page: 1, pageSize: 25, total: rows.length, totalPages: 1 }); }
if (p === '/api/correspondence/page') { mailReads++; return answer({ items: [mail], page: 1, pageSize: 50, total: 1, totalPages: 1 }); }
const fixtures = {
'/api/auth/config': { requireAuth: true }, '/api/auth/me': { id: 'a3-mail-owner', sessionContext: 'a3-mail-session', provider: 'local', firstName: 'Alex', displayName: 'Alex Sample', roles: [], uiLanguage: language === 'nb' ? 'nb-NO' : 'en', aiEnabled: false }, '/api/companies': [], '/api/jobapplications/reminders': [], '/api/notifications/unread-count': { count: 0 },
'/api/email/providers': [{ provider: 'gmail', displayName: 'Gmail', connected: true, address: 'alex@example.test', canRead: true, canSend: true }], '/api/email/drafts': draft ? [draft] : [], '/api/jobapplications/choices': [{ id: 42, jobTitle: 'Primary teacher', companyName: 'Synthetic school' }],
'/api/email/message': { id: 'message-1', threadId: 'thread-1', subject: 'Interview invitation', from: 'recruiter@example.test', to: 'alex@example.test', date: mail.date, snippet: 'Recorded invitation', bodyText: 'Please choose an interview time. <script>inert text</script>', labels: ['INBOX'], attachments: [] },
};
if (Object.hasOwn(fixtures, p)) return answer(fixtures[p]); report.unavailableReads.push(p); return route.fulfill({ status: 404, contentType: 'application/json', body: '{}' });
});
const scan = async state => {
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > innerWidth + 1), false, state);
const axe = await new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa', 'wcag21aa', 'wcag22aa']).analyze(); report.screens.push({ width, language, mode, state, violations: axe.violations }); assert.deepEqual(axe.violations, []);
await page.evaluate(() => window.scrollTo(0, 0)); await page.screenshot({ path: path.join(out, `${state}-${language}-${mode}-${width}.png`), fullPage: true });
};
await page.goto('http://localhost:4338/documents');
const docs = page.getByRole('region', { name: t('documentsYourDocuments'), exact: true }); await docs.getByText(document.name, { exact: true }).waitFor(); await scan('documents');
const reads = documentReads; await page.getByRole('button', { name: t('documentsJumpResults'), exact: true }).press('Enter'); assert.equal(await docs.evaluate(node => node === window.document.activeElement), true);
await docs.getByRole('button', { name: t('documentsReturnFilters'), exact: true }).click(); const search = page.getByRole('textbox', { name: t('documentsSearch'), exact: true }); await expect(search).toBeFocused(); assert.equal(documentReads, reads);
await search.fill('certificate'); await expect(docs.getByText(cv.name, { exact: true })).toHaveCount(0); await docs.getByText(document.name, { exact: true }).waitFor();
const download = page.waitForEvent('download'); await docs.getByRole('button', { name: t('documentsDownloadAttachment'), exact: true }).click(); const file = await download; assert.equal(file.suggestedFilename(), document.name); assert.equal(fs.readFileSync(await file.path(), 'utf8'), 'Controlled certificate fixture');
assert.equal(await docs.getByRole('link', { name: t('documentsOpenSource'), exact: true }).getAttribute('href'), document.sourceHref); assert.equal(await docs.locator('time').getAttribute('datetime'), document.recordedAt); await scan('document-filtered');
await page.goto('http://localhost:4338/correspondence'); const messages = page.getByRole('region', { name: t('emailInboxLinkedMessages'), exact: true }); await messages.getByText(mail.subject, { exact: true }).waitFor(); await scan('inbox');
const initialMailReads = mailReads; await page.getByRole('button', { name: t('emailInboxJumpMessages'), exact: true }).press('Enter'); await expect(messages).toBeFocused(); await messages.getByRole('button', { name: t('emailInboxReturnFilters'), exact: true }).click(); await expect(page.getByRole('textbox', { name: t('correspondenceSearch'), exact: true })).toBeFocused(); assert.equal(mailReads, initialMailReads);
await messages.getByRole('button', { name: t('emailInboxViewMessage'), exact: true }).click(); await messages.getByText('Please choose an interview time. <script>inert text</script>', { exact: true }).waitFor(); await scan('message');
await messages.getByRole('button', { name: t('emailInboxReply'), exact: true }).click(); const compose = page.getByRole('region', { name: t('emailInboxDraftTitle'), exact: true });
await compose.getByRole('textbox', { name: t('emailInboxRecipient'), exact: true }).fill('recruiter@example.test'); await compose.getByRole('textbox', { name: t('emailInboxMessage'), exact: true }).fill('Thank you. Please confirm the available interview times.');
await compose.getByRole('button', { name: t('emailInboxSaveDraft'), exact: true }).click(); await expect.poll(() => draft?.revision).toBe(4);
await compose.getByRole('button', { name: t('emailInboxReviewSend'), exact: true }).click(); const dialog = page.getByRole('dialog', { name: t('emailInboxConfirmSendTitle'), exact: true }); await dialog.waitFor(); assert.equal(sends, 0); await scan('send-confirmation');
await dialog.getByRole('button', { name: t('emailInboxSendEmail'), exact: true }).click(); await compose.getByText(t('emailInboxDeliveryUncertain'), { exact: true }).waitFor(); await expect(compose.getByRole('button', { name: t('emailInboxReviewSend'), exact: true })).toBeDisabled();
assert.equal(sends, 1); await expect(compose.getByRole('textbox', { name: t('emailInboxMessage'), exact: true })).toHaveValue(draft.bodyText); await scan('uncertain-send');
report.journeys.push({ width, language, mode, documentFilterFocusDownloadSource: 'PASS', inboxFocusPlainTextReplySaveExplicitConfirmUncertainNoRetry: 'PASS' }); await context.close();
}
assert.equal(report.screens.length, report.journeys.length * 6); assert.equal(report.mockedWrites.length, report.journeys.length * 2); assert.deepEqual(report.errors, []); assert.deepEqual(report.unexpectedWrites, []); assert.ok(report.blockedExternal.every(origin => origin === 'https://fonts.googleapis.com'));
console.log(`PASS:${report.journeys.length} document/mail journeys,${report.screens.length} axe scans/captures,${report.mockedWrites.length} fixture-only save/send requests.`);
} finally { fs.writeFileSync(path.join(out, process.env.A3_PILOT ? 'PILOT.json' : 'BROWSER.json'), JSON.stringify({ ...report, limits: ['Synthetic metadata/binary/mail/provider fixtures; not live storage, mailbox, OAuth or delivery.', 'Browser covers draft save, explicit confirmation and controlled uncertain outcome only; other recovery/conflict paths remain targeted tests.', 'No real sending, native device, final integrated security or release acceptance.'] }, null, 2)); await browser.close(); }
@@ -49,6 +49,7 @@ test('locale changes translate controls without repeating the read or translatin
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('combobox', { name: translations.nb.calendarSelectedMonth })).toHaveTextContent(new Intl.DateTimeFormat('nb-NO', { month: 'long', year: 'numeric', timeZone: 'UTC' }).format(new Date(`${month}-01T12:00:00Z`)));
expect(screen.getByRole('link', { name: /Recorded teacher role/ })).toBeVisible();
expect(get).toHaveBeenCalledTimes(1);
});
@@ -1,6 +1,6 @@
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 { Box, Button, Chip, MenuItem, 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';
@@ -61,7 +61,9 @@ export default function CalendarAgenda() {
<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%' }} />
<TextField select size="small" label={t('calendarSelectedMonth')} value={month} onChange={event => changeMonth(event.target.value)} sx={{ minWidth: 170, maxWidth: '100%' }}>
{Array.from({ length: 25 }, (_, index) => monthValue(new Date(year, monthNumber - 1 + index - 12, 1))).filter(value => Number(value.slice(0, 4)) >= 1000 && Number(value.slice(0, 4)) <= 9998).map(value => <MenuItem key={value} value={value}>{new Intl.DateTimeFormat(locale, { month: 'long', year: 'numeric', timeZone: 'UTC' }).format(calendarDate(`${value}-01`)!)}</MenuItem>)}
</TextField>
<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 }}>
@@ -118,6 +118,17 @@ function renderPage(initialPath = '/') {
}
describe('CorrespondenceInboxPage', () => {
test.each(['en', 'nb'] as const)('%s inbox filters and messages support request-free keyboard navigation', async language => {
localStorage.setItem('uiLanguage', language); renderPage();
await screen.findByText('Interview invite', { exact: true });
const count = mockedApi.get.mock.calls.length;
fireEvent.click(screen.getByRole('button', { name: copy('emailInboxJumpMessages', {}, language) }));
expect(screen.getByRole('region', { name: copy('emailInboxLinkedMessages', {}, language) })).toHaveFocus();
fireEvent.click(screen.getByRole('button', { name: copy('emailInboxReturnFilters', {}, language) }));
expect(screen.getByRole('textbox', { name: copy('correspondenceSearch', {}, language) })).toHaveFocus();
expect(mockedApi.get).toHaveBeenCalledTimes(count);
expect(mockedApi.post).not.toHaveBeenCalled(); expect(mockedApi.put).not.toHaveBeenCalled(); expect(mockedApi.delete).not.toHaveBeenCalled();
});
test('[P7-11 inbox] resumed draft fallback metadata follows locale without mutating stored prose or refetching', async () => {
const original = mockedApi.get.getMockImplementation();
mockedApi.get.mockImplementation((url: string, config?: any) => {
@@ -38,6 +38,27 @@ function deferred<T>() {
return { promise, resolve, reject };
}
describe.each(['en', 'nb'] as const)('%s uploaded wall-clock dates', locale => {
test.each(['2026-02-30T09:15:00', '2026-02-12T24:15:00', '2026-02-12T09:61:00', '2026-02-12T09:15:00junk'])('does not invent a timestamp from %s', async recordedAt => {
localStorage.setItem('uiLanguage', locale);
mockedApi.get.mockResolvedValue({ data: page([item({ recordedAt, dateKind: 'uploaded-local' })]) } as never);
renderPage();
expect(await screen.findByText(copy('documentsDateUnknown', {}, locale))).toBeVisible();
expect(document.querySelector('time')).not.toBeInTheDocument();
});
test('preserves fractional-second wall-clock metadata without timezone conversion', async () => {
localStorage.setItem('uiLanguage', locale);
const recordedAt = '2026-02-12T09:15:00.1234567';
mockedApi.get.mockResolvedValue({ data: page([item({ recordedAt, dateKind: 'uploaded-local' })]) } as never);
renderPage();
await screen.findByText('Metadata CV ÆØÅ', { exact: true });
const time = document.querySelector('time');
expect(time).toHaveAttribute('datetime', recordedAt);
expect(time).toHaveTextContent(new Intl.DateTimeFormat(locale === 'nb' ? 'nb-NO' : 'en-GB', { dateStyle: 'medium', timeStyle: 'short', timeZone: 'UTC' }).format(new Date('2026-02-12T09:15:00Z')));
});
});
function Probe() {
const location = useLocation();
const navigate = useNavigate();
@@ -75,6 +96,17 @@ afterEach(() => {
jest.restoreAllMocks();
});
test.each(['en', 'nb'] as const)('%s document filters and results support request-free keyboard navigation', async language => {
localStorage.setItem('uiLanguage', language); renderPage();
await screen.findByText('Metadata CV ÆØÅ', { exact: true });
const count = mockedApi.get.mock.calls.length;
fireEvent.click(screen.getByRole('button', { name: copy('documentsJumpResults', {}, language) }));
expect(screen.getByRole('region', { name: copy('documentsYourDocuments', {}, language) })).toHaveFocus();
fireEvent.click(screen.getByRole('button', { name: copy('documentsReturnFilters', {}, language) }));
expect(screen.getByRole('textbox', { name: copy('documentsSearch', {}, language) })).toHaveFocus();
expect(mockedApi.get).toHaveBeenCalledTimes(count);
});
test('[P7-07 documents] validates URL filters and reads one metadata page with literal Nordic search', async () => {
renderPage('/documents?q=100%25_%21+%C3%86%C3%98%C3%85&page=bad&type=unknown&state=unknown&visibility=unknown&sort=unknown&jobApplicationId=-2&keep=1#context');
expect(await screen.findByText('Metadata CV ÆØÅ', { exact: true })).toBeVisible();
+12
View File
@@ -2,6 +2,12 @@ export type UiLanguage = "en" | "nb";
export const translations = {
en: {
documentsFilters: "Document filters",
documentsJumpResults: "Jump to documents",
documentsReturnFilters: "Return to document filters",
emailInboxFilters: "Message filters",
emailInboxJumpMessages: "Jump to messages",
emailInboxReturnFilters: "Return to message filters",
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",
@@ -3191,6 +3197,12 @@ export const translations = {
coverAiReject: "Reject",
},
nb: {
documentsFilters: "Dokumentfiltre",
documentsJumpResults: "Gå til dokumenter",
documentsReturnFilters: "Tilbake til dokumentfiltre",
emailInboxFilters: "Meldingsfiltre",
emailInboxJumpMessages: "Gå til meldinger",
emailInboxReturnFilters: "Tilbake til meldingsfiltre",
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",
@@ -154,6 +154,8 @@ export default function CorrespondenceInboxPage() {
const [providersLoading, setProvidersLoading] = useState(false);
const providerPending = useRef(false);
const [selectedMessageId, setSelectedMessageId] = useState<number | null>(null);
const messagesPanel = useRef<HTMLDivElement>(null);
const searchField = useRef<HTMLInputElement>(null);
const [messageDetail, setMessageDetail] = useState<EmailMessageDetail | null>(null);
const [detailLoading, setDetailLoading] = useState(false);
const [detailError, setDetailError] = useState<ViewResourceError | null>(null);
@@ -755,8 +757,8 @@ export default function CorrespondenceInboxPage() {
{reviewVisited ? <Box hidden={view !== "review"}><GmailReviewPage embedded active={view === "review"} /></Box> : null}
<Box ref={inboxPanel} role="group" tabIndex={-1} aria-label={t("emailInboxLinkedMessages")} hidden={view !== "inbox"}>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "2fr 1fr 1fr auto" }, gap: 1.25, mb: 2 }}>
<TextField label={t("correspondenceSearch")} value={query} disabled={!!unlinkingThreadId} onChange={(e) => updateFilters({ q: e.target.value, page: "1" })} placeholder={t("emailInboxSearchPlaceholder")} />
<Box component="aside" aria-label={t("emailInboxFilters")} sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "2fr 1fr 1fr auto" }, gap: 1.25, mb: 2 }}>
<TextField inputRef={searchField} label={t("correspondenceSearch")} value={query} disabled={!!unlinkingThreadId} onChange={(e) => updateFilters({ q: e.target.value, page: "1" })} placeholder={t("emailInboxSearchPlaceholder")} />
<FormControl fullWidth>
<InputLabel id="email-direction-label">{t("emailInboxDirection")}</InputLabel>
<Select labelId="email-direction-label" value={direction} disabled={!!unlinkingThreadId} label={t("emailInboxDirection")} onChange={(e) => updateFilters({ direction: String(e.target.value), page: "1" })}>
@@ -775,6 +777,7 @@ export default function CorrespondenceInboxPage() {
</Select>
</FormControl>
<Button variant="outlined" onClick={() => { inboxPanel.current?.focus({ preventScroll: true }); void load(); }} disabled={loading || !!unlinkingThreadId}>{loading ? t("loading") : t("refresh")}</Button>
<Button size="small" onClick={() => messagesPanel.current?.focus()} sx={{ justifySelf: "start", gridColumn: "1 / -1" }}>{t("emailInboxJumpMessages")}</Button>
</Box>
{draft ? (
@@ -816,6 +819,8 @@ export default function CorrespondenceInboxPage() {
</Paper>
) : null}
<Box ref={messagesPanel} role="region" aria-label={t("emailInboxLinkedMessages")} tabIndex={-1} aria-busy={loading} sx={{ minWidth: 0 }}>
<Button size="small" sx={{ mb: 1 }} disabled={!!unlinkingThreadId} onClick={() => searchField.current?.focus()}>{t("emailInboxReturnFilters")}</Button>
<ViewStateNotice compact loading={loading} title={t("emailInboxLoadFailed")} error={loadError ? { ...loadError, message: loadError.message || t("emailInboxLoadFailed") } : null} onRetry={() => { inboxPanel.current?.focus({ preventScroll: true }); return load(); }} />
<ViewStateNotice compact loading={!!unlinkingThreadId} title={t("emailInboxUnlinkFailed")} error={unlinkError ? { ...unlinkError, message: unlinkError.message || t("emailInboxUnlinkFailed") } : null} />
@@ -885,6 +890,7 @@ export default function CorrespondenceInboxPage() {
</Box>
) : null}
</Box>
</Box>
</PageSection>
);
}
+14 -4
View File
@@ -82,6 +82,7 @@ export default function DocumentsPage() {
const listController = useRef<AbortController | null>(null);
const mounted = useRef(true);
const resultsRef = useRef<HTMLDivElement>(null);
const searchRef = useRef<HTMLInputElement>(null);
const [downloadState, setDownloadState] = useState<DownloadState | null>(null);
const downloadController = useRef<AbortController | null>(null);
const downloadFocus = useRef<{ button: HTMLButtonElement; key: string } | null>(null);
@@ -214,9 +215,16 @@ export default function DocumentsPage() {
const label: TranslationKey = item.dateKind === "uploaded-local" ? "documentsUploadedLocal" : item.dateKind === "captured-utc" ? "documentsCaptured" : item.dateKind === "history-recorded-utc" ? "documentsHistoryRecorded" : "documentsUpdated";
let value: Date | null = null;
if (item.recordedAt && item.dateKind === "uploaded-local") {
const local = /^(\d{4})-(\d\d)-(\d\d)T(\d\d):(\d\d):(\d\d)/.exec(item.recordedAt);
const local = /^(\d{4})-(\d\d)-(\d\d)T(\d\d):(\d\d):(\d\d)(?:\.\d{1,7})?$/.exec(item.recordedAt);
// UTC is only a formatting carrier for the unzoned wall-clock components.
if (local) value = new Date(Date.UTC(+local[1], +local[2] - 1, +local[3], +local[4], +local[5], +local[6]));
if (local) {
const parts = local.slice(1).map(Number);
const candidate = new Date(0);
candidate.setUTCFullYear(parts[0], parts[1] - 1, parts[2]);
candidate.setUTCHours(parts[3], parts[4], parts[5], 0);
// Date normalises impossible fields; show unknown rather than inventing a date.
if ([candidate.getUTCFullYear(), candidate.getUTCMonth() + 1, candidate.getUTCDate(), candidate.getUTCHours(), candidate.getUTCMinutes(), candidate.getUTCSeconds()].every((part, index) => part === parts[index])) value = candidate;
}
} else if (item.recordedAt && ["updated-utc", "captured-utc", "history-recorded-utc"].includes(item.dateKind)) value = new Date(item.recordedAt);
if (!value || Number.isNaN(value.valueOf())) return <Typography variant="body2" color="text.secondary">{t("documentsDateUnknown")}</Typography>;
return <Box><Typography variant="caption" color="text.secondary">{t(label)}</Typography><Typography variant="body2" component="time" dateTime={item.recordedAt!} sx={{ display: "block" }}>{value.toLocaleString(locale, { dateStyle: "medium", timeStyle: "short", ...(item.dateKind === "uploaded-local" ? { timeZone: "UTC" } : {}) })}</Typography></Box>;
@@ -267,8 +275,8 @@ export default function DocumentsPage() {
return <Stack spacing={2} data-content-density="comfortable" sx={{ minWidth: 0 }}>
<PageHeader level="h2" title={t("documentsYourDocuments")} description={t("documentsIndexHelp")} actions={<Button variant="outlined" startIcon={<RefreshIcon />} onClick={refresh} disabled={loading} sx={buttonSx}>{t("documentsRefresh")}</Button>} />
<Stack spacing={2}>
<TextField fullWidth size="small" label={t("documentsSearch")} value={q} onChange={(event) => updateQuery({ q: event.target.value, page: "1" }, true)} slotProps={{ htmlInput: { maxLength: 200 } }} />
<Stack component="aside" aria-label={t("documentsFilters")} spacing={2}>
<TextField inputRef={searchRef} fullWidth size="small" label={t("documentsSearch")} value={q} onChange={(event) => updateQuery({ q: event.target.value, page: "1" }, true)} slotProps={{ htmlInput: { maxLength: 200 } }} />
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "minmax(0, 1fr)", sm: "repeat(2, minmax(0, 1fr))", lg: "repeat(3, minmax(0, 1fr))" }, gap: 2, minWidth: 0 }}>
{filter("type", "documentsType", type, types)}
{filter("state", "documentsState", state, states)}
@@ -278,8 +286,10 @@ export default function DocumentsPage() {
</Box>
{jobsError ? <ViewStateNotice compact title={t("documentsJobsLoadFailed")} error={{ ...jobsError, message: t("documentsJobsLoadFailed") }} onRetry={() => { resultsRef.current?.focus(); return loadJobs(); }} /> : null}
<Typography variant="caption" color="text.secondary">{t("documentsDateOrderHelp")}</Typography>
<Button size="small" sx={{ alignSelf: "flex-start" }} onClick={() => resultsRef.current?.focus()}>{t("documentsJumpResults")}</Button>
</Stack>
<Box ref={resultsRef} role="region" aria-label={t("documentsYourDocuments")} tabIndex={-1} aria-busy={loading || !current && !error} sx={{ minWidth: 0 }}>
<Button size="small" sx={{ mb: 1 }} onClick={() => searchRef.current?.focus()}>{t("documentsReturnFilters")}</Button>
<ViewStateNotice compact loading={loading} title={t("documentsLoadFailed")} error={error ? { ...error, message: t("documentsLoadFailed") } : null} onRetry={refresh} />
{current && result ? <>
<Typography role="status" variant="body2" color="text.secondary" sx={{ mb: 2 }}>{t("documentsResultCount", { shown: items.length, total: result.total })}</Typography>