diff --git a/docs/redesign/phase-7/COMMITS.md b/docs/redesign/phase-7/COMMITS.md index 3332428..0dcfcbb 100644 --- a/docs/redesign/phase-7/COMMITS.md +++ b/docs/redesign/phase-7/COMMITS.md @@ -68,3 +68,11 @@ Use exactly three sequential implementation commits: embedded Application forms, ### Final P7-06 embedded-forms observation Final embedded-forms implemented scope is **CONDITIONAL GO**, normal push pending. The matching aggregate/generation repair resolves the reviewed cross-job draft leak: ordinary parent RED18/1, latest affected **55/4 PASS** and actual SPA/heldB/exact two-job readback PASS. Final parent build9sM6acEkOGxgN5fTRZXdE has **9PASS/1FAIL of10** (unchanged strict actual NB language204→500/RD11); fresh Jobs5/P2-D01ordinary1 give **15PASS/1FAIL of16**, qualified earlier CV4/Career11 give **30PASS/1FAIL relevant31**. Backend30 sameDLL/no-build; app/browser types/lint/build PASS. All23/111 current forms PNGs actually reviewed, 55 recorded axe scans0/741 (current22/154+Jobs8/89; earlierCV12/121+Career13/377), full incomplete contexts pending human qualification. See [portable forms result](p7-06/evidence/forms-results.json). +11frontend/0suites derives **717/94**; +10browser derives **283**, no integrated run. All57 groups56PARTIAL/C11DEFERRED and32routes, P513withheld/06Fdeferred/main unchanged/productionNO-GO. Inbox/read and compose/send remain; Phase8 not started. + +Forms committed and normal push verified **4c3254c472871d1cac05abcef502feb4a1be435b** at2026-10-13T21:27:19.353249UTC: cleanHEAD=origin=live development, main27657f3 unchanged. First slice implemented CONDITIONAL GO; overallP7-06 ongoing. Inbox/read entered next; compose/send follows. No production or Phase8 action. + +### P7-06 inbox/read review — final bounded observation + +[Inbox report](p7-06/INBOX.md) and [portable results](p7-06/evidence/inbox-results.json) retain actual42/5 frontend,25 unchanged-DLL/no-build backend and fresh8/8 native on build uhKavi8_u6PrcEm2rZ1On. App/browser types, lint and build pass; all20 latest PNGs actually displayed/reviewed, all-enabled20 axe scans0 violations/84 incomplete contrast contexts retained. Meaningful I7 and I8 RED→GREEN prove literal all/hash/current-query recovery and one-step Back after typing. The incorrect55 native assumption was corrected to positively preserve the existing owned trashed-job56/page50+6/detail200 contract; authorization stays strict. Twelve added cases/zero suites derive729/94 frontend and eight added native derive291 browser only. Implemented-scope CONDITIONAL GO, final hygiene/normal push pending; commit hash follows direct receipt in the successor compose boundary. Main unchanged, production NO-GO. Compose/send remains next. + +Final inbox hygiene PASS observed2026-10-14 05:14:23UTC: secret and working/staged/phase whitespace gates pass,15 docs/474 local links resolve,57 groups and32 routes retained. Normal development push pending; main unchanged. diff --git a/docs/redesign/phase-7/DEBT.md b/docs/redesign/phase-7/DEBT.md index 4dfbaac..ccf1919 100644 --- a/docs/redesign/phase-7/DEBT.md +++ b/docs/redesign/phase-7/DEBT.md @@ -170,3 +170,9 @@ The real native language save produced HTTP500 on repeated locale attempts with ### Final P7-06 embedded-forms observation Final embedded-forms implemented scope is **CONDITIONAL GO**, normal push pending. The matching aggregate/generation repair resolves the reviewed cross-job draft leak: ordinary parent RED18/1, latest affected **55/4 PASS** and actual SPA/heldB/exact two-job readback PASS. Final parent build9sM6acEkOGxgN5fTRZXdE has **9PASS/1FAIL of10** (unchanged strict actual NB language204→500/RD11); fresh Jobs5/P2-D01ordinary1 give **15PASS/1FAIL of16**, qualified earlier CV4/Career11 give **30PASS/1FAIL relevant31**. Backend30 sameDLL/no-build; app/browser types/lint/build PASS. All23/111 current forms PNGs actually reviewed, 55 recorded axe scans0/741 (current22/154+Jobs8/89; earlierCV12/121+Career13/377), full incomplete contexts pending human qualification. See [portable forms result](p7-06/evidence/forms-results.json). +11frontend/0suites derives **717/94**; +10browser derives **283**, no integrated run. All57 groups56PARTIAL/C11DEFERRED and32routes, P513withheld/06Fdeferred/main unchanged/productionNO-GO. Inbox/read and compose/send remain; Phase8 not started. + +### P7-06 inbox/read disposition + +[Inbox/read evidence](p7-06/evidence/inbox-results.json) confirms repairs for initial failure versus empty, exact GET-only list/detail/provider recovery, late-query/fallback exclusion, locale-only no-GET, review-note retention, synchronous explicit decisions and created-resource recovery. Literal all, URL hash/state/source and one-step Back after text-search edits have ordinary failing then passing regressions. The owned trashed-mail56/page50+6 contract is preserved; failed55 was an unsupported test premise, not a backend policy defect. Final42/5 units and8/8 native pass; all20 PNGs reviewed,20axe0/84 incomplete contrast contexts remain. Strict real language204 passed in this final run; this does not diagnose or close earlier RD11 HTTP500. Actual provider grants/sync/create atomicity, human reader/device/zoom and release evidence remain open. The confirmed compose UUID/save/send/complete-dirty-fields safety dependency remains mandatory at the next approved third boundary; no delivery or new attempt is enabled by this read slice. + +Final inbox hygiene PASS observed2026-10-14 05:14:23UTC: secret and working/staged/phase whitespace gates pass,15 docs/474 local links resolve,57 groups and32 routes retained. Normal development push pending; main unchanged. diff --git a/docs/redesign/phase-7/PRESERVATION.md b/docs/redesign/phase-7/PRESERVATION.md index 64824c0..d19fbf2 100644 --- a/docs/redesign/phase-7/PRESERVATION.md +++ b/docs/redesign/phase-7/PRESERVATION.md @@ -180,3 +180,9 @@ Implemented P7-03 scope is **CONDITIONAL GO**. The normal import/history push `5 ### Final P7-06 embedded-forms observation Final embedded-forms implemented scope is **CONDITIONAL GO**, normal push pending. The matching aggregate/generation repair resolves the reviewed cross-job draft leak: ordinary parent RED18/1, latest affected **55/4 PASS** and actual SPA/heldB/exact two-job readback PASS. Final parent build9sM6acEkOGxgN5fTRZXdE has **9PASS/1FAIL of10** (unchanged strict actual NB language204→500/RD11); fresh Jobs5/P2-D01ordinary1 give **15PASS/1FAIL of16**, qualified earlier CV4/Career11 give **30PASS/1FAIL relevant31**. Backend30 sameDLL/no-build; app/browser types/lint/build PASS. All23/111 current forms PNGs actually reviewed, 55 recorded axe scans0/741 (current22/154+Jobs8/89; earlierCV12/121+Career13/377), full incomplete contexts pending human qualification. See [portable forms result](p7-06/evidence/forms-results.json). +11frontend/0suites derives **717/94**; +10browser derives **283**, no integrated run. All57 groups56PARTIAL/C11DEFERRED and32routes, P513withheld/06Fdeferred/main unchanged/productionNO-GO. Inbox/read and compose/send remain; Phase8 not started. + +### P7-06 inbox/read review — bounded CONDITIONAL GO + +[Inbox/read report](p7-06/INBOX.md) and [exact evidence](p7-06/evidence/inbox-results.json) add M03 owner-page/filter/direction/link/detail/Back/hash/literal-all/GET-retry and late-query evidence, preserving owned trashed-parent correspondence56/page50+6/detail200 and foreign/anonymous denial. M02/M05 notes, synchronous explicit decisions, hidden pending decisions, created-ID recovery and saved-decision GET-only Retry have focused unit/controller proof; native queue/provider projections remain explicitly qualified. M06 provider-status GET recovery does not establish real grants/reconnect/sync.42/5 units,25 unchanged-DLL backend,8/8 native,20 reviewed PNGs/20axe0/84 incomplete; strict final language204 passes but RD11 remains undiagnosed. M04 compose/send is not accepted by this second slice. All57 groups remain56 PARTIAL/C11 DEFERRED,32routes unchanged; M01–M12/provider/output/release conditions persist. Hygiene/normal push pending. + +Final inbox hygiene PASS observed2026-10-14 05:14:23UTC: secret and working/staged/phase whitespace gates pass,15 docs/474 local links resolve,57 groups and32 routes retained. Normal development push pending; main unchanged. diff --git a/docs/redesign/phase-7/README.md b/docs/redesign/phase-7/README.md index ef83d0a..ffd87ac 100644 --- a/docs/redesign/phase-7/README.md +++ b/docs/redesign/phase-7/README.md @@ -14,7 +14,7 @@ The entry commit is `b5319981ad3d1bd635daff861453d23c0de5320b` on `codex/ui-rede | [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 pushc723a4e verified; Reminders17/3,new6native/backend6/types/lint/build pass;17actual PNG reviews/114incompletes qualified. P7-05 implemented CONDITIONAL GO, hygiene PASS/normal push627955a directly verified11:49:32UTC, clean localHEAD=origin=live/main unchanged; P7-06 next | -| [P7-06](p7-06/README.md) | Remaining Application forms and global correspondence | Overall ongoing; [embedded forms](p7-06/FORMS.md) CONDITIONAL GO pending final static/push. Entry39/3/latest54/4 units/backend30 no-build retained. Current native8/1 has only unchanged strict language-save204→HTTP500 EN (RD11); current Jobs5/P2-D01ordinary1 give14pass/1fail of15. Previous-build CV4/Career11 give29pass/1fail relevant30 with as-run qualifiers. PageDown passes; no rerun/waiver or new axe/push claim. Derived716/94 frontend/282browser cases only;57/32 unchanged. Inbox/read review then compose/send remain separate sequential commits | +| [P7-06](p7-06/README.md) | Remaining Application forms and global correspondence | Overall ongoing. Forms4c325 pushed/55/4 units/30 backend; current15PASS/1FAIL relevant16 retains strict RD11, qualified prior consumers30/1 relevant31. [Inbox/read](p7-06/INBOX.md) implemented CONDITIONAL GO, final hygiene/push pending:42/5 units,25 sameDLL backend,8/8 native, types/lint/build pass;20 reviewed PNGs/20axe0/84 incomplete. +12front derives729/94; +8native derives291, no full-suite claim. Compose/send is the mandatory third boundary;06F deferred;57/32/main unchanged | | 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 | | P7-08 | Existing notifications and operations | Existing store and real destinations; new enabled producers require F20 recovery evidence; planned | @@ -60,3 +60,7 @@ Implemented P7-03 scope is **CONDITIONAL GO**. The normal import/history push `5 ### Final P7-06 embedded-forms observation Final embedded-forms implemented scope is **CONDITIONAL GO**, normal push pending. The matching aggregate/generation repair resolves the reviewed cross-job draft leak: ordinary parent RED18/1, latest affected **55/4 PASS** and actual SPA/heldB/exact two-job readback PASS. Final parent build9sM6acEkOGxgN5fTRZXdE has **9PASS/1FAIL of10** (unchanged strict actual NB language204→500/RD11); fresh Jobs5/P2-D01ordinary1 give **15PASS/1FAIL of16**, qualified earlier CV4/Career11 give **30PASS/1FAIL relevant31**. Backend30 sameDLL/no-build; app/browser types/lint/build PASS. All23/111 current forms PNGs actually reviewed, 55 recorded axe scans0/741 (current22/154+Jobs8/89; earlierCV12/121+Career13/377), full incomplete contexts pending human qualification. See [portable forms result](p7-06/evidence/forms-results.json). +11frontend/0suites derives **717/94**; +10browser derives **283**, no integrated run. All57 groups56PARTIAL/C11DEFERRED and32routes, P513withheld/06Fdeferred/main unchanged/productionNO-GO. Inbox/read and compose/send remain; Phase8 not started. + +Forms committed and normal push verified **4c3254c472871d1cac05abcef502feb4a1be435b** at2026-10-13T21:27:19.353249UTC: cleanHEAD=origin=live development, main27657f3 unchanged. First slice implemented CONDITIONAL GO; overallP7-06 ongoing. Inbox/read entered next; compose/send follows. No production or Phase8 action. + +Final inbox hygiene PASS observed2026-10-14 05:14:23UTC: secret and working/staged/phase whitespace gates pass,15 docs/474 local links resolve,57 groups and32 routes retained. Normal development push pending; main unchanged. diff --git a/docs/redesign/phase-7/p7-06/FORMS.md b/docs/redesign/phase-7/p7-06/FORMS.md index ac4ad76..7d5e6b9 100644 --- a/docs/redesign/phase-7/p7-06/FORMS.md +++ b/docs/redesign/phase-7/p7-06/FORMS.md @@ -79,3 +79,5 @@ Final parent-source forms run at21:19:34.115654–21:21:37.836490UTC is **9 pass The authoritative final capsule is `forms/parent-final/results.json`, SHA256e1ff6f5c9928a765630db3229b1ae88312e65cea82d24efabc16ccec6a817c1e, with23/111 final forms images actually reviewed. Final triage `forms/axe-triage-parent.json` SHA25651f6adcb2b205f0a983c73646864a53ba2b724a453217c3f5390d0520e35da67 records currentforms22/154 plus currentJobs8/89 and earlierqualifiedCV12/121,Career13/377:55scans0violations/741incomplete. Twenty matchedforms scans retain7semantic removals;2newB captures and2missingstrictlocale captures explain scope changes. This is not improved contrast. All current snapshots/74export files match;6of7pre-native files match, native fixture/Home changes explicitly qualified. Exportdigest e91f356cb09a8e828ad10e6497eb2927d5e7159b48907ddcd7132458486f9a1f. FreshJobs5 at21:21:45.248272–21:23:27.806820 andordinaryP2-D01 at21:23:27.812337–21:23:39.684013 PASS; earlier CV4/Career11 remain correct as-run qualifications. SourceWorkspace7a0fb891f2632b35a8267ca0143a68522334f20bd2b2eda57b3ed2661e357934, nativeb349b5190226b0d4b5e4cfd9a56d0bd5507c7ad5da328f11d5b38829cb271914. Final static/normal push observation is separate. Final hygiene at21:26:32–35UTC passes tracked/untracked secret scan, working/staged/Phase7 whitespace,14document465relative-link checks,57groups and32original routes. Local development HEAD/origin still627955a after fresh fetch; main27657f3 unchanged. Normal development commit/push receipt follows in the inbox/read successor record, avoiding a self-referential commit hash. RD11 remains ordinary failing and mandatory; independent safe development can continue. + +Forms committed and normal push verified **4c3254c472871d1cac05abcef502feb4a1be435b** at2026-10-13T21:27:19.353249UTC: cleanHEAD=origin=live development, main27657f3 unchanged. First slice implemented CONDITIONAL GO; overallP7-06 ongoing. Inbox/read entered next; compose/send follows. No production or Phase8 action. diff --git a/docs/redesign/phase-7/p7-06/INBOX.md b/docs/redesign/phase-7/p7-06/INBOX.md new file mode 100644 index 0000000..d7afa1c --- /dev/null +++ b/docs/redesign/phase-7/p7-06/INBOX.md @@ -0,0 +1,63 @@ +# P7-06 — Inbox and read review + +**Status: bounded CONDITIONAL GO; final static checks and normal push pending.** This is the second of three approved P7-06 implementation boundaries: embedded forms → inbox/read review → compose/send. The forms slice is committed and pushed; this inbox slice has no verified push receipt yet. Draft persistence and send repairs remain the [next mandatory safety boundary](../DEBT.md#p7-06-confirmed-compose-safety-entry-dependency). Optional P7-06F remains DEFERRED. + +[Inbox entry](evidence/inbox-entry.json) retains the preceding forms push `4c3254c472871d1cac05abcef502feb4a1be435b`, observed on 2026-10-13 at 21:27:19.353249 UTC: clean local HEAD = origin = live development, local/origin/live main unchanged `27657f31131cd95d42f0e1290853ec9c9b3959a4`. [Forms results](evidence/forms-results.json) retain its separate evidence and conditions. + +## Existing owners and bounded repairs + +`CorrespondenceInboxPage` retains saved-message paging, search/direction/link filters, detail selection, job Back context and the legacy review alias. Filter changes preserve unrelated query parameters, route state and URL fragments. The literal search “all” stays a search term; only enum filters use the “all” sentinel. Typing replaces the current search history entry so one Back step returns to the prior screen. + +Read results belong to their captured query/page. A pending or failed new query cannot leave an old query's rows, counts or actions active. Same-query refresh can retain its successful snapshot. Generation guards reject late reads, and UI language changes retranslate without another inbox/provider/draft GET. + +Provider status has independent GET-only recovery. Detail recovery retains the exact provider/message identity and existing saved fallback; Hide, another selection or view changes invalidate stale completion. Saved bodies remain plain text. Shared surfaces, H2/H3 hierarchy, named groups, current-page metric labels, wrapping content and deliberate retry focus replace inconsistent presentation. + +`GmailReviewPage` retains its paired review/provider read owner. Errors remain distinct from a disconnected account or a genuinely empty queue. Notes and filters survive recovery and view changes. A synchronous local mutex protects explicit decisions even when a filter hides the active row. If a write succeeds and its following GET fails, Retry repeats only the read. If creation already returned an ID, recovery retains that resource and its Open job action rather than issuing another create. + +Exact decision, sync, create and unlink payloads remain compatible. This slice does not change mailbox grants, backend paging, tenant/resource authorization, draft revision semantics or send ownership. Local serialization is not distributed idempotency or live-provider delivery qualification. + +## Saved correspondence contract + +The established global inbox includes owned correspondence associated with jobs in Trash. The current controller/owner predicates agree with Phase 6 `b5319981` and production `27657f3`; there is no global correspondence soft-delete exclusion to introduce. + +The native fixture therefore positively preserves **56 owned messages**, exact date→ID ordering, pages **50/6**, owned trashed-message detail HTTP200, anonymous HTTP401 and foreign-owner HTTP404. An earlier expected total 55 was an unsupported policy assumption. Its failed run remains retained; correcting that oracle does not change backend behavior or weaken owner denial. + +## Automated evidence + +| Observation | Actual result | Qualification | +|---|---|---| +| Original affected baseline | **30/5 PASS** | 2026-10-13 16:29:38.669864–16:29:51.144721 UTC. Frozen source snapshots were collected after the run; no retrospective pre-run capture is claimed | +| Corrected meaningful initial RED | **30 pass/10 fail** | `unit-red-dialog-ready`, 21:34:16.284168–21:34:30.853292 UTC. Original 30 pass; failures expose recovery, late reads, locale rereads and mutation ownership | +| Initial candidate GREEN | **40/5 PASS** | `unit-green`, 21:45:30.565488–21:45:46.720094 UTC; 14 pre/post source captures match | +| I7 query-context RED → GREEN | **40 pass/1 fail → 41/5 PASS** | RED reproduces literal “all” being cleared. GREEN also executes fragment/source/filter/page, stale-query actions and no-write assertions; assertions after the first RED failure were not reached in RED | +| I8 Back-history RED → GREEN | **41 pass/1 fail → 42/5 PASS** | Four actual search input changes reproduce extra Back steps. RED ran 2026-10-13 22:06:13.284326–22:06:33.443764 UTC; its post-run source capture occurred after the usage pause on Oct14 | +| Latest affected GREEN | **42/5 PASS** | `unit-history-green`, 2026-10-14 05:08:44.577597–05:09:06.324638 UTC; 14 pre/post sources match, zero failed/pending/todo | +| Existing backend subset | **25 PASS** | 2026-10-13 21:33:13.648686–21:33:17.047640 UTC. Correspondence/Email/Gmail tests on the unchanged Release DLL with `--no-build`; no full backend rerun | +| Prior native candidate | **8/8 PASS** | Build `VzfBnZASyHuo3qkczerhH`, 2026-10-13 22:04:13.376117–22:05:00.507133 UTC; predates I8's search-history repair and cannot establish current-source native acceptance | +| Final current-source native | **8/8 PASS** | Build `uhKavi8_u6PrcEm2rZ1On`, 2026-10-14 05:09:27.486825–05:10:16.147321 UTC; all16 pre/post sources match, zero failed/skipped/flaky cases | + +Exact raw commands, reports, failed attempts and source hashes remain under `.artifacts/phase7/p7-06/inbox/`. The first native attempt recorded 6 pass/2 fail from fixture/locator premises. The two-case context RED retained a genuine old-row action defect and the false total 55 oracle; it did not reach the first case's fragment assertion. Original Message selectors were narrowed to the exact textbox after semantic groups made broad queries ambiguous; payload, confirmation, conflict and uncertain-state assertions remain. + +The five affected suites contain inbox 24, review 7, Gmail import 6, search context 2 and provider connections 3. **Twelve new frontend cases/zero suites** derive **729/94**, using forms 717/94. Eight native cases derive **291 browser cases** from 283. Both are inventories, not whole-suite pass counts. Backend 1,034 remains derived; latest whole frontend 573/89, backend 1,033 and sidecar 49 stay inherited until P7-12. + +## Native, visual and accessibility boundary + +Fresh app/browser TypeScript, lint and build pass against the final search-history source. App TypeScript ran 05:08:31–05:08:43, lint 05:08:43–05:09:06, browser TypeScript 05:09:06–05:09:18 and build 05:09:18–05:09:27 UTC on Oct14; all exit 0. Final capsule `.artifacts/phase7/p7-06/inbox/history-final/results.json` has SHA256 `e11bbee367949a6478889b1176d71ef5316a9d0c85713a0547761a8dfe29bc53` and 137,271 bytes. It records current build `uhKavi8_u6PrcEm2rZ1On`, eight ordinary native passes and all16 pre/post source matches. Finite static checks and normal push remain pending. + +All 20 latest PNGs were displayed and reviewed. No new clipped mail control was observed; saved bodies, fallback alerts, current-page labels, wrapping Bokmål and light/dark focus remain consistent in the sampled states. Native geometry records root horizontal overflow 0. Four long captures—populated 1440 and 320/360/390—were resized for display; sticky full-page detail/review composites do not establish viewport occlusion, physical keyboard, real zoom or assistive-technology acceptance. + +All 20 all-enabled axe scans record **0 violations/84 incomplete nodes**, retaining full vectors. All incompletes concern color contrast: 20 short badge-text nodes and 64 input/MUI Select background-overlap nodes. They require contextual human contrast evaluation. Neither zero violations nor screenshot review closes M01–M12. The prior candidate's 20 PNGs/20 scans remain separate earlier-build observations and are not counted again. + +Native proof distinguishes actual owned HTTP/SQLite correspondence reads and a nonlinked review-decision POST from controlled provider/review projections. The saved-list/detail/order/page/owner checks use disposable SQLite. Live-provider failure and the paired review queue use explicit local fixtures. A browser-only provider discriminator exercises fallback against real saved detail; it does not change stored provider data. The review recovery queue projects the actual decision response and is not independent provider persistence readback. + +No Google/Outlook/IMAP grant, provider sync/import, suggested-job creation, unlink, compose/draft/send, model call or delivery runs in the native read slice. Unit/backend coverage qualifies those existing request contracts where applicable. Strict real EN/Bokmål language HTTP204 assertions remain ordinary cases: one passing inbox fixture does not diagnose or close the separately reproduced RD11 HTTP500. + +Finite EN/Bokmål, light/dark and 320,360,390,768,1024,1280 landscape,1440 and1920 samples cover long inbox/detail/review content and keyboard recovery. Full-page captures can be resized and do not establish viewport focus visibility or physical keyboard behavior. Retain full axe incomplete vectors and the applicable human checks from the [approved handoff](../../phase-6/p6-05/PHASE7_HANDOFF.md#migration-order-and-commit-boundaries). + +## Acceptance conditions and next boundary + +All 57 preservation groups remain **56 PARTIAL / PRES-C11 DEFERRED** and 32 routes. M01–M12 remain **NOT VERIFIED**. RD11/RD21, screen-reader, physical touch/software keyboard, real zoom, font/photo/native-print/provider and release qualifications remain open. + +Complete review and push this second boundary before implementing the separately scoped compose/send repair. Preserve exact draft identity/revision and uncertain-delivery safeguards there. **P5-13 remains withheld; P7-06F remains deferred; production remains NO-GO; main is unchanged; Phase 8 has not started.** + +Final inbox hygiene PASS observed2026-10-14 05:14:23UTC: secret and working/staged/phase whitespace gates pass,15 docs/474 local links resolve,57 groups and32 routes retained. Normal development push pending; main unchanged. diff --git a/docs/redesign/phase-7/p7-06/README.md b/docs/redesign/phase-7/p7-06/README.md index 8047ef7..5f739cb 100644 --- a/docs/redesign/phase-7/p7-06/README.md +++ b/docs/redesign/phase-7/p7-06/README.md @@ -1,46 +1,53 @@ # P7-06 — Remaining Application forms and global correspondence -**Embedded forms: CONDITIONAL GO; final static checks and normal push pending. Overall P7-06 remains ongoing.** The [bounded forms report](FORMS.md) records repairs, meaningful failures and finite evidence, including the unchanged strict language-save HTTP500 failure (RD11). Follow the exact [approved handoff](../../phase-6/p6-05/PHASE7_HANDOFF.md#migration-order-and-commit-boundaries). P7-06 has exactly three sequential implementation commits: embedded forms, inbox/read review, then compose/send. Optional P7-06F remains **DEFERRED** until its exact mode→draft/review action and fallback are decided. +**Overall P7-06: ongoing. Embedded forms: implemented-scope CONDITIONAL GO, committed and pushed. Inbox/read review: bounded CONDITIONAL GO; final static checks and normal push pending. Compose/send: next mandatory boundary.** -[Entry evidence](evidence/entry.json) records the actual Reminders normal push `627955afa989d32fefa53f15da9ef3e0abf27038`, directly observed2026-10-13 11:49:32.473200UTC: localHEAD=origin=live development, clean worktree at observation, local/origin/live main unchanged `27657f31131cd95d42f0e1290853ec9c9b3959a4`. P7-05 implemented scope is CONDITIONAL GO. Its push adds no new runtime, human or production acceptance. +Follow the exact [approved handoff](../../phase-6/p6-05/PHASE7_HANDOFF.md#migration-order-and-commit-boundaries). P7-06 has three sequential implementation boundaries: embedded forms → inbox/read review → compose/send. Optional P7-06F remains **DEFERRED** until its exact mode→draft/review action and fallback are decided. -## Fresh pre-change baseline +## Current checkpoint status -The existing affected Jest baseline passes **39 cases/3 suites**: Application assets18, workspace overlay18 and workspace helper3, with zero failed, skipped/pending or todo cases. It ran2026-10-13 11:39:16.918910–11:39:27.061883UTC at HEAD `c723a4e3032185da623d81ace241eec748506fc7` with uncommitted Reminders work. All seven captured relevant sources remained unchanged through the run. This earlier source/run capsule is separate from the later clean627955a receipt. - -Exact ignored capsule: `.artifacts/phase7/p7-06/forms/unit-baseline`. Its raw JSON report is `p706-forms-baseline-20261013-review-20261013T113916Z.json`, SHA256 `6cd3a67d35f0172d3c7de32287a72c2f1fd3aee6b6bcee15474f7ceaeff3f81e`. Portable entry retains the command, source hashes, raw report descriptors and after-run source equality. Existing mocked tests establish a baseline; they do not establish a repair or native acceptance. - -## Sequential boundaries and required proof - -| Order | Existing owner and bounded work | Required exit | +| Boundary | Current result | Evidence and remaining work | |---|---|---| -| 1 — Embedded forms | `ApplicationAssets.tsx`; retain the seven-section workspace, table, routes and renderer. Correct loaded-state/error/retry/dirty/AI review presentation and synchronous operation ownership in this owner | Meaningful RED and focused unit/native proof for read error→GET-only Retry, loaded dirty save/restore failure, long letter/answers/history, dirty Cancel/Restore, locale change without resource reads/writes and held Save/AI Apply/action context. Preserve current data/readback and frozen package bytes | -| 2 — Inbox/read review | Existing CorrespondenceInboxPage/GmailReviewPage and review alias; retain list/detail/filter/page/back/provider ownership | Native populated/empty/loading/error/retry/foreign cases, filters/pages and detail→Back context, keyboard/focus/reflow and explicit review choices; no compose persistence restructuring | -| 3 — Compose/send | Existing draft revision, confirmation and delivery owners; repair the [confirmed compose safety dependency](../DEBT.md#p7-06-confirmed-compose-safety-entry-dependency) coherently at this boundary | Meaningful runtime RED, captured-identity/synchronous Save–Send protection, complete dirty fields, revision/stale-conflict recovery, explicit confirmed send, uncertain-delivery protection and deliberate definitive-failure new attempt; no automatic send/resend | +| Embedded Application forms | Implemented-scope **CONDITIONAL GO**; normal push verified | [Forms report](FORMS.md), [portable results](evidence/forms-results.json). Commit `4c3254c472871d1cac05abcef502feb4a1be435b`; RD11 and manual qualifications remain | +| Inbox/read review | Bounded **CONDITIONAL GO**; static checks and normal push pending | [Inbox report](INBOX.md), [entry evidence](evidence/inbox-entry.json). Latest affected units **42/5 PASS**, final native **8/8 PASS**, app/browser TypeScript, lint and build pass; manual/provider/reliability qualifications remain | +| Compose/send | **Not implemented at this boundary** | Repair the [confirmed safety dependency](../DEBT.md#p7-06-confirmed-compose-safety-entry-dependency) coherently after the inbox slice. Preserve draft revisions, explicit confirmation, uncertain delivery and deliberate failed-attempt recovery | -The first slice entered with confirmed code-path findings: mutation errors hide loaded dirty controls/history; initial GET failures lack Retry; locale changes trigger reads; dirty Restore has no confirmation; AI Apply during parent Save can lose edits and a mutable action can misattribute its suggestion. [FORMS.md](FORMS.md) records repairs, exact operation/identity guards and attachment recovery. Latest54/4 units pass. Current buildxyh9FYyySEw1uIao4DveJ native8/1 retains only the strict real-account language-save204→HTTP500 EN failure; keyboard PageDown passes. Fresh current Jobs5/P2-D01ordinary1 give14pass/1fail of15; previous-build CV4/Career11 extend the relevant count to29pass/1fail of30, retaining their as-run source/build qualifiers. Final static/push proof is pending; RD11 is not waived or closed. +The forms push was observed on **2026-10-13 at 21:27:19.353249 UTC**: clean local HEAD = origin = live `codex/ui-redesign-phase4`. Local/origin/live `main` remained `27657f31131cd95d42f0e1290853ec9c9b3959a4`. This receipt establishes source synchronization; it does not add human or production acceptance. -## Preserved contracts +## Completed forms evidence -- One CV variant per application; current source, association and tenant/resource boundaries. -- Cover-letter source/action and additive owned history; dirty discard remains deliberate. -- Bodyless restore without ExpectedVersion; [RD21](../DEBT.md) remains open rather than becoming a hidden concurrency rewrite. -- The two existing application answers use their current Notes encoding and clearing behavior. -- Draft/frozen packages, archived attachments and native P2-D01 byte proof remain unchanged. -- Existing shared MUI/theme/state/confirm/error/i18n owners; no generic Documents persistence or second renderer. +The forms slice preserves the seven-section workspace, CV cardinality and associations, existing letter/answer encoding, additive history, owned attachments and frozen package bytes. It adds generation/identity guards, durable GET-only recovery, deliberate dirty Restore confirmation and synchronous Save/AI Apply ownership. The later aggregate repair prevents one job's drafts from populating another job while its read is pending. -Follow EN/Bokmål, light/dark, narrow/long forms, density, keyboard/focus/error and relevant viewport/zoom requirements from the handoff. Capture exact source/run artifacts, inspect final images and triage all-enabled axe vectors without replacing applicable human/device checks. +Latest affected frontend tests pass **55 cases/4 suites**; the unchanged backend subset passes **30 cases** on the same Release DLL with `--no-build`. Final forms build `9sM6acEkOGxgN5fTRZXdE` records **9 pass/1 fail of 10** native cases. The strict actual Bokmål language save expected HTTP204 and received HTTP500: **RD11 remains an ordinary failure**. Fresh Jobs5 and ordinary P2-D01 give **15 pass/1 fail of 16** on that build. Qualified earlier-build CV4/Career11 extend the relevant observation to **30 pass/1 fail of 31**; they are not fresh current-build executions. -## Current disposition and retained gates +All **23/111 latest forms PNGs** were displayed and reviewed; the other 88 retained consumer images were not reviewed in that continuation. Across the qualified forms/Jobs/CV/Career lanes, **55 recorded axe scans** contain **0 violations/741 incomplete nodes**. Raw vectors and source/build limits remain in the forms report. Missing locale captures and changed scan scope do not establish contrast improvement. App/browser TypeScript, lint, build and finite static checks passed before the verified forms push. -All57 preservation groups remain **56 PARTIAL /PRES-C11 DEFERRED** and32 routes. Entry frontend **706/94**, browser **273** and backend **1,034** are **derived only**. Latest full frontend573/89, backend1,033 and sidecar49 remain inherited; P7-12 owns fresh integrated regression. No P7-06 additions are claimed by this baseline. +## Inbox/read scope -The forms slice adds10 frontend cases/zero suites and9 ordinary browser cases, deriving **716/94 frontend** and **282 browser cases** only. Latest affected54/4 includes existing named controls5/1, which are not counted again. Existing backend30/30 no-build and qualified RichText/workspace consumer evidence do not establish a new whole-suite run or complete forms acceptance. All22 current forms PNGs from `final-verified` were actually displayed/reviewed:22/110 total, with88 other retained images; corrected attachment-failure copy is confirmed. The earlier22/110 review remains separate. Sticky composites/scaled long images retain pixel/occlusion/zoom/device limits. Prior55 scans0/749 stay qualified; no new axe total is inferred. Final portable/static/hygiene/push records remain pending, with production NO-GO. +The second slice retains existing owner-paginated correspondence, detail/provider fallback, URL filters/pages, legacy alias and job Back context. It reuses shared surfaces, semantic headings, feedback and focus patterns. Read ownership prevents late or unrelated results from changing the active query; explicit recovery retains drafts and does not replay a saved decision POST. -The current reviewed capsule `.artifacts/phase7/p7-06/forms/final-verified/results.json` has SHA256 `28da994d574e819208d65edc756fac13eca0597a9d622bcd47a3eb488db7eea4`; captured `ApplicationAssets.tsx` has SHA256 `d3be34c85fd4ab8984356f5fdc8a5b741590120e4253f97246bf870486d027c1`. [FORMS.md](FORMS.md) retains the finite current/prior-source and visual qualifications. +The saved correspondence contract includes owned messages associated with jobs in Trash. The native oracle preserves all **56 owned messages**, exact ordering, pages **50/6**, owned trashed-message detail and foreign-resource denial. The earlier expected total 55 was a false policy assumption, retained as failed evidence rather than represented as a backend regression. -M01–M12 remain NOT VERIFIED. Actual reader/device/software keyboard/browser zoom, deployed fonts/photo/native print/other browser, provider and release qualifications remain. P5-13 stays withheld; P7-06F stays deferred; production remains **NO-GO**, main unchanged and Phase8 has no implementation. Embedded forms continue first; inbox/read review and compose/send remain separate later commits. +See [INBOX.md](INBOX.md) for meaningful RED/GREEN progression, the final-run boundary and provider-fixture limits. Compose persistence and send behavior remain the third slice. -### Final P7-06 embedded-forms observation +Final inbox build `uhKavi8_u6PrcEm2rZ1On` ran all eight native cases on 2026-10-14 at 05:09:27.486825–05:10:16.147321 UTC, with zero failed/skipped/flaky cases. All 16 captured pre/post sources match. All 20 latest PNGs were displayed and reviewed; all20 all-enabled axe scans record 0 violations/84 incomplete nodes. The incompletes require human contrast assessment, and full-page image review retains narrow/sticky/zoom/device limits. Strict real language HTTP204 passes in this fixture; separately reproduced RD11 remains intermittent and undiagnosed. -Final embedded-forms implemented scope is **CONDITIONAL GO**, normal push pending. The matching aggregate/generation repair resolves the reviewed cross-job draft leak: ordinary parent RED18/1, latest affected **55/4 PASS** and actual SPA/heldB/exact two-job readback PASS. Final parent build9sM6acEkOGxgN5fTRZXdE has **9PASS/1FAIL of10** (unchanged strict actual NB language204→500/RD11); fresh Jobs5/P2-D01ordinary1 give **15PASS/1FAIL of16**, qualified earlier CV4/Career11 give **30PASS/1FAIL relevant31**. Backend30 sameDLL/no-build; app/browser types/lint/build PASS. All23/111 current forms PNGs actually reviewed, 55 recorded axe scans0/741 (current22/154+Jobs8/89; earlierCV12/121+Career13/377), full incomplete contexts pending human qualification. See [portable forms result](evidence/forms-results.json). +11frontend/0suites derives **717/94**; +10browser derives **283**, no integrated run. All57 groups56PARTIAL/C11DEFERRED and32routes, P513withheld/06Fdeferred/main unchanged/productionNO-GO. Inbox/read and compose/send remain; Phase8 not started. +## Count reconciliation + +| Inventory | Entry | Forms additions | Inbox additions | Current derived total | +|---|---:|---:|---:|---:| +| Frontend cases/suites | 706/94 | +11 cases/+0 suites | +12 cases/+0 suites | **729/94** | +| Browser cases | 273 | +10 | +8 | **291** | +| Backend cases | 1,034 | +0 | +0 | **1,034** | + +These are **derived inventories**, not a fresh integrated pass count. Latest actual whole frontend **573/89**, backend **1,033** and sidecar **49** remain inherited. P7-12 requires fresh integrated regression and an explanation of every changed count. + +## Preservation and remaining gates + +All **57 preservation groups remain 56 PARTIAL / PRES-C11 DEFERRED**, with **32 routes** retained. Current forms and inbox evidence narrows particular workflow risks; it does not mark entire groups complete. Preserve Career Profile/CV scope, frozen packages, original paging/search semantics and server-side authorization. Bodyless restore without ExpectedVersion and partial-write behavior remain under RD21. + +Compose/send must establish captured draft identity, synchronous Save–Send protection, complete dirty-field handling, revision/conflict recovery, explicit confirmed send, uncertain-delivery protection and a deliberate new attempt after definitive failure. Do not automatically send or resend. No generic Documents persistence or second renderer belongs in this boundary. + +M01–M12 remain **NOT VERIFIED**. Actual screen-reader, physical touch/software keyboard, real zoom, provider, fonts/photo/native print and release evidence remain qualified. RD11 stays open; P5-13 stays withheld; P7-06F stays deferred. **Production: NO-GO; main unchanged. Phase 8 implementation: none.** + +Final inbox hygiene PASS observed2026-10-14 05:14:23UTC: secret and working/staged/phase whitespace gates pass,15 docs/474 local links resolve,57 groups and32 routes retained. Normal development push pending; main unchanged. diff --git a/docs/redesign/phase-7/p7-06/evidence/forms-results.json b/docs/redesign/phase-7/p7-06/evidence/forms-results.json index dac332b..f421c6f 100644 --- a/docs/redesign/phase-7/p7-06/evidence/forms-results.json +++ b/docs/redesign/phase-7/p7-06/evidence/forms-results.json @@ -3,7 +3,7 @@ "checkpoint": "P7-06", "slice": "Embedded application forms", "recordedUtc": "2026-10-13T21:26:30.542966+00:00", - "acceptance": "FORMS_IMPLEMENTED_SCOPE_CONDITIONAL_GO_HYGIENE_PASS_NORMAL_PUSH_PENDING", + "acceptance": "FORMS_IMPLEMENTED_SCOPE_CONDITIONAL_GO_HYGIENE_PASS_NORMAL_PUSH_VERIFIED", "overallCheckpoint": "ONGOING_INBOX_READ_AND_COMPOSE_SEND_REMAIN", "entry": { "schemaVersion": 1, @@ -3427,5 +3427,106 @@ "reproduced": "New B editor received A aggregate props while B GET held;18oldPASS/1newFAIL. Gate matchingID and generationguard preserve current owner; actualSPA native timeOrigin stable, nofields/no Bwrite held, exact B payload and both A drafts unchanged.", "review": "Read-only follow-up review found no remaining actionable gap in narrowrepair.", "limits": "Original locale-dependent aggregate reload/error presentation and RD21 noExpectedVersion remain; no new backend locking contract." + }, + "commit": "4c3254c472871d1cac05abcef502feb4a1be435b", + "normalPushVerification": { + "observedUtc": "2026-10-13T21:27:19.353249+00:00", + "branch": "codex/ui-redesign-phase4", + "head": "4c3254c472871d1cac05abcef502feb4a1be435b", + "originDevelopment": "4c3254c472871d1cac05abcef502feb4a1be435b", + "liveDevelopment": "4c3254c472871d1cac05abcef502feb4a1be435b", + "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": "4c3254c472871d1cac05abcef502feb4a1be435b\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": "4c3254c472871d1cac05abcef502feb4a1be435b\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": "4c3254c472871d1cac05abcef502feb4a1be435b\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" + } + ] } } diff --git a/docs/redesign/phase-7/p7-06/evidence/inbox-entry.json b/docs/redesign/phase-7/p7-06/evidence/inbox-entry.json new file mode 100644 index 0000000..201c02f --- /dev/null +++ b/docs/redesign/phase-7/p7-06/evidence/inbox-entry.json @@ -0,0 +1,114 @@ +{ + "schemaVersion": 1, + "checkpoint": "P7-06", + "slice": "Inbox and read review", + "entry": "CONDITIONAL_GO", + "precedingFormsPush": { + "observedUtc": "2026-10-13T21:27:19.353249+00:00", + "branch": "codex/ui-redesign-phase4", + "head": "4c3254c472871d1cac05abcef502feb4a1be435b", + "originDevelopment": "4c3254c472871d1cac05abcef502feb4a1be435b", + "liveDevelopment": "4c3254c472871d1cac05abcef502feb4a1be435b", + "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": "4c3254c472871d1cac05abcef502feb4a1be435b\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": "4c3254c472871d1cac05abcef502feb4a1be435b\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": "4c3254c472871d1cac05abcef502feb4a1be435b\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" + } + ] + }, + "scope": "Existing owner-paginated saved inbox/provider-detail fallback/independent provider status, current view filters/pages/Back/alias, paired Gmail review read/notes/explicit decisions/sync/create guards. Compose persistence/send remains third slice.", + "baseline": { + "actual": "30/5 affected cases2026-10-13T16:29:38.669864–16:29:51.144721UTC; frozenunit-baseline next-owner snapshots collected after run, not retrospective pre-run capture.", + "whole": "573/89 frontend,1033 backend,49 sidecar inherited;717/94 frontend,283 browser,1034 backend DERIVED ONLY;P7-12 full fresh mandatory.", + "rawPrefix": "p706-inbox-read-baseline-20261013-20261013T162938Z" + }, + "preservation": "57 groups56PARTIAL/C11DEFERRED;32routes;M01-M12NOTVERIFIED;RD11/RD21open;P513WITHHELD;06FDEFERRED;main27657f3/productionNO-GO;noPhase8", + "remaining": "Meaningful RED before product edits, bounded repairs and readback/owner tests, responsive/theme/locale/accessibility, affected tests/types/lint/build/static, normal push/receipt. No acceptance assigned." +} diff --git a/docs/redesign/phase-7/p7-06/evidence/inbox-results.json b/docs/redesign/phase-7/p7-06/evidence/inbox-results.json new file mode 100644 index 0000000..6579226 --- /dev/null +++ b/docs/redesign/phase-7/p7-06/evidence/inbox-results.json @@ -0,0 +1,1212 @@ +{ + "schemaVersion": 1, + "checkpoint": "P7-06", + "slice": "Inbox and read review", + "recordedUtc": "2026-10-14T05:12:56.311091+00:00", + "acceptance": "INBOX_IMPLEMENTED_SCOPE_CONDITIONAL_GO_HYGIENE_PASS_NORMAL_PUSH_PENDING", + "precedingFormsCommit": "4c3254c472871d1cac05abcef502feb4a1be435b", + "immutableFinalCapsule": { + "path": ".artifacts/phase7/p7-06/inbox/history-final/results.json", + "sha256": "e11bbee367949a6478889b1176d71ef5316a9d0c85713a0547761a8dfe29bc53", + "bytes": 137271, + "hashPolicy": "Exact raw bytes" + }, + "verifiedFileReferences": 201, + "unit": { + "result": { + "path": ".artifacts/phase7/p7-06/inbox/unit-history-green/results.json", + "sha256": "1eabc38795d18ea5c101ae9c8f74dedd44756e9f5109ca10468edbbddd97d057", + "bytes": 2850, + "hashPolicy": "Exact raw bytes" + }, + "capturedAtUtc": "2026-10-14T05:09:23.438541+00:00", + "allSourcesMatchPreRun": true, + "prefix": "p706-inbox-history-green-20261014-review-20261014T050844Z", + "command": { + "command": [ + "rtk", + "proxy", + "npm.cmd", + "test", + "--", + "--runInBand", + "--watchAll=false", + "--json", + "--outputFile=E:\\Storage\\Projects\\Active\\Job tracker\\.artifacts\\phase7\\current\\unit\\p706-inbox-history-green-20261014-review-20261014T050844Z.json", + "src/correspondence-inbox-page.test.tsx", + "src/gmail-review-page.test.tsx", + "src/correspondence-gmail-import.test.tsx", + "src/correspondence-search-context.test.ts", + "src/email-provider-connections.test.tsx" + ], + "startedUtc": "2026-10-14T05:08:44.577597+00:00", + "finishedUtc": "2026-10-14T05:09:06.324638+00:00", + "exitCode": 0, + "counts": { + "numPassedTests": 42, + "numFailedTests": 0, + "numPendingTests": 0, + "numTodoTests": 0, + "numPassedTestSuites": 5, + "numFailedTestSuites": 0 + } + }, + "counts": { + "numPassedTests": 42, + "numFailedTests": 0, + "numPendingTests": 0, + "numTodoTests": 0, + "numPassedTestSuites": 5, + "numFailedTestSuites": 0 + }, + "suites": [ + { + "name": "correspondence-inbox-page.test.tsx", + "passed": 24, + "failed": 0 + }, + { + "name": "correspondence-gmail-import.test.tsx", + "passed": 6, + "failed": 0 + }, + { + "name": "gmail-review-page.test.tsx", + "passed": 7, + "failed": 0 + }, + { + "name": "email-provider-connections.test.tsx", + "passed": 3, + "failed": 0 + }, + { + "name": "correspondence-search-context.test.ts", + "passed": 2, + "failed": 0 + } + ], + "frozenRawFiles": [ + { + "path": ".artifacts/phase7/p7-06/inbox/unit-history-green/raw/p706-inbox-history-green-20261014-review-20261014T050844Z.json", + "sha256": "2fe9b412e7ba312279a9248ec5fa30b17e073a836353542b203ceb1aa3378697", + "bytes": 19600 + }, + { + "path": ".artifacts/phase7/p7-06/inbox/unit-history-green/raw/p706-inbox-history-green-20261014-review-20261014T050844Z.log", + "sha256": "a56fbc5751c9c707e90a2c526f35b830deb7d11d794fbeccaeb5ee753165b104", + "bytes": 943 + }, + { + "path": ".artifacts/phase7/p7-06/inbox/unit-history-green/raw/p706-inbox-history-green-20261014-review-20261014T050844Z-command.json", + "sha256": "1a31282f9f0521039b68ccc7f9323144317a817886db1493135eb816d6c1351c", + "bytes": 859 + } + ], + "qualification": "42ordinaryPASS including I8 unchanged afterRED: fouractual search changes, exactquery/filter/source/hash, oneBackprior-screen. Original41PASS; mockedAxios/Jest only, no native/provider/model." + }, + "backend": { + "passed": 25, + "failed": 0, + "command": { + "command": [ + "rtk", + "proxy", + "C:/Users/Connor/.dotnet/dotnet.exe", + "test", + "JobTrackerApi.Tests/JobTrackerApi.Tests.csproj", + "--configuration", + "Release", + "--no-build", + "--filter", + "FullyQualifiedName~CorrespondenceControllerTests|FullyQualifiedName~EmailControllerTests|FullyQualifiedName~GmailControllerTests", + "--logger", + "trx;LogFileName=career.trx", + "--results-directory", + "E:\\Storage\\Projects\\Active\\Job tracker\\.artifacts\\phase7\\current\\backend-career\\p706-inbox-20261013-20261013T213313Z" + ], + "startedUtc": "2026-10-13T21:33:13.648686+00:00", + "finishedUtc": "2026-10-13T21:33:17.047640+00:00", + "exitCode": 0, + "logSha256": "ba47c705bae12eacc5f7bb28a4c246b1a77c751e320e50a08971a4936fa0b198", + "counts": { + "total": 25, + "passed": 25, + "failed": 0, + "notExecuted": 0 + }, + "resultSha256": "04de3c99bd26810f1665aa76aad272b4b9cb642f67000b2ea3e324921ef1646e" + }, + "raw": [ + { + "path": ".artifacts/phase7/current/backend-career/p706-inbox-20261013-20261013T213313Z/command.json", + "sha256": "9797bd655787a1bb255db6f58de4c769a6fb5f94d96cf79f0d58af63417894c6", + "bytes": 984, + "hashPolicy": "Exact raw bytes" + }, + { + "path": ".artifacts/phase7/current/backend-career/p706-inbox-20261013-20261013T213313Z/career.log", + "sha256": "ba47c705bae12eacc5f7bb28a4c246b1a77c751e320e50a08971a4936fa0b198", + "bytes": 616, + "hashPolicy": "Exact raw bytes" + }, + { + "path": ".artifacts/phase7/current/backend-career/p706-inbox-20261013-20261013T213313Z/career.trx", + "sha256": "04de3c99bd26810f1665aa76aad272b4b9cb642f67000b2ea3e324921ef1646e", + "bytes": 36263, + "hashPolicy": "Exact raw bytes" + } + ], + "qualification": "Existing Correspondence/Email/Gmail subset; unchanged verified Release DLL, no-build; no whole backend rerun." + }, + "native": { + "command": { + "name": "correspondence-read", + "command": [ + "rtk", + "proxy", + "node", + "node_modules/@playwright/test/cli.js", + "test", + "e2e/phase7-correspondence-read.spec.ts", + "--retries=0", + "--reporter=list,json", + "--output=E:\\Storage\\Projects\\Active\\Job tracker\\.artifacts\\phase7\\current\\ui\\correspondence-read-results" + ], + "startedUtc": "2026-10-14T05:09:27.486825+00:00", + "finishedUtc": "2026-10-14T05:10:16.147321+00:00", + "exitCode": 0 + }, + "commandRaw": { + "path": ".artifacts/phase7/p7-06/inbox/history-final/correspondence-read-command.json", + "sha256": "1a3e754cc6b8baab633d3c1d414ec604cee5a36655859401e4aef1d4ba9a13c8", + "bytes": 500, + "hashPolicy": "Exact measured raw bytes" + }, + "reportRaw": { + "path": ".artifacts/phase7/p7-06/inbox/history-final/correspondence-read.json", + "sha256": "fc6b42f39d67796457e174cfa500ab9c1bca31d53f2dbf7da0425a88a54ef761", + "bytes": 4115554, + "hashPolicy": "Exact measured raw bytes" + }, + "logRaw": { + "path": ".artifacts/phase7/p7-06/inbox/history-final/correspondence-read.log", + "sha256": "eb5e067e2e1d41b78c8adea3a47362fec549575eb3cd78aaad1f112d4853a7b5", + "bytes": 2007, + "hashPolicy": "Exact measured raw bytes" + }, + "stats": { + "startTime": "2026-10-14T05:09:28.106Z", + "duration": 47986.426, + "expected": 8, + "skipped": 0, + "unexpected": 0, + "flaky": 0 + }, + "cases": [ + { + "title": "[P7-06 read] actual owner page50, filters, ordering and job Back preserve saved-message context and ownership", + "file": "phase7-correspondence-read.spec.ts", + "line": 126, + "expectedStatus": "passed", + "project": "chromium", + "resultStatuses": [ + "passed" + ], + "retries": [ + 0 + ], + "errors": [ + [] + ], + "durationMs": 5761 + }, + { + "title": "[P7-06 read] initial503 Retry and held latest query remain distinct from true empty results", + "file": "phase7-correspondence-read.spec.ts", + "line": 163, + "expectedStatus": "passed", + "project": "chromium", + "resultStatuses": [ + "passed" + ], + "retries": [ + 0 + ], + "errors": [ + [] + ], + "durationMs": 3299 + }, + { + "title": "[P7-06 read] actual saved-detail Retry preserves its ID and Hide/another row cancel stale completion", + "file": "phase7-correspondence-read.spec.ts", + "line": 189, + "expectedStatus": "passed", + "project": "chromium", + "resultStatuses": [ + "passed" + ], + "retries": [ + 0 + ], + "errors": [ + [] + ], + "durationMs": 3389 + }, + { + "title": "[P7-06 read] qualified provider live-copy failure retries exact captured source before real saved fallback", + "file": "phase7-correspondence-read.spec.ts", + "line": 208, + "expectedStatus": "passed", + "project": "chromium", + "resultStatuses": [ + "passed" + ], + "retries": [ + 0 + ], + "errors": [ + [] + ], + "durationMs": 2275 + }, + { + "title": "[P7-06 read] strict real UI-language204 preserves inbox context, selected saved body and dirty review notes with zero read deltas", + "file": "phase7-correspondence-read.spec.ts", + "line": 226, + "expectedStatus": "passed", + "project": "chromium", + "resultStatuses": [ + "passed" + ], + "retries": [ + 0 + ], + "errors": [ + [] + ], + "durationMs": 3841 + }, + { + "title": "[P7-06 read] independent provider-status503 Retry only rereads status while saved owner messages remain usable", + "file": "phase7-correspondence-read.spec.ts", + "line": 243, + "expectedStatus": "passed", + "project": "chromium", + "resultStatuses": [ + "passed" + ], + "retries": [ + 0 + ], + "errors": [ + [] + ], + "durationMs": 2276 + }, + { + "title": "[P7-06 read] qualified paired review reads retain notes/filter and actual nonlinked decision recovery never replays committed POST", + "file": "phase7-correspondence-read.spec.ts", + "line": 258, + "expectedStatus": "passed", + "project": "chromium", + "resultStatuses": [ + "passed" + ], + "retries": [ + 0 + ], + "errors": [ + [] + ], + "durationMs": 4326 + }, + { + "title": "[P7-06 read] eight finite bilingual theme widths preserve long saved bodies and qualified review-note keyboard access", + "file": "phase7-correspondence-read.spec.ts", + "line": 297, + "expectedStatus": "passed", + "project": "chromium", + "resultStatuses": [ + "passed" + ], + "retries": [ + 0 + ], + "errors": [ + [] + ], + "durationMs": 14411 + } + ], + "reportErrors": [], + "configuration": { + "version": "1.62.1", + "workers": 1, + "projects": [ + { + "name": "chromium", + "retries": 0 + } + ] + } + }, + "gates": [ + { + "command": { + "name": "typecheck", + "command": [ + "rtk", + "proxy", + "npm.cmd", + "run", + "typecheck" + ], + "startedUtc": "2026-10-14T05:08:31.241097+00:00", + "finishedUtc": "2026-10-14T05:08:43.954058+00:00", + "exitCode": 0 + }, + "commandRaw": { + "path": ".artifacts/phase7/p7-06/inbox/history-final/gates/typecheck-command.json", + "sha256": "672e6ea51b08274dc2ccbf6818937997219b454eade1071059a2e4db5f66f286", + "bytes": 247, + "hashPolicy": "Exact measured raw bytes" + }, + "logRaw": { + "path": ".artifacts/phase7/p7-06/inbox/history-final/gates/typecheck.log", + "sha256": "89b0ec97b537eca6560c13ca2dfea9e3a97dd1bb35580c4ff39d2462db25e080", + "bytes": 148, + "hashPolicy": "Exact measured raw bytes" + } + }, + { + "command": { + "name": "lint", + "command": [ + "rtk", + "proxy", + "npm.cmd", + "run", + "lint" + ], + "startedUtc": "2026-10-14T05:08:43.956058+00:00", + "finishedUtc": "2026-10-14T05:09:06.339676+00:00", + "exitCode": 0 + }, + "commandRaw": { + "path": ".artifacts/phase7/p7-06/inbox/history-final/gates/lint-command.json", + "sha256": "3b5fe229c79a99dd2d5bf0178e033081c337315b92fd815fae282feb40875373", + "bytes": 237, + "hashPolicy": "Exact measured raw bytes" + }, + "logRaw": { + "path": ".artifacts/phase7/p7-06/inbox/history-final/gates/lint.log", + "sha256": "b040dbeca9b4adb3f6f4c25ac49de0d37e921375b2d9b57e87b542a10f599913", + "bytes": 136, + "hashPolicy": "Exact measured raw bytes" + } + }, + { + "command": { + "name": "browser-typecheck", + "command": [ + "rtk", + "proxy", + "npm.cmd", + "exec", + "tsc", + "--", + "--project", + "../.artifacts/phase7/e2e-tsconfig.json", + "--noEmit" + ], + "startedUtc": "2026-10-14T05:09:06.343177+00:00", + "finishedUtc": "2026-10-14T05:09:18.937328+00:00", + "exitCode": 0 + }, + "commandRaw": { + "path": ".artifacts/phase7/p7-06/inbox/history-final/gates/browser-typecheck-command.json", + "sha256": "67a5a49a92b0c9c1205b252baf46f74779c315c9bbaaf713d74e00504786db34", + "bytes": 343, + "hashPolicy": "Exact measured raw bytes" + }, + "logRaw": { + "path": ".artifacts/phase7/p7-06/inbox/history-final/gates/browser-typecheck.log", + "sha256": "d2993bc68ad9a63f711c8f5473155e2ba00fdcd461220cc3849185f2f9ad93e6", + "bytes": 78, + "hashPolicy": "Exact measured raw bytes" + } + }, + { + "command": { + "name": "build", + "command": [ + "rtk", + "proxy", + "npm.cmd", + "run", + "build" + ], + "startedUtc": "2026-10-14T05:09:18.939332+00:00", + "finishedUtc": "2026-10-14T05:09:27.480251+00:00", + "exitCode": 0 + }, + "commandRaw": { + "path": ".artifacts/phase7/p7-06/inbox/history-final/gates/build-command.json", + "sha256": "3498e9a755765ba5dfb0ea05928f4a25f224fd31cea9e68a3d0d0b7ee736b5db", + "bytes": 239, + "hashPolicy": "Exact measured raw bytes" + }, + "logRaw": { + "path": ".artifacts/phase7/p7-06/inbox/history-final/gates/build.log", + "sha256": "1b978578d84d1d35893bd07658cf37c393ddd1d9da064eca0a019cd9edae61fb", + "bytes": 670, + "hashPolicy": "Exact measured raw bytes" + } + } + ], + "buildId": "uhKavi8_u6PrcEm2rZ1On", + "export": { + "count": 74, + "sha256": "a2b6238906fedd6b52df64f2e963100d3d7b0f62e95c58095a963700534a8170", + "policy": "Windows Path sort; original output path + TAB + exact raw file SHA + LF" + }, + "sourceSnapshots": [ + { + "originalSourcePath": "job-tracker-ui/src/correspondence-inbox-page.test.tsx", + "raw": { + "path": ".artifacts/phase7/p7-06/inbox/history-final/source/job-tracker-ui/src/correspondence-inbox-page.test.tsx", + "sha256": "494e4e7da11a5910cd55b742837ca250ff0fa3efa9d28994eda15e98371b7ddf", + "bytes": 50069, + "hashPolicy": "Exact measured raw bytes" + }, + "canonicalLf": { + "path": ".artifacts/phase7/p7-06/inbox/history-final/source/job-tracker-ui/src/correspondence-inbox-page.test.tsx", + "sha256": "9e50b3cf9b5111d762d7947a4e45ac7cd4b4f3ecea5f95e07314fa679497c391", + "bytes": 49247, + "hashPolicy": "UTF-8 text with CRLF normalized to LF" + }, + "qualification": "Current candidate snapshot at collection time; parent must qualify unchanged as-run source before acceptance." + }, + { + "originalSourcePath": "job-tracker-ui/src/gmail-review-page.test.tsx", + "raw": { + "path": ".artifacts/phase7/p7-06/inbox/history-final/source/job-tracker-ui/src/gmail-review-page.test.tsx", + "sha256": "e67ca4f4f5bbd38043c4420159124dd98cad9789ebbfc380ebc1c2772a46b2a9", + "bytes": 16559, + "hashPolicy": "Exact measured raw bytes" + }, + "canonicalLf": { + "path": ".artifacts/phase7/p7-06/inbox/history-final/source/job-tracker-ui/src/gmail-review-page.test.tsx", + "sha256": "667bdfe4aa2cae9669777a066a8df2b7b7bf1fbb1fe719f20204bf3360231790", + "bytes": 16287, + "hashPolicy": "UTF-8 text with CRLF normalized to LF" + }, + "qualification": "Current candidate snapshot at collection time; parent must qualify unchanged as-run source before acceptance." + }, + { + "originalSourcePath": "job-tracker-ui/src/correspondence-gmail-import.test.tsx", + "raw": { + "path": ".artifacts/phase7/p7-06/inbox/history-final/source/job-tracker-ui/src/correspondence-gmail-import.test.tsx", + "sha256": "fbe0592502fa180ea32cbe18c700894f5e0da36b58f9320e0954dcb39d3c4cc0", + "bytes": 16504, + "hashPolicy": "Exact measured raw bytes" + }, + "canonicalLf": { + "path": ".artifacts/phase7/p7-06/inbox/history-final/source/job-tracker-ui/src/correspondence-gmail-import.test.tsx", + "sha256": "3ee780a8036b24859982e5f07bd5789f093b17bd79a428d4baee8d9a2c2695e3", + "bytes": 16087, + "hashPolicy": "UTF-8 text with CRLF normalized to LF" + }, + "qualification": "Current candidate snapshot at collection time; parent must qualify unchanged as-run source before acceptance." + }, + { + "originalSourcePath": "job-tracker-ui/src/correspondence-search-context.test.ts", + "raw": { + "path": ".artifacts/phase7/p7-06/inbox/history-final/source/job-tracker-ui/src/correspondence-search-context.test.ts", + "sha256": "91dfb843add49d1d68f083f22c5106dffa04b1322decb88f79de2ca140ac23ad", + "bytes": 986, + "hashPolicy": "Exact measured raw bytes" + }, + "canonicalLf": { + "path": ".artifacts/phase7/p7-06/inbox/history-final/source/job-tracker-ui/src/correspondence-search-context.test.ts", + "sha256": "8fc52ff3e10827e96194d6eb293e4872f65d4c73213b0b29e275bad9bbac5b15", + "bytes": 969, + "hashPolicy": "UTF-8 text with CRLF normalized to LF" + }, + "qualification": "Current candidate snapshot at collection time; parent must qualify unchanged as-run source before acceptance." + }, + { + "originalSourcePath": "job-tracker-ui/src/email-provider-connections.test.tsx", + "raw": { + "path": ".artifacts/phase7/p7-06/inbox/history-final/source/job-tracker-ui/src/email-provider-connections.test.tsx", + "sha256": "b8e090601bc7790bb1457e574d7bcf54c20d876c664f82d5b47176536494ff27", + "bytes": 3517, + "hashPolicy": "Exact measured raw bytes" + }, + "canonicalLf": { + "path": ".artifacts/phase7/p7-06/inbox/history-final/source/job-tracker-ui/src/email-provider-connections.test.tsx", + "sha256": "6924ea09580a11871cf5abd4548e0f89078963306a09d2001a160dca517d66fb", + "bytes": 3452, + "hashPolicy": "UTF-8 text with CRLF normalized to LF" + }, + "qualification": "Current candidate snapshot at collection time; parent must qualify unchanged as-run source before acceptance." + }, + { + "originalSourcePath": "job-tracker-ui/src/views/CorrespondenceInboxPage.tsx", + "raw": { + "path": ".artifacts/phase7/p7-06/inbox/history-final/source/job-tracker-ui/src/views/CorrespondenceInboxPage.tsx", + "sha256": "0929313b17ef18e3397251329c975ea87d151d03b17b5b372dfa2dc07bff382d", + "bytes": 43643, + "hashPolicy": "Exact measured raw bytes" + }, + "canonicalLf": { + "path": ".artifacts/phase7/p7-06/inbox/history-final/source/job-tracker-ui/src/views/CorrespondenceInboxPage.tsx", + "sha256": "ad7978b0833129fb74e99120285010c21c721547350e4ad886eaec62590b496a", + "bytes": 42813, + "hashPolicy": "UTF-8 text with CRLF normalized to LF" + }, + "qualification": "Current candidate snapshot at collection time; parent must qualify unchanged as-run source before acceptance." + }, + { + "originalSourcePath": "job-tracker-ui/src/views/GmailReviewPage.tsx", + "raw": { + "path": ".artifacts/phase7/p7-06/inbox/history-final/source/job-tracker-ui/src/views/GmailReviewPage.tsx", + "sha256": "b3674c1d5e0776a86af6996eecfe7ef9f707fe5e9d7f9ac94891ea93d3bd5d9e", + "bytes": 19744, + "hashPolicy": "Exact measured raw bytes" + }, + "canonicalLf": { + "path": ".artifacts/phase7/p7-06/inbox/history-final/source/job-tracker-ui/src/views/GmailReviewPage.tsx", + "sha256": "5e87cad1c5f5118fdd6d62d3aae35b83b3ab10d8d60722ed18c1dbb4487f9890", + "bytes": 19405, + "hashPolicy": "UTF-8 text with CRLF normalized to LF" + }, + "qualification": "Current candidate snapshot at collection time; parent must qualify unchanged as-run source before acceptance." + }, + { + "originalSourcePath": "job-tracker-ui/src/components/EmailProviderConnections.tsx", + "raw": { + "path": ".artifacts/phase7/p7-06/inbox/history-final/source/job-tracker-ui/src/components/EmailProviderConnections.tsx", + "sha256": "174eab8de4f89e6aa69b954de5d06d6361d6a7b77116ab8b47133f3c768042ed", + "bytes": 9715, + "hashPolicy": "Exact measured raw bytes" + }, + "canonicalLf": { + "path": ".artifacts/phase7/p7-06/inbox/history-final/source/job-tracker-ui/src/components/EmailProviderConnections.tsx", + "sha256": "c6bfd306580688d5aca274b48f33bf8a283c37c3d059953624fe2a7e4953acde", + "bytes": 9508, + "hashPolicy": "UTF-8 text with CRLF normalized to LF" + }, + "qualification": "Current candidate snapshot at collection time; parent must qualify unchanged as-run source before acceptance." + }, + { + "originalSourcePath": "job-tracker-ui/src/components/Correspondence.tsx", + "raw": { + "path": ".artifacts/phase7/p7-06/inbox/history-final/source/job-tracker-ui/src/components/Correspondence.tsx", + "sha256": "e0869cf484c202ab7faa508a9d5db720d4b46ac8c99a6f0eeb355cf7fb38b31b", + "bytes": 41057, + "hashPolicy": "Exact measured raw bytes" + }, + "canonicalLf": { + "path": ".artifacts/phase7/p7-06/inbox/history-final/source/job-tracker-ui/src/components/Correspondence.tsx", + "sha256": "e0fc56591a1ccc88e4bbe68b119e989cc011aa1f754a1870bbdb2cc33ffc72eb", + "bytes": 40298, + "hashPolicy": "UTF-8 text with CRLF normalized to LF" + }, + "qualification": "Current candidate snapshot at collection time; parent must qualify unchanged as-run source before acceptance." + }, + { + "originalSourcePath": "job-tracker-ui/src/types.ts", + "raw": { + "path": ".artifacts/phase7/p7-06/inbox/history-final/source/job-tracker-ui/src/types.ts", + "sha256": "a45779b0b7868c4a69ed4a17316899074416fc8c6f8f6c083827e949f790d7b1", + "bytes": 13452, + "hashPolicy": "Exact measured raw bytes" + }, + "canonicalLf": { + "path": ".artifacts/phase7/p7-06/inbox/history-final/source/job-tracker-ui/src/types.ts", + "sha256": "d7b551f669415f752fdc38850367fd89257e3f6d6c9d8b459cbf1a1ad91f6a85", + "bytes": 12920, + "hashPolicy": "UTF-8 text with CRLF normalized to LF" + }, + "qualification": "Current candidate snapshot at collection time; parent must qualify unchanged as-run source before acceptance." + }, + { + "originalSourcePath": "job-tracker-ui/src/i18n/translations.ts", + "raw": { + "path": ".artifacts/phase7/p7-06/inbox/history-final/source/job-tracker-ui/src/i18n/translations.ts", + "sha256": "de67f2ef3f83dad4f70310a1cdb517a4423f3826cc9dabfe6bbf041f3c09a2cc", + "bytes": 317202, + "hashPolicy": "Exact measured raw bytes" + }, + "canonicalLf": { + "path": ".artifacts/phase7/p7-06/inbox/history-final/source/job-tracker-ui/src/i18n/translations.ts", + "sha256": "de67f2ef3f83dad4f70310a1cdb517a4423f3826cc9dabfe6bbf041f3c09a2cc", + "bytes": 317202, + "hashPolicy": "UTF-8 text with CRLF normalized to LF" + }, + "qualification": "Current candidate snapshot at collection time; parent must qualify unchanged as-run source before acceptance." + }, + { + "originalSourcePath": "job-tracker-ui/src/components/ViewStateNotice.tsx", + "raw": { + "path": ".artifacts/phase7/p7-06/inbox/history-final/source/job-tracker-ui/src/components/ViewStateNotice.tsx", + "sha256": "fd4bb314346a1603d9e01a15fd02f01576b9fd959e83f8ff0b1dfe5ec94378e9", + "bytes": 2340, + "hashPolicy": "Exact measured raw bytes" + }, + "canonicalLf": { + "path": ".artifacts/phase7/p7-06/inbox/history-final/source/job-tracker-ui/src/components/ViewStateNotice.tsx", + "sha256": "7b6c3e6c311860897eb5327936dbb129e860781929cfd9a4d4e17f81c607c70c", + "bytes": 2312, + "hashPolicy": "UTF-8 text with CRLF normalized to LF" + }, + "qualification": "Current candidate snapshot at collection time; parent must qualify unchanged as-run source before acceptance." + }, + { + "originalSourcePath": "job-tracker-ui/src/hooks/useViewResource.ts", + "raw": { + "path": ".artifacts/phase7/p7-06/inbox/history-final/source/job-tracker-ui/src/hooks/useViewResource.ts", + "sha256": "8c73a2fd845b8bc4c4c10b7c69f8d12dae8e83c434c537558a78b74377511b27", + "bytes": 3292, + "hashPolicy": "Exact measured raw bytes" + }, + "canonicalLf": { + "path": ".artifacts/phase7/p7-06/inbox/history-final/source/job-tracker-ui/src/hooks/useViewResource.ts", + "sha256": "5912131c525ba85ae6994efc38306a10ec2e673cc0082686226b59f00df5da99", + "bytes": 3176, + "hashPolicy": "UTF-8 text with CRLF normalized to LF" + }, + "qualification": "Current candidate snapshot at collection time; parent must qualify unchanged as-run source before acceptance." + }, + { + "originalSourcePath": "job-tracker-ui/src/i18n/I18nProvider.tsx", + "raw": { + "path": ".artifacts/phase7/p7-06/inbox/history-final/source/job-tracker-ui/src/i18n/I18nProvider.tsx", + "sha256": "d22b5d033d0e405ffb5d581c752d61a24056d1e496f5f6a2f1baecef500f2113", + "bytes": 3184, + "hashPolicy": "Exact measured raw bytes" + }, + "canonicalLf": { + "path": ".artifacts/phase7/p7-06/inbox/history-final/source/job-tracker-ui/src/i18n/I18nProvider.tsx", + "sha256": "d22b5d033d0e405ffb5d581c752d61a24056d1e496f5f6a2f1baecef500f2113", + "bytes": 3184, + "hashPolicy": "UTF-8 text with CRLF normalized to LF" + }, + "qualification": "Current candidate snapshot at collection time; parent must qualify unchanged as-run source before acceptance." + }, + { + "originalSourcePath": "job-tracker-ui/src/App.tsx", + "raw": { + "path": ".artifacts/phase7/p7-06/inbox/history-final/source/job-tracker-ui/src/App.tsx", + "sha256": "f6d5b769ffd44f35373d06bab859d74ca5105b2d23749a7273a6b4a55097486e", + "bytes": 28100, + "hashPolicy": "Exact measured raw bytes" + }, + "canonicalLf": { + "path": ".artifacts/phase7/p7-06/inbox/history-final/source/job-tracker-ui/src/App.tsx", + "sha256": "ccc07bcdc05c204dee6b368057669483389a034517f524a9c4a42db12f1eddf4", + "bytes": 27578, + "hashPolicy": "UTF-8 text with CRLF normalized to LF" + }, + "qualification": "Current candidate snapshot at collection time; parent must qualify unchanged as-run source before acceptance." + }, + { + "originalSourcePath": "job-tracker-ui/e2e/phase7-correspondence-read.spec.ts", + "raw": { + "path": ".artifacts/phase7/p7-06/inbox/history-final/source/job-tracker-ui/e2e/phase7-correspondence-read.spec.ts", + "sha256": "5bedf8e8d62b2482afe27883fb2b3d77981eaab0f3abfc3f265122b55c19938e", + "bytes": 42571, + "hashPolicy": "Exact measured raw bytes" + }, + "canonicalLf": { + "path": ".artifacts/phase7/p7-06/inbox/history-final/source/job-tracker-ui/e2e/phase7-correspondence-read.spec.ts", + "sha256": "5bedf8e8d62b2482afe27883fb2b3d77981eaab0f3abfc3f265122b55c19938e", + "bytes": 42571, + "hashPolicy": "UTF-8 text with CRLF normalized to LF" + }, + "qualification": "Current candidate snapshot at collection time; parent must qualify unchanged as-run source before acceptance." + } + ], + "preNative": { + "path": ".artifacts/phase7/p7-06/inbox/native-history-pre.json", + "sha256": "0a302b7168f895fc1c43adad6daefebd935ae31f397463f072d6d2e74889aee5", + "bytes": 3075, + "hashPolicy": "Exact raw bytes" + }, + "postNative": { + "path": ".artifacts/phase7/p7-06/inbox/native-history-post.json", + "sha256": "9b5902b128f129afcc366648850e460f41cc8fe520adb16a11e067e2bc6e2914", + "bytes": 3020, + "hashPolicy": "Exact raw bytes" + }, + "visualReview": { + "reviewed": 20, + "retained": 20, + "images": [ + { + "raw": { + "path": ".artifacts/phase7/p7-06/inbox/history-final/embedded/correspondence-read/002-p706-read-en-light-1440-real-filtered-owner-page.png", + "sha256": "95874b0d51d1a5e30ae66f35f8a2be2b0e44e3fd75124a0596c0985971b18e65", + "bytes": 265981, + "hashPolicy": "Exact measured raw bytes" + }, + "originalEmbeddedReport": { + "path": ".artifacts/phase7/p7-06/inbox/history-final/correspondence-read.json", + "sha256": "fc6b42f39d67796457e174cfa500ab9c1bca31d53f2dbf7da0425a88a54ef761", + "bytes": 4115554, + "hashPolicy": "Exact measured raw bytes" + }, + "lane": "correspondence-read", + "test": "[P7-06 read] actual owner page50, filters, ordering and job Back preserve saved-message context and ownership", + "name": "p706-read-en-light-1440-real-filtered-owner-page", + "manualReview": "ACTUALLY_DISPLAYED_AND_REVIEWED_BY_PARENT" + }, + { + "raw": { + "path": ".artifacts/phase7/p7-06/inbox/history-final/embedded/correspondence-read/006-p706-read-en-light-1440-initial-list503-not-empty.png", + "sha256": "486fd56e03222994435186708b79f3a3d74c29bec93d5be3658e4b830a7ba6e0", + "bytes": 80685, + "hashPolicy": "Exact measured raw bytes" + }, + "originalEmbeddedReport": { + "path": ".artifacts/phase7/p7-06/inbox/history-final/correspondence-read.json", + "sha256": "fc6b42f39d67796457e174cfa500ab9c1bca31d53f2dbf7da0425a88a54ef761", + "bytes": 4115554, + "hashPolicy": "Exact measured raw bytes" + }, + "lane": "correspondence-read", + "test": "[P7-06 read] initial503 Retry and held latest query remain distinct from true empty results", + "name": "p706-read-en-light-1440-initial-list503-not-empty", + "manualReview": "ACTUALLY_DISPLAYED_AND_REVIEWED_BY_PARENT" + }, + { + "raw": { + "path": ".artifacts/phase7/p7-06/inbox/history-final/embedded/correspondence-read/008-p706-read-en-light-1440-held-current-query-no-stale-rows.png", + "sha256": "9663200c4617a69611b0abe96f56ac33882c689d0760d5e54f9f72d3dbeba045", + "bytes": 77778, + "hashPolicy": "Exact measured raw bytes" + }, + "originalEmbeddedReport": { + "path": ".artifacts/phase7/p7-06/inbox/history-final/correspondence-read.json", + "sha256": "fc6b42f39d67796457e174cfa500ab9c1bca31d53f2dbf7da0425a88a54ef761", + "bytes": 4115554, + "hashPolicy": "Exact measured raw bytes" + }, + "lane": "correspondence-read", + "test": "[P7-06 read] initial503 Retry and held latest query remain distinct from true empty results", + "name": "p706-read-en-light-1440-held-current-query-no-stale-rows", + "manualReview": "ACTUALLY_DISPLAYED_AND_REVIEWED_BY_PARENT" + }, + { + "raw": { + "path": ".artifacts/phase7/p7-06/inbox/history-final/embedded/correspondence-read/011-p706-read-en-light-1440-held-original-saved-detail-retry.png", + "sha256": "ddd849bf0dec9c0c9aed399c99d312710891770d17704163d0d2ee258133b5ab", + "bytes": 116539, + "hashPolicy": "Exact measured raw bytes" + }, + "originalEmbeddedReport": { + "path": ".artifacts/phase7/p7-06/inbox/history-final/correspondence-read.json", + "sha256": "fc6b42f39d67796457e174cfa500ab9c1bca31d53f2dbf7da0425a88a54ef761", + "bytes": 4115554, + "hashPolicy": "Exact measured raw bytes" + }, + "lane": "correspondence-read", + "test": "[P7-06 read] actual saved-detail Retry preserves its ID and Hide/another row cancel stale completion", + "name": "p706-read-en-light-1440-held-original-saved-detail-retry", + "manualReview": "ACTUALLY_DISPLAYED_AND_REVIEWED_BY_PARENT" + }, + { + "raw": { + "path": ".artifacts/phase7/p7-06/inbox/history-final/embedded/correspondence-read/013-p706-read-en-light-1440-actual-saved-body-and-metadata.png", + "sha256": "dcf0ea78afd6e7f08b70b53eeb9dcc1bd7ba8340d02494a7fa20614be1e637c4", + "bytes": 149861, + "hashPolicy": "Exact measured raw bytes" + }, + "originalEmbeddedReport": { + "path": ".artifacts/phase7/p7-06/inbox/history-final/correspondence-read.json", + "sha256": "fc6b42f39d67796457e174cfa500ab9c1bca31d53f2dbf7da0425a88a54ef761", + "bytes": 4115554, + "hashPolicy": "Exact measured raw bytes" + }, + "lane": "correspondence-read", + "test": "[P7-06 read] actual saved-detail Retry preserves its ID and Hide/another row cancel stale completion", + "name": "p706-read-en-light-1440-actual-saved-body-and-metadata", + "manualReview": "ACTUALLY_DISPLAYED_AND_REVIEWED_BY_PARENT" + }, + { + "raw": { + "path": ".artifacts/phase7/p7-06/inbox/history-final/embedded/correspondence-read/016-p706-read-en-light-1440-qualified-provider-unavailable-real-saved-copy.png", + "sha256": "87cddae55922edbdcd8ea67238088b439d25a49e415fbc3c8cdba892b34a0530", + "bytes": 145978, + "hashPolicy": "Exact measured raw bytes" + }, + "originalEmbeddedReport": { + "path": ".artifacts/phase7/p7-06/inbox/history-final/correspondence-read.json", + "sha256": "fc6b42f39d67796457e174cfa500ab9c1bca31d53f2dbf7da0425a88a54ef761", + "bytes": 4115554, + "hashPolicy": "Exact measured raw bytes" + }, + "lane": "correspondence-read", + "test": "[P7-06 read] qualified provider live-copy failure retries exact captured source before real saved fallback", + "name": "p706-read-en-light-1440-qualified-provider-unavailable-real-saved-copy", + "manualReview": "ACTUALLY_DISPLAYED_AND_REVIEWED_BY_PARENT" + }, + { + "raw": { + "path": ".artifacts/phase7/p7-06/inbox/history-final/embedded/correspondence-read/021-p706-read-nb-light-1440-strict-locale-preserved-saved-detail.png", + "sha256": "878db2f4c6f2fa4b6ee85e48f23cd75a38529dc122f7006f033e1b3ce2a323b5", + "bytes": 174017, + "hashPolicy": "Exact measured raw bytes" + }, + "originalEmbeddedReport": { + "path": ".artifacts/phase7/p7-06/inbox/history-final/correspondence-read.json", + "sha256": "fc6b42f39d67796457e174cfa500ab9c1bca31d53f2dbf7da0425a88a54ef761", + "bytes": 4115554, + "hashPolicy": "Exact measured raw bytes" + }, + "lane": "correspondence-read", + "test": "[P7-06 read] strict real UI-language204 preserves inbox context, selected saved body and dirty review notes with zero read deltas", + "name": "p706-read-nb-light-1440-strict-locale-preserved-saved-detail", + "manualReview": "ACTUALLY_DISPLAYED_AND_REVIEWED_BY_PARENT" + }, + { + "raw": { + "path": ".artifacts/phase7/p7-06/inbox/history-final/embedded/correspondence-read/024-p706-read-en-light-1440-strict-locale-dirty-review-notes.png", + "sha256": "c9c8cfac82ebbf51c5e79cf6cc187c81565ba0441bd4f833ed85dbf37f6cd929", + "bytes": 151077, + "hashPolicy": "Exact measured raw bytes" + }, + "originalEmbeddedReport": { + "path": ".artifacts/phase7/p7-06/inbox/history-final/correspondence-read.json", + "sha256": "fc6b42f39d67796457e174cfa500ab9c1bca31d53f2dbf7da0425a88a54ef761", + "bytes": 4115554, + "hashPolicy": "Exact measured raw bytes" + }, + "lane": "correspondence-read", + "test": "[P7-06 read] strict real UI-language204 preserves inbox context, selected saved body and dirty review notes with zero read deltas", + "name": "p706-read-en-light-1440-strict-locale-dirty-review-notes", + "manualReview": "ACTUALLY_DISPLAYED_AND_REVIEWED_BY_PARENT" + }, + { + "raw": { + "path": ".artifacts/phase7/p7-06/inbox/history-final/embedded/correspondence-read/027-p706-read-en-light-1440-held-status-retry-saved-body-retained.png", + "sha256": "ac245bee275d57ca18ef78d9eb3fda6fa7aa43ba7ec6560fe69a2b3aa7f408f5", + "bytes": 147333, + "hashPolicy": "Exact measured raw bytes" + }, + "originalEmbeddedReport": { + "path": ".artifacts/phase7/p7-06/inbox/history-final/correspondence-read.json", + "sha256": "fc6b42f39d67796457e174cfa500ab9c1bca31d53f2dbf7da0425a88a54ef761", + "bytes": 4115554, + "hashPolicy": "Exact measured raw bytes" + }, + "lane": "correspondence-read", + "test": "[P7-06 read] independent provider-status503 Retry only rereads status while saved owner messages remain usable", + "name": "p706-read-en-light-1440-held-status-retry-saved-body-retained", + "manualReview": "ACTUALLY_DISPLAYED_AND_REVIEWED_BY_PARENT" + }, + { + "raw": { + "path": ".artifacts/phase7/p7-06/inbox/history-final/embedded/correspondence-read/030-p706-read-en-light-1440-review-read-retry-retains-note-filter.png", + "sha256": "585a5f65470037dfe8f120a97170bf5bfdb7212d3e1b79c5236fefe0298d5586", + "bytes": 117170, + "hashPolicy": "Exact measured raw bytes" + }, + "originalEmbeddedReport": { + "path": ".artifacts/phase7/p7-06/inbox/history-final/correspondence-read.json", + "sha256": "fc6b42f39d67796457e174cfa500ab9c1bca31d53f2dbf7da0425a88a54ef761", + "bytes": 4115554, + "hashPolicy": "Exact measured raw bytes" + }, + "lane": "correspondence-read", + "test": "[P7-06 read] qualified paired review reads retain notes/filter and actual nonlinked decision recovery never replays committed POST", + "name": "p706-read-en-light-1440-review-read-retry-retains-note-filter", + "manualReview": "ACTUALLY_DISPLAYED_AND_REVIEWED_BY_PARENT" + }, + { + "raw": { + "path": ".artifacts/phase7/p7-06/inbox/history-final/embedded/correspondence-read/032-p706-read-en-light-1440-held-local-review-decision.png", + "sha256": "82a888c4897c722de364db4db06b56065a2b3b405d81765782012d4266175d33", + "bytes": 116360, + "hashPolicy": "Exact measured raw bytes" + }, + "originalEmbeddedReport": { + "path": ".artifacts/phase7/p7-06/inbox/history-final/correspondence-read.json", + "sha256": "fc6b42f39d67796457e174cfa500ab9c1bca31d53f2dbf7da0425a88a54ef761", + "bytes": 4115554, + "hashPolicy": "Exact measured raw bytes" + }, + "lane": "correspondence-read", + "test": "[P7-06 read] qualified paired review reads retain notes/filter and actual nonlinked decision recovery never replays committed POST", + "name": "p706-read-en-light-1440-held-local-review-decision", + "manualReview": "ACTUALLY_DISPLAYED_AND_REVIEWED_BY_PARENT" + }, + { + "raw": { + "path": ".artifacts/phase7/p7-06/inbox/history-final/embedded/correspondence-read/034-p706-read-en-light-1440-committed-decision-get-only-recovery.png", + "sha256": "9c3bbbdbaeda962759e644cc95bb8f981aec6c9379c8709f2378430e70e4a6ad", + "bytes": 126412, + "hashPolicy": "Exact measured raw bytes" + }, + "originalEmbeddedReport": { + "path": ".artifacts/phase7/p7-06/inbox/history-final/correspondence-read.json", + "sha256": "fc6b42f39d67796457e174cfa500ab9c1bca31d53f2dbf7da0425a88a54ef761", + "bytes": 4115554, + "hashPolicy": "Exact measured raw bytes" + }, + "lane": "correspondence-read", + "test": "[P7-06 read] qualified paired review reads retain notes/filter and actual nonlinked decision recovery never replays committed POST", + "name": "p706-read-en-light-1440-committed-decision-get-only-recovery", + "manualReview": "ACTUALLY_DISPLAYED_AND_REVIEWED_BY_PARENT" + }, + { + "raw": { + "path": ".artifacts/phase7/p7-06/inbox/history-final/embedded/correspondence-read/038-p706-read-en-light-320-long-inbox.png", + "sha256": "15cacd5322ccbadda0b002bb36ee03f92ab582287c05f91223567b031f0a76dc", + "bytes": 118211, + "hashPolicy": "Exact measured raw bytes" + }, + "originalEmbeddedReport": { + "path": ".artifacts/phase7/p7-06/inbox/history-final/correspondence-read.json", + "sha256": "fc6b42f39d67796457e174cfa500ab9c1bca31d53f2dbf7da0425a88a54ef761", + "bytes": 4115554, + "hashPolicy": "Exact measured raw bytes" + }, + "lane": "correspondence-read", + "test": "[P7-06 read] eight finite bilingual theme widths preserve long saved bodies and qualified review-note keyboard access", + "name": "p706-read-en-light-320-long-inbox", + "manualReview": "ACTUALLY_DISPLAYED_AND_REVIEWED_BY_PARENT" + }, + { + "raw": { + "path": ".artifacts/phase7/p7-06/inbox/history-final/embedded/correspondence-read/040-p706-read-nb-dark-360-long-review.png", + "sha256": "887edc93f62989127fc027a84095681f26ea1bf96f74c424b9b2b89a35e4548b", + "bytes": 127424, + "hashPolicy": "Exact measured raw bytes" + }, + "originalEmbeddedReport": { + "path": ".artifacts/phase7/p7-06/inbox/history-final/correspondence-read.json", + "sha256": "fc6b42f39d67796457e174cfa500ab9c1bca31d53f2dbf7da0425a88a54ef761", + "bytes": 4115554, + "hashPolicy": "Exact measured raw bytes" + }, + "lane": "correspondence-read", + "test": "[P7-06 read] eight finite bilingual theme widths preserve long saved bodies and qualified review-note keyboard access", + "name": "p706-read-nb-dark-360-long-review", + "manualReview": "ACTUALLY_DISPLAYED_AND_REVIEWED_BY_PARENT" + }, + { + "raw": { + "path": ".artifacts/phase7/p7-06/inbox/history-final/embedded/correspondence-read/042-p706-read-en-dark-390-long-detail.png", + "sha256": "7509ea1c1e26fe0107fc8afb604954aba8a3482dafbc8014d8b493bf80f41c0f", + "bytes": 157090, + "hashPolicy": "Exact measured raw bytes" + }, + "originalEmbeddedReport": { + "path": ".artifacts/phase7/p7-06/inbox/history-final/correspondence-read.json", + "sha256": "fc6b42f39d67796457e174cfa500ab9c1bca31d53f2dbf7da0425a88a54ef761", + "bytes": 4115554, + "hashPolicy": "Exact measured raw bytes" + }, + "lane": "correspondence-read", + "test": "[P7-06 read] eight finite bilingual theme widths preserve long saved bodies and qualified review-note keyboard access", + "name": "p706-read-en-dark-390-long-detail", + "manualReview": "ACTUALLY_DISPLAYED_AND_REVIEWED_BY_PARENT" + }, + { + "raw": { + "path": ".artifacts/phase7/p7-06/inbox/history-final/embedded/correspondence-read/044-p706-read-nb-light-768-long-inbox.png", + "sha256": "0d456725fded0c7e2ccb1825885f48261e981bfa67185ffd82db0314af2cab6a", + "bytes": 118530, + "hashPolicy": "Exact measured raw bytes" + }, + "originalEmbeddedReport": { + "path": ".artifacts/phase7/p7-06/inbox/history-final/correspondence-read.json", + "sha256": "fc6b42f39d67796457e174cfa500ab9c1bca31d53f2dbf7da0425a88a54ef761", + "bytes": 4115554, + "hashPolicy": "Exact measured raw bytes" + }, + "lane": "correspondence-read", + "test": "[P7-06 read] eight finite bilingual theme widths preserve long saved bodies and qualified review-note keyboard access", + "name": "p706-read-nb-light-768-long-inbox", + "manualReview": "ACTUALLY_DISPLAYED_AND_REVIEWED_BY_PARENT" + }, + { + "raw": { + "path": ".artifacts/phase7/p7-06/inbox/history-final/embedded/correspondence-read/046-p706-read-en-dark-1024-long-review.png", + "sha256": "2f332f31c6839f4094724828d0d174c71995d73f1948bae151a587a520ebc2f7", + "bytes": 144601, + "hashPolicy": "Exact measured raw bytes" + }, + "originalEmbeddedReport": { + "path": ".artifacts/phase7/p7-06/inbox/history-final/correspondence-read.json", + "sha256": "fc6b42f39d67796457e174cfa500ab9c1bca31d53f2dbf7da0425a88a54ef761", + "bytes": 4115554, + "hashPolicy": "Exact measured raw bytes" + }, + "lane": "correspondence-read", + "test": "[P7-06 read] eight finite bilingual theme widths preserve long saved bodies and qualified review-note keyboard access", + "name": "p706-read-en-dark-1024-long-review", + "manualReview": "ACTUALLY_DISPLAYED_AND_REVIEWED_BY_PARENT" + }, + { + "raw": { + "path": ".artifacts/phase7/p7-06/inbox/history-final/embedded/correspondence-read/048-p706-read-nb-light-1280-long-detail.png", + "sha256": "9e3113319e01882c8b1f5b62238ef6da911eb9455a1db47e1fb346e1f800a178", + "bytes": 170081, + "hashPolicy": "Exact measured raw bytes" + }, + "originalEmbeddedReport": { + "path": ".artifacts/phase7/p7-06/inbox/history-final/correspondence-read.json", + "sha256": "fc6b42f39d67796457e174cfa500ab9c1bca31d53f2dbf7da0425a88a54ef761", + "bytes": 4115554, + "hashPolicy": "Exact measured raw bytes" + }, + "lane": "correspondence-read", + "test": "[P7-06 read] eight finite bilingual theme widths preserve long saved bodies and qualified review-note keyboard access", + "name": "p706-read-nb-light-1280-long-detail", + "manualReview": "ACTUALLY_DISPLAYED_AND_REVIEWED_BY_PARENT" + }, + { + "raw": { + "path": ".artifacts/phase7/p7-06/inbox/history-final/embedded/correspondence-read/050-p706-read-en-light-1440-long-review.png", + "sha256": "d9b5c3c10754a91cdf21e8497ae606ad100e0b311c81c760b1baf492a0d4dbce", + "bytes": 152055, + "hashPolicy": "Exact measured raw bytes" + }, + "originalEmbeddedReport": { + "path": ".artifacts/phase7/p7-06/inbox/history-final/correspondence-read.json", + "sha256": "fc6b42f39d67796457e174cfa500ab9c1bca31d53f2dbf7da0425a88a54ef761", + "bytes": 4115554, + "hashPolicy": "Exact measured raw bytes" + }, + "lane": "correspondence-read", + "test": "[P7-06 read] eight finite bilingual theme widths preserve long saved bodies and qualified review-note keyboard access", + "name": "p706-read-en-light-1440-long-review", + "manualReview": "ACTUALLY_DISPLAYED_AND_REVIEWED_BY_PARENT" + }, + { + "raw": { + "path": ".artifacts/phase7/p7-06/inbox/history-final/embedded/correspondence-read/052-p706-read-nb-dark-1920-long-inbox.png", + "sha256": "6a073ad2f92cee55b1ff232780859aa5724eac39d654fba36db019f5d611b937", + "bytes": 155082, + "hashPolicy": "Exact measured raw bytes" + }, + "originalEmbeddedReport": { + "path": ".artifacts/phase7/p7-06/inbox/history-final/correspondence-read.json", + "sha256": "fc6b42f39d67796457e174cfa500ab9c1bca31d53f2dbf7da0425a88a54ef761", + "bytes": 4115554, + "hashPolicy": "Exact measured raw bytes" + }, + "lane": "correspondence-read", + "test": "[P7-06 read] eight finite bilingual theme widths preserve long saved bodies and qualified review-note keyboard access", + "name": "p706-read-nb-dark-1920-long-inbox", + "manualReview": "ACTUALLY_DISPLAYED_AND_REVIEWED_BY_PARENT" + } + ], + "qualification": "All20 latest PNGs actually displayed. Long1440/320/360/390 images resized by viewer; sticky full-page composites do not prove viewport occlusion, every pixel, actual browser zoom, physical touch/software keyboard or human reader acceptance. No new clipped mail actions observed in finite samples." + }, + "accessibility": { + "scans": 20, + "violations": 0, + "incomplete": 84, + "completeContextTriage": { + "path": ".artifacts/phase7/p7-06/inbox/axe-triage-history.json", + "sha256": "263f7d334991e525205fcc4a191dfb93a2066cefbac1b9fd3a6f942c98deee96", + "bytes": 3038, + "hashPolicy": "Exact raw bytes" + }, + "qualification": "20 short notification-badge contrast checks and64 MUI input/select background-overlap checks remain incomplete; not hidden or treated as passing contrast. Actual reader/device/zoom/settled focus/contrast qualification remains M01–M12." + }, + "countProvenance": "Actual42/5=original30/5+12newcases:inbox16+8=24,review3+4=7,import6,providers3,context2. +12frontend/0suites derives729/94 from717/94; +8ordinarynative derives291browser from283. Backend1034derived only; latest whole573/89frontend,1033backend,49sidecar inherited. Fresh integrated P7-12 remains mandatory.", + "history": [ + { + "path": ".artifacts/phase7/p7-06/inbox/unit-red/results.json", + "sha256": "111118280f731907057f5e6f9afbe281f45a82e477f7e1cb15f3c39831fe4da1", + "bytes": 23454, + "hashPolicy": "Exact raw bytes" + }, + { + "path": ".artifacts/phase7/p7-06/inbox/unit-red-dialog-ready/results.json", + "sha256": "6efe0230e9a77d5f8608529534842ae82c60d9dcf81898c81647a34a9ffa3dc8", + "bytes": 17668, + "hashPolicy": "Exact raw bytes" + }, + { + "path": ".artifacts/phase7/p7-06/inbox/unit-green/results.json", + "sha256": "5c3df1e78d3f58b33f437a95e81a5c203a6324ecc06fef1e0338ca386cf3ef46", + "bytes": 2860, + "hashPolicy": "Exact raw bytes" + }, + { + "path": ".artifacts/phase7/p7-06/inbox/unit-query-context-red/results.json", + "sha256": "96207556e3bf7add2f58e2677c3d23fa5e09c7ba686210aac9802dbac98ab735", + "bytes": 2768, + "hashPolicy": "Exact raw bytes" + }, + { + "path": ".artifacts/phase7/p7-06/inbox/unit-query-context-green/results.json", + "sha256": "cdb559a4c761171ae11d0aa49a5f92e82274f612c2a17c9cfcad0515450d49ed", + "bytes": 2929, + "hashPolicy": "Exact raw bytes" + }, + { + "path": ".artifacts/phase7/p7-06/inbox/unit-history-red/results.json", + "sha256": "7cd62517808492a310f289a8132c9c15f5e77d2103a730df3cc49f13b074228c", + "bytes": 12242, + "hashPolicy": "Exact raw bytes" + }, + { + "path": ".artifacts/phase7/p7-06/inbox/attempt-1-7cH9o3DrMs6yW3qQvGFYs/results.json", + "sha256": "db4c1a12df4c7f4a1a479dbba788506ce51f48434a738e54c91e217839c3f253", + "bytes": 138968, + "hashPolicy": "Exact raw bytes" + }, + { + "path": ".artifacts/phase7/p7-06/inbox/context-native-red/results.json", + "sha256": "6a6f12e096c0c165a3fdb8df88bdcbaea492852ec3af1ae202aadfbebaef7a0b", + "bytes": 44218, + "hashPolicy": "Exact raw bytes" + }, + { + "path": ".artifacts/phase7/p7-06/inbox/final-verified/results.json", + "sha256": "ed760185601a0c385d705fa0dc4464dc6e053ecd59c03f7a5adf958851150405", + "bytes": 137469, + "hashPolicy": "Exact raw bytes" + } + ], + "oracleCorrections": "Preserved failed runs. Initial company fixture reused an owned company instead of assuming every upsert201; status/textbox selectors scope the existing named control. The55mail oracle wrongly excluded owned trashed-job correspondence; established HEAD/Phase6/main owner-only list/detail contract requires56/page50+6 and ownedtrashed detail200, while foreign/anonymous denial remains strict. Native context RED firstcase failed false55 beforehashassert; secondcase genuinely exposed stale oldrows. I7 literalall and I8 per-keystroke history eachordinaryRED thenGREEN. I8 RED ranOct13 22:06 before usage interruption; postcaptureOct14 05:08 is honestly delayed unchanged-source observation.", + "scopeLimits": "Owned SQLite saved list/detail/GET recovery are real HTTP. Provider status/live-copy and review queue use explicit fixtures; one real owner-local nonlinked rejected decision is persisted. Queue refresh projects its real POSTDTO and is not independent live-provider readback. Sync/create recovery and tenant ownership have unit/controller proof, not actual grants/mailbox sync/distributed creation idempotency. Draft/send safety remains mandatory third boundary.", + "preservation": { + "groups": 57, + "partial": 56, + "deferred": [ + "PRES-C11" + ], + "routes": 32, + "references": [ + "PRES-M01", + "PRES-M02", + "PRES-M03", + "PRES-M05", + "PRES-M06", + "PRES-E08", + "PRES-E09", + "PRES-W01", + "PRES-W10" + ], + "qualification": "M04 compose/send next; no group promoted wholePASS. P2-D01 ordinary passing inherited forms current-source consumer evidence; no renderer/schema/auth changes." + }, + "retainedDebt": "M01–M12 NOT VERIFIED; P5-13/C11 withheld32cases/366required/0calls/0human,06Fdeferred. Strict real language204 passes this final run but earlier RD11 HTTP500 remains undiagnosed. RD21, provider/output/device/production qualifications retained. Main27657f3 unchanged, production NO-GO, noPhase8.", + "codeReview": "Initial concrete q/hash/stale-context repairs followed meaningful42/5 proof; final read-only reviewer found per-keystroke Back regression, then confirmedqreplace/I8 resolved and no remaining concrete inbox/read finding. Compose/send excluded.", + "staticChecks": { + "path": "docs/redesign/phase-7/p7-06/evidence/inbox-static-checks.json", + "sha256": "e70dd865d5ce871c6f44fbc55aedeae8b97dd5ff9348ea5fc20276c68aec5a46", + "bytes": 3166, + "hashPolicy": "Exact raw bytes" + } +} diff --git a/docs/redesign/phase-7/p7-06/evidence/inbox-static-checks.json b/docs/redesign/phase-7/p7-06/evidence/inbox-static-checks.json new file mode 100644 index 0000000..900fd0c --- /dev/null +++ b/docs/redesign/phase-7/p7-06/evidence/inbox-static-checks.json @@ -0,0 +1,104 @@ +{ + "schemaVersion": 1, + "checkpoint": "p7-06", + "status": "PASS", + "checkedUtc": "2026-10-14T05:14:23.672016+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-14T05:14:18.914725+00:00", + "finishedUtc": "2026-10-14T05:14:22.975570+00:00", + "exitCode": 0, + "log": ".artifacts/phase7/current/static/20261014T051418Z/secret-scan.log", + "logSha256Raw": "c5a88d5ebdc042adba125c1eaacbe44f9f4a5c19898c0f195573c00d511aa28d" + }, + { + "name": "working-whitespace", + "command": [ + "rtk", + "proxy", + "git", + "diff", + "--check" + ], + "startedUtc": "2026-10-14T05:14:22.975570+00:00", + "finishedUtc": "2026-10-14T05:14:23.059525+00:00", + "exitCode": 0, + "log": ".artifacts/phase7/current/static/20261014T051418Z/working-whitespace.log", + "logSha256Raw": "d2993bc68ad9a63f711c8f5473155e2ba00fdcd461220cc3849185f2f9ad93e6" + }, + { + "name": "staged-whitespace", + "command": [ + "rtk", + "proxy", + "git", + "diff", + "--cached", + "--check" + ], + "startedUtc": "2026-10-14T05:14:23.060532+00:00", + "finishedUtc": "2026-10-14T05:14:23.152590+00:00", + "exitCode": 0, + "log": ".artifacts/phase7/current/static/20261014T051418Z/staged-whitespace.log", + "logSha256Raw": "d2993bc68ad9a63f711c8f5473155e2ba00fdcd461220cc3849185f2f9ad93e6" + }, + { + "name": "phase7-whitespace", + "command": [ + "rtk", + "proxy", + "git", + "diff", + "--check", + "b5319981ad3d1bd635daff861453d23c0de5320b" + ], + "startedUtc": "2026-10-14T05:14:23.153091+00:00", + "finishedUtc": "2026-10-14T05:14:23.306728+00:00", + "exitCode": 0, + "log": ".artifacts/phase7/current/static/20261014T051418Z/phase7-whitespace.log", + "logSha256Raw": "d2993bc68ad9a63f711c8f5473155e2ba00fdcd461220cc3849185f2f9ad93e6" + } + ], + "untrackedSecretScan": { + "files": 0, + "findings": 0 + }, + "documentation": { + "files": 15, + "relativeLinks": 474, + "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." + ] +} diff --git a/docs/roadmap/todo.md b/docs/roadmap/todo.md index f0a7cd6..63724b3 100644 --- a/docs/roadmap/todo.md +++ b/docs/roadmap/todo.md @@ -100,3 +100,11 @@ Follow the approved [Phase 7 handoff](../redesign/phase-6/p6-05/PHASE7_HANDOFF.m ### Final P7-06 embedded-forms observation Final embedded-forms implemented scope is **CONDITIONAL GO**, normal push pending. The matching aggregate/generation repair resolves the reviewed cross-job draft leak: ordinary parent RED18/1, latest affected **55/4 PASS** and actual SPA/heldB/exact two-job readback PASS. Final parent build9sM6acEkOGxgN5fTRZXdE has **9PASS/1FAIL of10** (unchanged strict actual NB language204→500/RD11); fresh Jobs5/P2-D01ordinary1 give **15PASS/1FAIL of16**, qualified earlier CV4/Career11 give **30PASS/1FAIL relevant31**. Backend30 sameDLL/no-build; app/browser types/lint/build PASS. All23/111 current forms PNGs actually reviewed, 55 recorded axe scans0/741 (current22/154+Jobs8/89; earlierCV12/121+Career13/377), full incomplete contexts pending human qualification. See [portable forms result](../redesign/phase-7/p7-06/evidence/forms-results.json). +11frontend/0suites derives **717/94**; +10browser derives **283**, no integrated run. All57 groups56PARTIAL/C11DEFERRED and32routes, P513withheld/06Fdeferred/main unchanged/productionNO-GO. Inbox/read and compose/send remain; Phase8 not started. + +Forms committed and normal push verified **4c3254c472871d1cac05abcef502feb4a1be435b** at2026-10-13T21:27:19.353249UTC: cleanHEAD=origin=live development, main27657f3 unchanged. First slice implemented CONDITIONAL GO; overallP7-06 ongoing. Inbox/read entered next; compose/send follows. No production or Phase8 action. + +### P7-06 inbox/read final candidate + +Forms4c325 is pushed. [Inbox/read](../redesign/phase-7/p7-06/INBOX.md) now has42/5 units,25 backend unchanged-DLL/no-build and8/8 fresh native, plus types/lint/build and20 reviewed screenshots/20axe0/84 incomplete. Bounded CONDITIONAL GO; final hygiene/normal push pending. Derived729/94 frontend/291browser only. Complete the mandatory compose/send safety and presentation boundary next, then P7-07 projection before Documents.06F/P513 stay deferred/withheld, all57/32 and manual/release gates retained; main unchanged/production NO-GO/Phase8 not started. + +Final inbox hygiene PASS observed2026-10-14 05:14:23UTC: secret and working/staged/phase whitespace gates pass,15 docs/474 local links resolve,57 groups and32 routes retained. Normal development push pending; main unchanged. diff --git a/job-tracker-ui/e2e/phase7-correspondence-read.spec.ts b/job-tracker-ui/e2e/phase7-correspondence-read.spec.ts new file mode 100644 index 0000000..7f3c3a5 --- /dev/null +++ b/job-tracker-ui/e2e/phase7-correspondence-read.spec.ts @@ -0,0 +1,318 @@ +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 { CorrespondenceInboxItem } from '../src/views/CorrespondenceInboxPage'; +import type { GmailReviewQueueResponse, GmailReviewThread, GmailSuggestedJobsResponse } from '../src/types'; + +const apiUrl = 'http://localhost:5302/api'; +const password = 'E2ePassword123!'; +type Owner = { id: string; headers: Record }; +type Job = { id: number; companyId: number; jobTitle: string }; +type Message = { id: number; jobApplicationId: number; subject: string; content: string; provider: string; externalMessageId: string | null; externalThreadId: string | null }; +type Inbox = { items: CorrespondenceInboxItem[]; page: number; pageSize: number; total: number; totalPages: number }; +type Detail = { id: string; threadId: string; subject: string; from: string; to: string; snippet: string; bodyText: string; labels: string[]; attachments: { fileName: string }[] }; +type Sample = { language: 'en' | 'nb'; theme: 'light' | 'dark'; width: number; height: number }; +type RequestRecord = { method: string; path: string; query: Record; body: unknown }; +type Session = { owner: Owner; administrator: Owner; admin: APIRequestContext; requests: RequestRecord[] }; +const desktop: Sample = { language: 'en', theme: 'light', width: 1440, height: 900 }; +const boundary = 'Actual owner correspondence POST/page/message/job reads and local nonlinked review-decision POST use disposable isolated SQLite. Provider status/live-copy and paired Gmail queue reads are explicitly controlled local HTTP fixtures; no Google/Outlook/IMAP connection, provider import, generation/model, sync, create-suggested-job, unlink, compose/draft/save/send or delivery request is executed. Manual correspondence DTOs remain provider=manual on the real server. One provider-copy case changes only the browser projection discriminator for one actual-owned row to exercise live-copy fallback; stored body/detail/readback remain actual owner HTTP. Review queue projections are not provider qualification or independent persistence readback. Free accounts and explicit runtime worker disables prevent model/delivery. No raw SQL/schema/production writes; request receipts exclude credentials/CSRF/session headers. Finite all-enabled axe/target-size/keyboard images do not replace human reader, device, real zoom, font/photo or production evidence.'; +function interpolate(template: string, params: Record) { return template.replace(/\{(\w+)\}/g, (_, key: string) => String(params[key] ?? `{${key}}`)); } +function hold() { let release!: () => void; const pending = new Promise(resolve => { release = resolve; }); return { pending, release }; } +async function json(response: APIResponse | Response, status = 200): Promise { expect(response.status(), response.url()).toBe(status); return response.json(); } +function waitResponse(page: Page, method: string, path: string) { return page.waitForResponse(response => response.request().method() === method && new URL(response.url()).pathname === path); } +function count(session: Session, method: string, path: string) { return session.requests.filter(request => request.method === method && request.path === path); } +function readRequests(session: Session) { return session.requests.filter(request => request.method === 'GET' && /^\/api\/(?:correspondence|email\/providers|email\/message|gmail\/review-candidates|gmail\/suggested-jobs)(?:\/|$)/.test(request.path)); } +function noDelivery(session: Session) { expect(session.requests.filter(request => request.method !== 'GET' && /^\/api\/(?:email\/|gmail\/(?:manual-sync|create-suggested-job|unlink-thread)|ai(?:\/|$))/.test(request.path))).toEqual([]); } +async function keyboard(page: Page, locator: Locator) { await locator.focus(); await expect(locator).toBeFocused(); await page.keyboard.press('Enter'); } +function inbox(page: Page, language: Sample['language'] = 'en') { return page.getByRole('region', { name: translations[language].correspondenceInbox, exact: true }); } +function row(page: Page, subject: string) { return page.getByRole('article', { name: subject, exact: true }); } +function review(page: Page, language: Sample['language'] = 'en') { return page.getByRole('region', { name: translations[language].gmailReviewEmbeddedTitle, exact: true }); } +function detail(page: Page, subject: string, language: Sample['language'] = 'en') { return row(page, subject).getByRole('group', { name: translations[language].emailInboxMessage, exact: true }); } +function labelPrefix(label: string) { return new RegExp(`^${label.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?:\\s|$)`); } +async function select(page: Page, label: string, option: string) { await keyboard(page, page.getByRole('combobox', { name: labelPrefix(label) })); await keyboard(page, page.getByRole('option', { name: option, exact: true })); } +async function pageOwnedFocus(section: Locator) { + await expect.poll(() => section.evaluate(node => node.contains(document.activeElement) && document.activeElement !== document.body && document.activeElement?.isConnected === true)).toBe(true); +} +async function login(api: APIRequestContext, email: string): Promise { + 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 token = (await api.storageState()).cookies.find(cookie => cookie.name === 'XSRF-TOKEN'); expect(token).toBeDefined(); + return { id: me.id, headers: { 'X-CSRF-TOKEN': token!.value, 'X-Session-Context': me.sessionContext } }; +} +async function account(admin: APIRequestContext, owner: Owner) { + const email = `p706-read-${Date.now()}-${Math.random().toString(36).slice(2)}@example.test`; + expect((await admin.post(`${apiUrl}/users`, { headers: owner.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 blocked: string[] = []; + page.on('request', request => { + const url = new URL(request.url()); + if (!/^\/api\/(?:correspondence|email|gmail|ai|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) || ['blob:', 'data:', 'about:'].includes(url.protocol)) return route.continue(); + blocked.push(url.origin + url.pathname); return route.abort(); + }); + await provide({ owner, administrator, admin, requests }); + } finally { await info.attach('correspondence-read-boundary', { body: JSON.stringify({ boundary, requests, blockedExternalRequests: blocked }), contentType: 'application/json' }); await admin.dispose(); } + }, +}); +async function job(api: APIRequestContext, owner: Owner, title = 'Recorded correspondence job — æøå', ownedCompanyId?: number) { + const companyId = ownedCompanyId ?? (await json<{ id: number }>(await api.post(`${apiUrl}/companies`, { headers: owner.headers, data: { name: 'Recorded correspondence company — æøå' } }), 201)).id; + return json(await api.post(`${apiUrl}/jobapplications`, { headers: owner.headers, data: { companyId, jobTitle: title, status: 'Saved' } }), 201); +} +async function message(api: APIRequestContext, owner: Owner, jobId: number, subject: string, index = 0) { + const content = `Saved full plain-text body for ${subject} — æøå\n` + ('Recorded owner text; preserve exact saved copy and line breaks.\n').repeat(8) + ''; + return json(await api.post(`${apiUrl}/correspondence`, { headers: owner.headers, data: { + jobApplicationId: jobId, from: index % 3 === 0 ? 'Company' : 'Me', subject, content, channel: 'Email', + date: '2026-10-13T08:00:00Z', direction: ['inbound', 'outbound', 'internal'][index % 3], + externalMessageId: index % 2 === 0 ? `recorded-message-${jobId}-${index}` : null, externalThreadId: index % 2 === 0 ? `recorded-thread-${jobId}-${index}` : null, + externalFrom: 'Recorded recruiter ', externalTo: 'recorded-owner@example.test', + externalLabelsJson: JSON.stringify(['RECORDED', 'Owner label']), attachmentMetadataJson: JSON.stringify([{ FileName: 'Recorded metadata — æøå.txt', MimeType: 'text/plain', SizeBytes: 64, Inline: false }]), + } }), 201); +} +async function seed(page: Page, owner: Owner, number = 3, prefix = 'Recorded message') { + const parent = await job(page.request, owner); const messages: Message[] = []; + for (let index = 0; index < number; index++) messages.push(await message(page.request, owner, parent.id, `${prefix} ${String(index).padStart(2, '0')} — æøå`, index)); + return { job: parent, messages }; +} +async function pageRead(api: APIRequestContext, params: Record = {}) { return json(await api.get(`${apiUrl}/correspondence/page`, { params: { page: 1, pageSize: 50, ...params } })); } +async function enter(page: Page, owner: Owner, sample: Sample = desktop, path = '/correspondence?source=phase7-read&marker=preserved#inbox') { + 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(path); + 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(inbox(page, sample.language).getByRole('heading', { level: 2, name: translations[sample.language].correspondenceInbox, exact: true })).toBeVisible(); +} +async function show(page: Page, value: Message, language: Sample['language'] = 'en') { await keyboard(page, row(page, value.subject).getByRole('button', { name: translations[language].emailInboxViewMessage, exact: true })); } +async function capture(page: Page, info: TestInfo, sample: Sample, state: string) { + await page.evaluate(async () => { await document.fonts.ready; await new Promise(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'), name: document.activeElement?.getAttribute('aria-label') } })); + const axe = await new AxeBuilder({ page }).options({ rules: { 'target-size': { enabled: true } } }).analyze(); + const name = `p706-read-${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, no exclusions', 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 language/theme/width/state combinations.', 'All incomplete vectors retained; no human AT, physical touch/software keyboard, real zoom or precise screenshot contrast acceptance.', + 'Full-page images retain context but may be auto-resized and do not prove real viewport focus visibility.', 'Provider/review fixtures are expressly local projections; no live provider/model/send qualification.'], + }), contentType: 'application/json' }); + await info.attach(name, { body: await page.screenshot({ fullPage: true, animations: 'disabled' }), contentType: 'image/png' }); + expect(geometry.rootOverflow).toBe(0); expect(axe.violations).toEqual([]); +} +function reviewFixture(parent: Job, long = false) { + const threads: GmailReviewThread[] = ['review', 'unmatched'].map((routing, index) => ({ + threadId: `recorded-review-${parent.id}-${index}`, subject: long ? `Lang kontrolltråd om samfunnskritiske tjenester og inkluderende arbeidsprosesser Æ Ø Å ${index}` : `Recorded review thread ${index} — æøå`, + latestDate: '2026-10-13T08:00:00Z', messageCount: 2, routing, hasImportedMessages: false, decisionNote: `Previously recorded review note ${index}`, + matchedQueries: ['Recorded source query; no provider search'], jobCandidates: [{ jobApplicationId: parent.id, jobTitle: parent.jobTitle, companyName: 'Recorded correspondence company — æøå', score: 24, confidence: 'medium', reasons: [] }], messages: [], + })); + const queue: GmailReviewQueueResponse = { queries: ['Recorded source query; no provider search'], candidateThreadCount: 2, autoLinkThreadCount: 0, reviewThreadCount: 1, unmatchedThreadCount: 1, threads }; + const suggestions: GmailSuggestedJobsResponse = { count: 0, items: [] }; + return { queue, suggestions, threads }; +} +async function controlledReview(page: Page, fixture: ReturnType) { + await page.route(`${apiUrl}/gmail/review-candidates`, route => route.fulfill({ status: 200, json: fixture.queue })); + await page.route(`${apiUrl}/gmail/suggested-jobs`, route => route.fulfill({ status: 200, json: fixture.suggestions })); +} + +test('[P7-06 read] actual owner page50, filters, ordering and job Back preserve saved-message context and ownership', async ({ page, session, playwright }, info) => { + test.setTimeout(180_000); const seeded = await seed(page, session.owner, 55); + const removedJob = await job(page.request, session.owner, 'Deleted-parent messages stay absent', seeded.job.companyId); const removed = await message(page.request, session.owner, removedJob.id, 'Deleted-parent private body'); + expect((await page.request.delete(`${apiUrl}/jobapplications/${removedJob.id}`, { headers: session.owner.headers })).status()).toBe(204); + const foreign = await playwright.request.newContext(); const anonymous = await playwright.request.newContext(); + try { + const other = await login(foreign, await account(session.admin, session.administrator)); const otherJob = await job(foreign, other, 'Other owner job'); const otherMessage = await message(foreign, other, otherJob.id, 'Other owner private body'); + expect((await page.request.get(`${apiUrl}/correspondence/message/${otherMessage.id}`)).status()).toBe(404); expect((await foreign.get(`${apiUrl}/correspondence/message/${seeded.messages[0].id}`)).status()).toBe(404); expect((await anonymous.get(`${apiUrl}/correspondence/page`)).status()).toBe(401); + // Trashing an owned job preserves its saved correspondence; the existing + // owner-only message/page contract does not filter the parent's IsDeleted. + const ownedIds = [...seeded.messages.map(value => value.id), removed.id].sort((a, b) => b - a); + expect(await json(await page.request.get(`${apiUrl}/correspondence/message/${removed.id}`))).toMatchObject({ bodyText: removed.content, subject: removed.subject }); + const first = await pageRead(page.request); expect(first.total).toBe(56); expect(first.pageSize).toBe(50); expect(first.items.map(value => value.id)).toEqual(ownedIds.slice(0, 50)); + expect(first.items.every(value => value.provider === 'manual')).toBe(true); + await enter(page, session.owner); await expect(inbox(page).getByRole('article')).toHaveCount(50); await expect(page.getByText(interpolate(translations.en.emailInboxItems, { count: 56 }), { exact: true })).toBeVisible(); + await expect(row(page, removed.subject)).toBeVisible(); await expect(row(page, otherMessage.subject)).toHaveCount(0); + const pageTwo = waitResponse(page, 'GET', '/api/correspondence/page'); await keyboard(page, page.getByRole('navigation', { name: translations.en.emailInboxPages, exact: true }).getByRole('button', { name: 'Go to page 2', exact: true })); + const second = await json(await pageTwo); expect(second.page).toBe(2); expect(second.total).toBe(56); expect(second.items.map(value => value.id)).toEqual(ownedIds.slice(50)); expect(second.items).toHaveLength(6); await expect(inbox(page).getByRole('article')).toHaveCount(6); + await expect(page.getByText(interpolate(translations.en.emailInboxLinkedOnPage, { count: second.items.filter(value => value.externalThreadId).length }), { exact: true })).toBeVisible(); + const selected = second.items[0]; const loaded = waitResponse(page, 'GET', `/api/jobapplications/${selected.jobApplicationId}/workspace`); + await keyboard(page, row(page, selected.subject!).getByRole('button', { name: translations.en.emailInboxOpenJob, exact: true })); + await expect(page).toHaveURL(new RegExp(`/jobs/${selected.jobApplicationId}\\?section=communication`)); expect(await json<{ id: number }>(await loaded)).toMatchObject({ id: selected.jobApplicationId }); + await page.goBack(); await expect(inbox(page).getByRole('article')).toHaveCount(6); + expect(new URL(page.url()).searchParams.get('source')).toBe('phase7-read'); expect(new URL(page.url()).searchParams.get('marker')).toBe('preserved'); expect(new URL(page.url()).hash).toBe('#inbox'); + await expect(row(page, selected.subject!)).toBeVisible(); + await page.getByRole('textbox', { name: translations.en.correspondenceSearch, exact: true }).fill('Recorded message'); + await select(page, translations.en.emailInboxDirection, translations.en.emailInboxOutbound); await select(page, translations.en.emailInboxLinkState, translations.en.emailInboxLinkedThreads); + const filtered = await pageRead(page.request, { q: 'Recorded message', direction: 'outbound', linkState: 'linked' }); + await expect(inbox(page).getByRole('article')).toHaveCount(filtered.items.length); + expect(count(session, 'GET', '/api/correspondence/page').at(-1)?.query).toMatchObject({ q: 'Recorded message', direction: 'outbound', linkState: 'linked', page: '1', pageSize: '50' }); + for (const value of filtered.items) await expect(row(page, value.subject!)).toBeVisible(); + await capture(page, info, desktop, 'real-filtered-owner-page'); noDelivery(session); + await info.attach('correspondence-real-page', { body: JSON.stringify({ boundary, first: { total: first.total, pageSize: first.pageSize, ids: first.items.map(value => value.id) }, second: { page: second.page, ids: second.items.map(value => value.id) }, + filtered: { total: filtered.total, ids: filtered.items.map(value => value.id) }, qualification: 'Actual saved manual metadata and SQL page/filter/order boundaries. Linked/inbound chips describe the currently shown page; provider=manual is not a live mailbox.' }), contentType: 'application/json' }); + } finally { await foreign.dispose(); await anonymous.dispose(); } +}); + +test('[P7-06 read] initial503 Retry and held latest query remain distinct from true empty results', async ({ page, session }, info) => { + test.setTimeout(120_000); const seeded = await seed(page, session.owner, 2, 'Latest query'); const firstGate = hold(); const olderGate = hold(); const newerGate = hold(); let initialReads = 0; + await page.route(`${apiUrl}/correspondence/page?**`, async route => { + const q = new URL(route.request().url()).searchParams.get('q'); + if (q === 'Latest query 00') { await olderGate.pending; return route.continue(); } + if (q === 'Latest query 01') { await newerGate.pending; return route.continue(); } + initialReads += 1; if (initialReads === 1) return route.fulfill({ status: 503, json: {} }); + await firstGate.pending; return route.continue(); + }); + try { + await enter(page, session.owner); await expect(inbox(page).getByRole('alert').filter({ hasText: translations.en.emailInboxLoadFailed })).toBeVisible(); + await expect(inbox(page).getByText(translations.en.emailInboxNoMatches, { exact: true })).toHaveCount(0); await capture(page, info, desktop, 'initial-list503-not-empty'); + await keyboard(page, inbox(page).getByRole('button', { name: translations.en.retry, exact: true })); await expect.poll(() => initialReads).toBe(2); await pageOwnedFocus(inbox(page)); + await expect(inbox(page).getByRole('group', { name: translations.en.emailInboxLinkedMessages, exact: true }).getByRole('status').filter({ hasText: translations.en.loading })).toContainText(translations.en.loading); firstGate.release(); await expect(inbox(page).getByRole('article')).toHaveCount(2); + const search = page.getByRole('textbox', { name: translations.en.correspondenceSearch, exact: true }); + await search.fill('Latest query 00'); await expect.poll(() => count(session, 'GET', '/api/correspondence/page').filter(value => value.query.q === 'Latest query 00').length).toBe(1); + await search.fill('Latest query 01'); await expect.poll(() => count(session, 'GET', '/api/correspondence/page').filter(value => value.query.q === 'Latest query 01').length).toBe(1); + await expect(row(page, seeded.messages[0].subject)).toHaveCount(0); await expect(row(page, seeded.messages[1].subject)).toHaveCount(0); + await expect(inbox(page).getByText(translations.en.emailInboxNoMatches, { exact: true })).toHaveCount(0); await capture(page, info, desktop, 'held-current-query-no-stale-rows'); + newerGate.release(); await expect(row(page, seeded.messages[1].subject)).toBeVisible(); + const older = page.waitForResponse(response => new URL(response.url()).pathname === '/api/correspondence/page' && new URL(response.url()).searchParams.get('q') === 'Latest query 00'); olderGate.release(); expect((await older).status()).toBe(200); + await expect(row(page, seeded.messages[0].subject)).toHaveCount(0); await expect(row(page, seeded.messages[1].subject)).toBeVisible(); await expect(search).toHaveValue('Latest query 01'); + await search.fill('No matching saved fixture'); await expect(inbox(page).getByText(translations.en.emailInboxNoMatches, { exact: true })).toBeVisible(); noDelivery(session); + } finally { firstGate.release(); olderGate.release(); newerGate.release(); } +}); + +test('[P7-06 read] actual saved-detail Retry preserves its ID and Hide/another row cancel stale completion', async ({ page, session }, info) => { + test.setTimeout(120_000); const seeded = await seed(page, session.owner, 2, 'Saved detail'); await enter(page, session.owner); await expect(inbox(page).getByRole('article')).toHaveCount(2); + const first = seeded.messages[0]; const second = seeded.messages[1]; const gate = hold(); let attempts = 0; + await page.route(`${apiUrl}/correspondence/message/${first.id}`, async route => { attempts += 1; if (attempts === 1) return route.fulfill({ status: 503, json: {} }); await gate.pending; return route.continue(); }); + try { + await show(page, first); await expect(row(page, first.subject).getByRole('alert')).toContainText(translations.en.emailInboxMessageLoadFailed); + const listReads = count(session, 'GET', '/api/correspondence/page').length; await keyboard(page, row(page, first.subject).getByRole('button', { name: translations.en.retry, exact: true })); + await expect.poll(() => attempts).toBe(2); await capture(page, info, desktop, 'held-original-saved-detail-retry'); + await keyboard(page, row(page, first.subject).getByRole('button', { name: translations.en.emailInboxHideMessage, exact: true })); + await show(page, second); await expect(detail(page, second.subject)).toContainText(second.content); + const late = waitResponse(page, 'GET', `/api/correspondence/message/${first.id}`); gate.release(); expect((await late).status()).toBe(200); + await expect(detail(page, first.subject)).toHaveCount(0); await expect(detail(page, second.subject)).toContainText(second.content); + expect(count(session, 'GET', '/api/correspondence/page')).toHaveLength(listReads); + await show(page, first); await expect(detail(page, first.subject)).toContainText(first.content); await expect(detail(page, first.subject)).toContainText('Owner label'); await expect(detail(page, first.subject)).toContainText('Recorded metadata — æøå.txt'); + await expect(row(page, first.subject).locator('script')).toHaveCount(0); expect(await json(await page.request.get(`${apiUrl}/correspondence/message/${first.id}`))).toMatchObject({ bodyText: first.content }); + await capture(page, info, desktop, 'actual-saved-body-and-metadata'); noDelivery(session); + } finally { gate.release(); } +}); + +test('[P7-06 read] qualified provider live-copy failure retries exact captured source before real saved fallback', async ({ page, session }, info) => { + test.setTimeout(120_000); const seeded = await seed(page, session.owner, 1, 'Provider branch'); const saved = seeded.messages[0]; + await page.route(`${apiUrl}/email/providers`, route => route.fulfill({ status: 200, json: [{ provider: 'gmail', displayName: 'Gmail', connected: true, address: 'controlled-readonly@example.test', canRead: true, canSend: false }] })); + await page.route(`${apiUrl}/correspondence/page?**`, async route => { const actual = await route.fetch(); const data: Inbox = await actual.json(); return route.fulfill({ response: actual, json: { ...data, items: data.items.map(value => value.id === saved.id ? { ...value, provider: 'gmail' } : value) } }); }); + let providerReads = 0; let savedReads = 0; + await page.route(`${apiUrl}/email/message?**`, route => { providerReads += 1; expect(new URL(route.request().url()).searchParams.get('provider')).toBe('gmail'); expect(new URL(route.request().url()).searchParams.get('messageId')).toBe(saved.externalMessageId); return route.fulfill({ status: 503, json: {} }); }); + await page.route(`${apiUrl}/correspondence/message/${saved.id}`, route => { savedReads += 1; return savedReads === 1 ? route.fulfill({ status: 503, json: {} }) : route.continue(); }); + await enter(page, session.owner); await show(page, saved); await expect(row(page, saved.subject).getByRole('alert').filter({ hasText: translations.en.emailInboxMessageLoadFailed })).toBeVisible(); + const listReads = count(session, 'GET', '/api/correspondence/page').length; + await keyboard(page, row(page, saved.subject).getByRole('button', { name: translations.en.retry, exact: true })); + await expect(detail(page, saved.subject)).toContainText(saved.content); await expect(row(page, saved.subject)).toContainText(translations.en.emailInboxProviderCopyUnavailable); + expect(providerReads).toBe(2); expect(savedReads).toBe(2); expect(count(session, 'GET', '/api/correspondence/page')).toHaveLength(listReads); + expect((await pageRead(page.request)).items[0].provider).toBe('manual'); await expect(row(page, saved.subject).getByRole('button', { name: translations.en.emailInboxReply, exact: true })).toHaveCount(0); + await capture(page, info, desktop, 'qualified-provider-unavailable-real-saved-copy'); noDelivery(session); + await info.attach('provider-copy-qualification', { body: JSON.stringify({ boundary, actualOwnedMessageId: saved.id, underlyingProvider: 'manual', browserProjectionProvider: 'gmail', providerReads, actualSavedReadAttempts: savedReads, + qualification: 'Only browser discriminator/live-copy/status are controlled fixtures. Actual owned saved body/metadata and tenant admission remain real HTTP. No live mailbox or actual provider grant/read qualification.' }), contentType: 'application/json' }); +}); + +test('[P7-06 read] strict real UI-language204 preserves inbox context, selected saved body and dirty review notes with zero read deltas', async ({ page, session }, info) => { + test.setTimeout(120_000); const seeded = await seed(page, session.owner, 3, 'Locale preserved'); const queue = reviewFixture(seeded.job); await controlledReview(page, queue); + await enter(page, session.owner); await page.getByRole('textbox', { name: translations.en.correspondenceSearch, exact: true }).fill('Locale preserved'); await expect(inbox(page).getByRole('article')).toHaveCount(3); await show(page, seeded.messages[0]); await expect(detail(page, seeded.messages[0].subject)).toContainText(seeded.messages[0].content); + let reads = readRequests(session).length; + const locale = waitResponse(page, 'PUT', '/api/auth/preferences/language'); await keyboard(page, page.locator('header').first().getByRole('button', { name: 'Norsk', exact: true })); + const real = await locale; await info.attach('inbox-locale-first-http', { body: JSON.stringify({ status: real.status(), body: await real.text() }), contentType: 'application/json' }); expect(real.status()).toBe(204); + await expect(page.locator('html')).toHaveAttribute('lang', 'nb-NO'); await expect(page.getByRole('textbox', { name: translations.nb.correspondenceSearch, exact: true })).toHaveValue('Locale preserved'); await expect(detail(page, seeded.messages[0].subject, 'nb')).toContainText(seeded.messages[0].content); + expect(readRequests(session)).toHaveLength(reads); await capture(page, info, { ...desktop, language: 'nb' }, 'strict-locale-preserved-saved-detail'); + await keyboard(page, inbox(page, 'nb').getByRole('button', { name: translations.nb.emailInboxReviewSuggestions, exact: true })); await expect(review(page, 'nb').getByRole('article')).toHaveCount(2); + const subject = queue.threads[0].subject; await row(page, subject).getByRole('textbox', { name: translations.nb.gmailReviewNotes, exact: true }).fill('Ulagret kontrollnotat — æøå'); reads = readRequests(session).length; + const second = waitResponse(page, 'PUT', '/api/auth/preferences/language'); await keyboard(page, page.locator('header').first().getByRole('button', { name: 'English', exact: true })); + const secondReal = await second; await info.attach('inbox-locale-second-http', { body: JSON.stringify({ status: secondReal.status(), body: await secondReal.text() }), contentType: 'application/json' }); expect(secondReal.status()).toBe(204); + await expect(row(page, subject).getByRole('textbox', { name: translations.en.gmailReviewNotes, exact: true })).toHaveValue('Ulagret kontrollnotat — æøå'); expect(readRequests(session)).toHaveLength(reads); + await capture(page, info, desktop, 'strict-locale-dirty-review-notes'); noDelivery(session); + expect(session.requests.filter(request => request.method !== 'GET' && !request.path.startsWith('/api/jobapplications/'))).toEqual([]); +}); + +test('[P7-06 read] independent provider-status503 Retry only rereads status while saved owner messages remain usable', async ({ page, session }, info) => { + test.setTimeout(90_000); const seeded = await seed(page, session.owner, 2, 'Provider status independent'); let providerReads = 0; const gate = hold(); + await page.route(`${apiUrl}/email/providers`, async route => { providerReads += 1; if (providerReads === 1) return route.fulfill({ status: 503, json: {} }); await gate.pending; return route.fulfill({ status: 200, json: [{ provider: 'gmail', displayName: 'Gmail', connected: false, address: null, canRead: false, canSend: false }] }); }); + try { + await enter(page, session.owner); await expect(inbox(page).getByRole('article')).toHaveCount(2); await expect(inbox(page).getByRole('alert').filter({ hasText: translations.en.emailInboxProviderStatusUnavailable })).toBeVisible(); + await show(page, seeded.messages[0]); await expect(detail(page, seeded.messages[0].subject)).toContainText(seeded.messages[0].content); + const listReads = count(session, 'GET', '/api/correspondence/page').length; const savedReads = count(session, 'GET', `/api/correspondence/message/${seeded.messages[0].id}`).length; + const providerAlert = inbox(page).getByRole('alert').filter({ hasText: translations.en.emailInboxProviderStatusUnavailable }); await keyboard(page, providerAlert.getByRole('button', { name: translations.en.retry, exact: true })); + await expect.poll(() => providerReads).toBe(2); await expect(detail(page, seeded.messages[0].subject)).toContainText(seeded.messages[0].content); await capture(page, info, desktop, 'held-status-retry-saved-body-retained'); gate.release(); + await expect(page.getByRole('group', { name: translations.en.emailInboxProviderStatus, exact: true })).toContainText(interpolate(translations.en.emailInboxProviderNotConnected, { provider: 'Gmail' })); + expect(count(session, 'GET', '/api/correspondence/page')).toHaveLength(listReads); expect(count(session, 'GET', `/api/correspondence/message/${seeded.messages[0].id}`)).toHaveLength(savedReads); + await expect(inbox(page).getByRole('button', { name: translations.en.emailInboxCompose, exact: true })).toBeDisabled(); noDelivery(session); + } finally { gate.release(); } +}); + +test('[P7-06 read] qualified paired review reads retain notes/filter and actual nonlinked decision recovery never replays committed POST', async ({ page, session }, info) => { + test.setTimeout(120_000); const seeded = await seed(page, session.owner); const fixture = reviewFixture(seeded.job); let failRead = false; let queueReads = 0; let suggestionsReads = 0; let decisionWrites = 0; const gate = hold(); + await page.route(`${apiUrl}/gmail/review-candidates`, route => { queueReads += 1; return failRead ? route.fulfill({ status: 503, json: {} }) : route.fulfill({ status: 200, json: fixture.queue }); }); + await page.route(`${apiUrl}/gmail/suggested-jobs`, route => { suggestionsReads += 1; return route.fulfill({ status: 200, json: fixture.suggestions }); }); + await enter(page, session.owner, desktop, '/correspondence/review?source=phase7-review&marker=alias&view=inbox#queued'); + await expect.poll(() => { const url = new URL(page.url()); return url.pathname + url.search + url.hash; }).toBe('/correspondence?source=phase7-review&marker=alias&view=review#queued'); + await expect(review(page).getByRole('article')).toHaveCount(2); + const thread = fixture.threads[0]; const second = fixture.threads[1]; const notes = row(page, thread.subject).getByRole('textbox', { name: translations.en.gmailReviewNotes, exact: true }); await expect(notes).toHaveValue(thread.decisionNote!); await notes.fill(' Owner-reviewed decision note — æøå '); + await keyboard(page, review(page).getByRole('button', { name: translations.en.gmailReviewRouteReview, exact: true })); await expect(row(page, second.subject)).toHaveCount(0); + failRead = true; await keyboard(page, review(page).getByRole('button', { name: translations.en.correspondenceRefresh, exact: true })); + await expect(review(page).getByRole('alert')).toContainText(translations.en.gmailReviewLoadFailed); await expect(notes).toHaveValue(' Owner-reviewed decision note — æøå '); await expect(review(page).getByText(translations.en.gmailReviewNoCandidates, { exact: true })).toHaveCount(0); + failRead = false; await keyboard(page, review(page).getByRole('button', { name: translations.en.retry, exact: true })); await expect(notes).toHaveValue(' Owner-reviewed decision note — æøå '); + await expect(review(page).getByRole('button', { name: translations.en.gmailReviewRouteReview, exact: true })).toHaveAttribute('aria-pressed', 'true'); + await capture(page, info, desktop, 'review-read-retry-retains-note-filter'); + await page.route(`${apiUrl}/gmail/review-decision`, async route => { + decisionWrites += 1; + if (decisionWrites === 1) { await gate.pending; return route.fulfill({ status: 503, json: {} }); } + // Nonlinked decisions use the actual owner-local endpoint; never provider imports. + const actual = await route.fetch(); const data = await actual.json(); expect(actual.status()).toBe(200); expect(data.decision).toBe('rejected'); expect(data.note).toBe('Owner-reviewed decision note — æøå'); + fixture.threads[0].decisionNote = data.note; fixture.threads[0].routing = data.decision; failRead = true; + return route.fulfill({ response: actual, json: data }); + }); + try { + await keyboard(page, row(page, thread.subject).getByRole('button', { name: translations.en.gmailReviewReject, exact: true })); await expect.poll(() => decisionWrites).toBe(1); + await expect(notes).toBeDisabled(); await expect(review(page).getByRole('button', { name: translations.en.correspondenceRefresh, exact: true })).toBeDisabled(); + expect(count(session, 'POST', '/api/gmail/review-decision').map(request => request.body)).toEqual([{ threadId: thread.threadId, decision: 'rejected', jobApplicationId: null, note: 'Owner-reviewed decision note — æøå' }]); + await capture(page, info, desktop, 'held-local-review-decision'); gate.release(); + await expect(review(page).getByRole('alert')).toContainText(translations.en.gmailReviewSaveFailed); await expect(notes).toHaveValue(' Owner-reviewed decision note — æøå '); + const committed = waitResponse(page, 'POST', '/api/gmail/review-decision'); await keyboard(page, row(page, thread.subject).getByRole('button', { name: translations.en.gmailReviewReject, exact: true })); + expect(await json<{ note: string; decision: string }>(await committed)).toMatchObject({ note: 'Owner-reviewed decision note — æøå', decision: 'rejected' }); + await expect(review(page).getByRole('alert')).toContainText(translations.en.gmailReviewLoadFailed); const writes = decisionWrites; const q = queueReads; const s = suggestionsReads; failRead = false; + await keyboard(page, review(page).getByRole('button', { name: translations.en.retry, exact: true })); await expect.poll(() => queueReads).toBe(q + 1); await expect.poll(() => suggestionsReads).toBe(s + 1); expect(decisionWrites).toBe(writes); + await keyboard(page, review(page).getByRole('button', { name: translations.en.gmailReviewRouteRejected, exact: true })); await expect(row(page, thread.subject)).toBeVisible(); await expect(notes).toHaveValue(' Owner-reviewed decision note — æøå '); + await capture(page, info, desktop, 'committed-decision-get-only-recovery'); noDelivery(session); + await info.attach('review-provider-and-persistence-boundary', { body: JSON.stringify({ boundary, queueReads, suggestionsReads, actualNonlinkedWriteResponse: { threadId: thread.threadId, decision: 'rejected', note: 'Owner-reviewed decision note — æøå' }, decisionWrites, + qualification: 'Paired queue read fixture echoes actual200 local decision response. No supported pure decision GET was available: refreshed fixture notes are not an independent DB/provider persistence readback. Linked decisions/manual-sync/import/create/send are unexecuted.' }), contentType: 'application/json' }); + } finally { gate.release(); } +}); + +test('[P7-06 read] eight finite bilingual theme widths preserve long saved bodies and qualified review-note keyboard access', async ({ page, session }, info) => { + test.setTimeout(240_000); const seeded = await seed(page, session.owner, 3, 'Langt emne om samfunnskritisk plattformutvikling med mange samarbeidspartnere Æ Ø Å'); const fixture = reviewFixture(seeded.job, true); await controlledReview(page, fixture); + const samples: (Sample & { view: 'inbox' | 'detail' | 'review' })[] = [ + { language: 'en', theme: 'light', width: 320, height: 740, view: 'inbox' }, { language: 'nb', theme: 'dark', width: 360, height: 844, view: 'review' }, + { language: 'en', theme: 'dark', width: 390, height: 844, view: 'detail' }, { language: 'nb', theme: 'light', width: 768, height: 1024, view: 'inbox' }, + { language: 'en', theme: 'dark', width: 1024, height: 768, view: 'review' }, { language: 'nb', theme: 'light', width: 1280, height: 320, view: 'detail' }, + { language: 'en', theme: 'light', width: 1440, height: 900, view: 'review' }, { language: 'nb', theme: 'dark', width: 1920, height: 1080, view: 'inbox' }, + ]; + for (const sample of samples) { + await enter(page, session.owner, sample, `/correspondence?source=phase7-width${sample.view === 'review' ? '&view=review' : ''}#long`); const t = translations[sample.language]; + if (sample.view === 'review') { + await expect(review(page, sample.language).getByRole('article')).toHaveCount(2); + const control = row(page, fixture.threads[0].subject).getByRole('textbox', { name: t.gmailReviewNotes, exact: true }); await control.focus(); await expect(control).toBeFocused(); + } else { + await expect(inbox(page, sample.language).getByRole('article')).toHaveCount(3); + if (sample.view === 'detail') { await show(page, seeded.messages[0], sample.language); await expect(detail(page, seeded.messages[0].subject, sample.language)).toContainText(seeded.messages[0].content); } + const action = row(page, seeded.messages[0].subject).getByRole('button', { name: sample.view === 'detail' ? t.emailInboxHideMessage : t.emailInboxViewMessage, exact: true }); await action.focus(); await expect(action).toBeFocused(); + } + await page.evaluate(() => scrollTo(0, 0)); await capture(page, info, sample, `long-${sample.view}`); + } + noDelivery(session); expect(count(session, 'POST', '/api/gmail/review-decision')).toHaveLength(0); +}); diff --git a/job-tracker-ui/src/App.tsx b/job-tracker-ui/src/App.tsx index baf2963..002ff98 100644 --- a/job-tracker-ui/src/App.tsx +++ b/job-tracker-ui/src/App.tsx @@ -76,6 +76,13 @@ function LegacyBoardRedirect() { query.set("view", "board"); return ; } + +function LegacyCorrespondenceReviewRedirect() { + const location = useLocation(); + const query = new URLSearchParams(location.search); + query.set("view", "review"); + return ; +} type ThemeControls = { themeMode: ThemeModePref; onThemeModeChange: (value: ThemeModePref) => void }; const ThemeControlsContext = createContext(null); type MeResponse = { @@ -403,7 +410,7 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo } /> } /> } /> - } /> + } /> } /> } /> } /> diff --git a/job-tracker-ui/src/correspondence-inbox-page.test.tsx b/job-tracker-ui/src/correspondence-inbox-page.test.tsx index 8e147b5..ae3cfa3 100644 --- a/job-tracker-ui/src/correspondence-inbox-page.test.tsx +++ b/job-tracker-ui/src/correspondence-inbox-page.test.tsx @@ -1,12 +1,13 @@ import React from 'react'; import '@testing-library/jest-dom'; -import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'; -import { MemoryRouter, Navigate, Route, Routes } from 'react-router-dom'; +import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +import { MemoryRouter, Navigate, Route, Routes, useLocation, useNavigate } from 'react-router-dom'; import { ToastProvider } from './toast'; import { ConfirmProvider } from './confirm'; import { I18nProvider } from './i18n/I18nProvider'; import CorrespondenceInboxPage from './views/CorrespondenceInboxPage'; import { api } from './api'; +import { translations, type UiLanguage } from './i18n/translations'; jest.mock('./api', () => ({ api: { @@ -22,12 +23,56 @@ jest.mock('./api', () => ({ const mockedApi = api as jest.Mocked; -function renderPage() { +function copy(key: string, params: Record = {}, language: UiLanguage = document.documentElement.lang === 'nb-NO' ? 'nb' : 'en') { + return ((translations[language] as Record)[key] ?? key).replace(/\{(\w+)\}/g, (_, name) => String(params[name] ?? `{${name}}`)); +} + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (reason: unknown) => void; + const promise = new Promise((yes, no) => { resolve = yes; reject = no; }); + return { promise, resolve, reject }; +} + +async function changeLanguage(language: UiLanguage) { + await act(async () => { window.dispatchEvent(new StorageEvent('storage', { key: 'uiLanguage', newValue: language })); }); +} + +function retryNotice(key: string) { + const alert = screen.getAllByRole('alert').find((node) => node.textContent?.includes(copy(key)) && within(node).queryByRole('button', { name: copy('retry') })); + expect(alert).toBeDefined(); + return within(alert!); +} + +function messageItem(overrides: Record = {}) { + return { id: 1, jobApplicationId: 42, companyName: 'Acme Systems', jobTitle: 'Backend Engineer', from: 'Recruiter', direction: 'inbound', subject: 'Interview invite', channel: 'Email', date: '2026-02-13T11:30:00Z', contentPreview: 'Saved preview', externalThreadId: 'thread-1', externalMessageId: 'message-1', provider: 'gmail', externalFrom: 'Maria ', externalTo: 'owner@gmail.test', labelCount: 0, attachmentCount: 0, ...overrides }; +} + +function pageData(items: ReturnType[], page = 1, total = items.length, totalPages = total > 50 ? 2 : 1) { + return { items, page, pageSize: 50, total, totalPages }; +} + +function messageRow(subject: string) { + return within(screen.getByText(subject, { exact: true }).closest('.MuiPaper-root') as HTMLElement); +} + +function LocationProbe() { + const location = useLocation(); + return {location.pathname}{location.search}{location.hash}; +} + +function HistoryBackControl() { + const navigate = useNavigate(); + return ; +} + +function renderPage(initialPath = '/') { return render( - + + @@ -39,6 +84,13 @@ function renderPage() { describe('CorrespondenceInboxPage', () => { beforeEach(() => { window.localStorage.clear(); + document.documentElement.lang = 'en'; + jest.clearAllMocks(); + mockedApi.get.mockReset(); + mockedApi.post.mockReset(); + mockedApi.put.mockReset(); + mockedApi.patch.mockReset(); + mockedApi.delete.mockReset(); Object.defineProperty(globalThis.crypto, 'randomUUID', { configurable: true, value: jest.fn(() => '00000000-0000-4000-8000-000000000123') }); mockedApi.get.mockImplementation((url: string) => { if (url === '/email/providers') return Promise.resolve({ data: [ @@ -238,7 +290,7 @@ describe('CorrespondenceInboxPage', () => { expect(screen.getByLabelText(/recipient/i)).toHaveValue('Maria Recruiter '); expect(screen.getByLabelText(/subject/i)).toHaveValue('Re: Interview invite'); expect(screen.getByLabelText(/thread/i)).toHaveValue('thread-1'); - fireEvent.change(screen.getByLabelText(/message/i), { target: { value: 'Thank you. Tuesday works well.' } }); + fireEvent.change(screen.getByRole('textbox', { name: copy('emailInboxMessage') }), { target: { value: 'Thank you. Tuesday works well.' } }); fireEvent.click(screen.getByRole('button', { name: /review and send/i })); expect(await screen.findByRole('heading', { name: /confirm email send/i })).toBeInTheDocument(); @@ -246,7 +298,7 @@ describe('CorrespondenceInboxPage', () => { expect(mockedApi.post).not.toHaveBeenCalled(); fireEvent.click(screen.getByRole('button', { name: /^cancel$/i })); await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); - expect(screen.getByLabelText(/message/i)).toHaveValue('Thank you. Tuesday works well.'); + expect(screen.getByRole('textbox', { name: copy('emailInboxMessage') })).toHaveValue('Thank you. Tuesday works well.'); expect(mockedApi.post).not.toHaveBeenCalled(); fireEvent.click(screen.getByRole('button', { name: /review and send/i })); fireEvent.click(screen.getByRole('button', { name: /^send email$/i })); @@ -315,8 +367,8 @@ describe('CorrespondenceInboxPage', () => { renderPage(); fireEvent.click(await screen.findByRole('button', { name: /resume saved subject/i })); - expect(screen.getByLabelText(/message/i)).toHaveValue('Saved private draft.'); - fireEvent.change(screen.getByLabelText(/message/i), { target: { value: 'Conflicting local edit.' } }); + expect(screen.getByRole('textbox', { name: copy('emailInboxMessage') })).toHaveValue('Saved private draft.'); + fireEvent.change(screen.getByRole('textbox', { name: copy('emailInboxMessage') }), { target: { value: 'Conflicting local edit.' } }); fireEvent.click(screen.getByRole('button', { name: /^save changes$/i })); await waitFor(() => expect(mockedApi.put).toHaveBeenCalledWith('/email/drafts/draft-1', { @@ -326,7 +378,7 @@ describe('CorrespondenceInboxPage', () => { bodyText: 'Conflicting local edit.', })); expect(await screen.findByText(/changed in another tab/i)).toBeInTheDocument(); - expect(screen.getByLabelText(/message/i)).toHaveValue('Conflicting local edit.'); + expect(screen.getByRole('textbox', { name: copy('emailInboxMessage') })).toHaveValue('Conflicting local edit.'); }); test('persists a new delivery identity only after a definitive failed attempt', async () => { @@ -400,7 +452,7 @@ describe('CorrespondenceInboxPage', () => { expect(screen.getByLabelText(/thread/i)).toHaveValue('New message'); fireEvent.change(screen.getByLabelText(/recipient/i), { target: { value: 'recruiter@acme.test' } }); fireEvent.change(screen.getByLabelText(/subject/i), { target: { value: 'Application question' } }); - fireEvent.change(screen.getByLabelText(/message/i), { target: { value: 'Hello from a synthetic draft.' } }); + fireEvent.change(screen.getByRole('textbox', { name: copy('emailInboxMessage') }), { target: { value: 'Hello from a synthetic draft.' } }); fireEvent.click(screen.getByRole('button', { name: /^save draft$/i })); await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith('/email/drafts', { @@ -456,7 +508,7 @@ describe('CorrespondenceInboxPage', () => { renderPage(); fireEvent.click(await screen.findByRole('button', { name: /view message/i })); fireEvent.click(await screen.findByRole('button', { name: /reply with connected provider/i })); - fireEvent.change(screen.getByLabelText(/message/i), { target: { value: 'Synthetic reply.' } }); + fireEvent.change(screen.getByRole('textbox', { name: copy('emailInboxMessage') }), { target: { value: 'Synthetic reply.' } }); fireEvent.click(screen.getByRole('button', { name: /review and send/i })); fireEvent.click(await screen.findByRole('button', { name: /^send email$/i })); @@ -506,4 +558,280 @@ describe('CorrespondenceInboxPage', () => { expect(await screen.findByRole('heading', { name: /recruitment message review/i })).toBeInTheDocument(); expect(mockedApi.get).not.toHaveBeenCalledWith('/correspondence/page', expect.anything()); }); + + test('failed inbox reads retain a current-language error and recover with one explicit GET-only Retry', async () => { + const original = mockedApi.get.getMockImplementation(); + const retry = deferred(); + let listReads = 0; + mockedApi.get.mockImplementation((url: string, config?: any) => { + if (url === '/correspondence/page') return ++listReads === 1 ? Promise.reject({ response: { status: 503 } }) : retry.promise; + return original!(url, config); + }); + renderPage(); + await screen.findByText(copy('emailInboxLoadFailed')); + await waitFor(() => expect(mockedApi.get).toHaveBeenCalledWith('/jobapplications/choices', expect.anything())); + expect(screen.queryByText(copy('emailInboxNoMatches'))).not.toBeInTheDocument(); + const reads = mockedApi.get.mock.calls.length; + await changeLanguage('nb'); + expect(mockedApi.get).toHaveBeenCalledTimes(reads); + const button = retryNotice('emailInboxLoadFailed').getByRole('button', { name: copy('retry') }); + act(() => { fireEvent.click(button); fireEvent.click(button); }); + expect(listReads).toBe(2); + expect(mockedApi.get).toHaveBeenLastCalledWith('/correspondence/page', { params: { q: undefined, direction: undefined, linkState: undefined, page: 1, pageSize: 50 } }); + expect(screen.getByRole('progressbar', { name: copy('loading') })).toBeInTheDocument(); + await act(async () => { retry.resolve({ data: pageData([messageItem()]) }); await retry.promise; }); + expect(await screen.findByText('Acme Systems', { exact: false })).toBeInTheDocument(); + expect(screen.queryByText(copy('emailInboxNoMatches'))).not.toBeInTheDocument(); + expect(mockedApi.post).not.toHaveBeenCalled(); + expect(mockedApi.put).not.toHaveBeenCalled(); + expect(mockedApi.patch).not.toHaveBeenCalled(); + expect(mockedApi.delete).not.toHaveBeenCalled(); + }); + + test('a late old page cannot overwrite the latest query and page-one inbox snapshot', async () => { + const original = mockedApi.get.getMockImplementation(); + const oldPage = deferred(); + mockedApi.get.mockImplementation((url: string, config?: any) => { + if (url === '/correspondence/page') { + const { q, page } = config.params; + if (q === 'A' && page === 2) return oldPage.promise; + return Promise.resolve({ data: pageData([messageItem({ subject: q === 'B' ? 'Current B query' : 'A query first page' })], 1, q === 'B' ? 1 : 51, q === 'B' ? 1 : 2) } as any); + } + return original!(url, config); + }); + renderPage(); + fireEvent.change(await screen.findByRole('textbox', { name: copy('correspondenceSearch') }), { target: { value: 'A' } }); + await screen.findByText('A query first page'); + fireEvent.click(screen.getByRole('button', { name: /go to page 2/i })); + await waitFor(() => expect(mockedApi.get).toHaveBeenCalledWith('/correspondence/page', { params: { q: 'A', direction: undefined, linkState: undefined, page: 2, pageSize: 50 } })); + fireEvent.change(screen.getByRole('textbox', { name: copy('correspondenceSearch') }), { target: { value: 'B' } }); + await screen.findByText('Current B query'); + await act(async () => { oldPage.resolve({ data: pageData([messageItem({ subject: 'Obsolete A page two' })], 2, 51, 2) }); await oldPage.promise; }); + expect(screen.getByText('Current B query')).toBeInTheDocument(); + expect(screen.queryByText('Obsolete A page two')).not.toBeInTheDocument(); + expect(screen.getByRole('textbox', { name: copy('correspondenceSearch') })).toHaveValue('B'); + expect(mockedApi.get).toHaveBeenLastCalledWith('/correspondence/page', { params: { q: 'B', direction: undefined, linkState: undefined, page: 1, pageSize: 50 } }); + expect(screen.queryByRole('button', { name: /go to page 2/i })).not.toBeInTheDocument(); + expect(mockedApi.post).not.toHaveBeenCalled(); + }); + + test('message Retry replays the captured owned detail read without hiding it and localizes saved-copy fallback', async () => { + const original = mockedApi.get.getMockImplementation(); + let savedReads = 0; + mockedApi.get.mockImplementation((url: string, config?: any) => { + if (url === '/email/message') return Promise.reject({ response: { status: 503 } }); + if (url === '/correspondence/message/1') return ++savedReads === 1 ? Promise.reject({ response: { status: 503 } }) : Promise.resolve({ data: { id: 'message-1', threadId: 'thread-1', subject: 'Saved subject', from: 'Maria', to: 'Owner', bodyText: 'Owned saved body', snippet: '', labels: ['INBOX'], attachments: [{ fileName: 'owned.pdf' }] } } as any); + return original!(url, config); + }); + renderPage(); + fireEvent.click(await screen.findByRole('button', { name: copy('emailInboxViewMessage') })); + await screen.findByText(copy('emailInboxMessageLoadFailed')); + const reads = mockedApi.get.mock.calls.length; + await changeLanguage('nb'); + expect(mockedApi.get).toHaveBeenCalledTimes(reads); + fireEvent.click(retryNotice('emailInboxMessageLoadFailed').getByRole('button', { name: copy('retry') })); + expect(await screen.findByText('Owned saved body')).toBeInTheDocument(); + expect(screen.getByText(copy('emailInboxProviderCopyUnavailable'))).toBeInTheDocument(); + expect(screen.getByRole('button', { name: copy('emailInboxHideMessage') })).toBeInTheDocument(); + expect(screen.getByText('owned.pdf')).toBeInTheDocument(); + const details = mockedApi.get.mock.calls.filter(([url]) => url === '/email/message' || url === '/correspondence/message/1'); + expect(details).toEqual([ + ['/email/message', { params: { provider: 'gmail', messageId: 'message-1' } }], ['/correspondence/message/1'], + ['/email/message', { params: { provider: 'gmail', messageId: 'message-1' } }], ['/correspondence/message/1'], + ]); + expect(mockedApi.post).not.toHaveBeenCalled(); + expect(mockedApi.put).not.toHaveBeenCalled(); + }); + + test('hidden or inactive details never resume a saved-copy fallback and hub return preserves inbox context', async () => { + const original = mockedApi.get.getMockImplementation(); + const detailA = deferred(); + const detailB = deferred(); + const items = [messageItem({ subject: 'Detail A' }), messageItem({ id: 2, jobApplicationId: 43, subject: 'Detail B', externalThreadId: 'thread-2', externalMessageId: 'message-2' })]; + mockedApi.get.mockImplementation((url: string, config?: any) => { + if (url === '/correspondence/page') return Promise.resolve({ data: pageData(items, config.params.page, 51, 2) } as any); + if (url === '/email/message') return config.params.messageId === 'message-1' ? detailA.promise : detailB.promise; + return original!(url, config); + }); + renderPage(); + fireEvent.change(await screen.findByRole('textbox', { name: copy('correspondenceSearch') }), { target: { value: 'Recruiter' } }); + await screen.findByText('Detail B'); + fireEvent.click(screen.getByRole('button', { name: /go to page 2/i })); + await waitFor(() => expect(mockedApi.get).toHaveBeenCalledWith('/correspondence/page', { params: { q: 'Recruiter', direction: undefined, linkState: undefined, page: 2, pageSize: 50 } })); + fireEvent.click(messageRow('Detail A').getByRole('button', { name: copy('emailInboxViewMessage') })); + fireEvent.click(messageRow('Detail A').getByRole('button', { name: copy('emailInboxHideMessage') })); + await act(async () => { detailA.reject({ response: { status: 503 } }); await detailA.promise.catch(() => undefined); }); + expect(mockedApi.get).not.toHaveBeenCalledWith('/correspondence/message/1'); + fireEvent.click(messageRow('Detail B').getByRole('button', { name: copy('emailInboxViewMessage') })); + fireEvent.click(screen.getByRole('button', { name: copy('emailInboxReviewSuggestions') })); + await screen.findByRole('heading', { name: copy('gmailReviewEmbeddedTitle') }); + await act(async () => { detailB.reject({ response: { status: 503 } }); await detailB.promise.catch(() => undefined); }); + expect(mockedApi.get).not.toHaveBeenCalledWith('/correspondence/message/2'); + fireEvent.click(screen.getByRole('button', { name: copy('emailInboxLinkedMessages') })); + expect(await screen.findByRole('textbox', { name: copy('correspondenceSearch') })).toHaveValue('Recruiter'); + expect(await screen.findByText('Detail B')).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: copy('emailInboxHideMessage') })).not.toBeInTheDocument(); + expect(mockedApi.get).toHaveBeenLastCalledWith('/correspondence/page', { params: { q: 'Recruiter', direction: undefined, linkState: undefined, page: 2, pageSize: 50 } }); + expect(mockedApi.post).not.toHaveBeenCalled(); + }); + + test('provider status Retry is isolated from saved messages and drafts and live locale changes cause no reads', async () => { + const original = mockedApi.get.getMockImplementation(); + const providerRetry = deferred(); + let providerReads = 0; + mockedApi.get.mockImplementation((url: string, config?: any) => { + if (url === '/email/providers') return ++providerReads === 1 ? Promise.reject({ response: { status: 503 } }) : providerRetry.promise; + if (url === '/correspondence/page') return Promise.resolve({ data: pageData([messageItem()]) } as any); + if (url === '/email/drafts') return Promise.resolve({ data: [{ id: 'owned-draft', revision: 3, jobApplicationId: 42, provider: 'gmail', to: 'recruiter@acme.test', subject: 'Owned saved draft', bodyText: 'Private draft', threadId: 'thread-1', clientRequestId: 'owned-request', createdAtUtc: '2026-02-13T11:30:00Z', updatedAtUtc: '2026-02-13T11:30:00Z' }] } as any); + return original!(url, config); + }); + renderPage(); + await screen.findByText(copy('emailInboxProviderStatusUnavailable')); + await screen.findByRole('button', { name: /resume owned saved draft/i }); + await waitFor(() => expect(mockedApi.get).toHaveBeenCalledWith('/jobapplications/choices', expect.anything())); + const reads = mockedApi.get.mock.calls.length; + await changeLanguage('nb'); + expect(mockedApi.get).toHaveBeenCalledTimes(reads); + expect(screen.getByText(new Date('2026-02-13T11:30:00Z').toLocaleString('nb-NO'), { exact: false })).toBeInTheDocument(); + const button = retryNotice('emailInboxProviderStatusUnavailable').getByRole('button', { name: copy('retry') }); + act(() => { fireEvent.click(button); fireEvent.click(button); }); + expect(mockedApi.get).toHaveBeenCalledTimes(reads + 1); + expect(mockedApi.get).toHaveBeenLastCalledWith('/email/providers'); + await act(async () => { providerRetry.resolve({ data: [{ provider: 'gmail', displayName: 'Gmail', connected: true, address: 'owner@gmail.test', canRead: true, canSend: false }] }); await providerRetry.promise; }); + expect(await screen.findByText(/Gmail: owner@gmail\.test.*Kun lesing/)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: copy('emailInboxResumeDraft', { name: 'Owned saved draft' }) })).toBeInTheDocument(); + expect(screen.getByText('Interview invite')).toBeInTheDocument(); + expect(mockedApi.post).not.toHaveBeenCalled(); + expect(mockedApi.delete).not.toHaveBeenCalled(); + }); + + test('confirmed unlink owns pending actions and a failed successful-write refresh recovers with GET only', async () => { + const original = mockedApi.get.getMockImplementation(); + const unlink = deferred(); + let refreshFails = false; + const items = [messageItem({ subject: 'Unlink A' }), messageItem({ id: 2, jobApplicationId: 43, subject: 'Unlink B', externalThreadId: 'thread-2', externalMessageId: 'message-2' })]; + mockedApi.get.mockImplementation((url: string, config?: any) => url === '/correspondence/page' + ? refreshFails ? Promise.reject({ response: { status: 503 } }) : Promise.resolve({ data: pageData(items) } as any) + : original!(url, config)); + mockedApi.post.mockReturnValue(unlink.promise); + renderPage(); + fireEvent.click((await screen.findAllByRole('button', { name: copy('emailInboxUnlinkThread') }))[0]); + const dialog = await screen.findByRole('dialog'); + expect(mockedApi.post).not.toHaveBeenCalled(); + fireEvent.click(within(dialog).getByRole('button', { name: copy('emailInboxUnlinkThread') })); + await waitFor(() => expect(mockedApi.post).toHaveBeenCalledTimes(1)); + expect(mockedApi.post).toHaveBeenCalledWith('/gmail/unlink-thread', { jobApplicationId: 42, threadId: 'thread-1', note: 'Unlinked from Job email hub', nextDecision: 'review' }); + await waitFor(() => expect(messageRow('Unlink B').getByRole('button', { name: copy('emailInboxUnlinkThread') })).toBeInTheDocument()); + expect(messageRow('Unlink B').getByRole('button', { name: copy('emailInboxUnlinkThread') })).toBeDisabled(); + fireEvent.click(messageRow('Unlink B').getByRole('button', { name: copy('emailInboxUnlinkThread') })); + expect(mockedApi.post).toHaveBeenCalledTimes(1); + refreshFails = true; + await act(async () => { unlink.resolve({ data: { threadId: 'thread-1', jobApplicationId: 42, removedMessages: 1, decision: 'review' } }); await unlink.promise; }); + await waitFor(() => expect(mockedApi.get.mock.calls.filter(([url]) => url === '/correspondence/page')).toHaveLength(2)); + expect(screen.getByText('Unlink B')).toBeInTheDocument(); + refreshFails = false; + fireEvent.click(retryNotice('emailInboxLoadFailed').getByRole('button', { name: copy('retry') })); + await waitFor(() => expect(mockedApi.get.mock.calls.filter(([url]) => url === '/correspondence/page')).toHaveLength(3)); + expect(mockedApi.post).toHaveBeenCalledTimes(1); + expect(mockedApi.put).not.toHaveBeenCalled(); + expect(mockedApi.delete).not.toHaveBeenCalled(); + }); + + test('literal all search preserves source and hash and hides another query snapshot while retaining same-query refresh data', async () => { + const original = mockedApi.get.getMockImplementation(); + const refreshRecruiter = deferred(); + const allQuery = deferred(); + let recruiterReads = 0; + let allReads = 0; + mockedApi.get.mockImplementation((url: string, config?: any) => { + if (url === '/correspondence/page') { + if (config.params.q === 'Recruiter') return ++recruiterReads === 1 + ? Promise.resolve({ data: pageData([messageItem({ subject: 'Old Recruiter snapshot' })], 1, 51, 2) } as any) + : refreshRecruiter.promise; + if (config.params.q === 'all') { + if (++allReads === 1) return allQuery.promise; + return Promise.resolve({ data: pageData([messageItem({ subject: config.params.page === 2 ? 'All query page two' : 'Loaded literal all snapshot' })], config.params.page, 75, 2) } as any); + } + return Promise.resolve({ data: pageData([messageItem({ subject: 'Unexpected missing search' })]) } as any); + } + return original!(url, config); + }); + const location = () => new URL(screen.getByTestId('inbox-location').textContent!, 'http://localhost'); + renderPage('/correspondence?q=Recruiter&source=workflow#inbox'); + await screen.findByText('Old Recruiter snapshot'); + expect(screen.getByRole('textbox', { name: copy('correspondenceSearch') })).toHaveValue('Recruiter'); + fireEvent.click(screen.getByRole('button', { name: copy('refresh') })); + expect(recruiterReads).toBe(2); + expect(screen.getByText('Old Recruiter snapshot')).toBeInTheDocument(); + expect(screen.getByText(copy('emailInboxItems', { count: 51 }))).toBeInTheDocument(); + await act(async () => { refreshRecruiter.resolve({ data: pageData([messageItem({ subject: 'Old Recruiter snapshot' })], 1, 51, 2) }); await refreshRecruiter.promise; }); + + fireEvent.change(screen.getByRole('textbox', { name: copy('correspondenceSearch') }), { target: { value: 'all' } }); + expect(screen.getByRole('textbox', { name: copy('correspondenceSearch') })).toHaveValue('all'); + expect(location().searchParams.get('q')).toBe('all'); + expect(location().searchParams.get('source')).toBe('workflow'); + expect(location().hash).toBe('#inbox'); + expect(mockedApi.get).toHaveBeenLastCalledWith('/correspondence/page', { params: { q: 'all', direction: undefined, linkState: undefined, page: 1, pageSize: 50 } }); + expect(screen.queryByText('Old Recruiter snapshot')).not.toBeInTheDocument(); + expect(screen.queryByText(copy('emailInboxItems', { count: 51 }))).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: copy('emailInboxViewMessage') })).not.toBeInTheDocument(); + await act(async () => { allQuery.reject({ response: { status: 503 } }); await allQuery.promise.catch(() => undefined); }); + expect(screen.queryByText('Old Recruiter snapshot')).not.toBeInTheDocument(); + expect(screen.queryByText(copy('emailInboxItems', { count: 51 }))).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: copy('emailInboxUnlinkThread') })).not.toBeInTheDocument(); + fireEvent.click(retryNotice('emailInboxLoadFailed').getByRole('button', { name: copy('retry') })); + await screen.findByText('Loaded literal all snapshot'); + expect(screen.getByText(copy('emailInboxItems', { count: 75 }))).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: copy('emailInboxReviewSuggestions') })); + expect(location().searchParams.get('q')).toBe('all'); + expect(location().searchParams.get('source')).toBe('workflow'); + expect(location().hash).toBe('#inbox'); + fireEvent.click(screen.getByRole('button', { name: copy('emailInboxLinkedMessages') })); + await screen.findByText('Loaded literal all snapshot'); + expect(location().hash).toBe('#inbox'); + fireEvent.click(screen.getByRole('button', { name: /go to page 2/i })); + await screen.findByText('All query page two'); + expect(location().searchParams.get('page')).toBe('2'); + expect(location().searchParams.get('q')).toBe('all'); + expect(location().searchParams.get('source')).toBe('workflow'); + expect(location().hash).toBe('#inbox'); + expect(mockedApi.post).not.toHaveBeenCalled(); + expect(mockedApi.put).not.toHaveBeenCalled(); + expect(mockedApi.patch).not.toHaveBeenCalled(); + expect(mockedApi.delete).not.toHaveBeenCalled(); + }); + + test('typing an inbox search keeps one natural Back step to the prior screen and preserves filter context', async () => { + render( + + + + + + Previous screen} /> + } /> + + + , + ); + const search = await screen.findByRole('textbox', { name: copy('correspondenceSearch') }); + await screen.findByText('Interview invite'); + for (const text of ['A', 'Ac', 'Acm', 'Acme']) fireEvent.change(search, { target: { value: text } }); + await waitFor(() => expect(mockedApi.get).toHaveBeenCalledWith('/correspondence/page', { params: { q: 'Acme', direction: 'inbound', linkState: 'linked', page: 1, pageSize: 50 } })); + const current = new URL(screen.getByTestId('inbox-location').textContent!, 'http://localhost'); + expect(current.searchParams.get('q')).toBe('Acme'); + expect(current.searchParams.get('source')).toBe('workflow'); + expect(current.searchParams.get('direction')).toBe('inbound'); + expect(current.searchParams.get('linkState')).toBe('linked'); + expect(current.searchParams.get('page')).toBeNull(); + expect(current.hash).toBe('#inbox'); + fireEvent.click(screen.getByRole('button', { name: 'History Back' })); + expect(await screen.findByRole('heading', { name: 'Previous screen' })).toBeInTheDocument(); + expect(screen.getByTestId('inbox-location')).toHaveTextContent('/previous-screen'); + expect(screen.queryByRole('textbox', { name: copy('correspondenceSearch') })).not.toBeInTheDocument(); + expect(mockedApi.post).not.toHaveBeenCalled(); + expect(mockedApi.put).not.toHaveBeenCalled(); + expect(mockedApi.patch).not.toHaveBeenCalled(); + expect(mockedApi.delete).not.toHaveBeenCalled(); + }); }); diff --git a/job-tracker-ui/src/gmail-review-page.test.tsx b/job-tracker-ui/src/gmail-review-page.test.tsx index 45cd20c..5b23b27 100644 --- a/job-tracker-ui/src/gmail-review-page.test.tsx +++ b/job-tracker-ui/src/gmail-review-page.test.tsx @@ -1,11 +1,12 @@ import React from 'react'; import '@testing-library/jest-dom'; -import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; import { MemoryRouter } from 'react-router-dom'; import { ToastProvider } from './toast'; import { I18nProvider } from './i18n/I18nProvider'; import GmailReviewPage from './views/GmailReviewPage'; import { api } from './api'; +import { translations, type UiLanguage } from './i18n/translations'; jest.mock('./api', () => ({ api: { @@ -21,6 +22,39 @@ jest.mock('./api', () => ({ const mockedApi = api as jest.Mocked; +function copy(key: string, params: Record = {}, language: UiLanguage = document.documentElement.lang === 'nb-NO' ? 'nb' : 'en') { + return ((translations[language] as Record)[key] ?? key).replace(/\{(\w+)\}/g, (_, name) => String(params[name] ?? `{${name}}`)); +} + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (reason: unknown) => void; + const promise = new Promise((yes, no) => { resolve = yes; reject = no; }); + return { promise, resolve, reject }; +} + +async function changeLanguage(language: UiLanguage) { + await act(async () => { window.dispatchEvent(new StorageEvent('storage', { key: 'uiLanguage', newValue: language })); }); +} + +function retryNotice(key: string) { + const alert = screen.getAllByRole('alert').find((node) => node.textContent?.includes(copy(key)) && within(node).queryByRole('button', { name: copy('retry') })); + expect(alert).toBeDefined(); + return within(alert!); +} + +function reviewThread(overrides: Record = {}) { + return { threadId: 'thread-1', subject: 'Owned review thread', latestDate: '2026-02-13T11:30:00Z', messageCount: 2, routing: 'review', hasImportedMessages: false, decisionNote: 'Recorded review note', matchedQueries: ['"Acme" "Backend Developer"'], jobCandidates: [{ jobApplicationId: 42, jobTitle: 'Backend Developer', companyName: 'Acme', score: 24, confidence: 'medium', reasons: [{ label: 'company', value: 'Acme', points: 18 }] }], messages: [], ...overrides }; +} + +function reviewQueue(threads = [reviewThread()]) { + return { queries: [], candidateThreadCount: threads.length, autoLinkThreadCount: 0, reviewThreadCount: threads.length, unmatchedThreadCount: 0, threads }; +} + +function threadRow(subject: string) { + return within(screen.getByText(subject, { exact: true }).closest('.MuiPaper-root') as HTMLElement); +} + function renderPage() { return render( @@ -36,6 +70,13 @@ function renderPage() { describe('GmailReviewPage', () => { beforeEach(() => { window.localStorage.clear(); + document.documentElement.lang = 'en'; + jest.clearAllMocks(); + mockedApi.get.mockReset(); + mockedApi.post.mockReset(); + mockedApi.put.mockReset(); + mockedApi.patch.mockReset(); + mockedApi.delete.mockReset(); mockedApi.get.mockImplementation((url: string) => { if (url === '/gmail/review-candidates') { return Promise.resolve({ @@ -112,4 +153,134 @@ describe('GmailReviewPage', () => { expect(screen.getByRole('button', { name: 'Knytt til beste jobbtreff' })).toBeInTheDocument(); expect(screen.getByLabelText('Kontrollnotater')).toBeInTheDocument(); }); + + test('failed paired review reads have durable current-language recovery and one explicit GET-only Retry cycle', async () => { + const queueRetry = deferred(); + const suggestedRetry = deferred(); + let queueReads = 0; + let suggestedReads = 0; + mockedApi.get.mockImplementation((url: string) => { + if (url === '/gmail/review-candidates') return ++queueReads === 1 ? Promise.reject({ response: { status: 503 } }) : queueRetry.promise; + if (url === '/gmail/suggested-jobs') return ++suggestedReads === 1 ? Promise.resolve({ data: { count: 0, items: [] } } as any) : suggestedRetry.promise; + return Promise.reject(new Error(`Unexpected GET ${url}`)); + }); + renderPage(); + await screen.findByText(copy('gmailReviewLoadFailed')); + expect(screen.queryByText(copy('gmailReviewNoCandidates'))).not.toBeInTheDocument(); + const reads = mockedApi.get.mock.calls.length; + await changeLanguage('nb'); + expect(mockedApi.get).toHaveBeenCalledTimes(reads); + const button = retryNotice('gmailReviewLoadFailed').getByRole('button', { name: copy('retry') }); + act(() => { fireEvent.click(button); fireEvent.click(button); }); + expect(queueReads).toBe(2); + expect(suggestedReads).toBe(2); + await act(async () => { queueRetry.resolve({ data: reviewQueue() }); await queueRetry.promise; }); + expect(screen.getByRole('progressbar', { name: copy('loading') })).toBeInTheDocument(); + await act(async () => { suggestedRetry.resolve({ data: { count: 0, items: [] } }); await suggestedRetry.promise; }); + expect(await screen.findByText('Owned review thread')).toBeInTheDocument(); + expect(mockedApi.post).not.toHaveBeenCalled(); + expect(mockedApi.put).not.toHaveBeenCalled(); + expect(mockedApi.delete).not.toHaveBeenCalled(); + }); + + test('review notes round-trip without losing edits to filtering, refresh, failed explicit saving or locale changes', async () => { + let serverNote = 'Recorded review note'; + let writeFails = true; + mockedApi.get.mockImplementation((url: string) => Promise.resolve({ data: url === '/gmail/review-candidates' + ? reviewQueue([reviewThread({ decisionNote: serverNote })]) : { count: 0, items: [] } } as any)); + mockedApi.post.mockImplementation((url: string, body?: any) => { + expect(url).toBe('/gmail/review-decision'); + if (writeFails) return Promise.reject({ response: { status: 503 } }); + serverNote = body.note; + return Promise.resolve({ data: { threadId: body.threadId, decision: body.decision, jobApplicationId: body.jobApplicationId, note: body.note } } as any); + }); + const mounted = renderPage(); + const note = await screen.findByRole('textbox', { name: copy('gmailReviewNotes') }); + expect(note).toHaveValue('Recorded review note'); + fireEvent.change(note, { target: { value: ' Unsaved owned review note ' } }); + fireEvent.click(screen.getByRole('button', { name: copy('gmailReviewRouteRejected') })); + expect(screen.queryByRole('textbox', { name: copy('gmailReviewNotes') })).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: copy('gmailReviewRouteAll') })); + expect(screen.getByRole('textbox', { name: copy('gmailReviewNotes') })).toHaveValue(' Unsaved owned review note '); + serverNote = 'Changed saved server note'; + fireEvent.click(screen.getByRole('button', { name: copy('correspondenceRefresh') })); + await waitFor(() => expect(mockedApi.get.mock.calls.filter(([url]) => url === '/gmail/review-candidates')).toHaveLength(2)); + await waitFor(() => expect(screen.getByRole('button', { name: copy('correspondenceRefresh') })).toBeEnabled()); + expect(screen.getByRole('textbox', { name: copy('gmailReviewNotes') })).toHaveValue(' Unsaved owned review note '); + fireEvent.click(screen.getByRole('button', { name: copy('gmailReviewKeepInReview') })); + await screen.findByText(copy('gmailReviewSaveFailed')); + expect(screen.getByRole('textbox', { name: copy('gmailReviewNotes') })).toHaveValue(' Unsaved owned review note '); + const reads = mockedApi.get.mock.calls.length; + await changeLanguage('nb'); + expect(mockedApi.get).toHaveBeenCalledTimes(reads); + expect(screen.getByRole('textbox', { name: copy('gmailReviewNotes') })).toHaveValue(' Unsaved owned review note '); + writeFails = false; + fireEvent.click(screen.getByRole('button', { name: copy('gmailReviewKeepInReview') })); + await waitFor(() => expect(mockedApi.post).toHaveBeenCalledTimes(2)); + expect(mockedApi.post).toHaveBeenNthCalledWith(1, '/gmail/review-decision', { threadId: 'thread-1', decision: 'review', jobApplicationId: null, note: 'Unsaved owned review note' }); + expect(mockedApi.post).toHaveBeenNthCalledWith(2, '/gmail/review-decision', { threadId: 'thread-1', decision: 'review', jobApplicationId: null, note: 'Unsaved owned review note' }); + await waitFor(() => expect(screen.getByRole('button', { name: copy('gmailReviewKeepInReview') })).toBeEnabled()); + mounted.unmount(); + renderPage(); + expect(await screen.findByRole('textbox', { name: copy('gmailReviewNotes') })).toHaveValue('Unsaved owned review note'); + expect(mockedApi.put).not.toHaveBeenCalled(); + }); + + test('decision, manual sync and suggested creation each own their captured request against duplicate and competing actions', async () => { + const second = reviewThread({ threadId: 'thread-2', subject: 'Other owned thread' }); + const suggestion = { threadId: 'thread-1', companyName: 'Acme', suggestedJobTitle: 'Backend Developer', subject: 'Suggestion subject', recruiterName: 'Maria', recruiterEmail: 'maria@acme.test', preview: 'Recorded preview' }; + mockedApi.get.mockImplementation((url: string) => Promise.resolve({ data: url === '/gmail/review-candidates' ? reviewQueue([reviewThread(), second]) : { count: 1, items: [suggestion] } } as any)); + renderPage(); + await screen.findByText('Owned review thread'); + const operations = [ + { key: 'decision', url: '/gmail/review-decision', body: { threadId: 'thread-1', decision: 'linked', jobApplicationId: 42, note: 'Captured note' } }, + { key: 'sync', url: '/gmail/manual-sync', body: { lookbackDays: 365, maxResultsPerQuery: 8, autoImportHighConfidence: true, includeSpamTrash: false } }, + { key: 'create', url: '/gmail/create-suggested-job', body: { threadId: 'thread-1', companyName: 'Acme', jobTitle: 'Backend Developer', recruiterName: 'Maria', recruiterEmail: 'maria@acme.test', notes: 'Captured note', status: 'Applied' } }, + ]; + for (const operation of operations) { + mockedApi.post.mockReset(); + const held = deferred(); + mockedApi.post.mockReturnValue(held.promise); + fireEvent.change(threadRow('Owned review thread').getByRole('textbox', { name: copy('gmailReviewNotes') }), { target: { value: ' Captured note ' } }); + const primary = operation.key === 'decision' ? threadRow('Owned review thread').getByRole('button', { name: copy('gmailReviewLinkTopJob') }) + : operation.key === 'sync' ? screen.getByRole('button', { name: copy('gmailReviewRunSync') }) + : threadRow('Owned review thread').getByRole('button', { name: copy('createJob') }); + const otherDecision = threadRow('Other owned thread').getByRole('button', { name: copy('gmailReviewKeepInReview') }); + const sync = screen.getByRole('button', { name: copy('gmailReviewRunSync') }); + const create = threadRow('Owned review thread').getByRole('button', { name: copy('createJob') }); + act(() => { fireEvent.click(primary); fireEvent.click(primary); fireEvent.click(otherDecision); fireEvent.click(sync); fireEvent.click(create); }); + expect(mockedApi.post).toHaveBeenCalledTimes(1); + expect(mockedApi.post).toHaveBeenCalledWith(operation.url, operation.body); + expect(otherDecision).toBeDisabled(); + expect(sync).toBeDisabled(); + expect(create).toBeDisabled(); + await act(async () => { held.reject({ response: { status: 503 } }); await held.promise.catch(() => undefined); }); + await waitFor(() => expect(threadRow('Other owned thread').getByRole('button', { name: copy('gmailReviewKeepInReview') })).toBeEnabled()); + expect(threadRow('Owned review thread').getByRole('textbox', { name: copy('gmailReviewNotes') })).toHaveValue(' Captured note '); + } + expect(mockedApi.post.mock.calls.some(([url]) => url === '/email/send')).toBe(false); + expect(mockedApi.put).not.toHaveBeenCalled(); + expect(mockedApi.delete).not.toHaveBeenCalled(); + }); + + test('an already-saved decision with failed paired refresh recovers reads without duplicating the mutation', async () => { + let readFails = false; + mockedApi.get.mockImplementation((url: string) => readFails ? Promise.reject({ response: { status: 503 } }) : Promise.resolve({ data: url === '/gmail/review-candidates' ? reviewQueue() : { count: 0, items: [] } } as any)); + mockedApi.post.mockImplementation(() => { readFails = true; return Promise.resolve({ data: { threadId: 'thread-1', decision: 'review', jobApplicationId: null, note: 'Edited review note' } } as any); }); + renderPage(); + fireEvent.change(await screen.findByRole('textbox', { name: copy('gmailReviewNotes') }), { target: { value: 'Edited review note' } }); + fireEvent.click(screen.getByRole('button', { name: copy('gmailReviewKeepInReview') })); + await waitFor(() => expect(mockedApi.get).toHaveBeenCalledTimes(4)); + expect(mockedApi.post).toHaveBeenCalledTimes(1); + expect(screen.getByRole('textbox', { name: copy('gmailReviewNotes') })).toHaveValue('Edited review note'); + expect(screen.queryByText(copy('gmailReviewSaveFailed'))).not.toBeInTheDocument(); + readFails = false; + fireEvent.click(retryNotice('gmailReviewLoadFailed').getByRole('button', { name: copy('retry') })); + await waitFor(() => expect(mockedApi.get).toHaveBeenCalledTimes(6)); + expect(mockedApi.get.mock.calls.slice(-2)).toEqual([['/gmail/review-candidates'], ['/gmail/suggested-jobs']]); + expect(mockedApi.post).toHaveBeenCalledTimes(1); + expect(mockedApi.post).toHaveBeenCalledWith('/gmail/review-decision', { threadId: 'thread-1', decision: 'review', jobApplicationId: null, note: 'Edited review note' }); + expect(screen.getByText('Owned review thread')).toBeInTheDocument(); + expect(mockedApi.put).not.toHaveBeenCalled(); + }); }); diff --git a/job-tracker-ui/src/i18n/translations.ts b/job-tracker-ui/src/i18n/translations.ts index 04ad14f..df25b2e 100644 --- a/job-tracker-ui/src/i18n/translations.ts +++ b/job-tracker-ui/src/i18n/translations.ts @@ -49,7 +49,9 @@ export const translations = { emailInboxSubtitle: "Review linked correspondence and suggested recruitment messages in one place.", emailInboxItems: "{count} items", emailInboxLinked: "{count} linked", + emailInboxLinkedOnPage: "{count} linked on this page", emailInboxInboundCount: "{count} inbound", + emailInboxInboundOnPage: "{count} inbound on this page", emailInboxLinkedMessages: "Linked messages", emailInboxReviewSuggestions: "Review suggestions", emailInboxCompose: "Compose new email", @@ -1949,6 +1951,7 @@ export const translations = { correspondenceMessagesCount: "{count} messages", correspondenceAlreadyLinked: "Already linked", gmailReviewLoadFailed: "Failed to load Gmail review candidates.", + gmailReviewNotConnected: "Connect Gmail in Connected accounts to review your messages.", gmailReviewLinked: "Thread linked and imported.", gmailReviewRejected: "Thread rejected from review.", gmailReviewKeptSuggested: "Thread kept as suggested job material.", @@ -2703,7 +2706,9 @@ export const translations = { emailInboxSubtitle: "Se tilknyttet korrespondanse og foreslåtte rekrutteringsmeldinger på ett sted.", emailInboxItems: "{count} elementer", emailInboxLinked: "{count} tilknyttet", + emailInboxLinkedOnPage: "{count} tilknyttet på denne siden", emailInboxInboundCount: "{count} innkommende", + emailInboxInboundOnPage: "{count} innkommende på denne siden", emailInboxLinkedMessages: "Tilknyttede meldinger", emailInboxReviewSuggestions: "Gjennomgå forslag", emailInboxCompose: "Skriv ny e-post", @@ -4603,6 +4608,7 @@ export const translations = { correspondenceMessagesCount: "{count} meldinger", correspondenceAlreadyLinked: "Allerede tilknyttet", gmailReviewLoadFailed: "Kunne ikke laste kandidater til Gmail-kontroll.", + gmailReviewNotConnected: "Koble til Gmail under Tilkoblede kontoer for å gjennomgå meldingene dine.", gmailReviewLinked: "Tråden ble koblet til og importert.", gmailReviewRejected: "Tråden ble avvist fra kontrollen.", gmailReviewKeptSuggested: "Tråden ble beholdt som materiale til en foreslått jobb.", diff --git a/job-tracker-ui/src/views/CorrespondenceInboxPage.tsx b/job-tracker-ui/src/views/CorrespondenceInboxPage.tsx index c04c252..28828fa 100644 --- a/job-tracker-ui/src/views/CorrespondenceInboxPage.tsx +++ b/job-tracker-ui/src/views/CorrespondenceInboxPage.tsx @@ -1,11 +1,10 @@ -import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { useNavigate, useSearchParams } from "react-router-dom"; +import React, { useCallback, useEffect, useId, useMemo, useRef, useState } from "react"; +import { useLocation, useNavigate, useSearchParams } from "react-router-dom"; import { Box, Alert, Autocomplete, Chip, - CircularProgress, FormControl, InputLabel, MenuItem, @@ -23,6 +22,9 @@ import { useConfirm } from "../confirm"; import { useToast } from "../toast"; import { useI18n } from "../i18n/I18nProvider"; import GmailReviewPage from "./GmailReviewPage"; +import ViewStateNotice from "../components/ViewStateNotice"; +import { PageSection } from "../layout/PageLayout"; +import type { ViewResourceError } from "../hooks/useViewResource"; export type CorrespondenceInboxItem = { id: number; @@ -112,15 +114,23 @@ type EmailSendResult = { failureCategory?: string | null; }; +function readError(error: any): ViewResourceError { + const status = error?.response?.status as number | undefined; + const unauthorized = status === 401 || status === 403; + return { kind: unauthorized ? "unauthorized" : "error", status, retryable: !unauthorized, message: getApiErrorMessage(error, "") }; +} + export default function CorrespondenceInboxPage() { const navigate = useNavigate(); - const [searchParams, setSearchParams] = useSearchParams(); + const location = useLocation(); + const [searchParams] = useSearchParams(); const view = searchParams.get("view") === "review" ? "review" : "inbox"; const { toast } = useToast(); const { confirm } = useConfirm(); const { language, t } = useI18n(); const [items, setItems] = useState([]); - const [inboxPage, setInboxPage] = useState(1); + const page = Number(searchParams.get("page")); + const inboxPage = Number.isInteger(page) && page > 0 && page <= 2147483647 ? page : 1; const [inboxTotal, setInboxTotal] = useState(0); const [inboxTotalPages, setInboxTotalPages] = useState(0); const [providers, setProviders] = useState([]); @@ -133,16 +143,35 @@ export default function CorrespondenceInboxPage() { const [composeJobId, setComposeJobId] = useState(""); const [composeProvider, setComposeProvider] = useState(""); const [providerStatusError, setProviderStatusError] = useState(false); + const [providerError, setProviderError] = useState(null); + const [providersLoading, setProvidersLoading] = useState(false); + const providerPending = useRef(false); const [selectedMessageId, setSelectedMessageId] = useState(null); const [messageDetail, setMessageDetail] = useState(null); const [detailLoading, setDetailLoading] = useState(false); - const [detailError, setDetailError] = useState(null); - const [detailNotice, setDetailNotice] = useState(null); + const [detailError, setDetailError] = useState(null); + const [detailNotice, setDetailNotice] = useState(false); const detailRequest = useRef(0); + const detailItem = useRef(null); + const detailPending = useRef(false); + const detailPanel = useRef(null); + const inboxPanel = useRef(null); + const providerPanel = useRef(null); + const rowId = useId(); const [loading, setLoading] = useState(false); - const [query, setQuery] = useState(""); - const [direction, setDirection] = useState("all"); - const [linkState, setLinkState] = useState("all"); + const [loadError, setLoadError] = useState(null); + const [hasLoaded, setHasLoaded] = useState(false); + const listRequest = useRef(0); + const pendingList = useRef(null); + const [loadedListKey, setLoadedListKey] = useState(null); + const query = searchParams.get("q") ?? ""; + const direction = ["inbound", "outbound", "internal"].includes(searchParams.get("direction") ?? "") ? searchParams.get("direction")! : "all"; + const linkState = ["linked", "manual"].includes(searchParams.get("linkState") ?? "") ? searchParams.get("linkState")! : "all"; + const currentListKey = JSON.stringify([query.trim(), direction, linkState, inboxPage]); + const hasCurrentInbox = loadedListKey === currentListKey && loadError?.kind !== "unauthorized"; + const [reviewVisited, setReviewVisited] = useState(view === "review"); + const mounted = useRef(true); + const [draftsReadError, setDraftsReadError] = useState(false); const [draft, setDraft] = useState(null); const [storedDrafts, setStoredDrafts] = useState([]); const [savingDraft, setSavingDraft] = useState(false); @@ -151,9 +180,33 @@ export default function CorrespondenceInboxPage() { const [sendError, setSendError] = useState(null); const [sending, setSending] = useState(false); const [unlinkingThreadId, setUnlinkingThreadId] = useState(null); + const unlinkPending = useRef(false); + const [unlinkError, setUnlinkError] = useState(null); + + const updateFilters = useCallback((changes: Record) => { + const next = new URLSearchParams(searchParams); + for (const [key, value] of Object.entries(changes)) { + if (!value || (value === "all" && (key === "direction" || key === "linkState")) || (key === "page" && value === "1") || (key === "view" && value === "inbox")) next.delete(key); + else next.set(key, value); + } + navigate({ pathname: location.pathname, search: `?${next}`, hash: location.hash }, { state: location.state, replace: Object.hasOwn(changes, "q") }); + }, [location.hash, location.pathname, location.state, navigate, searchParams]); + const latestFilters = useRef(updateFilters); + latestFilters.current = updateFilters; + + useEffect(() => { + mounted.current = true; + return () => { mounted.current = false; listRequest.current += 1; detailRequest.current += 1; }; + }, []); const load = useCallback(async () => { + if (view !== "inbox") return; + const key = JSON.stringify([query.trim(), direction, linkState, inboxPage]); + if (pendingList.current === key) return; + const request = ++listRequest.current; + pendingList.current = key; setLoading(true); + setLoadError(null); try { const res = await api.get("/correspondence/page", { params: { @@ -164,35 +217,63 @@ export default function CorrespondenceInboxPage() { pageSize: 50, }, }); + if (!mounted.current || request !== listRequest.current) return; setItems(res.data?.items ?? []); setInboxTotal(res.data?.total ?? 0); setInboxTotalPages(res.data?.totalPages ?? 0); - if (res.data?.page && res.data.page !== inboxPage) setInboxPage(res.data.page); + setLoadedListKey(key); + if (res.data?.page && res.data.page !== inboxPage) latestFilters.current({ page: String(res.data.page) }); + setHasLoaded(true); + detailRequest.current += 1; + detailItem.current = null; + detailPending.current = false; setSelectedMessageId(null); setMessageDetail(null); } catch (error) { - toast(getApiErrorMessage(error, t("emailInboxLoadFailed")), "error"); + if (mounted.current && request === listRequest.current) setLoadError(readError(error)); } finally { - setLoading(false); + if (mounted.current && request === listRequest.current) { pendingList.current = null; setLoading(false); } } - }, [direction, inboxPage, linkState, query, t, toast]); + }, [direction, inboxPage, linkState, query, view]); + const latestLoad = useRef(load); + latestLoad.current = load; useEffect(() => { if (view === "inbox") void load(); + else setReviewVisited(true); + return () => { + listRequest.current += 1; + pendingList.current = null; + detailRequest.current += 1; + detailItem.current = null; + detailPending.current = false; + setSelectedMessageId(null); + setMessageDetail(null); + setDetailLoading(false); + }; }, [load, view]); - useEffect(() => { - api.get("/email/providers") - .then((response) => { - setProviders(response.data ?? []); - setProviderStatusError(false); - }) - .catch(() => { - setProviders([]); - setProviderStatusError(true); - }) - .finally(() => setProviderStatusLoaded(true)); + const loadProviders = useCallback(async () => { + if (providerPending.current) return; + providerPending.current = true; + setProvidersLoading(true); + try { + const response = await api.get("/email/providers"); + if (!mounted.current) return; + setProviders(response.data ?? []); + setProviderStatusError(false); + setProviderError(null); + } catch (error) { + if (!mounted.current) return; + setProviders([]); + setProviderStatusError(true); + setProviderError(readError(error)); + } finally { + providerPending.current = false; + if (mounted.current) { setProviderStatusLoaded(true); setProvidersLoading(false); } + } }, []); + useEffect(() => { void loadProviders(); }, [loadProviders]); useEffect(() => { const timer = window.setTimeout(() => { @@ -211,36 +292,33 @@ export default function CorrespondenceInboxPage() { const loadStoredDrafts = useCallback(async () => { try { const response = await api.get("/email/drafts"); - setStoredDrafts(Array.isArray(response.data) ? response.data : []); + if (mounted.current) { setStoredDrafts(Array.isArray(response.data) ? response.data : []); setDraftsReadError(false); } } catch { - setDraftError(t("emailInboxDraftsLoadFailed")); + if (mounted.current) setDraftsReadError(true); } - }, [t]); + }, []); useEffect(() => { void loadStoredDrafts(); }, [loadStoredDrafts]); const filteredSummary = useMemo(() => { + if (!hasCurrentInbox) return { linked: 0, inbound: 0 }; const linked = items.filter((item) => item.externalThreadId).length; const inbound = items.filter((item) => item.direction === "inbound").length; return { linked, inbound }; - }, [items]); + }, [hasCurrentInbox, items]); const sendCapableProviders = useMemo(() => providers.filter((provider) => provider.connected && provider.canSend), [providers]); - const showMessage = async (item: CorrespondenceInboxItem) => { - if (selectedMessageId === item.id) { - detailRequest.current += 1; - setSelectedMessageId(null); - setMessageDetail(null); - return; - } - + const loadMessage = async (item: CorrespondenceInboxItem) => { + if (detailPending.current && detailItem.current?.id === item.id) return; const request = ++detailRequest.current; + detailPending.current = true; + detailItem.current = item; setSelectedMessageId(item.id); setMessageDetail(null); setDetailError(null); - setDetailNotice(null); + setDetailNotice(false); setDetailLoading(true); try { if (item.provider && item.provider !== "manual" && item.externalMessageId) { @@ -253,7 +331,7 @@ export default function CorrespondenceInboxPage() { return; } catch { if (request !== detailRequest.current) return; - setDetailNotice(t("emailInboxProviderCopyUnavailable")); + setDetailNotice(true); } } @@ -262,12 +340,22 @@ export default function CorrespondenceInboxPage() { setMessageDetail(saved.data); } catch (error) { if (request !== detailRequest.current) return; - setDetailError(getApiErrorMessage(error, t("emailInboxMessageLoadFailed"))); + setDetailError(readError(error)); } finally { - if (request === detailRequest.current) setDetailLoading(false); + if (request === detailRequest.current) { detailPending.current = false; setDetailLoading(false); } } }; + const showMessage = (item: CorrespondenceInboxItem) => { + if (selectedMessageId !== item.id) return loadMessage(item); + detailRequest.current += 1; + detailItem.current = null; + detailPending.current = false; + setSelectedMessageId(null); + setMessageDetail(null); + setDetailLoading(false); + }; + const startReply = async (item: CorrespondenceInboxItem) => { if (!messageDetail || !item.provider) return; const provider = providers.find((candidate) => candidate.provider === item.provider); @@ -490,33 +578,37 @@ export default function CorrespondenceInboxPage() { }; const unlinkGmailThread = async (item: CorrespondenceInboxItem) => { - if (item.provider !== "gmail" || !item.externalThreadId || unlinkingThreadId) return; - if (!(await confirm({ - title: t("emailInboxUnlinkTitle"), - message: t("emailInboxUnlinkMessage", { job: item.companyName || t("emailInboxThisJob") }), - confirmLabel: t("emailInboxUnlinkThread"), - destructive: true, - }))) return; - - setUnlinkingThreadId(item.externalThreadId); + if (item.provider !== "gmail" || !item.externalThreadId || unlinkPending.current) return; + unlinkPending.current = true; try { + if (!(await confirm({ + title: t("emailInboxUnlinkTitle"), + message: t("emailInboxUnlinkMessage", { job: item.companyName || t("emailInboxThisJob") }), + confirmLabel: t("emailInboxUnlinkThread"), + destructive: true, + })) || !mounted.current) return; + setUnlinkingThreadId(item.externalThreadId); + inboxPanel.current?.focus({ preventScroll: true }); + setUnlinkError(null); await api.post("/gmail/unlink-thread", { jobApplicationId: item.jobApplicationId, threadId: item.externalThreadId, note: "Unlinked from Job email hub", nextDecision: "review", }); + if (!mounted.current) return; if (selectedMessageId === item.id) { detailRequest.current += 1; setSelectedMessageId(null); setMessageDetail(null); } - await load(); + await latestLoad.current(); toast(t("emailInboxUnlinked"), "success"); } catch (error) { - toast(getApiErrorMessage(error, t("emailInboxUnlinkFailed")), "error"); + if (mounted.current) setUnlinkError({ ...readError(error), retryable: false }); } finally { - setUnlinkingThreadId(null); + unlinkPending.current = false; + if (mounted.current) setUnlinkingThreadId(null); } }; @@ -528,33 +620,20 @@ export default function CorrespondenceInboxPage() { }; return ( - + - - {t("correspondenceInbox")} - - {t("emailInboxSubtitle")} - - - {view === "inbox" ? } label={t("emailInboxItems", { count: inboxTotal })} variant="outlined" /> : null} - {view === "inbox" ? 0 ? "success" : "default"} /> : null} - {view === "inbox" ? : null} - - + {view === "inbox" && hasCurrentInbox ? } label={t("emailInboxItems", { count: inboxTotal })} variant="outlined" /> : null} + {view === "inbox" && hasCurrentInbox ? 0 ? "success" : "default"} /> : null} + {view === "inbox" && hasCurrentInbox ? : null} + + - + + {providers.map((provider) => ( ))} - {providerStatusError ? ( - {t("emailInboxProviderStatusUnavailable")} - ) : null} + { providerPanel.current?.focus({ preventScroll: true }); return loadProviders(); }} /> + {jobsLoaded && jobs.length === 0 ? {t("emailInboxAddJobFirst")} : null} {providerStatusLoaded && !providerStatusError && sendCapableProviders.length === 0 ? {t("emailInboxReconnectToCompose")} : null} {draftError && !draft ? {draftError} : null} + {draftsReadError && !draft ? {t("emailInboxDraftsLoadFailed")} : null} {storedDrafts.length > 0 ? ( @@ -615,12 +694,13 @@ export default function CorrespondenceInboxPage() { ) : null} - {view === "review" ? : <> + {reviewVisited ? : null} + + ); } diff --git a/job-tracker-ui/src/views/GmailReviewPage.tsx b/job-tracker-ui/src/views/GmailReviewPage.tsx index 11aa94a..29f1ea9 100644 --- a/job-tracker-ui/src/views/GmailReviewPage.tsx +++ b/job-tracker-ui/src/views/GmailReviewPage.tsx @@ -1,23 +1,48 @@ -import React, { useCallback, useEffect, useMemo, useState } from "react"; -import { Box, Button, Chip, CircularProgress, Paper, Stack, TextField, Typography } from "@mui/material"; +import React, { useCallback, useEffect, useId, useMemo, useRef, useState } from "react"; +import { Box, Button, Chip, Paper, Stack, TextField, Typography } from "@mui/material"; import { api, getApiErrorMessage } from "../api"; import { CreatedSuggestedGmailJobResult, GmailManualSyncResult, GmailReviewQueueResponse, GmailSuggestedJobsResponse } from "../types"; import { useToast } from "../toast"; import { useNavigate } from "react-router-dom"; import { useI18n } from "../i18n/I18nProvider"; +import type { TranslationKey } from "../i18n/translations"; +import type { ViewResourceError } from "../hooks/useViewResource"; +import ViewStateNotice from "../components/ViewStateNotice"; +import { PageHeader, PageSection } from "../layout/PageLayout"; -export default function GmailReviewPage({ embedded = false }: { embedded?: boolean }) { +type ReviewRequestError = { response?: { status?: number; data?: { code?: string } } }; + +function noticeError(cause: unknown, fallback: string): ViewResourceError { + const status = (cause as ReviewRequestError)?.response?.status; + const unauthorized = status === 401 || status === 403; + return { kind: unauthorized ? "unauthorized" : !status || status >= 500 ? "unavailable" : "error", status, retryable: !unauthorized, message: getApiErrorMessage(cause, fallback) }; +} + +export default function GmailReviewPage({ embedded = false, active = true }: { embedded?: boolean; active?: boolean }) { const { toast } = useToast(); const { t } = useI18n(); const navigate = useNavigate(); const [data, setData] = useState(null); const [suggestions, setSuggestions] = useState(null); const [loading, setLoading] = useState(false); - const [syncing, setSyncing] = useState(false); - const [savingThreadId, setSavingThreadId] = useState(null); - const [creatingThreadId, setCreatingThreadId] = useState(null); + const [readError, setReadError] = useState(null); + const [writeError, setWriteError] = useState<{ cause: unknown; key: TranslationKey } | null>(null); + const [operation, setOperation] = useState<{ kind: "decision" | "sync" | "create"; threadId?: string } | null>(null); + const [createdJobs, setCreatedJobs] = useState>({}); const [routingFilter, setRoutingFilter] = useState<"all" | "auto-link" | "review" | "unmatched" | "suggested" | "linked" | "rejected">("all"); const [notes, setNotes] = useState>({}); + const sectionId = useId(); + const bodyRef = useRef(null); + const mountedRef = useRef(false); + const activeRef = useRef(active); + const viewSequence = useRef(0); + const readSequence = useRef(0); + const loadingRef = useRef(false); + const mutationRef = useRef(false); + const tRef = useRef(t); + activeRef.current = active; + tRef.current = t; + const isCurrentView = useCallback((view: number) => mountedRef.current && activeRef.current && view === viewSequence.current, []); const routeLabel = (value: string) => { if (value === "auto-link") return t("gmailReviewRouteAutoLink"); if (value === "review") return t("gmailReviewRouteReview"); @@ -29,14 +54,20 @@ export default function GmailReviewPage({ embedded = false }: { embedded?: boole }; const load = useCallback(async () => { + if (!mountedRef.current || !activeRef.current || loadingRef.current) return false; + const view = viewSequence.current; + const request = ++readSequence.current; + loadingRef.current = true; setLoading(true); try { const [reviewRes, suggestedRes] = await Promise.all([ api.get("/gmail/review-candidates"), api.get("/gmail/suggested-jobs"), ]); + if (!isCurrentView(view) || request !== readSequence.current) return false; setData(reviewRes.data); setSuggestions(suggestedRes.data); + setReadError(null); setNotes((prev) => { const next = { ...prev }; for (const thread of reviewRes.data.threads) { @@ -44,19 +75,54 @@ export default function GmailReviewPage({ embedded = false }: { embedded?: boole } return next; }); + return true; } catch (error) { - toast(getApiErrorMessage(error, t("gmailReviewLoadFailed")), "error"); + if (isCurrentView(view) && request === readSequence.current) setReadError(error); + return false; } finally { - setLoading(false); + if (mountedRef.current && request === readSequence.current) { + loadingRef.current = false; + setLoading(false); + } } - }, [t, toast]); + }, [isCurrentView]); useEffect(() => { - void load(); + mountedRef.current = true; + return () => { + mountedRef.current = false; + readSequence.current += 1; + viewSequence.current += 1; + }; + }, []); + + useEffect(() => { + viewSequence.current += 1; + readSequence.current += 1; + loadingRef.current = false; + setLoading(false); + if (active && !mutationRef.current) void load(); + }, [active, load]); + + const beginOperation = useCallback((kind: "decision" | "sync" | "create", threadId?: string) => { + if (!mountedRef.current || !activeRef.current || mutationRef.current || loadingRef.current || readError) return null; + bodyRef.current?.focus(); + mutationRef.current = true; + setOperation({ kind, threadId }); + setWriteError(null); + return viewSequence.current; + }, [readError]); + + const finishOperation = useCallback((view: number) => { + mutationRef.current = false; + if (!mountedRef.current) return; + setOperation(null); + if (activeRef.current && view !== viewSequence.current) void load(); }, [load]); const saveDecision = useCallback(async (threadId: string, decision: "linked" | "rejected" | "review" | "suggested", jobApplicationId?: number) => { - setSavingThreadId(threadId); + const view = beginOperation("decision", threadId); + if (view === null) return; try { await api.post("/gmail/review-decision", { threadId, @@ -64,26 +130,29 @@ export default function GmailReviewPage({ embedded = false }: { embedded?: boole jobApplicationId: decision === "linked" ? jobApplicationId ?? null : null, note: notes[threadId]?.trim() || null, }); + if (!isCurrentView(view)) return; await load(); + if (!isCurrentView(view)) return; toast( decision === "linked" - ? t("gmailReviewLinked") + ? tRef.current("gmailReviewLinked") : decision === "rejected" - ? t("gmailReviewRejected") + ? tRef.current("gmailReviewRejected") : decision === "suggested" - ? t("gmailReviewKeptSuggested") - : t("gmailReviewReturned"), + ? tRef.current("gmailReviewKeptSuggested") + : tRef.current("gmailReviewReturned"), "success", ); } catch (error) { - toast(getApiErrorMessage(error, t("gmailReviewSaveFailed")), "error"); + if (isCurrentView(view)) setWriteError({ cause: error, key: "gmailReviewSaveFailed" }); } finally { - setSavingThreadId(null); + finishOperation(view); } - }, [load, notes, t, toast]); + }, [beginOperation, finishOperation, isCurrentView, load, notes, toast]); const runManualSync = useCallback(async () => { - setSyncing(true); + const view = beginOperation("sync"); + if (view === null) return; try { const res = await api.post("/gmail/manual-sync", { lookbackDays: 365, @@ -91,23 +160,26 @@ export default function GmailReviewPage({ embedded = false }: { embedded?: boole autoImportHighConfidence: true, includeSpamTrash: false, }); + if (!isCurrentView(view)) return; await load(); + if (!isCurrentView(view)) return; toast( - t("gmailReviewSyncFinished", { linked: res.data.importedThreads, review: res.data.reviewThreadCount, unmatched: res.data.unmatchedThreadCount }), + tRef.current("gmailReviewSyncFinished", { linked: res.data.importedThreads, review: res.data.reviewThreadCount, unmatched: res.data.unmatchedThreadCount }), "success", ); } catch (error) { - toast(getApiErrorMessage(error, t("gmailReviewSyncFailed")), "error"); + if (isCurrentView(view)) setWriteError({ cause: error, key: "gmailReviewSyncFailed" }); } finally { - setSyncing(false); + finishOperation(view); } - }, [load, t, toast]); + }, [beginOperation, finishOperation, isCurrentView, load, toast]); const createSuggestedJob = useCallback(async (threadId: string) => { const suggestion = suggestions?.items.find((item) => item.threadId === threadId); - if (!suggestion) return; + if (!suggestion || createdJobs[threadId]) return; - setCreatingThreadId(threadId); + const view = beginOperation("create", threadId); + if (view === null) return; try { const res = await api.post("/gmail/create-suggested-job", { threadId, @@ -118,92 +190,93 @@ export default function GmailReviewPage({ embedded = false }: { embedded?: boole notes: notes[threadId]?.trim() || suggestion.preview || null, status: "Applied", }); - await load(); - toast(t(res.data.imported === 1 ? "gmailReviewCreatedImportedMessage" : "gmailReviewCreatedImportedMessages", { count: res.data.imported }), "success"); - navigate(`/jobs/${res.data.jobApplicationId}?section=communication`); + if (!mountedRef.current) return; + // Keep a successful creation identity even if the owner changes views before it settles. + setCreatedJobs((prev) => ({ ...prev, [threadId]: res.data.jobApplicationId })); + if (!isCurrentView(view)) return; + const refreshed = await load(); + if (!isCurrentView(view)) return; + toast(tRef.current(res.data.imported === 1 ? "gmailReviewCreatedImportedMessage" : "gmailReviewCreatedImportedMessages", { count: res.data.imported }), "success"); + if (refreshed) navigate(`/jobs/${res.data.jobApplicationId}?section=communication`); } catch (error) { - toast(getApiErrorMessage(error, t("gmailReviewCreateFailed")), "error"); + if (isCurrentView(view)) setWriteError({ cause: error, key: "gmailReviewCreateFailed" }); } finally { - setCreatingThreadId(null); + finishOperation(view); } - }, [load, navigate, notes, suggestions?.items, t, toast]); + }, [beginOperation, createdJobs, finishOperation, isCurrentView, load, navigate, notes, suggestions?.items, t, toast]); const filteredThreads = useMemo(() => { const threads = data?.threads ?? []; return routingFilter === "all" ? threads : threads.filter((thread) => thread.routing === routingFilter); }, [data?.threads, routingFilter]); - return ( - - - - {embedded ? t("gmailReviewEmbeddedTitle") : t("gmailReviewTitle")} - - {t("gmailReviewSubtitle")} - - - - - - {!embedded ? : null} - - + const disconnected = (readError as ReviewRequestError)?.response?.data?.code === "gmail_not_connected"; + const readTitle = t(disconnected ? "gmailReviewNotConnected" : "gmailReviewLoadFailed"); + const readNotice = readError ? noticeError(readError, readTitle) : null; + if (disconnected && readNotice) readNotice.message = readTitle; + const unavailable = disconnected || readNotice?.kind === "unauthorized"; + const controlsDisabled = !active || loading || operation !== null || readError !== null; + const chipSx = { maxWidth: "100%", height: "auto", minHeight: 24, "& .MuiChip-label": { whiteSpace: "normal", overflowWrap: "anywhere", py: 0.5 } }; + const actions = <> + + + {!embedded ? : null} + ; + const body = + { bodyRef.current?.focus(); void load(); }} /> + {writeError ? : null} - + {(["all", "auto-link", "review", "unmatched", "suggested", "linked", "rejected"] as const).map((value) => ( - ))} - {data ? ( + {!unavailable && data ? ( - - - - - {suggestions?.count ? : null} + + + + + {suggestions?.count ? : null} ) : null} - {loading ? : null} - {!loading && data && filteredThreads.length === 0 ? {t("gmailReviewNoCandidates")} : null} + {!loading && !readError && data && filteredThreads.length === 0 ? {t("gmailReviewNoCandidates")} : null} - {filteredThreads.map((thread) => { + {!unavailable && filteredThreads.map((thread) => { const suggestion = (suggestions?.items ?? []).find((item) => item.threadId === thread.threadId); + const headingId = `${sectionId}-${encodeURIComponent(thread.threadId)}`; return ( - + - {thread.subject} + {thread.subject} {t("gmailReviewThreadSummary", { count: thread.messageCount, route: routeLabel(thread.routing) })} {thread.matchedQueries.slice(0, 3).map((query) => ( - + ))} - {thread.hasImportedMessages ? : null} + {thread.hasImportedMessages ? : null} setNotes((prev) => ({ ...prev, [thread.threadId]: event.target.value }))} + onChange={(event) => { + if (!activeRef.current || mutationRef.current) return; + setNotes((prev) => ({ ...prev, [thread.threadId]: event.target.value })); + }} + disabled={!active || operation !== null} size="small" fullWidth multiline @@ -217,17 +290,18 @@ export default function GmailReviewPage({ embedded = false }: { embedded?: boole ) : null} - + {thread.jobCandidates.slice(0, 2).map((candidate) => ( ))} {thread.jobCandidates[0] ? ( - ) : null} @@ -235,7 +309,7 @@ export default function GmailReviewPage({ embedded = false }: { embedded?: boole - {suggestion ? ( + {createdJobs[thread.threadId] ? : suggestion ? ( ) : null}