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:
2026-03-24 14:28:01 +01:00
parent d166f9854d
commit 9adbde3f5e
12 changed files with 974 additions and 314 deletions
@@ -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<string> BuildFollowUpApproach(string status, List<string> matchedTags, List<string> 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<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);
@@ -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<JobApplicationDto>(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<JobApplicationDto>(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<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)
{
@@ -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<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 (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")]