feat(ui): migrate reminders and protect follow-up recovery
This commit is contained in:
@@ -51,4 +51,6 @@ Observed Discovery normal push2026-10-12 22:49:31.085724UTC proves localHEAD=ori
|
||||
|
||||
| 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 |
|
||||
| P7-05 Offers | `c723a4e3032185da623d81ace241eec748506fc7` | [Results](p7-05/evidence/offers-results.json):9/1,new4native,backend6,app/browser types/lint/build PASS;14images reviewed/14scans0/15qualified. Hygiene PASS/normal push directly verified2026-10-13 11:19:57UTC, cleanlocalHEAD=origin=live/main unchanged. +7/0frontend696/93/browser267 derived only; Reminders next/main unchanged |
|
||||
|
||||
| P7-05 Reminders | Actual hash follows observed normal push | [Results](p7-05/evidence/reminders-results.json):17/3,new6native,backend6,app/browser types/lint/build PASS;17images reviewed/17scans0/114qualified. Final hygiene/normal push pending. +10/+1frontend706/94/browser273 derived only; all4P7-05domains implemented CONDITIONAL GO; P7-06 next/main unchanged |
|
||||
|
||||
@@ -146,3 +146,11 @@ Public18 top-document all-enabled scans0/12 retain sandbox frame-tested occurren
|
||||
### 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.
|
||||
|
||||
### P7-06 confirmed compose safety entry dependency
|
||||
|
||||
Read-only connected-code inspection confirms existing CorrespondenceInboxPage updateDraft implicitly rotates UUID/clears every delivery result, allowing edits to erase uncertain/pending/sending/sent/failed safeguards. A held draft Save can merge into later work; Save and Send can race while new persistence assigns a server UUID. Recipient-only/subject-only dirty checks are incomplete. Runtime RED remains required; these are confirmed code paths, not passing evidence or a completed repair. Before P7-06 compose presentation acceptance, serialize captured-identity mutations/confirmations and preserve explicit definitive-failure new-attempt/revision/owner/idempotency contracts, with meaningful unit/native failure recovery. No real mail/provider execution or backend/schema change is authorized by this finding. Keep approved embedded-forms then inbox/read then compose/send commit sequence; independently push the coherent safety repair at that boundary.
|
||||
|
||||
### P7-05 Reminders finite evidence
|
||||
|
||||
[Reminders results](p7-05/evidence/reminders-results.json) repair reproduced duplicateheldPATCHs, toast-only local failure, host-locale dates, localeGET and managedfocus/reflow in the existing owner. Old GET-only retry/movedfocus behaviors already passed RED and remain. Explicit captured write Retry does not prove network-uncertain event deduplication; lastwriter/date/DST obligations remain. Clear shows existing Shellpoll lag; generated English reasons inNB go toP7-11, customfallback staysraw. New17all-enabledscans0/114 retain full contrast/heading-context requirements; no human/device/output/wholePRES debt closes.
|
||||
|
||||
@@ -164,3 +164,7 @@ Implemented P7-03 scope is **CONDITIONAL GO**. The normal import/history push `5
|
||||
### 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.
|
||||
|
||||
### P7-05 Reminders and checkpoint disposition
|
||||
|
||||
[Reminders results](p7-05/evidence/reminders-results.json): J01/J02/J09/W11 unchanged groups/reasons/query/date/body/event/route and owner boundaries; capturedwrite versusGET-only retry, held single mutation and focus. Fresh17/3/new6native/backend6/types/lint/build pass;17actual image reviews/17all-enabledscans0/114 qualified. All57 remain56 PARTIAL/C11 DEFERRED/32routes. Generated reason localisation→P7-11; polling/network-uncertain write/DST/manual obligations retained. P7-05 implemented CONDITIONAL GO/hygienePASS+pushpending;706/94/browser273 derived only. P7-06 entry must preserve loaded dirty forms/frozen history and repair confirmed compose identity/save races at its approved boundary.
|
||||
|
||||
@@ -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 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-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 pushc723a4e verified; Reminders17/3,new6native/backend6/types/lint/build pass;17actual PNG reviews/114incompletes qualified. P7-05 implemented CONDITIONAL GO, hygiene PASS/Reminders normal push pending; P7-06 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 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.
|
||||
**P7-05 implemented scope CONDITIONAL GO; all four domains implemented, final Reminders hygiene PASS/normal push 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 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.
|
||||
**Discovery implemented scope CONDITIONAL GO; hygiene PASS/normal push34a4d6d verified. Companies hygiene PASS/normal pusha8aba85 verified; Offers hygiene PASS/normal pushc723a4e verified; Reminders remains 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 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.
|
||||
**Companies implemented scope CONDITIONAL GO; hygiene PASS/normal pusha8aba85 verified. Offers hygiene PASS/normal pushc723a4e verified; Reminders remains 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.
|
||||
|
||||
@@ -51,7 +51,7 @@ Companies was normally pushed as a8aba852994859d4c010cb57bb0607ec9c564bbe; direc
|
||||
|
||||
## 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.
|
||||
**Offers implemented scope CONDITIONAL GO; hygiene PASS/normal pushc723a4e verified. 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.
|
||||
|
||||
@@ -62,3 +62,21 @@ All **14 latest PNGs were displayed/reviewed**, including eight balanced320/360/
|
||||
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.
|
||||
|
||||
## Reminders implemented result and P7-05 disposition
|
||||
|
||||
**Reminders and implemented P7-05 scope CONDITIONAL GO; hygiene PASS/normal push pending.** [Reminders entry](evidence/reminders-entry.json) carries the actual verified Offers pushc723a4e. [Finite results](evidence/reminders-results.json): fresh **17/3 frontend**, **6 ordinary native**, **6 unchanged-backend**, app/browser TypeScript, lint and build pass. New10/+1suite gives **706/94 frontend derived only**; new6 gives **273 browser derived only**. P7-05 adds33 cases/1suite and24 ordinary browser cases across four domains; no integrated totals until P7-12. Latest whole573/89/backend1,033/sidecar49 stay inherited, backend1,034 derived.
|
||||
|
||||
Existing four groups, server reason precedence, group order,14-day query and workflow destinations remain. Shared H2/header, named stable region, H3 groups with readable counts, H4 named articles, tokens, wrapping chips and actions replace one-off presentation. Canonical statuses and dates follow current UI language; translated controls include their visible text in contextual accessible names. Custom fallback text remains verbatim; generated serverEnglish reasons in Bokmål are explicit P7-11 residual localisation work.
|
||||
|
||||
One synchronous mutex protects PATCH and its following refresh; all row actions lock through pending state. Focus transfers to the stable region before controls disable, survives row removal and never overrides deliberately moved focus. Durable local failure uses the existing shared notice/Toast. Explicit write Retry retains the original job/date/offset; a new row action creates fresh intent. PATCH204 followed by failed GET shows a read error and Retry repeats GET only, preserving the single recorded event. Loaded rows stay available but disabled while recovery is needed; initial/permission failures do not fabricate empty results. Locale changes retranslate controls/fallbacks without another page read.
|
||||
|
||||
Real isolated HTTP proves all4 groups/counts/reasons/routes/Back and owner/deleted/anonymous boundaries, exact+3/+7/null payloads and stored follow-up/history after reload. Existing absolute-millisecond-to-UTC-date calculation and noExpectedVersion contract are preserved; calendar/DST/distributed idempotency are unqualified. A network-uncertain PATCH may already have committed. Controlled500 did not commit; local duplicate guards and GET-only failed-refresh recovery do not prove uncertain-write deduplication. Shell's independent identical initial/60s poll remains; Clear image shows pageempty/sidebarbadge1 until poll, an observed existing cadence qualification. No claim of one total GET/page or instantaneous sidebar consistency.
|
||||
|
||||
All **17 final PNGs were displayed/reviewed**, including eight balanced320/360/390/768/1024/1280 landscape/1440/1920 EN/Bokmål/light/dark samples and read/write/recovery/focus states. The320px long capture was display-resized; no every-pixel/device claim. Root overflow0 and keyboard actions pass. Seventeenall-enabled axe+target-size scans retain **0 violations/114 incompletes**:17 invisible-zero badge,92 unknown-background overlap (90 row actions/2 Norsk header) and5 unable-to-determine-previous-heading during held read recovery. Complete vectors remain; empty related nodes/ratio0 do not establish causes/contrast. Human reader/browse announcements, actual zoom/physicaltouch/software keyboard and output/production requirements stay open.
|
||||
|
||||
Meaningful RED2/8 includes3 actual PATCHs for each held+3/+7/clear, unreachable local write recovery, incorrect host-locale date and extra locale GET; unchanged10 new+7 original assertions now pass17/3. First native6/6 passes with no skips/retries/xfail/waivers. Real runtime explicitly disablesfollow-up/AI/saved-search workers; no delivery/model/qualification. All57 groups remain56 PARTIAL/C11 DEFERRED and32 routes. P5-13 withheld, P7-06F deferred, production NO-GO/main27657f3 and Phase8 separate.
|
||||
|
||||
All four approved P7-05 domains are now implemented as separate coherent commits: Discovery34a4d6d, Companiesa8aba85, Offersc723a4e and this Reminders boundary. Proceed to P7-06 embedded forms, then inbox/read, then compose/send. Existing seven-section workspace remains; confirmed recovery/dirty/delivery safety findings are entry dependencies for the affected owner, not permission for a framework or schema rewrite.
|
||||
|
||||
[Final finite hygiene](evidence/reminders-static-checks.json) passed four raw commands at2026-10-13 11:48:26–28UTC,12documents/437links0errors,57groups/32routes. Final staged whitespace follows staging; normal Reminders push receipt follows observation in P7-06 entry.
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"checkpoint": "P7-05",
|
||||
"slice": "Offers",
|
||||
"recordedUtc": "2026-10-13T11:18:51.433412+00:00",
|
||||
"acceptance": "OFFERS_IMPLEMENTED_SCOPE_CONDITIONAL_GO_HYGIENE_PASS_NORMAL_PUSH_PENDING_REMINDERS_PENDING",
|
||||
"acceptance": "OFFERS_IMPLEMENTED_SCOPE_CONDITIONAL_GO_HYGIENE_PASS_NORMAL_PUSH_VERIFIED_REMINDERS_PENDING",
|
||||
"entryReceipt": {
|
||||
"checkpoint": "P7-05",
|
||||
"slice": "Offers",
|
||||
@@ -1947,5 +1947,105 @@
|
||||
"Local links check target existence, not URL/heading anchors.",
|
||||
"Static evidence is not runtime/manual/device/provider/production acceptance."
|
||||
]
|
||||
},
|
||||
"push": {
|
||||
"observedUtc": "2026-10-13T11:19:57.030234+00:00",
|
||||
"branch": "codex/ui-redesign-phase4",
|
||||
"head": "c723a4e3032185da623d81ace241eec748506fc7",
|
||||
"originDevelopment": "c723a4e3032185da623d81ace241eec748506fc7",
|
||||
"liveDevelopment": "c723a4e3032185da623d81ace241eec748506fc7",
|
||||
"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": "c723a4e3032185da623d81ace241eec748506fc7\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": "c723a4e3032185da623d81ace241eec748506fc7\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": "c723a4e3032185da623d81ace241eec748506fc7\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,164 @@
|
||||
{
|
||||
"checkpoint": "P7-05",
|
||||
"slice": "Reminders",
|
||||
"entry": "CONDITIONAL_GO",
|
||||
"previousOffersPush": {
|
||||
"observedUtc": "2026-10-13T11:19:57.030234+00:00",
|
||||
"branch": "codex/ui-redesign-phase4",
|
||||
"head": "c723a4e3032185da623d81ace241eec748506fc7",
|
||||
"originDevelopment": "c723a4e3032185da623d81ace241eec748506fc7",
|
||||
"liveDevelopment": "c723a4e3032185da623d81ace241eec748506fc7",
|
||||
"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": "c723a4e3032185da623d81ace241eec748506fc7\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": "c723a4e3032185da623d81ace241eec748506fc7\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": "c723a4e3032185da623d81ace241eec748506fc7\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/components/RemindersView.tsx",
|
||||
"gitBlobSha256": "42780d9229c1ff3b56b33562da58ca17ab182b2397dafeaf9cdd7d3fb04a1f0c",
|
||||
"bytes": 6075
|
||||
},
|
||||
{
|
||||
"path": "job-tracker-ui/src/jobWorkflowSignals.ts",
|
||||
"gitBlobSha256": "cde4372017c13f21de8b4e2259923d80beda36506784d7be089432622fbd6b0b",
|
||||
"bytes": 2701
|
||||
},
|
||||
{
|
||||
"path": "job-tracker-ui/src/daily-control-loop.test.tsx",
|
||||
"gitBlobSha256": "9bd0a047e384ced6616129271e771658bd6bf133aaf7c3a7fa6d6a8f1e9a735e",
|
||||
"bytes": 11650
|
||||
},
|
||||
{
|
||||
"path": "job-tracker-ui/src/workflow-trust-signals.test.tsx",
|
||||
"gitBlobSha256": "92a27f54f9b347a5069845f62524fe9cd5d2d10196ec6619e6895dbf04fe7b4b",
|
||||
"bytes": 11688
|
||||
},
|
||||
{
|
||||
"path": "JobTrackerApi/Controllers/JobApplicationLifecycleController.cs",
|
||||
"gitBlobSha256": "a86e09a8c6b5564e47959f969e843855f2ba866ffad816e3ba41dd84d0a115ad",
|
||||
"bytes": 6593
|
||||
}
|
||||
],
|
||||
"baseline": "Fresh7/2 daily-control-loop/workflow-trust-signals, no separate missing helper test claimed. Source/prerun/raw frozen in ignored reminders/unit-baseline. Latest whole573/89/backend1033/sidecar49 inherited;696/93/frontend267browser/backend1034 derived only.",
|
||||
"scope": "Existing upcomingDays14/all4groups/workflow reason precedence and destinations, original UTC+3/+7/clear followup PATCH andFollowUpSet event/noExpectedVersion. Protect held operations/durableexplicit recovery/readRetry, currentlocale/status/date/reflow/focus; no date/DST/provider/model/followMode/schema/production change.",
|
||||
"backendBaseline": {
|
||||
"command": [
|
||||
"rtk",
|
||||
"proxy",
|
||||
"C:/Users/Connor/.dotnet/dotnet.exe",
|
||||
"test",
|
||||
"JobTrackerApi.Tests/JobTrackerApi.Tests.csproj",
|
||||
"--configuration",
|
||||
"Release",
|
||||
"--no-build",
|
||||
"--filter",
|
||||
"FullyQualifiedName~JobApplicationsWorkflowSignalsTests|FullyQualifiedName~InterviewPrepTests.Setting_a_follow_up|FullyQualifiedName~InterviewPrepTests.Follow_up_counts|FullyQualifiedName~InterviewPrepTests.Clearing_a_follow_up|FullyQualifiedName~InterviewPrepTests.Follow_up_is_not_reachable",
|
||||
"--logger",
|
||||
"trx;LogFileName=career.trx",
|
||||
"--results-directory",
|
||||
"E:\\Storage\\Projects\\Active\\Job tracker\\.artifacts\\phase7\\current\\backend-career\\p705-reminders-20261013T112252Z"
|
||||
],
|
||||
"startedUtc": "2026-10-13T11:22:52.268196+00:00",
|
||||
"finishedUtc": "2026-10-13T11:22:54.980345+00:00",
|
||||
"exitCode": 0,
|
||||
"logSha256": "ad0ef58d24b234fbeba0a693a2b4013e4ffd03e0ba13d2730ed985aa46829cc9",
|
||||
"counts": {
|
||||
"total": 6,
|
||||
"passed": 6,
|
||||
"failed": 0,
|
||||
"notExecuted": 0
|
||||
},
|
||||
"resultSha256": "b46be1188b9460210f54d09c2dcb82928233069e14f8a7504425e8d4ff51d989"
|
||||
},
|
||||
"backendQualification": "Existing WorkflowSignals2+InterviewPrep followup4 pass on same verified unchanged Release DLL; no full backend rebuild/run and no mail delivery qualification."
|
||||
}
|
||||
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:48:29.328699+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:48:26.119344+00:00",
|
||||
"finishedUtc": "2026-10-13T11:48:28.659087+00:00",
|
||||
"exitCode": 0,
|
||||
"log": ".artifacts/phase7/current/static/20261013T114826Z/secret-scan.log",
|
||||
"logSha256Raw": "c5a88d5ebdc042adba125c1eaacbe44f9f4a5c19898c0f195573c00d511aa28d"
|
||||
},
|
||||
{
|
||||
"name": "working-whitespace",
|
||||
"command": [
|
||||
"rtk",
|
||||
"proxy",
|
||||
"git",
|
||||
"diff",
|
||||
"--check"
|
||||
],
|
||||
"startedUtc": "2026-10-13T11:48:28.660091+00:00",
|
||||
"finishedUtc": "2026-10-13T11:48:28.762104+00:00",
|
||||
"exitCode": 0,
|
||||
"log": ".artifacts/phase7/current/static/20261013T114826Z/working-whitespace.log",
|
||||
"logSha256Raw": "0e9d3abafb46d589ad291e48e12aeb87ddf8e28f5b77a06ab2bddb34e1920bdc"
|
||||
},
|
||||
{
|
||||
"name": "staged-whitespace",
|
||||
"command": [
|
||||
"rtk",
|
||||
"proxy",
|
||||
"git",
|
||||
"diff",
|
||||
"--cached",
|
||||
"--check"
|
||||
],
|
||||
"startedUtc": "2026-10-13T11:48:28.762104+00:00",
|
||||
"finishedUtc": "2026-10-13T11:48:28.838663+00:00",
|
||||
"exitCode": 0,
|
||||
"log": ".artifacts/phase7/current/static/20261013T114826Z/staged-whitespace.log",
|
||||
"logSha256Raw": "d2993bc68ad9a63f711c8f5473155e2ba00fdcd461220cc3849185f2f9ad93e6"
|
||||
},
|
||||
{
|
||||
"name": "phase7-whitespace",
|
||||
"command": [
|
||||
"rtk",
|
||||
"proxy",
|
||||
"git",
|
||||
"diff",
|
||||
"--check",
|
||||
"b5319981ad3d1bd635daff861453d23c0de5320b"
|
||||
],
|
||||
"startedUtc": "2026-10-13T11:48:28.838663+00:00",
|
||||
"finishedUtc": "2026-10-13T11:48:28.986924+00:00",
|
||||
"exitCode": 0,
|
||||
"log": ".artifacts/phase7/current/static/20261013T114826Z/phase7-whitespace.log",
|
||||
"logSha256Raw": "a5b924c165678b49a53b6057628b86d2f2382969a42a62cf348b91e6d250137c"
|
||||
}
|
||||
],
|
||||
"untrackedSecretScan": {
|
||||
"files": 4,
|
||||
"findings": 0
|
||||
},
|
||||
"documentation": {
|
||||
"files": 12,
|
||||
"relativeLinks": 437,
|
||||
"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 pusha8aba85 verified; Offers9/1,new4native/backend6/types/lint/build pass;14actual PNG reviews,15incompletes qualified. Offers hygiene PASS/normal push pending; Reminders next.
|
||||
- [x] 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 pushc723a4e verified; Reminders17/3,new6native/backend6/types/lint/build pass;17actual PNG reviews/114incompletes qualified. P7-05 implemented CONDITIONAL GO, hygiene PASS/Reminders normal push pending; P7-06 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 remindersUrl = `${jobsUrl}/reminders`;
|
||||
const password = 'E2ePassword123!';
|
||||
type Owner = { id: string; headers: Record<string, string> };
|
||||
type Sample = { language: 'en' | 'nb'; theme: 'light' | 'dark'; width: number; height: number };
|
||||
type RequestRecord = { method: string; path: string; query: { [key: string]: string }; body: unknown };
|
||||
type Session = { owner: Owner; admin: APIRequestContext; administrator: Owner; requests: RequestRecord[] };
|
||||
type FollowUpEvent = { id: number; type: string; oldValue: string | null; newValue: string | null };
|
||||
const desktop: Sample = { language: 'en', theme: 'light', width: 1440, height: 900 };
|
||||
const fixedTime = '2026-10-12T23:30:00-08:00';
|
||||
const boundary = 'Actual owner HTTP fixtures, follow-up PATCH204, reloads, history and ownership checks use disposable isolated SQLite. Free account entitlements disable model access; the serial runtime disables AI, follow-up delivery and saved-search alert workers. No provider/model/send endpoint, raw SQL or schema is invoked. Controlled PATCH/GET failure and held requests qualify presentation and recovery, not production reliability. Browser request metadata excludes session/CSRF headers. The Shell independently requests the identical reminders?upcomingDays=14 endpoint initially and every60s; observed counts retain that source and do not claim one total GET per page. Existing absolute-millisecond-to-UTC-date scheduling and followMode route metadata are preserved; no DST/calendar or deferred composer qualification is claimed.';
|
||||
|
||||
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-reminders-${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), body: request.postData() ? request.postDataJSON() : null });
|
||||
});
|
||||
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('reminders-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: 'Applied', ...fields } }), 201);
|
||||
}
|
||||
async function read(api: APIRequestContext) { return json<JobApplication[]>(await api.get(remindersUrl, { params: { upcomingDays: 14 } })); }
|
||||
async function nativeJob(api: APIRequestContext, id: number) { return json<JobApplication>(await api.get(`${jobsUrl}/${id}`)); }
|
||||
async function history(api: APIRequestContext, id: number) {
|
||||
return (await json<FollowUpEvent[]>(await api.get(`${jobsUrl}/${id}/history`))).filter(event => event.type === 'FollowUpSet').sort((a, b) => a.id - b.id);
|
||||
}
|
||||
async function groups(api: APIRequestContext, owner: Owner, prefix = 'Recorded reminder') {
|
||||
const source = await company(api, owner, `${prefix} company`);
|
||||
const future = new Date(Date.now() + 3 * 86_400_000).toISOString();
|
||||
const rows = [
|
||||
await job(api, owner, source.id, `${prefix} package work`, { dateApplied: '2026-01-01T12:00:00Z' }),
|
||||
await job(api, owner, source.id, `${prefix} interview prep`, { status: 'Interview', followUpAt: '2026-01-01T12:00:00Z' }),
|
||||
await job(api, owner, source.id, `${prefix} follow-up`, { status: 'Rejected', feedbackRequestedAt: '2026-01-01T12:00:00Z', followUpAt: '2026-01-01T12:00:00Z' }),
|
||||
await job(api, owner, source.id, `${prefix} scheduled prospect`, { status: 'Saved', followUpAt: future }),
|
||||
];
|
||||
expect(rows.map(row => row.workflowSignal?.actionKey)).toEqual(['package-work', 'interview-prep', 'follow-up', 'review-readiness']);
|
||||
return { source, rows };
|
||||
}
|
||||
function region(page: Page, language: Sample['language'] = 'en') { return page.getByRole('region', { name: translations[language].remindersTitle, exact: true }); }
|
||||
function article(page: Page, title: string) { return page.getByRole('article', { name: title, exact: true }); }
|
||||
function text(template: string, params: { [key: string]: string | number }) { return template.replace(/\{(\w+)\}/g, (_, key: string) => String(params[key] ?? `{${key}}`)); }
|
||||
function snooze(page: Page, row: JobApplication, days: number, language: Sample['language'] = 'en') {
|
||||
return article(page, row.jobTitle).getByRole('button', { name: text(translations[language].remindersSnoozeForJob, { days, job: row.jobTitle }), exact: true });
|
||||
}
|
||||
function clear(page: Page, row: JobApplication, language: Sample['language'] = 'en') {
|
||||
return article(page, row.jobTitle).getByRole('button', { name: text(translations[language].remindersClearForJob, { job: row.jobTitle }), exact: true });
|
||||
}
|
||||
function action(row: JobApplication, language: Sample['language'] = 'en') {
|
||||
const t = translations[language];
|
||||
const signal = row.workflowSignal;
|
||||
const label = !signal?.needsAttention ? t.remindersOpen : signal.actionKey === 'package-work' ? t.jobTablePackageWork
|
||||
: signal.actionKey === 'interview-prep' ? t.jobTableInterviewStage : signal.actionKey === 'follow-up' ? t.jobTableFollowUp : t.jobTableReadiness;
|
||||
return `${label}: ${row.jobTitle}`;
|
||||
}
|
||||
function requests(session: Session, method: string, path: string) { return session.requests.filter(request => request.method === method && request.path === path); }
|
||||
function hold() { let release!: () => void; const pending = new Promise<void>(resolve => { release = resolve; }); return { pending, release }; }
|
||||
async function keyboard(page: Page, control: Locator) { await control.focus(); await expect(control).toBeFocused(); await page.keyboard.press('Enter'); }
|
||||
function response(page: Page, method: string, url: string) { return page.waitForResponse(value => value.url() === url && value.request().method() === method); }
|
||||
|
||||
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('/reminders?source=phase7-reminders#groups');
|
||||
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].reminders, exact: true })).toBeVisible();
|
||||
await expect(page.getByRole('heading', { level: 2, name: translations[sample.language].remindersTitle, 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),
|
||||
focused: { tag: document.activeElement?.tagName, role: document.activeElement?.getAttribute('role'), label: document.activeElement?.getAttribute('aria-label') },
|
||||
}));
|
||||
const axe = await new AxeBuilder({ page }).options({ rules: { 'target-size': { enabled: true } } }).analyze();
|
||||
const name = `p7-reminders-${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 default-enabled rules plus target-size', violations: axe.violations, incomplete: axe.incomplete, passes: axe.passes.map(rule => ({ id: rule.id, nodes: rule.nodes.length })) },
|
||||
qualifications: ['Finite isolated Chromium states, not all width/language/theme/state combinations.',
|
||||
'Every violation/incomplete vector is retained for context review. Keyboard/focus checks are not human AT, physical touch/software keyboard or real zoom acceptance.',
|
||||
'Full-page images retain sticky/scroll/focus context; they do not establish precise contrast or focus occlusion throughout the real viewport.',
|
||||
'Provider/model/delivery, deployed fonts/photos, production readiness and DST/calendar qualification remain separate.'],
|
||||
}), contentType: 'application/json' });
|
||||
await info.attach(name, { body: await page.screenshot({ fullPage: true, animations: 'disabled' }), contentType: 'image/png' });
|
||||
expect(geometry.rootOverflow, `${name} root reflow`).toBe(0); expect(axe.violations, `${name} complete axe violations`).toEqual([]);
|
||||
}
|
||||
|
||||
test('[P7-05 Reminders] actual empty and four workflow groups preserve counts, reasons, routes and owner boundaries', async ({ page, session, playwright }, info) => {
|
||||
test.setTimeout(120_000);
|
||||
await enter(page, session.owner); await expect(region(page).getByText(translations.en.remindersNothing, { exact: true })).toBeVisible();
|
||||
await capture(page, info, desktop, 'actual-empty');
|
||||
const { source, rows } = await groups(page.request, session.owner);
|
||||
const deleted = await job(page.request, session.owner, source.id, 'Deleted reminder 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 reminder company');
|
||||
const foreignJob = await job(foreignApi, foreign, foreignCompany.id, 'Foreign reminder must remain absent');
|
||||
const authoritative = await read(page.request);
|
||||
expect(authoritative.map(row => row.id).sort((a, b) => a - b)).toEqual(rows.map(row => row.id).sort((a, b) => a - b));
|
||||
expect((await page.request.patch(`${jobsUrl}/${foreignJob.id}/followup`, { headers: session.owner.headers, data: { followUpAt: null } })).status()).toBe(404);
|
||||
expect(await history(foreignApi, foreignJob.id)).toEqual([]); expect((await anonymous.get(remindersUrl)).status()).toBe(401);
|
||||
await page.reload(); await expect(region(page).getByRole('article')).toHaveCount(4);
|
||||
const titles = [translations.en.remindersMissingTailoredCv, translations.en.remindersMissingInterviewPrep, translations.en.remindersFollowUpDue, translations.en.remindersOther];
|
||||
expect(await region(page).getByRole('heading', { level: 3 }).allTextContents()).toEqual(titles);
|
||||
for (let index = 0; index < rows.length; index++) {
|
||||
const row = authoritative.find(value => value.id === rows[index].id)!;
|
||||
const group = region(page).getByRole('region', { name: titles[index], exact: true });
|
||||
await expect(group.getByRole('article')).toHaveCount(1); await expect(group.getByRole('article', { name: row.jobTitle, exact: true })).toBeVisible();
|
||||
await expect(article(page, row.jobTitle)).toContainText(row.workflowSignal!.reason);
|
||||
await expect(article(page, row.jobTitle).getByRole('heading', { level: 4 })).toContainText(row.jobTitle);
|
||||
}
|
||||
await expect(article(page, deleted.jobTitle)).toHaveCount(0); await expect(article(page, foreignJob.jobTitle)).toHaveCount(0);
|
||||
await capture(page, info, desktop, 'actual-four-groups');
|
||||
const paths = [`/jobs/${rows[0].id}?section=cv`, `/jobs/${rows[1].id}?section=interview`,
|
||||
`/jobs/${rows[2].id}?section=communication&followMode=waiting-update`, `/jobs/${rows[3].id}?section=checklist`];
|
||||
for (let index = 0; index < rows.length; index++) {
|
||||
const loaded = response(page, 'GET', `${jobsUrl}/${rows[index].id}/workspace`);
|
||||
await keyboard(page, article(page, rows[index].jobTitle).getByRole('button', { name: action(rows[index]), exact: true }));
|
||||
await expect.poll(() => new URL(page.url()).pathname + new URL(page.url()).search).toBe(paths[index]);
|
||||
expect(await json<{ id: number; jobTitle: string }>(await loaded)).toMatchObject({ id: rows[index].id, jobTitle: rows[index].jobTitle });
|
||||
await page.goBack(); await expect(page).toHaveURL(/\/reminders\?source=phase7-reminders#groups$/); await expect(article(page, rows[index].jobTitle)).toBeVisible();
|
||||
}
|
||||
expect(session.requests.filter(request => /followup-draft|send-followup/.test(request.path))).toEqual([]);
|
||||
await info.attach('reminders-real-groups', { body: JSON.stringify({ boundary, rows: authoritative, groupCounts: [1, 1, 1, 1], existingPaths: paths,
|
||||
qualification: 'The four groups follow actual server signals and current frontend grouping. followMode remains metadata only; no composer qualification, generation or delivery occurs.' }), contentType: 'application/json' });
|
||||
} finally { await foreignApi.dispose(); await anonymous.dispose(); }
|
||||
});
|
||||
|
||||
test('[P7-05 Reminders] plus3, plus7 and clear persist actual dates and one history event per write with existing UTC-date semantics', async ({ page, session }, info) => {
|
||||
test.setTimeout(90_000); await page.clock.setFixedTime(new Date(fixedTime));
|
||||
const source = await company(page.request, session.owner, 'Recorded scheduling company');
|
||||
const row = await job(page.request, session.owner, source.id, 'Scheduled prospect removed after clear', { status: 'Saved', followUpAt: '2026-10-16T00:00:00Z' });
|
||||
expect(await history(page.request, row.id)).toEqual([]); await enter(page, session.owner); await expect(article(page, row.jobTitle)).toBeVisible();
|
||||
for (const [days, date] of [[3, '2026-10-16'], [7, '2026-10-20']] as const) {
|
||||
const completed = response(page, 'PATCH', `${jobsUrl}/${row.id}/followup`);
|
||||
await keyboard(page, snooze(page, row, days)); expect((await completed).status()).toBe(204);
|
||||
await expect(snooze(page, row, days)).toBeEnabled(); expect((await nativeJob(page.request, row.id)).followUpAt?.slice(0, 10)).toBe(date);
|
||||
expect(requests(session, 'PATCH', `/api/jobapplications/${row.id}/followup`).at(-1)?.body).toEqual({ followUpAt: date });
|
||||
}
|
||||
const cleared = response(page, 'PATCH', `${jobsUrl}/${row.id}/followup`);
|
||||
await keyboard(page, clear(page, row)); expect((await cleared).status()).toBe(204);
|
||||
await expect(article(page, row.jobTitle)).toHaveCount(0); await expect(region(page)).toBeFocused();
|
||||
expect((await nativeJob(page.request, row.id)).followUpAt).toBeNull();
|
||||
expect(requests(session, 'PATCH', `/api/jobapplications/${row.id}/followup`).map(request => request.body)).toEqual([{ followUpAt: '2026-10-16' }, { followUpAt: '2026-10-20' }, { followUpAt: null }]);
|
||||
expect((await history(page.request, row.id)).map(event => event.newValue?.slice(0, 10) ?? null)).toEqual(['2026-10-16', '2026-10-20', null]);
|
||||
await capture(page, info, desktop, 'cleared-row-region-focus');
|
||||
await info.attach('reminders-date-semantics', { body: JSON.stringify({ fixedBrowserTime: fixedTime, fixedUtcTime: new Date(fixedTime).toISOString(), expected: ['2026-10-16', '2026-10-20', null],
|
||||
qualification: 'This checks the preserved Date.now+N*24h toISOString date slice. It does not replace that behavior with local-calendar arithmetic or establish DST/mobile keyboard qualification.' }), contentType: 'application/json' });
|
||||
});
|
||||
|
||||
test('[P7-05 Reminders] held single PATCH locks every row action and failed explicit Retry preserves its captured date and moved focus', async ({ page, session }, info) => {
|
||||
test.setTimeout(90_000); await page.clock.setFixedTime(new Date(fixedTime));
|
||||
const source = await company(page.request, session.owner, 'Pending reminder company');
|
||||
const row = await job(page.request, session.owner, source.id, 'Captured failed follow-up action');
|
||||
await job(page.request, session.owner, source.id, 'Other row must be locked');
|
||||
await enter(page, session.owner); await expect(article(page, row.jobTitle)).toBeVisible();
|
||||
const first = hold(); const second = hold(); let attempts = 0;
|
||||
await page.route(`${jobsUrl}/${row.id}/followup`, async route => {
|
||||
if (route.request().method() !== 'PATCH') return route.continue(); attempts += 1;
|
||||
if (attempts === 1) { await first.pending; await route.fulfill({ status: 503, json: {} }); }
|
||||
else { await second.pending; await route.continue(); }
|
||||
});
|
||||
try {
|
||||
await keyboard(page, snooze(page, row, 3)); await expect.poll(() => attempts).toBe(1); await expect(region(page)).toBeFocused();
|
||||
for (const button of await region(page).getByRole('article').getByRole('button').all()) await expect(button).toBeDisabled();
|
||||
expect(requests(session, 'PATCH', `/api/jobapplications/${row.id}/followup`)).toHaveLength(1);
|
||||
expect(await history(page.request, row.id)).toEqual([]); await capture(page, info, desktop, 'held-single-patch'); first.release();
|
||||
await expect(region(page).getByRole('alert')).toContainText(text(translations.en.remindersMutationFailedForJob, { job: row.jobTitle }));
|
||||
await expect(region(page).getByRole('button', { name: translations.en.retry, exact: true })).toBeVisible();
|
||||
expect(await history(page.request, row.id)).toEqual([]); await capture(page, info, desktop, 'captured-patch-failure');
|
||||
await page.clock.setFixedTime(new Date('2026-10-13T23:30:00-08:00'));
|
||||
await keyboard(page, region(page).getByRole('button', { name: translations.en.retry, exact: true })); await expect.poll(() => attempts).toBe(2);
|
||||
await expect(region(page)).toBeFocused(); const external = page.locator('header').getByRole('button', { name: 'Norsk', exact: true });
|
||||
await external.focus(); await expect(external).toBeFocused(); second.release();
|
||||
await expect(snooze(page, row, 3)).toBeEnabled(); await expect(region(page).getByRole('alert')).toHaveCount(0); await expect(external).toBeFocused();
|
||||
expect(requests(session, 'PATCH', `/api/jobapplications/${row.id}/followup`).map(request => request.body)).toEqual([{ followUpAt: '2026-10-16' }, { followUpAt: '2026-10-16' }]);
|
||||
expect((await nativeJob(page.request, row.id)).followUpAt?.slice(0, 10)).toBe('2026-10-16'); expect(await history(page.request, row.id)).toHaveLength(1);
|
||||
await capture(page, info, desktop, 'actual-retry-moved-focus-preserved');
|
||||
} finally { first.release(); second.release(); }
|
||||
});
|
||||
|
||||
test('[P7-05 Reminders] committed PATCH204 followed by a failed reload retries only GET and preserves its single history event', async ({ page, session }, info) => {
|
||||
test.setTimeout(90_000); await page.clock.setFixedTime(new Date(fixedTime));
|
||||
const source = await company(page.request, session.owner, 'Committed reminder company');
|
||||
const row = await job(page.request, session.owner, source.id, 'Committed follow-up with failed view refresh');
|
||||
await enter(page, session.owner); await expect(article(page, row.jobTitle)).toBeVisible();
|
||||
const gate = hold(); let reads = 0;
|
||||
await page.route(`${remindersUrl}?**`, async route => {
|
||||
reads += 1; if (reads === 1) await route.fulfill({ status: 500, json: {} }); else { await gate.pending; await route.continue(); }
|
||||
});
|
||||
try {
|
||||
const completed = response(page, 'PATCH', `${jobsUrl}/${row.id}/followup`); await keyboard(page, snooze(page, row, 3)); expect((await completed).status()).toBe(204);
|
||||
await expect(region(page).getByRole('alert')).toContainText(translations.en.remindersLoadFailed);
|
||||
expect((await nativeJob(page.request, row.id)).followUpAt?.slice(0, 10)).toBe('2026-10-16'); expect(await history(page.request, row.id)).toHaveLength(1);
|
||||
expect(requests(session, 'PATCH', `/api/jobapplications/${row.id}/followup`)).toHaveLength(1);
|
||||
await expect(region(page).getByText(text(translations.en.remindersMutationFailedForJob, { job: row.jobTitle }), { exact: true })).toHaveCount(0);
|
||||
await capture(page, info, desktop, 'committed-write-read-failure');
|
||||
await keyboard(page, region(page).getByRole('button', { name: translations.en.retry, exact: true })); await expect.poll(() => reads).toBe(2);
|
||||
await expect(region(page)).toBeFocused(); await expect(region(page).getByRole('status')).toContainText(translations.en.loading);
|
||||
expect(requests(session, 'PATCH', `/api/jobapplications/${row.id}/followup`)).toHaveLength(1); await capture(page, info, desktop, 'held-get-only-recovery'); gate.release();
|
||||
await expect(article(page, row.jobTitle)).toBeVisible(); await expect(region(page).getByRole('alert')).toHaveCount(0);
|
||||
expect(await history(page.request, row.id)).toHaveLength(1); expect(requests(session, 'PATCH', `/api/jobapplications/${row.id}/followup`)).toHaveLength(1);
|
||||
} finally { gate.release(); }
|
||||
});
|
||||
|
||||
test('[P7-05 Reminders] live EN/Bokmal translates status/date controls and preserves raw reasons without a page-triggered refetch', async ({ page, session }, info) => {
|
||||
test.setTimeout(90_000); const { rows } = await groups(page.request, session.owner);
|
||||
await enter(page, session.owner); await expect(region(page).getByRole('article')).toHaveCount(4);
|
||||
await expect.poll(() => requests(session, 'GET', '/api/jobapplications/reminders').length).toBeGreaterThanOrEqual(2);
|
||||
const baseline = requests(session, 'GET', '/api/jobapplications/reminders').length;
|
||||
const preference = response(page, 'PUT', `${apiUrl}/auth/preferences/language`);
|
||||
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(region(page, 'nb').getByRole('article')).toHaveCount(4);
|
||||
for (const row of rows) await expect(article(page, row.jobTitle)).toContainText(row.workflowSignal!.reason);
|
||||
await expect(article(page, rows[1].jobTitle).getByText(translations.nb.statusInterview, { exact: true })).toBeVisible();
|
||||
const authoritative = (await read(page.request)).find(row => row.id === rows[3].id)!;
|
||||
const date = await page.evaluate(value => new Date(value).toLocaleDateString('nb-NO'), authoritative.followUpAt!);
|
||||
await expect(article(page, rows[3].jobTitle).getByText(text(translations.nb.remindersFollowUpDate, { date }), { exact: true })).toBeVisible();
|
||||
await expect(snooze(page, rows[0], 3, 'nb')).toBeVisible(); await expect(clear(page, rows[0], 'nb')).toBeVisible();
|
||||
const elapsed = await page.evaluate(() => performance.now()); expect(elapsed).toBeLessThan(60_000);
|
||||
expect(requests(session, 'GET', '/api/jobapplications/reminders')).toHaveLength(baseline);
|
||||
expect(session.requests.filter(request => request.method === 'PATCH')).toEqual([]);
|
||||
await capture(page, info, { ...desktop, language: 'nb' }, 'live-locale-raw-reasons');
|
||||
await info.attach('reminders-shell-read-boundary', { body: JSON.stringify({ boundary, initialObservedReads: baseline, afterLocaleReads: requests(session, 'GET', '/api/jobapplications/reminders').length,
|
||||
documentElapsedMs: elapsed, qualification: 'The baseline includes both Shell initial read and page read. This measured locale transition completes before the Shell60s poll and creates no additional identical request. No total-GET1 or Shell reliability claim.' }), contentType: 'application/json' });
|
||||
});
|
||||
|
||||
test('[P7-05 Reminders] eight finite bilingual theme widths preserve long rows and keyboard-accessible actions', async ({ page, session }, info) => {
|
||||
test.setTimeout(240_000);
|
||||
const prefix = 'Langsiktig søknadsoppfølging for samfunnskritiske tjenester med mange samarbeidspartnere Æ Ø Å';
|
||||
const { rows } = await groups(page.request, session.owner, prefix);
|
||||
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) {
|
||||
await enter(page, session.owner, sample); await expect(region(page, sample.language).getByRole('article')).toHaveCount(4);
|
||||
for (const row of rows) {
|
||||
await expect(article(page, row.jobTitle).getByRole('heading', { level: 4 })).toContainText(row.jobTitle);
|
||||
await expect(article(page, row.jobTitle)).toContainText(row.workflowSignal!.reason);
|
||||
const open = article(page, row.jobTitle).getByRole('button', { name: action(row, sample.language), exact: true });
|
||||
await open.scrollIntoViewIfNeeded(); await open.focus(); await expect(open).toBeFocused();
|
||||
for (const days of [3, 7]) { await snooze(page, row, days, sample.language).focus(); await expect(snooze(page, row, days, sample.language)).toBeFocused(); }
|
||||
}
|
||||
await page.evaluate(() => scrollTo(0, 0)); await capture(page, info, sample, 'long-four-group-rows');
|
||||
}
|
||||
});
|
||||
@@ -1,15 +1,24 @@
|
||||
import React, { useMemo } from "react";
|
||||
import React, { useMemo, useRef, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { Box, Button, Chip, Divider, Paper, Typography } from "@mui/material";
|
||||
import { Box, Button, Chip, Divider, Paper, Stack, Typography } from "@mui/material";
|
||||
|
||||
import { api } from "../api";
|
||||
import { api, getApiErrorMessage } from "../api";
|
||||
import ViewStateNotice from "./ViewStateNotice";
|
||||
import { JobApplication } from "../types";
|
||||
import { buildWorkflowPath, getReminderGroup, getWorkflowAction } from "../jobWorkflowSignals";
|
||||
import { useToast } from "../toast";
|
||||
import { useI18n } from "../i18n/I18nProvider";
|
||||
import { useViewResource } from "../hooks/useViewResource";
|
||||
import { useViewResource, type ViewResourceError } from "../hooks/useViewResource";
|
||||
import { PageHeader } from "../layout/PageLayout";
|
||||
import { statusLabel } from "../pipeline";
|
||||
|
||||
type FollowUpFailure = {
|
||||
job: Pick<JobApplication, "id" | "jobTitle">;
|
||||
days: number | null;
|
||||
date: string | null;
|
||||
error: ViewResourceError;
|
||||
};
|
||||
|
||||
type ReminderGroups = {
|
||||
missingCv: JobApplication[];
|
||||
@@ -27,13 +36,16 @@ function groupItems(items: JobApplication[]): ReminderGroups {
|
||||
return groups;
|
||||
}
|
||||
|
||||
function ReminderSection({ title, items, onOpen, onSetFollowUp }: { title: string; items: JobApplication[]; onOpen: (job: JobApplication) => void; onSetFollowUp: (id: number, days: number | null) => void }) {
|
||||
const { t } = useI18n();
|
||||
function ReminderSection({ title, items, disabled, onOpen, onSetFollowUp }: { title: string; items: JobApplication[]; disabled: boolean; onOpen: (job: JobApplication) => void; onSetFollowUp: (job: JobApplication, days: number | null) => void }) {
|
||||
const { language, t } = useI18n();
|
||||
if (items.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 1.25 }}>
|
||||
<Typography variant="h6">{title}</Typography>
|
||||
<Box component="section" aria-label={title} sx={{ display: "flex", flexDirection: "column", gap: 1.25, minWidth: 0 }}>
|
||||
<Stack direction="row" alignItems="center" spacing={1}>
|
||||
<Typography component="h3" variant="sectionTitle">{title}</Typography>
|
||||
<Chip size="small" label={items.length} />
|
||||
</Stack>
|
||||
{items.map((j) => {
|
||||
const action = getWorkflowAction(j, {
|
||||
packageWork: t("jobTablePackageWork"),
|
||||
@@ -43,23 +55,22 @@ function ReminderSection({ title, items, onOpen, onSetFollowUp }: { title: strin
|
||||
});
|
||||
|
||||
return (
|
||||
<Paper key={j.id} sx={{ p: 1.5, display: "grid", gridTemplateColumns: "1fr auto", gap: 1, alignItems: "center" }}>
|
||||
<Box>
|
||||
<Typography sx={{ fontWeight: 900, lineHeight: 1.25 }}>
|
||||
<Paper key={j.id} component="article" aria-label={j.jobTitle} variant="outlined" sx={{ p: "var(--app-card-padding)", minWidth: 0, display: "grid", gridTemplateColumns: { xs: "minmax(0, 1fr)", md: "minmax(0, 1fr) auto" }, gap: 1.5, alignItems: "center" }}>
|
||||
<Box sx={{ minWidth: 0 }}>
|
||||
<Typography component="h4" variant="body1" sx={{ fontWeight: 700, lineHeight: 1.4, overflowWrap: "anywhere" }}>
|
||||
{j.company?.name ?? ""} <span style={{ fontWeight: 700, opacity: 0.7 }}>•</span> {j.jobTitle}
|
||||
</Typography>
|
||||
<Box sx={{ display: "flex", gap: 1, mt: 0.5, flexWrap: "wrap" }}>
|
||||
<Box sx={{ display: "flex", gap: 1, mt: 0.5, flexWrap: "wrap", minWidth: 0, "& .MuiChip-root": { maxWidth: "100%", height: "auto", minHeight: 24 }, "& .MuiChip-label": { whiteSpace: "normal", overflowWrap: "anywhere", py: 0.5 } }}>
|
||||
{j.needsFollowUp ? <Chip size="small" color="warning" label={t("remindersFollowUpLabel")} /> : null}
|
||||
{(j.workflowSignal?.reason ?? j.followUpReason) ? <Chip size="small" label={j.workflowSignal?.reason ?? j.followUpReason} variant="outlined" /> : null}
|
||||
{j.followUpAt ? <Chip size="small" label={t("remindersFollowUpDate", { date: new Date(j.followUpAt).toLocaleDateString() })} variant="outlined" /> : null}
|
||||
<Chip size="small" label={j.status} variant="outlined" />
|
||||
{j.followUpAt ? <Chip size="small" label={t("remindersFollowUpDate", { date: new Date(j.followUpAt).toLocaleDateString(language === "nb" ? "nb-NO" : "en") })} variant="outlined" /> : null}
|
||||
<Chip size="small" label={statusLabel(t, j.status)} variant="outlined" />
|
||||
</Box>
|
||||
</Box>
|
||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", justifyContent: "flex-end" }}>
|
||||
<Button size="small" variant="outlined" onClick={() => onOpen(j)}>{action?.label ?? t("remindersOpen")}</Button>
|
||||
<Button size="small" variant="outlined" onClick={() => onSetFollowUp(j.id, 3)}>+3d</Button>
|
||||
<Button size="small" variant="outlined" onClick={() => onSetFollowUp(j.id, 7)}>+7d</Button>
|
||||
<Button size="small" onClick={() => onSetFollowUp(j.id, null)}>{t("remindersClear")}</Button>
|
||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", minWidth: 0, justifyContent: { xs: "flex-start", md: "flex-end" }, "& > button": { maxWidth: "100%", whiteSpace: "normal", overflowWrap: "anywhere" } }}>
|
||||
<Button size="small" variant="outlined" disabled={disabled} aria-label={`${action?.label ?? t("remindersOpen")}: ${j.jobTitle}`} onClick={() => onOpen(j)}>{action?.label ?? t("remindersOpen")}</Button>
|
||||
{[3, 7].map(days => <Button key={days} size="small" variant="outlined" disabled={disabled} aria-label={t("remindersSnoozeForJob", { days, job: j.jobTitle })} onClick={() => onSetFollowUp(j, days)}>{t("remindersSnoozeDays", { days })}</Button>)}
|
||||
<Button size="small" disabled={disabled} aria-label={t("remindersClearForJob", { job: j.jobTitle })} onClick={() => onSetFollowUp(j, null)}>{t("remindersClear")}</Button>
|
||||
</Box>
|
||||
</Paper>
|
||||
);
|
||||
@@ -72,6 +83,10 @@ export default function RemindersView() {
|
||||
const navigate = useNavigate();
|
||||
const { toast } = useToast();
|
||||
const { t } = useI18n();
|
||||
const pending = useRef(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [failure, setFailure] = useState<FollowUpFailure | null>(null);
|
||||
const resultsRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const remindersResource = useViewResource(
|
||||
async () => {
|
||||
@@ -80,59 +95,81 @@ export default function RemindersView() {
|
||||
},
|
||||
{
|
||||
initialData: [],
|
||||
errorMessage: t("remindersLoadFailed"),
|
||||
deps: [t],
|
||||
errorMessage: "",
|
||||
},
|
||||
);
|
||||
|
||||
const items = remindersResource.data;
|
||||
const grouped = useMemo(() => groupItems(items), [items]);
|
||||
const loading = busy || remindersResource.loading || remindersResource.refreshing;
|
||||
const disabled = loading || !!remindersResource.error;
|
||||
|
||||
const openJob = (job: JobApplication) => {
|
||||
if (pending.current || disabled) return;
|
||||
navigate(buildWorkflowPath(job));
|
||||
};
|
||||
|
||||
const setFollowUp = async (id: number, daysFromNow: number | null) => {
|
||||
const setFollowUp = async (job: Pick<JobApplication, "id" | "jobTitle">, daysFromNow: number | null,
|
||||
date = daysFromNow === null ? null : new Date(Date.now() + daysFromNow * 24 * 60 * 60 * 1000).toISOString().slice(0, 10)) => {
|
||||
if (pending.current || disabled) return;
|
||||
pending.current = true;
|
||||
resultsRef.current?.focus();
|
||||
setBusy(true);
|
||||
setFailure(null);
|
||||
try {
|
||||
const d = daysFromNow === null ? null : new Date(Date.now() + daysFromNow * 24 * 60 * 60 * 1000).toISOString().slice(0, 10);
|
||||
await api.patch(`/jobapplications/${id}/followup`, { followUpAt: d });
|
||||
await api.patch(`/jobapplications/${job.id}/followup`, { followUpAt: date });
|
||||
toast(daysFromNow === null ? t("remindersFollowUpCleared") : t("remindersFollowUpSet"), "success");
|
||||
await remindersResource.reload();
|
||||
} catch {
|
||||
} catch (error) {
|
||||
const status = (error as { response?: { status?: number } })?.response?.status;
|
||||
const unauthorized = status === 401 || status === 403;
|
||||
setFailure({ job: { id: job.id, jobTitle: job.jobTitle }, days: daysFromNow, date,
|
||||
error: { kind: unauthorized ? "unauthorized" : "error", message: getApiErrorMessage(error, ""), retryable: !unauthorized, status } });
|
||||
toast(t("remindersFollowUpFailed"), "error");
|
||||
} finally {
|
||||
pending.current = false;
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Paper sx={{ mt: 0, p: 2 }}>
|
||||
<Typography variant="h6" sx={{ mb: 1 }}>{t("remindersTitle")}</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary", mb: 2 }}>
|
||||
{t("remindersSubtitle")}
|
||||
</Typography>
|
||||
const retryRead = async () => {
|
||||
if (pending.current || loading) return;
|
||||
pending.current = true;
|
||||
resultsRef.current?.focus();
|
||||
setBusy(true);
|
||||
try { await remindersResource.reload(); }
|
||||
finally { pending.current = false; setBusy(false); }
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack spacing={2} sx={{ minWidth: 0 }}>
|
||||
<PageHeader level="h2" title={t("remindersTitle")} description={t("remindersSubtitle")} />
|
||||
|
||||
<Box ref={resultsRef} role="region" aria-label={t("remindersTitle")} tabIndex={-1} aria-busy={loading} sx={{ minWidth: 0 }}>
|
||||
<ViewStateNotice
|
||||
loading={remindersResource.loading}
|
||||
error={remindersResource.error}
|
||||
title={t("remindersLoadFailed")}
|
||||
description={t("remindersLoadFailedBody")}
|
||||
onRetry={remindersResource.reload}
|
||||
compact loading={loading}
|
||||
error={failure?.error ?? (remindersResource.error ? { ...remindersResource.error, message: remindersResource.error.message || t("remindersLoadFailed") } : null)}
|
||||
title={failure ? t("remindersMutationFailedForJob", { job: failure.job.jobTitle }) : t("remindersLoadFailed")}
|
||||
description={failure ? undefined : t("remindersLoadFailedBody")}
|
||||
onRetry={failure ? () => setFollowUp(failure.job, failure.days, failure.date) : retryRead}
|
||||
/>
|
||||
|
||||
{!remindersResource.loading && !remindersResource.error ? (
|
||||
{!remindersResource.loading && remindersResource.error?.kind !== "unauthorized" ? (
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
|
||||
<ReminderSection title={t("remindersMissingTailoredCv")} items={grouped.missingCv} onOpen={openJob} onSetFollowUp={setFollowUp} />
|
||||
<ReminderSection title={t("remindersMissingInterviewPrep")} items={grouped.missingInterviewNotes} onOpen={openJob} onSetFollowUp={setFollowUp} />
|
||||
<ReminderSection title={t("remindersFollowUpDue")} items={grouped.overdueFollowUp} onOpen={openJob} onSetFollowUp={setFollowUp} />
|
||||
<ReminderSection title={t("remindersOther")} items={grouped.other} onOpen={openJob} onSetFollowUp={setFollowUp} />
|
||||
<ReminderSection title={t("remindersMissingTailoredCv")} items={grouped.missingCv} disabled={disabled} onOpen={openJob} onSetFollowUp={setFollowUp} />
|
||||
<ReminderSection title={t("remindersMissingInterviewPrep")} items={grouped.missingInterviewNotes} disabled={disabled} onOpen={openJob} onSetFollowUp={setFollowUp} />
|
||||
<ReminderSection title={t("remindersFollowUpDue")} items={grouped.overdueFollowUp} disabled={disabled} onOpen={openJob} onSetFollowUp={setFollowUp} />
|
||||
<ReminderSection title={t("remindersOther")} items={grouped.other} disabled={disabled} onOpen={openJob} onSetFollowUp={setFollowUp} />
|
||||
|
||||
{items.length === 0 ? <Typography sx={{ color: "text.secondary", textAlign: "center", py: 3 }}>{t("remindersNothing")}</Typography> : null}
|
||||
{!loading && !remindersResource.error && items.length === 0 ? <Typography sx={{ color: "text.secondary", textAlign: "center", py: 3 }}>{t("remindersNothing")}</Typography> : null}
|
||||
</Box>
|
||||
) : null}
|
||||
</Box>
|
||||
|
||||
<Divider sx={{ my: 2 }} />
|
||||
<Typography variant="caption" sx={{ color: "text.secondary" }}>
|
||||
{t("remindersTip")}
|
||||
</Typography>
|
||||
</Paper>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1617,6 +1617,10 @@ export const translations = {
|
||||
remindersTip: "Tip: focus on tailored CV and interview prep first for the highest-value roles.",
|
||||
remindersOpen: "Open",
|
||||
remindersClear: "Clear",
|
||||
remindersSnoozeDays: "+{days} days",
|
||||
remindersSnoozeForJob: "+{days} days: set follow-up for {job}",
|
||||
remindersClearForJob: "Clear follow-up: {job}",
|
||||
remindersMutationFailedForJob: "Could not update follow-up for {job}.",
|
||||
remindersFollowUpLabel: "Follow up",
|
||||
remindersFollowUpDate: "Follow-up: {date}",
|
||||
remindersFollowUpCleared: "Follow-up cleared.",
|
||||
@@ -4262,6 +4266,10 @@ export const translations = {
|
||||
remindersTip: "Tips: fokuser på skreddersydd CV og intervjuforberedelse først for de mest verdifulle rollene.",
|
||||
remindersOpen: "Åpne",
|
||||
remindersClear: "Fjern",
|
||||
remindersSnoozeDays: "+{days} dager",
|
||||
remindersSnoozeForJob: "+{days} dager: sett oppfølging for {job}",
|
||||
remindersClearForJob: "Fjern oppfølging: {job}",
|
||||
remindersMutationFailedForJob: "Kunne ikke oppdatere oppfølging for {job}.",
|
||||
remindersFollowUpLabel: "Følg opp",
|
||||
remindersFollowUpDate: "Oppfølging: {date}",
|
||||
remindersFollowUpCleared: "Oppfølging fjernet.",
|
||||
|
||||
@@ -0,0 +1,308 @@
|
||||
import React from "react";
|
||||
import "@testing-library/jest-dom";
|
||||
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import { MemoryRouter, useLocation } from "react-router-dom";
|
||||
import { api } from "./api";
|
||||
import RemindersView from "./components/RemindersView";
|
||||
import { I18nProvider } from "./i18n/I18nProvider";
|
||||
import { translations, type UiLanguage } from "./i18n/translations";
|
||||
import { ToastProvider } from "./toast";
|
||||
import type { JobApplication, WorkflowSignal } from "./types";
|
||||
|
||||
jest.mock("./api", () => ({
|
||||
api: { get: jest.fn(), patch: 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 readRequest = { params: { upcomingDays: 14 } };
|
||||
const now = Date.parse("2026-11-15T23:30:00Z");
|
||||
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
document.documentElement.lang = "en";
|
||||
jest.clearAllMocks();
|
||||
mockedApi.get.mockReset();
|
||||
mockedApi.patch.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => jest.restoreAllMocks());
|
||||
|
||||
function language(): UiLanguage {
|
||||
return document.documentElement.lang === "nb-NO" ? "nb" : "en";
|
||||
}
|
||||
|
||||
function copy(key: string, params: Record<string, string | number> = {}, locale: UiLanguage = language()) {
|
||||
const template = (translations[locale] as Record<string, string>)[key] ?? key;
|
||||
return template.replace(/\{(\w+)\}/g, (_, name) => String(params[name] ?? `{${name}}`));
|
||||
}
|
||||
|
||||
function signal(actionKey: WorkflowSignal["actionKey"], workspaceTab: WorkflowSignal["workspaceTab"], reason: string): WorkflowSignal {
|
||||
return {
|
||||
actionKey, workspaceTab, reason, needsAttention: true,
|
||||
hasPackageGap: actionKey === "package-work", needsInterviewPrep: actionKey === "interview-prep",
|
||||
needsFollowUpAction: actionKey === "follow-up", hasTailoredCv: true,
|
||||
hasSavedApplicationAnswerDraft: true, hasInterviewPrepNotes: true,
|
||||
};
|
||||
}
|
||||
|
||||
function job(id: number, overrides: Partial<JobApplication> = {}): JobApplication {
|
||||
return {
|
||||
id, jobTitle: `Reminder role ${id}`, company: { id, name: `Company ${id}` }, status: "Waiting",
|
||||
dateApplied: "2026-11-01", savedAt: "2026-10-20", responseReceived: false, daysSince: 14,
|
||||
needsFollowUp: true, followUpAt: "2026-11-17", followUpReason: "Legacy source reason",
|
||||
workflowSignal: signal("follow-up", "follow-up", "Recorded workflow reason"), ...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
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}</output>;
|
||||
}
|
||||
|
||||
function renderPage() {
|
||||
return render(<ToastProvider><I18nProvider><MemoryRouter initialEntries={["/reminders"]}>
|
||||
<RouteContext /><button>Outside reminder controls</button><RemindersView />
|
||||
</MemoryRouter></I18nProvider></ToastProvider>);
|
||||
}
|
||||
|
||||
function changeLanguage(locale: UiLanguage) {
|
||||
act(() => { window.dispatchEvent(new StorageEvent("storage", { key: "uiLanguage", newValue: locale })); });
|
||||
}
|
||||
|
||||
// Mutation contracts also run against the legacy row presentation, so RED reaches
|
||||
// duplicate-write/recovery behavior instead of stopping at the new article name.
|
||||
// The first case separately requires every migrated article and action name.
|
||||
function card(title: string): HTMLElement {
|
||||
const article = screen.queryByRole("article", { name: title });
|
||||
if (article) return article;
|
||||
const label = screen.getByText(new RegExp(title));
|
||||
return label.closest<HTMLElement>(".MuiPaper-root")!;
|
||||
}
|
||||
|
||||
function action(title: string, days: number | null): HTMLElement {
|
||||
const name = days === null ? copy("remindersClearForJob", { job: title }) : copy("remindersSnoozeForJob", { days, job: title });
|
||||
return within(card(title)).queryByRole("button", { name })
|
||||
?? within(card(title)).getByRole("button", { name: days === null ? copy("remindersClear") : `+${days}d` });
|
||||
}
|
||||
|
||||
function region() {
|
||||
return screen.getByRole("region", { name: copy("remindersTitle") });
|
||||
}
|
||||
|
||||
function feedbackScope(): HTMLElement {
|
||||
return screen.queryByRole("region", { name: copy("remindersTitle") })
|
||||
?? screen.getByText(copy("remindersTitle")).closest<HTMLElement>(".MuiPaper-root")!;
|
||||
}
|
||||
|
||||
function assertNoOtherWrites() {
|
||||
expect(mockedApi.put).not.toHaveBeenCalled();
|
||||
expect(mockedApi.post).not.toHaveBeenCalled();
|
||||
expect(mockedApi.delete).not.toHaveBeenCalled();
|
||||
}
|
||||
|
||||
test("preserves all four server-signal groups, raw reasons, contextual destinations and legacy fallback", async () => {
|
||||
const items = [
|
||||
job(11, { workflowSignal: signal("package-work", "tailored-cv", "Source CV reason"), tailoredCvText: "Existing text is not a client grouping rule" }),
|
||||
job(22, { status: "Interview", workflowSignal: signal("interview-prep", "interview-prep", "Source interview reason") }),
|
||||
job(33, { workflowSignal: { ...signal("follow-up", "follow-up", "Signal wins over legacy reason"), followMode: "waiting-update" }, followUpReason: "Tailored CV missing" }),
|
||||
job(44, { workflowSignal: signal("review-readiness", "readiness", "Source readiness reason") }),
|
||||
job(55, { status: "Custom hiring stage", workflowSignal: undefined, followUpReason: "Raw legacy reason — æøå" }),
|
||||
];
|
||||
mockedApi.get.mockResolvedValue({ data: items } as any);
|
||||
renderPage();
|
||||
expect(await screen.findByRole("heading", { level: 2, name: copy("remindersTitle") })).toBeInTheDocument();
|
||||
await screen.findByRole("article", { name: items[0].jobTitle });
|
||||
expect(screen.getAllByRole("heading", { level: 3 }).map((heading) => heading.textContent)).toEqual([
|
||||
copy("remindersMissingTailoredCv"), copy("remindersMissingInterviewPrep"), copy("remindersFollowUpDue"), copy("remindersOther"),
|
||||
]);
|
||||
const rowHeadings = screen.getAllByRole("heading", { level: 4 });
|
||||
expect(rowHeadings).toHaveLength(items.length);
|
||||
items.forEach((item, index) => expect(rowHeadings[index]).toHaveTextContent(item.jobTitle));
|
||||
expect(screen.getAllByRole("article")).toHaveLength(items.length);
|
||||
const paths = ["/jobs/11?section=cv", "/jobs/22?section=interview", "/jobs/33?section=communication&followMode=waiting-update", "/jobs/44?section=checklist", "/jobs/55"];
|
||||
const labels = ["jobTablePackageWork", "jobTableInterviewStage", "jobTableFollowUp", "jobTableReadiness", "remindersOpen"];
|
||||
items.forEach((item, index) => {
|
||||
const row = screen.getByRole("article", { name: item.jobTitle });
|
||||
expect(within(row).getByText(item.workflowSignal?.reason ?? item.followUpReason!)).toBeInTheDocument();
|
||||
for (const days of [3, 7]) expect(within(row).getByRole("button", { name: copy("remindersSnoozeForJob", { days, job: item.jobTitle }) })).toBeEnabled();
|
||||
expect(within(row).getByRole("button", { name: copy("remindersClearForJob", { job: item.jobTitle }) })).toBeEnabled();
|
||||
fireEvent.click(within(row).getByRole("button", { name: new RegExp(copy(labels[index]), "i") }));
|
||||
expect(screen.getByLabelText("Current route").textContent).toBe(paths[index]);
|
||||
});
|
||||
expect(screen.queryByText("Tailored CV missing")).not.toBeInTheDocument();
|
||||
expect(mockedApi.get).toHaveBeenCalledTimes(1);
|
||||
expect(mockedApi.get).toHaveBeenCalledWith("/jobapplications/reminders", readRequest);
|
||||
expect(mockedApi.patch).not.toHaveBeenCalled();
|
||||
assertNoOtherWrites();
|
||||
});
|
||||
|
||||
test.each([
|
||||
{ days: 3, expectedDate: "2026-11-18" },
|
||||
{ days: 7, expectedDate: "2026-11-22" },
|
||||
{ days: null, expectedDate: null },
|
||||
])("a held $days-day action submits one snapshot and locks every row action through refresh", async ({ days, expectedDate }) => {
|
||||
jest.spyOn(Date, "now").mockReturnValue(now);
|
||||
const target = job(71);
|
||||
const other = job(72);
|
||||
const write = deferred<any>();
|
||||
const refresh = deferred<any>();
|
||||
mockedApi.get.mockResolvedValueOnce({ data: [target, other] } as any).mockImplementationOnce(() => refresh.promise);
|
||||
mockedApi.patch.mockImplementation(() => write.promise);
|
||||
renderPage();
|
||||
await screen.findByText(new RegExp(target.jobTitle));
|
||||
const intended = action(target.jobTitle, days);
|
||||
const competing = action(other.jobTitle, days === 3 ? 7 : 3);
|
||||
const clear = action(target.jobTitle, null);
|
||||
intended.focus();
|
||||
act(() => { fireEvent.click(intended); fireEvent.click(competing); fireEvent.click(clear); });
|
||||
expect(mockedApi.patch).toHaveBeenCalledTimes(1);
|
||||
expect(mockedApi.patch).toHaveBeenCalledWith("/jobapplications/71/followup", { followUpAt: expectedDate });
|
||||
for (const item of [target, other]) within(card(item.jobTitle)).getAllByRole("button").forEach((button) => expect(button).toBeDisabled());
|
||||
expect(region()).toHaveFocus();
|
||||
await act(async () => { write.resolve({ status: 204 }); });
|
||||
expect(mockedApi.get).toHaveBeenCalledTimes(2);
|
||||
for (const item of [target, other]) within(card(item.jobTitle)).getAllByRole("button").forEach((button) => expect(button).toBeDisabled());
|
||||
await act(async () => { refresh.resolve({ data: [target, other] }); });
|
||||
await waitFor(() => expect(action(target.jobTitle, days)).toBeEnabled());
|
||||
expect(mockedApi.patch).toHaveBeenCalledTimes(1);
|
||||
expect(mockedApi.get).toHaveBeenNthCalledWith(2, "/jobapplications/reminders", readRequest);
|
||||
assertNoOtherWrites();
|
||||
});
|
||||
|
||||
test("a failed PATCH retains the row and durable localized error; Retry reuses its captured date while a fresh action uses the new clock", async () => {
|
||||
const clock = jest.spyOn(Date, "now").mockReturnValue(now);
|
||||
const target = job(81);
|
||||
mockedApi.get.mockResolvedValue({ data: [target] } as any);
|
||||
mockedApi.patch.mockRejectedValueOnce({ response: { status: 503 } }).mockResolvedValue({ status: 204 } as any);
|
||||
renderPage();
|
||||
await screen.findByText(new RegExp(target.jobTitle));
|
||||
fireEvent.click(action(target.jobTitle, 3));
|
||||
await waitFor(() => expect(mockedApi.patch).toHaveBeenCalledTimes(1));
|
||||
expect(await within(feedbackScope()).findByRole("alert")).toHaveTextContent(copy("remindersMutationFailedForJob", { job: target.jobTitle }));
|
||||
expect(card(target.jobTitle)).toBeVisible();
|
||||
expect(mockedApi.get).toHaveBeenCalledTimes(1);
|
||||
changeLanguage("nb");
|
||||
expect(within(feedbackScope()).getByRole("alert")).toHaveTextContent(copy("remindersMutationFailedForJob", { job: target.jobTitle }));
|
||||
expect(mockedApi.get).toHaveBeenCalledTimes(1);
|
||||
clock.mockReturnValue(Date.parse("2026-11-25T23:30:00Z"));
|
||||
fireEvent.click(within(feedbackScope()).getByRole("button", { name: copy("retry") }));
|
||||
await waitFor(() => expect(mockedApi.patch).toHaveBeenCalledTimes(2));
|
||||
expect(mockedApi.patch).toHaveBeenNthCalledWith(1, "/jobapplications/81/followup", { followUpAt: "2026-11-18" });
|
||||
expect(mockedApi.patch).toHaveBeenNthCalledWith(2, "/jobapplications/81/followup", { followUpAt: "2026-11-18" });
|
||||
await waitFor(() => expect(within(feedbackScope()).queryByRole("alert")).not.toBeInTheDocument());
|
||||
expect(mockedApi.get).toHaveBeenCalledTimes(2);
|
||||
fireEvent.click(action(target.jobTitle, 3));
|
||||
await waitFor(() => expect(mockedApi.patch).toHaveBeenCalledTimes(3));
|
||||
expect(mockedApi.patch).toHaveBeenNthCalledWith(3, "/jobapplications/81/followup", { followUpAt: "2026-11-28" });
|
||||
assertNoOtherWrites();
|
||||
});
|
||||
|
||||
test("after PATCH 204 and failed refresh, Retry reads only reminders and never repeats the event-producing PATCH", async () => {
|
||||
const target = job(91);
|
||||
mockedApi.get.mockResolvedValueOnce({ data: [target] } as any)
|
||||
.mockRejectedValueOnce({ response: { status: 503 } })
|
||||
.mockResolvedValueOnce({ data: [target] } as any);
|
||||
mockedApi.patch.mockResolvedValue({ status: 204 } as any);
|
||||
renderPage();
|
||||
await screen.findByText(new RegExp(target.jobTitle));
|
||||
fireEvent.click(action(target.jobTitle, null));
|
||||
expect(await within(feedbackScope()).findByRole("alert")).toHaveTextContent(copy("remindersLoadFailed"));
|
||||
expect(within(feedbackScope()).queryByText(copy("remindersMutationFailedForJob", { job: target.jobTitle }))).not.toBeInTheDocument();
|
||||
expect(mockedApi.patch).toHaveBeenCalledTimes(1);
|
||||
expect(mockedApi.patch).toHaveBeenCalledWith("/jobapplications/91/followup", { followUpAt: null });
|
||||
expect(mockedApi.get).toHaveBeenCalledTimes(2);
|
||||
fireEvent.click(within(feedbackScope()).getByRole("button", { name: copy("retry") }));
|
||||
await screen.findByText(new RegExp(target.jobTitle));
|
||||
expect(mockedApi.get).toHaveBeenCalledTimes(3);
|
||||
expect(mockedApi.get).toHaveBeenNthCalledWith(3, "/jobapplications/reminders", readRequest);
|
||||
expect(mockedApi.patch).toHaveBeenCalledTimes(1);
|
||||
assertNoOtherWrites();
|
||||
});
|
||||
|
||||
test("live EN/Bokmål presentation uses explicit date locale and canonical statuses while preserving raw reasons without GET or writes", async () => {
|
||||
const originalDate = Date.prototype.toLocaleDateString;
|
||||
jest.spyOn(Date.prototype, "toLocaleDateString").mockImplementation(function (
|
||||
this: Date,
|
||||
locales?: Parameters<typeof Date.prototype.toLocaleDateString>[0],
|
||||
options?: Parameters<typeof Date.prototype.toLocaleDateString>[1],
|
||||
) { return originalDate.call(this, locales ?? "de-DE", options); });
|
||||
const target = job(101, { followUpReason: "Do not replace the workflow reason", workflowSignal: signal("follow-up", "follow-up", "Raw source reason — æøå") });
|
||||
const custom = job(102, { status: "Faglig vurdering", workflowSignal: undefined, followUpReason: "Custom reason stays literal" });
|
||||
mockedApi.get.mockResolvedValue({ data: [target, custom] } as any);
|
||||
renderPage();
|
||||
await screen.findByText(new RegExp(target.jobTitle));
|
||||
expect(within(card(target.jobTitle)).getByText(copy("remindersFollowUpDate", { date: originalDate.call(new Date(target.followUpAt!), "en") }))).toBeInTheDocument();
|
||||
changeLanguage("nb");
|
||||
expect(screen.getByRole("heading", { level: 2, name: copy("remindersTitle") })).toBeInTheDocument();
|
||||
expect(within(card(target.jobTitle)).getByText(copy("remindersFollowUpDate", { date: originalDate.call(new Date(target.followUpAt!), "nb-NO") }))).toBeInTheDocument();
|
||||
expect(within(card(target.jobTitle)).getByText(translations.nb.statusWaiting)).toBeInTheDocument();
|
||||
expect(within(card(target.jobTitle)).getByText(target.workflowSignal!.reason)).toBeInTheDocument();
|
||||
expect(screen.queryByText(target.followUpReason!)).not.toBeInTheDocument();
|
||||
expect(within(card(custom.jobTitle)).getByText(custom.status)).toBeInTheDocument();
|
||||
expect(within(card(custom.jobTitle)).getByText(custom.followUpReason!)).toBeInTheDocument();
|
||||
expect(mockedApi.get).toHaveBeenCalledTimes(1);
|
||||
expect(mockedApi.patch).not.toHaveBeenCalled();
|
||||
assertNoOtherWrites();
|
||||
});
|
||||
|
||||
test("a generic read failure translates live without GET and explicit read Retry preserves its original request", async () => {
|
||||
mockedApi.get.mockRejectedValueOnce({ response: { status: 503 } }).mockResolvedValueOnce({ data: [] } as any);
|
||||
renderPage();
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent(copy("remindersLoadFailed"));
|
||||
changeLanguage("nb");
|
||||
expect(screen.getByRole("alert")).toHaveTextContent(copy("remindersLoadFailed"));
|
||||
expect(mockedApi.get).toHaveBeenCalledTimes(1);
|
||||
fireEvent.click(screen.getByRole("button", { name: copy("retry") }));
|
||||
expect(await screen.findByText(copy("remindersNothing"))).toBeInTheDocument();
|
||||
expect(mockedApi.get).toHaveBeenCalledTimes(2);
|
||||
expect(mockedApi.get).toHaveBeenNthCalledWith(2, "/jobapplications/reminders", readRequest);
|
||||
expect(mockedApi.patch).not.toHaveBeenCalled();
|
||||
assertNoOtherWrites();
|
||||
});
|
||||
|
||||
test("when an action removes its row, focus stays in the named results region instead of a removed button", async () => {
|
||||
const target = job(111, { status: "Custom stage", workflowSignal: undefined });
|
||||
const write = deferred<any>();
|
||||
mockedApi.get.mockResolvedValueOnce({ data: [target] } as any).mockResolvedValueOnce({ data: [] } as any);
|
||||
mockedApi.patch.mockImplementationOnce(() => write.promise);
|
||||
renderPage();
|
||||
await screen.findByText(new RegExp(target.jobTitle));
|
||||
const clear = action(target.jobTitle, null);
|
||||
clear.focus();
|
||||
fireEvent.click(clear);
|
||||
expect(region()).toHaveFocus();
|
||||
await act(async () => { write.resolve({ status: 204 }); });
|
||||
expect(await screen.findByText(copy("remindersNothing"))).toBeInTheDocument();
|
||||
expect(screen.queryByRole("article", { name: target.jobTitle })).not.toBeInTheDocument();
|
||||
expect(region()).toHaveFocus();
|
||||
expect(mockedApi.patch).toHaveBeenCalledTimes(1);
|
||||
assertNoOtherWrites();
|
||||
});
|
||||
|
||||
test("a removed reminder row does not steal focus deliberately moved outside while its PATCH is held", async () => {
|
||||
const target = job(121, { status: "Custom stage", workflowSignal: undefined });
|
||||
const write = deferred<any>();
|
||||
mockedApi.get.mockResolvedValueOnce({ data: [target] } as any).mockResolvedValueOnce({ data: [] } as any);
|
||||
mockedApi.patch.mockImplementationOnce(() => write.promise);
|
||||
renderPage();
|
||||
await screen.findByText(new RegExp(target.jobTitle));
|
||||
const clear = action(target.jobTitle, null);
|
||||
clear.focus();
|
||||
fireEvent.click(clear);
|
||||
const outside = screen.getByRole("button", { name: "Outside reminder controls" });
|
||||
outside.focus();
|
||||
await act(async () => { write.resolve({ status: 204 }); });
|
||||
expect(await screen.findByText(copy("remindersNothing"))).toBeInTheDocument();
|
||||
expect(outside).toHaveFocus();
|
||||
expect(screen.queryByRole("article", { name: target.jobTitle })).not.toBeInTheDocument();
|
||||
expect(mockedApi.patch).toHaveBeenCalledTimes(1);
|
||||
assertNoOtherWrites();
|
||||
});
|
||||
Reference in New Issue
Block a user