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 Events); public sealed record TimelineDto( IReadOnlyList Days, IReadOnlyList Milestones, IReadOnlyList Categories, int TotalEvents); public interface IApplicationTimelineService { Task 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 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(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()); } }