feat(timeline): emit application lifecycle events
CI and Deploy / test (push) Failing after 1m3s
CI and Deploy / deploy (push) Has been skipped

The timeline could interpret InterviewScheduled, InterviewCompleted,
OfferReceived and FollowUpCompleted, but only StatusChanged and FollowUpSet were
ever written, so those branches never rendered.

Events are now derived from the status TRANSITION in one shared emitter rather
than at each call site, so the two status-change boundaries in
JobApplicationsController cannot drift apart and a third would get the behaviour
for free. Both boundaries now call it instead of hand-writing the StatusChanged
block.

Deriving from the transition rather than the resulting state is what prevents
duplicates: one user action produces at most one lifecycle event, re-saving an
unchanged status produces none, and reaching an offer twice records it once.
Moving an application backwards is treated as a correction, not a completed
interview, so only a forward move out of an interview stage counts. An
Interview to Offer move reports the offer, which is the thing the user cares
about.

Completing a follow-up checklist item emits FollowUpCompleted, guarded on the
same transition rule so re-saving a done item stays silent. The task itself
remains a checklist item — this only records that it happened.

No new history store: every event is a JobEvent row, which stays the single
source of application history.

393 backend tests pass, including timeline rendering of the emitted events.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
cesnimda
2026-07-19 17:30:40 +02:00
parent c0bf69ad56
commit 432e1fd667
4 changed files with 279 additions and 22 deletions
@@ -0,0 +1,86 @@
using JobTrackerApi.Data;
using JobTrackerApi.Models;
namespace JobTrackerApi.Services;
// Phase 5.6 — emitting the lifecycle events the timeline already knows how to read.
//
// JobEvent stays the single history source: this adds rows to it, it does not add a second store.
// Every emitter lives here rather than at each call site, so the two status-change boundaries in
// JobApplicationsController cannot drift apart, and a third boundary gets the behaviour for free.
//
// Events are derived from the TRANSITION, not from the resulting state, so one user action produces
// at most one lifecycle event and re-saving an unchanged status produces none.
// docs/architecture/application-workspace.md.
public static class JobLifecycleEvents
{
// Records the status change itself plus, when the transition warrants it, one lifecycle event.
// Replaces the hand-written StatusChanged block at each call site.
public static void RecordStatusChange(JobTrackerContext db, JobApplication job, string? oldStatus, DateTime at)
{
var from = oldStatus ?? string.Empty;
var to = job.Status ?? string.Empty;
if (string.Equals(from, to, StringComparison.OrdinalIgnoreCase)) return;
db.JobEvents.Add(new JobEvent
{
JobApplicationId = job.Id,
Type = "StatusChanged",
OldValue = oldStatus,
NewValue = job.Status,
At = at,
});
var lifecycle = LifecycleFor(from, to);
if (lifecycle is null) return;
db.JobEvents.Add(new JobEvent
{
JobApplicationId = job.Id,
Type = lifecycle,
// Useful metadata: which transition produced it, so the timeline can say more than the type.
OldValue = oldStatus,
NewValue = job.Status,
At = at,
});
}
// At most one lifecycle event per transition. Ordered so an Interview -> Offer move reports the
// offer, which is the thing the user actually cares about.
private static string? LifecycleFor(string from, string to)
{
var wasInterview = IsInterviewStage(from);
var isInterview = IsInterviewStage(to);
if (IsOfferStage(to) && !IsOfferStage(from)) return "OfferReceived";
if (isInterview && !wasInterview) return "InterviewScheduled";
// Only counts as completed when the application moved FORWARD out of the interview stage.
// Dragging a card back to an earlier stage is a correction, not a completed interview.
if (wasInterview && !isInterview && JobPipeline.OrderOf(to) > JobPipeline.OrderOf(from))
{
return "InterviewCompleted";
}
return null;
}
// Emitted when a follow-up checklist item is ticked off. The task itself stays a checklist item —
// this only records that it happened.
public static void RecordFollowUpCompleted(JobTrackerContext db, int jobApplicationId, string title)
{
db.JobEvents.Add(new JobEvent
{
JobApplicationId = jobApplicationId,
Type = "FollowUpCompleted",
NewValue = title,
At = DateTime.Now,
});
}
private static bool IsInterviewStage(string? status) =>
(status ?? string.Empty).Contains("interview", StringComparison.OrdinalIgnoreCase);
private static bool IsOfferStage(string? status) =>
(status ?? string.Empty).Contains("offer", StringComparison.OrdinalIgnoreCase);
}