From 9adbde3f5eb2b4387b10c161a7f444b1598f3356 Mon Sep 17 00:00:00 2001 From: cesnimda Date: Tue, 24 Mar 2026 14:28:01 +0100 Subject: [PATCH] =?UTF-8?q?feat(S05/T01):=20Unified=20workflow=20trust=20s?= =?UTF-8?q?ignals=20across=20the=20API,=20table,=20da=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - JobTrackerApi/Controllers/JobApplicationsController.cs - JobTrackerApi.Tests/JobApplicationsWorkflowSignalsTests.cs - job-tracker-ui/src/jobWorkflowSignals.ts - job-tracker-ui/src/components/JobTable.tsx - job-tracker-ui/src/components/DashboardView.tsx - job-tracker-ui/src/components/RemindersView.tsx - job-tracker-ui/src/workflow-trust-signals.test.tsx --- .gsd/journal/2026-03-24.jsonl | 8 + .gsd/milestones/M001/slices/S05/S05-PLAN.md | 2 +- .../M001/slices/S05/tasks/T01-SUMMARY.md | 84 ++++ .../JobApplicationsWorkflowSignalsTests.cs | 152 ++++++ .../Controllers/JobApplicationsController.cs | 436 ++++++++++-------- .../src/components/DashboardView.tsx | 39 +- job-tracker-ui/src/components/JobTable.tsx | 103 ++--- .../src/components/RemindersView.tsx | 32 +- .../src/daily-control-loop.test.tsx | 58 ++- job-tracker-ui/src/jobWorkflowSignals.ts | 80 ++++ job-tracker-ui/src/types.ts | 21 +- .../src/workflow-trust-signals.test.tsx | 273 +++++++++++ 12 files changed, 974 insertions(+), 314 deletions(-) create mode 100644 .gsd/milestones/M001/slices/S05/tasks/T01-SUMMARY.md create mode 100644 JobTrackerApi.Tests/JobApplicationsWorkflowSignalsTests.cs create mode 100644 job-tracker-ui/src/jobWorkflowSignals.ts create mode 100644 job-tracker-ui/src/workflow-trust-signals.test.tsx diff --git a/.gsd/journal/2026-03-24.jsonl b/.gsd/journal/2026-03-24.jsonl index cce412e..b179d66 100644 --- a/.gsd/journal/2026-03-24.jsonl +++ b/.gsd/journal/2026-03-24.jsonl @@ -68,3 +68,11 @@ {"ts":"2026-03-24T13:02:04.657Z","flowId":"9a9419e5-3975-4382-a2e4-b1323aa878eb","seq":2,"eventType":"dispatch-match","rule":"planning → plan-slice","data":{"unitType":"plan-slice","unitId":"M001/S05"}} {"ts":"2026-03-24T13:02:04.662Z","flowId":"9a9419e5-3975-4382-a2e4-b1323aa878eb","seq":3,"eventType":"unit-start","data":{"unitType":"plan-slice","unitId":"M001/S05"}} {"ts":"2026-03-24T13:05:20.939Z","flowId":"9a9419e5-3975-4382-a2e4-b1323aa878eb","seq":4,"eventType":"unit-end","data":{"unitType":"plan-slice","unitId":"M001/S05","status":"completed","artifactVerified":true},"causedBy":{"flowId":"9a9419e5-3975-4382-a2e4-b1323aa878eb","seq":3}} +{"ts":"2026-03-24T13:05:21.511Z","flowId":"9a9419e5-3975-4382-a2e4-b1323aa878eb","seq":5,"eventType":"iteration-end","data":{"iteration":1}} +{"ts":"2026-03-24T13:05:21.513Z","flowId":"f7f931a0-5c2a-4941-952e-74d3f5d60ab0","seq":1,"eventType":"iteration-start","data":{"iteration":2}} +{"ts":"2026-03-24T13:05:21.639Z","flowId":"f7f931a0-5c2a-4941-952e-74d3f5d60ab0","seq":2,"eventType":"dispatch-match","rule":"executing → execute-task","data":{"unitType":"execute-task","unitId":"M001/S05/T01"}} +{"ts":"2026-03-24T13:05:21.648Z","flowId":"f7f931a0-5c2a-4941-952e-74d3f5d60ab0","seq":3,"eventType":"unit-start","data":{"unitType":"execute-task","unitId":"M001/S05/T01"}} +{"ts":"2026-03-24T13:12:04.885Z","flowId":"09dff360-cfa9-4365-b737-b6aea08e2378","seq":1,"eventType":"iteration-start","data":{"iteration":1}} +{"ts":"2026-03-24T13:12:04.965Z","flowId":"09dff360-cfa9-4365-b737-b6aea08e2378","seq":2,"eventType":"dispatch-match","rule":"executing → execute-task","data":{"unitType":"execute-task","unitId":"M001/S05/T01"}} +{"ts":"2026-03-24T13:12:04.972Z","flowId":"09dff360-cfa9-4365-b737-b6aea08e2378","seq":3,"eventType":"unit-start","data":{"unitType":"execute-task","unitId":"M001/S05/T01"}} +{"ts":"2026-03-24T13:28:01.394Z","flowId":"09dff360-cfa9-4365-b737-b6aea08e2378","seq":4,"eventType":"unit-end","data":{"unitType":"execute-task","unitId":"M001/S05/T01","status":"completed","artifactVerified":true},"causedBy":{"flowId":"09dff360-cfa9-4365-b737-b6aea08e2378","seq":3}} diff --git a/.gsd/milestones/M001/slices/S05/S05-PLAN.md b/.gsd/milestones/M001/slices/S05/S05-PLAN.md index 36b13cc..0ef7818 100644 --- a/.gsd/milestones/M001/slices/S05/S05-PLAN.md +++ b/.gsd/milestones/M001/slices/S05/S05-PLAN.md @@ -44,7 +44,7 @@ The work is grouped into two tasks because the slice has two different risks. Fi ## Tasks -- [ ] **T01: Centralize workflow trust signals across overview and readiness surfaces** `est:5h` +- [x] **T01: Centralize workflow trust signals across overview and readiness surfaces** `est:5h` - Why: S05 cannot prove coherence while table, dashboard, reminders, and readiness still infer next actions from separate brittle rules. - Files: `JobTrackerApi/Controllers/JobApplicationsController.cs`, `JobTrackerApi.Tests/JobApplicationsWorkflowSignalsTests.cs`, `job-tracker-ui/src/types.ts`, `job-tracker-ui/src/jobWorkflowSignals.ts`, `job-tracker-ui/src/components/JobTable.tsx`, `job-tracker-ui/src/components/DashboardView.tsx`, `job-tracker-ui/src/components/RemindersView.tsx`, `job-tracker-ui/src/workflow-trust-signals.test.tsx` - Do: add explicit workflow trust/action fields or normalized routing metadata at the controller/DTO layer, introduce a shared UI helper that consumes those fields instead of parsing free-form strings or raw `notes`, and update table/dashboard/reminders to route from the same source of truth without breaking the existing shared `/jobs?open=...&tab=...` workspace entry pattern. diff --git a/.gsd/milestones/M001/slices/S05/tasks/T01-SUMMARY.md b/.gsd/milestones/M001/slices/S05/tasks/T01-SUMMARY.md new file mode 100644 index 0000000..6fbecad --- /dev/null +++ b/.gsd/milestones/M001/slices/S05/tasks/T01-SUMMARY.md @@ -0,0 +1,84 @@ +--- +id: T01 +parent: S05 +milestone: M001 +provides: + - Shared workflow trust/action signals for overview and readiness surfaces +key_files: + - JobTrackerApi/Controllers/JobApplicationsController.cs + - JobTrackerApi.Tests/JobApplicationsWorkflowSignalsTests.cs + - job-tracker-ui/src/jobWorkflowSignals.ts + - job-tracker-ui/src/components/JobTable.tsx + - job-tracker-ui/src/components/DashboardView.tsx + - job-tracker-ui/src/components/RemindersView.tsx + - job-tracker-ui/src/workflow-trust-signals.test.tsx +key_decisions: + - Treated saved application answers as explicit workflow state via WorkflowSignal instead of inferring package readiness from generic notes text. +patterns_established: + - Overview surfaces must derive routing, labels, and grouping from workflowSignal plus jobWorkflowSignals.ts, not from followUpReason string matching. +observability_surfaces: + - GET /api/jobapplications/reminders + - GET /api/jobapplications/{id}/readiness + - job-tracker-ui/src/jobWorkflowSignals.ts + - job-tracker-ui/src/workflow-trust-signals.test.tsx + - JobTrackerApi.Tests/JobApplicationsWorkflowSignalsTests.cs +duration: 2h 10m +verification_result: passed +completed_at: 2026-03-24T14:02:04+01:00 +blocker_discovered: false +--- + +# T01: Centralize workflow trust signals across overview and readiness surfaces + +**Unified workflow trust signals across the API, table, dashboard, and reminders.** + +## What Happened + +I verified the local controller and UI code first, found that the backend already had the beginnings of a `WorkflowSignal` contract, and confirmed the remaining drift was on the consumer side: `JobTable`, `DashboardView`, and `RemindersView` still fell back to `followUpReason` parsing or raw `notes`/`tailoredCvText` heuristics. + +On the backend, I fixed the broken `RulesEngine.Decision` type references so the controller compiled again, kept the saved-application-answer block parsing as the package-readiness source of truth, and added focused tests proving two things: generic notes do not satisfy saved-answer readiness, and reminders/readiness now expose normalized workflow routing metadata for both package work and follow-up work. + +On the frontend, I completed the shared type contract by adding `WorkflowSignal` to `job-tracker-ui/src/types.ts`, extended `ReadinessResponse` to surface that signal, and rewrote `job-tracker-ui/src/jobWorkflowSignals.ts` as the one shared helper for overview routing, labels, and reminder grouping. + +I then updated the three overview surfaces to consume that helper directly. `JobTable` now drives urgency chips, primary actions, and the readiness filter from `workflowSignal` instead of raw `notes` or `followUpReason`. `DashboardView` now chooses reminder labels/routes from the same helper. `RemindersView` now groups jobs and opens workspace tabs from `workflowSignal` rather than parsing reason text. + +To keep the existing integrated coverage useful, I also updated `src/daily-control-loop.test.tsx` to the new contract so the broader slice-level regression continues to validate the overview loop instead of enforcing the old heuristic behavior. + +## Verification + +I ran the focused T01 backend and frontend checks from the task plan and both passed. I also ran the slice-level verification commands relevant at this stage: the broader focused UI regression bundle now passes, the production build passes, and the only remaining slice-level failure is the missing `src/end-to-end-trust-loop.test.tsx`, which belongs to T02 and is expected for this intermediate task. + +## Verification Evidence + +| # | Command | Exit Code | Verdict | Duration | +|---|---------|-----------|---------|----------| +| 1 | `dotnet test JobTrackerApi.Tests/JobTrackerApi.Tests.csproj --filter JobApplicationsWorkflowSignalsTests` | 0 | ✅ pass | 3.62s | +| 2 | `CI=true npm --prefix job-tracker-ui test -- --watch=false --runTestsByPath src/workflow-trust-signals.test.tsx` | 0 | ✅ pass | 3.37s | +| 3 | `CI=true npm --prefix job-tracker-ui test -- --watch=false --runTestsByPath src/end-to-end-trust-loop.test.tsx` | 1 | ❌ fail | 0.62s | +| 4 | `CI=true npm --prefix job-tracker-ui test -- --watch=false --runTestsByPath src/correspondence-gmail-import.test.tsx src/job-details-generated-drafts.test.tsx src/job-details-followup-drafts.test.tsx src/daily-control-loop.test.tsx` | 0 | ✅ pass | 4.62s | +| 5 | `CI=true npm --prefix job-tracker-ui run build` | 0 | ✅ pass | 12.73s | + +## Diagnostics + +Inspect `GET /api/jobapplications/reminders` and `GET /api/jobapplications/{id}/readiness` to confirm `workflowSignal.actionKey`, `workspaceTab`, `followMode`, and package/interview/follow-up booleans. On the UI side, inspect `job-tracker-ui/src/jobWorkflowSignals.ts` and run `src/workflow-trust-signals.test.tsx` to catch any routing/grouping drift. Failures now surface as deterministic targeted test failures instead of silent string-parsing mismatches. + +## Deviations + +I also corrected a pre-existing controller compile issue (`RulesEngine.Decision` vs. `FollowUpDecision`) and updated the existing `src/daily-control-loop.test.tsx` regression so it validates the new shared workflow contract instead of the retired heuristic behavior. + +## Known Issues + +- `src/end-to-end-trust-loop.test.tsx` does not exist yet, so that slice-level verification command still fails until T02 adds the integrated trust-loop proof. +- The React test runs emit React Router future-flag warnings, but they are warnings only and did not affect pass/fail outcomes. + +## Files Created/Modified + +- `JobTrackerApi/Controllers/JobApplicationsController.cs` — fixed the follow-up decision type usage while preserving and exposing the normalized workflow signal contract. +- `JobTrackerApi.Tests/JobApplicationsWorkflowSignalsTests.cs` — added focused backend proofs for package-gap vs. generic-notes handling and normalized reminder routing metadata. +- `job-tracker-ui/src/types.ts` — added the shared `WorkflowSignal` type and extended readiness responses to carry it. +- `job-tracker-ui/src/jobWorkflowSignals.ts` — centralized workflow routing, labels, and reminder grouping for overview surfaces. +- `job-tracker-ui/src/components/JobTable.tsx` — switched next-action chips/buttons and readiness filtering to the shared workflow signal. +- `job-tracker-ui/src/components/DashboardView.tsx` — switched reminder labels/routes to the shared workflow signal helper. +- `job-tracker-ui/src/components/RemindersView.tsx` — switched grouping and open-action routing to workflow signal metadata. +- `job-tracker-ui/src/workflow-trust-signals.test.tsx` — added focused frontend regression coverage proving overview surfaces stay aligned. +- `job-tracker-ui/src/daily-control-loop.test.tsx` — updated the broader loop regression to the shared workflow contract. diff --git a/JobTrackerApi.Tests/JobApplicationsWorkflowSignalsTests.cs b/JobTrackerApi.Tests/JobApplicationsWorkflowSignalsTests.cs new file mode 100644 index 0000000..ad8786c --- /dev/null +++ b/JobTrackerApi.Tests/JobApplicationsWorkflowSignalsTests.cs @@ -0,0 +1,152 @@ +using System.Security.Claims; +using JobTrackerApi.Controllers; +using JobTrackerApi.Data; +using JobTrackerApi.Models; +using JobTrackerApi.Services; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using Moq; +using Xunit; + +namespace JobTrackerApi.Tests; + +public sealed class JobApplicationsWorkflowSignalsTests +{ + [Fact] + public async Task Readiness_keeps_package_gap_when_notes_do_not_contain_saved_answer_block() + { + 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", + Status = "Applied", + DateApplied = DateTime.UtcNow, + TailoredCvText = "Saved tailored CV", + Notes = "Interview talking points and recruiter notes without any saved application-answer markers.", + HasPortfolio = true, + NextAction = "Wait for recruiter reply" + }; + db.JobApplications.Add(job); + await db.SaveChangesAsync(); + + var controller = CreateController(db, "user-1"); + var result = await controller.GetReadiness(job.Id, CancellationToken.None); + + var ok = Assert.IsType(result.Result); + var payload = Assert.IsType(ok.Value); + + Assert.Equal("package-work", payload.WorkflowSignal.ActionKey); + Assert.True(payload.WorkflowSignal.HasPackageGap); + Assert.True(payload.WorkflowSignal.HasTailoredCv); + Assert.False(payload.WorkflowSignal.HasSavedApplicationAnswerDraft); + Assert.Contains(payload.Missing, item => item.Contains("Save application answers", StringComparison.OrdinalIgnoreCase)); + Assert.DoesNotContain(payload.Reminders, item => item.Contains("tailored cv and saved application answers", StringComparison.OrdinalIgnoreCase) && item.Contains("notes", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public async Task Reminders_return_normalized_workflow_routes_for_package_and_follow_up_actions() + { + await using var db = CreateDb(); + var company = new Company { Name = "Acme", OwnerUserId = "user-1" }; + db.Companies.Add(company); + await db.SaveChangesAsync(); + + var packageGapJob = new JobApplication + { + JobTitle = "Platform Engineer", + CompanyId = company.Id, + OwnerUserId = "user-1", + Status = "Waiting", + DateApplied = DateTime.UtcNow.AddDays(-20), + Notes = "General notes only", + TailoredCvText = null, + ResponseReceived = false + }; + + var followUpJob = new JobApplication + { + JobTitle = "API Engineer", + CompanyId = company.Id, + OwnerUserId = "user-1", + Status = "Waiting", + DateApplied = DateTime.UtcNow.AddDays(-20), + TailoredCvText = "Saved tailored CV", + Notes = "Prep notes\n\n<<>>\nSaved answer\n<<>>", + ResponseReceived = false + }; + + db.JobApplications.AddRange(packageGapJob, followUpJob); + await db.SaveChangesAsync(); + + var controller = CreateController(db, "user-1"); + var result = await controller.GetReminders(14, CancellationToken.None); + + var ok = Assert.IsType(result.Result); + var payload = Assert.IsType>(ok.Value); + + var packageReminder = Assert.Single(payload, item => item.Id == packageGapJob.Id); + Assert.Equal("package-work", packageReminder.WorkflowSignal.ActionKey); + Assert.Equal("tailored-cv", packageReminder.WorkflowSignal.WorkspaceTab); + Assert.Equal(packageReminder.WorkflowSignal.Reason, packageReminder.FollowUpReason); + Assert.True(packageReminder.WorkflowSignal.HasPackageGap); + + var followUpReminder = Assert.Single(payload, item => item.Id == followUpJob.Id); + Assert.Equal("follow-up", followUpReminder.WorkflowSignal.ActionKey); + Assert.Equal("follow-up", followUpReminder.WorkflowSignal.WorkspaceTab); + Assert.Equal("waiting-update", followUpReminder.WorkflowSignal.FollowMode); + Assert.Equal(followUpReminder.WorkflowSignal.Reason, followUpReminder.FollowUpReason); + Assert.True(followUpReminder.WorkflowSignal.NeedsFollowUpAction); + } + + private static JobApplicationsController CreateController(JobTrackerContext db, string userId) + { + var controller = new JobApplicationsController(db, Mock.Of(), Mock.Of(), CreateUserManager().Object); + controller.ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext + { + User = new ClaimsPrincipal(new ClaimsIdentity(new[] + { + new Claim(ClaimTypes.NameIdentifier, userId) + }, "test")) + } + }; + return controller; + } + + private static JobTrackerContext CreateDb() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + var currentUser = new Mock(); + currentUser.SetupGet(service => service.UserId).Returns("user-1"); + return new JobTrackerContext(options, currentUser.Object); + } + + private static Mock> CreateUserManager() + { + var store = new Mock>(); + return new Mock>( + store.Object, + Options.Create(new IdentityOptions()), + new PasswordHasher(), + Array.Empty>(), + Array.Empty>(), + new UpperInvariantLookupNormalizer(), + new IdentityErrorDescriber(), + null!, + new NullLogger>() + ); + } +} diff --git a/JobTrackerApi/Controllers/JobApplicationsController.cs b/JobTrackerApi/Controllers/JobApplicationsController.cs index a274f9f..1b01a7d 100644 --- a/JobTrackerApi/Controllers/JobApplicationsController.cs +++ b/JobTrackerApi/Controllers/JobApplicationsController.cs @@ -388,6 +388,47 @@ namespace JobTrackerApi.Controllers return variants; } + private JobApplicationDto BuildJobApplicationDto(JobApplication job, FollowUpDecision followUpDecision, string? followUpReasonOverride = null, string? fullSummary = null) + { + var workflowSignal = BuildWorkflowSignal(job, followUpDecision); + + return new JobApplicationDto( + Id: job.Id, + CompanyId: job.CompanyId, + Company: job.Company, + JobTitle: job.JobTitle, + Status: job.Status, + DateApplied: job.DateApplied, + ResponseReceived: job.ResponseReceived, + ResponseDate: job.ResponseDate, + Notes: job.Notes, + CoverLetterText: job.CoverLetterText, + JobUrl: job.JobUrl, + Description: job.Description, + TranslatedDescription: job.TranslatedDescription, + DescriptionLanguage: job.DescriptionLanguage, + Tags: job.Tags, + Deadline: job.Deadline, + Location: job.Location, + Salary: job.Salary, + NextAction: job.NextAction, + FollowUpAt: job.FollowUpAt, + FeedbackRequestedAt: job.FeedbackRequestedAt, + HasResume: job.HasResume, + HasCoverLetter: job.HasCoverLetter, + HasPortfolio: job.HasPortfolio, + HasOtherAttachment: job.HasOtherAttachment, + IsDeleted: job.IsDeleted, + DeletedAt: job.DeletedAt, + DaysSince: job.DaysSince, + NeedsFollowUp: followUpDecision.NeedsFollowUp, + FollowUpReason: followUpReasonOverride ?? followUpDecision.Reason, + TailoredCvText: job.TailoredCvText, + WorkflowSignal: workflowSignal, + ShortSummary: job.ShortSummary, + FullSummary: fullSummary); + } + private static List BuildFollowUpApproach(string status, List matchedTags, List missingTags) { var normalized = (status ?? string.Empty).Trim(); @@ -510,6 +551,176 @@ namespace JobTrackerApi.Controllers return Uri.TryCreate(value, UriKind.Absolute, out var uri) ? uri.ToString() : value; } + private static string RemoveSavedApplicationAnswerDraft(string? notes) + { + 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) + { + 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(); + } + + const string legacyPrefix = "Application answer draft:"; + var legacyIndex = value.IndexOf(legacyPrefix, StringComparison.OrdinalIgnoreCase); + if (legacyIndex >= 0) + { + return value[..legacyIndex].Trim(); + } + + return value.Trim(); + } + + private static bool HasInterviewPrepNotes(string? notes) => !string.IsNullOrWhiteSpace(RemoveSavedApplicationAnswerDraft(notes)); + + private static bool IsInterviewStage(string status) => + status.Contains("Interview", StringComparison.OrdinalIgnoreCase); + + private static bool IsActiveWorkflowStatus(string status) + { + var normalized = (status ?? string.Empty).Trim(); + return normalized switch + { + "Applied" => true, + "Waiting" => true, + "Interview" => true, + "Interviewing" => true, + "Offer" => true, + _ => false, + }; + } + + public sealed record WorkflowSignalDto( + string ActionKey, + string Reason, + string WorkspaceTab, + string? FollowMode, + bool NeedsAttention, + bool HasPackageGap, + bool NeedsInterviewPrep, + bool NeedsFollowUpAction, + bool HasTailoredCv, + bool HasSavedApplicationAnswerDraft, + bool HasInterviewPrepNotes + ); + + private static WorkflowSignalDto BuildWorkflowSignal(JobApplication job, FollowUpDecision followUpDecision) + { + var hasTailoredCv = !string.IsNullOrWhiteSpace(job.TailoredCvText); + var hasSavedApplicationAnswerDraft = !string.IsNullOrWhiteSpace(ExtractSavedApplicationAnswerDraft(job.Notes)); + var hasInterviewPrepNotes = HasInterviewPrepNotes(job.Notes); + var needsInterviewPrep = IsInterviewStage(job.Status) && !hasInterviewPrepNotes; + var hasPackageGap = IsActiveWorkflowStatus(job.Status) && (!hasTailoredCv || !hasSavedApplicationAnswerDraft); + var needsFollowUpAction = followUpDecision.NeedsFollowUp || (!job.ResponseReceived && job.FollowUpAt is null); + + if (needsInterviewPrep) + { + return new WorkflowSignalDto( + ActionKey: "interview-prep", + Reason: "Interview stage reached but prep notes are still missing.", + WorkspaceTab: "interview-prep", + FollowMode: null, + NeedsAttention: true, + HasPackageGap: hasPackageGap, + NeedsInterviewPrep: true, + NeedsFollowUpAction: needsFollowUpAction, + HasTailoredCv: hasTailoredCv, + HasSavedApplicationAnswerDraft: hasSavedApplicationAnswerDraft, + HasInterviewPrepNotes: hasInterviewPrepNotes); + } + + if (hasPackageGap) + { + var reason = !hasTailoredCv && !hasSavedApplicationAnswerDraft + ? "Tailored CV and saved application answers still need work." + : !hasTailoredCv + ? "Tailored CV missing for this role." + : "Saved application answers still need work."; + + return new WorkflowSignalDto( + ActionKey: "package-work", + Reason: reason, + WorkspaceTab: "tailored-cv", + FollowMode: null, + NeedsAttention: true, + HasPackageGap: true, + NeedsInterviewPrep: needsInterviewPrep, + NeedsFollowUpAction: needsFollowUpAction, + HasTailoredCv: hasTailoredCv, + HasSavedApplicationAnswerDraft: hasSavedApplicationAnswerDraft, + HasInterviewPrepNotes: hasInterviewPrepNotes); + } + + if (needsFollowUpAction) + { + var reason = !string.IsNullOrWhiteSpace(followUpDecision.Reason) + ? followUpDecision.Reason! + : !job.ResponseReceived && job.FollowUpAt is null + ? "No response yet and no follow-up is scheduled." + : "Follow-up is due for this role."; + + return new WorkflowSignalDto( + ActionKey: "follow-up", + Reason: reason, + WorkspaceTab: "follow-up", + FollowMode: "waiting-update", + NeedsAttention: true, + HasPackageGap: hasPackageGap, + NeedsInterviewPrep: needsInterviewPrep, + NeedsFollowUpAction: true, + HasTailoredCv: hasTailoredCv, + HasSavedApplicationAnswerDraft: hasSavedApplicationAnswerDraft, + HasInterviewPrepNotes: hasInterviewPrepNotes); + } + + return new WorkflowSignalDto( + ActionKey: "review-readiness", + Reason: "No urgent workflow gaps are blocking this job right now.", + WorkspaceTab: "readiness", + FollowMode: null, + NeedsAttention: false, + HasPackageGap: hasPackageGap, + NeedsInterviewPrep: needsInterviewPrep, + NeedsFollowUpAction: needsFollowUpAction, + HasTailoredCv: hasTailoredCv, + HasSavedApplicationAnswerDraft: hasSavedApplicationAnswerDraft, + HasInterviewPrepNotes: hasInterviewPrepNotes); + } + + private static List BuildReadinessReminders(JobApplication job, WorkflowSignalDto workflowSignal) + { + var reminders = new List(); + + if (workflowSignal.HasPackageGap) + { + reminders.Add(workflowSignal.HasTailoredCv + ? "Saved application answers are still missing from the package." + : workflowSignal.HasSavedApplicationAnswerDraft + ? "This role is active but still missing a tailored CV." + : "This role is active but still needs a tailored CV and saved application answers."); + } + + if (workflowSignal.NeedsInterviewPrep) + { + reminders.Add("Interview stage reached but prep notes are still missing."); + } + + if (workflowSignal.NeedsFollowUpAction) + { + reminders.Add(job.FollowUpAt is null + ? "No response yet and no follow-up is scheduled." + : workflowSignal.Reason); + } + + return reminders + .Where(reminder => !string.IsNullOrWhiteSpace(reminder)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + } public sealed record PagedResult(List Items, int Total, int Page, int PageSize); @@ -544,6 +755,8 @@ namespace JobTrackerApi.Controllers int DaysSince, bool NeedsFollowUp, string? FollowUpReason, + string? TailoredCvText, + WorkflowSignalDto WorkflowSignal, string? ShortSummary, string? FullSummary ); @@ -658,40 +871,7 @@ namespace JobTrackerApi.Controllers // Use persisted short summary when available to avoid repeated model calls. var shortSummary = j.ShortSummary; var summary = shortSummary; // list endpoints return the short summary only - dtoItems.Add(new JobApplicationDto( - Id: j.Id, - CompanyId: j.CompanyId, - Company: j.Company, - JobTitle: j.JobTitle, - Status: j.Status, - DateApplied: j.DateApplied, - ResponseReceived: j.ResponseReceived, - ResponseDate: j.ResponseDate, - Notes: j.Notes, - CoverLetterText: j.CoverLetterText, - JobUrl: j.JobUrl, - Description: j.Description, - TranslatedDescription: j.TranslatedDescription, - DescriptionLanguage: j.DescriptionLanguage, - Tags: j.Tags, - Deadline: j.Deadline, - Location: j.Location, - Salary: j.Salary, - NextAction: j.NextAction, - FollowUpAt: j.FollowUpAt, - FeedbackRequestedAt: j.FeedbackRequestedAt, - HasResume: j.HasResume, - HasCoverLetter: j.HasCoverLetter, - HasPortfolio: j.HasPortfolio, - HasOtherAttachment: j.HasOtherAttachment, - IsDeleted: j.IsDeleted, - DeletedAt: j.DeletedAt, - DaysSince: j.DaysSince, - NeedsFollowUp: d.NeedsFollowUp, - FollowUpReason: d.Reason, - ShortSummary: shortSummary, - FullSummary: null - )); + dtoItems.Add(BuildJobApplicationDto(j, d)); } return Ok(new PagedResult(dtoItems, totalCount, page, pageSize)); @@ -722,40 +902,7 @@ namespace JobTrackerApi.Controllers var d = RulesEngine.Evaluate(settings, j, now, lm); var shortSummary = j.ShortSummary; var summary = shortSummary; - dtos.Add(new JobApplicationDto( - Id: j.Id, - CompanyId: j.CompanyId, - Company: j.Company, - JobTitle: j.JobTitle, - Status: j.Status, - DateApplied: j.DateApplied, - ResponseReceived: j.ResponseReceived, - ResponseDate: j.ResponseDate, - Notes: j.Notes, - CoverLetterText: j.CoverLetterText, - JobUrl: j.JobUrl, - Description: j.Description, - TranslatedDescription: j.TranslatedDescription, - DescriptionLanguage: j.DescriptionLanguage, - Tags: j.Tags, - Deadline: j.Deadline, - Location: j.Location, - Salary: j.Salary, - NextAction: j.NextAction, - FollowUpAt: j.FollowUpAt, - FeedbackRequestedAt: j.FeedbackRequestedAt, - HasResume: j.HasResume, - HasCoverLetter: j.HasCoverLetter, - HasPortfolio: j.HasPortfolio, - HasOtherAttachment: j.HasOtherAttachment, - IsDeleted: j.IsDeleted, - DeletedAt: j.DeletedAt, - DaysSince: j.DaysSince, - NeedsFollowUp: d.NeedsFollowUp, - FollowUpReason: d.Reason, - ShortSummary: shortSummary, - FullSummary: null - )); + dtos.Add(BuildJobApplicationDto(j, d)); } return Ok(new PagedResult(dtos, total, page, pageSize)); @@ -782,40 +929,7 @@ namespace JobTrackerApi.Controllers // surface readable English analysis while the original text remains available. var full = await _summarizer.SummarizeAsync(BuildSummarySource(job), 250, 40); - return Ok(new JobApplicationDto( - Id: job.Id, - CompanyId: job.CompanyId, - Company: job.Company, - JobTitle: job.JobTitle, - Status: job.Status, - DateApplied: job.DateApplied, - ResponseReceived: job.ResponseReceived, - ResponseDate: job.ResponseDate, - Notes: job.Notes, - CoverLetterText: job.CoverLetterText, - JobUrl: job.JobUrl, - Description: job.Description, - TranslatedDescription: job.TranslatedDescription, - DescriptionLanguage: job.DescriptionLanguage, - Tags: job.Tags, - Deadline: job.Deadline, - Location: job.Location, - Salary: job.Salary, - NextAction: job.NextAction, - FollowUpAt: job.FollowUpAt, - FeedbackRequestedAt: job.FeedbackRequestedAt, - HasResume: job.HasResume, - HasCoverLetter: job.HasCoverLetter, - HasPortfolio: job.HasPortfolio, - HasOtherAttachment: job.HasOtherAttachment, - IsDeleted: job.IsDeleted, - DeletedAt: job.DeletedAt, - DaysSince: job.DaysSince, - NeedsFollowUp: d.NeedsFollowUp, - FollowUpReason: d.Reason, - ShortSummary: job.ShortSummary, - FullSummary: full - )); + return Ok(BuildJobApplicationDto(job, d, fullSummary: full)); } [HttpGet("board")] @@ -875,62 +989,10 @@ namespace JobTrackerApi.Controllers lastMsg.TryGetValue(j.Id, out var lm); var d = RulesEngine.Evaluate(settings, j, now, lm); var upcoming = j.FollowUpAt is not null && j.FollowUpAt.Value <= upcomingTo; - if (!d.NeedsFollowUp && !upcoming) continue; - var shortSummary = j.ShortSummary; - var reminderReason = d.Reason; - if (string.IsNullOrWhiteSpace(j.TailoredCvText)) - { - reminderReason = string.IsNullOrWhiteSpace(reminderReason) - ? "Tailored CV missing for an active role." - : $"{reminderReason} Tailored CV missing."; - } - if (j.Status.Contains("Interview", StringComparison.OrdinalIgnoreCase) && string.IsNullOrWhiteSpace(j.Notes)) - { - reminderReason = string.IsNullOrWhiteSpace(reminderReason) - ? "Interview coming up but prep notes are missing." - : $"{reminderReason} Interview prep notes missing."; - } - if (!j.ResponseReceived && j.FollowUpAt is null) - { - reminderReason = string.IsNullOrWhiteSpace(reminderReason) - ? "No response yet and no follow-up date is scheduled." - : $"{reminderReason} No follow-up date is scheduled."; - } + var workflowSignal = BuildWorkflowSignal(j, d); + if (!workflowSignal.NeedsAttention && !upcoming) continue; - dtos.Add(new JobApplicationDto( - Id: j.Id, - CompanyId: j.CompanyId, - Company: j.Company, - JobTitle: j.JobTitle, - Status: j.Status, - DateApplied: j.DateApplied, - ResponseReceived: j.ResponseReceived, - ResponseDate: j.ResponseDate, - Notes: j.Notes, - CoverLetterText: j.CoverLetterText, - JobUrl: j.JobUrl, - Description: j.Description, - TranslatedDescription: j.TranslatedDescription, - DescriptionLanguage: j.DescriptionLanguage, - Tags: j.Tags, - Deadline: j.Deadline, - Location: j.Location, - Salary: j.Salary, - NextAction: j.NextAction, - FollowUpAt: j.FollowUpAt, - FeedbackRequestedAt: j.FeedbackRequestedAt, - HasResume: j.HasResume, - HasCoverLetter: j.HasCoverLetter, - HasPortfolio: j.HasPortfolio, - HasOtherAttachment: j.HasOtherAttachment, - IsDeleted: j.IsDeleted, - DeletedAt: j.DeletedAt, - DaysSince: j.DaysSince, - NeedsFollowUp: d.NeedsFollowUp, - FollowUpReason: reminderReason, - ShortSummary: shortSummary, - FullSummary: null - )); + dtos.Add(BuildJobApplicationDto(j, d, followUpReasonOverride: workflowSignal.Reason)); } // Sort: needsFollowUp first, then nearest followUpAt. @@ -1201,40 +1263,7 @@ namespace JobTrackerApi.Controllers .FirstOrDefaultAsync(cancellationToken); var followUp = RulesEngine.Evaluate(settings, job, DateTime.Now, lastMsg); - return Ok(new JobApplicationDto( - Id: job.Id, - CompanyId: job.CompanyId, - Company: job.Company, - JobTitle: job.JobTitle, - Status: job.Status, - DateApplied: job.DateApplied, - ResponseReceived: job.ResponseReceived, - ResponseDate: job.ResponseDate, - Notes: job.Notes, - CoverLetterText: job.CoverLetterText, - JobUrl: job.JobUrl, - Description: job.Description, - TranslatedDescription: job.TranslatedDescription, - DescriptionLanguage: job.DescriptionLanguage, - Tags: job.Tags, - Deadline: job.Deadline, - Location: job.Location, - Salary: job.Salary, - NextAction: job.NextAction, - FollowUpAt: job.FollowUpAt, - FeedbackRequestedAt: job.FeedbackRequestedAt, - HasResume: job.HasResume, - HasCoverLetter: job.HasCoverLetter, - HasPortfolio: job.HasPortfolio, - HasOtherAttachment: job.HasOtherAttachment, - IsDeleted: job.IsDeleted, - DeletedAt: job.DeletedAt, - DaysSince: job.DaysSince, - NeedsFollowUp: followUp.NeedsFollowUp, - FollowUpReason: followUp.Reason, - ShortSummary: job.ShortSummary, - FullSummary: null - )); + return Ok(BuildJobApplicationDto(job, followUp)); } [HttpDelete("{id:int}")] @@ -1648,7 +1677,7 @@ namespace JobTrackerApi.Controllers public sealed record SaveApplicationDraftsRequest(string? CoverLetterText, string? Notes, string? RecruiterMessageDraft); private 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); + public sealed record ReadinessDto(int Score, string Level, List Completed, List Missing, List Reminders, WorkflowSignalDto WorkflowSignal); private static string BuildPackageModeInstruction(string? mode) { @@ -1916,25 +1945,32 @@ Candidate master CV: .FirstOrDefaultAsync(j => j.Id == id, cancellationToken); if (job is null) return NotFound(); + var settings = await RulesEngine.GetSettings(_db, cancellationToken); + var now = DateTime.Now; + var lastMessageAt = await _db.Correspondences + .AsNoTracking() + .Where(c => c.JobApplicationId == id) + .MaxAsync(c => (DateTime?)c.Date, cancellationToken); + var followUpDecision = RulesEngine.Evaluate(settings, job, now, lastMessageAt); + var workflowSignal = BuildWorkflowSignal(job, followUpDecision); + var completed = new List(); var missing = new List(); - var reminders = new List(); - if (!string.IsNullOrWhiteSpace(job.TailoredCvText)) completed.Add("Tailored CV saved"); else missing.Add("Tailor your CV for this role"); + if (workflowSignal.HasTailoredCv) completed.Add("Tailored CV saved"); else missing.Add("Tailor your CV for this role"); if (!string.IsNullOrWhiteSpace(job.CoverLetterText)) completed.Add("Cover letter draft ready"); else missing.Add("Create a cover letter draft"); if (job.HasPortfolio) completed.Add("Portfolio attached"); else missing.Add("Consider adding a relevant portfolio example"); if (!string.IsNullOrWhiteSpace(job.Company?.RecruiterEmail)) completed.Add("Recruiter contact available"); else missing.Add("Capture recruiter contact details if possible"); if (!string.IsNullOrWhiteSpace(job.NextAction)) completed.Add("Next action captured"); else missing.Add("Write the next action so follow-up is clear"); if (job.FollowUpAt is not null) completed.Add("Follow-up scheduled"); else missing.Add("Schedule a follow-up date"); + if (workflowSignal.HasSavedApplicationAnswerDraft) completed.Add("Saved application answers available"); else missing.Add("Save application answers for this role"); + if (workflowSignal.HasInterviewPrepNotes || !IsInterviewStage(job.Status)) completed.Add("Interview prep notes captured"); else missing.Add("Capture interview prep notes before the interview"); - if (!job.ResponseReceived && string.IsNullOrWhiteSpace(job.TailoredCvText)) reminders.Add("This role is active but still missing a tailored CV."); - if (job.Status.Contains("Interview", StringComparison.OrdinalIgnoreCase) && string.IsNullOrWhiteSpace(job.Notes)) reminders.Add("Interview stage reached but prep notes are still missing."); - if (!job.ResponseReceived && job.FollowUpAt is null) reminders.Add("No response yet and no follow-up is scheduled."); - - var score = Math.Clamp(completed.Count * 15 + (string.IsNullOrWhiteSpace(job.Description) ? 0 : 10), 20, 100); + var reminders = BuildReadinessReminders(job, workflowSignal); + var score = Math.Clamp(completed.Count * 12 + (string.IsNullOrWhiteSpace(job.Description) ? 0 : 10), 20, 100); var level = score >= 80 ? "Ready" : score >= 60 ? "Needs polish" : "Needs work"; - return Ok(new ReadinessDto(score, level, completed, missing, reminders)); + return Ok(new ReadinessDto(score, level, completed, missing, reminders, workflowSignal)); } [HttpPut("{id:int}/tailored-cv")] diff --git a/job-tracker-ui/src/components/DashboardView.tsx b/job-tracker-ui/src/components/DashboardView.tsx index 2c7141c..b60f522 100644 --- a/job-tracker-ui/src/components/DashboardView.tsx +++ b/job-tracker-ui/src/components/DashboardView.tsx @@ -23,7 +23,8 @@ import AutoGraphIcon from "@mui/icons-material/AutoGraph"; import { api } from "../api"; import { getUserKeyFromToken } from "../themePrefs"; import { useI18n } from "../i18n/I18nProvider"; -import { buildJobWorkspacePath, JOB_DETAILS_TABS } from "../jobWorkspaceRoute"; +import { buildWorkflowPath, getWorkflowAction } from "../jobWorkflowSignals"; +import { JobApplication } from "../types"; interface JobStats { total: number; @@ -34,15 +35,7 @@ interface JobStats { averageDaysSinceApplied: number; } -type ReminderJob = { - id: number; - jobTitle: string; - status: string; - followUpAt?: string | null; - tailoredCvText?: string | null; - followUpReason?: string | null; - company?: { name?: string | null }; -}; +type ReminderJob = JobApplication; type AnalyticsPoint = { month: string; applied: number; responses: number }; type TagPoint = { tag: string; count: number }; @@ -167,7 +160,7 @@ export default function DashboardView() { const tagColors = [theme.palette.primary.main, theme.palette.success.main, theme.palette.warning.main, theme.palette.info.main, theme.palette.error.main]; const funnelMax = overview?.funnel?.length ? Math.max(...overview.funnel.map((item) => item.count)) : 0; const topSource = overview?.responseRateBySource?.[0]; - const missingCvCount = reminderJobs.filter((job) => !job.tailoredCvText).length; + const missingCvCount = reminderJobs.filter((job) => job.workflowSignal?.hasPackageGap).length; const metricCards = [ { @@ -214,13 +207,15 @@ export default function DashboardView() { const totalResponses = responseValues.reduce((sum, value) => sum + value, 0); const responseRate = totalApplied > 0 ? Math.round((totalResponses / totalApplied) * 100) : 0; const priorityJobs = reminderJobs.slice(0, 5); + const getReminderAction = (job: ReminderJob) => getWorkflowAction(job, { + packageWork: t("jobTablePackageWork"), + followUp: t("jobTableFollowUp"), + interviewPrep: t("jobTableInterviewStage"), + readiness: t("jobTableReadiness"), + }); + const openReminderJob = (job: ReminderJob) => { - const reason = (job.followUpReason ?? '').toLowerCase(); - if (reason.includes('tailored cv')) { - navigate(buildJobWorkspacePath(job.id, { tab: JOB_DETAILS_TABS.tailoredCv })); - return; - } - navigate(buildJobWorkspacePath(job.id, { tab: JOB_DETAILS_TABS.followUp, followMode: 'waiting-update' })); + navigate(buildWorkflowPath(job)); }; return ( @@ -393,17 +388,19 @@ export default function DashboardView() { {t("remindersNothing")} ) : ( - {priorityJobs.map((job) => ( + {priorityJobs.map((job) => { + const action = getReminderAction(job); + return ( {job.company?.name ?? t("jobTableCompany")} • {job.jobTitle} - {job.followUpReason ?? t("remindersFollowUpLabel")} + {action?.detail ?? job.workflowSignal?.reason ?? job.followUpReason ?? t("remindersFollowUpLabel")} - ))} + )})} )} diff --git a/job-tracker-ui/src/components/JobTable.tsx b/job-tracker-ui/src/components/JobTable.tsx index 96c97a5..a1a2773 100644 --- a/job-tracker-ui/src/components/JobTable.tsx +++ b/job-tracker-ui/src/components/JobTable.tsx @@ -47,29 +47,8 @@ import { useToast } from "../toast"; import SavedViewsMenu, { SavedViewParams } from "./SavedViewsMenu"; import { useDialogActions } from "../dialogs"; import { useI18n } from "../i18n/I18nProvider"; -import { buildJobWorkspacePath, JOB_DETAILS_TABS } from "../jobWorkspaceRoute"; - -interface JobApplication { - id: number; - jobTitle: string; - status: string; - dateApplied: string; - daysSince: number; - jobUrl?: string | null; - notes?: string | null; - location?: string | null; - salary?: string | null; - tags?: string | null; - description?: string | null; - isDeleted?: boolean; - company: { name: string }; - companyId?: number; - needsFollowUp?: boolean; - followUpReason?: string | null; - shortSummary?: string | null; - fullSummary?: string | null; - tailoredCvText?: string | null; -} +import { JobApplication } from "../types"; +import { getWorkflowAction, needsInterviewPrep, needsWorkflowWork } from "../jobWorkflowSignals"; interface PagedResult { items: T[]; @@ -210,8 +189,8 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col const filteredJobs = useMemo(() => { if (readinessFilter === "all") return jobs; - if (readinessFilter === "interview") return jobs.filter((job) => job.status === "Interview" || job.status === "Interviewing"); - return jobs.filter((job) => !job.tailoredCvText || !job.notes); + if (readinessFilter === "interview") return jobs.filter((job) => needsInterviewPrep(job)); + return jobs.filter((job) => needsWorkflowWork(job)); }, [jobs, readinessFilter]); const selectedAllOnPage = filteredJobs.length > 0 && filteredJobs.every((job) => selectedIds.includes(job.id)); @@ -289,58 +268,38 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col return src.length > 220 ? `${src.slice(0, 220)}...` : src; }; - const openFollowUpWorkspace = (jobId: number) => { - navigate(buildJobWorkspacePath(jobId, { tab: JOB_DETAILS_TABS.followUp, followMode: "waiting-update" })); - }; - - const openTailoredCvWorkspace = (jobId: number) => { - navigate(buildJobWorkspacePath(jobId, { tab: JOB_DETAILS_TABS.tailoredCv })); - }; - - const getPackageActionDetail = (job: JobApplication) => { - const missingTailoredCv = !job.tailoredCvText; - const missingNotes = !job.notes?.trim(); - - if (missingTailoredCv && missingNotes) return t("jobTablePackageMissingCvAndNotes"); - if (missingTailoredCv) return t("jobTableCvMissing"); - if (missingNotes) return t("jobTablePackageMissingNotes"); - return null; - }; + const buildWorkflowActionDetail = (job: JobApplication) => getWorkflowAction(job, { + packageWork: t("jobTablePackageWork"), + followUp: t("jobTableFollowUp"), + interviewPrep: t("jobTableInterviewStage"), + readiness: t("jobTableReadiness"), + }); const getActionSignals = (job: JobApplication) => { - const signals: Array<{ - label: string; - detail: string; - onClick: () => void; - variant: "contained" | "outlined"; - color?: "warning" | "primary"; - }> = []; + const action = buildWorkflowActionDetail(job); + if (!action || job.isDeleted) return []; - if (job.needsFollowUp) { - signals.push({ - label: t("jobTableFollowUp"), - detail: job.followUpReason ?? t("jobTableNeedsFollowUp"), - onClick: () => openFollowUpWorkspace(job.id), - variant: "contained", - color: "warning", - }); - } - - const packageDetail = !job.isDeleted ? getPackageActionDetail(job) : null; - if (packageDetail) { - signals.push({ - label: t("jobTablePackageWork"), - detail: packageDetail, - onClick: () => openTailoredCvWorkspace(job.id), - variant: job.needsFollowUp ? "outlined" : "contained", - color: job.needsFollowUp ? "primary" : "warning", - }); - } - - return signals; + return [{ + label: action.label, + detail: action.detail, + onClick: () => navigate(action.path), + variant: action.key === "follow-up" ? "contained" as const : "outlined" as const, + color: action.key === "follow-up" ? "warning" as const : "primary" as const, + }]; }; - const getPrimaryAction = (job: JobApplication) => getActionSignals(job)[0] ?? null; + const getPrimaryAction = (job: JobApplication) => { + const action = buildWorkflowActionDetail(job); + if (!action || job.isDeleted) return null; + + return { + label: action.label, + detail: action.detail, + onClick: () => navigate(action.path), + variant: action.key === "follow-up" ? "contained" as const : "outlined" as const, + color: action.key === "follow-up" ? "warning" as const : "primary" as const, + }; + }; return ( diff --git a/job-tracker-ui/src/components/RemindersView.tsx b/job-tracker-ui/src/components/RemindersView.tsx index eff4b2d..ea548de 100644 --- a/job-tracker-ui/src/components/RemindersView.tsx +++ b/job-tracker-ui/src/components/RemindersView.tsx @@ -5,9 +5,9 @@ import { Box, Button, Chip, Divider, Paper, Typography } from "@mui/material"; import { api } from "../api"; import { JobApplication } from "../types"; +import { buildWorkflowPath, getReminderGroup, getWorkflowAction } from "../jobWorkflowSignals"; import { useToast } from "../toast"; import { useI18n } from "../i18n/I18nProvider"; -import { buildJobWorkspacePath, JOB_DETAILS_TABS } from "../jobWorkspaceRoute"; type ReminderGroups = { missingCv: JobApplication[]; @@ -19,11 +19,8 @@ type ReminderGroups = { function groupItems(items: JobApplication[]): ReminderGroups { const groups: ReminderGroups = { missingCv: [], missingInterviewNotes: [], overdueFollowUp: [], other: [] }; items.forEach((item) => { - const reason = (item.followUpReason ?? "").toLowerCase(); - if (reason.includes("tailored cv")) groups.missingCv.push(item); - else if (reason.includes("interview prep") || reason.includes("prep notes")) groups.missingInterviewNotes.push(item); - else if (reason.includes("follow-up") || reason.includes("follow up")) groups.overdueFollowUp.push(item); - else groups.other.push(item); + const group = getReminderGroup(item); + groups[group].push(item); }); return groups; } @@ -35,7 +32,15 @@ function ReminderSection({ title, items, onOpen, onSetFollowUp }: { title: strin return ( {title} - {items.map((j) => ( + {items.map((j) => { + const action = getWorkflowAction(j, { + packageWork: t("jobTablePackageWork"), + followUp: t("jobTableFollowUp"), + interviewPrep: t("jobTableInterviewStage"), + readiness: t("jobTableReadiness"), + }); + + return ( @@ -43,19 +48,19 @@ function ReminderSection({ title, items, onOpen, onSetFollowUp }: { title: strin {j.needsFollowUp ? : null} - {j.followUpReason ? : null} + {(j.workflowSignal?.reason ?? j.followUpReason) ? : null} {j.followUpAt ? : null} - + - ))} + )})} ); } @@ -78,12 +83,7 @@ export default function RemindersView() { const grouped = useMemo(() => groupItems(items), [items]); const openJob = (job: JobApplication) => { - const reason = (job.followUpReason ?? '').toLowerCase(); - if (reason.includes('tailored cv')) { - navigate(buildJobWorkspacePath(job.id, { tab: JOB_DETAILS_TABS.tailoredCv })); - return; - } - navigate(buildJobWorkspacePath(job.id, { tab: JOB_DETAILS_TABS.followUp, followMode: 'waiting-update' })); + navigate(buildWorkflowPath(job)); }; const setFollowUp = async (id: number, daysFromNow: number | null) => { diff --git a/job-tracker-ui/src/daily-control-loop.test.tsx b/job-tracker-ui/src/daily-control-loop.test.tsx index 2e50b5b..5fe18bd 100644 --- a/job-tracker-ui/src/daily-control-loop.test.tsx +++ b/job-tracker-ui/src/daily-control-loop.test.tsx @@ -26,8 +26,21 @@ const pagedJobs = { needsFollowUp: true, followUpReason: 'Follow-up due soon', shortSummary: 'Strong backend match', - tailoredCvText: null, - notes: null, + tailoredCvText: 'Saved CV', + notes: 'Notes\n\n<<>>\nSaved answer\n<<>>', + workflowSignal: { + actionKey: 'follow-up', + reason: 'Follow-up due soon', + workspaceTab: 'follow-up', + followMode: 'waiting-update', + needsAttention: true, + hasPackageGap: false, + needsInterviewPrep: false, + needsFollowUpAction: true, + hasTailoredCv: true, + hasSavedApplicationAnswerDraft: true, + hasInterviewPrepNotes: true, + }, }, { id: 43, @@ -42,6 +55,19 @@ const pagedJobs = { shortSummary: 'Platform work', tailoredCvText: null, notes: null, + workflowSignal: { + actionKey: 'package-work', + reason: 'Tailored CV missing for this role.', + workspaceTab: 'tailored-cv', + followMode: null, + needsAttention: true, + hasPackageGap: true, + needsInterviewPrep: false, + needsFollowUpAction: false, + hasTailoredCv: false, + hasSavedApplicationAnswerDraft: false, + hasInterviewPrepNotes: false, + }, }, ], total: 2, @@ -61,6 +87,19 @@ const reminderItems = [ followUpReason: 'Follow-up due soon', tailoredCvText: 'Saved CV', followUpAt: new Date().toISOString(), + workflowSignal: { + actionKey: 'follow-up', + reason: 'Follow-up due soon', + workspaceTab: 'follow-up', + followMode: 'waiting-update', + needsAttention: true, + hasPackageGap: false, + needsInterviewPrep: false, + needsFollowUpAction: true, + hasTailoredCv: true, + hasSavedApplicationAnswerDraft: true, + hasInterviewPrepNotes: true, + }, }, { id: 43, @@ -73,6 +112,19 @@ const reminderItems = [ followUpReason: 'Tailored CV missing', tailoredCvText: null, followUpAt: new Date().toISOString(), + workflowSignal: { + actionKey: 'package-work', + reason: 'Tailored CV missing for this role.', + workspaceTab: 'tailored-cv', + followMode: null, + needsAttention: true, + hasPackageGap: true, + needsInterviewPrep: false, + needsFollowUpAction: true, + hasTailoredCv: false, + hasSavedApplicationAnswerDraft: false, + hasInterviewPrepNotes: false, + }, }, ]; @@ -152,7 +204,7 @@ test('reminders open action routes tailored-cv gaps into the tailored cv workspa const platformJob = await screen.findByText(/platform engineer/i); const platformCard = platformJob.closest('.MuiPaper-root') ?? platformJob.parentElement?.parentElement; expect(platformCard).toBeTruthy(); - fireEvent.click(within(platformCard as HTMLElement).getByRole('button', { name: /open/i })); + fireEvent.click(within(platformCard as HTMLElement).getByRole('button', { name: /build package/i })); await waitFor(() => { expect(screen.getByTestId('location-indicator')).toHaveTextContent('/jobs'); diff --git a/job-tracker-ui/src/jobWorkflowSignals.ts b/job-tracker-ui/src/jobWorkflowSignals.ts new file mode 100644 index 0000000..a4655ad --- /dev/null +++ b/job-tracker-ui/src/jobWorkflowSignals.ts @@ -0,0 +1,80 @@ +import { JobApplication, WorkflowSignal } from "./types"; +import { buildJobWorkspacePath, JOB_DETAILS_TABS } from "./jobWorkspaceRoute"; + +const TAB_BY_WORKSPACE: Record = { + "tailored-cv": JOB_DETAILS_TABS.tailoredCv, + "follow-up": JOB_DETAILS_TABS.followUp, + "interview-prep": JOB_DETAILS_TABS.interviewPrep, + readiness: JOB_DETAILS_TABS.readiness, +}; + +export type WorkflowActionPresentation = { + key: WorkflowSignal["actionKey"]; + label: string; + detail: string; + path: string; + tab: number; + followMode?: string; +}; + +export type ReminderGroupKey = "missingCv" | "missingInterviewNotes" | "overdueFollowUp" | "other"; + +export function getWorkflowSignal(job: Pick): WorkflowSignal | null { + return job.workflowSignal ?? null; +} + +export function buildWorkflowPath(job: Pick): string { + const signal = getWorkflowSignal(job); + const tab = signal ? TAB_BY_WORKSPACE[signal.workspaceTab] ?? JOB_DETAILS_TABS.overview : JOB_DETAILS_TABS.overview; + return buildJobWorkspacePath(job.id, { + tab, + followMode: signal?.followMode ?? undefined, + }); +} + +export function getWorkflowAction( + job: Pick, + labels: { + packageWork: string; + followUp: string; + interviewPrep: string; + readiness: string; + }, +): WorkflowActionPresentation | null { + const signal = getWorkflowSignal(job); + if (!signal || !signal.needsAttention) return null; + + const label = signal.actionKey === "package-work" + ? labels.packageWork + : signal.actionKey === "interview-prep" + ? labels.interviewPrep + : signal.actionKey === "follow-up" + ? labels.followUp + : labels.readiness; + + return { + key: signal.actionKey, + label, + detail: signal.reason, + path: buildWorkflowPath(job), + tab: TAB_BY_WORKSPACE[signal.workspaceTab] ?? JOB_DETAILS_TABS.overview, + followMode: signal.followMode ?? undefined, + }; +} + +export function needsWorkflowWork(job: Pick): boolean { + const signal = getWorkflowSignal(job); + return Boolean(signal?.hasPackageGap || signal?.needsInterviewPrep); +} + +export function needsInterviewPrep(job: Pick): boolean { + return Boolean(getWorkflowSignal(job)?.needsInterviewPrep); +} + +export function getReminderGroup(job: Pick): ReminderGroupKey { + const actionKey = getWorkflowSignal(job)?.actionKey; + if (actionKey === "package-work") return "missingCv"; + if (actionKey === "interview-prep") return "missingInterviewNotes"; + if (actionKey === "follow-up") return "overdueFollowUp"; + return "other"; +} diff --git a/job-tracker-ui/src/types.ts b/job-tracker-ui/src/types.ts index eb0904b..528289e 100644 --- a/job-tracker-ui/src/types.ts +++ b/job-tracker-ui/src/types.ts @@ -11,6 +11,23 @@ export interface Company { pipelineStage?: string; } +export type WorkflowActionKey = "package-work" | "follow-up" | "interview-prep" | "review-readiness"; +export type WorkflowWorkspaceTab = "tailored-cv" | "follow-up" | "interview-prep" | "readiness"; + +export interface WorkflowSignal { + actionKey: WorkflowActionKey; + reason: string; + workspaceTab: WorkflowWorkspaceTab; + followMode?: string | null; + needsAttention: boolean; + hasPackageGap: boolean; + needsInterviewPrep: boolean; + needsFollowUpAction: boolean; + hasTailoredCv: boolean; + hasSavedApplicationAnswerDraft: boolean; + hasInterviewPrepNotes: boolean; +} + export interface JobApplication { id: number; jobTitle: string; @@ -38,6 +55,7 @@ export interface JobApplication { fullSummary?: string | null; tailoredCvText?: string | null; tailoredCvUpdatedAt?: string | null; + workflowSignal?: WorkflowSignal | null; hasResume?: boolean; hasCoverLetter?: boolean; @@ -48,7 +66,7 @@ export interface JobApplication { isDeleted?: boolean; deletedAt?: string; needsFollowUp?: boolean; - followUpReason?: string; + followUpReason?: string | null; } export interface CandidateFitChannelGuidance { @@ -97,6 +115,7 @@ export interface ReadinessResponse { completed: string[]; missing: string[]; reminders: string[]; + workflowSignal: WorkflowSignal; } export interface FollowUpDraft { diff --git a/job-tracker-ui/src/workflow-trust-signals.test.tsx b/job-tracker-ui/src/workflow-trust-signals.test.tsx new file mode 100644 index 0000000..4a22208 --- /dev/null +++ b/job-tracker-ui/src/workflow-trust-signals.test.tsx @@ -0,0 +1,273 @@ +import React from 'react'; +import '@testing-library/jest-dom'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom'; +import { ConfirmProvider } from './confirm'; +import { PromptProvider } from './prompt'; +import { ToastProvider } from './toast'; +import { I18nProvider } from './i18n/I18nProvider'; +import DashboardView from './components/DashboardView'; +import RemindersView from './components/RemindersView'; +import JobTable from './components/JobTable'; +import { api } from './api'; +import { JobApplication } from './types'; + +const mockedApi = api as jest.Mocked; + +function buildJob(overrides: Partial): JobApplication { + return { + id: 1, + jobTitle: 'Backend Developer', + company: { id: 1, name: 'Acme' }, + companyId: 1, + status: 'Waiting', + dateApplied: new Date('2026-03-01T00:00:00Z').toISOString(), + location: 'Oslo', + salary: undefined, + nextAction: undefined, + followUpAt: new Date('2026-03-20T00:00:00Z').toISOString(), + feedbackRequestedAt: undefined, + responseReceived: false, + responseDate: undefined, + description: 'Role summary', + translatedDescription: undefined, + descriptionLanguage: undefined, + tags: undefined, + deadline: undefined, + notes: 'General notes', + coverLetterText: undefined, + recruiterMessageDraft: undefined, + jobUrl: undefined, + shortSummary: 'Strong match', + fullSummary: null, + tailoredCvText: 'Saved CV', + tailoredCvUpdatedAt: null, + workflowSignal: { + actionKey: 'follow-up', + reason: 'Follow-up is due for this role.', + workspaceTab: 'follow-up', + followMode: 'waiting-update', + needsAttention: true, + hasPackageGap: false, + needsInterviewPrep: false, + needsFollowUpAction: true, + hasTailoredCv: true, + hasSavedApplicationAnswerDraft: true, + hasInterviewPrepNotes: true, + }, + hasResume: true, + hasCoverLetter: false, + hasPortfolio: false, + hasOtherAttachment: false, + daysSince: 10, + isDeleted: false, + deletedAt: undefined, + needsFollowUp: true, + followUpReason: 'Follow-up is due for this role.', + ...overrides, + }; +} + +function setupApiMocks({ reminders, jobs }: { reminders?: JobApplication[]; jobs?: JobApplication[] }) { + mockedApi.get.mockImplementation((url: string) => { + if (url === '/companies') return Promise.resolve({ data: [{ id: 1, name: 'Acme' }, { id: 2, name: 'Beta' }] } as any); + if (url === '/jobapplications/reminders') return Promise.resolve({ data: reminders ?? [] } as any); + if (url === '/jobapplications') return Promise.resolve({ data: { items: jobs ?? [], total: jobs?.length ?? 0, page: 1, pageSize: 15 } } as any); + if (url === '/jobapplications/stats') return Promise.resolve({ data: { total: reminders?.length ?? 0, active: reminders?.length ?? 0, deleted: 0, byStatus: {}, appliedLast30Days: reminders?.length ?? 0, averageDaysSinceApplied: 7 } } as any); + if (url === '/jobapplications/analytics-overview') return Promise.resolve({ data: { funnel: [], responseRateBySource: [], topCompanies: [], totalResponses: 1, totalActive: reminders?.length ?? 0 } } as any); + if (url === '/jobapplications/analytics' || url === '/jobapplications/tags') return Promise.resolve({ data: [] } as any); + if (url === '/jobapplications/tag-trends') return Promise.resolve({ data: { months: [], series: [] } } as any); + return Promise.resolve({ data: [] } as any); + }); +} + +function LocationIndicator() { + const location = useLocation(); + return
{location.pathname}{location.search}
; +} + +function renderWithProviders(initialPath: string, routes: React.ReactNode) { + return render( + + + + + + {routes} + + + + + , + ); +} + +beforeEach(() => { + window.localStorage.clear(); +}); + +afterEach(() => { + jest.clearAllMocks(); +}); + +test('follow-up workflow signals route all overview surfaces to the same follow-up workspace', async () => { + const job = buildJob({ + id: 42, + followUpReason: 'Tailored CV missing', + workflowSignal: { + actionKey: 'follow-up', + reason: 'Follow-up is due for this role.', + workspaceTab: 'follow-up', + followMode: 'waiting-update', + needsAttention: true, + hasPackageGap: false, + needsInterviewPrep: false, + needsFollowUpAction: true, + hasTailoredCv: true, + hasSavedApplicationAnswerDraft: true, + hasInterviewPrepNotes: true, + }, + }); + + setupApiMocks({ reminders: [job], jobs: [job] }); + + const dashboardRender = renderWithProviders('/dashboard', <> + } /> + } /> + ); + + await screen.findByText(/follow-up is due for this role/i); + fireEvent.click(await screen.findByRole('button', { name: /follow up/i })); + await waitFor(() => expect(screen.getByTestId('location-indicator')).toHaveTextContent('/jobs?open=42&tab=4&followMode=waiting-update')); + dashboardRender.unmount(); + + setupApiMocks({ reminders: [job], jobs: [job] }); + const remindersRender = renderWithProviders('/reminders', <> + } /> + } /> + ); + + fireEvent.click(await screen.findByRole('button', { name: /follow up/i })); + await waitFor(() => expect(screen.getByTestId('location-indicator')).toHaveTextContent('/jobs?open=42&tab=4&followMode=waiting-update')); + remindersRender.unmount(); + + setupApiMocks({ reminders: [job], jobs: [job] }); + renderWithProviders('/table', <> + {}} columns={{ status: true, dateApplied: true, daysSince: true, jobUrl: false }} onColumnsChange={() => {}} mode="jobs" />} /> + } /> + ); + + fireEvent.click(await screen.findByRole('button', { name: /backend developer — follow up signal/i })); + await waitFor(() => expect(screen.getByTestId('location-indicator')).toHaveTextContent('/jobs?open=42&tab=4&followMode=waiting-update')); +}); + +test('package-work workflow signals route all overview surfaces to the shared tailored-cv workspace', async () => { + const job = buildJob({ + id: 43, + jobTitle: 'Platform Engineer', + followUpReason: 'Follow-up due soon', + workflowSignal: { + actionKey: 'package-work', + reason: 'Saved application answers still need work.', + workspaceTab: 'tailored-cv', + followMode: null, + needsAttention: true, + hasPackageGap: true, + needsInterviewPrep: false, + needsFollowUpAction: true, + hasTailoredCv: true, + hasSavedApplicationAnswerDraft: false, + hasInterviewPrepNotes: true, + }, + }); + + setupApiMocks({ reminders: [job], jobs: [job] }); + + const dashboardRender = renderWithProviders('/dashboard', <> + } /> + } /> + ); + + fireEvent.click(await screen.findByRole('button', { name: /build package/i })); + await waitFor(() => expect(screen.getByTestId('location-indicator')).toHaveTextContent('/jobs?open=43&tab=3')); + dashboardRender.unmount(); + + setupApiMocks({ reminders: [job], jobs: [job] }); + const remindersRender = renderWithProviders('/reminders', <> + } /> + } /> + ); + + fireEvent.click(await screen.findByRole('button', { name: /build package/i })); + await waitFor(() => expect(screen.getByTestId('location-indicator')).toHaveTextContent('/jobs?open=43&tab=3')); + remindersRender.unmount(); + + setupApiMocks({ reminders: [job], jobs: [job] }); + renderWithProviders('/table', <> + {}} columns={{ status: true, dateApplied: true, daysSince: true, jobUrl: false }} onColumnsChange={() => {}} mode="jobs" />} /> + } /> + ); + + fireEvent.click(await screen.findByRole('button', { name: /platform engineer — build package signal/i })); + await waitFor(() => expect(screen.getByTestId('location-indicator')).toHaveTextContent('/jobs?open=43&tab=3')); +}); + +test('job table readiness filter follows workflow signals instead of raw notes or cv text heuristics', async () => { + const packageGapJob = buildJob({ + id: 44, + jobTitle: 'Application Engineer', + status: 'Applied', + notes: 'General notes only', + tailoredCvText: 'Saved tailored CV', + workflowSignal: { + actionKey: 'package-work', + reason: 'Saved application answers still need work.', + workspaceTab: 'tailored-cv', + followMode: null, + needsAttention: true, + hasPackageGap: true, + needsInterviewPrep: false, + needsFollowUpAction: false, + hasTailoredCv: true, + hasSavedApplicationAnswerDraft: false, + hasInterviewPrepNotes: true, + }, + }); + + const readyRejectedJob = buildJob({ + id: 45, + jobTitle: 'Operations Analyst', + status: 'Rejected', + notes: 'Some notes', + tailoredCvText: null, + needsFollowUp: false, + followUpReason: null, + workflowSignal: { + actionKey: 'review-readiness', + reason: 'No urgent workflow gaps are blocking this job right now.', + workspaceTab: 'readiness', + followMode: null, + needsAttention: false, + hasPackageGap: false, + needsInterviewPrep: false, + needsFollowUpAction: false, + hasTailoredCv: false, + hasSavedApplicationAnswerDraft: false, + hasInterviewPrepNotes: false, + }, + }); + + setupApiMocks({ jobs: [packageGapJob, readyRejectedJob] }); + + renderWithProviders('/table', <> + {}} columns={{ status: true, dateApplied: true, daysSince: true, jobUrl: false }} onColumnsChange={() => {}} mode="jobs" />} /> + ); + + const readinessSelect = (await screen.findAllByRole('combobox')).find((element) => /all readiness/i.test(element.textContent ?? '')); + expect(readinessSelect).toBeTruthy(); + fireEvent.mouseDown(readinessSelect as HTMLElement); + fireEvent.click(await screen.findByRole('option', { name: /needs work/i })); + + expect(await screen.findByText(/application engineer/i)).toBeInTheDocument(); + expect(screen.queryByText(/operations analyst/i)).not.toBeInTheDocument(); +});