feat(workspace): unified application checklist (Phase 5 milestone 2)
CI and Deploy / test (push) Failing after 1m8s
CI and Deploy / deploy (push) Has been skipped

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:
cesnimda
2026-07-19 11:15:46 +02:00
parent e55a6e86b7
commit 3a906b881e
18 changed files with 3783 additions and 76 deletions
@@ -16,7 +16,29 @@ public sealed class ApplicationWorkspaceTests
var currentUser = new Mock<ICurrentUserService>();
currentUser.SetupGet(s => s.UserId).Returns(userId);
var db = new JobTrackerContext(options, currentUser.Object);
return (db, new ApplicationWorkspaceService(db));
return (db, new ApplicationWorkspaceService(db, new ApplicationChecklistService(db)));
}
// Milestone 2: the next action comes from the checklist, and every preparation item outranks the
// later categories. Seeding the parts a test is not asserting on keeps the assertion about one rule.
private static async Task CompletePreparationAsync(JobTrackerContext db, string owner, int jobId, params string[] except)
{
db.CareerProfiles.Add(new CareerProfile
{
OwnerUserId = owner,
Experiences = { new CareerExperience { OwnerUserId = owner, Title = "Dev", Company = "Acme" } },
});
await db.SaveChangesAsync();
var svc = new ApplicationChecklistService(db);
var checklist = await svc.GetAsync(owner, jobId, default);
foreach (var item in checklist!.Items.Where(i =>
i.Category == ChecklistCategories.Preparation &&
i.Status == ChecklistStatuses.Pending &&
!except.Contains(i.SystemKey)))
{
await svc.UpdateAsync(owner, jobId, item.Id, new ChecklistItemInput(null, null, null, ChecklistStatuses.Done, null), default);
}
}
private static async Task<JobApplication> SeedAsync(JobTrackerContext db, string owner, Action<JobApplication>? tweak = null)
@@ -129,7 +151,8 @@ public sealed class ApplicationWorkspaceTests
var o = await svc.GetOverviewAsync("user-1", job.Id, default);
Assert.Equal("add-job-details", o!.NextStep!.Key);
Assert.Equal("review-job-details", o!.NextStep!.Key);
Assert.Equal("job-details", o.NextStep.Section);
}
[Fact]
@@ -138,20 +161,22 @@ public sealed class ApplicationWorkspaceTests
var (db, svc) = New("user-1");
await using var _ = db;
var job = await SeedAsync(db, "user-1");
await CompletePreparationAsync(db, "user-1", job.Id,
"prepare-cv", "create-cover-letter", "attach-supporting-documents");
Assert.Equal("prepare-cv", (await svc.GetOverviewAsync("user-1", job.Id, default))!.NextStep!.Key);
job.TailoredCvText = "tailored";
await db.SaveChangesAsync();
Assert.Equal("write-cover-letter", (await svc.GetOverviewAsync("user-1", job.Id, default))!.NextStep!.Key);
Assert.Equal("create-cover-letter", (await svc.GetOverviewAsync("user-1", job.Id, default))!.NextStep!.Key);
job.CoverLetterText = "Dear team";
await db.SaveChangesAsync();
Assert.Equal("attach-documents", (await svc.GetOverviewAsync("user-1", job.Id, default))!.NextStep!.Key);
Assert.Equal("attach-supporting-documents", (await svc.GetOverviewAsync("user-1", job.Id, default))!.NextStep!.Key);
}
[Fact]
public async Task Next_step_prioritises_interview_prep_at_the_interview_stage()
public async Task Next_step_reaches_interview_prep_once_preparation_is_done()
{
var (db, svc) = New("user-1");
await using var _ = db;
@@ -160,13 +185,32 @@ public sealed class ApplicationWorkspaceTests
j.Status = "Interview";
j.TailoredCvText = "tailored";
j.CoverLetterText = "letter";
j.FollowUpAt = DateTime.UtcNow.AddDays(3);
j.NextAction = "Confirm the interview slot";
});
db.Attachments.Add(new Attachment { JobApplicationId = job.Id, FileName = "c.pdf", FilePath = "/tmp/c.pdf", FileType = "PDF" });
await db.SaveChangesAsync();
await CompletePreparationAsync(db, "user-1", job.Id);
var o = await svc.GetOverviewAsync("user-1", job.Id, default);
Assert.Equal("prepare-interview", o!.NextStep!.Key);
Assert.Equal("prepare-interview-notes", o!.NextStep!.Key);
Assert.Equal("interview", o.NextStep.Section);
}
[Fact]
public async Task Overview_reports_checklist_progress()
{
var (db, svc) = New("user-1");
await using var _ = db;
var job = await SeedAsync(db, "user-1");
var o = await svc.GetOverviewAsync("user-1", job.Id, default);
Assert.NotNull(o!.ChecklistProgress);
Assert.True(o.ChecklistProgress!.Total > 0);
// The advert is present, so that item auto-completed from the same signal readiness uses.
Assert.True(o.ChecklistProgress.Completed > 0);
}
[Fact]