3d74baef78
Phase 5.5. Completes the lifecycle after submission: prepare, communicate, chase. Interview preparation gets a durable, user-owned store. There were already two per-application AI stores, InterviewPrepNote and AiWorkspaceNote, but both are caches that regenerate when their context signature changes — anything a user typed into them would eventually be overwritten. InterviewPrepItem is the side nothing regenerates, covering company research, technical notes, behavioural answers, STAR examples and the user's own questions in one table, because those categories differ only by label and adding one must not need a migration. Each item records whether the user wrote it or accepted a suggestion, and an IsPrepared flag makes the section double as the preparation checklist. Generation stays in the existing AiWorkspaceService "interview" module, appended to AiInteraction as before. A suggestion is history until the user adds it as a prep item; opening the section generates nothing. Follow-up reuses what exists rather than adding a tracker. The date is JobApplication.FollowUpAt, the same field RulesEngine and the reminder hosted service already act on, so reminders keep working with no new wiring. The task stays an ApplicationChecklistItem in the follow-up category — the section counts open tasks without owning them. The record is a FollowUpSet JobEvent, the same type the rest of the app emits. Communication is untouched: Correspondence already owns recruiter contacts, history and notes, and the workspace already mounted it. The timeline interpreter learned five more types — InterviewScheduled, InterviewCompleted and OfferReceived as milestones, FollowUpCreated and FollowUpCompleted as routine, deliberately outside the milestone spine so it stays a summary of what actually happened. JobEvent remains the history source. InterviewPrepItems is reconciler-owned with a no-op migration, guarded on JobApplications, and verified on a fresh MariaDB 11: int AUTO_INCREMENT primary key, varchar owner and title, tinyint flag, datetime(6), composite index inside the key limit. 371 backend tests, 128 frontend tests, Release build and the production build all pass locally. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
192 lines
8.0 KiB
C#
192 lines
8.0 KiB
C#
using JobTrackerApi.Data;
|
|
using JobTrackerApi.Models;
|
|
using Microsoft.EntityFrameworkCore;
|
|
|
|
namespace JobTrackerApi.Services;
|
|
|
|
// Phase 5.3 Milestone 1 — timeline intelligence.
|
|
//
|
|
// A READ-ONLY interpretation layer over JobEvent. JobEvent stays the source of historical truth: this
|
|
// writes nothing, stores nothing, and adds no table. It turns rows like
|
|
// ("StatusChanged", "Applied", "Interview") into a sentence, tags each event with a category and
|
|
// whether it is a milestone, and groups the result by day so the workspace can render a real
|
|
// timeline instead of a flat list. docs/architecture/application-workspace.md.
|
|
public sealed record TimelineEventDto(
|
|
int Id,
|
|
string Type,
|
|
string Category,
|
|
string Summary,
|
|
string? Detail,
|
|
bool IsMilestone,
|
|
DateTime At);
|
|
|
|
public sealed record TimelineDayDto(DateTime Date, string Label, IReadOnlyList<TimelineEventDto> Events);
|
|
|
|
public sealed record TimelineDto(
|
|
IReadOnlyList<TimelineDayDto> Days,
|
|
IReadOnlyList<TimelineEventDto> Milestones,
|
|
IReadOnlyList<string> Categories,
|
|
int TotalEvents);
|
|
|
|
public interface IApplicationTimelineService
|
|
{
|
|
Task<TimelineDto?> GetAsync(string ownerUserId, int jobApplicationId, string? category, bool milestonesOnly, CancellationToken ct);
|
|
}
|
|
|
|
public sealed class ApplicationTimelineService : IApplicationTimelineService
|
|
{
|
|
// Event categories, so the UI can filter without knowing every raw Type.
|
|
public const string CategoryLifecycle = "lifecycle";
|
|
public const string CategoryStage = "stage";
|
|
public const string CategoryFollowUp = "follow-up";
|
|
public const string CategoryCommunication = "communication";
|
|
public const string CategoryAi = "ai";
|
|
|
|
private readonly JobTrackerContext _db;
|
|
|
|
public ApplicationTimelineService(JobTrackerContext db)
|
|
{
|
|
_db = db;
|
|
}
|
|
|
|
public async Task<TimelineDto?> GetAsync(string ownerUserId, int jobApplicationId, string? category, bool milestonesOnly, CancellationToken ct)
|
|
{
|
|
var owns = await _db.JobApplications.AsNoTracking()
|
|
.AnyAsync(j => j.Id == jobApplicationId && j.OwnerUserId == ownerUserId, ct);
|
|
if (!owns) return null;
|
|
|
|
var events = await _db.JobEvents.AsNoTracking()
|
|
.Where(e => e.JobApplicationId == jobApplicationId)
|
|
.OrderByDescending(e => e.At)
|
|
.ThenByDescending(e => e.Id)
|
|
.ToListAsync(ct);
|
|
|
|
var projected = events.Select(Describe).ToList();
|
|
|
|
var filtered = projected
|
|
.Where(e => string.IsNullOrWhiteSpace(category) || string.Equals(e.Category, category, StringComparison.OrdinalIgnoreCase))
|
|
.Where(e => !milestonesOnly || e.IsMilestone)
|
|
.ToList();
|
|
|
|
var days = filtered
|
|
.GroupBy(e => e.At.Date)
|
|
.OrderByDescending(g => g.Key)
|
|
.Select(g => new TimelineDayDto(g.Key, DayLabel(g.Key), g.ToList()))
|
|
.ToList();
|
|
|
|
return new TimelineDto(
|
|
days,
|
|
// Milestones ignore the active filter: they are the "what actually happened" spine and stay
|
|
// visible while the user narrows the detail below.
|
|
projected.Where(e => e.IsMilestone).ToList(),
|
|
projected.Select(e => e.Category).Distinct().OrderBy(c => c, StringComparer.Ordinal).ToList(),
|
|
projected.Count);
|
|
}
|
|
|
|
// One JobEvent row -> a sentence a human can read, plus its category and milestone flag.
|
|
private static TimelineEventDto Describe(JobEvent e)
|
|
{
|
|
var type = (e.Type ?? string.Empty).Trim();
|
|
var (category, summary, isMilestone) = type switch
|
|
{
|
|
"Created" => (CategoryLifecycle, "Application created", true),
|
|
"Deleted" => (CategoryLifecycle, "Application moved to trash", false),
|
|
"Restored" => (CategoryLifecycle, "Application restored from trash", false),
|
|
"Undo" => (CategoryLifecycle, "Change undone", false),
|
|
"StatusChanged" => (CategoryStage, StatusSummary(e), IsMilestoneStatus(e.NewValue)),
|
|
// Phase 5.5 lifecycle events. Interviews and offers are milestones; scheduling and
|
|
// completing a follow-up is routine, so it stays out of the milestone spine.
|
|
"InterviewScheduled" => (CategoryStage, InterviewSummary(e, "Interview scheduled"), true),
|
|
"InterviewCompleted" => (CategoryStage, InterviewSummary(e, "Interview completed"), true),
|
|
"OfferReceived" => (CategoryStage, "Offer received", true),
|
|
"FollowUpCreated" => (CategoryFollowUp, FollowUpSummary(e), false),
|
|
"FollowUpCompleted" => (CategoryFollowUp, "Follow-up completed", false),
|
|
"FollowUpSet" => (CategoryFollowUp, FollowUpSummary(e), false),
|
|
"ResponseUpdated" => (CategoryCommunication, ResponseSummary(e), false),
|
|
"ReplyReceived" => (CategoryCommunication, "Reply received", true),
|
|
"AiRefreshed" => (CategoryAi, "AI suggestions refreshed", false),
|
|
_ => (CategoryLifecycle, string.IsNullOrWhiteSpace(type) ? "Activity recorded" : Humanize(type), false),
|
|
};
|
|
|
|
// The note is the user's own words, so it always wins as the detail line.
|
|
var detail = !string.IsNullOrWhiteSpace(e.Note) ? e.Note!.Trim() : null;
|
|
|
|
return new TimelineEventDto(e.Id, type, category, summary, detail, isMilestone, e.At);
|
|
}
|
|
|
|
private static string StatusSummary(JobEvent e)
|
|
{
|
|
var from = Clean(e.OldValue);
|
|
var to = Clean(e.NewValue);
|
|
if (to is null) return "Status changed";
|
|
return from is null ? $"Moved to {to}" : $"Moved from {from} to {to}";
|
|
}
|
|
|
|
private static string InterviewSummary(JobEvent e, string prefix)
|
|
{
|
|
var detail = Clean(e.NewValue);
|
|
if (detail is null) return prefix;
|
|
return DateTime.TryParse(detail, out var parsed)
|
|
? $"{prefix} for {parsed:d MMMM yyyy}"
|
|
: $"{prefix} — {detail}";
|
|
}
|
|
|
|
private static string FollowUpSummary(JobEvent e)
|
|
{
|
|
var to = Clean(e.NewValue);
|
|
if (to is null) return "Follow-up cleared";
|
|
return DateTime.TryParse(to, out var parsed)
|
|
? $"Follow-up scheduled for {parsed:d MMMM yyyy}"
|
|
: $"Follow-up scheduled for {to}";
|
|
}
|
|
|
|
private static string ResponseSummary(JobEvent e)
|
|
{
|
|
var to = Clean(e.NewValue);
|
|
return to is null ? "Response status updated" : $"Response marked {to}";
|
|
}
|
|
|
|
// The stages that actually mean something happened, as opposed to routine housekeeping.
|
|
private static bool IsMilestoneStatus(string? status)
|
|
{
|
|
var s = (status ?? string.Empty).Trim();
|
|
if (s.Length == 0) return false;
|
|
return s.Contains("applied", StringComparison.OrdinalIgnoreCase)
|
|
|| s.Contains("interview", StringComparison.OrdinalIgnoreCase)
|
|
|| s.Contains("offer", StringComparison.OrdinalIgnoreCase)
|
|
|| s.Contains("rejected", StringComparison.OrdinalIgnoreCase)
|
|
|| s.Contains("accepted", StringComparison.OrdinalIgnoreCase)
|
|
|| s.Contains("declined", StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
private static string DayLabel(DateTime date)
|
|
{
|
|
var today = DateTime.Now.Date;
|
|
if (date == today) return "Today";
|
|
if (date == today.AddDays(-1)) return "Yesterday";
|
|
return date.Year == today.Year ? date.ToString("dddd d MMMM") : date.ToString("d MMMM yyyy");
|
|
}
|
|
|
|
private static string? Clean(string? value) =>
|
|
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
|
|
|
// "StatusChanged" -> "Status changed", so an unknown future type still reads as a sentence.
|
|
private static string Humanize(string type)
|
|
{
|
|
var chars = new List<char>(type.Length + 4);
|
|
for (var i = 0; i < type.Length; i++)
|
|
{
|
|
if (i > 0 && char.IsUpper(type[i]))
|
|
{
|
|
chars.Add(' ');
|
|
chars.Add(char.ToLowerInvariant(type[i]));
|
|
}
|
|
else
|
|
{
|
|
chars.Add(type[i]);
|
|
}
|
|
}
|
|
return new string(chars.ToArray());
|
|
}
|
|
}
|