feat(ui): migrate recorded offer comparison

This commit is contained in:
cesnimda
2026-10-13 13:19:53 +02:00
parent a8aba85299
commit c723a4e303
14 changed files with 2935 additions and 42 deletions
+307
View File
@@ -0,0 +1,307 @@
import AxeBuilder from '@axe-core/playwright';
import { expect, test as base, type APIRequestContext, type APIResponse, type Locator, type Page, type Response, type TestInfo } from '@playwright/test';
import { translations } from '../src/i18n/translations';
import type { JobApplication } from '../src/types';
const apiUrl = 'http://localhost:5302/api';
const jobsUrl = `${apiUrl}/jobapplications`;
const password = 'E2ePassword123!';
type Owner = { id: string; headers: Record<string, string> };
type Sample = { language: 'en' | 'nb'; theme: 'light' | 'dark'; width: number; height: number };
type PagedJobs = { items: JobApplication[]; total: number; page: number; pageSize: number };
type RequestRecord = { method: string; path: string; query: Record<string, string> };
type Session = { owner: Owner; admin: APIRequestContext; administrator: Owner; requests: RequestRecord[] };
const desktop: Sample = { language: 'en', theme: 'light', width: 1440, height: 900 };
const params = { status: 'Offer', page: '1', pageSize: '25', sortBy: 'dateApplied', sortDir: 'asc' };
const boundary = 'Offers, companies, non-Offer/deleted/foreign rows, dates and salary facts are created through the actual owner HTTP API in disposable isolated SQLite. Free account entitlements prohibit model use; the worker is disabled by the native configuration. Controlled GET failure/hold establish presentation and explicit recovery only. One explicitly marked legacy salary-period projection derives from an actual owned GET because the current write API normalizes unknown periods to null. No provider, model, external service, raw SQL, schema write, decision deadline, paging feature or salary conversion is introduced. Browser records exclude credentials and session/CSRF headers.';
async function json<T>(response: APIResponse | Response, status = 200): Promise<T> {
expect(response.status(), `${response.url()} returns ${status}`).toBe(status);
return response.json();
}
async function login(api: APIRequestContext, email: string): Promise<Owner> {
expect((await api.post(`${apiUrl}/auth/login`, { data: { email, password } })).status()).toBe(200);
const me = await json<{ id: string; sessionContext: string }>(await api.get(`${apiUrl}/auth/me`));
const csrf = (await api.storageState()).cookies.find(cookie => cookie.name === 'XSRF-TOKEN');
expect(csrf).toBeDefined();
return { id: me.id, headers: { 'X-CSRF-TOKEN': csrf!.value, 'X-Session-Context': me.sessionContext } };
}
async function account(admin: APIRequestContext, administrator: Owner) {
const email = `p7-offers-${Date.now()}-${Math.random().toString(36).slice(2)}@example.test`;
expect((await admin.post(`${apiUrl}/users`, { headers: administrator.headers, data: { email, password, roles: [] } })).status()).toBe(200);
return email;
}
const test = base.extend<{ session: Session }>({
session: async ({ page, context, playwright }, provide, info) => {
expect(process.env.PLAYWRIGHT_REUSE_SERVERS).not.toBe('true');
expect(info.config.metadata.isolatedDataRoot).toEqual(expect.any(String));
const admin = await playwright.request.newContext();
const requests: RequestRecord[] = [];
const blockedExternalRequests: string[] = [];
page.on('request', request => {
const url = new URL(request.url());
if (!/^\/api\/jobapplications(?:\/|$)/.test(url.pathname)) return;
requests.push({ method: request.method(), path: url.pathname, query: Object.fromEntries(url.searchParams) });
});
try {
const administrator = await login(admin, 'e2e@example.test');
const owner = await login(page.request, await account(admin, administrator));
expect((await json<{ entitlements: { ai: boolean } }>(await page.request.get(`${apiUrl}/auth/me`))).entitlements.ai).toBe(false);
await context.route('**/*', route => {
const url = new URL(route.request().url());
if (!['localhost', '127.0.0.1'].includes(url.hostname) && !['data:', 'blob:', 'about:'].includes(url.protocol)) {
blockedExternalRequests.push(url.origin + url.pathname);
return route.abort();
}
return route.continue();
});
await provide({ owner, admin, administrator, requests });
} finally {
await info.attach('offers-request-boundary', { body: JSON.stringify({ boundary, requests, blockedExternalRequests }), contentType: 'application/json' });
await admin.dispose();
}
},
});
async function company(api: APIRequestContext, owner: Owner, name: string) {
return json<{ id: number; name: string }>(await api.post(`${apiUrl}/companies`, { headers: owner.headers, data: { name, location: 'Oslo', source: 'User' } }), 201);
}
async function job(api: APIRequestContext, owner: Owner, companyId: number, jobTitle: string, fields: Partial<JobApplication> = {}) {
return json<JobApplication>(await api.post(jobsUrl, { headers: owner.headers, data: { companyId, jobTitle, status: 'Offer', ...fields } }), 201);
}
function results(page: Page, language: Sample['language'] = 'en') {
return page.getByRole('region', { name: translations[language].offerComparisonTitle, exact: true });
}
function card(page: Page, title: string) { return page.getByRole('article', { name: title, exact: true }); }
function fact(article: Locator, label: string) { return article.getByText(label, { exact: true }).locator('..'); }
function sampleLabel(language: Sample['language'], shown: number, total: number) {
return translations[language].offerComparisonSampleCount.replace('{shown}', String(shown)).replace('{total}', String(total));
}
function offerRequests(requests: RequestRecord[]) {
return requests.filter(request => request.method === 'GET' && request.path === '/api/jobapplications' && request.query.status === 'Offer' && request.query.pageSize === '25');
}
function hold() { let release!: () => void; const pending = new Promise<void>(resolve => { release = resolve; }); return { pending, release }; }
async function keyboard(page: Page, element: Locator) { await element.focus(); await expect(element).toBeFocused(); await page.keyboard.press('Enter'); }
async function enter(page: Page, owner: Owner, sample: Sample = desktop) {
expect((await page.request.put(`${apiUrl}/auth/preferences/language`, { headers: owner.headers,
data: { language: sample.language === 'nb' ? 'nb-NO' : 'en' } })).status()).toBe(204);
await page.addInitScript(({ language, theme }) => {
if (localStorage.getItem('uiLanguage') === null) localStorage.setItem('uiLanguage', language);
if (localStorage.getItem('jobtracker.themeMode') === null) localStorage.setItem('jobtracker.themeMode', theme);
}, sample);
await page.setViewportSize({ width: sample.width, height: sample.height });
await page.emulateMedia({ reducedMotion: 'reduce' });
await page.goto('/offers?source=phase7-offers#comparison');
const stored = await page.evaluate(() => ({ language: localStorage.getItem('uiLanguage'), theme: localStorage.getItem('jobtracker.themeMode') }));
if (stored.language !== sample.language || stored.theme !== sample.theme) {
await page.evaluate(({ language, theme }) => { localStorage.setItem('uiLanguage', language); localStorage.setItem('jobtracker.themeMode', theme); }, sample);
await page.reload();
}
await expect(page.locator('html')).toHaveAttribute('lang', sample.language === 'nb' ? 'nb-NO' : 'en');
await expect(page.locator('html')).toHaveAttribute('data-color-scheme', sample.theme);
await expect(page.getByRole('heading', { level: 1 })).toHaveCount(1);
await expect(page.getByRole('heading', { level: 1, name: translations[sample.language].offerComparisonTitle, exact: true })).toBeVisible();
await expect(page.getByRole('heading', { level: 2, name: translations[sample.language].offerComparisonTitle, exact: true })).toBeVisible();
}
async function capture(page: Page, info: TestInfo, sample: Sample, state: string) {
await page.evaluate(async () => { await document.fonts.ready; await new Promise<void>(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))); });
const geometry = await page.evaluate(() => ({ width: innerWidth, height: innerHeight,
rootOverflow: Math.max(0, document.documentElement.scrollWidth - document.documentElement.clientWidth),
outsideViewport: Array.from(document.querySelectorAll('body *')).filter(element => element.getClientRects().length > 0
&& getComputedStyle(element).visibility !== 'hidden' && !element.closest('[hidden],[aria-hidden="true"]'))
.map(element => { const box = element.getBoundingClientRect(); return { tag: element.tagName, role: element.getAttribute('role'), label: element.getAttribute('aria-label'),
text: element.textContent?.slice(0, 100), left: box.left, right: box.right, width: box.width }; })
.filter(element => element.width > 0 && (element.left < -1 || element.right > innerWidth + 1)).slice(0, 30),
}));
const axe = await new AxeBuilder({ page }).options({ rules: { 'target-size': { enabled: true } } }).analyze();
const name = `p7-offers-${sample.language}-${sample.theme}-${sample.width}-${state}`;
await info.attach(`${name}-audit`, { body: JSON.stringify({ sample, state, geometry, boundary, ariaSnapshot: await page.locator('body').ariaSnapshot(),
axe: { scope: 'All enabled axe rules plus target-size', violations: axe.violations, incomplete: axe.incomplete,
passes: axe.passes.map(rule => ({ id: rule.id, nodes: rule.nodes.length })) },
qualifications: ['Finite Chromium states; not all combinations of width, language, theme and state.',
'Complete violations and incomplete node vectors are retained; incomplete findings require contextual review.',
'Keyboard and axe checks do not constitute human screen-reader, physical touch/software-keyboard, real zoom, production font/photo or release acceptance.'],
}), contentType: 'application/json' });
await info.attach(name, { body: await page.screenshot({ fullPage: true, animations: 'disabled' }), contentType: 'image/png' });
expect(geometry.rootOverflow, `${name} app chrome reflows`).toBe(0);
expect(axe.violations, `${name} complete axe violations`).toEqual([]);
}
test('[P7-05 Offers] actual 25-of-27 ordered sample excludes other/deleted/foreign jobs and opens the existing full list and job', async ({ page, playwright, session }, info) => {
test.setTimeout(120_000);
const source = await company(page.request, session.owner, 'Actual recorded offers');
const offers: JobApplication[] = [];
for (let index = 0; index < 27; index++) offers.push(await job(page.request, session.owner, source.id, `Recorded offer ${String(index).padStart(2, '0')}`, {
dateApplied: new Date(Date.UTC(2026, 0, index + 1, 12)).toISOString(), deadline: new Date(Date.UTC(2027, 0, 27 - index, 12)).toISOString(),
}));
const other = await job(page.request, session.owner, source.id, 'Applied job is outside Offer sample', { status: 'Applied' });
const deleted = await job(page.request, session.owner, source.id, 'Deleted Offer must remain absent');
expect((await page.request.delete(`${jobsUrl}/${deleted.id}`, { headers: session.owner.headers })).status()).toBe(204);
const foreignApi = await playwright.request.newContext();
const anonymous = await playwright.request.newContext();
try {
const foreign = await login(foreignApi, await account(session.admin, session.administrator));
const foreignCompany = await company(foreignApi, foreign, 'Foreign private company');
const foreignJob = await job(foreignApi, foreign, foreignCompany.id, 'Foreign private Offer must remain absent');
const readback = await json<PagedJobs>(await page.request.get(jobsUrl, { params }));
expect(readback).toMatchObject({ total: 27, page: 1, pageSize: 25 });
expect(readback.items.map(item => item.id)).toEqual(offers.slice(0, 25).map(item => item.id));
const secondPage = await json<PagedJobs>(await page.request.get(jobsUrl, { params: { ...params, page: '2' } }));
expect(secondPage.items.map(item => item.id)).toEqual(offers.slice(25).map(item => item.id));
expect((await page.request.get(`${jobsUrl}/${foreignJob.id}`)).status()).toBe(404);
expect((await anonymous.get(jobsUrl, { params })).status()).toBe(401);
await enter(page, session.owner);
await expect(results(page).getByText(sampleLabel('en', 25, 27), { exact: true })).toBeVisible();
await expect(results(page).getByText(translations.en.offerComparisonSampleHelp, { exact: true })).toBeVisible();
await expect(results(page).getByRole('article')).toHaveCount(25);
expect(await results(page).getByRole('heading', { level: 3 }).allTextContents()).toEqual(offers.slice(0, 25).map(item => item.jobTitle));
for (const excluded of [other, deleted, foreignJob, offers[25], offers[26]]) await expect(card(page, excluded.jobTitle)).toHaveCount(0);
expect(offerRequests(session.requests)[0].query).toEqual(params);
const fullList = page.waitForResponse(response => {
const url = new URL(response.url());
return url.pathname === '/api/jobapplications' && url.searchParams.get('status') === 'Offer' && url.searchParams.get('sortBy') === 'dateApplied'
&& url.searchParams.get('sortDir') === 'asc';
});
await keyboard(page, page.getByRole('button', { name: translations.en.offerComparisonAllOffers, exact: true }));
await expect(page).toHaveURL(/\/jobs\?status=Offer&sortBy=dateApplied&sortDir=asc$/);
expect((await json<PagedJobs>(await fullList)).total).toBe(27);
await page.goBack(); await expect(page).toHaveURL(/\/offers\?source=phase7-offers#comparison$/);
await expect(card(page, offers[0].jobTitle)).toBeVisible();
const detail = page.waitForResponse(response => response.url() === `${jobsUrl}/${offers[0].id}/workspace` && response.request().method() === 'GET');
await keyboard(page, card(page, offers[0].jobTitle).getByRole('button', { name: translations.en.workspaceOpenFull, exact: true }));
await expect(page).toHaveURL(new RegExp(`/jobs/${offers[0].id}$`));
expect(await json<{ id: number; jobTitle: string }>(await detail)).toMatchObject({ id: offers[0].id, jobTitle: offers[0].jobTitle });
await page.goBack(); await expect(card(page, offers[0].jobTitle)).toBeVisible();
await capture(page, info, desktop, 'actual-sample-and-back');
await info.attach('offers-real-http-sample', { body: JSON.stringify({ boundary, ownedOfferIds: offers.map(item => item.id), firstPageIds: readback.items.map(item => item.id),
omittedButAvailableIds: secondPage.items.map(item => item.id), excludedOwnedIds: [other.id, deleted.id], foreignJobId: foreignJob.id,
qualification: 'The UI displays exactly one existing API page. The second API-page read is fixture evidence, not an added comparison paging feature. Full-list and Back navigation use existing Jobs routes.' }), contentType: 'application/json' });
} finally { await foreignApi.dispose(); await anonymous.dispose(); }
});
test('[P7-05 Offers] actual empty, zero/equal/missing/raw salary and recorded date facts remain honest with a qualified legacy period projection', async ({ page, session }, info) => {
test.setTimeout(90_000);
const source = await company(page.request, session.owner, 'Recorded salary facts');
await job(page.request, session.owner, source.id, 'Saved job outside offers', { status: 'Saved' });
await enter(page, session.owner);
await expect(results(page).getByText(translations.en.offerComparisonEmpty, { exact: true })).toBeVisible();
await expect(results(page).getByRole('article')).toHaveCount(0);
await expect(page.getByRole('button', { name: translations.en.offerComparisonAllOffers, exact: true })).toHaveCount(0);
await capture(page, info, desktop, 'actual-empty');
const applied = '2026-01-04T12:00:00Z'; const deadline = '2027-02-05T12:00:00Z';
const zero = await job(page.request, session.owner, source.id, 'Recorded zero compensation', { salaryMin: 0, salaryMax: 0, salaryCurrency: 'NOK', salaryPeriod: 'year', dateApplied: applied, deadline });
const equal = await job(page.request, session.owner, source.id, 'Recorded equal compensation', { salaryMin: 50_000, salaryMax: 50_000, salaryCurrency: 'EUR', salaryPeriod: 'month' });
const missing = await job(page.request, session.owner, source.id, 'Missing salary facts');
const raw = await job(page.request, session.owner, source.id, 'Candidate written salary', { salary: 'Negotiable — recorded by candidate' });
const legacy = await job(page.request, session.owner, source.id, 'Legacy unknown-period projection', { salaryMin: 70_000, salaryCurrency: 'QXD', salaryPeriod: 'fortnight' });
expect(legacy.salaryPeriod).toBeNull();
await page.route(`${jobsUrl}?**`, async route => {
if (new URL(route.request().url()).searchParams.get('status') !== 'Offer') return route.continue();
const response = await route.fetch(); const data: PagedJobs = await response.json();
const owned = data.items.find(item => item.id === legacy.id); expect(owned).toBeDefined();
owned!.salaryPeriod = 'fortnight';
await route.fulfill({ response, json: data });
});
await page.reload();
await expect(card(page, zero.jobTitle)).toBeVisible();
const amount = await page.evaluate(() => ({ equal: (50_000).toLocaleString('en'), legacy: (70_000).toLocaleString('en') }));
await expect(fact(card(page, zero.jobTitle), translations.en.offerComparisonCompensation)).toContainText('0 – 0 NOK / Year');
await expect(fact(card(page, equal.jobTitle), translations.en.offerComparisonCompensation)).toContainText(`${amount.equal} – ${amount.equal} EUR / Month`);
await expect(fact(card(page, missing.jobTitle), translations.en.offerComparisonCompensation)).toContainText(translations.en.offerComparisonNotRecorded);
await expect(fact(card(page, raw.jobTitle), translations.en.offerComparisonCompensation)).toContainText(raw.salary!);
await expect(fact(card(page, legacy.jobTitle), translations.en.offerComparisonCompensation)).toContainText(`${amount.legacy} QXD / fortnight`);
const dates = await page.evaluate(({ applied, deadline }) => ({ applied: new Date(applied).toLocaleDateString('en'), deadline: new Date(deadline).toLocaleDateString('en') }), { applied, deadline });
await expect(fact(card(page, zero.jobTitle), translations.en.jobTableDateApplied)).toContainText(dates.applied);
await expect(fact(card(page, zero.jobTitle), translations.en.offerComparisonDecisionDeadline)).toContainText(dates.deadline);
await expect(results(page).getByText('Decision deadline', { exact: true })).toHaveCount(0);
await expect(results(page).getByText(sampleLabel('en', 5, 5), { exact: true })).toBeVisible();
await expect(results(page).getByText(translations.en.offerComparisonSampleHelp, { exact: true })).toHaveCount(0);
await capture(page, info, desktop, 'recorded-salary-with-legacy-projection');
await info.attach('offers-legacy-projection-boundary', { body: JSON.stringify({ boundary, id: legacy.id, actualPersistedSalaryPeriod: legacy.salaryPeriod,
browserProjectionSalaryPeriod: 'fortnight', qualification: 'Only this legacy field is replaced in an actual owned GET projection. Unknown-period persistence is not claimed; actual API readback remains null. Zero/equal/missing/free-text salaries and recorded dates come from actual HTTP records. Number formatting follows the preserved Offers local formatter and current UI locale. Both recorded bounds remain displayed; recognized periods are localized and unknown periods remain verbatim. No currency conversion is performed.' }), contentType: 'application/json' });
expect((await json<JobApplication>(await page.request.get(`${jobsUrl}/${legacy.id}`))).salaryPeriod).toBeNull();
});
test('[P7-05 Offers] held actual reads and controlled failure recover through explicit keyboard Retry without locale refetch', async ({ page, session }, info) => {
test.setTimeout(90_000);
const source = await company(page.request, session.owner, 'Offers recovery source');
const offer = await job(page.request, session.owner, source.id, 'Retained actual recovery offer');
const initial = hold(); const retry = hold(); let attempts = 0;
await page.route(`${jobsUrl}?**`, async route => {
if (new URL(route.request().url()).searchParams.get('status') !== 'Offer') return route.continue();
attempts += 1;
if (attempts === 1) { await initial.pending; await route.fulfill({ status: 500, json: {} }); }
else { await retry.pending; await route.continue(); }
});
await enter(page, session.owner);
await expect.poll(() => attempts).toBe(1);
await expect(results(page).getByRole('status')).toContainText(translations.en.loading);
await expect(results(page).getByRole('article')).toHaveCount(0);
await expect(page.getByRole('button', { name: translations.en.offerComparisonAllOffers, exact: true })).toHaveCount(0);
await capture(page, info, desktop, 'controlled-held-initial');
initial.release();
await expect(results(page).getByRole('alert')).toContainText(translations.en.offerComparisonLoadFailed);
await capture(page, info, desktop, 'controlled-load-failure');
const preference = page.waitForResponse(response => response.url() === `${apiUrl}/auth/preferences/language` && response.request().method() === 'PUT');
await keyboard(page, page.locator('header').getByRole('button', { name: 'Norsk', exact: true }));
expect((await preference).status()).toBe(204);
await expect(page.locator('html')).toHaveAttribute('lang', 'nb-NO');
await expect(results(page, 'nb').getByRole('alert')).toContainText(translations.nb.offerComparisonLoadFailed);
expect(attempts).toBe(1); expect(offerRequests(session.requests)).toHaveLength(1);
await keyboard(page, results(page, 'nb').getByRole('button', { name: translations.nb.retry, exact: true }));
await expect.poll(() => attempts).toBe(2);
await expect(results(page, 'nb')).toBeFocused();
await expect(results(page, 'nb').getByRole('status')).toContainText(translations.nb.loading);
await expect(results(page, 'nb').getByRole('button', { name: translations.nb.retry, exact: true })).toHaveCount(0);
await capture(page, info, { ...desktop, language: 'nb' }, 'controlled-held-explicit-retry');
retry.release();
await expect(card(page, offer.jobTitle)).toBeVisible();
await expect(results(page, 'nb').getByRole('alert')).toHaveCount(0);
await expect(results(page, 'nb').getByText(sampleLabel('nb', 1, 1), { exact: true })).toBeVisible();
expect(attempts).toBe(2); expect(offerRequests(session.requests).map(request => request.query)).toEqual([params, params]);
const english = page.waitForResponse(response => response.url() === `${apiUrl}/auth/preferences/language` && response.request().method() === 'PUT');
await keyboard(page, page.locator('header').getByRole('button', { name: 'English', exact: true }));
expect((await english).status()).toBe(204);
await expect(page.getByRole('heading', { level: 2, name: translations.en.offerComparisonTitle, exact: true })).toBeVisible();
expect(attempts).toBe(2); expect(offerRequests(session.requests)).toHaveLength(2);
expect((await json<JobApplication>(await page.request.get(`${jobsUrl}/${offer.id}`))).jobTitle).toBe(offer.jobTitle);
});
test('[P7-05 Offers] eight finite bilingual theme widths preserve long recorded facts and reachable keyboard actions', async ({ page, session }, info) => {
test.setTimeout(240_000);
const source = await company(page.request, session.owner, 'Samfunnskritiske systemer og langsiktige muligheter for nordnorske virksomheter Æ Ø Å');
const offer = await job(page.request, session.owner, source.id, 'Senior utvikler med ansvar for samfunnskritiske tjenester og grundig kvalitetssikring Æ Ø Å', {
location: 'Tromsø og flere samarbeidende kommuner i Nord-Norge', salaryMin: 850_000, salaryMax: 950_000, salaryCurrency: 'NOK', salaryPeriod: 'year',
dateApplied: '2026-01-10T12:00:00Z', deadline: '2027-02-20T12:00:00Z', followUpAt: '2027-02-10T12:00:00Z',
nextAction: 'Avklar oppgavene og arbeidstiden med rekruttereren før du vurderer det registrerte tilbudet.',
notes: 'Dette er kandidatens egne registrerte notater. Ingen lønnsvurdering eller kunstig svarfrist er lagt til.\nSammenlign de dokumenterte opplysningene og les vilkårene grundig.',
});
await job(page.request, session.owner, source.id, 'Another recorded offer without compensation facts', { notes: 'Missing facts remain unknown.' });
const samples: Sample[] = [
{ language: 'en', theme: 'light', width: 320, height: 740 }, { language: 'nb', theme: 'dark', width: 360, height: 844 },
{ language: 'en', theme: 'dark', width: 390, height: 844 }, { language: 'nb', theme: 'light', width: 768, height: 1024 },
{ language: 'en', theme: 'dark', width: 1024, height: 768 }, { language: 'nb', theme: 'light', width: 1280, height: 320 },
{ language: 'en', theme: 'light', width: 1440, height: 900 }, { language: 'nb', theme: 'dark', width: 1920, height: 1080 },
];
for (const sample of samples) {
const t = translations[sample.language];
await enter(page, session.owner, sample);
await expect(card(page, offer.jobTitle)).toBeVisible();
await expect(card(page, offer.jobTitle).getByRole('heading', { level: 3, name: offer.jobTitle, exact: true })).toBeVisible();
await expect(card(page, offer.jobTitle)).toContainText(source.name);
await expect(fact(card(page, offer.jobTitle), t.offerComparisonNotes)).toContainText(offer.notes!);
await expect(results(page, sample.language).getByText(sampleLabel(sample.language, 2, 2), { exact: true })).toBeVisible();
const open = card(page, offer.jobTitle).getByRole('button', { name: t.workspaceOpenFull, exact: true });
await open.scrollIntoViewIfNeeded(); await open.focus(); await expect(open).toBeFocused();
await page.evaluate(() => scrollTo(0, 0));
await capture(page, info, sample, 'long-recorded-facts');
}
});
+10 -4
View File
@@ -1653,12 +1653,15 @@ export const translations = {
offerComparisonNav: "Compare offers",
offerComparisonTitle: "Offer comparison",
offerComparisonPageSubtitle: "Compare active offers using the facts already recorded on each application.",
offerComparisonHelp: "Review compensation, location, decision dates, next actions and your own notes side by side. Missing details stay visibly unknown.",
offerComparisonHelp: "Review compensation, location, recorded application dates and deadlines, next actions and your own notes side by side. Missing details stay visibly unknown.",
offerComparisonSampleCount: "Showing {shown} of {total} active offers",
offerComparisonSampleHelp: "This comparison shows the first 25 offers by recorded application date. Open all offers to review the complete list.",
offerComparisonAllOffers: "Open all offers",
offerComparisonLoadFailed: "Could not load active offers.",
offerComparisonEmpty: "No active offers to compare",
offerComparisonEmptyHelp: "When an application reaches Offer, it will appear here automatically.",
offerComparisonCompensation: "Compensation",
offerComparisonDecisionDeadline: "Decision deadline",
offerComparisonDecisionDeadline: "Recorded application deadline",
offerComparisonNotes: "Notes and considerations",
offerComparisonNotRecorded: "Not recorded",
adminUsersTitle: "Users",
@@ -4295,12 +4298,15 @@ export const translations = {
offerComparisonNav: "Sammenlign tilbud",
offerComparisonTitle: "Sammenligning av tilbud",
offerComparisonPageSubtitle: "Sammenlign aktive tilbud med opplysningene som allerede er registrert i hver søknad.",
offerComparisonHelp: "Se lønn, sted, svarfrister, neste handling og egne notater side om side. Manglende opplysninger vises tydelig som ukjente.",
offerComparisonHelp: "Se lønn, sted, registrerte søknadsdatoer og frister, neste handling og egne notater side om side. Manglende opplysninger vises tydelig som ukjente.",
offerComparisonSampleCount: "Viser {shown} av {total} aktive tilbud",
offerComparisonSampleHelp: "Sammenligningen viser de første 25 tilbudene etter registrert søknadsdato. Åpne alle tilbud for å se hele listen.",
offerComparisonAllOffers: "Åpne alle tilbud",
offerComparisonLoadFailed: "Kunne ikke laste aktive tilbud.",
offerComparisonEmpty: "Ingen aktive tilbud å sammenligne",
offerComparisonEmptyHelp: "Når en søknad får status Tilbud, vises den automatisk her.",
offerComparisonCompensation: "Kompensasjon",
offerComparisonDecisionDeadline: "Svarfrist",
offerComparisonDecisionDeadline: "Registrert søknadsfrist",
offerComparisonNotes: "Notater og vurderinger",
offerComparisonNotRecorded: "Ikke registrert",
adminUsersTitle: "Brukere",
+210 -7
View File
@@ -1,34 +1,237 @@
import React from "react";
import "@testing-library/jest-dom";
import { render, screen } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { MemoryRouter, useLocation } from "react-router-dom";
import { api } from "./api";
import { I18nProvider } from "./i18n/I18nProvider";
import { translations, type UiLanguage } from "./i18n/translations";
import type { JobApplication } from "./types";
import OfferComparisonPage from "./views/OfferComparisonPage";
jest.mock("./api", () => ({
api: { get: jest.fn() },
api: { get: jest.fn(), put: jest.fn(), post: jest.fn(), delete: jest.fn() },
getApiErrorMessage: (_error: unknown, fallback: string) => fallback,
}));
const mockedApi = api as jest.Mocked<typeof api>;
const request = { params: { status: "Offer", page: 1, pageSize: 25, sortBy: "dateApplied", sortDir: "asc" } };
beforeEach(() => {
window.localStorage.clear();
jest.clearAllMocks();
mockedApi.get.mockReset();
});
afterEach(() => jest.restoreAllMocks());
function copy(key: string, language: UiLanguage = "en", params: Record<string, number> = {}) {
const template = (translations[language] as Record<string, string>)[key] ?? key;
return template.replace(/\{(\w+)\}/g, (_, name) => String(params[name] ?? `{${name}}`));
}
function offer(id: number, overrides: Partial<JobApplication> = {}): JobApplication {
return {
id, jobTitle: `Offer role ${id}`, company: { id, name: `Company ${id}` }, status: "Offer",
dateApplied: "2026-08-01", savedAt: "2026-07-20", responseReceived: true, daysSince: 10,
...overrides,
};
}
function paged(items: JobApplication[], total = items.length) {
return { data: { items, total, page: 1, pageSize: 25 } };
}
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason: unknown) => void;
const promise = new Promise<T>((yes, no) => { resolve = yes; reject = no; });
return { promise, resolve, reject };
}
function RouteContext() {
const location = useLocation();
return <output aria-label="Current route">{location.pathname}{location.search}{location.hash}</output>;
}
function renderPage(language: UiLanguage = "en") {
window.localStorage.setItem("uiLanguage", language);
return render(<I18nProvider><MemoryRouter initialEntries={["/offers"]}><RouteContext /><OfferComparisonPage /></MemoryRouter></I18nProvider>);
}
function changeLanguage(language: UiLanguage) {
act(() => { window.dispatchEvent(new StorageEvent("storage", { key: "uiLanguage", newValue: language })); });
}
function assertNoWrites() {
expect(mockedApi.put).not.toHaveBeenCalled();
expect(mockedApi.post).not.toHaveBeenCalled();
expect(mockedApi.delete).not.toHaveBeenCalled();
}
test("compares recorded offer facts without filling missing values", async () => {
mockedApi.get.mockResolvedValue({ data: { items: [
{ id: 1, jobTitle: "Backend Engineer", company: { id: 1, name: "Acme" }, status: "Offer", salaryMin: 800000, salaryMax: 900000, salaryCurrency: "NOK", salaryPeriod: "year", location: "Oslo", deadline: "2026-09-10", nextAction: "Review contract", notes: "Hybrid three days", dateApplied: "2026-08-01", savedAt: "2026-07-20", responseReceived: true, daysSince: 10 },
{ id: 2, jobTitle: "Platform Engineer", company: { id: 2, name: "Beta" }, status: "Offer", dateApplied: "2026-08-02", savedAt: "2026-07-21", responseReceived: true, daysSince: 9 },
], total: 2, page: 1, pageSize: 25 } } as any);
render(<I18nProvider><MemoryRouter><OfferComparisonPage /></MemoryRouter></I18nProvider>);
renderPage();
expect(await screen.findByText("Backend Engineer")).toBeInTheDocument();
expect(screen.getByText("800,000 – 900,000 NOK / year")).toBeInTheDocument();
expect(screen.getByText(`800,000 – 900,000 NOK / ${translations.en.salaryPeriodYear}`)).toBeInTheDocument();
expect(screen.getByText("Hybrid three days")).toBeInTheDocument();
expect(screen.getAllByText("Not recorded").length).toBeGreaterThan(0);
expect(mockedApi.get).toHaveBeenCalledWith("/jobapplications", { params: { status: "Offer", page: 1, pageSize: 25, sortBy: "deadline", sortDir: "asc" } });
expect(mockedApi.get).toHaveBeenCalledWith("/jobapplications", request);
assertNoWrites();
});
test("shows an honest empty state when there are no offers", async () => {
mockedApi.get.mockResolvedValue({ data: { items: [], total: 0, page: 1, pageSize: 25 } } as any);
render(<I18nProvider><MemoryRouter><OfferComparisonPage /></MemoryRouter></I18nProvider>);
renderPage();
expect(await screen.findByText("No active offers to compare")).toBeInTheDocument();
assertNoWrites();
});
test("shows the first 25 of the real total in server application-date order without inventing the remaining offers", async () => {
const items = Array.from({ length: 25 }, (_, index) => offer(index + 1, {
dateApplied: `2026-08-${String(index + 1).padStart(2, "0")}`,
deadline: index === 0 ? "2026-12-01" : "2026-09-01",
}));
mockedApi.get.mockResolvedValue(paged(items, 61) as any);
renderPage();
expect(await screen.findByText(copy("offerComparisonSampleCount", "en", { shown: 25, total: 61 }))).toBeInTheDocument();
expect(screen.getByText(copy("offerComparisonSampleHelp"))).toBeInTheDocument();
expect(screen.getAllByRole("article")).toHaveLength(25);
expect(screen.getAllByRole("heading", { level: 3 }).map((heading) => heading.textContent)).toEqual(items.map((item) => item.jobTitle));
expect(screen.queryByText("Offer role 26")).not.toBeInTheDocument();
expect(mockedApi.get).toHaveBeenCalledTimes(1);
expect(mockedApi.get).toHaveBeenCalledWith("/jobapplications", request);
assertNoWrites();
});
test("opens the full recorded offer source with the same supported filter and ordering", async () => {
mockedApi.get.mockResolvedValue(paged([offer(7)], 42) as any);
renderPage();
fireEvent.click(await screen.findByRole("button", { name: copy("offerComparisonAllOffers") }));
const route = new URL(screen.getByLabelText("Current route").textContent!, "https://example.test");
expect(route.pathname).toBe("/jobs");
expect(Object.fromEntries(route.searchParams.entries())).toEqual({ status: "Offer", sortBy: "dateApplied", sortDir: "asc" });
expect(mockedApi.get).toHaveBeenCalledTimes(1);
assertNoWrites();
});
test("distinguishes held and failed reads from empty results and recovers through one explicit identical retry", async () => {
const initial = deferred<ReturnType<typeof paged>>();
const retry = deferred<ReturnType<typeof paged>>();
mockedApi.get.mockImplementationOnce(() => initial.promise as any).mockImplementationOnce(() => retry.promise as any);
renderPage();
expect(screen.queryByText(translations.en.offerComparisonEmpty)).not.toBeInTheDocument();
expect(mockedApi.get).toHaveBeenCalledTimes(1);
await act(async () => { initial.reject({ response: { status: 503 } }); });
expect(await screen.findByRole("alert")).toHaveTextContent(translations.en.offerComparisonLoadFailed);
expect(screen.queryByText(translations.en.offerComparisonEmpty)).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: translations.en.retry }));
expect(screen.queryByRole("button", { name: translations.en.retry })).not.toBeInTheDocument();
expect(screen.queryByText(translations.en.offerComparisonEmpty)).not.toBeInTheDocument();
expect(mockedApi.get).toHaveBeenCalledTimes(2);
expect(mockedApi.get).toHaveBeenNthCalledWith(1, "/jobapplications", request);
expect(mockedApi.get).toHaveBeenNthCalledWith(2, "/jobapplications", request);
await act(async () => { retry.resolve(paged([offer(91)])); });
expect(await screen.findByRole("article", { name: "Offer role 91" })).toBeInTheDocument();
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
expect(screen.queryByText(translations.en.offerComparisonEmpty)).not.toBeInTheDocument();
expect(mockedApi.get).toHaveBeenCalledTimes(2);
assertNoWrites();
});
test("a live locale change translates loaded offer chrome and preserves recorded facts without another GET", async () => {
const notes = "Recorded English notes stay unchanged — æøå";
mockedApi.get.mockResolvedValue(paged([offer(8, { jobTitle: "English source title", notes, salary: "GBP package as recorded" })]) as any);
renderPage();
expect(await screen.findByText(notes)).toBeInTheDocument();
changeLanguage("nb");
expect(screen.getByRole("heading", { level: 2, name: translations.nb.offerComparisonTitle })).toBeInTheDocument();
const article = screen.getByRole("article", { name: "English source title" });
expect(within(article).getByText(notes)).toBeInTheDocument();
expect(within(article).getByText("GBP package as recorded")).toBeInTheDocument();
expect(within(article).getByText(translations.nb.statusOffer)).toBeInTheDocument();
expect(mockedApi.get).toHaveBeenCalledTimes(1);
assertNoWrites();
});
test("an existing read failure translates without refetching and Retry retains the same query", async () => {
mockedApi.get.mockRejectedValueOnce({ response: { status: 503 } }).mockResolvedValueOnce(paged([offer(9)]) as any);
renderPage();
expect(await screen.findByRole("alert")).toHaveTextContent(translations.en.offerComparisonLoadFailed);
changeLanguage("nb");
expect(screen.getByRole("alert")).toHaveTextContent(translations.nb.offerComparisonLoadFailed);
expect(mockedApi.get).toHaveBeenCalledTimes(1);
fireEvent.click(screen.getByRole("button", { name: translations.nb.retry }));
expect(await screen.findByRole("article", { name: "Offer role 9" })).toBeInTheDocument();
expect(mockedApi.get).toHaveBeenNthCalledWith(2, "/jobapplications", request);
expect(mockedApi.get).toHaveBeenCalledTimes(2);
assertNoWrites();
});
test("structured salaries follow UI locale rather than host locale while preserving zero, bounds, currency, unknown periods and free text", async () => {
const originalLocaleString = Number.prototype.toLocaleString;
jest.spyOn(Number.prototype, "toLocaleString").mockImplementation(function (
this: number,
locales?: Parameters<typeof Number.prototype.toLocaleString>[0],
options?: Parameters<typeof Number.prototype.toLocaleString>[1],
) {
return originalLocaleString.call(this, locales ?? "de-DE", options);
});
const rawSalary = " Negotiable — no numeric estimate ";
const items = [
offer(11, { salaryMin: 0, salaryCurrency: "NOK", salaryPeriod: "year" }),
offer(12, { salaryMin: 850000, salaryCurrency: "NOK", salaryPeriod: "month" }),
offer(13, { salaryMax: 900000, salaryCurrency: "USD", salaryPeriod: "hour" }),
offer(14, { salaryMin: 800000, salaryMax: 800000, salaryPeriod: "fortnight" }),
offer(15, { salary: rawSalary }),
offer(16, { salaryMin: 800000, salaryMax: 900000, salaryCurrency: "SEK" }),
offer(17, { salaryMin: 700000, salaryCurrency: "NOK", salaryPeriod: "constructor" }),
];
mockedApi.get.mockResolvedValue(paged(items) as any);
renderPage();
await screen.findByRole("article", { name: items[0].jobTitle });
const salaryText = (id: number) => {
const article = screen.getByRole("article", { name: `Offer role ${id}` });
const label = within(article).getByText(copy("offerComparisonCompensation", document.documentElement.lang === "nb-NO" ? "nb" : "en"));
return label.parentElement!.textContent!.replace(label.textContent!, "");
};
const normalizeSpaces = (value: string) => value.replace(/\s+/g, " ").trim();
for (const language of ["en", "nb"] as const) {
if (language === "nb") changeLanguage(language);
const fmt = (value: number) => new Intl.NumberFormat(language === "nb" ? "nb-NO" : "en").format(value);
expect(normalizeSpaces(salaryText(11))).toBe(`0 NOK / ${translations[language].salaryPeriodYear}`);
expect(normalizeSpaces(salaryText(12))).toBe(normalizeSpaces(`${fmt(850000)} NOK / ${translations[language].salaryPeriodMonth}`));
expect(normalizeSpaces(salaryText(13))).toBe(normalizeSpaces(`${fmt(900000)} USD / ${translations[language].salaryPeriodHour}`));
expect(normalizeSpaces(salaryText(14))).toBe(normalizeSpaces(`${fmt(800000)} – ${fmt(800000)} / fortnight`));
expect(salaryText(15)).toBe(rawSalary);
expect(normalizeSpaces(salaryText(16))).toBe(normalizeSpaces(`${fmt(800000)} – ${fmt(900000)} SEK`));
expect(normalizeSpaces(salaryText(17))).toBe(normalizeSpaces(`${fmt(700000)} NOK / constructor`));
}
expect(mockedApi.get).toHaveBeenCalledTimes(1);
assertNoWrites();
});
test("uses recorded applied/deadline/contact dates, keeps absent applied dates unknown, and opens the exact current application", async () => {
const item = offer(402, { jobTitle: "Recorded role", dateApplied: "2026-08-01", deadline: "2026-09-10", followUpAt: "2026-09-12" });
const missing = offer(407, { jobTitle: "No applied date", dateApplied: null, savedAt: "2026-07-20" });
mockedApi.get.mockResolvedValue(paged([item, missing]) as any);
renderPage("nb");
const article = await screen.findByRole("article", { name: item.jobTitle });
const fact = (label: string) => within(article).getByText(label).parentElement!;
const date = (value: string) => new Date(value).toLocaleDateString("nb-NO");
expect(fact(translations.nb.jobTableDateApplied)).toHaveTextContent(date(item.dateApplied!));
expect(fact(translations.nb.offerComparisonDecisionDeadline)).toHaveTextContent(date(item.deadline!));
expect(fact(translations.nb.companiesNextContact)).toHaveTextContent(date(item.followUpAt!));
expect(within(article).getByText(translations.nb.statusOffer)).toBeInTheDocument();
const missingArticle = screen.getByRole("article", { name: missing.jobTitle });
expect(within(missingArticle).getByText(translations.nb.jobTableDateApplied).parentElement).toHaveTextContent("—");
expect(within(missingArticle).queryByText(date(missing.savedAt))).not.toBeInTheDocument();
fireEvent.click(within(article).getByRole("button", { name: translations.nb.workspaceOpenFull }));
await waitFor(() => expect(screen.getByLabelText("Current route")).toHaveTextContent(/^\/jobs\/402$/));
expect(mockedApi.get).toHaveBeenCalledTimes(1);
assertNoWrites();
});
@@ -1,9 +1,11 @@
import { useCallback } from "react";
import { Alert, Box, Button, Chip, Paper, Skeleton, Stack, Typography } from "@mui/material";
import { useCallback, useRef } from "react";
import { Box, Button, Chip, Paper, Stack, Typography } from "@mui/material";
import { useNavigate } from "react-router-dom";
import { api } from "../api";
import { useViewResource } from "../hooks/useViewResource";
import { useI18n } from "../i18n/I18nProvider";
import ViewStateNotice from "../components/ViewStateNotice";
import { PageHeader } from "../layout/PageLayout";
import type { JobApplication } from "../types";
type PagedJobs = { items: JobApplication[]; total: number; page: number; pageSize: number };
@@ -11,44 +13,60 @@ type PagedJobs = { items: JobApplication[]; total: number; page: number; pageSiz
export default function OfferComparisonPage() {
const navigate = useNavigate();
const { language, t } = useI18n();
const load = useCallback(() => api.get<PagedJobs>("/jobapplications", { params: { status: "Offer", page: 1, pageSize: 25, sortBy: "deadline", sortDir: "asc" } }).then((r) => r.data), []);
const resource = useViewResource(load, { initialData: { items: [], total: 0, page: 1, pageSize: 25 }, errorMessage: t("offerComparisonLoadFailed"), deps: [t] });
const date = (value?: string | null) => value ? new Date(value).toLocaleDateString(language === "nb" ? "nb-NO" : "en") : "—";
const load = useCallback(() => api.get<PagedJobs>("/jobapplications", { params: { status: "Offer", page: 1, pageSize: 25, sortBy: "dateApplied", sortDir: "asc" } }).then((r) => r.data), []);
const resource = useViewResource(load, { initialData: { items: [], total: 0, page: 1, pageSize: 25 }, errorMessage: "" });
const resultsRef = useRef<HTMLDivElement>(null);
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 periods = new Map([["year", t("salaryPeriodYear")], ["month", t("salaryPeriodMonth")], ["hour", t("salaryPeriodHour")]]);
return (
<Stack spacing={2.5}>
<Box>
<Typography variant="h5" sx={{ fontWeight: 900 }}>{t("offerComparisonTitle")}</Typography>
<Typography color="text.secondary">{t("offerComparisonHelp")}</Typography>
</Box>
{resource.loading ? <Stack direction={{ xs: "column", lg: "row" }} spacing={2}>{[0, 1].map((n) => <Skeleton key={n} variant="rounded" height={360} sx={{ flex: 1 }} />)}</Stack> : null}
{resource.error ? <Alert severity="error" action={<Button color="inherit" onClick={() => void resource.reload()}>{t("retry")}</Button>}>{resource.error.message}</Alert> : null}
{!resource.loading && !resource.error && resource.data.items.length === 0 ? (
<Paper variant="outlined" sx={{ p: 4, textAlign: "center" }}>
<Typography variant="h6" sx={{ fontWeight: 800 }}>{t("offerComparisonEmpty")}</Typography>
<Stack spacing={2} sx={{ minWidth: 0 }}>
<PageHeader level="h2" title={t("offerComparisonTitle")} description={t("offerComparisonHelp")}
actions={!loading && !resource.error && resource.data.items.length > 0 ? <Button variant="outlined" onClick={() => navigate("/jobs?status=Offer&sortBy=dateApplied&sortDir=asc")}>{t("offerComparisonAllOffers")}</Button> : null} />
<Box ref={resultsRef} role="region" aria-label={t("offerComparisonTitle")} tabIndex={-1} aria-busy={loading} sx={{ minWidth: 0 }}>
<ViewStateNotice compact loading={loading} title={t("offerComparisonLoadFailed")}
error={resource.error ? { ...resource.error, message: resource.error.message || t("offerComparisonLoadFailed") } : null}
onRetry={() => {
if (loading || retryPending.current) return;
retryPending.current = true;
resultsRef.current?.focus();
void resource.reload().finally(() => { retryPending.current = false; });
}} />
{!loading && !resource.error && resource.data.items.length === 0 ? (
<Paper variant="outlined" sx={{ p: "var(--app-card-padding)", textAlign: "center" }}>
<Typography component="h3" variant="sectionTitle">{t("offerComparisonEmpty")}</Typography>
<Typography color="text.secondary" sx={{ mt: 0.5, mb: 2 }}>{t("offerComparisonEmptyHelp")}</Typography>
<Button variant="contained" onClick={() => navigate("/jobs")}>{t("jobApplications")}</Button>
</Paper>
) : null}
{!loading && !resource.error && resource.data.items.length > 0 ? <Box sx={{ mb: 2 }}>
<Typography variant="body2">{t("offerComparisonSampleCount", { shown: resource.data.items.length, total: resource.data.total })}</Typography>
{resource.data.total > resource.data.items.length ? <Typography variant="body2" color="text.secondary" sx={{ mt: 0.5, overflowWrap: "anywhere" }}>{t("offerComparisonSampleHelp")}</Typography> : null}
</Box> : null}
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "repeat(2, minmax(0, 1fr))", xl: "repeat(3, minmax(0, 1fr))" }, gap: 2 }}>
{resource.data.items.map((offer) => (
<Paper key={offer.id} variant="outlined" sx={{ p: 2.5, display: "flex", flexDirection: "column", minWidth: 0 }}>
<Paper key={offer.id} component="article" aria-label={offer.jobTitle} variant="outlined" sx={{ p: "var(--app-card-padding)", display: "flex", flexDirection: "column", minWidth: 0 }}>
<Stack direction="row" justifyContent="space-between" spacing={1} alignItems="flex-start">
<Box sx={{ minWidth: 0 }}><Typography variant="h6" sx={{ fontWeight: 900, overflowWrap: "anywhere" }}>{offer.jobTitle}</Typography><Typography color="text.secondary">{offer.company?.name}</Typography></Box>
<Chip size="small" color="success" label={t("statusOffer")} />
<Box sx={{ minWidth: 0 }}><Typography component="h3" variant="sectionTitle" sx={{ overflowWrap: "anywhere" }}>{offer.jobTitle}</Typography><Typography color="text.secondary" sx={{ overflowWrap: "anywhere" }}>{offer.company?.name}</Typography></Box>
<Chip size="small" color="success" label={t("statusOffer")} sx={{ flexShrink: 0 }} />
</Stack>
<Stack spacing={1.25} sx={{ my: 2 }}>
<Fact label={t("offerComparisonCompensation")} value={formatSalary(offer, t("offerComparisonNotRecorded"))} />
<Fact label={t("offerComparisonCompensation")} value={formatSalary(offer, t("offerComparisonNotRecorded"), locale, periods)} />
<Fact label={t("companiesLocation")} value={offer.location || t("offerComparisonNotRecorded")} />
<Fact label={t("jobTableDateApplied")} value={date(offer.dateApplied)} />
<Fact label={t("offerComparisonDecisionDeadline")} value={date(offer.deadline)} />
<Fact label={t("companiesNextContact")} value={date(offer.followUpAt)} />
<Fact label={t("workspaceNextAction")} value={offer.nextAction || t("offerComparisonNotRecorded")} />
<Fact label={t("offerComparisonNotes")} value={offer.notes || t("offerComparisonNotRecorded")} multiline />
</Stack>
<Button variant="outlined" sx={{ mt: "auto", alignSelf: "flex-start" }} onClick={() => navigate(`/jobs/${offer.id}`)}>{t("workspaceOpenFull")}</Button>
<Button variant="outlined" sx={{ mt: "auto", alignSelf: "flex-start", maxWidth: "100%", whiteSpace: "normal", overflowWrap: "anywhere" }} onClick={() => navigate(`/jobs/${offer.id}`)}>{t("workspaceOpenFull")}</Button>
</Paper>
))}
</Box>
</Box>
</Stack>
);
}
@@ -57,10 +75,11 @@ function Fact({ label, value, multiline = false }: { label: string; value: strin
return <Box><Typography variant="overline" color="text.secondary">{label}</Typography><Typography variant="body2" sx={{ whiteSpace: multiline ? "pre-wrap" : "normal", overflowWrap: "anywhere" }}>{value}</Typography></Box>;
}
function formatSalary(job: JobApplication, fallback: string) {
function formatSalary(job: JobApplication, fallback: string, locale: string, periods: Map<string, string>) {
if (job.salaryMin != null || job.salaryMax != null) {
const range = [job.salaryMin, job.salaryMax].filter((value) => value != null).map((value) => Number(value).toLocaleString()).join(" – ");
return `${range} ${job.salaryCurrency || ""}${job.salaryPeriod ? ` / ${job.salaryPeriod}` : ""}`.trim();
const range = [job.salaryMin, job.salaryMax].filter((value) => value != null).map((value) => Number(value).toLocaleString(locale)).join(" – ");
const period = job.salaryPeriod ? periods.get(job.salaryPeriod.toLowerCase()) ?? job.salaryPeriod : null;
return `${range} ${job.salaryCurrency || ""}${period ? ` / ${period}` : ""}`.trim();
}
return job.salary || fallback;
}