feat(workspace): unified application checklist (Phase 5 milestone 2)
Evolve the existing readiness workflow into one persisted, user-controlled checklist rather than adding a second tracker. ApplicationChecklistItem records only completion state and user intent. Each default system item carries a stable SystemKey and an AutoSignal — the same signal /readiness already computed — and re-syncs on every read: a satisfied signal auto-completes the item, a reverted signal reopens it, and a manual tick always wins. Users can add, reorder, dismiss and delete. Readiness is refactored into a projection of the checklist (score = completion percentage, completed/missing = live items by status). Its DTO shape and the workflowSignal/reminders health view are unchanged, so no API contract breaks. The workspace's next recommended action now comes from the first pending checklist item in category priority order (preparation, submission, follow-up, interview, custom), replacing the parallel ruleset — so the overview can never recommend something already ticked off, and a user's own task can be next. The table follows the established MariaDB-safe path: the scaffolded migration is a no-op and the idempotent reconciler owns the DDL for both providers. Verified on MariaDB 11 — auto_increment PK, varchar/datetime(6)/tinyint(1) columns, both indexes inside the key limit, cascade delete, unique system key per application, and NULL system keys not colliding for custom items. 329 backend tests, 94 frontend tests, type check, production build and both Docker builds pass locally. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -36,7 +36,8 @@ public sealed record WorkspaceOverviewDto(
|
||||
int AiInteractionCount,
|
||||
DateTimeOffset? LastAiAtUtc,
|
||||
IReadOnlyList<WorkspaceActivityDto> RecentActivity,
|
||||
WorkspaceNextStepDto? NextStep);
|
||||
WorkspaceNextStepDto? NextStep,
|
||||
ChecklistProgressDto? ChecklistProgress);
|
||||
|
||||
public interface IApplicationWorkspaceService
|
||||
{
|
||||
@@ -48,10 +49,12 @@ public sealed class ApplicationWorkspaceService : IApplicationWorkspaceService
|
||||
private const int RecentActivityCount = 8;
|
||||
|
||||
private readonly JobTrackerContext _db;
|
||||
private readonly IApplicationChecklistService _checklist;
|
||||
|
||||
public ApplicationWorkspaceService(JobTrackerContext db)
|
||||
public ApplicationWorkspaceService(JobTrackerContext db, IApplicationChecklistService checklist)
|
||||
{
|
||||
_db = db;
|
||||
_checklist = checklist;
|
||||
}
|
||||
|
||||
public async Task<WorkspaceOverviewDto?> GetOverviewAsync(string ownerUserId, int jobApplicationId, CancellationToken ct)
|
||||
@@ -90,6 +93,10 @@ public sealed class ApplicationWorkspaceService : IApplicationWorkspaceService
|
||||
.Select(e => new WorkspaceActivityDto(e.Type, e.Note ?? e.NewValue, e.At))
|
||||
.ToListAsync(ct);
|
||||
|
||||
// The checklist is the single workflow surface, so the overview's "next step" and progress both
|
||||
// come from it rather than a parallel ruleset. Seeds itself on first read.
|
||||
var checklist = await _checklist.GetAsync(ownerUserId, jobApplicationId, ct);
|
||||
|
||||
var stage = JobPipeline.Stages.FirstOrDefault(s => string.Equals(s.Key, JobPipeline.Normalize(job.Status), StringComparison.OrdinalIgnoreCase));
|
||||
var hasCoverLetter = job.HasCoverLetter || !string.IsNullOrWhiteSpace(job.CoverLetterText);
|
||||
|
||||
@@ -115,42 +122,20 @@ public sealed class ApplicationWorkspaceService : IApplicationWorkspaceService
|
||||
aiCount,
|
||||
lastAi,
|
||||
activity,
|
||||
NextStep(job, cv, hasCoverLetter, documentCount));
|
||||
NextStep(checklist),
|
||||
checklist?.Progress);
|
||||
}
|
||||
|
||||
// "The user should never ask what to do next." First unmet rule in priority order wins. Ordered so
|
||||
// the answer matches where the application actually is: understand the role, prepare the material,
|
||||
// send it, then chase it.
|
||||
private static WorkspaceNextStepDto? NextStep(JobApplication job, WorkspaceCvDto cv, bool hasCoverLetter, int documentCount)
|
||||
// "The user should never ask what to do next." Milestone 2 moved this onto the checklist: the first
|
||||
// pending item, in category priority order (preparation, submission, follow-up, interview, custom)
|
||||
// then the user's own ordering. One workflow surface — the overview cannot recommend something the
|
||||
// checklist has already been ticked off, and a user-added task can be the next action.
|
||||
private static WorkspaceNextStepDto? NextStep(ChecklistDto? checklist)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(job.Description))
|
||||
return new("add-job-details", "Add the job advert", "Analysis and matching need the advert text.", "job-details");
|
||||
|
||||
if (JobPipeline.IsProspect(job.Status))
|
||||
{
|
||||
if (cv.VariantId is null && !cv.HasTailoredCvText)
|
||||
return new("prepare-cv", "Prepare a CV for this role", "No CV variant is attached yet.", "cv");
|
||||
if (!hasCoverLetter)
|
||||
return new("write-cover-letter", "Write a cover letter", "A tailored letter measurably lifts response rates.", "cover-letter");
|
||||
return new("submit-application", "Submit the application", "The material is ready — move it out of the prospect stage.", "overview");
|
||||
}
|
||||
|
||||
if (cv.VariantId is null && !cv.HasTailoredCvText)
|
||||
return new("prepare-cv", "Prepare a CV for this role", "No CV variant is attached to this application.", "cv");
|
||||
if (!hasCoverLetter)
|
||||
return new("write-cover-letter", "Write a cover letter", "No cover letter draft saved for this application.", "cover-letter");
|
||||
if (documentCount == 0)
|
||||
return new("attach-documents", "Attach supporting documents", "Certificates or references strengthen the application.", "documents");
|
||||
if (IsInterviewStage(job.Status))
|
||||
return new("prepare-interview", "Prepare for the interview", "This application has reached the interview stage.", "interview");
|
||||
if (job.FollowUpAt is null && job.DateApplied is not null)
|
||||
return new("schedule-follow-up", "Schedule a follow-up", "Applied with no follow-up date set.", "overview");
|
||||
if (string.IsNullOrWhiteSpace(job.NextAction))
|
||||
return new("set-next-action", "Write the next action", "Keeps the application moving deliberately.", "overview");
|
||||
|
||||
return null;
|
||||
if (checklist is null) return null;
|
||||
var next = ApplicationChecklistService.NextPending(checklist);
|
||||
return next is null
|
||||
? null
|
||||
: new WorkspaceNextStepDto(next.SystemKey ?? $"custom-{next.Id}", next.Title, next.Description ?? string.Empty, next.Section ?? "checklist");
|
||||
}
|
||||
|
||||
private static bool IsInterviewStage(string? status) =>
|
||||
(status ?? string.Empty).Contains("interview", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user