Files
RealCV/src/RealCV.Infrastructure/Services/TimelineAnalyserService.cs
Peter Foster 232036746f fix: Ignore employment overlaps at the same company
Sequential roles at the same company (promotions, transfers) should not
be flagged as suspicious overlaps. Only flag overlaps between different
companies.

- Add IsSameCompany() check before flagging overlaps
- Normalize company names to handle variations like "BMW" vs "BMW UK"
- Remove common suffixes (Ltd, PLC, Group, etc.) for comparison

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 19:45:37 +00:00

268 lines
8.4 KiB
C#

using Microsoft.Extensions.Logging;
using RealCV.Application.Interfaces;
using RealCV.Application.Models;
namespace RealCV.Infrastructure.Services;
public sealed class TimelineAnalyserService : ITimelineAnalyserService
{
private readonly ILogger<TimelineAnalyserService> _logger;
private const int MinimumGapMonths = 3;
private const int AllowedOverlapMonths = 2;
public TimelineAnalyserService(ILogger<TimelineAnalyserService> logger)
{
_logger = logger;
}
public TimelineAnalysisResult Analyse(List<EmploymentEntry> employmentHistory)
{
ArgumentNullException.ThrowIfNull(employmentHistory);
if (employmentHistory.Count == 0)
{
_logger.LogDebug("No employment history to analyse");
return new TimelineAnalysisResult
{
TotalGapMonths = 0,
TotalOverlapMonths = 0,
Gaps = [],
Overlaps = []
};
}
// Filter entries with valid dates and sort by start date
var sortedEmployment = employmentHistory
.Where(e => e.StartDate.HasValue)
.OrderBy(e => e.StartDate!.Value)
.ToList();
if (sortedEmployment.Count == 0)
{
_logger.LogDebug("No employment entries with valid dates to analyse");
return new TimelineAnalysisResult
{
TotalGapMonths = 0,
TotalOverlapMonths = 0,
Gaps = [],
Overlaps = []
};
}
var gaps = DetectGaps(sortedEmployment);
var overlaps = DetectOverlaps(sortedEmployment);
var totalGapMonths = gaps.Sum(g => g.Months);
var totalOverlapMonths = overlaps.Sum(o => o.Months);
_logger.LogInformation(
"Timeline analysis complete: {GapCount} gaps ({TotalGapMonths} months), {OverlapCount} overlaps ({TotalOverlapMonths} months)",
gaps.Count, totalGapMonths, overlaps.Count, totalOverlapMonths);
return new TimelineAnalysisResult
{
TotalGapMonths = totalGapMonths,
TotalOverlapMonths = totalOverlapMonths,
Gaps = gaps,
Overlaps = overlaps
};
}
private List<TimelineGap> DetectGaps(List<EmploymentEntry> sortedEmployment)
{
var gaps = new List<TimelineGap>();
for (var i = 0; i < sortedEmployment.Count - 1; i++)
{
var current = sortedEmployment[i];
var next = sortedEmployment[i + 1];
// Get the effective end date for the current position
var currentEndDate = GetEffectiveEndDate(current);
var nextStartDate = next.StartDate!.Value;
// Skip if there's no gap or overlap
if (currentEndDate >= nextStartDate)
{
continue;
}
var gapMonths = CalculateMonthsDifference(currentEndDate, nextStartDate);
// Only report gaps of 3+ months
if (gapMonths >= MinimumGapMonths)
{
_logger.LogDebug(
"Detected {Months} month gap between {EndDate} and {StartDate}",
gapMonths, currentEndDate, nextStartDate);
gaps.Add(new TimelineGap
{
StartDate = currentEndDate,
EndDate = nextStartDate,
Months = gapMonths
});
}
}
return gaps;
}
private List<TimelineOverlap> DetectOverlaps(List<EmploymentEntry> sortedEmployment)
{
var overlaps = new List<TimelineOverlap>();
for (var i = 0; i < sortedEmployment.Count; i++)
{
for (var j = i + 1; j < sortedEmployment.Count; j++)
{
var earlier = sortedEmployment[i];
var later = sortedEmployment[j];
// Skip overlaps at the same company (internal promotions/transfers)
if (IsSameCompany(earlier.CompanyName, later.CompanyName))
{
_logger.LogDebug(
"Ignoring overlap at same company: {Company1} -> {Company2}",
earlier.CompanyName, later.CompanyName);
continue;
}
var overlap = CalculateOverlap(earlier, later);
if (overlap is not null && overlap.Value.Months > AllowedOverlapMonths)
{
_logger.LogDebug(
"Detected {Months} month overlap between {Company1} and {Company2}",
overlap.Value.Months, earlier.CompanyName, later.CompanyName);
overlaps.Add(new TimelineOverlap
{
Company1 = earlier.CompanyName,
Company2 = later.CompanyName,
OverlapStart = overlap.Value.Start,
OverlapEnd = overlap.Value.End,
Months = overlap.Value.Months
});
}
}
}
return overlaps;
}
/// <summary>
/// Determines if two company names refer to the same company.
/// Handles variations like "BMW" vs "BMW UK" vs "BMW Group".
/// </summary>
private static bool IsSameCompany(string? company1, string? company2)
{
if (string.IsNullOrWhiteSpace(company1) || string.IsNullOrWhiteSpace(company2))
{
return false;
}
// Normalize names for comparison
var name1 = NormalizeCompanyName(company1);
var name2 = NormalizeCompanyName(company2);
// Exact match after normalization
if (name1.Equals(name2, StringComparison.OrdinalIgnoreCase))
{
return true;
}
// Check if one contains the other (for "BMW" vs "BMW UK" cases)
if (name1.Length >= 3 && name2.Length >= 3)
{
if (name1.StartsWith(name2, StringComparison.OrdinalIgnoreCase) ||
name2.StartsWith(name1, StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
return false;
}
private static string NormalizeCompanyName(string name)
{
// Remove common suffixes and normalize
var normalized = name.Trim();
string[] suffixes = ["Ltd", "Ltd.", "Limited", "PLC", "Plc", "Inc", "Inc.",
"Corporation", "Corp", "Corp.", "UK", "Group", "(UK)", "& Co", "& Co."];
foreach (var suffix in suffixes)
{
if (normalized.EndsWith(" " + suffix, StringComparison.OrdinalIgnoreCase))
{
normalized = normalized[..^(suffix.Length + 1)].Trim();
}
}
return normalized;
}
private static (DateOnly Start, DateOnly End, int Months)? CalculateOverlap(
EmploymentEntry earlier,
EmploymentEntry later)
{
if (!earlier.StartDate.HasValue || !later.StartDate.HasValue)
{
return null;
}
var earlierEnd = GetEffectiveEndDate(earlier);
var laterStart = later.StartDate.Value;
// No overlap if earlier job ended before later job started
if (earlierEnd <= laterStart)
{
return null;
}
var laterEnd = GetEffectiveEndDate(later);
// The overlap period
var overlapStart = laterStart;
var overlapEnd = earlierEnd < laterEnd ? earlierEnd : laterEnd;
if (overlapStart >= overlapEnd)
{
return null;
}
var months = CalculateMonthsDifference(overlapStart, overlapEnd);
return (overlapStart, overlapEnd, months);
}
private static DateOnly GetEffectiveEndDate(EmploymentEntry entry)
{
if (entry.EndDate.HasValue)
{
return entry.EndDate.Value;
}
// If marked as current or no end date, use today
return DateOnly.FromDateTime(DateTime.UtcNow);
}
private static int CalculateMonthsDifference(DateOnly startDate, DateOnly endDate)
{
var yearDiff = endDate.Year - startDate.Year;
var monthDiff = endDate.Month - startDate.Month;
var totalMonths = (yearDiff * 12) + monthDiff;
// Add a month if we've passed the day in the month
if (endDate.Day >= startDate.Day)
{
totalMonths++;
}
return Math.Max(0, totalMonths);
}
}