From deed948183860f80f45b2d3b5aa02f6d506f93ab Mon Sep 17 00:00:00 2001 From: cesnimda Date: Sat, 15 Aug 2026 17:34:32 +0200 Subject: [PATCH] feat(jobs): complete workspace draft parity --- BLOCKERS.md | 23 +--- .../ApplicationWorkspaceTests.cs | 18 +++ .../JobApplicationsApplicationPackageTests.cs | 71 +++++++++++ .../JobApplicationsControllerTests.cs | 4 +- .../JobApplicationsEndpointBehaviorTests.cs | 7 +- .../Controllers/JobApplicationDtos.cs | 6 +- .../Controllers/JobApplicationsController.cs | 25 ++-- .../Services/ApplicationWorkspaceService.cs | 6 +- .../Services/JobApplicationHelpers.cs | 24 +++- docs/architecture/application-workspace.md | 25 ++-- docs/audits/verification-log.md | 1 + .../jobs-002-application-workspace.md | 24 ++-- docs/work-programmes/decisions.md | 10 ++ docs/work-programmes/master-progress.md | 10 +- docs/work-programmes/master-work-plan.md | 12 +- docs/work-programmes/session-handoff.md | 16 +-- job-tracker-ui/e2e/smoke.spec.ts | 112 ++++++++++++++++++ .../src/application-assets.test.tsx | 57 ++++++++- job-tracker-ui/src/application-drafts.test.ts | 24 ++++ .../application-workspace-overlay.test.tsx | 73 ++++++++++-- job-tracker-ui/src/applicationDrafts.ts | 31 +++++ job-tracker-ui/src/applicationWorkspace.ts | 4 + .../src/components/ApplicationAssets.tsx | 101 +++++++++++++++- .../src/components/EditJobDialog.tsx | 3 +- .../src/components/JobDetailsDialog.tsx | 26 +--- job-tracker-ui/src/components/JobTable.tsx | 14 ++- .../job-workspace/useJobWorkspaceBaseData.ts | 19 +-- .../src/views/ApplicationWorkspacePage.tsx | 61 +++++++++- 28 files changed, 676 insertions(+), 131 deletions(-) create mode 100644 job-tracker-ui/src/application-drafts.test.ts create mode 100644 job-tracker-ui/src/applicationDrafts.ts diff --git a/BLOCKERS.md b/BLOCKERS.md index b56609d..59a5270 100644 --- a/BLOCKERS.md +++ b/BLOCKERS.md @@ -1,6 +1,6 @@ # Blockers -Updated: 2026-07-31 +Updated: 2026-08-15 ## Stripe billing @@ -17,31 +17,16 @@ Updated: 2026-07-31 - **Why:** The 2026-07-31 anonymous production check confirms `allowRegistration=true`, `turnstileEnabled=true`, and Google sign-in enabled. Completing Turnstile and creating a disposable account requires an interactive production browser session. - **Required:** Register one disposable account through Turnstile, verify email/sign-in/rate-limit behavior, then remove the account if it is not needed. - **Recommended:** Monitor Turnstile and rate-limit failures during the first public rollout; keep email verification required. -- **Current status:** Production returns `allowRegistration=true`, `turnstileEnabled=true`, `googleEnabled=true`, and `microsoftEnabled=false`. A registration request without a Turnstile token is rejected with HTTP 400. SMTP is configured and enabled. The release branch now maps `AUTH_REQUIRE_EMAIL_VERIFICATION`; production must set it to `true` before the interactive signup test. - -## CI runner verification - -- **Blocked:** Proving that the current release gate completes on the self-hosted runner. -- **Why:** The workflow now runs the complete backend, frontend, dependency-audit, browser, and production-build checks, but historical runner failures were intermittent and the current working tree has not been submitted to remote CI. Local success cannot prove runner health. -- **Required:** Submit the reviewed changes and run the Gitea workflow. If it still fails early, inspect the job log and `journalctl -u act_runner`/runner resources on the host. -- **Recommended:** Keep the full gate intact; fix the runner instead of skipping or filtering tests. -- **Current status:** The `release-readiness` branch is pushed to origin. Creating the pull request at `https://git.cesnimda.uk/cesnimda/jobtrackingapp/pulls/new/release-readiness` still requires an authenticated Gitea browser or CLI session; neither is available in this workspace. - -## React Router security release - -- **Blocked:** Clearing the final two moderate React Router package findings without introducing a higher-severity advisory. -- **Why:** The reported paths affect redirects and SSR hydration. This application uses declarative `BrowserRouter` (not SSR/RSC), and post-login redirects reject protocol-relative and backslash paths. The redirect-fixed React Router 7.18.2 release is itself covered by a high-severity RSC advisory; npm's suggested high-severity fix downgrades to a release that reintroduces the moderate redirect findings. No published version clears both sets. -- **Required:** Upgrade React Router when a release clears both the redirect/SSR findings and the RSC advisory, then rerun Jest, production build, and Playwright. -- **Recommended:** Keep 6.30.3 plus the explicit redirect allowlist until that release; do not force an audit-driven major downgrade/upgrade that leaves tests unable to load. +- **Current status:** Production returns `allowRegistration=true`, `turnstileEnabled=true`, `googleEnabled=true`, and `microsoftEnabled=false`. A registration request without a Turnstile token is rejected with HTTP 400. SMTP is configured and enabled. The operator reports `AUTH_REQUIRE_EMAIL_VERIFICATION` is now enabled; the disposable interactive signup is still required to prove the deployed behavior end to end. ## Production verification and deployment - **Blocked:** Authenticated production smoke tests, backup restore verification against real data, OAuth-provider checks, and deployment. - **Why:** These require production access, real credentials, and operator authorization. -- **Required:** Follow `docs/release-candidate-review.md` and `docs/release-checklist.md` on the production host. +- **Required:** After the current pull request passes CI and is approved, follow `docs/release-candidate-review.md` and `docs/release-checklist.md` on the production host. Confirm the admin-only version badge matches the deployed commit, then run the authenticated application workspace, Career, CV, attachment, email-verification and rollback checks. - **Recommended:** Verify backup/restore before deployment, then exercise login, existing application counts, Career Workspace, public CV refresh/download, AI, and attachments in order. - **Current access check:** Read-only SSH access is confirmed to the LAN production host as both `root` and `pi` using the existing `id_ed25519` identity. All four containers are healthy and the host has 44 GB free. No production change or deployment was attempted. -- **Current status:** Anonymous production checks confirm the frontend and `/api/auth/config` return HTTP 200. The public `/health` path currently returns the SPA HTML shell; the release branch now proxies that exact path to the backend and includes a regression test. +- **Current status:** Anonymous production checks confirm the frontend and `/api/auth/config` return HTTP 200. The public `/health` path currently returns the SPA HTML shell; the release branch proxies that exact path to the backend and includes a regression test. Gitea PR 28 exists and an earlier complete PR gate passed; the new local commits still need their remote CI run after push. React Router is now 7.18.2 and the recorded local dependency audit is clean, so the superseded router/runner blockers were removed. ## Legacy job/application column cutover diff --git a/JobTrackerApi.Tests/ApplicationWorkspaceTests.cs b/JobTrackerApi.Tests/ApplicationWorkspaceTests.cs index 735ef61..d6eb40f 100644 --- a/JobTrackerApi.Tests/ApplicationWorkspaceTests.cs +++ b/JobTrackerApi.Tests/ApplicationWorkspaceTests.cs @@ -83,6 +83,24 @@ public sealed class ApplicationWorkspaceTests Assert.Equal(0, o.DocumentCount); } + [Fact] + public async Task Overview_separates_application_answers_from_human_notes() + { + var (db, svc) = New("user-1"); + await using var _ = db; + var job = await SeedAsync(db, "user-1", j => + { + j.Notes = "Ask about the platform team.\n\n<<>>\nI enjoy solving customer problems.\n<<>>"; + j.RecruiterMessageDraft = "Hello Maria"; + }); + + var o = await svc.GetOverviewAsync("user-1", job.Id, default); + + Assert.Equal("Ask about the platform team.", o!.Notes); + Assert.Equal("I enjoy solving customer problems.", o.ApplicationAnswerDraft); + Assert.Equal("Hello Maria", o.RecruiterMessageDraft); + } + [Fact] public async Task Overview_surfaces_the_attached_cv_variant() { diff --git a/JobTrackerApi.Tests/JobApplicationsApplicationPackageTests.cs b/JobTrackerApi.Tests/JobApplicationsApplicationPackageTests.cs index ad17de0..769bc3d 100644 --- a/JobTrackerApi.Tests/JobApplicationsApplicationPackageTests.cs +++ b/JobTrackerApi.Tests/JobApplicationsApplicationPackageTests.cs @@ -46,6 +46,77 @@ public sealed class JobApplicationsApplicationPackageTests Assert.Equal("Updated notes block", saved.Notes); } + [Fact] + public async Task Save_application_drafts_updates_and_clears_answer_without_changing_human_notes() + { + await using var db = CreateDb(); + var company = new Company { Name = "Acme", OwnerUserId = "user-1" }; + db.Companies.Add(company); + await db.SaveChangesAsync(); + + var job = new JobApplication + { + JobTitle = "Backend Developer", + CompanyId = company.Id, + OwnerUserId = "user-1", + Notes = "Human note\n\n<<>>\nOld answer\n<<>>", + RecruiterMessageDraft = "Old recruiter message" + }; + db.JobApplications.Add(job); + await db.SaveChangesAsync(); + + var controller = CreateController(db, Mock.Of(), "user-1"); + var updated = await controller.SaveApplicationDrafts( + job.Id, + new SaveApplicationDraftsRequest(null, null, " New recruiter message ", " New answer "), + CancellationToken.None); + + Assert.IsType(updated); + Assert.Equal("Human note", JobApplicationHelpers.RemoveSavedApplicationAnswerDraft(job.Notes)); + Assert.Equal("New answer", JobApplicationHelpers.ExtractSavedApplicationAnswerDraft(job.Notes)); + Assert.Equal("New recruiter message", job.RecruiterMessageDraft); + + job.Notes += "\n\n<<>>\nDuplicate stale answer\n<<>>"; + var cleared = await controller.SaveApplicationDrafts( + job.Id, + new SaveApplicationDraftsRequest(null, null, "", ""), + CancellationToken.None); + + Assert.IsType(cleared); + Assert.Equal("Human note", job.Notes); + Assert.Null(job.RecruiterMessageDraft); + } + + [Fact] + public async Task Save_application_drafts_cannot_mutate_another_users_application() + { + await using var db = CreateDb(); + var company = new Company { Name = "Other Acme", OwnerUserId = "user-2" }; + db.Companies.Add(company); + await db.SaveChangesAsync(); + + var otherUsersJob = new JobApplication + { + JobTitle = "Private role", + CompanyId = company.Id, + OwnerUserId = "user-2", + Notes = "Private note", + RecruiterMessageDraft = "Private draft" + }; + db.JobApplications.Add(otherUsersJob); + await db.SaveChangesAsync(); + + var controller = CreateController(db, Mock.Of(), "user-1"); + var result = await controller.SaveApplicationDrafts( + otherUsersJob.Id, + new SaveApplicationDraftsRequest(null, null, "Changed", "Changed"), + CancellationToken.None); + + Assert.IsType(result); + Assert.Equal("Private note", otherUsersJob.Notes); + Assert.Equal("Private draft", otherUsersJob.RecruiterMessageDraft); + } + [Fact] public async Task Generate_application_package_uses_imported_correspondence_and_recruiter_context() { diff --git a/JobTrackerApi.Tests/JobApplicationsControllerTests.cs b/JobTrackerApi.Tests/JobApplicationsControllerTests.cs index d774d2f..6a1f03c 100644 --- a/JobTrackerApi.Tests/JobApplicationsControllerTests.cs +++ b/JobTrackerApi.Tests/JobApplicationsControllerTests.cs @@ -21,7 +21,7 @@ public sealed class JobApplicationsControllerTests } [Fact] - public void Save_application_drafts_request_supports_cover_letter_and_notes() + public void Save_application_drafts_request_supports_the_complete_application_package() { var type = typeof(SaveApplicationDraftsRequest); Assert.NotNull(type); @@ -30,5 +30,7 @@ public sealed class JobApplicationsControllerTests var parameters = ctor.GetParameters().Select(x => x.Name).Where(x => x is not null).Select(x => x!).ToHashSet(StringComparer.OrdinalIgnoreCase); Assert.Contains("coverLetterText", parameters); Assert.Contains("notes", parameters); + Assert.Contains("applicationAnswerDraft", parameters); + Assert.Contains("recruiterMessageDraft", parameters); } } diff --git a/JobTrackerApi.Tests/JobApplicationsEndpointBehaviorTests.cs b/JobTrackerApi.Tests/JobApplicationsEndpointBehaviorTests.cs index bcf3d37..b7b1dae 100644 --- a/JobTrackerApi.Tests/JobApplicationsEndpointBehaviorTests.cs +++ b/JobTrackerApi.Tests/JobApplicationsEndpointBehaviorTests.cs @@ -225,7 +225,7 @@ public sealed class JobApplicationsEndpointBehaviorTests } [Fact] - public async Task Update_drops_invalid_salary_period_and_negative_values() + public async Task Update_normalizes_salary_and_preserves_the_separate_application_answer() { await using var db = CreateDb(); var company = new Company { Name = "Acme", OwnerUserId = "user-1" }; @@ -241,6 +241,7 @@ public sealed class JobApplicationsEndpointBehaviorTests SalaryMax = 60000m, SalaryCurrency = "NOK", SalaryPeriod = "year", + Notes = "Old human note\n\n<<>>\nSaved answer\n<<>>", Job = new Job { CompanyId = company.Id, JobTitle = "Backend Dev", Source = "nav", CountryCode = "NO" }, }; db.JobApplications.Add(job); @@ -261,7 +262,7 @@ public sealed class JobApplicationsEndpointBehaviorTests SalaryPeriod: "fortnight", NextAction: null, FollowUpAt: null, - Notes: null, + Notes: "Updated human note", Description: null, TranslatedDescription: null, DescriptionLanguage: null, @@ -281,6 +282,8 @@ public sealed class JobApplicationsEndpointBehaviorTests Assert.Null(saved.SalaryMax); Assert.Null(saved.SalaryCurrency); Assert.Null(saved.SalaryPeriod); + Assert.Equal("Updated human note", JobApplicationHelpers.RemoveSavedApplicationAnswerDraft(saved.Notes)); + Assert.Equal("Saved answer", JobApplicationHelpers.ExtractSavedApplicationAnswerDraft(saved.Notes)); var opportunity = await db.Jobs.SingleAsync(); Assert.Equal(saved.JobTitle, opportunity.JobTitle); Assert.Null(opportunity.SalaryMin); diff --git a/JobTrackerApi/Controllers/JobApplicationDtos.cs b/JobTrackerApi/Controllers/JobApplicationDtos.cs index 9242a1d..244d8df 100644 --- a/JobTrackerApi/Controllers/JobApplicationDtos.cs +++ b/JobTrackerApi/Controllers/JobApplicationDtos.cs @@ -218,7 +218,11 @@ namespace JobTrackerApi.Controllers TailoredCvRenderOptions? RenderOptions, string? Status); public sealed record GenerateApplicationPackageDto(string TailoredCvText, string? CoverLetterDraft, string? ApplicationAnswerDraft, string? RecruiterMessageDraft, List KeyPoints, List AttachmentSignals, List AttachmentFilesUsed, List CoverLetterVariants, List RecruiterMessageVariants); - public sealed record SaveApplicationDraftsRequest(string? CoverLetterText, string? Notes, string? RecruiterMessageDraft); + public sealed record SaveApplicationDraftsRequest( + string? CoverLetterText, + string? Notes, + string? RecruiterMessageDraft, + string? ApplicationAnswerDraft = null); public sealed record SavedPackageMaterial(string? TailoredCvText, string? CoverLetterText, string? RecruiterMessageDraft, string? Notes); public sealed record InterviewPrepDto(string Summary, List TalkingPoints, List LikelyQuestions, List WeakSpots); public sealed record ReadinessDto(int Score, string Level, List Completed, List Missing, List Reminders, WorkflowSignalDto WorkflowSignal); diff --git a/JobTrackerApi/Controllers/JobApplicationsController.cs b/JobTrackerApi/Controllers/JobApplicationsController.cs index 490a5bc..57a805a 100644 --- a/JobTrackerApi/Controllers/JobApplicationsController.cs +++ b/JobTrackerApi/Controllers/JobApplicationsController.cs @@ -837,7 +837,11 @@ Canonical profile: job.FeedbackRequestedAt = request.FeedbackRequestedAt; // HasResume/HasCoverLetter/HasPortfolio/HasOtherAttachment are derived from // Attachment rows, not settable here -- see AttachmentsController.RecomputeAttachmentFlagsAsync. - job.Notes = request.Notes; + // Application answers use the legacy Notes column for storage compatibility, but the + // general editor owns only human notes. Preserve the separate answer when those notes + // are edited; the application-package endpoint is the only place that clears it. + var savedApplicationAnswer = ExtractSavedApplicationAnswerDraft(job.Notes); + job.Notes = UpsertSavedApplicationAnswerDraft(request.Notes, savedApplicationAnswer); job.Description = request.Description; job.TranslatedDescription = request.TranslatedDescription; job.DescriptionLanguage = request.DescriptionLanguage; @@ -1826,19 +1830,26 @@ Candidate CV/profile: var job = await _db.JobApplications.FirstOrDefaultAsync(j => j.Id == id, cancellationToken); if (job is null) return NotFound(); - if (!string.IsNullOrWhiteSpace(request.CoverLetterText)) + if (request.CoverLetterText is not null) { - job.CoverLetterText = request.CoverLetterText.Trim(); + job.CoverLetterText = string.IsNullOrWhiteSpace(request.CoverLetterText) ? null : request.CoverLetterText.Trim(); } - if (!string.IsNullOrWhiteSpace(request.Notes)) + if (request.Notes is not null) { - job.Notes = request.Notes.Trim(); + job.Notes = string.IsNullOrWhiteSpace(request.Notes) ? null : request.Notes.Trim(); } - if (!string.IsNullOrWhiteSpace(request.RecruiterMessageDraft)) + if (request.ApplicationAnswerDraft is not null) { - job.RecruiterMessageDraft = request.RecruiterMessageDraft.Trim(); + job.Notes = UpsertSavedApplicationAnswerDraft(job.Notes, request.ApplicationAnswerDraft); + } + + if (request.RecruiterMessageDraft is not null) + { + job.RecruiterMessageDraft = string.IsNullOrWhiteSpace(request.RecruiterMessageDraft) + ? null + : request.RecruiterMessageDraft.Trim(); } await _db.SaveChangesAsync(cancellationToken); diff --git a/JobTrackerApi/Services/ApplicationWorkspaceService.cs b/JobTrackerApi/Services/ApplicationWorkspaceService.cs index 2c2cb0d..6252243 100644 --- a/JobTrackerApi/Services/ApplicationWorkspaceService.cs +++ b/JobTrackerApi/Services/ApplicationWorkspaceService.cs @@ -34,6 +34,8 @@ public sealed record WorkspaceOverviewDto( string? DescriptionLanguage, IReadOnlyList Tags, string? Notes, + string? ApplicationAnswerDraft, + string? RecruiterMessageDraft, string? Source, string? CountryCode, bool HasJobDescription, @@ -131,7 +133,9 @@ public sealed class ApplicationWorkspaceService : IApplicationWorkspaceService job.TranslatedDescription, job.DescriptionLanguage, JobApplicationHelpers.SplitTags(job.Tags).Distinct(StringComparer.OrdinalIgnoreCase).ToList(), - job.Notes, + JobApplicationHelpers.RemoveSavedApplicationAnswerDraft(job.Notes), + JobApplicationHelpers.ExtractSavedApplicationAnswerDraft(job.Notes), + job.RecruiterMessageDraft, job.Job?.Source, job.Job?.CountryCode, !string.IsNullOrWhiteSpace(job.Description) || !string.IsNullOrWhiteSpace(job.TranslatedDescription), diff --git a/JobTrackerApi/Services/JobApplicationHelpers.cs b/JobTrackerApi/Services/JobApplicationHelpers.cs index 7a67d09..9106e0f 100644 --- a/JobTrackerApi/Services/JobApplicationHelpers.cs +++ b/JobTrackerApi/Services/JobApplicationHelpers.cs @@ -281,6 +281,21 @@ namespace JobTrackerApi.Services return null; } + public static string? UpsertSavedApplicationAnswerDraft(string? notes, string? draft) + { + var humanNotes = RemoveSavedApplicationAnswerDraft(notes); + var answer = (draft ?? string.Empty).Trim(); + if (string.IsNullOrWhiteSpace(answer)) + { + return string.IsNullOrWhiteSpace(humanNotes) ? null : humanNotes; + } + + var answerBlock = $"{ApplicationAnswerDraftStart}\n{answer}\n{ApplicationAnswerDraftEnd}"; + return string.IsNullOrWhiteSpace(humanNotes) + ? answerBlock + : $"{humanNotes}\n\n{answerBlock}"; + } + public static string BuildFollowUpSubject(JobApplication job, Correspondence? lastMessage) { var subject = (lastMessage?.Subject ?? string.Empty).Trim(); @@ -467,13 +482,14 @@ namespace JobTrackerApi.Services var value = notes ?? string.Empty; if (string.IsNullOrWhiteSpace(value)) return string.Empty; - var startIndex = value.IndexOf(ApplicationAnswerDraftStart, StringComparison.Ordinal); - var endIndex = value.IndexOf(ApplicationAnswerDraftEnd, StringComparison.Ordinal); - if (startIndex >= 0 && endIndex > startIndex) + while (true) { + var startIndex = value.IndexOf(ApplicationAnswerDraftStart, StringComparison.Ordinal); + var endIndex = value.IndexOf(ApplicationAnswerDraftEnd, StringComparison.Ordinal); + if (startIndex < 0 || endIndex <= startIndex) break; var before = value[..startIndex].Trim(); var after = value[(endIndex + ApplicationAnswerDraftEnd.Length)..].Trim(); - return string.Join("\n\n", new[] { before, after }.Where(part => !string.IsNullOrWhiteSpace(part))).Trim(); + value = string.Join("\n\n", new[] { before, after }.Where(part => !string.IsNullOrWhiteSpace(part))).Trim(); } const string legacyPrefix = "Application answer draft:"; diff --git a/docs/architecture/application-workspace.md b/docs/architecture/application-workspace.md index 10f03ac..6e195f1 100644 --- a/docs/architecture/application-workspace.md +++ b/docs/architecture/application-workspace.md @@ -6,7 +6,7 @@ ## What it is -A dedicated surface for one `JobApplication` at `/applications/{id}`, so an application is a place you +A dedicated surface for one `JobApplication` at `/jobs/{id}`, so an application is a place you work rather than a row you edit in a modal. Job tracking stays the product; the workspace is the application's home. @@ -23,6 +23,7 @@ The workspace **owns no data and duplicates none**. It is an aggregate read plus | Checklist | `ApplicationChecklistItem` — completion state only, seeded from the readiness signals | | CV | Phase 4 `CvVariant` — a lens over the master `CareerProfile`; the application only points at one | | Cover Letter | `JobApplication.CoverLetterText` + `CoverLetterVersions` history | +| Application answers / recruiter draft | compatibility fields on `JobApplication`, exposed as separate workspace fields | | Analysis / Match / Interview | Phase 5 `AiWorkspacePanel` + `AiInteraction` history | | Documents | `Attachment` | | Communication | `Correspondence` | @@ -134,13 +135,14 @@ rejecting a duplicate system key, and NULL system keys not colliding. ## Frontend -`ApplicationWorkspacePage` (`/applications/:id`) — a left nav plus a content pane, section selected by -`?section=`, so a section is linkable and survives refresh. Reached from the job dialog's "Open -application workspace" button. +`ApplicationWorkspacePage` (`/jobs/:id`) — a left nav plus a content pane, section selected by +`?section=`, so a section is linkable and survives refresh. The whole application row/card opens this +route; the legacy job-details dialog is not part of the production navigation flow. -The dialog passes an optional `onOpenWorkspace` callback rather than calling `useNavigate` itself: -`JobDetailsDialog` must stay renderable without a `` (several suites mount it standalone), so -router context belongs to the caller. +Cover-letter and application-package editors report dirty state to the workspace. Section changes, +Back/Forward navigation and application exit use the shared application confirmation dialog, while a +hard refresh receives the browser's unload warning. Returning through the workspace Back action +restores focus to the originating application row. The Checklist section (`ApplicationChecklist`) groups items by category, shows a completion bar, and supports tick/untick, add, remove and reorder. System items are labelled "Detected" when a signal @@ -289,6 +291,15 @@ the builder. Nothing auto-applies, and no suggestion mutates a variant or the pr Creation methods: write it, start from the built-in template, or generate from the AI panel below the editor. The editor is always the user's; generation is never triggered by opening the page. +### Application answers and recruiter message + +The Cover Letter section also owns the saved application answer and recruiter-message editors that +were previously reachable only through the retired modal. The existing database representation is +kept for compatibility: the application answer remains a marked block in `JobApplication.Notes`, but +the workspace aggregate separates it from human notes and the API owns insertion/removal. General job +edits preserve the answer, and the ordinary Notes UI never exposes the storage markers. Empty saves +can intentionally clear either draft. + ### Documents Unchanged. The existing `Attachments` component and `/api/attachments` already handle CV, cover diff --git a/docs/audits/verification-log.md b/docs/audits/verification-log.md index d20251d..b58abd1 100644 --- a/docs/audits/verification-log.md +++ b/docs/audits/verification-log.md @@ -200,3 +200,4 @@ Output was reduced to filenames and commit counts. The token artifact appears un | V-166 | Focused/full backend and frontend tests; optimized frontend build/TypeScript; diff review | Repository root / `job-tracker-ui` | Verify an administrator can identify the deployed application version without exposing build metadata in the normal-user UI/API bootstrap | PASS — focused Auth/System 36/36 and AppShell 2/2; backend 640/640; frontend 53 suites/221 tests; production build passes. Configured version/commit reaches admin `/api/auth/me`, normal users receive no configured metadata, and the responsive header badge is absent unless the admin-owned prop is supplied | Local/JSDOM evidence only; remote CI and deployed version comparison remain. Jest retains the documented force-exit/open-handle notice | Priority repository increment verified; ship proof remains | | V-167 | Failing Career round-trip reproduction; affected/full backend; focused Career/Profile Jest; persistence-consumer and diff review | Repository root / `job-tracker-ui` | Preserve every reviewed Career text value across save/get/version/projection/import use without weakening extraction cleanup or write bounds | PASS — pre-fix location became `Oslo, Norway and` and the test failed; post-fix affected backend 112/112, backend 642/642 and Career/Profile UI 17/17 pass. Website path/query, remote location, free-form date, custom language and incomplete WIP entry round-trip; oversized website is rejected explicitly | Local SQLite/JSDOM only; no real private CV/model/provider/production data. One initial Jest command used nonexistent paths and was corrected; the correct files passed | Reviewed/extracted normalization boundary verified locally | | V-168 | Failing renderer contrast test; focused/full backend; CV Builder/public Jest; real Chromium computed-style/overflow probe; pathological A4 PDF export and text inspection | Repository root / `job-tracker-ui` | Make header/sidebar contact text readable for theme and custom palettes while keeping public renderer settings inert | PASS — renderer/settings 25/25, backend 644/644 and CV UI 22/22. Chromium computed white on Modern blue, black on `#f8fafc`, white on Technical sidebar, with zero element overflow. A 14-role/75-skill fixture produced a 17-page 259,447-byte A4 PDF with 1,685 final-page characters. CSS-like accent/font payloads normalize to null | Synthetic local data/browser only; authenticated application journey and production browser binary remain unverified. A direct PowerShell assembly probe failed to load dependencies before the compiled temporary test probe passed; temporary proof cleanup was blocked by execution policy | Renderer contrast/public-setting boundary verified locally | +| V-169 | Legacy-vs-dedicated workspace trace; failing package DTO/UI regressions; focused/full backend and frontend; optimized build; authenticated Playwright application journey; diff review | Repository root / `job-tracker-ui` | Close JOBS-002 application-package parity, marker encapsulation, dirty navigation, focus return, responsive/theme/history/error and tenant-safety gaps | PASS — focused backend 30/30, focused frontend 28/28 plus route/focus 6/6, backend 647/647, frontend 54 suites/227 tests, build and Playwright 7/7. Chromium covers keyboard row entry, Back/Forward, saved refresh, dirty-edit cancel, focus return, missing job and long data at 375/768/1440 in explicit light/dark with zero overflow. Cross-owner workspace read and draft write return not found | Synthetic local SQLite/account/browser only; no production/provider/private data. Two targeted browser iterations corrected locators, and a transient SQLite company-create 500 led to bounded idempotent setup retry; behavior assertions were not weakened. Existing GSI and Jest open-handle warnings remain | JOBS-002 repository/browser scope verified; production/native assistive-device gates remain | diff --git a/docs/verification/jobs-002-application-workspace.md b/docs/verification/jobs-002-application-workspace.md index c7696b7..113f40c 100644 --- a/docs/verification/jobs-002-application-workspace.md +++ b/docs/verification/jobs-002-application-workspace.md @@ -25,17 +25,25 @@ Updated: 2026-08-15 - The header bell opens a theme-aware notification popover anchored to the bell. It supports loading/error/empty states, mark-read, dismiss, notification-owned destinations and a separate link to the global Operations page. - Return navigation preserves the complete URL-owned list state, including when a workspace section changes. +## Increment 3 — application-package parity and navigation safety + +- Application answers and recruiter messages are editable on the dedicated page beside the versioned cover-letter workflow. Empty saves intentionally clear drafts. +- Internal application-answer markers no longer render in Job Details or the general edit dialog. Editing ordinary notes preserves the stored answer instead of deleting it. +- Cover-letter and application-draft dirty state blocks section, Back/Forward and exit navigation through the shared confirmation dialog; hard refresh/close receives the browser unload warning. +- Returning with the workspace Back action restores keyboard focus to the originating row/card. +- The application-drafts mutation and workspace aggregate are tenant-filtered; direct cross-owner reads/writes return not found. + ## Verification -- Focused Jest: workspace/table and workflow routing — 8/8 pass. -- Focused backend workspace aggregate: 9/9 pass. -- Production build: pass. Standalone repository-wide `tsc --noEmit` still exposes pre-existing React Router test-prop and target errors; no new application-source error was reported. +- Focused Jest: workspace/assets/storage helpers and legacy compatibility — 28/28 pass; focused route/focus/dirty regression 6/6 pass. +- Focused backend workspace/application-draft/controller behavior — 30/30 pass. +- Full backend 647/647; full frontend 54 suites and 227/227 tests; optimized production build/TypeScript pass. +- Real Chromium application journey passes at 375/768/1440 in explicit light and dark modes with no horizontal overflow. It covers keyboard row entry, Back/Forward, dark refresh, saved draft reload, unsaved-change cancel, long company/title/advert/URL content, focus return, valid section deep links and a missing-job error state. +- Complete Playwright suite: 7/7 pass. - Notification/AppShell/Operations focused Jest: 3 suites and 6/6 pass. - Evidence: V-158–V-163 in `docs/audits/verification-log.md`. -## Remaining before completion +## Remaining before production completion -- URL-owned list state is implemented for search, status, company, location, follow-up, readiness, deleted visibility, sort/direction and page. Direct hydration and dedicated-page return pass focused tests; real-browser refresh/history remains. -- Confirm the existing section-level save/dirty behavior does not need an additional workspace-level navigation guard. -- Add real-browser 375/768/1440, light/dark, keyboard/focus restoration, refresh/history, error and long-content evidence. -- Run tenant-authorization regressions and the production smoke gate. +- Run the authenticated production application/workspace smoke after this branch is merged and deployed. +- Native screen-reader/mobile assistive-technology behavior is not claimed by Chromium automation and remains an operator/device spot check. diff --git a/docs/work-programmes/decisions.md b/docs/work-programmes/decisions.md index 0ac3ba3..1250789 100644 --- a/docs/work-programmes/decisions.md +++ b/docs/work-programmes/decisions.md @@ -699,3 +699,13 @@ - **Consequences:** preview, public HTML and PDF retain one render path; normal entries avoid awkward splits while large content can cross pages safely. Existing variants remain compatible and acquire shared custom ordering on edit. - **User approval required:** No; this implements the requested CV rework without schema, dependency or production changes. - **Reversible:** Revert the renderer/editor/resolver checkpoint; stored settings remain compatible because the existing `Sections` and `custom:` contract is used. + +## DEC-071 — Encapsulate application-answer compatibility storage + +- **Date:** 2026-08-15 +- **Decision:** Keep the existing marked answer block in `JobApplication.Notes` for storage compatibility, but make the backend the owner of extracting, removing and updating it. Expose human notes and application answers as separate workspace fields, and preserve the answer through the general application editor. +- **Reason/evidence:** the retired modal was the only editor that understood the marker. The dedicated page displayed markers as ordinary notes, and saving the general editor could erase the answer. A new schema/table would add migration risk for one text value while the existing representation remains adequate behind a clean boundary. +- **Alternatives considered:** expose the marker format in every editor; create a second application-package table immediately; copy the retired modal wholesale. These leak implementation details, add avoidable migration/state duplication, or restore the popup architecture the user rejected. +- **Consequences:** the dedicated page edits and clears answer/recruiter drafts directly, ordinary notes remain readable, existing rows require no migration, and legacy calls continue to work. A future normalized column/table can migrate behind the same API without another UI change. +- **User approval required:** No; this is compatibility-safe implementation of the requested dedicated workspace. +- **Reversible:** Revert the workspace/API boundary; no schema or stored-data rewrite occurred. diff --git a/docs/work-programmes/master-progress.md b/docs/work-programmes/master-progress.md index 26257d0..f0fe04b 100644 --- a/docs/work-programmes/master-progress.md +++ b/docs/work-programmes/master-progress.md @@ -2,17 +2,17 @@ Updated: 2026-08-15 -- **Overall programme status:** Active. Seven packages are locally verified; twenty-two are implemented with verification incomplete; JOBS-002 is in progress. The prioritized admin-only version indicator is implemented and focused-tested on the release branch; the current branch still requires full/remote gates and production verification. -- **Current work package:** `JOBS-002` — applications table and dedicated workspace (`IN PROGRESS`). The canonical page/table/sidebar integration exists; remaining parity, dirty-edit, tenant-authorization and browser regression work is tracked in the immediate queue. +- **Overall programme status:** Active. Seven packages are locally verified; twenty-two are implemented with verification incomplete; UX-002 is in progress. The prioritized admin-only version indicator and the repository/browser JOBS-002 scope are implemented on the release branch; remote and production verification remain. +- **Current work package:** `UX-002` — cross-application contrast/accessibility completion (`IN PROGRESS`). JOBS-002 now has application-package parity, dirty-navigation protection, tenant regressions and real-Chromium responsive/theme/history/error evidence. - **Completed work packages:** None are `DONE`; all repository security/AI packages still have applicable browser, provider and/or production gates. - **Locally verified work:** SEC-001, SEC-002, SEC-003, SEC-005A, CORE-001, PROD-002 and DEP-001 (`VERIFIED LOCALLY`). -- **Implemented, verification incomplete:** SEC-004, SEC-005B, SEC-008, CORE-002, BG-001, OPS-001A/B/C, POL-001/002, AI-001/002/003/004, UX-001/002/003, QA-001, CAREER-001/002, MAIL-001 and JOBS-001 (`IMPLEMENTED — NOT VERIFIED`). UX-003 safe local/browser scope is implemented; production/native-device gates remain. +- **Implemented, verification incomplete:** SEC-004, SEC-005B, SEC-008, CORE-002, BG-001, OPS-001A/B/C, POL-001/002, AI-001/002/003/004, UX-001/003, QA-001, CAREER-001/002, MAIL-001 and JOBS-001/002 (`IMPLEMENTED — NOT VERIFIED`). JOBS-002 and UX-003 safe local/browser scope is implemented; production/native-device gates remain. - **Production-verified work:** None. - **Blocked work:** SEC-006 parser upgrades remain outside the scoped frontend advisory permission; PROD-001/003/004 and REL-001 require documented production access and unfinished dependencies. Real provider, SMTP/MariaDB and production environments are unavailable; DEP-001 awaits approved merge/live verification. The in-app browser is available for local UI checks. - **Deferred work:** None. Conditional multi-replica coordination, model deletion, realtime operation delivery and unrelated production changes remain outside current packages. -- **Immediate order:** JOBS-002 parity/regression; cross-app contrast/accessibility; PRODUCT-001; VER-001. Admin version (`a6cffe0`), Career lossless persistence (`f0b9b22`) and CV contact contrast are locally complete. External-only work remains skipped, not allowed to stall this queue. +- **Immediate order:** cross-app contrast/accessibility; PRODUCT-001; VER-001; tracking/blocker reconciliation. Admin version (`a6cffe0`), Career lossless persistence (`f0b9b22`), CV contact contrast (`3b86ea2`) and JOBS-002 repository/browser scope are locally complete. External-only work remains skipped, not allowed to stall this queue. - **Status counts:** 7 `VERIFIED LOCALLY`; 22 `IMPLEMENTED — NOT VERIFIED`; 1 `IN PROGRESS`; 4 `NOT STARTED`; 5 `BLOCKED`; 0 `DONE`; 0 `DEFERRED`. -- **Test status:** backend 644/644; CV renderer/settings 25/25; CV Builder/public UI 22/22; affected Career/import/profile/job paths 112/112; Career/Profile UI 17/17; prior frontend 53/53 suites and 221/221 tests plus optimized production build/TypeScript pass. Real Chromium contrast/overflow checks pass and the harder pathological fixture exports a 17-page A4 PDF with final-page text. Prior AI sidecar 22/22, Playwright 6/6 and npm audit 0 evidence remain current. Historical JT-019 and Jest force-exit/open-handle behavior remain recorded. +- **Test status:** backend 647/647; frontend 54/54 suites and 227/227 tests; optimized production build/TypeScript pass; Playwright 7/7. The application workspace journey passes keyboard entry, Back/Forward, focus return, unsaved navigation, saved-draft refresh, missing-job handling, long data, and explicit light/dark 375/768/1440 no-overflow checks. CV renderer/settings 25/25, CV Builder/public UI 22/22, AI sidecar 22/22 and npm audit 0 evidence remain current. Historical JT-019 and Jest force-exit/open-handle behavior remain recorded. - **Deployment status:** Gitea pull-request run 609 passed the complete CI job in 4m20s. Deploy was intentionally skipped because the workflow deploys only a `push` to `main`; live remains unchanged. No merge/deployment was performed directly, no production migrations were run and the AI operation worker remains disabled by default. - **Production status:** Unchanged and unverified. No provider/model call, model pull, external request or paid API occurred. - **Known regressions:** None found by automated/local browser checks. Jest still needs `--forceExit` and reports its existing open-handle notice. Email-provider/send tests are fake/local only; real delivery is not claimed. Current MAIL browser evidence is 1280×720 only because the browser surface could not resize or perform native Tab traversal. Interrupted attempts are aged after 15 minutes and notified without retry; the five-minute scan is unmeasured on a large ledger. Direct clean EF-only SQLite migration still hits the pre-existing historical blank-chain defect before later migrations; normal startup owns reconciliation. Cross-feature monthly AI usage accounting remains a rollout gap. diff --git a/docs/work-programmes/master-work-plan.md b/docs/work-programmes/master-work-plan.md index f763e07..0117c50 100644 --- a/docs/work-programmes/master-work-plan.md +++ b/docs/work-programmes/master-work-plan.md @@ -16,7 +16,7 @@ Allowed statuses are `NOT STARTED`, `IN PROGRESS`, `IMPLEMENTED — NOT VERIFIED `DONE` requires every applicable acceptance criterion, focused and regression tests, browser/accessibility/theme/mobile checks, tenant and entitlement checks, documentation, migration/rollback evidence, and production verification. Repository-only work that still requires production is at most `VERIFIED LOCALLY`. -Exactly one implementation item may be `IN PROGRESS`. As of this revision it is **JOBS-002**. +Exactly one implementation item may be `IN PROGRESS`. As of this revision it is **UX-002**. ## Consolidated dependency order @@ -57,8 +57,8 @@ This queue records the highest-value work that can proceed without production cr | 1 | Admin-only deployed-version indicator in the application header | DEP-001, VER-001 | Implemented with authenticated API and shell tests. The badge shows the CI deployment version and exposes the commit SHA in its accessible label/tooltip only for administrators; full regression, remote CI and deployment smoke remain. | | 2 | Lossless Career field persistence | CAREER-001 | Implemented and locally verified. Manual website/location/contact/date/language values now use a reviewed-data persistence boundary; extraction heuristics remain isolated to extraction. Full remote/production smoke remains. | | 3 | CV contact/header/sidebar contrast correction | CAREER-002 | Implemented and locally verified. Header/custom-accent and sidebar palettes own readable foregrounds; real Chromium computed-style/overflow checks and a 17-page A4 PDF proof pass. | -| 4 | Dedicated Job Details parity and JOBS-002 closure | JOBS-002 | Next implementable package and in progress. Finish any remaining legacy follow-up/application-package parity, dirty-edit behavior, tenant authorization and 375/768/1440 theme/keyboard/history/error/long-data verification. | -| 5 | Cross-application contrast/accessibility pass | UX-002, UX-003, VER-001 | Queued after the scoped Career/CV corrections. Audit semantic alerts, secondary text, focus, loading/empty/error states and remaining hardcoded colors before documenting larger redesigns. | +| 4 | Dedicated Job Details parity and JOBS-002 closure | JOBS-002 | Locally complete. Application answers/recruiter drafts now live on the dedicated page, note markers are encapsulated, edits are lossless, dirty navigation is guarded, focus returns to the row, tenant regressions pass, and Chromium covers 375/768/1440 light/dark/history/error/long data. Production smoke remains. | +| 5 | Cross-application contrast/accessibility pass | UX-002, UX-003, VER-001 | In progress. Audit semantic alerts, secondary text, focus, loading/empty/error states and remaining hardcoded colors before documenting larger redesigns. | | 6 | Honest Free/Pro homepage and upgrade surfaces | PRODUCT-001 | Not started. Inventory existing claims first; do not invent pricing, limits or trial terms before billing configuration is real. | | 7 | Complete application action matrix and full regression | VER-001 | Not started. Populate incrementally, then run the complete backend/frontend/sidecar/E2E gates and accurately classify external production/provider checks. | | 8 | Tracking and blocker reconciliation | All | Keep this plan, progress, handoff, verification log and `BLOCKERS.md` aligned after every logical increment; remove stale CI/dependency statements only when current evidence proves them obsolete. | @@ -700,11 +700,11 @@ This queue records the highest-value work that can proceed without production cr - **Required tests:** route/history/filter persistence/focus/unsaved/direct link/mobile, tenant authorization. - **Required browser verification:** three widths/themes/keyboard/back-forward/refresh/error/long data. - **Required production verification:** existing application/workspace smoke. -- **Status:** `IN PROGRESS`. -- **Blocker:** none after dependencies. +- **Status:** `IMPLEMENTED — NOT VERIFIED`. +- **Blocker:** authenticated production smoke remains external. - **Evidence:** V-158–V-162; `docs/verification/jobs-002-application-workspace.md`. - **Commit:** `bd5362c` (URL-owned list state), `109745e` (canonical dedicated page/table/sidebar integration). -- **Remaining work:** canonical `/jobs/:id`, row/card navigation, compact priority columns, richer job details, contextual links, sidebar cleanup and notification popover are implemented. Still required: confirm section dirty-edit behavior; browser widths/themes/keyboard/history/error/long-data checks; authorization regression and production smoke. Do not place every field in the table or duplicate workspace data. +- **Remaining work:** repository and real-Chromium scope is locally complete, including application-package parity, dirty navigation, focus return, tenant denial, three widths/themes/history/error/long data. Authenticated production smoke and a native assistive-technology spot check remain. Do not place every field in the table or duplicate workspace data. ### UX-003 — Kanban theme-state correction diff --git a/docs/work-programmes/session-handoff.md b/docs/work-programmes/session-handoff.md index 6e6127d..b543c1d 100644 --- a/docs/work-programmes/session-handoff.md +++ b/docs/work-programmes/session-handoff.md @@ -2,17 +2,17 @@ Updated: 2026-08-15 -- **Exact current task:** commit/push the CV contrast/public-render hardening increment, then continue JOBS-002 parity and regression closure. -- **Last completed step:** fixed header/sidebar contact contrast, added automatic custom-accent foreground selection, and restricted public renderer colour/font overrides to safe supported values. -- **Files currently modified:** CV settings normalization, themed renderer, renderer regressions and CAREER-002/work-programme evidence. -- **Commands already run:** failing renderer contrast reproduction; renderer/settings 25/25; full backend 644/644; CV Builder/public UI 22/22; real Chromium computed-style/overflow and 17-page PDF proof; diff check. -- **Test results:** all repository tests listed above pass. Chromium computed expected white/dark/white foregrounds for Modern/default, Modern/light override and Technical/sidebar, with zero overflow; the 259,447-byte A4 PDF has 17 pages and extractable final-page text. Jest retains the documented force-exit/open-handle notice. +- **Exact current task:** commit/push JOBS-002 application-package parity and navigation-safety closure, then continue the cross-application contrast/accessibility pass. +- **Last completed step:** moved application-answer/recruiter drafts onto the dedicated workspace, encapsulated legacy note markers, preserved answers through ordinary edits, guarded dirty navigation and restored list focus. +- **Files currently modified:** workspace/draft backend and UI boundaries, route/focus behavior, unit/Chromium regressions, application-workspace architecture and programme evidence. +- **Commands already run:** focused backend 30/30; focused frontend 28/28 plus route/focus 6/6; full backend 647/647; full frontend 54 suites/227 tests; optimized build; full Playwright 7/7; repeated targeted workspace Chromium passes; diff review pending after documentation. +- **Test results:** all repository tests listed above pass. Workspace Chromium covers explicit light/dark at 375/768/1440, keyboard entry, Back/Forward, focus return, refresh, dirty-edit cancel, long data, missing-job handling and zero horizontal overflow. Jest retains the documented force-exit/open-handle notice. - **Services currently running:** none on task-owned ports 3000/5202. Playwright stopped its disposable API/Next servers. Pre-existing Docker services were not changed. - **Temporary files or processes:** no task-owned process is running and the failed disposable migration database was removed. Existing synthetic browser evidence/account and startup-created local backup remain documented. No provider account, real email, private content, paid service or production service was accessed. - **Production changes currently active:** none. No deployment, migration, provider connection/sync/send or production payload occurred. - **Rollback status:** downgrade `20260810080858_AddEmailDraftClientRequestId`, then `20260810075206_AddEmailDrafts`, before reverting draft commits; then follow the existing MAIL rollback order (`ee5ef7e`, `449faeb`, `123fc55`/`e9937ac`, ledger downgrade before `653f011`). No production migration/deploy/provider grant occurred. -- **Uncommitted changes:** V-168 CV contrast and renderer-setting hardening; no dependency/schema/config/migration change. V-166/V-167 are pushed as `a6cffe0`/`f0b9b22`. +- **Uncommitted changes:** V-169 JOBS-002 parity/navigation increment; no dependency/schema/config/migration change. V-166/V-167/V-168 are pushed as `a6cffe0`/`f0b9b22`/`3b86ea2`. - **Known failures:** live deployment is not verified because PR deploy is intentionally skipped and the active branch is not approved for merge. Draft export/API/UI, full thread/category actions and non-Gmail review remain; existing accounts need re-consent and IMAP stays read-only. A clean full-chain SQLite apply fails in the pre-existing JT-019 migration before the new draft migration. Browser/provider/MariaDB/production unavailable or unverified; recovery scan performance is unmeasured at large ledger scale; Jest open handles; SEC-006 parser dependency work is still separately gated; parser isolation remains SEC-007. -- **Exact next action:** review, commit and push V-168; then trace the remaining legacy-vs-dedicated Job Details feature parity and close the highest-value JOBS-002 gap. -- **Work that can continue independently:** the immediate queue in the master plan: Career lossless persistence, CV contrast, JOBS-002 closure, cross-app contrast/accessibility, PRODUCT-001 and VER-001. UX/JOBS production, MAIL provider mutations, SEC-006/007 and PROD packages retain their recorded external gates. +- **Exact next action:** review, commit and push V-169; then execute the scoped cross-application contrast/accessibility inventory and corrections under UX-002/UX-003/VER-001. +- **Work that can continue independently:** cross-app contrast/accessibility, PRODUCT-001, VER-001 and tracking reconciliation. UX/JOBS production, MAIL provider mutations, SEC-006/007 and PROD packages retain their recorded external gates. - **Decisions still required from the user:** none for synthetic/code-inspected repository work. Any provider connection or send test, internet/package upgrades, private data, external/paid providers and production actions retain explicit approval/safety gates; SEC-009 retention/legal policy remains unresolved. diff --git a/job-tracker-ui/e2e/smoke.spec.ts b/job-tracker-ui/e2e/smoke.spec.ts index 2fc66a2..f37925c 100644 --- a/job-tracker-ui/e2e/smoke.spec.ts +++ b/job-tracker-ui/e2e/smoke.spec.ts @@ -45,6 +45,118 @@ test("a saved job can be created through the reviewed UI flow", async ({ page }) await expect(page.getByText(title, { exact: true })).toBeVisible(); }); +test("the dedicated application workspace survives deep links, long data and unsaved navigation", async ({ page }) => { + const suffix = Date.now().toString(); + const companyName = `Application Workspace Company With A Deliberately Long Name ${suffix}`; + const title = `Principal Platform Reliability Engineer For Distributed Customer Systems ${suffix}`; + + await login(page); + const headers = await csrfHeader(page); + let companyResponse = await page.request.post(`${apiUrl}/companies`, { + headers, + data: { name: companyName, location: "Oslo and remote across Europe", source: "direct" }, + }); + for (let attempt = 0; attempt < 2 && !companyResponse.ok(); attempt += 1) { + await page.waitForTimeout(250); + companyResponse = await page.request.post(`${apiUrl}/companies`, { + headers, + data: { name: companyName, location: "Oslo and remote across Europe", source: "direct" }, + }); + } + const company = await companyResponse.json(); + expect(companyResponse.ok(), `company create failed: ${companyResponse.status()} ${JSON.stringify(company)}`).toBeTruthy(); + + const jobData = { + jobTitle: title, + companyId: company.id, + status: "Applied", + location: "Oslo and remote across Europe", + salary: null, + salaryMin: null, + salaryMax: null, + salaryCurrency: null, + salaryPeriod: null, + nextAction: "Prepare a concise application answer", + followUpAt: null, + notes: "Ask about platform ownership and the incident response rotation.", + description: `Build reliable distributed systems. ${"Long responsibility text ".repeat(80)} https://example.test/${"very-long-path-segment/".repeat(12)}`, + translatedDescription: null, + descriptionLanguage: "en", + tags: JSON.stringify([".NET", "Kubernetes", "Incident response"]), + deadline: null, + coverLetterText: null, + jobUrl: "https://example.test/jobs/platform-reliability", + dateApplied: new Date().toISOString(), + feedbackRequestedAt: null, + source: "direct", + countryCode: "NO", + }; + let jobResponse = await page.request.post(`${apiUrl}/jobapplications`, { headers, data: jobData }); + for (let attempt = 0; attempt < 2 && !jobResponse.ok(); attempt += 1) { + await page.waitForTimeout(250); + jobResponse = await page.request.post(`${apiUrl}/jobapplications`, { headers, data: jobData }); + } + expect(jobResponse.ok()).toBeTruthy(); + const job = await jobResponse.json(); + + await page.goto("/jobs"); + const applicationRow = page.getByRole("row", { name: new RegExp(`Open ${title}`, "i") }); + await applicationRow.focus(); + await page.keyboard.press("Enter"); + await expect(page).toHaveURL(new RegExp(`/jobs/${job.id}$`)); + await expect(page.getByRole("heading", { name: title })).toBeVisible(); + + await page.goBack(); + await expect(page).toHaveURL(/\/jobs$/); + await page.goForward(); + await expect(page).toHaveURL(new RegExp(`/jobs/${job.id}$`)); + + const workspaceNav = page.getByRole("navigation", { name: "Workspace sections" }); + await workspaceNav.getByRole("button", { name: "Cover Letter", exact: true }).click(); + await expect(page).toHaveURL(new RegExp(`/jobs/${job.id}\\?section=cover-letter$`)); + await page.getByLabel("Application answer").fill("A reviewed answer that must not be lost."); + await workspaceNav.getByRole("button", { name: "Match", exact: true }).click(); + await expect(page.getByRole("dialog", { name: "Unsaved application changes" })).toBeVisible(); + await page.getByRole("button", { name: "Keep editing" }).click(); + await expect(page.getByLabel("Application answer")).toHaveValue("A reviewed answer that must not be lost."); + await page.getByRole("button", { name: "Save application drafts" }).click(); + await expect(page.getByText("Unsaved changes")).toHaveCount(0); + + await page.evaluate(() => window.localStorage.setItem("jobtracker.themeMode", "light")); + await page.reload(); + for (const width of [375, 768, 1440]) { + await page.setViewportSize({ width, height: 900 }); + await expect(page.getByRole("heading", { name: title })).toBeVisible(); + const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth); + expect(overflow).toBeLessThanOrEqual(1); + } + + await page.evaluate(() => window.localStorage.setItem("jobtracker.themeMode", "dark")); + await page.reload(); + await expect(page.getByLabel("Application answer")).toHaveValue("A reviewed answer that must not be lost."); + await expect(page.locator("html")).toHaveAttribute("data-color-scheme", "dark"); + + for (const width of [375, 768, 1440]) { + await page.setViewportSize({ width, height: 900 }); + await expect(page.getByRole("heading", { name: title })).toBeVisible(); + const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth); + expect(overflow).toBeLessThanOrEqual(1); + } + + await workspaceNav.getByRole("button", { name: "Job Details", exact: true }).click(); + await expect(page).toHaveURL(new RegExp(`/jobs/${job.id}\\?section=job-details$`)); + await expect(page.getByText("Ask about platform ownership and the incident response rotation.")).toBeVisible(); + await expect(page.getByText("<<>>")).toHaveCount(0); + + await page.getByRole("button", { name: "Back to applications" }).click(); + await expect(page).toHaveURL(/\/jobs$/); + await expect(page.getByRole("row", { name: new RegExp(`Open ${title}`, "i") })).toBeFocused(); + + await page.goto("/jobs/2147483647"); + await expect(page.getByRole("alert").filter({ hasText: /Not Found|Could not open this application/i })).toBeVisible(); + await expect(page.getByRole("button", { name: "Back to applications" })).toBeVisible(); +}); + test("Career Workspace loads from the authenticated application shell", async ({ page }) => { await login(page); await page.goto("/career"); diff --git a/job-tracker-ui/src/application-assets.test.tsx b/job-tracker-ui/src/application-assets.test.tsx index 9f98b48..520520a 100644 --- a/job-tracker-ui/src/application-assets.test.tsx +++ b/job-tracker-ui/src/application-assets.test.tsx @@ -3,7 +3,7 @@ import "@testing-library/jest-dom"; import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { - ApplicationCoverLetterSection, ApplicationCvSection, + ApplicationCoverLetterSection, ApplicationCvSection, ApplicationPackageDraftsSection, } from "./components/ApplicationAssets"; import { api } from "./api"; @@ -134,11 +134,13 @@ test("cover letter loads the current text and its history", async () => { test("editing marks the draft dirty and saving sends the new text", async () => { routeGet(); mockedApi.put.mockResolvedValue({ data: { ...coverLetter, text: "Dear hiring team" } } as any); + const onDirtyChange = jest.fn(); - render(); + render(); fireEvent.change(await screen.findByLabelText("Cover letter"), { target: { value: "Dear hiring team" } }); expect(screen.getByText("Unsaved changes")).toBeInTheDocument(); + expect(onDirtyChange).toHaveBeenLastCalledWith(true); fireEvent.click(screen.getByRole("button", { name: "Save" })); await waitFor(() => expect(mockedApi.put).toHaveBeenCalledWith( @@ -187,3 +189,54 @@ test("a failed load surfaces an error", async () => { expect(await screen.findByText(/Could not load this section/i)).toBeInTheDocument(); }); + +// ---------- Application package drafts ---------- + +test("application answer and recruiter drafts save from the dedicated workspace", async () => { + mockedApi.put.mockResolvedValue({ data: undefined } as any); + const onSaved = jest.fn(); + + render( + , + ); + + fireEvent.change(screen.getByLabelText("Application answer"), { target: { value: "Edited answer" } }); + fireEvent.change(screen.getByLabelText("Recruiter message"), { target: { value: "Edited recruiter note" } }); + expect(screen.getByText("Unsaved changes")).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Save application drafts" })); + + await waitFor(() => expect(mockedApi.put).toHaveBeenCalledWith( + "/jobapplications/7/application-drafts", + { + applicationAnswerDraft: "Edited answer", + recruiterMessageDraft: "Edited recruiter note", + }, + )); + expect(onSaved).toHaveBeenCalled(); +}); + +test("application package drafts can be cleared without deleting ordinary notes", async () => { + mockedApi.put.mockResolvedValue({ data: undefined } as any); + + render( + , + ); + + fireEvent.change(screen.getByLabelText("Application answer"), { target: { value: "" } }); + fireEvent.change(screen.getByLabelText("Recruiter message"), { target: { value: "" } }); + fireEvent.click(screen.getByRole("button", { name: "Save application drafts" })); + + await waitFor(() => expect(mockedApi.put).toHaveBeenCalledWith( + "/jobapplications/7/application-drafts", + { applicationAnswerDraft: "", recruiterMessageDraft: "" }, + )); +}); diff --git a/job-tracker-ui/src/application-drafts.test.ts b/job-tracker-ui/src/application-drafts.test.ts new file mode 100644 index 0000000..2febc7a --- /dev/null +++ b/job-tracker-ui/src/application-drafts.test.ts @@ -0,0 +1,24 @@ +import { + extractApplicationAnswerDraft, removeApplicationAnswerDraft, upsertApplicationAnswerDraft, +} from "./applicationDrafts"; + +test("application answer helpers keep human notes separate", () => { + const stored = upsertApplicationAnswerDraft("Human note", " Draft answer "); + + expect(stored).toBe("Human note\n\n<<>>\nDraft answer\n<<>>"); + expect(extractApplicationAnswerDraft(stored)).toBe("Draft answer"); + expect(removeApplicationAnswerDraft(stored)).toBe("Human note"); +}); + +test("clearing an answer retains human notes and removes all marker blocks", () => { + const duplicated = "Human note\n\n<<>>\nFirst\n<<>>\n\n<<>>\nSecond\n<<>>"; + + expect(upsertApplicationAnswerDraft(duplicated, "")).toBe("Human note"); +}); + +test("legacy application answer labels remain readable during migration", () => { + const legacy = "Human note\n\nApplication answer draft:\nLegacy answer"; + + expect(extractApplicationAnswerDraft(legacy)).toBe("Legacy answer"); + expect(removeApplicationAnswerDraft(legacy)).toBe("Human note"); +}); diff --git a/job-tracker-ui/src/application-workspace-overlay.test.tsx b/job-tracker-ui/src/application-workspace-overlay.test.tsx index aa54fea..49edb94 100644 --- a/job-tracker-ui/src/application-workspace-overlay.test.tsx +++ b/job-tracker-ui/src/application-workspace-overlay.test.tsx @@ -1,7 +1,7 @@ import React from "react"; import "@testing-library/jest-dom"; import { fireEvent, render, screen, waitFor, within } from "@testing-library/react"; -import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom"; +import { createMemoryRouter, RouterProvider, Route, Routes, useLocation } from "react-router-dom"; import { api } from "./api"; import JobTable from "./components/JobTable"; @@ -21,13 +21,40 @@ jest.mock("./components/ApplicationIntelligence", () => ({ ApplicationTimeline: () =>
Timeline section
, })); jest.mock("./components/ApplicationAssets", () => ({ - ApplicationCoverLetterSection: () =>
Cover letter section
, + ApplicationCoverLetterSection: ({ onDirtyChange }: { onDirtyChange?: (dirty: boolean) => void }) => ( +
+ Cover letter section + +
+ ), ApplicationCvSection: () =>
CV section
, + ApplicationPackageDraftsSection: () =>
Application drafts section
, })); jest.mock("./components/InterviewPrep", () => ({ ApplicationInterviewPrep: () =>
Interview section
})); const mockedApi = api as jest.Mocked; +// React Router's data router builds a Request for in-memory navigations. JSDOM does not provide +// one, and these tests do not run loaders or inspect request bodies, so a small contract stub is +// enough to exercise blocker/history behavior. +class RouterTestRequest { + url: string; + method: string; + signal?: AbortSignal; + headers: Headers; + body: unknown; + + constructor(url: string, init: RequestInit = {}) { + this.url = url; + this.method = init.method ?? "GET"; + this.signal = init.signal ?? undefined; + this.headers = new Headers(init.headers); + this.body = init.body; + } +} + +Object.assign(globalThis, { Request: RouterTestRequest }); + const job = { id: 42, jobTitle: "Backend Developer", @@ -64,6 +91,8 @@ const overview = { descriptionLanguage: "en", tags: [".NET", "SQL"], notes: "Ask about the platform team.", + applicationAnswerDraft: "Saved answer", + recruiterMessageDraft: "Saved recruiter message", source: "nav", countryCode: "NO", hasJobDescription: true, @@ -88,18 +117,27 @@ function LocationControls() { } function renderTable(path = "/jobs") { + const router = createMemoryRouter([ + { + path: "*", + element: ( + <> + + + {}} columns={{ status: true, dateApplied: true, daysSince: true, jobUrl: false }} onColumnsChange={() => {}} />} /> + } /> + + + ), + }, + ], { initialEntries: [path] }); + return render( - - - - {}} columns={{ status: true, dateApplied: true, daysSince: true, jobUrl: false }} onColumnsChange={() => {}} />} /> - } /> - - + @@ -136,6 +174,7 @@ test("opens the dedicated workspace from the whole row and preserves list state fireEvent.click(screen.getByRole("button", { name: /back to applications/i })); await waitFor(() => expect(screen.getByTestId("location")).toHaveTextContent("/jobs?q=backend")); expect(await screen.findByRole("textbox", { name: /search/i })).toHaveValue("backend"); + await waitFor(() => expect(screen.getByRole("row", { name: /open backend developer/i })).toHaveFocus()); }); test("opens a direct workspace URL and returns to applications", async () => { @@ -165,6 +204,22 @@ test("handles a deleted or inaccessible job without rendering a broken workspace expect(screen.getByRole("button", { name: /back to applications/i })).toBeInTheDocument(); }); +test("warns before section navigation would discard application edits", async () => { + renderTable("/jobs/42?section=cover-letter"); + + fireEvent.click(await screen.findByRole("button", { name: "Make cover letter dirty" })); + fireEvent.click(screen.getByRole("button", { name: "Match" })); + + expect(await screen.findByRole("dialog", { name: /Unsaved application changes/i })).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Keep editing" })); + expect(screen.getByTestId("location")).toHaveTextContent("/jobs/42?section=cover-letter"); + await waitFor(() => expect(screen.queryByRole("dialog", { name: /Unsaved application changes/i })).not.toBeInTheDocument()); + + fireEvent.click(screen.getByRole("button", { name: "Match" })); + fireEvent.click(await screen.findByRole("button", { name: "Discard and leave" })); + await waitFor(() => expect(screen.getByTestId("location")).toHaveTextContent("/jobs/42?section=match")); +}); + test("hydrates list filters, sort and page from a shareable URL", async () => { renderTable("/jobs?q=backend&status=Interview&companyId=1&location=Oslo&needsFollowUp=1&readiness=interview&includeDeleted=1&sortBy=company&sortDir=asc&page=2"); diff --git a/job-tracker-ui/src/applicationDrafts.ts b/job-tracker-ui/src/applicationDrafts.ts new file mode 100644 index 0000000..49eae1a --- /dev/null +++ b/job-tracker-ui/src/applicationDrafts.ts @@ -0,0 +1,31 @@ +const APPLICATION_ANSWER_START = "<<>>"; +const APPLICATION_ANSWER_END = "<<>>"; + +export function extractApplicationAnswerDraft(notes?: string | null) { + const value = (notes ?? "").trim(); + if (!value) return ""; + + const startIndex = value.indexOf(APPLICATION_ANSWER_START); + const endIndex = value.indexOf(APPLICATION_ANSWER_END); + if (startIndex >= 0 && endIndex > startIndex) { + return value.slice(startIndex + APPLICATION_ANSWER_START.length, endIndex).trim(); + } + + const legacyMatch = value.match(/Application answer draft:\s*\n([\s\S]*)$/i); + return legacyMatch?.[1]?.trim() ?? ""; +} + +export function removeApplicationAnswerDraft(notes?: string | null) { + const value = notes ?? ""; + const markerPattern = new RegExp(`${APPLICATION_ANSWER_START}[\\s\\S]*?${APPLICATION_ANSWER_END}`, "g"); + const withoutMarkers = value.replace(markerPattern, "").trim(); + const legacyIndex = withoutMarkers.search(/Application answer draft:\s*\n/i); + return (legacyIndex >= 0 ? withoutMarkers.slice(0, legacyIndex) : withoutMarkers).trim(); +} + +export function upsertApplicationAnswerDraft(notes: string | null | undefined, draft: string) { + const humanNotes = removeApplicationAnswerDraft(notes); + const answer = draft.trim(); + const block = answer ? `${APPLICATION_ANSWER_START}\n${answer}\n${APPLICATION_ANSWER_END}` : ""; + return [humanNotes, block].filter(Boolean).join("\n\n"); +} diff --git a/job-tracker-ui/src/applicationWorkspace.ts b/job-tracker-ui/src/applicationWorkspace.ts index 807d119..e796efe 100644 --- a/job-tracker-ui/src/applicationWorkspace.ts +++ b/job-tracker-ui/src/applicationWorkspace.ts @@ -32,6 +32,8 @@ export type WorkspaceOverview = { descriptionLanguage: string | null; tags: string[]; notes: string | null; + applicationAnswerDraft: string | null; + recruiterMessageDraft: string | null; source: string | null; countryCode: string | null; hasJobDescription: boolean; @@ -241,6 +243,8 @@ export const applicationAssetsApi = { api.put(`/jobapplications/${jobId}/cover-letter`, { text, source, aiAction }).then((r) => r.data), restoreCoverLetter: (jobId: number, version: number) => api.post(`/jobapplications/${jobId}/cover-letter/versions/${version}/restore`).then((r) => r.data), + saveApplicationDrafts: (jobId: number, applicationAnswerDraft: string, recruiterMessageDraft: string) => + api.put(`/jobapplications/${jobId}/application-drafts`, { applicationAnswerDraft, recruiterMessageDraft }).then(() => undefined), }; // Phase 5.5 — Interview preparation and follow-up. Prep content is the user's; AI suggestions come diff --git a/job-tracker-ui/src/components/ApplicationAssets.tsx b/job-tracker-ui/src/components/ApplicationAssets.tsx index 31400bc..bcd1aa5 100644 --- a/job-tracker-ui/src/components/ApplicationAssets.tsx +++ b/job-tracker-ui/src/components/ApplicationAssets.tsx @@ -243,7 +243,7 @@ I would welcome the chance to talk it through. Kind regards, [Your name]`; -export function ApplicationCoverLetterSection({ jobId }: { jobId: number }) { +export function ApplicationCoverLetterSection({ jobId, onDirtyChange }: { jobId: number; onDirtyChange?: (dirty: boolean) => void }) { const { data, error, loading, setData, setError } = useAsset( () => applicationAssetsApi.coverLetter(jobId), [jobId], @@ -256,6 +256,11 @@ export function ApplicationCoverLetterSection({ jobId }: { jobId: number }) { const text = draft ?? data?.text ?? ""; const dirty = draft !== null && draft !== (data?.text ?? ""); + useEffect(() => { + onDirtyChange?.(dirty); + return () => onDirtyChange?.(false); + }, [dirty, onDirtyChange]); + const save = async (value: string, source = "manual") => { setBusy(true); try { @@ -367,3 +372,97 @@ export function ApplicationCoverLetterSection({ jobId }: { jobId: number }) { ); } + +// ---------- Application answer and recruiter message ---------- + +type PackageDrafts = { applicationAnswer: string; recruiterMessage: string }; + +export function ApplicationPackageDraftsSection({ + jobId, + initialApplicationAnswer, + initialRecruiterMessage, + onSaved, + onDirtyChange, +}: { + jobId: number; + initialApplicationAnswer: string; + initialRecruiterMessage: string; + onSaved?: () => void; + onDirtyChange?: (dirty: boolean) => void; +}) { + const initial = { applicationAnswer: initialApplicationAnswer, recruiterMessage: initialRecruiterMessage }; + const [saved, setSaved] = useState(initial); + const [draft, setDraft] = useState(null); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + if (draft === null) setSaved(initial); + // `draft` is deliberately excluded: a parent refresh must never overwrite in-progress edits. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [jobId, initialApplicationAnswer, initialRecruiterMessage]); + + const value = draft ?? saved; + const dirty = draft !== null && ( + draft.applicationAnswer !== saved.applicationAnswer || + draft.recruiterMessage !== saved.recruiterMessage + ); + + useEffect(() => { + onDirtyChange?.(dirty); + return () => onDirtyChange?.(false); + }, [dirty, onDirtyChange]); + const update = (patch: Partial) => setDraft({ ...value, ...patch }); + + const save = async () => { + setBusy(true); + try { + await applicationAssetsApi.saveApplicationDrafts(jobId, value.applicationAnswer, value.recruiterMessage); + setSaved(value); + setDraft(null); + setError(null); + onSaved?.(); + } catch (err) { + setError(getApiErrorMessage(err, "Could not save the application drafts.")); + } finally { + setBusy(false); + } + }; + + return ( + + + update({ applicationAnswer })} + placeholder="Draft an answer for motivation, suitability, or another application-form question." + /> + update({ recruiterMessage })} + placeholder="Draft a concise message to the recruiter or hiring manager." + /> + + + + {dirty && } + + + + ); +} diff --git a/job-tracker-ui/src/components/EditJobDialog.tsx b/job-tracker-ui/src/components/EditJobDialog.tsx index 1c764e7..09bbbc4 100644 --- a/job-tracker-ui/src/components/EditJobDialog.tsx +++ b/job-tracker-ui/src/components/EditJobDialog.tsx @@ -25,6 +25,7 @@ import { useCompanies } from "../hooks/useCompanies"; import TagsInput from "./TagsInput"; import { useI18n } from "../i18n/I18nProvider"; import { PIPELINE_STATUSES, statusLabel } from "../pipeline"; +import { removeApplicationAnswerDraft } from "../applicationDrafts"; interface Props { open: boolean; @@ -121,7 +122,7 @@ export default function EditJobDialog({ open, jobId, onClose, onSaved }: Props) setNextAction((j as any).nextAction ?? ""); setFollowUpAt((j as any).followUpAt ? toDateInputValue((j as any).followUpAt) : ""); setJobUrl(j.jobUrl ?? ""); - setNotes(j.notes ?? ""); + setNotes(removeApplicationAnswerDraft(j.notes)); setDescription((j as any).description ?? ""); setTranslatedDescription((j as any).translatedDescription ?? ""); setDescriptionLanguage((j as any).descriptionLanguage ?? ""); diff --git a/job-tracker-ui/src/components/JobDetailsDialog.tsx b/job-tracker-ui/src/components/JobDetailsDialog.tsx index 2ea0181..c4b13f4 100644 --- a/job-tracker-ui/src/components/JobDetailsDialog.tsx +++ b/job-tracker-ui/src/components/JobDetailsDialog.tsx @@ -38,6 +38,7 @@ import { useI18n } from "../i18n/I18nProvider"; import { useJobWorkspaceBaseData } from "./job-workspace/useJobWorkspaceBaseData"; import { useWorkspaceTabCache } from "./job-workspace/useWorkspaceTabCache"; import { useAccountPlan } from "../accountPlan"; +import { upsertApplicationAnswerDraft } from "../applicationDrafts"; type GenerationMode = "default" | "concise" | "ats" | "achievement" | "interview"; type CoverLetterStyle = "balanced" | "concise" | "formal" | "bold"; @@ -85,31 +86,6 @@ function copyLines(items: string[]) { return navigator.clipboard.writeText(items.map((item) => `• ${item}`).join("\n")); } -const APPLICATION_ANSWER_START = "<<>>"; -const APPLICATION_ANSWER_END = "<<>>"; - -function upsertApplicationAnswerDraft(notes: string | null | undefined, draft: string) { - const trimmedNotes = (notes ?? "").trim(); - const trimmedDraft = draft.trim(); - const block = trimmedDraft - ? `${APPLICATION_ANSWER_START}\n${trimmedDraft}\n${APPLICATION_ANSWER_END}` - : ""; - - if (!trimmedNotes) return block; - - const markerPattern = new RegExp(`${APPLICATION_ANSWER_START}[\\s\\S]*?${APPLICATION_ANSWER_END}`, "g"); - if (markerPattern.test(trimmedNotes)) { - return block ? trimmedNotes.replace(markerPattern, block).trim() : trimmedNotes.replace(markerPattern, "").trim(); - } - - const legacyPattern = /(?:\n\n)?Application answer draft:\s*\n[\s\S]*$/i; - if (legacyPattern.test(trimmedNotes)) { - return block ? trimmedNotes.replace(legacyPattern, `\n\n${block}`).trim() : trimmedNotes.replace(legacyPattern, "").trim(); - } - - return block ? `${trimmedNotes}\n\n${block}` : trimmedNotes; -} - function getWorkspaceStatus(currentValue: string, savedValue: string) { const current = currentValue.trim(); const saved = savedValue.trim(); diff --git a/job-tracker-ui/src/components/JobTable.tsx b/job-tracker-ui/src/components/JobTable.tsx index 843a2e1..7395743 100644 --- a/job-tracker-ui/src/components/JobTable.tsx +++ b/job-tracker-ui/src/components/JobTable.tsx @@ -175,6 +175,7 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col const location = useLocation(); const navigate = useNavigate(); const listRouteRef = useRef(`${location.pathname}${location.search}`); + const restoredFocusJobIdRef = useRef(null); const [jobs, setJobs] = useState([]); const [total, setTotal] = useState(0); const [page, setPage] = useState(() => queryPage(location.search)); @@ -287,7 +288,7 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col }; const openJob = useCallback((jobId: number, path?: string) => { - navigate(path ?? `/jobs/${jobId}`, { state: { from: listRouteRef.current } }); + navigate(path ?? `/jobs/${jobId}`, { state: { from: listRouteRef.current, focusJobId: jobId } }); }, [navigate]); const params = useMemo(() => ({ @@ -338,6 +339,15 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col return jobs.filter((job) => needsWorkflowWork(job)); }, [jobs, readinessFilter]); + useEffect(() => { + const focusJobId = (location.state as { focusJobId?: unknown } | null)?.focusJobId; + if (typeof focusJobId !== "number" || restoredFocusJobIdRef.current === focusJobId || jobsResource.loading) return; + const row = document.querySelector(`[data-job-row-id="${focusJobId}"]`); + if (!row) return; + restoredFocusJobIdRef.current = focusJobId; + row.focus(); + }, [filteredJobs, jobsResource.loading, location.state]); + // Distinguishes "you have zero jobs, period" from "no results match your filters" so the // empty state can actually help a first-time user instead of just saying "nothing here". const noFiltersActive = !debouncedSearch.trim() && statusFilter === "All" && companyFilterId === "All" @@ -640,6 +650,7 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col return ( = 0 && endIndex > startIndex) { - return value.slice(startIndex + APPLICATION_ANSWER_START.length, endIndex).trim(); - } - - const legacyMatch = value.match(/Application answer draft:\s*\n([\s\S]*)$/i); - return legacyMatch?.[1]?.trim() ?? ""; -} - export function useJobWorkspaceBaseData({ open, jobId, @@ -124,5 +108,4 @@ export function useJobWorkspaceBaseData({ }; } -export { extractApplicationAnswerDraft }; export type { PackageWorkspaceState }; diff --git a/job-tracker-ui/src/views/ApplicationWorkspacePage.tsx b/job-tracker-ui/src/views/ApplicationWorkspacePage.tsx index 278445b..9099881 100644 --- a/job-tracker-ui/src/views/ApplicationWorkspacePage.tsx +++ b/job-tracker-ui/src/views/ApplicationWorkspacePage.tsx @@ -1,5 +1,7 @@ import React, { useCallback, useEffect, useMemo, useState } from "react"; -import { useLocation, useNavigate, useParams, useSearchParams } from "react-router-dom"; +import { + BlockerFunction, useBeforeUnload, useBlocker, useLocation, useNavigate, useParams, useSearchParams, +} from "react-router-dom"; import { Alert, Box, Button, Chip, Divider, IconButton, List, ListItemButton, ListItemText, Paper, @@ -24,10 +26,11 @@ import { ApplicationAnalysis, ApplicationMatch, ApplicationTimeline, } from "../components/ApplicationIntelligence"; import { - ApplicationCoverLetterSection, ApplicationCvSection, + ApplicationCoverLetterSection, ApplicationCvSection, ApplicationPackageDraftsSection, } from "../components/ApplicationAssets"; import { ApplicationInterviewPrep } from "../components/InterviewPrep"; import EditJobDialog from "../components/EditJobDialog"; +import { useConfirm } from "../confirm"; import { WORKSPACE_SECTIONS, WorkspaceOverview, WorkspaceSectionKey, applicationWorkspaceApi, workspaceSection, } from "../applicationWorkspace"; @@ -60,12 +63,48 @@ export function ApplicationWorkspace({ const jobId = jobIdOverride ?? Number(id); const location = useLocation(); const navigate = useNavigate(); + const { confirm } = useConfirm(); const [params, setParams] = useSearchParams(); const section = sectionOverride ?? workspaceSection(params.get("section")); const [overview, setOverview] = useState(null); const [error, setError] = useState(null); const [editOpen, setEditOpen] = useState(false); + const [coverLetterDirty, setCoverLetterDirty] = useState(false); + const [packageDraftsDirty, setPackageDraftsDirty] = useState(false); + const hasUnsavedChanges = coverLetterDirty || packageDraftsDirty; + + const shouldBlock = useCallback( + ({ currentLocation, nextLocation }) => hasUnsavedChanges && ( + currentLocation.pathname !== nextLocation.pathname || currentLocation.search !== nextLocation.search + ), + [hasUnsavedChanges], + ); + const blocker = useBlocker(shouldBlock); + + useBeforeUnload(useCallback((event) => { + if (!hasUnsavedChanges) return; + event.preventDefault(); + event.returnValue = ""; + }, [hasUnsavedChanges])); + + useEffect(() => { + if (blocker.state !== "blocked") return; + const blockedNavigation = blocker; + let active = true; + void confirm({ + title: "Unsaved application changes", + message: "Leaving this section will discard changes that have not been saved.", + confirmLabel: "Discard and leave", + cancelLabel: "Keep editing", + destructive: true, + }).then((approved) => { + if (!active) return; + if (approved) blockedNavigation.proceed(); + else blockedNavigation.reset(); + }); + return () => { active = false; }; + }, [blocker, confirm]); const load = useCallback(async () => { if (!Number.isInteger(jobId) || jobId <= 0) { @@ -90,8 +129,13 @@ export function ApplicationWorkspace({ else setParams({ section: next }, { replace: true, state: location.state }); }; const close = onClose ?? (() => { - const from = (location.state as { from?: unknown } | null)?.from; - navigate(typeof from === "string" && from.startsWith("/") && !from.startsWith("//") ? from : "/jobs", { replace: true }); + const state = location.state as { from?: unknown; focusJobId?: unknown } | null; + const from = state?.from; + const focusJobId = typeof state?.focusJobId === "number" ? state.focusJobId : undefined; + navigate( + typeof from === "string" && from.startsWith("/") && !from.startsWith("//") ? from : "/jobs", + { replace: true, state: focusJobId ? { focusJobId } : undefined }, + ); }); if (error) { @@ -171,7 +215,14 @@ export function ApplicationWorkspace({ {section === "cv" && jobId > 0 && } {section === "cover-letter" && jobId > 0 && ( <> - + + {/* Generation stays an explicit user action, below the editor the user owns. */}