Files
jobtrackingapp/JobTrackerApi/Services/ApplicationTimelineService.cs
T

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/overview.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());
}
}