feat(ui): migrate recorded offer comparison
This commit is contained in:
@@ -49,4 +49,6 @@ Observed normal public push2026-10-12 22:24:44.035140UTC proves localHEAD=origin
|
||||
|
||||
Observed Discovery normal push2026-10-12 22:49:31.085724UTC proves localHEAD=origin=live34a4d6d and clean worktree, with local/origin/live main27657f3 unchanged. Companies successor records exact receipt and committed baseline; no runtime bytes replaced.
|
||||
|
||||
| P7-05 Companies | Actual hash follows observed normal push | [Results](p7-05/evidence/companies-results.json):30/4,new6+existing2+named4native,backend2,app/browser types/lint/build PASS;20/50images reviewed;new20scans0/91+named20WCAG0/163 qualified. Final hygiene/normal push pending. +7/0frontend689/93 and browser263 derived only; Offers/Reminders next, main unchanged |
|
||||
| P7-05 Companies | a8aba852994859d4c010cb57bb0607ec9c564bbe | [Results](p7-05/evidence/companies-results.json):30/4,new6+existing2+named4native,backend2,app/browser types/lint/build PASS;20/50images reviewed;new20scans0/91+named20WCAG0/163 qualified. Hygiene PASS/normal push directly verified2026-10-13 06:59:14UTC, clean localHEAD=origin=live/main unchanged. +7/0frontend689/93 and browser263 derived only; Offers/Reminders next, main unchanged |
|
||||
|
||||
| P7-05 Offers | Actual hash follows observed normal push | [Results](p7-05/evidence/offers-results.json):9/1,new4native,backend6,app/browser types/lint/build PASS;14images reviewed/14scans0/15qualified. Final hygiene/normal push pending. +7/0frontend696/93/browser267 derived only; Reminders next/main unchanged |
|
||||
|
||||
@@ -142,3 +142,7 @@ Public18 top-document all-enabled scans0/12 retain sandbox frame-tested occurren
|
||||
### P7-05 Companies evidence
|
||||
|
||||
[Companies results](p7-05/evidence/companies-results.json) repair demonstrated duplicate/held-save draft ownership, stale relation reads, deep-link hash/state loss, locale refetch, incorrect arbitrary-stage translation, stale response title and BODY focus after dialog exit. Local post-exit fallback preserves valid MUI/moved focus. New20 all-enabled scans0/91 and named20 WCAG0/163 retain complete unresolved overlap/occlusion/modal/contrast/wrapper vectors; finite keyboard/reflow proof does not close M01–M12. Date readbacks compare full calendar seconds without claiming SQLite Kind/DST equivalence. Existing shared cached consumers remain; no inherited debt or whole PRES group is closed.
|
||||
|
||||
### P7-05 Offers evidence
|
||||
|
||||
[Offers results](p7-05/evidence/offers-results.json) correct unsupported deadline-sort intent with explicit existing effectiveorder, unlabelled first25 sample, misleading decision-deadline wording, host-number locale and locale-triggered refetch/fallback/recovery focus. Original numeric/free-text/currency contract retained; no server data meaning change. Fourteenall-enabled scans0/15 retain contextual contrast incompletes; human/device/zoom/output/production obligations remain. No whole PRES or inherited scaling/calendar/provider debt closes.
|
||||
|
||||
@@ -160,3 +160,7 @@ Implemented P7-03 scope is **CONDITIONAL GO**. The normal import/history push `5
|
||||
### P7-05 Companies finite disposition
|
||||
|
||||
[Companies results](p7-05/evidence/companies-results.json): J01/J08/E08/W11 original nine-field payload/date/source/free-stage semantics, current response title, owner/tenant relation/latest20 projection, protected draft recovery, stale reads, query/hash/state/Back and post-exit focus. Fresh30/4/new6+existing2+named4native/backend2/types/lint/build pass;20/50 images actually reviewed. New20scans0/91 and named20WCAG0/163 remain qualified. No capability/route/API/schema/AI/output change; all57 remain56 PARTIAL/C11 DEFERRED and32 routes. Derived689/93/browser263 are not integrated totals. Cached consumers/manual/release obligations and Offers/Reminders remain; final hygiene/normal push pending.
|
||||
|
||||
### P7-05 Offers finite disposition
|
||||
|
||||
[Offers results](p7-05/evidence/offers-results.json): J01/J02/J08/W11 first25/actualtotal/effectiveorder, supported full-list route/Back/owned workspace, owner/foreign/anonymous boundaries and original salary/date/free-text contract. Fresh9/1/new4native/backend6/types/lint/build pass;14actual image reviews and14all-enabledscans0/15 qualified. Unknown-period compatibility is explicit legacyGETprojection, not persistence proof. All57 remain56 PARTIAL/C11 DEFERRED/32routes; derived696/93/browser267 are not integrated totals. Reminders/manual/release obligations remain; final hygiene/normal push pending.
|
||||
|
||||
@@ -13,7 +13,7 @@ The entry commit is `b5319981ad3d1bd635daff861453d23c0de5320b` on `codex/ui-rede
|
||||
| [P7-02](p7-02/README.md) | Onboarding and Career overview/evidence | Entryad7b419 pushed/verified; bounded onboarding CONDITIONAL GO32/13/4+types/lint/build/13 actual PNG inspections, hygiene PASS and d21501aa push verified23:55:12UTC. Separate SQLite evidence-list repair91 guards/HTTP1/Release passed, pushed01281f52/direct receipt00:06:47UTC with unfinished Career work preserved. Career affected46/3/native14/preservation2/types/lint/build and actual22-image review pass; implemented checkpoint CONDITIONAL GO with99 incompletes/manual gates. Career final hygiene PASS (four commands0,9docs347links0errors); normally pushed8b4d5c2/direct localHEAD=origin=live receipt05:51:04UTC, clean worktree/main unchanged |
|
||||
| [P7-03](p7-03/README.md) | Career structured editing and import review | Source-ID and structured-editor commits pushed; final import/history runtime/design CONDITIONAL GO: affected76/6, native35, backend98/seeder24, types/lint/build PASS;33/54 final images inspected. Import17 scans0/302, editor13 scans0/377, Career22 scans0/99 retained. Final staged hygiene PASS; normal push5d6e3235 directly verified17:28:54UTC with clean worktree/main unchanged. RD21/human/provider/output gates remain;57/32 unchanged |
|
||||
| [P7-04](p7-04/README.md) | CV library and public entry | Implemented CONDITIONAL GO; library fcacae7 push verified. Public affected38/3/native8/types/lint/build and fresh fidelity16/36+8/24 PASS;18 images actually reviewed, frame-tested12/manual gates retained. Public final hygiene PASS/normal push bfa0ab6 directly verified22:24:44UTC, clean main unchanged |
|
||||
| [P7-05](p7-05/README.md) | Discovery, Companies, Offers and Reminders | Entry bfa0ab6 verified; Discovery21/1,new8+existing4native/backend14/types/lint/build pass;18 actual PNG reviews/qualified81+52incompletes. Discovery implemented CONDITIONAL GO, hygiene PASS/normal push34a4d6d verified; Companies30/4,new6+old2+named4native/backend2/types/lint/build pass;20actual PNG reviews/qualified91+163incompletes. Companies implemented CONDITIONAL GO, hygiene PASS/normal push pending; Offers/Reminders next as separate commits |
|
||||
| [P7-05](p7-05/README.md) | Discovery, Companies, Offers and Reminders | Entry bfa0ab6 verified; Discovery21/1,new8+existing4native/backend14/types/lint/build pass;18 actual PNG reviews/qualified81+52incompletes. Discovery implemented CONDITIONAL GO, hygiene PASS/normal push34a4d6d verified; Companies30/4,new6+old2+named4native/backend2/types/lint/build pass;20actual PNG reviews/qualified91+163incompletes. Companies implemented CONDITIONAL GO, hygiene PASS/normal pusha8aba85 verified; Offers9/1,new4native/backend6/types/lint/build pass;14actual PNG reviews/15incompletes qualified. Offers implemented CONDITIONAL GO, hygiene PASS/normal push pending; Reminders next |
|
||||
| P7-06 | Remaining Application forms and global correspondence | Existing seven-section workspace, immutable packages and draft/send owners; planned |
|
||||
| P7-06F | Deferred `followMode` reviewed composer | Optional separately reviewed mail slice; **DEFERRED** until mode→draft/review action and fallback are explicitly decided |
|
||||
| P7-07 | Documents owner-scoped index | Authenticated metadata-only paginated backend projection first; source destinations from P7-04/06; planned |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# P7-05 — Discovery, Companies, Offers and Reminders
|
||||
|
||||
**Discovery implemented scope CONDITIONAL GO; hygiene PASS/normal push34a4d6d verified. Companies, Offers and Reminders remain pending.** Follow the exact [approved handoff](../../phase-6/p6-05/PHASE7_HANDOFF.md#migration-order-and-commit-boundaries): Discovery, Companies, Offers, then Reminders as four separate normal development commits.
|
||||
**Discovery implemented scope CONDITIONAL GO; hygiene PASS/normal push34a4d6d verified. Companies hygiene PASS/normal pusha8aba85 verified; Offers and Reminders remain pending.** Follow the exact [approved handoff](../../phase-6/p6-05/PHASE7_HANDOFF.md#migration-order-and-commit-boundaries): Discovery, Companies, Offers, then Reminders as four separate normal development commits.
|
||||
|
||||
[Entry evidence](evidence/entry.json) records the actual bfa0ab6 public push, clean direct development equality/main unchanged and13 exact committed source baselines. P7-04 implemented scope is CONDITIONAL GO: affected38/3/public native8/fidelity16/36+8/24/types/lint/build/static checks pass;18 public PNGs actually inspected. Sandbox frame-tested12 and separate settings incompletes223 remain. Whole frontend573/89/backend1,033/sidecar49 are inherited; frontend673/93/browser249/backend1,034 are derived expectations, not integrated reruns.
|
||||
|
||||
@@ -15,7 +15,7 @@ PRES-J01/J02/J08/J09/W11 and all57 groups remain binding;32 routes stay. No stan
|
||||
|
||||
## Discovery implemented result
|
||||
|
||||
**Discovery implemented scope CONDITIONAL GO; hygiene PASS/normal push34a4d6d verified. Companies, Offers and Reminders remain pending.** [Finite results](evidence/discovery-results.json) retain exact final source/build/raw outcomes and failed attempts. Fresh affected **21/1**, new ordinary browser **8**, existing ordinary browser **4**, backend **14**, app/browser TypeScript, lint and production build pass. The original12 unit cases remain; +9/0 gives **682/93 frontend derived only**. Browser+8 gives **257 derived only**; no fresh integrated totals until P7-12.
|
||||
**Discovery implemented scope CONDITIONAL GO; hygiene PASS/normal push34a4d6d verified. Companies hygiene PASS/normal pusha8aba85 verified; Offers and Reminders remain pending.** [Finite results](evidence/discovery-results.json) retain exact final source/build/raw outcomes and failed attempts. Fresh affected **21/1**, new ordinary browser **8**, existing ordinary browser **4**, backend **14**, app/browser TypeScript, lint and production build pass. The original12 unit cases remain; +9/0 gives **682/93 frontend derived only**. Browser+8 gives **257 derived only**; no fresh integrated totals until P7-12.
|
||||
|
||||
Shell retains its H1 and intro. Saved searches use the established section title/tokens; results use a shared H2, feedback and named focusable region. Job cards are named articles with H3 headings and wrapping actions. Manual search clears saved context; switching saved sources clears old rows before the pending response. One synchronous operation guard prevents duplicate requests and locks the three draft fields and dependent mutations. Read Retry uses the exact submitted GET or bodyless saved-ID POST while preserving both later unsaved filter drafts. Mutation failures preserve their drafts/current rows and require the original action, without an unrelated manual-search Retry. Semantic fallback keys retranslate without refetching.
|
||||
|
||||
@@ -29,7 +29,7 @@ Meaningful initial unit RED11/10, peer Retry RED20/1, first native3/5 and focus
|
||||
|
||||
## Companies implemented result
|
||||
|
||||
**Companies implemented scope CONDITIONAL GO; hygiene PASS/normal push pending. Offers and Reminders remain pending.** [Companies entry](evidence/companies-entry.json) verifies the preceding Discovery push34a4d6d and six committed baselines. [Finite results](evidence/companies-results.json) retain exact source/export, failures, contextual triage and all raw outcomes. Fresh affected **30/4**, new ordinary browser **6**, existing company recovery **2**, named-controls **4**, backend **2**, app/browser TypeScript, lint and production build pass. +7 cases/0 suites gives **689/93 frontend derived only**; browser+6 gives **263 derived only**. Latest whole frontend573/89/backend1,033/sidecar49 remain inherited; P7-12 integrated acceptance is pending.
|
||||
**Companies implemented scope CONDITIONAL GO; hygiene PASS/normal pusha8aba85 verified. Offers and Reminders remain pending.** [Companies entry](evidence/companies-entry.json) verifies the preceding Discovery push34a4d6d and six committed baselines. [Finite results](evidence/companies-results.json) retain exact source/export, failures, contextual triage and all raw outcomes. Fresh affected **30/4**, new ordinary browser **6**, existing company recovery **2**, named-controls **4**, backend **2**, app/browser TypeScript, lint and production build pass. +7 cases/0 suites gives **689/93 frontend derived only**; browser+6 gives **263 derived only**. Latest whole frontend573/89/backend1,033/sidecar49 remain inherited; P7-12 integrated acceptance is pending.
|
||||
|
||||
Shared tokens and semantic mobile articles/headings replace one-off surfaces. Desktop uses a named keyboard-scrollable table with row headers and predictable source/pipeline/date/action columns; its840px canvas scrolls within the container without app-chrome overflow. Existing nine-field PUT, trim/null/date/source semantics, existing routes and arbitrary company pipeline text stay intact. Canonical related application statuses use existing localisation. Loaded relationship title prefers fresh response metadata; selected name remains available during loading.
|
||||
|
||||
@@ -44,3 +44,21 @@ Real isolated SQLite save/reload, source normalization, latest20 active contacts
|
||||
Baseline6/2, corrected RED6/7, expanded RED26/4, raw-stage/title RED11/1 and native4/2 plus focused0/1 are frozen. No skipped/expected-failure/retried tests, relaxed original assertions or disabled axe rules. All57 statuses remain56 PARTIAL/C11 DEFERRED;32 routes. Main stays27657f3/production NO-GO, P5-13 withheld and Phase8 separate.
|
||||
|
||||
[Final finite hygiene](evidence/companies-static-checks.json) passed four raw commands at2026-10-13 06:58:01–05UTC,12documents/425links0errors,57groups/32routes; final staged whitespace is checked after staging. Existing named-controls12 dialog scans assert all WCAG-tagged violations,8 full-page scans gate five historical rule IDs; all retained raw scanned violations are independently0. These scopes do not replace the new20 all-enabled Companies scans. Normal push receipt follows observation in the next slice.
|
||||
|
||||
### Observed Companies push and Offers entry
|
||||
|
||||
Companies was normally pushed as a8aba852994859d4c010cb57bb0607ec9c564bbe; direct equality of local HEAD/origin/live was observed2026-10-13 06:59:14.861602UTC with a clean tree and local/origin/live main unchanged27657f3. Offers follows as a separate bounded domain commit; its two-case source baseline passed before changes. Legacy salary DTO projection, where needed, is distinguished from backend-normalized stored records.
|
||||
|
||||
## Offers implemented result
|
||||
|
||||
**Offers implemented scope CONDITIONAL GO; hygiene PASS/normal push pending. Reminders remains pending.** [Offers entry](evidence/offers-entry.json) carries the verified Companies pusha8aba85 and exact committed baselines. [Finite results](evidence/offers-results.json) record fresh **9/1 frontend**, **4 ordinary native**, **6 unchanged-backend** cases and app/browser TypeScript, lint and production build PASS. +7/0 gives **696/93 frontend derived only**; browser+4 gives **267 derived only**. Latest whole573/89/backend1,033/sidecar49 are inherited until P7-12.
|
||||
|
||||
The existing first25 comparison is labelled with shown/actualtotal; when truncated, copy explains application-date order and links to the complete supported Jobs list. ExplicitdateAppliedasc preserves the actual old deadline-fallback order. Existing source route/filter, Back and owned Job workspace response/navigation pass. Shared H2/header, feedback, named stable region, H3 articles, card tokens and wrapping actions match the design system. Read Retry uses the original query, transfers focus before disabling its control and guards duplicates. Live EN/Bokmål changes retranslate loaded/error states without another Offer read. Empty/error/pending are distinguished.
|
||||
|
||||
Recorded application date and application deadline remain distinct from offer-decision data. Existing numeric zero/single/equal bounds, range separator, currency and untrimmed salary free text are preserved. Number/date formatting follows UI language; known year/month/hour use existing translations; unknown literals includingconstructor remain raw throughMap.get. No shared salary helper replacement: its abbreviated periods and equal-bound collapse would change this established presentation contract. Current backend writes normalize unknown salary periods to null; the explicit legacyfortnight GET projection proves display compatibility only, with actual null readback retained. No conversion, new decision deadline or comparison paging feature.
|
||||
|
||||
All **14 latest PNGs were displayed/reviewed**, including eight balanced320/360/390/768/1024/1280 landscape/1440/1920 EN/Bokmål/light/dark samples, actual empty/salary and controlled read/recovery states. Root overflow0 and native keyboard source/Job actions and stable Retry focus pass. The25-record long-page capture was display-resized; exact25 titles/order/exclusion are DOM/HTTP assertions rather than every-pixel acceptance. Fourteen all-enabled axe+target-size scans retain **0 violations/15 incompletes** with full context; human reader, contrast qualification, real zoom, physical touch/software keyboard and deployed output remain open.
|
||||
|
||||
Unit RED1/8 becomes9/1; a test-only narrow Intl.Locale mock type was corrected without altered assertions after preserving failed types/build. Native first3/1 timeout was a test wait for plain detail; actual Job navigation correctly used the existingworkspace endpoint. Correct endpoint plus exact ownedID/title and unchanged120s timeout now pass4/4. No skips/expected failures/retries/relaxed assertions or axe waivers. All57 remain56 PARTIAL/C11 DEFERRED,32 routes, main27657f3/production NO-GO; P5-13 withheld and Phase8 separate.
|
||||
|
||||
[Final finite hygiene](evidence/offers-static-checks.json): four raw commands0 at2026-10-13 11:18:52–55UTC,12documents/431links0errors,57groups/32routes. Contrast15 comprises14 invisible-zero badge short-text nodes and1 unknown-background overlap on the selected Norsk header button in NB/light1440 held Retry; relatedNodes are empty and ratio0 inconclusive. Deliberate focus is visible in captures, but full-page landscape focus below the initial fold after capture scroll reset is not viewport-occlusion qualification. Final staged whitespace follows staging; normal push receipt follows observation in the successor.
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"checkpoint": "P7-05",
|
||||
"slice": "Companies",
|
||||
"recordedUtc": "2026-10-13T06:57:23.030262+00:00",
|
||||
"acceptance": "COMPANIES_IMPLEMENTED_SCOPE_CONDITIONAL_GO_HYGIENE_PASS_NORMAL_PUSH_PENDING_OFFERS_REMINDERS_PENDING",
|
||||
"acceptance": "COMPANIES_IMPLEMENTED_SCOPE_CONDITIONAL_GO_HYGIENE_PASS_NORMAL_PUSH_VERIFIED_OFFERS_REMINDERS_PENDING",
|
||||
"entryReceipt": {
|
||||
"checkpoint": "P7-05",
|
||||
"slice": "Companies",
|
||||
@@ -4509,5 +4509,105 @@
|
||||
"Static evidence is not runtime/manual/device/provider/production acceptance."
|
||||
]
|
||||
},
|
||||
"peerReview": "Read-only connected-contract review found no actionable introduced regression; no extra runtime claimed."
|
||||
"peerReview": "Read-only connected-contract review found no actionable introduced regression; no extra runtime claimed.",
|
||||
"push": {
|
||||
"observedUtc": "2026-10-13T06:59:14.861602+00:00",
|
||||
"branch": "codex/ui-redesign-phase4",
|
||||
"head": "a8aba852994859d4c010cb57bb0607ec9c564bbe",
|
||||
"originDevelopment": "a8aba852994859d4c010cb57bb0607ec9c564bbe",
|
||||
"liveDevelopment": "a8aba852994859d4c010cb57bb0607ec9c564bbe",
|
||||
"main": "27657f31131cd95d42f0e1290853ec9c9b3959a4",
|
||||
"originMain": "27657f31131cd95d42f0e1290853ec9c9b3959a4",
|
||||
"liveMain": "27657f31131cd95d42f0e1290853ec9c9b3959a4",
|
||||
"workingTreeClean": true,
|
||||
"pendingPaths": [],
|
||||
"commands": [
|
||||
{
|
||||
"command": [
|
||||
"rtk",
|
||||
"proxy",
|
||||
"git",
|
||||
"branch",
|
||||
"--show-current"
|
||||
],
|
||||
"exitCode": 0,
|
||||
"stdout": "codex/ui-redesign-phase4\n",
|
||||
"stderr": "[rtk] /!\\ No hook installed — run `rtk init -g` for automatic token savings\n"
|
||||
},
|
||||
{
|
||||
"command": [
|
||||
"rtk",
|
||||
"proxy",
|
||||
"git",
|
||||
"rev-parse",
|
||||
"HEAD"
|
||||
],
|
||||
"exitCode": 0,
|
||||
"stdout": "a8aba852994859d4c010cb57bb0607ec9c564bbe\n",
|
||||
"stderr": "[rtk] /!\\ No hook installed — run `rtk init -g` for automatic token savings\n"
|
||||
},
|
||||
{
|
||||
"command": [
|
||||
"rtk",
|
||||
"proxy",
|
||||
"git",
|
||||
"rev-parse",
|
||||
"origin/codex/ui-redesign-phase4"
|
||||
],
|
||||
"exitCode": 0,
|
||||
"stdout": "a8aba852994859d4c010cb57bb0607ec9c564bbe\n",
|
||||
"stderr": "[rtk] /!\\ No hook installed — run `rtk init -g` for automatic token savings\n"
|
||||
},
|
||||
{
|
||||
"command": [
|
||||
"rtk",
|
||||
"proxy",
|
||||
"git",
|
||||
"rev-parse",
|
||||
"main"
|
||||
],
|
||||
"exitCode": 0,
|
||||
"stdout": "27657f31131cd95d42f0e1290853ec9c9b3959a4\n",
|
||||
"stderr": "[rtk] /!\\ No hook installed — run `rtk init -g` for automatic token savings\n"
|
||||
},
|
||||
{
|
||||
"command": [
|
||||
"rtk",
|
||||
"proxy",
|
||||
"git",
|
||||
"rev-parse",
|
||||
"origin/main"
|
||||
],
|
||||
"exitCode": 0,
|
||||
"stdout": "27657f31131cd95d42f0e1290853ec9c9b3959a4\n",
|
||||
"stderr": "[rtk] /!\\ No hook installed — run `rtk init -g` for automatic token savings\n"
|
||||
},
|
||||
{
|
||||
"command": [
|
||||
"rtk",
|
||||
"proxy",
|
||||
"git",
|
||||
"ls-remote",
|
||||
"origin",
|
||||
"refs/heads/codex/ui-redesign-phase4",
|
||||
"refs/heads/main"
|
||||
],
|
||||
"exitCode": 0,
|
||||
"stdout": "a8aba852994859d4c010cb57bb0607ec9c564bbe\trefs/heads/codex/ui-redesign-phase4\n27657f31131cd95d42f0e1290853ec9c9b3959a4\trefs/heads/main\n",
|
||||
"stderr": "[rtk] /!\\ No hook installed — run `rtk init -g` for automatic token savings\n"
|
||||
},
|
||||
{
|
||||
"command": [
|
||||
"rtk",
|
||||
"proxy",
|
||||
"git",
|
||||
"status",
|
||||
"--porcelain=v1"
|
||||
],
|
||||
"exitCode": 0,
|
||||
"stdout": "",
|
||||
"stderr": "[rtk] /!\\ No hook installed — run `rtk init -g` for automatic token savings\n"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
{
|
||||
"checkpoint": "P7-05",
|
||||
"slice": "Offers",
|
||||
"entry": "CONDITIONAL_GO",
|
||||
"previousCompaniesPush": {
|
||||
"observedUtc": "2026-10-13T06:59:14.861602+00:00",
|
||||
"branch": "codex/ui-redesign-phase4",
|
||||
"head": "a8aba852994859d4c010cb57bb0607ec9c564bbe",
|
||||
"originDevelopment": "a8aba852994859d4c010cb57bb0607ec9c564bbe",
|
||||
"liveDevelopment": "a8aba852994859d4c010cb57bb0607ec9c564bbe",
|
||||
"main": "27657f31131cd95d42f0e1290853ec9c9b3959a4",
|
||||
"originMain": "27657f31131cd95d42f0e1290853ec9c9b3959a4",
|
||||
"liveMain": "27657f31131cd95d42f0e1290853ec9c9b3959a4",
|
||||
"workingTreeClean": true,
|
||||
"pendingPaths": [],
|
||||
"commands": [
|
||||
{
|
||||
"command": [
|
||||
"rtk",
|
||||
"proxy",
|
||||
"git",
|
||||
"branch",
|
||||
"--show-current"
|
||||
],
|
||||
"exitCode": 0,
|
||||
"stdout": "codex/ui-redesign-phase4\n",
|
||||
"stderr": "[rtk] /!\\ No hook installed — run `rtk init -g` for automatic token savings\n"
|
||||
},
|
||||
{
|
||||
"command": [
|
||||
"rtk",
|
||||
"proxy",
|
||||
"git",
|
||||
"rev-parse",
|
||||
"HEAD"
|
||||
],
|
||||
"exitCode": 0,
|
||||
"stdout": "a8aba852994859d4c010cb57bb0607ec9c564bbe\n",
|
||||
"stderr": "[rtk] /!\\ No hook installed — run `rtk init -g` for automatic token savings\n"
|
||||
},
|
||||
{
|
||||
"command": [
|
||||
"rtk",
|
||||
"proxy",
|
||||
"git",
|
||||
"rev-parse",
|
||||
"origin/codex/ui-redesign-phase4"
|
||||
],
|
||||
"exitCode": 0,
|
||||
"stdout": "a8aba852994859d4c010cb57bb0607ec9c564bbe\n",
|
||||
"stderr": "[rtk] /!\\ No hook installed — run `rtk init -g` for automatic token savings\n"
|
||||
},
|
||||
{
|
||||
"command": [
|
||||
"rtk",
|
||||
"proxy",
|
||||
"git",
|
||||
"rev-parse",
|
||||
"main"
|
||||
],
|
||||
"exitCode": 0,
|
||||
"stdout": "27657f31131cd95d42f0e1290853ec9c9b3959a4\n",
|
||||
"stderr": "[rtk] /!\\ No hook installed — run `rtk init -g` for automatic token savings\n"
|
||||
},
|
||||
{
|
||||
"command": [
|
||||
"rtk",
|
||||
"proxy",
|
||||
"git",
|
||||
"rev-parse",
|
||||
"origin/main"
|
||||
],
|
||||
"exitCode": 0,
|
||||
"stdout": "27657f31131cd95d42f0e1290853ec9c9b3959a4\n",
|
||||
"stderr": "[rtk] /!\\ No hook installed — run `rtk init -g` for automatic token savings\n"
|
||||
},
|
||||
{
|
||||
"command": [
|
||||
"rtk",
|
||||
"proxy",
|
||||
"git",
|
||||
"ls-remote",
|
||||
"origin",
|
||||
"refs/heads/codex/ui-redesign-phase4",
|
||||
"refs/heads/main"
|
||||
],
|
||||
"exitCode": 0,
|
||||
"stdout": "a8aba852994859d4c010cb57bb0607ec9c564bbe\trefs/heads/codex/ui-redesign-phase4\n27657f31131cd95d42f0e1290853ec9c9b3959a4\trefs/heads/main\n",
|
||||
"stderr": "[rtk] /!\\ No hook installed — run `rtk init -g` for automatic token savings\n"
|
||||
},
|
||||
{
|
||||
"command": [
|
||||
"rtk",
|
||||
"proxy",
|
||||
"git",
|
||||
"status",
|
||||
"--porcelain=v1"
|
||||
],
|
||||
"exitCode": 0,
|
||||
"stdout": "",
|
||||
"stderr": "[rtk] /!\\ No hook installed — run `rtk init -g` for automatic token savings\n"
|
||||
}
|
||||
]
|
||||
},
|
||||
"baselineSources": [
|
||||
{
|
||||
"path": "job-tracker-ui/src/views/OfferComparisonPage.tsx",
|
||||
"gitBlobSha256": "2886abcc12451fbd1af48e174195b2769d3d2d4ffaa24d33205ef6ba770e4af9",
|
||||
"bytes": 4633
|
||||
},
|
||||
{
|
||||
"path": "job-tracker-ui/src/offer-comparison.test.tsx",
|
||||
"gitBlobSha256": "07e7e0feccae89c9357f2cb23f4f4966f5f3040748733a52a5c2e7ca02a76db2",
|
||||
"bytes": 2151
|
||||
},
|
||||
{
|
||||
"path": "job-tracker-ui/src/salary.ts",
|
||||
"gitBlobSha256": "9c46a23c52a5fa06ab76f44d4e0538c99697e2905e2fec4b63d9f43fc2b924cd",
|
||||
"bytes": 998
|
||||
},
|
||||
{
|
||||
"path": "job-tracker-ui/src/i18n/translations.ts",
|
||||
"gitBlobSha256": "e71fcb064e0d04f944d31076a5b51b98671c7711af280a0d35b17f325d321bb3",
|
||||
"bytes": 314840
|
||||
},
|
||||
{
|
||||
"path": "JobTrackerApi/Controllers/JobApplicationsController.cs",
|
||||
"gitBlobSha256": "381abecdc11cc1712a766bc20f272cb917913bd0a14e88e9c0b76a279fb99290",
|
||||
"bytes": 102550
|
||||
}
|
||||
],
|
||||
"baseline": "Affected original2/1 PASS at2026-10-13 06:07UTC; original raw reports moved under verified move receipt to ignored offers/unit-baseline; legacy original path in receipts preserved. Full573/89/backend1033/sidecar49 inherited;689/93/frontend263browser/backend1034 derived only.",
|
||||
"scope": "Preserve first25 Offer sample and effective dateAppliedasc thenID order; show true total/source; preserve numericzero/single/equal/freeText/currency and localise knownperiods/UIlocale. Existing deadline is applicationdeadline, no new decisiondeadline/paging/write/schema/provider/model.",
|
||||
"backendBaseline": {
|
||||
"passed": 6,
|
||||
"failed": 0,
|
||||
"command": {
|
||||
"command": [
|
||||
"rtk",
|
||||
"proxy",
|
||||
"C:/Users/Connor/.dotnet/dotnet.exe",
|
||||
"test",
|
||||
"JobTrackerApi.Tests/JobTrackerApi.Tests.csproj",
|
||||
"--configuration",
|
||||
"Release",
|
||||
"--no-build",
|
||||
"--filter",
|
||||
"FullyQualifiedName~JobApplicationsEndpointBehaviorTests.Create_normalizes_structured_salary|FullyQualifiedName~JobApplicationsEndpointBehaviorTests.Update_normalizes_salary|FullyQualifiedName~JobApplicationsAuthorizationTests",
|
||||
"--logger",
|
||||
"trx;LogFileName=career.trx",
|
||||
"--results-directory",
|
||||
"E:\\Storage\\Projects\\Active\\Job tracker\\.artifacts\\phase7\\current\\backend-career\\p705-offers-20261013T070413Z"
|
||||
],
|
||||
"startedUtc": "2026-10-13T07:04:13.564332+00:00",
|
||||
"finishedUtc": "2026-10-13T07:04:16.390885+00:00",
|
||||
"exitCode": 0,
|
||||
"logSha256": "c26e7dd98bfca2847f2727a922feccfa68857b5beed0b43789139da529e672c6",
|
||||
"counts": {
|
||||
"total": 6,
|
||||
"passed": 6,
|
||||
"failed": 0,
|
||||
"notExecuted": 0
|
||||
},
|
||||
"resultSha256": "18c96f6d957ad1c2402d98a11b7ed8de3b58f72a8f647d7da4c7c004e665a246"
|
||||
},
|
||||
"qualification": "Existing salary normalization2 and JobApplicationsAuthorization4 on same verified Release DLL; no backend rebuild/change. No whole backend rerun."
|
||||
},
|
||||
"plannedCountDelta": {
|
||||
"frontendNewOrdinary": 7,
|
||||
"frontendExpectedDerivedOnly": "696/93",
|
||||
"browserNewOrdinary": 4,
|
||||
"browserExpectedDerivedOnly": 267,
|
||||
"finalIntegrated": "P7-12 pending"
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,104 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"checkpoint": "p7-05",
|
||||
"status": "PASS",
|
||||
"checkedUtc": "2026-10-13T11:18:55.517193+00:00",
|
||||
"commands": [
|
||||
{
|
||||
"name": "secret-scan",
|
||||
"command": [
|
||||
"rtk",
|
||||
"proxy",
|
||||
"C:\\Users\\Connor\\AppData\\Local\\Programs\\Python\\Python312\\python.exe",
|
||||
"-X",
|
||||
"utf8",
|
||||
"scripts/supply-chain.py",
|
||||
"scan-secrets"
|
||||
],
|
||||
"startedUtc": "2026-10-13T11:18:52.164850+00:00",
|
||||
"finishedUtc": "2026-10-13T11:18:54.802698+00:00",
|
||||
"exitCode": 0,
|
||||
"log": ".artifacts/phase7/current/static/20261013T111852Z/secret-scan.log",
|
||||
"logSha256Raw": "c5a88d5ebdc042adba125c1eaacbe44f9f4a5c19898c0f195573c00d511aa28d"
|
||||
},
|
||||
{
|
||||
"name": "working-whitespace",
|
||||
"command": [
|
||||
"rtk",
|
||||
"proxy",
|
||||
"git",
|
||||
"diff",
|
||||
"--check"
|
||||
],
|
||||
"startedUtc": "2026-10-13T11:18:54.803197+00:00",
|
||||
"finishedUtc": "2026-10-13T11:18:54.913983+00:00",
|
||||
"exitCode": 0,
|
||||
"log": ".artifacts/phase7/current/static/20261013T111852Z/working-whitespace.log",
|
||||
"logSha256Raw": "1ad9cdcf0c20e8f3e6e8a8f073e3e38a08f70bb875d1a9e51f94197bffc046b5"
|
||||
},
|
||||
{
|
||||
"name": "staged-whitespace",
|
||||
"command": [
|
||||
"rtk",
|
||||
"proxy",
|
||||
"git",
|
||||
"diff",
|
||||
"--cached",
|
||||
"--check"
|
||||
],
|
||||
"startedUtc": "2026-10-13T11:18:54.914488+00:00",
|
||||
"finishedUtc": "2026-10-13T11:18:54.993373+00:00",
|
||||
"exitCode": 0,
|
||||
"log": ".artifacts/phase7/current/static/20261013T111852Z/staged-whitespace.log",
|
||||
"logSha256Raw": "d2993bc68ad9a63f711c8f5473155e2ba00fdcd461220cc3849185f2f9ad93e6"
|
||||
},
|
||||
{
|
||||
"name": "phase7-whitespace",
|
||||
"command": [
|
||||
"rtk",
|
||||
"proxy",
|
||||
"git",
|
||||
"diff",
|
||||
"--check",
|
||||
"b5319981ad3d1bd635daff861453d23c0de5320b"
|
||||
],
|
||||
"startedUtc": "2026-10-13T11:18:54.993865+00:00",
|
||||
"finishedUtc": "2026-10-13T11:18:55.149009+00:00",
|
||||
"exitCode": 0,
|
||||
"log": ".artifacts/phase7/current/static/20261013T111852Z/phase7-whitespace.log",
|
||||
"logSha256Raw": "9ad9611d63e6009b19e629bb7b699f607c956ef7cbbab73d3e43a03813d15b69"
|
||||
}
|
||||
],
|
||||
"untrackedSecretScan": {
|
||||
"files": 3,
|
||||
"findings": 0
|
||||
},
|
||||
"documentation": {
|
||||
"files": 12,
|
||||
"relativeLinks": 431,
|
||||
"errors": []
|
||||
},
|
||||
"preservation": {
|
||||
"groups": 57,
|
||||
"statuses": {
|
||||
"PARTIAL": 56,
|
||||
"PASS": 0,
|
||||
"FAIL": 0,
|
||||
"DEFERRED": 1
|
||||
},
|
||||
"originalRoutesPreserved": true,
|
||||
"currentConcreteRoutes": 32
|
||||
},
|
||||
"production": {
|
||||
"main": "27657f31131cd95d42f0e1290853ec9c9b3959a4",
|
||||
"originMain": "27657f31131cd95d42f0e1290853ec9c9b3959a4",
|
||||
"status": "NO-GO",
|
||||
"qualification": "Local fetched refs; direct push observation recorded separately."
|
||||
},
|
||||
"hashPolicy": "Portable JSON UTF8 LF; raw log hashes measure exact saved bytes.",
|
||||
"limits": [
|
||||
"Pattern scanner skips binary and files over2MiB; not historical Git/production secrets.",
|
||||
"Local links check target existence, not URL/heading anchors.",
|
||||
"Static evidence is not runtime/manual/device/provider/production acceptance."
|
||||
]
|
||||
}
|
||||
@@ -87,7 +87,7 @@ Follow the approved [Phase 7 handoff](../redesign/phase-6/p6-05/PHASE7_HANDOFF.m
|
||||
- [x] P7-02 [onboarding guidance and Career overview/evidence](../redesign/phase-7/p7-02/README.md): entryad7b419 pushed/verified; onboarding implemented-scope CONDITIONAL GO32/13/4+types/lint/build/13 actual PNG inspections/hygiene, pushedd21501aa/direct receipt23:55:12UTC. Separate actual SQLite evidence-list repair91 guards/HTTP1/Release passed and pushed01281f52/direct receipt00:06:47UTC; unfinished Career work preserved/main unchanged. Career affected46/3/native14/fresh preservation2/types/lint/build and22 actual PNG inspections pass; implemented P7-02 CONDITIONAL GO, final Career hygiene PASS (four commands0,9docs347links0errors) and normal push8b4d5c2 directly verified05:51:04UTC, localHEAD=origin=live/clean worktree/main unchanged. Career0/99 axe incompletes/manual gates retained; fullfrontend610/backend1,034 are derived only, latest fresh full573/89 unchanged.
|
||||
- [x] P7-03 Career structured editor and import/history: Implemented P7-03 scope is **CONDITIONAL GO**. The normal import/history push `5d6e32358c79cc8cfe505013fc79995940079767` was directly verified at 2026-10-12 17:28:54.912933UTC: local HEAD=origin=live development, clean worktree, local/origin/live main unchanged `27657f31131cd95d42f0e1290853ec9c9b3959a4`. Fresh affected76/6, ordinary native35, backend98/seeder24, types/lint/build and staged hygiene pass; 33/54 retained final images were inspected. Import17 scans0/302, editor13 scans0/377 and Career22 scans0/99 remain qualified. RD21 concurrency/partial-write recovery, old orphan records and human/device/provider/output requirements remain open. All57 groups remain56 PARTIAL/C11 DEFERRED,32 routes retained. 648/93 frontend and233 browser are derived only; latest whole573/89 remains inherited. Production NO-GO, P5-13 withholding and Phase8 separation remain unchanged.
|
||||
- [x] P7-04 CV library and public entry implemented scope CONDITIONAL GO: library fcacae7 pushed/verified; public38/3 affected/native8/fidelity16/36+8/24/types/lint/build PASS,18 actual public images reviewed. Public final hygiene PASS and bfa0ab6 normal push verified; sandbox frame-tested12 and human/device/production qualifications retained.
|
||||
- [ ] P7-05 Discovery, Companies, Offers and Reminders: Discovery21/1,new8+existing4native/backend14/types/lint/build pass;18 actual new PNG reviews,81+52 axe incompletes retained. Discovery hygiene PASS/normal push34a4d6d verified. Companies30/4,new6+old2+named4native/backend2/types/lint/build pass;20actual PNG reviews,91+163incompletes qualified. Companies hygiene PASS/normal push pending; Offers/Reminders next.
|
||||
- [ ] P7-05 Discovery, Companies, Offers and Reminders: Discovery21/1,new8+existing4native/backend14/types/lint/build pass;18 actual new PNG reviews,81+52 axe incompletes retained. Discovery hygiene PASS/normal push34a4d6d verified. Companies30/4,new6+old2+named4native/backend2/types/lint/build pass;20actual PNG reviews,91+163incompletes qualified. Companies hygiene PASS/normal pusha8aba85 verified; Offers9/1,new4native/backend6/types/lint/build pass;14actual PNG reviews,15incompletes qualified. Offers hygiene PASS/normal push pending; Reminders next.
|
||||
- [ ] P7-06 remaining Application forms and global correspondence.
|
||||
- [ ] P7-06F optional reviewed followMode composer: retain deferral unless its required action/fallback decision is established.
|
||||
- [ ] P7-07 owner-scoped metadata projection, then Documents interface.
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user