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