Initial commit: TrueCV CV verification platform
Clean architecture solution with: - Domain: Entities (User, CVCheck, CVFlag, CompanyCache) and Enums - Application: Service interfaces, DTOs, and models - Infrastructure: EF Core, Identity, Hangfire, external API clients, services - Web: Blazor Server UI with pages and components Features: - CV upload and parsing (PDF/DOCX) using Claude API - Employment verification against Companies House API - Timeline analysis for gaps and overlaps - Veracity scoring algorithm - Background job processing with Hangfire - Azure Blob Storage for file storage Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
426
src/TrueCV.Web/Components/Shared/TimelineVisualization.razor
Normal file
426
src/TrueCV.Web/Components/Shared/TimelineVisualization.razor
Normal file
@@ -0,0 +1,426 @@
|
||||
@using TrueCV.Application.Models
|
||||
|
||||
<div class="timeline-visualization">
|
||||
@if (Employment is null || Employment.Count == 0)
|
||||
{
|
||||
<div class="timeline-empty">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" fill="currentColor" class="bi bi-calendar-range text-muted mb-3" viewBox="0 0 16 16">
|
||||
<path d="M9 7a1 1 0 0 1 1-1h5v2h-5a1 1 0 0 1-1-1M1 9h4a1 1 0 0 1 0 2H1z"/>
|
||||
<path d="M3.5 0a.5.5 0 0 1 .5.5V1h8V.5a.5.5 0 0 1 1 0V1h1a2 2 0 0 1 2 2v11a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V3a2 2 0 0 1 2-2h1V.5a.5.5 0 0 1 .5-.5M1 4v10a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V4z"/>
|
||||
</svg>
|
||||
<span class="text-muted">No employment timeline data available</span>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="timeline-header mb-3">
|
||||
<div class="timeline-legend">
|
||||
<span class="legend-item">
|
||||
<span class="legend-color employment"></span>
|
||||
Employment
|
||||
</span>
|
||||
@if (Analysis?.Gaps?.Count > 0)
|
||||
{
|
||||
<span class="legend-item">
|
||||
<span class="legend-color gap"></span>
|
||||
Gap (@Analysis.TotalGapMonths months total)
|
||||
</span>
|
||||
}
|
||||
@if (Analysis?.Overlaps?.Count > 0)
|
||||
{
|
||||
<span class="legend-item">
|
||||
<span class="legend-color overlap"></span>
|
||||
Overlap (@Analysis.TotalOverlapMonths months total)
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="timeline-container">
|
||||
@{
|
||||
var timelineData = GetTimelineData();
|
||||
var minDate = timelineData.MinDate;
|
||||
var maxDate = timelineData.MaxDate;
|
||||
var totalMonths = GetMonthsDifference(minDate, maxDate);
|
||||
}
|
||||
|
||||
<div class="timeline-axis">
|
||||
@foreach (var year in GetYearMarkers(minDate, maxDate))
|
||||
{
|
||||
var position = GetPositionPercentage(year, minDate, totalMonths);
|
||||
<div class="timeline-year-marker" style="left: @position%;">
|
||||
<span class="year-label">@year.Year</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="timeline-bars">
|
||||
@foreach (var entry in Employment.OrderBy(e => e.StartDate ?? DateOnly.MaxValue))
|
||||
{
|
||||
var startDate = entry.StartDate ?? minDate;
|
||||
var endDate = entry.EndDate ?? (entry.IsCurrent ? DateOnly.FromDateTime(DateTime.Today) : maxDate);
|
||||
var left = GetPositionPercentage(startDate, minDate, totalMonths);
|
||||
var width = GetWidthPercentage(startDate, endDate, totalMonths);
|
||||
|
||||
<div class="timeline-bar-row">
|
||||
<div class="timeline-bar employment-bar"
|
||||
style="left: @left%; width: @width%;"
|
||||
title="@entry.CompanyName: @FormatDateRange(entry.StartDate, entry.EndDate, entry.IsCurrent)">
|
||||
<span class="bar-label">@entry.CompanyName</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (Analysis?.Gaps?.Count > 0)
|
||||
{
|
||||
<div class="timeline-gaps">
|
||||
@foreach (var gap in Analysis.Gaps)
|
||||
{
|
||||
var left = GetPositionPercentage(gap.StartDate, minDate, totalMonths);
|
||||
var width = GetWidthPercentage(gap.StartDate, gap.EndDate, totalMonths);
|
||||
|
||||
<div class="timeline-gap-marker"
|
||||
style="left: @left%; width: @width%;"
|
||||
title="Gap: @gap.Months months (@gap.StartDate.ToString("MMM yyyy") - @gap.EndDate.ToString("MMM yyyy"))">
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (Analysis?.Overlaps?.Count > 0)
|
||||
{
|
||||
<div class="timeline-overlaps">
|
||||
@foreach (var overlap in Analysis.Overlaps)
|
||||
{
|
||||
var left = GetPositionPercentage(overlap.OverlapStart, minDate, totalMonths);
|
||||
var width = GetWidthPercentage(overlap.OverlapStart, overlap.OverlapEnd, totalMonths);
|
||||
|
||||
<div class="timeline-overlap-marker"
|
||||
style="left: @left%; width: @width%;"
|
||||
title="Overlap: @overlap.Company1 & @overlap.Company2 (@overlap.Months months)">
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (Analysis is not null && (Analysis.Gaps.Count > 0 || Analysis.Overlaps.Count > 0))
|
||||
{
|
||||
<div class="timeline-details mt-4">
|
||||
@if (Analysis.Gaps.Count > 0)
|
||||
{
|
||||
<div class="timeline-detail-section">
|
||||
<h6 class="detail-title text-warning">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-exclamation-triangle me-2" viewBox="0 0 16 16">
|
||||
<path d="M7.938 2.016A.13.13 0 0 1 8.002 2a.13.13 0 0 1 .063.016.15.15 0 0 1 .054.057l6.857 11.667c.036.06.035.124.002.183a.2.2 0 0 1-.054.06.1.1 0 0 1-.066.017H1.146a.1.1 0 0 1-.066-.017.2.2 0 0 1-.054-.06.18.18 0 0 1 .002-.183L7.884 2.073a.15.15 0 0 1 .054-.057m1.044-.45a1.13 1.13 0 0 0-1.96 0L.165 13.233c-.457.778.091 1.767.98 1.767h13.713c.889 0 1.438-.99.98-1.767z"/>
|
||||
<path d="M7.002 12a1 1 0 1 1 2 0 1 1 0 0 1-2 0M7.1 5.995a.905.905 0 1 1 1.8 0l-.35 3.507a.552.552 0 0 1-1.1 0z"/>
|
||||
</svg>
|
||||
Employment Gaps
|
||||
</h6>
|
||||
<ul class="detail-list">
|
||||
@foreach (var gap in Analysis.Gaps)
|
||||
{
|
||||
<li>
|
||||
<strong>@gap.Months months</strong> gap from @gap.StartDate.ToString("MMM yyyy") to @gap.EndDate.ToString("MMM yyyy")
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (Analysis.Overlaps.Count > 0)
|
||||
{
|
||||
<div class="timeline-detail-section">
|
||||
<h6 class="detail-title text-danger">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-layers me-2" viewBox="0 0 16 16">
|
||||
<path d="M8.235 1.559a.5.5 0 0 0-.47 0l-7.5 4a.5.5 0 0 0 0 .882L3.188 8 .264 9.559a.5.5 0 0 0 0 .882l7.5 4a.5.5 0 0 0 .47 0l7.5-4a.5.5 0 0 0 0-.882L12.813 8l2.922-1.559a.5.5 0 0 0 0-.882zm3.515 7.008L14.438 10 8 13.433 1.562 10 4.25 8.567l3.515 1.874a.5.5 0 0 0 .47 0zM8 9.433 1.562 6 8 2.567 14.438 6z"/>
|
||||
</svg>
|
||||
Employment Overlaps
|
||||
</h6>
|
||||
<ul class="detail-list">
|
||||
@foreach (var overlap in Analysis.Overlaps)
|
||||
{
|
||||
<li>
|
||||
<strong>@overlap.Months months</strong> overlap between <em>@overlap.Company1</em> and <em>@overlap.Company2</em>
|
||||
(@overlap.OverlapStart.ToString("MMM yyyy") - @overlap.OverlapEnd.ToString("MMM yyyy"))
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.timeline-visualization {
|
||||
background-color: #fff;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 0.5rem;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.timeline-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 2rem;
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
.timeline-legend {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.legend-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.legend-color {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.legend-color.employment { background-color: #0d6efd; }
|
||||
.legend-color.gap { background-color: #fd7e14; }
|
||||
.legend-color.overlap { background-color: #dc3545; }
|
||||
|
||||
.timeline-container {
|
||||
position: relative;
|
||||
padding: 2rem 0;
|
||||
min-height: 150px;
|
||||
}
|
||||
|
||||
.timeline-axis {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 30px;
|
||||
border-top: 1px solid #dee2e6;
|
||||
}
|
||||
|
||||
.timeline-year-marker {
|
||||
position: absolute;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.timeline-year-marker::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -5px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 1px;
|
||||
height: 10px;
|
||||
background-color: #dee2e6;
|
||||
}
|
||||
|
||||
.year-label {
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
font-size: 0.75rem;
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
.timeline-bars {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
|
||||
.timeline-bar-row {
|
||||
position: relative;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.timeline-bar {
|
||||
position: absolute;
|
||||
height: 100%;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 0.5rem;
|
||||
overflow: hidden;
|
||||
min-width: 20px;
|
||||
}
|
||||
|
||||
.employment-bar {
|
||||
background-color: #0d6efd;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.bar-label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.timeline-gaps {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 30px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.timeline-gap-marker {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(253, 126, 20, 0.2);
|
||||
border-left: 2px dashed #fd7e14;
|
||||
border-right: 2px dashed #fd7e14;
|
||||
pointer-events: auto;
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
.timeline-overlaps {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 30px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.timeline-overlap-marker {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(220, 53, 69, 0.15);
|
||||
border-left: 2px solid #dc3545;
|
||||
border-right: 2px solid #dc3545;
|
||||
pointer-events: auto;
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
.timeline-details {
|
||||
border-top: 1px solid #dee2e6;
|
||||
padding-top: 1rem;
|
||||
}
|
||||
|
||||
.timeline-detail-section {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.timeline-detail-section:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.detail-title {
|
||||
font-size: 0.875rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.detail-list {
|
||||
font-size: 0.875rem;
|
||||
margin-bottom: 0;
|
||||
padding-left: 1.5rem;
|
||||
}
|
||||
|
||||
.detail-list li {
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.detail-list li:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@code {
|
||||
[Parameter]
|
||||
public TimelineAnalysisResult? Analysis { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public List<EmploymentEntry>? Employment { get; set; }
|
||||
|
||||
private (DateOnly MinDate, DateOnly MaxDate) GetTimelineData()
|
||||
{
|
||||
if (Employment is null || Employment.Count == 0)
|
||||
{
|
||||
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||
return (today.AddYears(-5), today);
|
||||
}
|
||||
|
||||
var dates = new List<DateOnly>();
|
||||
|
||||
foreach (var entry in Employment)
|
||||
{
|
||||
if (entry.StartDate.HasValue)
|
||||
{
|
||||
dates.Add(entry.StartDate.Value);
|
||||
}
|
||||
if (entry.EndDate.HasValue)
|
||||
{
|
||||
dates.Add(entry.EndDate.Value);
|
||||
}
|
||||
else if (entry.IsCurrent)
|
||||
{
|
||||
dates.Add(DateOnly.FromDateTime(DateTime.Today));
|
||||
}
|
||||
}
|
||||
|
||||
if (dates.Count == 0)
|
||||
{
|
||||
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||
return (today.AddYears(-5), today);
|
||||
}
|
||||
|
||||
var minDate = dates.Min().AddMonths(-3);
|
||||
var maxDate = dates.Max().AddMonths(3);
|
||||
|
||||
return (minDate, maxDate);
|
||||
}
|
||||
|
||||
private static int GetMonthsDifference(DateOnly start, DateOnly end)
|
||||
{
|
||||
return ((end.Year - start.Year) * 12) + end.Month - start.Month;
|
||||
}
|
||||
|
||||
private static double GetPositionPercentage(DateOnly date, DateOnly minDate, int totalMonths)
|
||||
{
|
||||
if (totalMonths == 0) return 0;
|
||||
var months = GetMonthsDifference(minDate, date);
|
||||
return Math.Max(0, Math.Min(100, (double)months / totalMonths * 100));
|
||||
}
|
||||
|
||||
private static double GetWidthPercentage(DateOnly start, DateOnly end, int totalMonths)
|
||||
{
|
||||
if (totalMonths == 0) return 0;
|
||||
var months = GetMonthsDifference(start, end);
|
||||
return Math.Max(1, Math.Min(100, (double)months / totalMonths * 100));
|
||||
}
|
||||
|
||||
private static IEnumerable<DateOnly> GetYearMarkers(DateOnly minDate, DateOnly maxDate)
|
||||
{
|
||||
var startYear = minDate.Year;
|
||||
var endYear = maxDate.Year;
|
||||
|
||||
for (var year = startYear; year <= endYear; year++)
|
||||
{
|
||||
yield return new DateOnly(year, 1, 1);
|
||||
}
|
||||
}
|
||||
|
||||
private static string FormatDateRange(DateOnly? startDate, DateOnly? endDate, bool isCurrent)
|
||||
{
|
||||
var start = startDate?.ToString("MMM yyyy") ?? "Unknown";
|
||||
var end = isCurrent ? "Present" : (endDate?.ToString("MMM yyyy") ?? "Unknown");
|
||||
return $"{start} - {end}";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user