fix(i18n): preserve literal calendar dates in offer comparisons
This commit is contained in:
@@ -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.
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -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));
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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 (
|
||||
|
||||
Reference in New Issue
Block a user