feat(S05/T01): Unified workflow trust signals across the API, table, da…
- 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
This commit is contained in:
@@ -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.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: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: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}}
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ The work is grouped into two tasks because the slice has two different risks. Fi
|
|||||||
|
|
||||||
## Tasks
|
## 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.
|
- 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`
|
- 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.
|
- 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.
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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<OkObjectResult>(result.Result);
|
||||||
|
var payload = Assert.IsType<JobApplicationsController.ReadinessDto>(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<<<APPLICATION_ANSWER_DRAFT>>>\nSaved answer\n<<<END_APPLICATION_ANSWER_DRAFT>>>",
|
||||||
|
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<OkObjectResult>(result.Result);
|
||||||
|
var payload = Assert.IsType<List<JobApplicationsController.JobApplicationDto>>(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<ISummarizerService>(), Mock.Of<IAppEmailSender>(), 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<JobTrackerContext>()
|
||||||
|
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||||
|
.Options;
|
||||||
|
var currentUser = new Mock<ICurrentUserService>();
|
||||||
|
currentUser.SetupGet(service => service.UserId).Returns("user-1");
|
||||||
|
return new JobTrackerContext(options, currentUser.Object);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Mock<UserManager<ApplicationUser>> CreateUserManager()
|
||||||
|
{
|
||||||
|
var store = new Mock<IUserStore<ApplicationUser>>();
|
||||||
|
return new Mock<UserManager<ApplicationUser>>(
|
||||||
|
store.Object,
|
||||||
|
Options.Create(new IdentityOptions()),
|
||||||
|
new PasswordHasher<ApplicationUser>(),
|
||||||
|
Array.Empty<IUserValidator<ApplicationUser>>(),
|
||||||
|
Array.Empty<IPasswordValidator<ApplicationUser>>(),
|
||||||
|
new UpperInvariantLookupNormalizer(),
|
||||||
|
new IdentityErrorDescriber(),
|
||||||
|
null!,
|
||||||
|
new NullLogger<UserManager<ApplicationUser>>()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -388,6 +388,47 @@ namespace JobTrackerApi.Controllers
|
|||||||
return variants;
|
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<string> BuildFollowUpApproach(string status, List<string> matchedTags, List<string> missingTags)
|
private static List<string> BuildFollowUpApproach(string status, List<string> matchedTags, List<string> missingTags)
|
||||||
{
|
{
|
||||||
var normalized = (status ?? string.Empty).Trim();
|
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;
|
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<string> BuildReadinessReminders(JobApplication job, WorkflowSignalDto workflowSignal)
|
||||||
|
{
|
||||||
|
var reminders = new List<string>();
|
||||||
|
|
||||||
|
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<T>(List<T> Items, int Total, int Page, int PageSize);
|
public sealed record PagedResult<T>(List<T> Items, int Total, int Page, int PageSize);
|
||||||
|
|
||||||
@@ -544,6 +755,8 @@ namespace JobTrackerApi.Controllers
|
|||||||
int DaysSince,
|
int DaysSince,
|
||||||
bool NeedsFollowUp,
|
bool NeedsFollowUp,
|
||||||
string? FollowUpReason,
|
string? FollowUpReason,
|
||||||
|
string? TailoredCvText,
|
||||||
|
WorkflowSignalDto WorkflowSignal,
|
||||||
string? ShortSummary,
|
string? ShortSummary,
|
||||||
string? FullSummary
|
string? FullSummary
|
||||||
);
|
);
|
||||||
@@ -658,40 +871,7 @@ namespace JobTrackerApi.Controllers
|
|||||||
// Use persisted short summary when available to avoid repeated model calls.
|
// Use persisted short summary when available to avoid repeated model calls.
|
||||||
var shortSummary = j.ShortSummary;
|
var shortSummary = j.ShortSummary;
|
||||||
var summary = shortSummary; // list endpoints return the short summary only
|
var summary = shortSummary; // list endpoints return the short summary only
|
||||||
dtoItems.Add(new JobApplicationDto(
|
dtoItems.Add(BuildJobApplicationDto(j, d));
|
||||||
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
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return Ok(new PagedResult<JobApplicationDto>(dtoItems, totalCount, page, pageSize));
|
return Ok(new PagedResult<JobApplicationDto>(dtoItems, totalCount, page, pageSize));
|
||||||
@@ -722,40 +902,7 @@ namespace JobTrackerApi.Controllers
|
|||||||
var d = RulesEngine.Evaluate(settings, j, now, lm);
|
var d = RulesEngine.Evaluate(settings, j, now, lm);
|
||||||
var shortSummary = j.ShortSummary;
|
var shortSummary = j.ShortSummary;
|
||||||
var summary = shortSummary;
|
var summary = shortSummary;
|
||||||
dtos.Add(new JobApplicationDto(
|
dtos.Add(BuildJobApplicationDto(j, d));
|
||||||
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
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return Ok(new PagedResult<JobApplicationDto>(dtos, total, page, pageSize));
|
return Ok(new PagedResult<JobApplicationDto>(dtos, total, page, pageSize));
|
||||||
@@ -782,40 +929,7 @@ namespace JobTrackerApi.Controllers
|
|||||||
// surface readable English analysis while the original text remains available.
|
// surface readable English analysis while the original text remains available.
|
||||||
var full = await _summarizer.SummarizeAsync(BuildSummarySource(job), 250, 40);
|
var full = await _summarizer.SummarizeAsync(BuildSummarySource(job), 250, 40);
|
||||||
|
|
||||||
return Ok(new JobApplicationDto(
|
return Ok(BuildJobApplicationDto(job, d, fullSummary: full));
|
||||||
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
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("board")]
|
[HttpGet("board")]
|
||||||
@@ -875,62 +989,10 @@ namespace JobTrackerApi.Controllers
|
|||||||
lastMsg.TryGetValue(j.Id, out var lm);
|
lastMsg.TryGetValue(j.Id, out var lm);
|
||||||
var d = RulesEngine.Evaluate(settings, j, now, lm);
|
var d = RulesEngine.Evaluate(settings, j, now, lm);
|
||||||
var upcoming = j.FollowUpAt is not null && j.FollowUpAt.Value <= upcomingTo;
|
var upcoming = j.FollowUpAt is not null && j.FollowUpAt.Value <= upcomingTo;
|
||||||
if (!d.NeedsFollowUp && !upcoming) continue;
|
var workflowSignal = BuildWorkflowSignal(j, d);
|
||||||
var shortSummary = j.ShortSummary;
|
if (!workflowSignal.NeedsAttention && !upcoming) continue;
|
||||||
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.";
|
|
||||||
}
|
|
||||||
|
|
||||||
dtos.Add(new JobApplicationDto(
|
dtos.Add(BuildJobApplicationDto(j, d, followUpReasonOverride: workflowSignal.Reason));
|
||||||
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
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort: needsFollowUp first, then nearest followUpAt.
|
// Sort: needsFollowUp first, then nearest followUpAt.
|
||||||
@@ -1201,40 +1263,7 @@ namespace JobTrackerApi.Controllers
|
|||||||
.FirstOrDefaultAsync(cancellationToken);
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
var followUp = RulesEngine.Evaluate(settings, job, DateTime.Now, lastMsg);
|
var followUp = RulesEngine.Evaluate(settings, job, DateTime.Now, lastMsg);
|
||||||
|
|
||||||
return Ok(new JobApplicationDto(
|
return Ok(BuildJobApplicationDto(job, followUp));
|
||||||
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
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpDelete("{id:int}")]
|
[HttpDelete("{id:int}")]
|
||||||
@@ -1648,7 +1677,7 @@ namespace JobTrackerApi.Controllers
|
|||||||
public sealed record SaveApplicationDraftsRequest(string? CoverLetterText, string? Notes, string? RecruiterMessageDraft);
|
public sealed record SaveApplicationDraftsRequest(string? CoverLetterText, string? Notes, string? RecruiterMessageDraft);
|
||||||
private sealed record SavedPackageMaterial(string? TailoredCvText, string? CoverLetterText, string? RecruiterMessageDraft, string? Notes);
|
private sealed record SavedPackageMaterial(string? TailoredCvText, string? CoverLetterText, string? RecruiterMessageDraft, string? Notes);
|
||||||
public sealed record InterviewPrepDto(string Summary, List<string> TalkingPoints, List<string> LikelyQuestions, List<string> WeakSpots);
|
public sealed record InterviewPrepDto(string Summary, List<string> TalkingPoints, List<string> LikelyQuestions, List<string> WeakSpots);
|
||||||
public sealed record ReadinessDto(int Score, string Level, List<string> Completed, List<string> Missing, List<string> Reminders);
|
public sealed record ReadinessDto(int Score, string Level, List<string> Completed, List<string> Missing, List<string> Reminders, WorkflowSignalDto WorkflowSignal);
|
||||||
|
|
||||||
private static string BuildPackageModeInstruction(string? mode)
|
private static string BuildPackageModeInstruction(string? mode)
|
||||||
{
|
{
|
||||||
@@ -1916,25 +1945,32 @@ Candidate master CV:
|
|||||||
.FirstOrDefaultAsync(j => j.Id == id, cancellationToken);
|
.FirstOrDefaultAsync(j => j.Id == id, cancellationToken);
|
||||||
if (job is null) return NotFound();
|
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<string>();
|
var completed = new List<string>();
|
||||||
var missing = new List<string>();
|
var missing = new List<string>();
|
||||||
var reminders = new List<string>();
|
|
||||||
|
|
||||||
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 (!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 (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.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 (!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 (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.");
|
var reminders = BuildReadinessReminders(job, workflowSignal);
|
||||||
if (job.Status.Contains("Interview", StringComparison.OrdinalIgnoreCase) && string.IsNullOrWhiteSpace(job.Notes)) reminders.Add("Interview stage reached but prep notes are still missing.");
|
var score = Math.Clamp(completed.Count * 12 + (string.IsNullOrWhiteSpace(job.Description) ? 0 : 10), 20, 100);
|
||||||
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 level = score >= 80 ? "Ready" : score >= 60 ? "Needs polish" : "Needs work";
|
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")]
|
[HttpPut("{id:int}/tailored-cv")]
|
||||||
|
|||||||
@@ -23,7 +23,8 @@ import AutoGraphIcon from "@mui/icons-material/AutoGraph";
|
|||||||
import { api } from "../api";
|
import { api } from "../api";
|
||||||
import { getUserKeyFromToken } from "../themePrefs";
|
import { getUserKeyFromToken } from "../themePrefs";
|
||||||
import { useI18n } from "../i18n/I18nProvider";
|
import { useI18n } from "../i18n/I18nProvider";
|
||||||
import { buildJobWorkspacePath, JOB_DETAILS_TABS } from "../jobWorkspaceRoute";
|
import { buildWorkflowPath, getWorkflowAction } from "../jobWorkflowSignals";
|
||||||
|
import { JobApplication } from "../types";
|
||||||
|
|
||||||
interface JobStats {
|
interface JobStats {
|
||||||
total: number;
|
total: number;
|
||||||
@@ -34,15 +35,7 @@ interface JobStats {
|
|||||||
averageDaysSinceApplied: number;
|
averageDaysSinceApplied: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
type ReminderJob = {
|
type ReminderJob = JobApplication;
|
||||||
id: number;
|
|
||||||
jobTitle: string;
|
|
||||||
status: string;
|
|
||||||
followUpAt?: string | null;
|
|
||||||
tailoredCvText?: string | null;
|
|
||||||
followUpReason?: string | null;
|
|
||||||
company?: { name?: string | null };
|
|
||||||
};
|
|
||||||
|
|
||||||
type AnalyticsPoint = { month: string; applied: number; responses: number };
|
type AnalyticsPoint = { month: string; applied: number; responses: number };
|
||||||
type TagPoint = { tag: string; count: 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 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 funnelMax = overview?.funnel?.length ? Math.max(...overview.funnel.map((item) => item.count)) : 0;
|
||||||
const topSource = overview?.responseRateBySource?.[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 = [
|
const metricCards = [
|
||||||
{
|
{
|
||||||
@@ -214,13 +207,15 @@ export default function DashboardView() {
|
|||||||
const totalResponses = responseValues.reduce((sum, value) => sum + value, 0);
|
const totalResponses = responseValues.reduce((sum, value) => sum + value, 0);
|
||||||
const responseRate = totalApplied > 0 ? Math.round((totalResponses / totalApplied) * 100) : 0;
|
const responseRate = totalApplied > 0 ? Math.round((totalResponses / totalApplied) * 100) : 0;
|
||||||
const priorityJobs = reminderJobs.slice(0, 5);
|
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 openReminderJob = (job: ReminderJob) => {
|
||||||
const reason = (job.followUpReason ?? '').toLowerCase();
|
navigate(buildWorkflowPath(job));
|
||||||
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' }));
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -393,17 +388,19 @@ export default function DashboardView() {
|
|||||||
<Typography sx={{ color: "text.secondary" }}>{t("remindersNothing")}</Typography>
|
<Typography sx={{ color: "text.secondary" }}>{t("remindersNothing")}</Typography>
|
||||||
) : (
|
) : (
|
||||||
<Stack spacing={1.1}>
|
<Stack spacing={1.1}>
|
||||||
{priorityJobs.map((job) => (
|
{priorityJobs.map((job) => {
|
||||||
|
const action = getReminderAction(job);
|
||||||
|
return (
|
||||||
<Box key={job.id} sx={{ p: 1.5, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: alpha(theme.palette.primary.main, 0.03), display: "flex", justifyContent: "space-between", gap: 2, alignItems: "center", flexWrap: "wrap" }}>
|
<Box key={job.id} sx={{ p: 1.5, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: alpha(theme.palette.primary.main, 0.03), display: "flex", justifyContent: "space-between", gap: 2, alignItems: "center", flexWrap: "wrap" }}>
|
||||||
<Box>
|
<Box>
|
||||||
<Typography sx={{ fontWeight: 900 }}>{job.company?.name ?? t("jobTableCompany")} • {job.jobTitle}</Typography>
|
<Typography sx={{ fontWeight: 900 }}>{job.company?.name ?? t("jobTableCompany")} • {job.jobTitle}</Typography>
|
||||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>{job.followUpReason ?? t("remindersFollowUpLabel")}</Typography>
|
<Typography variant="body2" sx={{ color: "text.secondary" }}>{action?.detail ?? job.workflowSignal?.reason ?? job.followUpReason ?? t("remindersFollowUpLabel")}</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
<Button variant="outlined" onClick={() => openReminderJob(job)}>
|
<Button variant="outlined" onClick={() => openReminderJob(job)}>
|
||||||
{job.followUpReason?.toLowerCase().includes('tailored cv') ? t("jobDetailsTabTailoredCv") : t("jobTableFollowUp")}
|
{action?.label ?? t("remindersOpen")}
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
))}
|
)})}
|
||||||
</Stack>
|
</Stack>
|
||||||
)}
|
)}
|
||||||
<Box sx={{ mt: 1.5 }}>
|
<Box sx={{ mt: 1.5 }}>
|
||||||
|
|||||||
@@ -47,29 +47,8 @@ import { useToast } from "../toast";
|
|||||||
import SavedViewsMenu, { SavedViewParams } from "./SavedViewsMenu";
|
import SavedViewsMenu, { SavedViewParams } from "./SavedViewsMenu";
|
||||||
import { useDialogActions } from "../dialogs";
|
import { useDialogActions } from "../dialogs";
|
||||||
import { useI18n } from "../i18n/I18nProvider";
|
import { useI18n } from "../i18n/I18nProvider";
|
||||||
import { buildJobWorkspacePath, JOB_DETAILS_TABS } from "../jobWorkspaceRoute";
|
import { JobApplication } from "../types";
|
||||||
|
import { getWorkflowAction, needsInterviewPrep, needsWorkflowWork } from "../jobWorkflowSignals";
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PagedResult<T> {
|
interface PagedResult<T> {
|
||||||
items: T[];
|
items: T[];
|
||||||
@@ -210,8 +189,8 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
|
|||||||
|
|
||||||
const filteredJobs = useMemo(() => {
|
const filteredJobs = useMemo(() => {
|
||||||
if (readinessFilter === "all") return jobs;
|
if (readinessFilter === "all") return jobs;
|
||||||
if (readinessFilter === "interview") return jobs.filter((job) => job.status === "Interview" || job.status === "Interviewing");
|
if (readinessFilter === "interview") return jobs.filter((job) => needsInterviewPrep(job));
|
||||||
return jobs.filter((job) => !job.tailoredCvText || !job.notes);
|
return jobs.filter((job) => needsWorkflowWork(job));
|
||||||
}, [jobs, readinessFilter]);
|
}, [jobs, readinessFilter]);
|
||||||
|
|
||||||
const selectedAllOnPage = filteredJobs.length > 0 && filteredJobs.every((job) => selectedIds.includes(job.id));
|
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;
|
return src.length > 220 ? `${src.slice(0, 220)}...` : src;
|
||||||
};
|
};
|
||||||
|
|
||||||
const openFollowUpWorkspace = (jobId: number) => {
|
const buildWorkflowActionDetail = (job: JobApplication) => getWorkflowAction(job, {
|
||||||
navigate(buildJobWorkspacePath(jobId, { tab: JOB_DETAILS_TABS.followUp, followMode: "waiting-update" }));
|
packageWork: t("jobTablePackageWork"),
|
||||||
};
|
followUp: t("jobTableFollowUp"),
|
||||||
|
interviewPrep: t("jobTableInterviewStage"),
|
||||||
const openTailoredCvWorkspace = (jobId: number) => {
|
readiness: t("jobTableReadiness"),
|
||||||
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 getActionSignals = (job: JobApplication) => {
|
const getActionSignals = (job: JobApplication) => {
|
||||||
const signals: Array<{
|
const action = buildWorkflowActionDetail(job);
|
||||||
label: string;
|
if (!action || job.isDeleted) return [];
|
||||||
detail: string;
|
|
||||||
onClick: () => void;
|
|
||||||
variant: "contained" | "outlined";
|
|
||||||
color?: "warning" | "primary";
|
|
||||||
}> = [];
|
|
||||||
|
|
||||||
if (job.needsFollowUp) {
|
return [{
|
||||||
signals.push({
|
label: action.label,
|
||||||
label: t("jobTableFollowUp"),
|
detail: action.detail,
|
||||||
detail: job.followUpReason ?? t("jobTableNeedsFollowUp"),
|
onClick: () => navigate(action.path),
|
||||||
onClick: () => openFollowUpWorkspace(job.id),
|
variant: action.key === "follow-up" ? "contained" as const : "outlined" as const,
|
||||||
variant: "contained",
|
color: action.key === "follow-up" ? "warning" as const : "primary" as const,
|
||||||
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;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
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 (
|
return (
|
||||||
<Box>
|
<Box>
|
||||||
|
|||||||
@@ -5,9 +5,9 @@ import { Box, Button, Chip, Divider, Paper, Typography } from "@mui/material";
|
|||||||
|
|
||||||
import { api } from "../api";
|
import { api } from "../api";
|
||||||
import { JobApplication } from "../types";
|
import { JobApplication } from "../types";
|
||||||
|
import { buildWorkflowPath, getReminderGroup, getWorkflowAction } from "../jobWorkflowSignals";
|
||||||
import { useToast } from "../toast";
|
import { useToast } from "../toast";
|
||||||
import { useI18n } from "../i18n/I18nProvider";
|
import { useI18n } from "../i18n/I18nProvider";
|
||||||
import { buildJobWorkspacePath, JOB_DETAILS_TABS } from "../jobWorkspaceRoute";
|
|
||||||
|
|
||||||
type ReminderGroups = {
|
type ReminderGroups = {
|
||||||
missingCv: JobApplication[];
|
missingCv: JobApplication[];
|
||||||
@@ -19,11 +19,8 @@ type ReminderGroups = {
|
|||||||
function groupItems(items: JobApplication[]): ReminderGroups {
|
function groupItems(items: JobApplication[]): ReminderGroups {
|
||||||
const groups: ReminderGroups = { missingCv: [], missingInterviewNotes: [], overdueFollowUp: [], other: [] };
|
const groups: ReminderGroups = { missingCv: [], missingInterviewNotes: [], overdueFollowUp: [], other: [] };
|
||||||
items.forEach((item) => {
|
items.forEach((item) => {
|
||||||
const reason = (item.followUpReason ?? "").toLowerCase();
|
const group = getReminderGroup(item);
|
||||||
if (reason.includes("tailored cv")) groups.missingCv.push(item);
|
groups[group].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);
|
|
||||||
});
|
});
|
||||||
return groups;
|
return groups;
|
||||||
}
|
}
|
||||||
@@ -35,7 +32,15 @@ function ReminderSection({ title, items, onOpen, onSetFollowUp }: { title: strin
|
|||||||
return (
|
return (
|
||||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 1.25 }}>
|
<Box sx={{ display: "flex", flexDirection: "column", gap: 1.25 }}>
|
||||||
<Typography variant="h6">{title}</Typography>
|
<Typography variant="h6">{title}</Typography>
|
||||||
{items.map((j) => (
|
{items.map((j) => {
|
||||||
|
const action = getWorkflowAction(j, {
|
||||||
|
packageWork: t("jobTablePackageWork"),
|
||||||
|
followUp: t("jobTableFollowUp"),
|
||||||
|
interviewPrep: t("jobTableInterviewStage"),
|
||||||
|
readiness: t("jobTableReadiness"),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
<Paper key={j.id} sx={{ p: 1.5, display: "grid", gridTemplateColumns: "1fr auto", gap: 1, alignItems: "center" }}>
|
<Paper key={j.id} sx={{ p: 1.5, display: "grid", gridTemplateColumns: "1fr auto", gap: 1, alignItems: "center" }}>
|
||||||
<Box>
|
<Box>
|
||||||
<Typography sx={{ fontWeight: 900, lineHeight: 1.25 }}>
|
<Typography sx={{ fontWeight: 900, lineHeight: 1.25 }}>
|
||||||
@@ -43,19 +48,19 @@ function ReminderSection({ title, items, onOpen, onSetFollowUp }: { title: strin
|
|||||||
</Typography>
|
</Typography>
|
||||||
<Box sx={{ display: "flex", gap: 1, mt: 0.5, flexWrap: "wrap" }}>
|
<Box sx={{ display: "flex", gap: 1, mt: 0.5, flexWrap: "wrap" }}>
|
||||||
{j.needsFollowUp ? <Chip size="small" color="warning" label={t("remindersFollowUpLabel")} /> : null}
|
{j.needsFollowUp ? <Chip size="small" color="warning" label={t("remindersFollowUpLabel")} /> : null}
|
||||||
{j.followUpReason ? <Chip size="small" label={j.followUpReason} variant="outlined" /> : null}
|
{(j.workflowSignal?.reason ?? j.followUpReason) ? <Chip size="small" label={j.workflowSignal?.reason ?? j.followUpReason} variant="outlined" /> : null}
|
||||||
{j.followUpAt ? <Chip size="small" label={t("remindersFollowUpDate", { date: new Date(j.followUpAt).toLocaleDateString() })} variant="outlined" /> : null}
|
{j.followUpAt ? <Chip size="small" label={t("remindersFollowUpDate", { date: new Date(j.followUpAt).toLocaleDateString() })} variant="outlined" /> : null}
|
||||||
<Chip size="small" label={j.status} variant="outlined" />
|
<Chip size="small" label={j.status} variant="outlined" />
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", justifyContent: "flex-end" }}>
|
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", justifyContent: "flex-end" }}>
|
||||||
<Button size="small" variant="outlined" onClick={() => onOpen(j)}>{t("remindersOpen")}</Button>
|
<Button size="small" variant="outlined" onClick={() => onOpen(j)}>{action?.label ?? t("remindersOpen")}</Button>
|
||||||
<Button size="small" variant="outlined" onClick={() => onSetFollowUp(j.id, 3)}>+3d</Button>
|
<Button size="small" variant="outlined" onClick={() => onSetFollowUp(j.id, 3)}>+3d</Button>
|
||||||
<Button size="small" variant="outlined" onClick={() => onSetFollowUp(j.id, 7)}>+7d</Button>
|
<Button size="small" variant="outlined" onClick={() => onSetFollowUp(j.id, 7)}>+7d</Button>
|
||||||
<Button size="small" onClick={() => onSetFollowUp(j.id, null)}>{t("remindersClear")}</Button>
|
<Button size="small" onClick={() => onSetFollowUp(j.id, null)}>{t("remindersClear")}</Button>
|
||||||
</Box>
|
</Box>
|
||||||
</Paper>
|
</Paper>
|
||||||
))}
|
)})}
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -78,12 +83,7 @@ export default function RemindersView() {
|
|||||||
const grouped = useMemo(() => groupItems(items), [items]);
|
const grouped = useMemo(() => groupItems(items), [items]);
|
||||||
|
|
||||||
const openJob = (job: JobApplication) => {
|
const openJob = (job: JobApplication) => {
|
||||||
const reason = (job.followUpReason ?? '').toLowerCase();
|
navigate(buildWorkflowPath(job));
|
||||||
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' }));
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const setFollowUp = async (id: number, daysFromNow: number | null) => {
|
const setFollowUp = async (id: number, daysFromNow: number | null) => {
|
||||||
|
|||||||
@@ -26,8 +26,21 @@ const pagedJobs = {
|
|||||||
needsFollowUp: true,
|
needsFollowUp: true,
|
||||||
followUpReason: 'Follow-up due soon',
|
followUpReason: 'Follow-up due soon',
|
||||||
shortSummary: 'Strong backend match',
|
shortSummary: 'Strong backend match',
|
||||||
tailoredCvText: null,
|
tailoredCvText: 'Saved CV',
|
||||||
notes: null,
|
notes: 'Notes\n\n<<<APPLICATION_ANSWER_DRAFT>>>\nSaved answer\n<<<END_APPLICATION_ANSWER_DRAFT>>>',
|
||||||
|
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,
|
id: 43,
|
||||||
@@ -42,6 +55,19 @@ const pagedJobs = {
|
|||||||
shortSummary: 'Platform work',
|
shortSummary: 'Platform work',
|
||||||
tailoredCvText: null,
|
tailoredCvText: null,
|
||||||
notes: 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,
|
total: 2,
|
||||||
@@ -61,6 +87,19 @@ const reminderItems = [
|
|||||||
followUpReason: 'Follow-up due soon',
|
followUpReason: 'Follow-up due soon',
|
||||||
tailoredCvText: 'Saved CV',
|
tailoredCvText: 'Saved CV',
|
||||||
followUpAt: new Date().toISOString(),
|
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,
|
id: 43,
|
||||||
@@ -73,6 +112,19 @@ const reminderItems = [
|
|||||||
followUpReason: 'Tailored CV missing',
|
followUpReason: 'Tailored CV missing',
|
||||||
tailoredCvText: null,
|
tailoredCvText: null,
|
||||||
followUpAt: new Date().toISOString(),
|
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 platformJob = await screen.findByText(/platform engineer/i);
|
||||||
const platformCard = platformJob.closest('.MuiPaper-root') ?? platformJob.parentElement?.parentElement;
|
const platformCard = platformJob.closest('.MuiPaper-root') ?? platformJob.parentElement?.parentElement;
|
||||||
expect(platformCard).toBeTruthy();
|
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(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByTestId('location-indicator')).toHaveTextContent('/jobs');
|
expect(screen.getByTestId('location-indicator')).toHaveTextContent('/jobs');
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { JobApplication, WorkflowSignal } from "./types";
|
||||||
|
import { buildJobWorkspacePath, JOB_DETAILS_TABS } from "./jobWorkspaceRoute";
|
||||||
|
|
||||||
|
const TAB_BY_WORKSPACE: Record<string, number> = {
|
||||||
|
"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<JobApplication, "workflowSignal">): WorkflowSignal | null {
|
||||||
|
return job.workflowSignal ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildWorkflowPath(job: Pick<JobApplication, "id" | "workflowSignal">): 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<JobApplication, "id" | "workflowSignal">,
|
||||||
|
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<JobApplication, "workflowSignal">): boolean {
|
||||||
|
const signal = getWorkflowSignal(job);
|
||||||
|
return Boolean(signal?.hasPackageGap || signal?.needsInterviewPrep);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function needsInterviewPrep(job: Pick<JobApplication, "workflowSignal">): boolean {
|
||||||
|
return Boolean(getWorkflowSignal(job)?.needsInterviewPrep);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getReminderGroup(job: Pick<JobApplication, "workflowSignal">): 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";
|
||||||
|
}
|
||||||
@@ -11,6 +11,23 @@ export interface Company {
|
|||||||
pipelineStage?: string;
|
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 {
|
export interface JobApplication {
|
||||||
id: number;
|
id: number;
|
||||||
jobTitle: string;
|
jobTitle: string;
|
||||||
@@ -38,6 +55,7 @@ export interface JobApplication {
|
|||||||
fullSummary?: string | null;
|
fullSummary?: string | null;
|
||||||
tailoredCvText?: string | null;
|
tailoredCvText?: string | null;
|
||||||
tailoredCvUpdatedAt?: string | null;
|
tailoredCvUpdatedAt?: string | null;
|
||||||
|
workflowSignal?: WorkflowSignal | null;
|
||||||
|
|
||||||
hasResume?: boolean;
|
hasResume?: boolean;
|
||||||
hasCoverLetter?: boolean;
|
hasCoverLetter?: boolean;
|
||||||
@@ -48,7 +66,7 @@ export interface JobApplication {
|
|||||||
isDeleted?: boolean;
|
isDeleted?: boolean;
|
||||||
deletedAt?: string;
|
deletedAt?: string;
|
||||||
needsFollowUp?: boolean;
|
needsFollowUp?: boolean;
|
||||||
followUpReason?: string;
|
followUpReason?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CandidateFitChannelGuidance {
|
export interface CandidateFitChannelGuidance {
|
||||||
@@ -97,6 +115,7 @@ export interface ReadinessResponse {
|
|||||||
completed: string[];
|
completed: string[];
|
||||||
missing: string[];
|
missing: string[];
|
||||||
reminders: string[];
|
reminders: string[];
|
||||||
|
workflowSignal: WorkflowSignal;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FollowUpDraft {
|
export interface FollowUpDraft {
|
||||||
|
|||||||
@@ -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<typeof api>;
|
||||||
|
|
||||||
|
function buildJob(overrides: Partial<JobApplication>): 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 <div data-testid="location-indicator">{location.pathname}{location.search}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderWithProviders(initialPath: string, routes: React.ReactNode) {
|
||||||
|
return render(
|
||||||
|
<ToastProvider>
|
||||||
|
<I18nProvider>
|
||||||
|
<ConfirmProvider>
|
||||||
|
<PromptProvider>
|
||||||
|
<MemoryRouter initialEntries={[initialPath]}>
|
||||||
|
<Routes>{routes}</Routes>
|
||||||
|
</MemoryRouter>
|
||||||
|
</PromptProvider>
|
||||||
|
</ConfirmProvider>
|
||||||
|
</I18nProvider>
|
||||||
|
</ToastProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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', <>
|
||||||
|
<Route path="/dashboard" element={<><LocationIndicator /><DashboardView /></>} />
|
||||||
|
<Route path="/jobs" element={<LocationIndicator />} />
|
||||||
|
</>);
|
||||||
|
|
||||||
|
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', <>
|
||||||
|
<Route path="/reminders" element={<><LocationIndicator /><RemindersView /></>} />
|
||||||
|
<Route path="/jobs" element={<LocationIndicator />} />
|
||||||
|
</>);
|
||||||
|
|
||||||
|
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', <>
|
||||||
|
<Route path="/table" element={<><LocationIndicator /><JobTable refreshToken={0} pageSize={15} onPageSizeChange={() => {}} columns={{ status: true, dateApplied: true, daysSince: true, jobUrl: false }} onColumnsChange={() => {}} mode="jobs" /></>} />
|
||||||
|
<Route path="/jobs" element={<LocationIndicator />} />
|
||||||
|
</>);
|
||||||
|
|
||||||
|
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', <>
|
||||||
|
<Route path="/dashboard" element={<><LocationIndicator /><DashboardView /></>} />
|
||||||
|
<Route path="/jobs" element={<LocationIndicator />} />
|
||||||
|
</>);
|
||||||
|
|
||||||
|
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', <>
|
||||||
|
<Route path="/reminders" element={<><LocationIndicator /><RemindersView /></>} />
|
||||||
|
<Route path="/jobs" element={<LocationIndicator />} />
|
||||||
|
</>);
|
||||||
|
|
||||||
|
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', <>
|
||||||
|
<Route path="/table" element={<><LocationIndicator /><JobTable refreshToken={0} pageSize={15} onPageSizeChange={() => {}} columns={{ status: true, dateApplied: true, daysSince: true, jobUrl: false }} onColumnsChange={() => {}} mode="jobs" /></>} />
|
||||||
|
<Route path="/jobs" element={<LocationIndicator />} />
|
||||||
|
</>);
|
||||||
|
|
||||||
|
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', <>
|
||||||
|
<Route path="/table" element={<JobTable refreshToken={0} pageSize={15} onPageSizeChange={() => {}} 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();
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user