diff --git a/docs/ui-modernisation/visual-correction/OFFER_DATE.md b/docs/ui-modernisation/visual-correction/OFFER_DATE.md new file mode 100644 index 00000000..3234d0e8 --- /dev/null +++ b/docs/ui-modernisation/visual-correction/OFFER_DATE.md @@ -0,0 +1,7 @@ +# Offer calendar-date display + +4 November 2026. V11 is reproduced by controlled string fixtures: a literal `2026-12-01` displayed as30 November in America/Los_Angeles. The offer fact formatter now selects UTC only for exact YYYY-MM-DD strings. Timestamp strings retain their existing local-time formatting; missing values retain the unknown marker. No stored value, API contract, timezone provenance, deadline precision, query, mutation or route changes. + +RED: two western-timezone EN/NB failures, four other timezone cases passed and nine existing cases filtered. GREEN: all15 offer comparison tests passed, including six EN/NB combinations for Los Angeles, Oslo and Kiritimati plus timestamp, missing-value, navigation, salary, retry and read-only behavior. TypeScript and scoped ESLint passed. Independent read-only review found no actionable findings and ran no checks. Public-auth browser evidence predates this unbuilt change and does not qualify it. + +Next: after sealing the public-auth export, build this fix and run affected rendered offer qualification. Rollback reverts the formatter and six added tests; no migration. This does not assert that deployed API records use literal date-only serialization, or resolve the separate original timezone/precision model gaps. diff --git a/docs/ui-modernisation/visual-correction/OFFER_DATE_RED.json b/docs/ui-modernisation/visual-correction/OFFER_DATE_RED.json new file mode 100644 index 00000000..86803703 --- /dev/null +++ b/docs/ui-modernisation/visual-correction/OFFER_DATE_RED.json @@ -0,0 +1 @@ +{"numFailedTestSuites":1,"numFailedTests":2,"numPassedTestSuites":0,"numPassedTests":4,"numPendingTestSuites":0,"numPendingTests":9,"numRuntimeErrorTestSuites":0,"numTodoTests":0,"numTotalTestSuites":1,"numTotalTests":15,"openHandles":[],"snapshot":{"added":0,"didUpdate":false,"failure":false,"filesAdded":0,"filesRemoved":0,"filesRemovedList":[],"filesUnmatched":0,"filesUpdated":0,"matched":0,"total":0,"unchecked":0,"uncheckedKeysByFile":[],"unmatched":0,"updated":0},"startTime":1793753186124,"success":false,"testResults":[{"assertionResults":[{"ancestorTitles":[],"duration":null,"failing":false,"failureDetails":[],"failureMessages":[],"fullName":"compares recorded offer facts without filling missing values","invocations":1,"location":null,"numPassingAsserts":0,"retryReasons":[],"startAt":1793753189800,"status":"pending","title":"compares recorded offer facts without filling missing values"},{"ancestorTitles":[],"duration":null,"failing":false,"failureDetails":[],"failureMessages":[],"fullName":"shows an honest empty state when there are no offers","invocations":1,"location":null,"numPassingAsserts":0,"retryReasons":[],"startAt":1793753189801,"status":"pending","title":"shows an honest empty state when there are no offers"},{"ancestorTitles":[],"duration":null,"failing":false,"failureDetails":[],"failureMessages":[],"fullName":"shows the first 25 of the real total in server application-date order without inventing the remaining offers","invocations":1,"location":null,"numPassingAsserts":0,"retryReasons":[],"startAt":1793753189801,"status":"pending","title":"shows the first 25 of the real total in server application-date order without inventing the remaining offers"},{"ancestorTitles":[],"duration":null,"failing":false,"failureDetails":[],"failureMessages":[],"fullName":"opens the full recorded offer source with the same supported filter and ordering","invocations":1,"location":null,"numPassingAsserts":0,"retryReasons":[],"startAt":1793753189801,"status":"pending","title":"opens the full recorded offer source with the same supported filter and ordering"},{"ancestorTitles":[],"duration":null,"failing":false,"failureDetails":[],"failureMessages":[],"fullName":"distinguishes held and failed reads from empty results and recovers through one explicit identical retry","invocations":1,"location":null,"numPassingAsserts":0,"retryReasons":[],"startAt":1793753189801,"status":"pending","title":"distinguishes held and failed reads from empty results and recovers through one explicit identical retry"},{"ancestorTitles":[],"duration":null,"failing":false,"failureDetails":[],"failureMessages":[],"fullName":"a live locale change translates loaded offer chrome and preserves recorded facts without another GET","invocations":1,"location":null,"numPassingAsserts":0,"retryReasons":[],"startAt":1793753189801,"status":"pending","title":"a live locale change translates loaded offer chrome and preserves recorded facts without another GET"},{"ancestorTitles":[],"duration":null,"failing":false,"failureDetails":[],"failureMessages":[],"fullName":"an existing read failure translates without refetching and Retry retains the same query","invocations":1,"location":null,"numPassingAsserts":0,"retryReasons":[],"startAt":1793753189801,"status":"pending","title":"an existing read failure translates without refetching and Retry retains the same query"},{"ancestorTitles":[],"duration":null,"failing":false,"failureDetails":[],"failureMessages":[],"fullName":"structured salaries follow UI locale rather than host locale while preserving zero, bounds, currency, unknown periods and free text","invocations":1,"location":null,"numPassingAsserts":0,"retryReasons":[],"startAt":1793753189801,"status":"pending","title":"structured salaries follow UI locale rather than host locale while preserving zero, bounds, currency, unknown periods and free text"},{"ancestorTitles":[],"duration":null,"failing":false,"failureDetails":[],"failureMessages":[],"fullName":"uses recorded applied/deadline/contact dates, keeps absent applied dates unknown, and opens the exact current application","invocations":1,"location":null,"numPassingAsserts":0,"retryReasons":[],"startAt":1793753189801,"status":"pending","title":"uses recorded applied/deadline/contact dates, keeps absent applied dates unknown, and opens the exact current application"},{"ancestorTitles":[],"duration":136,"failing":false,"failureDetails":[{"matcherResult":{"pass":false,"message":"expect(element).toHaveTextContent()\n\nExpected element to have text content:\n 12/1/2026\nReceived:\n Date Applied11/30/2026"}}],"failureMessages":["Error: expect(element).toHaveTextContent()\n\nExpected element to have text content:\n 12/1/2026\nReceived:\n Date Applied11/30/2026\n at toHaveTextContent (C:\\Users\\Connor\\.codex\\worktrees\\direct-roadmap-integration\\Job tracker\\job-tracker-ui\\src\\offer-comparison.test.tsx:253:62)"],"fullName":"keeps a literal calendar date while timestamps retain local semantics: en/America/Los_Angeles","invocations":1,"location":null,"numPassingAsserts":0,"retryReasons":[],"startAt":1793753189801,"status":"failed","title":"keeps a literal calendar date while timestamps retain local semantics: en/America/Los_Angeles"},{"ancestorTitles":[],"duration":41,"failing":false,"failureDetails":[{"matcherResult":{"pass":false,"message":"expect(element).toHaveTextContent()\n\nExpected element to have text content:\n 1.12.2026\nReceived:\n Søkt dato30.11.2026"}}],"failureMessages":["Error: expect(element).toHaveTextContent()\n\nExpected element to have text content:\n 1.12.2026\nReceived:\n Søkt dato30.11.2026\n at toHaveTextContent (C:\\Users\\Connor\\.codex\\worktrees\\direct-roadmap-integration\\Job tracker\\job-tracker-ui\\src\\offer-comparison.test.tsx:253:62)"],"fullName":"keeps a literal calendar date while timestamps retain local semantics: nb/America/Los_Angeles","invocations":1,"location":null,"numPassingAsserts":0,"retryReasons":[],"startAt":1793753189946,"status":"failed","title":"keeps a literal calendar date while timestamps retain local semantics: nb/America/Los_Angeles"},{"ancestorTitles":[],"duration":30,"failing":false,"failureDetails":[],"failureMessages":[],"fullName":"keeps a literal calendar date while timestamps retain local semantics: en/Europe/Oslo","invocations":1,"location":null,"numPassingAsserts":7,"retryReasons":[],"startAt":1793753189987,"status":"passed","title":"keeps a literal calendar date while timestamps retain local semantics: en/Europe/Oslo"},{"ancestorTitles":[],"duration":32,"failing":false,"failureDetails":[],"failureMessages":[],"fullName":"keeps a literal calendar date while timestamps retain local semantics: nb/Europe/Oslo","invocations":1,"location":null,"numPassingAsserts":7,"retryReasons":[],"startAt":1793753190017,"status":"passed","title":"keeps a literal calendar date while timestamps retain local semantics: nb/Europe/Oslo"},{"ancestorTitles":[],"duration":31,"failing":false,"failureDetails":[],"failureMessages":[],"fullName":"keeps a literal calendar date while timestamps retain local semantics: en/Pacific/Kiritimati","invocations":1,"location":null,"numPassingAsserts":7,"retryReasons":[],"startAt":1793753190049,"status":"passed","title":"keeps a literal calendar date while timestamps retain local semantics: en/Pacific/Kiritimati"},{"ancestorTitles":[],"duration":31,"failing":false,"failureDetails":[],"failureMessages":[],"fullName":"keeps a literal calendar date while timestamps retain local semantics: nb/Pacific/Kiritimati","invocations":1,"location":null,"numPassingAsserts":7,"retryReasons":[],"startAt":1793753190080,"status":"passed","title":"keeps a literal calendar date while timestamps retain local semantics: nb/Pacific/Kiritimati"}],"endTime":1793753190125,"message":" ● keeps a literal calendar date while timestamps retain local semantics: en/America/Los_Angeles\n\n expect(element).toHaveTextContent()\n\n Expected element to have text content:\n 12/1/2026\n Received:\n Date Applied11/30/2026\n\n 251 | const article = await screen.findByRole(\"article\", { name: item.jobTitle });\n 252 | const fact = (label: string) => within(article).getByText(label).parentElement!;\n > 253 | expect(fact(translations[language].jobTableDateApplied)).toHaveTextContent(language === \"nb\" ? \"1.12.2026\" : \"12/1/2026\");\n | ^\n 254 | const formatter = new Intl.DateTimeFormat(language === \"nb\" ? \"nb-NO\" : \"en\", { timeZone });\n 255 | expect(fact(translations[language].offerComparisonDecisionDeadline)).toHaveTextContent(formatter.format(new Date(item.deadline!)));\n 256 | expect(fact(translations[language].companiesNextContact)).toHaveTextContent(formatter.format(new Date(item.followUpAt!)));\n\n at toHaveTextContent (src/offer-comparison.test.tsx:253:62)\n\n ● keeps a literal calendar date while timestamps retain local semantics: nb/America/Los_Angeles\n\n expect(element).toHaveTextContent()\n\n Expected element to have text content:\n 1.12.2026\n Received:\n Søkt dato30.11.2026\n\n 251 | const article = await screen.findByRole(\"article\", { name: item.jobTitle });\n 252 | const fact = (label: string) => within(article).getByText(label).parentElement!;\n > 253 | expect(fact(translations[language].jobTableDateApplied)).toHaveTextContent(language === \"nb\" ? \"1.12.2026\" : \"12/1/2026\");\n | ^\n 254 | const formatter = new Intl.DateTimeFormat(language === \"nb\" ? \"nb-NO\" : \"en\", { timeZone });\n 255 | expect(fact(translations[language].offerComparisonDecisionDeadline)).toHaveTextContent(formatter.format(new Date(item.deadline!)));\n 256 | expect(fact(translations[language].companiesNextContact)).toHaveTextContent(formatter.format(new Date(item.followUpAt!)));\n\n at toHaveTextContent (src/offer-comparison.test.tsx:253:62)\n","name":"C:\\Users\\Connor\\.codex\\worktrees\\direct-roadmap-integration\\Job tracker\\job-tracker-ui\\src\\offer-comparison.test.tsx","startTime":1793753187105,"status":"failed","summary":""}],"wasInterrupted":false} diff --git a/docs/ui-modernisation/visual-correction/OFFER_DATE_TESTS.json b/docs/ui-modernisation/visual-correction/OFFER_DATE_TESTS.json new file mode 100644 index 00000000..64d55178 --- /dev/null +++ b/docs/ui-modernisation/visual-correction/OFFER_DATE_TESTS.json @@ -0,0 +1 @@ +{"numFailedTestSuites":0,"numFailedTests":0,"numPassedTestSuites":1,"numPassedTests":15,"numPendingTestSuites":0,"numPendingTests":0,"numRuntimeErrorTestSuites":0,"numTodoTests":0,"numTotalTestSuites":1,"numTotalTests":15,"openHandles":[],"snapshot":{"added":0,"didUpdate":false,"failure":false,"filesAdded":0,"filesRemoved":0,"filesRemovedList":[],"filesUnmatched":0,"filesUpdated":0,"matched":0,"total":0,"unchecked":0,"uncheckedKeysByFile":[],"unmatched":0,"updated":0},"startTime":1793753201945,"success":true,"testResults":[{"assertionResults":[{"ancestorTitles":[],"duration":113,"failing":false,"failureDetails":[],"failureMessages":[],"fullName":"compares recorded offer facts without filling missing values","invocations":1,"location":null,"numPassingAsserts":8,"retryReasons":[],"startAt":1793753205661,"status":"passed","title":"compares recorded offer facts without filling missing values"},{"ancestorTitles":[],"duration":31,"failing":false,"failureDetails":[],"failureMessages":[],"fullName":"shows an honest empty state when there are no offers","invocations":1,"location":null,"numPassingAsserts":4,"retryReasons":[],"startAt":1793753205775,"status":"passed","title":"shows an honest empty state when there are no offers"},{"ancestorTitles":[],"duration":315,"failing":false,"failureDetails":[],"failureMessages":[],"fullName":"shows the first 25 of the real total in server application-date order without inventing the remaining offers","invocations":1,"location":null,"numPassingAsserts":10,"retryReasons":[],"startAt":1793753205806,"status":"passed","title":"shows the first 25 of the real total in server application-date order without inventing the remaining offers"},{"ancestorTitles":[],"duration":59,"failing":false,"failureDetails":[],"failureMessages":[],"fullName":"opens the full recorded offer source with the same supported filter and ordering","invocations":1,"location":null,"numPassingAsserts":6,"retryReasons":[],"startAt":1793753206121,"status":"passed","title":"opens the full recorded offer source with the same supported filter and ordering"},{"ancestorTitles":[],"duration":57,"failing":false,"failureDetails":[],"failureMessages":[],"fullName":"distinguishes held and failed reads from empty results and recovers through one explicit identical retry","invocations":1,"location":null,"numPassingAsserts":16,"retryReasons":[],"startAt":1793753206180,"status":"passed","title":"distinguishes held and failed reads from empty results and recovers through one explicit identical retry"},{"ancestorTitles":[],"duration":44,"failing":false,"failureDetails":[],"failureMessages":[],"fullName":"a live locale change translates loaded offer chrome and preserves recorded facts without another GET","invocations":1,"location":null,"numPassingAsserts":9,"retryReasons":[],"startAt":1793753206237,"status":"passed","title":"a live locale change translates loaded offer chrome and preserves recorded facts without another GET"},{"ancestorTitles":[],"duration":61,"failing":false,"failureDetails":[],"failureMessages":[],"fullName":"an existing read failure translates without refetching and Retry retains the same query","invocations":1,"location":null,"numPassingAsserts":9,"retryReasons":[],"startAt":1793753206281,"status":"passed","title":"an existing read failure translates without refetching and Retry retains the same query"},{"ancestorTitles":[],"duration":159,"failing":false,"failureDetails":[],"failureMessages":[],"fullName":"structured salaries follow UI locale rather than host locale while preserving zero, bounds, currency, unknown periods and free text","invocations":1,"location":null,"numPassingAsserts":18,"retryReasons":[],"startAt":1793753206342,"status":"passed","title":"structured salaries follow UI locale rather than host locale while preserving zero, bounds, currency, unknown periods and free text"},{"ancestorTitles":[],"duration":76,"failing":false,"failureDetails":[],"failureMessages":[],"fullName":"uses recorded applied/deadline/contact dates, keeps absent applied dates unknown, and opens the exact current application","invocations":1,"location":null,"numPassingAsserts":11,"retryReasons":[],"startAt":1793753206502,"status":"passed","title":"uses recorded applied/deadline/contact dates, keeps absent applied dates unknown, and opens the exact current application"},{"ancestorTitles":[],"duration":31,"failing":false,"failureDetails":[],"failureMessages":[],"fullName":"keeps a literal calendar date while timestamps retain local semantics: en/America/Los_Angeles","invocations":1,"location":null,"numPassingAsserts":7,"retryReasons":[],"startAt":1793753206578,"status":"passed","title":"keeps a literal calendar date while timestamps retain local semantics: en/America/Los_Angeles"},{"ancestorTitles":[],"duration":31,"failing":false,"failureDetails":[],"failureMessages":[],"fullName":"keeps a literal calendar date while timestamps retain local semantics: nb/America/Los_Angeles","invocations":1,"location":null,"numPassingAsserts":7,"retryReasons":[],"startAt":1793753206610,"status":"passed","title":"keeps a literal calendar date while timestamps retain local semantics: nb/America/Los_Angeles"},{"ancestorTitles":[],"duration":15,"failing":false,"failureDetails":[],"failureMessages":[],"fullName":"keeps a literal calendar date while timestamps retain local semantics: en/Europe/Oslo","invocations":1,"location":null,"numPassingAsserts":7,"retryReasons":[],"startAt":1793753206641,"status":"passed","title":"keeps a literal calendar date while timestamps retain local semantics: en/Europe/Oslo"},{"ancestorTitles":[],"duration":27,"failing":false,"failureDetails":[],"failureMessages":[],"fullName":"keeps a literal calendar date while timestamps retain local semantics: nb/Europe/Oslo","invocations":1,"location":null,"numPassingAsserts":7,"retryReasons":[],"startAt":1793753206656,"status":"passed","title":"keeps a literal calendar date while timestamps retain local semantics: nb/Europe/Oslo"},{"ancestorTitles":[],"duration":35,"failing":false,"failureDetails":[],"failureMessages":[],"fullName":"keeps a literal calendar date while timestamps retain local semantics: en/Pacific/Kiritimati","invocations":1,"location":null,"numPassingAsserts":7,"retryReasons":[],"startAt":1793753206683,"status":"passed","title":"keeps a literal calendar date while timestamps retain local semantics: en/Pacific/Kiritimati"},{"ancestorTitles":[],"duration":32,"failing":false,"failureDetails":[],"failureMessages":[],"fullName":"keeps a literal calendar date while timestamps retain local semantics: nb/Pacific/Kiritimati","invocations":1,"location":null,"numPassingAsserts":7,"retryReasons":[],"startAt":1793753206718,"status":"passed","title":"keeps a literal calendar date while timestamps retain local semantics: nb/Pacific/Kiritimati"}],"endTime":1793753206751,"message":"","name":"C:\\Users\\Connor\\.codex\\worktrees\\direct-roadmap-integration\\Job tracker\\job-tracker-ui\\src\\offer-comparison.test.tsx","startTime":1793753202952,"status":"passed","summary":""}],"wasInterrupted":false} diff --git a/job-tracker-ui/scripts/verify-a3-secondary.mjs b/job-tracker-ui/scripts/verify-a3-secondary.mjs index 4bad0212..ef17d19d 100644 --- a/job-tracker-ui/scripts/verify-a3-secondary.mjs +++ b/job-tracker-ui/scripts/verify-a3-secondary.mjs @@ -8,20 +8,22 @@ import path from 'node:path'; 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 visualMatrix = Boolean(process.env.A3_VISUAL_MATRIX); -const out = path.resolve(import.meta.dirname, visualMatrix ? '../../docs/ui-modernisation/visual-correction/evidence/secondary-after' : '../../docs/ui-modernisation/evidence/secondary/browser'); +const offerDates = process.env.A3_OFFER_DATES === '1'; +const out = path.resolve(import.meta.dirname, offerDates ? '../../docs/ui-modernisation/visual-correction/evidence/offer-dates' : visualMatrix ? '../../docs/ui-modernisation/visual-correction/evidence/secondary-after' : '../../docs/ui-modernisation/evidence/secondary/browser'); fs.mkdirSync(out, { recursive: true }); const report = { screens: [], journeys: [], errors: [], writes: [], unavailableReads: [], blockedExternal: [] }; const browser = await chromium.launch(); try { - const configurations = process.env.A3_PILOT ? [[320, 'en', 'light']] : (visualMatrix ? [320, 360, 390, 430, 768, 1024, 1280, 1440, 1920] : [320, 390, 768, 1440]).flatMap(width => ['en', 'nb'].flatMap(language => ['light', 'dark'].map(mode => [width, language, mode]))); - for (const [width, language, mode] of configurations) { - console.log(`Checking ${width}/${language}/${mode}`); + const configurations = offerDates ? ['America/Los_Angeles', 'Europe/Oslo', 'Pacific/Kiritimati'].flatMap(timeZone => ['en', 'nb'].map(language => [390, language, 'light', timeZone])) : process.env.A3_PILOT ? [[320, 'en', 'light']] : (visualMatrix ? [320, 360, 390, 430, 768, 1024, 1280, 1440, 1920] : [320, 390, 768, 1440]).flatMap(width => ['en', 'nb'].flatMap(language => ['light', 'dark'].map(mode => [width, language, mode]))); + for (const [width, language, mode, timeZone] of configurations) { + console.log(`Checking ${width}/${language}/${mode}/${timeZone ?? 'host'}`); const t = key => { assert.ok(translations[language][key], key); return translations[language][key]; }; - const context = await browser.newContext({ viewport: { width, height: 1000 }, reducedMotion: 'reduce' }); + const context = await browser.newContext({ viewport: { width, height: 1000 }, reducedMotion: 'reduce', ...(timeZone ? { timezoneId: timeZone } : {}) }); const page = await context.newPage(); page.on('pageerror', error => report.errors.push(error.message)); await page.addInitScript(({ language, mode }) => { localStorage.setItem('uiLanguage', language); localStorage.setItem('jobtracker.themeMode', mode); }, { language, mode }); const company = { id: 7, name: 'Recorded school', location: 'Oslo', website: null, contactName: 'Recorded contact', contactEmail: 'contact@example.test', contactPhone: null, notes: '', jobsCount: 1 }; const offers = [{ id: 42, jobTitle: 'Recorded teacher role', company, status: 'Offer', dateApplied: '2026-10-01', deadline: '2026-12-01', location: 'Oslo', salaryMin: 610000, salaryMax: 710000, salaryCurrency: 'NOK', salaryPeriod: 'year', notes: 'Recorded teaching role', followUpAt: null }, { id: 43, jobTitle: 'Recorded nursing role', company: { id: 8, name: 'Recorded clinic' }, status: 'Offer', dateApplied: '2026-10-02', location: 'Bergen', salaryMin: null, salaryMax: null, notes: '', deadline: null, followUpAt: null }]; + if (offerDates) offers[0].followUpAt = '2026-12-01T00:30:00Z'; let authReads = 0; await page.route('**/*', route => { const req = route.request(), url = new URL(req.url()), p = url.pathname; @@ -46,12 +48,21 @@ try { const scan = async state => { assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > innerWidth + 1), false, `overflow:${state}`); const result = await new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa', 'wcag21aa', 'wcag22aa']).analyze(); - report.screens.push({ width, language, mode, state, violations: result.violations }); assert.deepEqual(result.violations, [], `${state} accessibility`); + report.screens.push({ width, language, mode, timeZone, state, violations: result.violations }); assert.deepEqual(result.violations, [], `${state} accessibility`); await page.evaluate(() => window.scrollTo({ top: 0, behavior: 'instant' })); await expect.poll(() => page.evaluate(() => window.scrollY)).toBe(0); - await page.screenshot({ path: path.join(out, `${state}-${language}-${mode}-${width}.png`), fullPage: width >= 768 }); + await page.screenshot({ path: path.join(out, `${state}-${language}-${mode}-${width}${timeZone ? '-' + timeZone.replaceAll('/', '_') : ''}.png`), fullPage: width >= 768 }); }; - await page.goto('http://localhost:4338/companies'); await expect(page.getByText(company.name, { exact: true })).toBeVisible(); await scan('companies'); + if (!offerDates) { await page.goto('http://localhost:4338/companies'); await expect(page.getByText(company.name, { exact: true })).toBeVisible(); await scan('companies'); } await page.goto('http://localhost:4338/offers'); await expect(page.getByRole('article', { name: offers[0].jobTitle, exact: true })).toBeVisible(); await expect(page.getByRole('article', { name: offers[1].jobTitle, exact: true })).toContainText(t('offerComparisonNotRecorded')); await scan('offers'); + if (offerDates) { + const article = page.getByRole('article', { name: offers[0].jobTitle, exact: true }); + const fact = key => article.getByText(t(key), { exact: true }).locator('..'); + await expect(fact('jobTableDateApplied')).toContainText(language === 'nb' ? '1.10.2026' : '10/1/2026'); + await expect(fact('offerComparisonDecisionDeadline')).toContainText(language === 'nb' ? '1.12.2026' : '12/1/2026'); + const expected = new Intl.DateTimeFormat(language === 'nb' ? 'nb-NO' : 'en', { timeZone }).format(new Date(offers[0].followUpAt)); + await expect(fact('companiesNextContact')).toContainText(expected); + report.journeys.push({ width, language, mode, timeZone, literalCalendarDatesAndLocalTimestamp: 'PASS' }); await context.close(); continue; + } await page.goto('http://localhost:4338/operations'); await expect(page.locator('[data-operation-id]')).toContainText('Recorded processing stage'); await expect(page.getByRole('button', { name: t('cancel'), exact: true })).toHaveCount(0); await scan('operations'); await page.goto('http://localhost:4338/analytics?months=6'); await expect(page.getByText('Teaching', { exact: true }).first()).toBeVisible(); await page.getByRole('button', { name: t('dashboardMonthsShort').replace('{count}', '24'), exact: true }).click(); await expect.poll(() => new URL(page.url()).searchParams.get('months')).toBe('24'); await scan('analytics'); await page.goto('http://localhost:4338/trash'); await expect(page.locator('main h1')).toBeVisible(); await expect(page.getByRole('dialog')).toHaveCount(0); await scan('trash-empty'); @@ -61,7 +72,7 @@ try { assert.equal(pdf.origin, 'http://localhost:5202'); assert.equal(pdf.pathname, '/api/public-cv/synthetic/pdf'); assert.equal(authReads, previousAuthReads); await scan('public-cv'); report.journeys.push({ width, language, mode, sixDirectRoutes: 'PASS', populatedAndUnknownOffers: 'PASS', operationActionPermissions: 'PASS', analyticsPeriodQuery: 'PASS', publicRouteWithoutIdentityRead: 'PASS' }); await context.close(); } - assert.equal(report.screens.length, configurations.length * 6); assert.equal(report.journeys.length, configurations.length); assert.deepEqual(report.errors, []); assert.deepEqual(report.writes, []); assert.ok(report.blockedExternal.every(origin => origin === 'https://fonts.googleapis.com')); + assert.equal(report.screens.length, configurations.length * (offerDates ? 1 : 6)); assert.equal(report.journeys.length, configurations.length); assert.deepEqual(report.errors, []); assert.deepEqual(report.writes, []); assert.ok(report.blockedExternal.every(origin => origin === 'https://fonts.googleapis.com')); console.log(`PASS:${report.journeys.length} controlled route journeys,${report.screens.length} axe scans/captures; zero mutations.`); } finally { fs.writeFileSync(path.join(out, process.env.A3_PILOT ? 'PILOT.json' : 'BROWSER.json'), JSON.stringify({ ...report, limits: ['All data/render responses synthetic. No actual job providers, operation workers, public CV access-control or physical PDF qualification.', 'Trash is empty; real restore/delete excluded. Public CV route makes no identity read in this fixture, not deployed anonymous-access acceptance.', 'No models, real credentials, native/human, integrated security, production or release qualification.'] }, null, 2)); diff --git a/job-tracker-ui/src/offer-comparison.test.tsx b/job-tracker-ui/src/offer-comparison.test.tsx index ddaf9ec0..6fefac95 100644 --- a/job-tracker-ui/src/offer-comparison.test.tsx +++ b/job-tracker-ui/src/offer-comparison.test.tsx @@ -236,3 +236,25 @@ test("uses recorded applied/deadline/contact dates, keeps absent applied dates u expect(mockedApi.get).toHaveBeenCalledTimes(1); assertNoWrites(); }); + +test.each(["America/Los_Angeles", "Europe/Oslo", "Pacific/Kiritimati"].flatMap(timeZone => + (["en", "nb"] as const).map(language => ({ timeZone, language }))))( + "keeps a literal calendar date while timestamps retain local semantics: $language/$timeZone", + async ({ timeZone, language }) => { + const original = Date.prototype.toLocaleDateString; + jest.spyOn(Date.prototype, "toLocaleDateString").mockImplementation(function ( + this: Date, locales?: Intl.LocalesArgument, options?: Intl.DateTimeFormatOptions, + ) { return original.call(this, locales, { timeZone, ...options }); }); + const item = offer(503, { dateApplied: "2026-12-01", deadline: "2026-12-01T00:30:00Z", followUpAt: "2026-12-01T23:30:00-08:00" }); + mockedApi.get.mockResolvedValue(paged([item]) as any); + renderPage(language); + const article = await screen.findByRole("article", { name: item.jobTitle }); + const fact = (label: string) => within(article).getByText(label).parentElement!; + expect(fact(translations[language].jobTableDateApplied)).toHaveTextContent(language === "nb" ? "1.12.2026" : "12/1/2026"); + const formatter = new Intl.DateTimeFormat(language === "nb" ? "nb-NO" : "en", { timeZone }); + expect(fact(translations[language].offerComparisonDecisionDeadline)).toHaveTextContent(formatter.format(new Date(item.deadline!))); + expect(fact(translations[language].companiesNextContact)).toHaveTextContent(formatter.format(new Date(item.followUpAt!))); + expect(mockedApi.get).toHaveBeenCalledTimes(1); + assertNoWrites(); + }, +); diff --git a/job-tracker-ui/src/views/OfferComparisonPage.tsx b/job-tracker-ui/src/views/OfferComparisonPage.tsx index 99a36774..3c92cf25 100644 --- a/job-tracker-ui/src/views/OfferComparisonPage.tsx +++ b/job-tracker-ui/src/views/OfferComparisonPage.tsx @@ -18,7 +18,7 @@ export default function OfferComparisonPage() { const retryPending = useRef(false); const loading = resource.loading || resource.refreshing; const locale = language === "nb" ? "nb-NO" : "en"; - const date = (value?: string | null) => value ? new Date(value).toLocaleDateString(locale) : "—"; + const date = (value?: string | null) => value ? new Date(value).toLocaleDateString(locale, /^\d{4}-\d{2}-\d{2}$/.test(value) ? { timeZone: "UTC" } : undefined) : "—"; const periods = new Map([["year", t("salaryPeriodYear")], ["month", t("salaryPeriodMonth")], ["hour", t("salaryPeriodHour")]]); return (