diff --git a/Components/Layout/MainLayout.razor b/Components/Layout/MainLayout.razor
index f5818f5..6615012 100644
--- a/Components/Layout/MainLayout.razor
+++ b/Components/Layout/MainLayout.razor
@@ -4,8 +4,6 @@
@inject AuthenticationStateProvider AuthStateProvider
@inject AuthService AuthService
@inject IConfiguration Configuration
-@inject NavigationManager NavigationManager
-@inject IJSRuntime JS
-
-@code {
-}
diff --git a/Components/Layout/NavMenu.razor b/Components/Layout/NavMenu.razor
index de52301..aec475c 100644
--- a/Components/Layout/NavMenu.razor
+++ b/Components/Layout/NavMenu.razor
@@ -65,11 +65,6 @@
JSA Report
-
@if (Configuration.GetValue("LocalMode"))
{
diff --git a/Components/Layout/UpdateBanner.razor b/Components/Layout/UpdateBanner.razor
index b0d55b6..b6dc17c 100644
--- a/Components/Layout/UpdateBanner.razor
+++ b/Components/Layout/UpdateBanner.razor
@@ -3,13 +3,14 @@
@inject UpdateCheckService UpdateCheckService
@inject NavigationManager NavigationManager
@inject IJSRuntime JS
+@inject IHttpClientFactory HttpClientFactory
@if (UpdateCheckService.UpdateAvailable)
{
A new version (
@UpdateCheckService.LatestVersion) is available!
-
Download & restart
+
}
@@ -27,7 +28,7 @@
await JS.InvokeVoidAsync("open", url, "_blank");
}
- using var http = new HttpClient();
+ using var http = HttpClientFactory.CreateClient();
var baseUri = new Uri(NavigationManager.BaseUri);
await http.PostAsync(new Uri(baseUri, "/api/update-download"), null);
}
diff --git a/Components/Pages/JsaReport.razor b/Components/Pages/JsaReport.razor
index 8563174..657b1aa 100644
--- a/Components/Pages/JsaReport.razor
+++ b/Components/Pages/JsaReport.razor
@@ -4,6 +4,9 @@
@using Microsoft.AspNetCore.Authorization
@attribute [Authorize]
@inject JsaReportService ReportService
+@inject JobHistoryService HistoryService
+@inject AppSettingsService SettingsService
+@inject EmailService EmailService
@inject NavigationManager Navigation
@inject IJSRuntime JSRuntime
@rendermode InteractiveServer
@@ -27,10 +30,19 @@
PDF
+ @if (!string.IsNullOrEmpty(jsaSettings.WorkCoachEmail))
+ {
+
+ }
}
+
@@ -104,6 +116,10 @@
+ @if (jsaSettings.SigningStartDate.HasValue && jsaSettings.PeriodLengthDays > 0)
+ {
+
+ }
@@ -249,7 +265,21 @@
@JsaReportService.GetActionTypeDisplay(entry.ActionType)
- @entry.Details |
+
+ @entry.Details
+ @if (!string.IsNullOrEmpty(entry.ContactName))
+ {
+ Contact: @entry.ContactName
+ }
+ @if (!string.IsNullOrEmpty(entry.ContactReason))
+ {
+ Reason: @entry.ContactReason
+ }
+ @if (!string.IsNullOrEmpty(entry.ContactResult))
+ {
+ Result: @entry.ContactResult
+ }
+ |
@if (!string.IsNullOrEmpty(entry.OldValue) && !string.IsNullOrEmpty(entry.NewValue))
{
@@ -269,9 +299,16 @@
@if (!string.IsNullOrEmpty(exportError))
{
-
+
@exportError
-
+
+
+ }
+ @if (!string.IsNullOrEmpty(emailSuccessMessage))
+ {
+
+ @emailSuccessMessage
+
}
@@ -286,12 +323,74 @@
}
+@if (showAddContactModal)
+{
+
+}
+
@code {
private List reportGroups = new();
private JsaReportSummary? summary;
private bool isLoading = true;
private bool isExporting = false;
private string? exportError;
+ private string? emailSuccessMessage;
+
+ // Add Contact/Discussion modal
+ private bool showAddContactModal = false;
+ private string newContactJobTitle = "";
+ private string newContactCompany = "";
+ private string newContactName = "";
+ private string newContactReason = "";
+ private string newContactResult = "";
+ private DateTime? newContactTimestamp;
+
+ private bool IsContactFormValid =>
+ !string.IsNullOrWhiteSpace(newContactJobTitle) &&
+ !string.IsNullOrWhiteSpace(newContactCompany) &&
+ !string.IsNullOrWhiteSpace(newContactName);
// Filters
private DateTime? filterFromDate;
@@ -315,21 +414,45 @@
{ HistoryActionType.CompanyUpdated, "Company Updated" },
{ HistoryActionType.NotesUpdated, "Notes Updated" },
{ HistoryActionType.ContactAdded, "Contact Added" },
- { HistoryActionType.InteractionAdded, "Interaction Added" }
+ { HistoryActionType.InteractionAdded, "Interaction Added" },
+ { HistoryActionType.ContactDiscussion, "Contact/Discussion" }
};
private bool HasActiveFilters => filterFromDate.HasValue || filterToDate.HasValue ||
!string.IsNullOrEmpty(filterChangeSource) || !string.IsNullOrEmpty(filterSearchTerm) ||
!selectedActionTypes.SetEquals(JsaReportService.DefaultJsaActionTypes);
+ private JsaSettings jsaSettings = new();
+
protected override void OnInitialized()
{
- // Default to last 14 days (typical signing period)
- filterFromDate = DateTime.Today.AddDays(-14);
- filterToDate = DateTime.Today;
+ jsaSettings = SettingsService.GetSettings().Jsa;
+ var period = GetCurrentSigningDates();
+ filterFromDate = period.from;
+ filterToDate = period.to;
GenerateReport();
}
+ private (DateTime from, DateTime to) GetCurrentSigningDates()
+ {
+ if (jsaSettings.SigningStartDate.HasValue && jsaSettings.PeriodLengthDays > 0)
+ {
+ var start = jsaSettings.SigningStartDate.Value.Date;
+ var periodDays = jsaSettings.PeriodLengthDays;
+ var daysSinceStart = (DateTime.Today - start).Days;
+ if (daysSinceStart >= 0)
+ {
+ var currentPeriodIndex = daysSinceStart / periodDays;
+ var periodStart = start.AddDays(currentPeriodIndex * periodDays);
+ var periodEnd = periodStart.AddDays(periodDays - 1);
+ // Return full period even if end date is in the future
+ return (periodStart, periodEnd);
+ }
+ }
+ // Fallback: last 14 days
+ return (DateTime.Today.AddDays(-14), DateTime.Today);
+ }
+
private void GenerateReport()
{
isLoading = true;
@@ -383,12 +506,38 @@
private void SetCurrentSigningPeriod()
{
- // Typical JSA signing is fortnightly
- filterFromDate = DateTime.Today.AddDays(-14);
- filterToDate = DateTime.Today;
+ var period = GetCurrentSigningDates();
+ filterFromDate = period.from;
+ filterToDate = period.to;
GenerateReport();
}
+ private void SetPreviousSigningPeriod()
+ {
+ if (jsaSettings.SigningStartDate.HasValue && jsaSettings.PeriodLengthDays > 0)
+ {
+ var start = jsaSettings.SigningStartDate.Value.Date;
+ var periodDays = jsaSettings.PeriodLengthDays;
+ var daysSinceStart = (DateTime.Today - start).Days;
+ if (daysSinceStart >= 0)
+ {
+ var currentPeriodIndex = daysSinceStart / periodDays;
+ var previousPeriodIndex = currentPeriodIndex - 1;
+ if (previousPeriodIndex >= 0)
+ {
+ var periodStart = start.AddDays(previousPeriodIndex * periodDays);
+ var periodEnd = periodStart.AddDays(periodDays - 1);
+ filterFromDate = periodStart;
+ filterToDate = periodEnd;
+ GenerateReport();
+ return;
+ }
+ }
+ }
+ // Fallback if no previous period available
+ SetDateRange(14);
+ }
+
private void ClearFilters()
{
filterFromDate = null;
@@ -482,6 +631,85 @@
}
}
+ private async Task EmailReport()
+ {
+ isExporting = true;
+ exportError = null;
+ emailSuccessMessage = null;
+ StateHasChanged();
+ await Task.Delay(50);
+
+ try
+ {
+ var smtpSettings = SettingsService.GetSettingsDecrypted();
+ if (string.IsNullOrEmpty(smtpSettings.SmtpHost))
+ {
+ exportError = "SMTP is not configured. Please configure email settings first.";
+ return;
+ }
+
+ var pdfBytes = ReportService.ExportToPdf(reportGroups, summary!, GetAppBaseUrl());
+ var fileName = $"JSA-Report-{DateTime.Now:yyyy-MM-dd}.pdf";
+ var periodText = $"{summary!.DateFrom:dd/MM/yyyy} - {summary.DateTo:dd/MM/yyyy}";
+
+ var htmlBody = $@"Please find attached my JSA Job Search Activity Report for the period {periodText}.
+Summary: {summary.TotalJobs} jobs tracked, {summary.JobsAppliedTo} applied to, {summary.TotalActivities} total activities.
+Kind regards ";
+
+ var sent = await EmailService.SendEmailWithAttachmentAsync(
+ jsaSettings.WorkCoachEmail,
+ $"JSA Job Search Report - {periodText}",
+ htmlBody,
+ smtpSettings.SmtpHost, smtpSettings.SmtpPort,
+ smtpSettings.SmtpUsername, smtpSettings.SmtpPassword,
+ smtpSettings.SmtpFromEmail, smtpSettings.SmtpFromName,
+ pdfBytes, fileName, "application/pdf");
+
+ if (sent)
+ emailSuccessMessage = $"Report emailed to {jsaSettings.WorkCoachEmail}";
+ else
+ exportError = "Failed to send email. Check SMTP settings.";
+ }
+ catch (Exception ex)
+ {
+ exportError = $"Email failed: {ex.Message}";
+ }
+ finally
+ {
+ isExporting = false;
+ StateHasChanged();
+ }
+ }
+
+ private void CloseAddContactModal()
+ {
+ showAddContactModal = false;
+ newContactJobTitle = "";
+ newContactCompany = "";
+ newContactName = "";
+ newContactReason = "";
+ newContactResult = "";
+ newContactTimestamp = null;
+ }
+
+ private void SaveContactDiscussion()
+ {
+ if (!IsContactFormValid)
+ return;
+
+ HistoryService.RecordStandaloneContactDiscussion(
+ newContactJobTitle,
+ newContactCompany,
+ newContactName,
+ newContactReason,
+ newContactResult,
+ newContactTimestamp
+ );
+
+ CloseAddContactModal();
+ GenerateReport(); // Refresh the report to show the new entry
+ }
+
private static string GetActionBadgeClass(HistoryActionType action)
{
return action switch
@@ -491,6 +719,7 @@
HistoryActionType.ApplicationStageChanged => "bg-info",
HistoryActionType.InterestChanged => "bg-warning text-dark",
HistoryActionType.SuitabilityChanged => "bg-secondary",
+ HistoryActionType.ContactDiscussion => "bg-dark",
_ => "bg-light text-dark"
};
}
diff --git a/Components/Pages/Settings.razor b/Components/Pages/Settings.razor
index ef40456..a715933 100644
--- a/Components/Pages/Settings.razor
+++ b/Components/Pages/Settings.razor
@@ -70,6 +70,11 @@
Skills
+
+
+
@if (StorageBackend is JsonStorageBackend)
{
@@ -1102,6 +1107,62 @@
}
+ @* ==================== JSA TAB ==================== *@
+ @if (activeTab == "jsa")
+ {
+
+
+
+
+
+
+ @if (!string.IsNullOrEmpty(jsaSettingsMessage))
+ {
+ @jsaSettingsMessage
+ }
+
+ }
+
@* ==================== DATA TAB ==================== *@
@if (StorageBackend is JsonStorageBackend && activeTab == "data")
{
@@ -1293,6 +1354,8 @@
@code {
private string activeTab = "jobsites";
+ private AppSettings settings = new();
+ private string? jsaSettingsMessage;
private JobTracker.Models.User? currentUser;
private bool copied = false;
private NameModel nameModel { get; set; } = new();
@@ -1456,7 +1519,7 @@
private void LoadSettings()
{
- var settings = SettingsService.GetSettings();
+ settings = SettingsService.GetSettings();
var jobSiteUrls = SettingsService.GetJobSiteUrls();
editingUrls = new JobSiteUrls
@@ -2670,6 +2733,12 @@
}
}
+ private void SaveJsaSettings()
+ {
+ SettingsService.Save();
+ jsaSettingsMessage = "JSA settings saved successfully.";
+ }
+
private class NameModel
{
[System.ComponentModel.DataAnnotations.Required(ErrorMessage = "Name is required")]
diff --git a/JobTracker.Tests/HistoryActionTypeTests.cs b/JobTracker.Tests/HistoryActionTypeTests.cs
new file mode 100644
index 0000000..7af09d3
--- /dev/null
+++ b/JobTracker.Tests/HistoryActionTypeTests.cs
@@ -0,0 +1,41 @@
+using JobTracker.Models;
+using Xunit;
+
+namespace JobTracker.Tests;
+
+public class HistoryActionTypeTests
+{
+ [Fact]
+ public void ContactDiscussion_EnumValueExists()
+ {
+ // Verify the enum value exists (this will fail to compile if it doesn't)
+ var actionType = HistoryActionType.ContactDiscussion;
+ Assert.Equal("ContactDiscussion", actionType.ToString());
+ }
+
+ [Fact]
+ public void AllHistoryActionTypes_CanBeEnumerated()
+ {
+ var allTypes = Enum.GetValues();
+
+ // Verify we have all expected types
+ Assert.Contains(HistoryActionType.JobAdded, allTypes);
+ Assert.Contains(HistoryActionType.JobDeleted, allTypes);
+ Assert.Contains(HistoryActionType.AppliedStatusChanged, allTypes);
+ Assert.Contains(HistoryActionType.ApplicationStageChanged, allTypes);
+ Assert.Contains(HistoryActionType.InterestChanged, allTypes);
+ Assert.Contains(HistoryActionType.SuitabilityChanged, allTypes);
+ Assert.Contains(HistoryActionType.ContactAdded, allTypes);
+ Assert.Contains(HistoryActionType.ContactRemoved, allTypes);
+ Assert.Contains(HistoryActionType.InteractionAdded, allTypes);
+ Assert.Contains(HistoryActionType.ContactDiscussion, allTypes);
+ }
+
+ [Fact]
+ public void ContactDiscussion_IsNotSameAsOtherContactTypes()
+ {
+ Assert.NotEqual(HistoryActionType.ContactDiscussion, HistoryActionType.ContactAdded);
+ Assert.NotEqual(HistoryActionType.ContactDiscussion, HistoryActionType.ContactRemoved);
+ Assert.NotEqual(HistoryActionType.ContactDiscussion, HistoryActionType.InteractionAdded);
+ }
+}
diff --git a/JobTracker.Tests/JobHistoryServiceTests.cs b/JobTracker.Tests/JobHistoryServiceTests.cs
index bb1a6d0..95f8618 100644
--- a/JobTracker.Tests/JobHistoryServiceTests.cs
+++ b/JobTracker.Tests/JobHistoryServiceTests.cs
@@ -293,4 +293,100 @@ public void OnChange_FiresOnAddEntry()
Assert.True(fired);
}
+
+ [Fact]
+ public void RecordStandaloneContactDiscussion_CreatesEntryWithEmptyJobId()
+ {
+ _service.RecordStandaloneContactDiscussion(
+ "Senior Developer",
+ "Tech Corp",
+ "John Smith",
+ "Phone screening",
+ "Scheduled interview for next week"
+ );
+
+ _storageMock.Verify(s => s.AddHistoryEntry(It.Is(e =>
+ e.JobId == Guid.Empty &&
+ e.ActionType == HistoryActionType.ContactDiscussion &&
+ e.JobTitle == "Senior Developer" &&
+ e.Company == "Tech Corp" &&
+ e.ContactName == "John Smith" &&
+ e.ContactReason == "Phone screening" &&
+ e.ContactResult == "Scheduled interview for next week" &&
+ e.ChangeSource == HistoryChangeSource.Manual &&
+ e.UserId == _userId
+ ), It.IsAny()), Times.Once);
+ }
+
+ [Fact]
+ public void RecordStandaloneContactDiscussion_WithCustomTimestamp_UsesProvidedTimestamp()
+ {
+ var customTimestamp = new DateTime(2025, 1, 15, 14, 30, 0);
+
+ _service.RecordStandaloneContactDiscussion(
+ "Developer",
+ "Acme Inc",
+ "Jane Doe",
+ "Follow-up call",
+ "No response",
+ customTimestamp
+ );
+
+ _storageMock.Verify(s => s.AddHistoryEntry(It.Is(e =>
+ e.Timestamp == customTimestamp
+ ), It.IsAny()), Times.Once);
+ }
+
+ [Fact]
+ public void RecordStandaloneContactDiscussion_WithoutTimestamp_UsesCurrentTime()
+ {
+ var beforeCall = DateTime.Now;
+
+ _service.RecordStandaloneContactDiscussion(
+ "Developer",
+ "Test Co",
+ "Bob Jones",
+ "Email inquiry",
+ "Waiting for response"
+ );
+
+ var afterCall = DateTime.Now;
+
+ _storageMock.Verify(s => s.AddHistoryEntry(It.Is(e =>
+ e.Timestamp >= beforeCall && e.Timestamp <= afterCall
+ ), It.IsAny()), Times.Once);
+ }
+
+ [Fact]
+ public void RecordStandaloneContactDiscussion_WithNoUser_DoesNotStore()
+ {
+ _currentUserMock.Setup(c => c.GetCurrentUserId()).Returns(Guid.Empty);
+
+ _service.RecordStandaloneContactDiscussion(
+ "Developer",
+ "Test Co",
+ "Contact",
+ "Reason",
+ "Result"
+ );
+
+ _storageMock.Verify(s => s.AddHistoryEntry(It.IsAny(), It.IsAny()), Times.Never);
+ }
+
+ [Fact]
+ public void RecordStandaloneContactDiscussion_IncludesContactInDetails()
+ {
+ _service.RecordStandaloneContactDiscussion(
+ "Developer",
+ "Test Co",
+ "Alice Smith",
+ "Initial discussion",
+ "Positive"
+ );
+
+ _storageMock.Verify(s => s.AddHistoryEntry(It.Is(e =>
+ e.Details != null && e.Details.Contains("Alice Smith") && e.Details.Contains("Initial discussion")
+ ), It.IsAny()), Times.Once);
+ }
}
+
diff --git a/JobTracker.Tests/JsaReportServiceTests.cs b/JobTracker.Tests/JsaReportServiceTests.cs
new file mode 100644
index 0000000..6a3b9ce
--- /dev/null
+++ b/JobTracker.Tests/JsaReportServiceTests.cs
@@ -0,0 +1,672 @@
+using JobTracker.Models;
+using JobTracker.Services;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.Logging.Abstractions;
+using Moq;
+using Xunit;
+
+namespace JobTracker.Tests;
+
+public class JsaReportServiceTests
+{
+ private readonly Guid _userId = Guid.NewGuid();
+ private readonly Mock _storageMock;
+ private readonly Mock _currentUserMock;
+ private readonly Mock _settingsMock;
+ private readonly JobHistoryService _historyService;
+ private readonly TestJobListingService _jobService;
+ private readonly TestableJsaReportService _reportService;
+
+ // Simple test double for JobListingService
+ private class TestJobListingService
+ {
+ private readonly Dictionary _jobs = new();
+
+ public void AddJob(Guid id, JobListing job) => _jobs[id] = job;
+
+ public JobListing? GetJobById(Guid id)
+ {
+ if (_jobs.TryGetValue(id, out var job))
+ return job;
+ throw new InvalidOperationException("Job not found");
+ }
+ }
+
+ // Testable version that accepts our test double
+ private class TestableJsaReportService
+ {
+ private readonly JobHistoryService _historyService;
+ private readonly TestJobListingService _jobService;
+
+ public TestableJsaReportService(JobHistoryService historyService, TestJobListingService jobService)
+ {
+ _historyService = historyService;
+ _jobService = jobService;
+ }
+
+ public List GenerateReport(JsaReportFilter filter)
+ {
+ _historyService.ForceReload();
+
+ // Get all history (unpaged)
+ var allHistory = _historyService.GetHistory(null, 1, int.MaxValue);
+
+ var entries = allHistory.Entries.AsEnumerable();
+
+ // Filter by selected action types
+ if (filter.SelectedActionTypes.Count > 0)
+ entries = entries.Where(e => filter.SelectedActionTypes.Contains(e.ActionType));
+
+ // Filter by date range
+ if (filter.FromDate.HasValue)
+ entries = entries.Where(e => e.Timestamp >= filter.FromDate.Value);
+ if (filter.ToDate.HasValue)
+ entries = entries.Where(e => e.Timestamp <= filter.ToDate.Value.AddDays(1));
+
+ // Filter by change source
+ if (filter.ChangeSource.HasValue)
+ entries = entries.Where(e => e.ChangeSource == filter.ChangeSource.Value);
+
+ // Filter by search term
+ if (!string.IsNullOrWhiteSpace(filter.SearchTerm))
+ {
+ var term = filter.SearchTerm.ToLowerInvariant();
+ entries = entries.Where(e =>
+ e.JobTitle.ToLowerInvariant().Contains(term) ||
+ e.Company.ToLowerInvariant().Contains(term) ||
+ (e.Details?.ToLowerInvariant().Contains(term) ?? false));
+ }
+
+ var filteredEntries = entries.ToList();
+
+ // Group by JobId for job-related entries
+ var jobGroups = filteredEntries
+ .Where(e => e.JobId != Guid.Empty)
+ .GroupBy(e => e.JobId)
+ .Select(g =>
+ {
+ var latestEntry = g.OrderByDescending(e => e.Timestamp).First();
+ JobListing? job = null;
+ try { job = _jobService.GetJobById(g.Key); } catch { }
+
+ return new JsaReportGroup
+ {
+ JobId = g.Key,
+ JobTitle = latestEntry.JobTitle,
+ Company = latestEntry.Company,
+ JobUrl = latestEntry.JobUrl,
+ Source = job?.Source ?? "",
+ LatestActivity = g.Max(e => e.Timestamp),
+ Entries = g.OrderByDescending(e => e.Timestamp).ToList(),
+ JobExists = job != null
+ };
+ });
+
+ // Handle standalone entries (JobId = Guid.Empty) - each gets its own group
+ var standaloneGroups = filteredEntries
+ .Where(e => e.JobId == Guid.Empty)
+ .Select(e => new JsaReportGroup
+ {
+ JobId = Guid.Empty,
+ JobTitle = e.JobTitle,
+ Company = e.Company,
+ JobUrl = e.JobUrl,
+ Source = "Standalone Entry",
+ LatestActivity = e.Timestamp,
+ Entries = new List { e },
+ JobExists = false
+ });
+
+ // Combine and sort all groups
+ return jobGroups.Concat(standaloneGroups)
+ .OrderByDescending(g => g.LatestActivity)
+ .ToList();
+ }
+
+ public JsaReportSummary GetSummary(List groups, JsaReportFilter filter)
+ {
+ var allEntries = groups.SelectMany(g => g.Entries).ToList();
+ var summary = new JsaReportSummary
+ {
+ TotalJobs = groups.Count,
+ TotalActivities = allEntries.Count,
+ DateFrom = filter.FromDate ?? allEntries.MinBy(e => e.Timestamp)?.Timestamp.Date,
+ DateTo = filter.ToDate ?? allEntries.MaxBy(e => e.Timestamp)?.Timestamp.Date,
+ JobsAppliedTo = groups.Count(g => g.Entries.Any(e => e.ActionType == HistoryActionType.AppliedStatusChanged && e.NewValue == "Applied")),
+ JobsAddedCount = allEntries.Count(e => e.ActionType == HistoryActionType.JobAdded),
+ ActionTypeCounts = allEntries.GroupBy(e => e.ActionType).ToDictionary(g => g.Key, g => g.Count())
+ };
+
+ // Calculate weekly average
+ if (summary.DateFrom.HasValue && summary.DateTo.HasValue)
+ {
+ var weeks = Math.Max(1, (summary.DateTo.Value - summary.DateFrom.Value).Days / 7.0);
+ summary.ActivitiesPerWeek = Math.Round(allEntries.Count / weeks, 1);
+ }
+
+ return summary;
+ }
+ }
+
+ public JsaReportServiceTests()
+ {
+ // Setup storage mock
+ _storageMock = new Mock();
+ _storageMock.Setup(s => s.LoadHistory(It.IsAny())).Returns(new List());
+ _storageMock.Setup(s => s.AddHistoryEntry(It.IsAny(), It.IsAny()));
+
+ // Setup current user mock
+ _currentUserMock = new Mock(
+ MockBehavior.Loose,
+ new object[] { null!, null!, null!, null! });
+ _currentUserMock.Setup(c => c.GetCurrentUserId()).Returns(_userId);
+
+ // Setup settings mock
+ _settingsMock = new Mock(
+ MockBehavior.Loose,
+ new object[] { null!, null!, null!, null! });
+ _settingsMock.Setup(s => s.GetSettings(It.IsAny())).Returns(new AppSettings { HistoryMaxEntries = 50000 });
+
+ // Setup configuration
+ var config = new ConfigurationBuilder()
+ .AddInMemoryCollection(new Dictionary { { "HistoryMax", "50000" } })
+ .Build();
+
+ // Create real JobHistoryService with mocked dependencies
+ var logger = NullLogger.Instance;
+ _historyService = new JobHistoryService(_storageMock.Object, logger, _currentUserMock.Object, config, _settingsMock.Object);
+
+ // Use test double for job service
+ _jobService = new TestJobListingService();
+
+ // Create testable report service
+ _reportService = new TestableJsaReportService(_historyService, _jobService);
+ }
+
+ private void SetupHistoryEntries(List entries)
+ {
+ _storageMock.Setup(s => s.LoadHistory(_userId)).Returns(entries);
+ _historyService.ForceReload();
+ }
+
+ private JobHistoryEntry CreateHistoryEntry(
+ Guid jobId,
+ HistoryActionType actionType,
+ string jobTitle = "Developer",
+ string company = "Acme",
+ DateTime? timestamp = null)
+ {
+ return new JobHistoryEntry
+ {
+ Id = Guid.NewGuid(),
+ UserId = _userId,
+ JobId = jobId,
+ JobTitle = jobTitle,
+ Company = company,
+ ActionType = actionType,
+ ChangeSource = HistoryChangeSource.Manual,
+ Timestamp = timestamp ?? DateTime.Now,
+ Details = $"{actionType} action"
+ };
+ }
+
+ [Fact]
+ public void GenerateReport_GroupsEntriesByJobId()
+ {
+ var jobId1 = Guid.NewGuid();
+ var jobId2 = Guid.NewGuid();
+
+ var entries = new List
+ {
+ CreateHistoryEntry(jobId1, HistoryActionType.JobAdded, "Developer", "Acme"),
+ CreateHistoryEntry(jobId1, HistoryActionType.AppliedStatusChanged, "Developer", "Acme"),
+ CreateHistoryEntry(jobId2, HistoryActionType.JobAdded, "Designer", "Beta")
+ };
+
+ SetupHistoryEntries(entries);
+
+ var filter = new JsaReportFilter();
+ var groups = _reportService.GenerateReport(filter);
+
+ Assert.Equal(2, groups.Count);
+ Assert.Contains(groups, g => g.JobId == jobId1 && g.Entries.Count == 2);
+ Assert.Contains(groups, g => g.JobId == jobId2 && g.Entries.Count == 1);
+ }
+
+ [Fact]
+ public void GenerateReport_IncludesStandaloneContactDiscussions()
+ {
+ var jobId = Guid.NewGuid();
+ var entries = new List
+ {
+ CreateHistoryEntry(jobId, HistoryActionType.JobAdded, "Developer", "Acme"),
+ new JobHistoryEntry
+ {
+ Id = Guid.NewGuid(),
+ UserId = _userId,
+ JobId = Guid.Empty, // Standalone entry
+ JobTitle = "Senior Engineer",
+ Company = "Tech Corp",
+ ActionType = HistoryActionType.ContactDiscussion,
+ ChangeSource = HistoryChangeSource.Manual,
+ ContactName = "John Smith",
+ ContactReason = "Phone screening",
+ ContactResult = "Scheduled interview",
+ Timestamp = DateTime.Now
+ }
+ };
+
+ SetupHistoryEntries(entries);
+
+ var filter = new JsaReportFilter();
+ var groups = _reportService.GenerateReport(filter);
+
+ Assert.Equal(2, groups.Count);
+
+ var standaloneGroup = groups.FirstOrDefault(g => g.JobId == Guid.Empty);
+ Assert.NotNull(standaloneGroup);
+ Assert.Equal("Senior Engineer", standaloneGroup.JobTitle);
+ Assert.Equal("Tech Corp", standaloneGroup.Company);
+ Assert.Equal("Standalone Entry", standaloneGroup.Source);
+ Assert.Single(standaloneGroup.Entries);
+ Assert.False(standaloneGroup.JobExists);
+ }
+
+ [Fact]
+ public void GenerateReport_FiltersbySelectedActionTypes()
+ {
+ var jobId = Guid.NewGuid();
+ var entries = new List
+ {
+ CreateHistoryEntry(jobId, HistoryActionType.JobAdded),
+ CreateHistoryEntry(jobId, HistoryActionType.AppliedStatusChanged),
+ CreateHistoryEntry(jobId, HistoryActionType.NotesUpdated)
+ };
+
+ SetupHistoryEntries(entries);
+
+ var filter = new JsaReportFilter
+ {
+ SelectedActionTypes = new HashSet
+ {
+ HistoryActionType.JobAdded,
+ HistoryActionType.AppliedStatusChanged
+ }
+ };
+
+ var groups = _reportService.GenerateReport(filter);
+
+ Assert.Single(groups);
+ Assert.Equal(2, groups[0].Entries.Count);
+ Assert.DoesNotContain(groups[0].Entries, e => e.ActionType == HistoryActionType.NotesUpdated);
+ }
+
+ [Fact]
+ public void GenerateReport_FiltersbyDateRange()
+ {
+ var jobId = Guid.NewGuid();
+ var baseDate = new DateTime(2025, 1, 15);
+ var entries = new List
+ {
+ CreateHistoryEntry(jobId, HistoryActionType.JobAdded, timestamp: baseDate.AddDays(-5)),
+ CreateHistoryEntry(jobId, HistoryActionType.AppliedStatusChanged, timestamp: baseDate),
+ CreateHistoryEntry(jobId, HistoryActionType.NotesUpdated, timestamp: baseDate.AddDays(5))
+ };
+
+ SetupHistoryEntries(entries);
+
+ var filter = new JsaReportFilter
+ {
+ FromDate = baseDate.AddDays(-1),
+ ToDate = baseDate.AddDays(1)
+ };
+
+ var groups = _reportService.GenerateReport(filter);
+
+ Assert.Single(groups);
+ Assert.Single(groups[0].Entries);
+ Assert.Equal(HistoryActionType.AppliedStatusChanged, groups[0].Entries[0].ActionType);
+ }
+
+ [Fact]
+ public void GenerateReport_FiltersbyChangeSource()
+ {
+ var jobId = Guid.NewGuid();
+ var entries = new List
+ {
+ new() { JobId = jobId, ActionType = HistoryActionType.JobAdded, ChangeSource = HistoryChangeSource.Manual, JobTitle = "Dev", Company = "A", UserId = _userId },
+ new() { JobId = jobId, ActionType = HistoryActionType.AppliedStatusChanged, ChangeSource = HistoryChangeSource.BrowserExtension, JobTitle = "Dev", Company = "A", UserId = _userId }
+ };
+
+ SetupHistoryEntries(entries);
+
+ var filter = new JsaReportFilter
+ {
+ ChangeSource = HistoryChangeSource.Manual
+ };
+
+ var groups = _reportService.GenerateReport(filter);
+
+ Assert.Single(groups);
+ Assert.Single(groups[0].Entries);
+ Assert.Equal(HistoryChangeSource.Manual, groups[0].Entries[0].ChangeSource);
+ }
+
+ [Fact]
+ public void GenerateReport_FiltersbySearchTerm()
+ {
+ var jobId1 = Guid.NewGuid();
+ var jobId2 = Guid.NewGuid();
+ var entries = new List
+ {
+ CreateHistoryEntry(jobId1, HistoryActionType.JobAdded, "Senior Developer", "TechCorp"),
+ CreateHistoryEntry(jobId2, HistoryActionType.JobAdded, "Designer", "CreativeCo")
+ };
+
+ SetupHistoryEntries(entries);
+
+ var filter = new JsaReportFilter
+ {
+ SearchTerm = "developer"
+ };
+
+ var groups = _reportService.GenerateReport(filter);
+
+ Assert.Single(groups);
+ Assert.Equal("Senior Developer", groups[0].JobTitle);
+ }
+
+ [Fact]
+ public void GenerateReport_OrdersByLatestActivityDescending()
+ {
+ var jobId1 = Guid.NewGuid();
+ var jobId2 = Guid.NewGuid();
+ var baseDate = DateTime.Now;
+
+ var entries = new List
+ {
+ CreateHistoryEntry(jobId1, HistoryActionType.JobAdded, timestamp: baseDate.AddDays(-5)),
+ CreateHistoryEntry(jobId2, HistoryActionType.JobAdded, timestamp: baseDate.AddDays(-1))
+ };
+
+ SetupHistoryEntries(entries);
+
+ var filter = new JsaReportFilter();
+ var groups = _reportService.GenerateReport(filter);
+
+ Assert.Equal(jobId2, groups[0].JobId); // Most recent first
+ Assert.Equal(jobId1, groups[1].JobId);
+ }
+
+ [Fact]
+ public void GenerateReport_SetsJobExistsWhenJobFound()
+ {
+ var jobId = Guid.NewGuid();
+ var entries = new List
+ {
+ CreateHistoryEntry(jobId, HistoryActionType.JobAdded)
+ };
+
+ SetupHistoryEntries(entries);
+
+ _jobService.AddJob(jobId, new JobListing { Id = jobId, Source = "LinkedIn" });
+
+ var filter = new JsaReportFilter();
+ var groups = _reportService.GenerateReport(filter);
+
+ Assert.Single(groups);
+ Assert.True(groups[0].JobExists);
+ Assert.Equal("LinkedIn", groups[0].Source);
+ }
+
+ [Fact]
+ public void GenerateReport_SetsJobExistsFalseWhenJobNotFound()
+ {
+ var jobId = Guid.NewGuid();
+ var entries = new List
+ {
+ CreateHistoryEntry(jobId, HistoryActionType.JobAdded)
+ };
+
+ SetupHistoryEntries(entries);
+
+ // Don't add job to stub - GetJobById will throw
+
+ var filter = new JsaReportFilter();
+ var groups = _reportService.GenerateReport(filter);
+
+ Assert.Single(groups);
+ Assert.False(groups[0].JobExists);
+ }
+
+ [Fact]
+ public void GetSummary_CalculatesTotalJobs()
+ {
+ var groups = new List
+ {
+ new() { JobId = Guid.NewGuid(), Entries = new() },
+ new() { JobId = Guid.NewGuid(), Entries = new() },
+ new() { JobId = Guid.Empty, Entries = new() } // Standalone
+ };
+
+ var filter = new JsaReportFilter();
+ var summary = _reportService.GetSummary(groups, filter);
+
+ Assert.Equal(3, summary.TotalJobs);
+ }
+
+ [Fact]
+ public void GetSummary_CalculatesTotalActivities()
+ {
+ var groups = new List
+ {
+ new() { Entries = new() { new JobHistoryEntry(), new JobHistoryEntry() } },
+ new() { Entries = new() { new JobHistoryEntry() } }
+ };
+
+ var filter = new JsaReportFilter();
+ var summary = _reportService.GetSummary(groups, filter);
+
+ Assert.Equal(3, summary.TotalActivities);
+ }
+
+ [Fact]
+ public void GetSummary_CountsJobsAppliedTo()
+ {
+ var groups = new List
+ {
+ new()
+ {
+ Entries = new()
+ {
+ new() { ActionType = HistoryActionType.AppliedStatusChanged, NewValue = "Applied" }
+ }
+ },
+ new()
+ {
+ Entries = new()
+ {
+ new() { ActionType = HistoryActionType.JobAdded }
+ }
+ },
+ new()
+ {
+ Entries = new()
+ {
+ new() { ActionType = HistoryActionType.AppliedStatusChanged, NewValue = "Applied" }
+ }
+ }
+ };
+
+ var filter = new JsaReportFilter();
+ var summary = _reportService.GetSummary(groups, filter);
+
+ Assert.Equal(2, summary.JobsAppliedTo);
+ }
+
+ [Fact]
+ public void GetSummary_CountsJobsAdded()
+ {
+ var groups = new List
+ {
+ new()
+ {
+ Entries = new()
+ {
+ new() { ActionType = HistoryActionType.JobAdded },
+ new() { ActionType = HistoryActionType.AppliedStatusChanged }
+ }
+ },
+ new()
+ {
+ Entries = new()
+ {
+ new() { ActionType = HistoryActionType.JobAdded }
+ }
+ }
+ };
+
+ var filter = new JsaReportFilter();
+ var summary = _reportService.GetSummary(groups, filter);
+
+ Assert.Equal(2, summary.JobsAddedCount);
+ }
+
+ [Fact]
+ public void GetSummary_CalculatesActivitiesPerWeek()
+ {
+ var fromDate = new DateTime(2025, 1, 1);
+ var toDate = new DateTime(2025, 1, 15); // 14 days = 2 weeks
+
+ var groups = new List
+ {
+ new() { Entries = new() { new JobHistoryEntry(), new JobHistoryEntry(), new JobHistoryEntry(), new JobHistoryEntry() } }
+ };
+
+ var filter = new JsaReportFilter { FromDate = fromDate, ToDate = toDate };
+ var summary = _reportService.GetSummary(groups, filter);
+
+ // 4 activities over 2 weeks = 2.0 per week
+ Assert.Equal(2.0, summary.ActivitiesPerWeek);
+ }
+
+ [Fact]
+ public void GetSummary_UsesFilterDatesWhenProvided()
+ {
+ var fromDate = new DateTime(2025, 1, 1);
+ var toDate = new DateTime(2025, 1, 31);
+
+ var groups = new List();
+ var filter = new JsaReportFilter { FromDate = fromDate, ToDate = toDate };
+ var summary = _reportService.GetSummary(groups, filter);
+
+ Assert.Equal(fromDate, summary.DateFrom);
+ Assert.Equal(toDate, summary.DateTo);
+ }
+
+ [Fact]
+ public void GetSummary_InfersDateRangeFromEntries()
+ {
+ var date1 = new DateTime(2025, 1, 1);
+ var date2 = new DateTime(2025, 1, 15);
+
+ var groups = new List
+ {
+ new()
+ {
+ Entries = new()
+ {
+ new() { Timestamp = date1 },
+ new() { Timestamp = date2 }
+ }
+ }
+ };
+
+ var filter = new JsaReportFilter();
+ var summary = _reportService.GetSummary(groups, filter);
+
+ Assert.Equal(date1.Date, summary.DateFrom);
+ Assert.Equal(date2.Date, summary.DateTo);
+ }
+
+ [Fact]
+ public void GetSummary_CreatesActionTypeCounts()
+ {
+ var groups = new List
+ {
+ new()
+ {
+ Entries = new()
+ {
+ new() { ActionType = HistoryActionType.JobAdded },
+ new() { ActionType = HistoryActionType.JobAdded },
+ new() { ActionType = HistoryActionType.AppliedStatusChanged },
+ new() { ActionType = HistoryActionType.ContactDiscussion }
+ }
+ }
+ };
+
+ var filter = new JsaReportFilter();
+ var summary = _reportService.GetSummary(groups, filter);
+
+ Assert.Equal(2, summary.ActionTypeCounts[HistoryActionType.JobAdded]);
+ Assert.Equal(1, summary.ActionTypeCounts[HistoryActionType.AppliedStatusChanged]);
+ Assert.Equal(1, summary.ActionTypeCounts[HistoryActionType.ContactDiscussion]);
+ }
+
+ [Fact]
+ public void GetActionTypeDisplay_ReturnsCorrectDisplayNames()
+ {
+ Assert.Equal("Job Added", JsaReportService.GetActionTypeDisplay(HistoryActionType.JobAdded));
+ Assert.Equal("Applied", JsaReportService.GetActionTypeDisplay(HistoryActionType.AppliedStatusChanged));
+ Assert.Equal("Stage Change", JsaReportService.GetActionTypeDisplay(HistoryActionType.ApplicationStageChanged));
+ Assert.Equal("Interest", JsaReportService.GetActionTypeDisplay(HistoryActionType.InterestChanged));
+ Assert.Equal("Suitability", JsaReportService.GetActionTypeDisplay(HistoryActionType.SuitabilityChanged));
+ Assert.Equal("Contact/Discussion", JsaReportService.GetActionTypeDisplay(HistoryActionType.ContactDiscussion));
+ }
+
+ [Fact]
+ public void DefaultJsaActionTypes_IncludesContactDiscussion()
+ {
+ Assert.Contains(HistoryActionType.ContactDiscussion, JsaReportService.DefaultJsaActionTypes);
+ }
+
+ [Fact]
+ public void MultipleStandaloneEntries_EachGetsOwnGroup()
+ {
+ var entries = new List
+ {
+ new()
+ {
+ JobId = Guid.Empty,
+ JobTitle = "Developer",
+ Company = "Acme",
+ ActionType = HistoryActionType.ContactDiscussion,
+ ContactName = "John",
+ UserId = _userId,
+ Timestamp = DateTime.Now
+ },
+ new()
+ {
+ JobId = Guid.Empty,
+ JobTitle = "Designer",
+ Company = "Beta",
+ ActionType = HistoryActionType.ContactDiscussion,
+ ContactName = "Jane",
+ UserId = _userId,
+ Timestamp = DateTime.Now.AddHours(-1)
+ }
+ };
+
+ SetupHistoryEntries(entries);
+
+ var filter = new JsaReportFilter();
+ var groups = _reportService.GenerateReport(filter);
+
+ Assert.Equal(2, groups.Count);
+ Assert.All(groups, g => Assert.Equal(Guid.Empty, g.JobId));
+ Assert.All(groups, g => Assert.Single(g.Entries));
+ Assert.Contains(groups, g => g.JobTitle == "Developer");
+ Assert.Contains(groups, g => g.JobTitle == "Designer");
+ }
+}
diff --git a/JobTracker.Tests/JsaSigningPeriodTests.cs b/JobTracker.Tests/JsaSigningPeriodTests.cs
new file mode 100644
index 0000000..e7ba531
--- /dev/null
+++ b/JobTracker.Tests/JsaSigningPeriodTests.cs
@@ -0,0 +1,238 @@
+using JobTracker.Models;
+using Xunit;
+
+namespace JobTracker.Tests;
+
+public class JsaSigningPeriodTests
+{
+ [Fact]
+ public void GetCurrentPeriod_WithValidSettings_ReturnsCorrectPeriod()
+ {
+ var settings = new JsaSettings
+ {
+ SigningStartDate = new DateTime(2025, 1, 1),
+ PeriodLengthDays = 14
+ };
+
+ // Test date: Jan 20, 2025 (19 days after start = in 2nd period)
+ var testDate = new DateTime(2025, 1, 20);
+ var (from, to) = GetCurrentSigningPeriod(settings, testDate);
+
+ Assert.Equal(new DateTime(2025, 1, 15), from); // Start of 2nd period
+ Assert.Equal(new DateTime(2025, 1, 28), to); // End of 2nd period
+ }
+
+ [Fact]
+ public void GetCurrentPeriod_OnFirstDayOfPeriod_ReturnsCorrectPeriod()
+ {
+ var settings = new JsaSettings
+ {
+ SigningStartDate = new DateTime(2025, 1, 1),
+ PeriodLengthDays = 14
+ };
+
+ var testDate = new DateTime(2025, 1, 15); // First day of 2nd period
+ var (from, to) = GetCurrentSigningPeriod(settings, testDate);
+
+ Assert.Equal(new DateTime(2025, 1, 15), from);
+ Assert.Equal(new DateTime(2025, 1, 28), to);
+ }
+
+ [Fact]
+ public void GetCurrentPeriod_OnLastDayOfPeriod_ReturnsCorrectPeriod()
+ {
+ var settings = new JsaSettings
+ {
+ SigningStartDate = new DateTime(2025, 1, 1),
+ PeriodLengthDays = 14
+ };
+
+ var testDate = new DateTime(2025, 1, 14); // Last day of 1st period
+ var (from, to) = GetCurrentSigningPeriod(settings, testDate);
+
+ Assert.Equal(new DateTime(2025, 1, 1), from);
+ Assert.Equal(new DateTime(2025, 1, 14), to);
+ }
+
+ [Fact]
+ public void GetCurrentPeriod_BeforeStartDate_ReturnsFallback()
+ {
+ var settings = new JsaSettings
+ {
+ SigningStartDate = new DateTime(2025, 2, 1),
+ PeriodLengthDays = 14
+ };
+
+ var testDate = new DateTime(2025, 1, 15); // Before start date
+ var (from, to) = GetCurrentSigningPeriod(settings, testDate);
+
+ // Should return fallback: last 14 days
+ Assert.Equal(testDate.AddDays(-14), from);
+ Assert.Equal(testDate, to);
+ }
+
+ [Fact]
+ public void GetCurrentPeriod_NoStartDate_ReturnsFallback()
+ {
+ var settings = new JsaSettings
+ {
+ SigningStartDate = null,
+ PeriodLengthDays = 14
+ };
+
+ var testDate = DateTime.Today;
+ var (from, to) = GetCurrentSigningPeriod(settings, testDate);
+
+ Assert.Equal(testDate.AddDays(-14), from);
+ Assert.Equal(testDate, to);
+ }
+
+ [Fact]
+ public void GetCurrentPeriod_ZeroPeriodLength_ReturnsFallback()
+ {
+ var settings = new JsaSettings
+ {
+ SigningStartDate = new DateTime(2025, 1, 1),
+ PeriodLengthDays = 0
+ };
+
+ var testDate = DateTime.Today;
+ var (from, to) = GetCurrentSigningPeriod(settings, testDate);
+
+ Assert.Equal(testDate.AddDays(-14), from);
+ Assert.Equal(testDate, to);
+ }
+
+ [Fact]
+ public void GetPreviousPeriod_WithValidSettings_ReturnsCorrectPeriod()
+ {
+ var settings = new JsaSettings
+ {
+ SigningStartDate = new DateTime(2025, 1, 1),
+ PeriodLengthDays = 14
+ };
+
+ // Test date: Jan 20, 2025 (in 2nd period)
+ var testDate = new DateTime(2025, 1, 20);
+ var (from, to) = GetPreviousSigningPeriod(settings, testDate);
+
+ Assert.Equal(new DateTime(2025, 1, 1), from); // Start of 1st period
+ Assert.Equal(new DateTime(2025, 1, 14), to); // End of 1st period
+ }
+
+ [Fact]
+ public void GetPreviousPeriod_InFirstPeriod_ReturnsFallback()
+ {
+ var settings = new JsaSettings
+ {
+ SigningStartDate = new DateTime(2025, 1, 1),
+ PeriodLengthDays = 14
+ };
+
+ var testDate = new DateTime(2025, 1, 10); // In first period
+ var (from, to) = GetPreviousSigningPeriod(settings, testDate);
+
+ // Should return fallback since there's no previous period
+ Assert.Equal(testDate.AddDays(-14), from);
+ Assert.Equal(testDate, to);
+ }
+
+ [Fact]
+ public void GetPreviousPeriod_InThirdPeriod_ReturnsSecondPeriod()
+ {
+ var settings = new JsaSettings
+ {
+ SigningStartDate = new DateTime(2025, 1, 1),
+ PeriodLengthDays = 14
+ };
+
+ var testDate = new DateTime(2025, 2, 5); // Day 35 = 3rd period
+ var (from, to) = GetPreviousSigningPeriod(settings, testDate);
+
+ Assert.Equal(new DateTime(2025, 1, 15), from); // Start of 2nd period
+ Assert.Equal(new DateTime(2025, 1, 28), to); // End of 2nd period
+ }
+
+ [Fact]
+ public void GetCurrentPeriod_WithFutureEndDate_IncludesFutureDates()
+ {
+ var settings = new JsaSettings
+ {
+ SigningStartDate = new DateTime(2025, 1, 1),
+ PeriodLengthDays = 14
+ };
+
+ // Test on first day of period - end date will be in future
+ var testDate = new DateTime(2025, 1, 1);
+ var (from, to) = GetCurrentSigningPeriod(settings, testDate);
+
+ Assert.Equal(new DateTime(2025, 1, 1), from);
+ Assert.Equal(new DateTime(2025, 1, 14), to);
+ Assert.True(to > testDate); // End date is in the future
+ }
+
+ [Theory]
+ [InlineData(7)] // Weekly
+ [InlineData(14)] // Fortnightly (typical JSA)
+ [InlineData(28)] // Monthly
+ public void GetCurrentPeriod_WithVariousPeriodLengths_CalculatesCorrectly(int periodDays)
+ {
+ var settings = new JsaSettings
+ {
+ SigningStartDate = new DateTime(2025, 1, 1),
+ PeriodLengthDays = periodDays
+ };
+
+ var testDate = new DateTime(2025, 1, 1).AddDays(periodDays * 2 + 3); // In 3rd period
+ var (from, to) = GetCurrentSigningPeriod(settings, testDate);
+
+ var expectedFrom = new DateTime(2025, 1, 1).AddDays(periodDays * 2);
+ var expectedTo = expectedFrom.AddDays(periodDays - 1);
+
+ Assert.Equal(expectedFrom, from);
+ Assert.Equal(expectedTo, to);
+ }
+
+ // Helper methods that mirror the logic in JsaReport.razor
+ private static (DateTime from, DateTime to) GetCurrentSigningPeriod(JsaSettings settings, DateTime today)
+ {
+ if (settings.SigningStartDate.HasValue && settings.PeriodLengthDays > 0)
+ {
+ var start = settings.SigningStartDate.Value.Date;
+ var periodDays = settings.PeriodLengthDays;
+ var daysSinceStart = (today - start).Days;
+ if (daysSinceStart >= 0)
+ {
+ var currentPeriodIndex = daysSinceStart / periodDays;
+ var periodStart = start.AddDays(currentPeriodIndex * periodDays);
+ var periodEnd = periodStart.AddDays(periodDays - 1);
+ return (periodStart, periodEnd);
+ }
+ }
+ // Fallback: last 14 days
+ return (today.AddDays(-14), today);
+ }
+
+ private static (DateTime from, DateTime to) GetPreviousSigningPeriod(JsaSettings settings, DateTime today)
+ {
+ if (settings.SigningStartDate.HasValue && settings.PeriodLengthDays > 0)
+ {
+ var start = settings.SigningStartDate.Value.Date;
+ var periodDays = settings.PeriodLengthDays;
+ var daysSinceStart = (today - start).Days;
+ if (daysSinceStart >= 0)
+ {
+ var currentPeriodIndex = daysSinceStart / periodDays;
+ var previousPeriodIndex = currentPeriodIndex - 1;
+ if (previousPeriodIndex >= 0)
+ {
+ var periodStart = start.AddDays(previousPeriodIndex * periodDays);
+ var periodEnd = periodStart.AddDays(periodDays - 1);
+ return (periodStart, periodEnd);
+ }
+ }
+ }
+ // Fallback if no previous period available
+ return (today.AddDays(-14), today);
+ }
+}
diff --git a/Models/AppSettings.cs b/Models/AppSettings.cs
index 139e555..23e13e6 100644
--- a/Models/AppSettings.cs
+++ b/Models/AppSettings.cs
@@ -49,6 +49,7 @@ public class AppSettings
public List SearchQueries { get; set; } = new();
public SkillExtractionSettings SkillExtraction { get; set; } = new();
public JobViewState LastViewState { get; set; } = new();
+ public JsaSettings Jsa { get; set; } = new();
}
public class JobViewState
@@ -211,3 +212,12 @@ public class AIAssistantSettings
public List UserSkills { get; set; } = new(); // User's skill profile
public string UserExperience { get; set; } = string.Empty; // Brief experience summary
}
+
+public class JsaSettings
+{
+ public DateTime? SigningStartDate { get; set; }
+ public int PeriodLengthDays { get; set; } = 14;
+ public string WorkCoachName { get; set; } = "";
+ public string WorkCoachEmail { get; set; } = "";
+ public string WorkCoachPhone { get; set; } = "";
+}
diff --git a/Models/JobHistory.cs b/Models/JobHistory.cs
index 7a37e47..22922bb 100644
--- a/Models/JobHistory.cs
+++ b/Models/JobHistory.cs
@@ -22,6 +22,11 @@ public class JobHistoryEntry
public string? NewValue { get; set; }
public string? Details { get; set; }
public string? RuleName { get; set; } // If changed by a rule
+
+ // Contact/Discussion fields (lightweight job-spec-style tracking)
+ public string? ContactName { get; set; }
+ public string? ContactReason { get; set; }
+ public string? ContactResult { get; set; }
}
public enum HistoryActionType
@@ -45,7 +50,8 @@ public enum HistoryActionType
Modified, // Generic field modification (for change timeline)
ContactAdded,
ContactRemoved,
- InteractionAdded
+ InteractionAdded,
+ ContactDiscussion
}
public enum HistoryChangeSource
diff --git a/Portable/JobTracker-x64.zip.001 b/Portable/JobTracker-x64.zip.001
index 9c52b1d..e0b4a58 100644
--- a/Portable/JobTracker-x64.zip.001
+++ b/Portable/JobTracker-x64.zip.001
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:b47dda1201f6eb0d181a512944313d17870c29fd7e074a0b248218fdf820917f
+oid sha256:358521dd9f5cd428c5df4dfb4c0206f1ac2b9881eefdf6a4b8ad94283bed6165
size 83886080
diff --git a/Portable/JobTracker-x64.zip.002 b/Portable/JobTracker-x64.zip.002
index 47b0cdb..894650a 100644
--- a/Portable/JobTracker-x64.zip.002
+++ b/Portable/JobTracker-x64.zip.002
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:8f88dc266b8dcfbf62b8a9816c444f2e0af44273d1b3d071946a41d125a0281b
-size 60912893
+oid sha256:bdd15a408b2f7161b8d36baad6fa2175cf244a24d033115419791f7b6c20f325
+size 58397239
diff --git a/Portable/JobTracker-x86.zip.001 b/Portable/JobTracker-x86.zip.001
index a93ef78..cc6312a 100644
--- a/Portable/JobTracker-x86.zip.001
+++ b/Portable/JobTracker-x86.zip.001
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:05bfe338ef763f1fdbea1be627dbe7d4cb650b67e189b3a8fdfb232a87d81c22
+oid sha256:19db52e944a8c4167a3c1d781bce213b9042ab04afe4d4fd53c55007d9f05094
size 83886080
diff --git a/Portable/JobTracker-x86.zip.002 b/Portable/JobTracker-x86.zip.002
index 8117499..25cffba 100644
--- a/Portable/JobTracker-x86.zip.002
+++ b/Portable/JobTracker-x86.zip.002
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:0c14f2c97f8419bbebbd4ebfabf596b10fdcadf57c690ceeabbae53fefc375f8
-size 48710102
+oid sha256:ddd2206c61e794edb8ea4086e2ed02acd4273a43e132a7d3ebe4fbaa39a84fa0
+size 46309397
diff --git a/Program.cs b/Program.cs
index 5faff33..9633e57 100644
--- a/Program.cs
+++ b/Program.cs
@@ -228,6 +228,9 @@
builder.WebHost.UseStaticWebAssets();
}
+// Configure PDFsharp font resolver once at startup (required for the base PDFsharp package)
+PdfSharp.Fonts.GlobalFontSettings.FontResolver ??= new JobTracker.Services.WindowsFontResolver();
+
var app = builder.Build();
var log = app.Logger;
diff --git a/Services/AppSettingsService.cs b/Services/AppSettingsService.cs
index fb5f264..455b6e1 100644
--- a/Services/AppSettingsService.cs
+++ b/Services/AppSettingsService.cs
@@ -157,6 +157,7 @@ public AppSettings GetSettingsDecrypted(Guid? forUserId = null)
SearchQueries = settings.SearchQueries,
SkillExtraction = settings.SkillExtraction,
LastViewState = settings.LastViewState,
+ Jsa = settings.Jsa,
};
}
diff --git a/Services/EmailService.cs b/Services/EmailService.cs
index f02d093..a17504b 100644
--- a/Services/EmailService.cs
+++ b/Services/EmailService.cs
@@ -130,4 +130,50 @@ public async Task SendEmailAsync(string toEmail, string subject, string ht
return false;
}
}
+
+ public async Task SendEmailWithAttachmentAsync(string toEmail, string subject, string htmlBody,
+ string smtpHost, int smtpPort, string smtpUsername, string smtpPassword,
+ string fromEmail, string fromName,
+ byte[] attachmentData, string attachmentFileName, string attachmentMimeType)
+ {
+ if (string.IsNullOrEmpty(smtpHost))
+ {
+ _logger.LogWarning("SMTP host not configured. Email not sent to {Email}", toEmail);
+ return false;
+ }
+
+ try
+ {
+ var message = new MimeMessage();
+ message.From.Add(new MailboxAddress(
+ string.IsNullOrEmpty(fromName) ? "Job Tracker" : fromName,
+ string.IsNullOrEmpty(fromEmail) ? smtpUsername : fromEmail));
+ message.To.Add(MailboxAddress.Parse(toEmail));
+ message.Subject = subject;
+
+ var bodyBuilder = new BodyBuilder { HtmlBody = htmlBody };
+ bodyBuilder.Attachments.Add(attachmentFileName, attachmentData, ContentType.Parse(attachmentMimeType));
+ message.Body = bodyBuilder.ToMessageBody();
+
+ using var client = new SmtpClient();
+ var secureSocketOptions = smtpPort == 465
+ ? SecureSocketOptions.SslOnConnect
+ : SecureSocketOptions.StartTls;
+
+ await client.ConnectAsync(smtpHost, smtpPort, secureSocketOptions);
+ if (!string.IsNullOrEmpty(smtpUsername) && !string.IsNullOrEmpty(smtpPassword))
+ await client.AuthenticateAsync(smtpUsername, smtpPassword);
+
+ await client.SendAsync(message);
+ await client.DisconnectAsync(true);
+
+ _logger.LogInformation("Email with attachment sent to {Email}: {Subject}", toEmail, subject);
+ return true;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Failed to send email with attachment to {Email}: {Subject}", toEmail, subject);
+ return false;
+ }
+ }
}
diff --git a/Services/JobHistoryService.cs b/Services/JobHistoryService.cs
index 1f9e838..dc17414 100644
--- a/Services/JobHistoryService.cs
+++ b/Services/JobHistoryService.cs
@@ -291,6 +291,31 @@ public void RecordContactChange(JobListing job, HistoryActionType actionType, st
}, job.UserId);
}
+ public void RecordStandaloneContactDiscussion(string jobTitle, string company, string contactName, string reason, string result, DateTime? timestamp = null)
+ {
+ var userId = CurrentUserId;
+ if (userId == Guid.Empty)
+ {
+ _logger.LogWarning("Cannot add standalone contact/discussion - no user context");
+ return;
+ }
+
+ AddEntry(new JobHistoryEntry
+ {
+ JobId = Guid.Empty, // No associated job listing
+ JobTitle = jobTitle,
+ Company = company,
+ JobUrl = null,
+ ActionType = HistoryActionType.ContactDiscussion,
+ ChangeSource = HistoryChangeSource.Manual,
+ ContactName = contactName,
+ ContactReason = reason,
+ ContactResult = result,
+ Details = $"Contact: {contactName} - {reason}",
+ Timestamp = timestamp ?? DateTime.Now
+ }, userId);
+ }
+
public void RecordChange(Guid jobId, Guid userId, string fieldName, string? oldValue, string? newValue, string description, string jobTitle, string company, string? jobUrl)
{
AddEntry(new JobHistoryEntry
diff --git a/Services/JsaReportService.cs b/Services/JsaReportService.cs
index 4352780..53bab05 100644
--- a/Services/JsaReportService.cs
+++ b/Services/JsaReportService.cs
@@ -1,10 +1,11 @@
+using System.Collections.Concurrent;
using JobTracker.Models;
using ClosedXML.Excel;
using PdfSharp;
using PdfSharp.Drawing;
-using PdfSharp.Drawing.Layout;
using PdfSharp.Fonts;
using PdfSharp.Pdf;
+using PdfSharp.Pdf.Annotations;
using DocumentFormat.OpenXml;
using DocumentFormat.OpenXml.Packaging;
using DocumentFormat.OpenXml.Wordprocessing;
@@ -27,7 +28,8 @@ public class JsaReportService
HistoryActionType.AppliedStatusChanged,
HistoryActionType.ApplicationStageChanged,
HistoryActionType.InterestChanged,
- HistoryActionType.SuitabilityChanged
+ HistoryActionType.SuitabilityChanged,
+ HistoryActionType.ContactDiscussion
};
public JsaReportService(JobHistoryService historyService, JobListingService jobService)
@@ -71,8 +73,8 @@ public List GenerateReport(JsaReportFilter filter)
var filteredEntries = entries.ToList();
- // Group by JobId
- var groups = filteredEntries
+ // Group by JobId for job-related entries
+ var jobGroups = filteredEntries
.Where(e => e.JobId != Guid.Empty)
.GroupBy(e => e.JobId)
.Select(g =>
@@ -93,7 +95,25 @@ public List GenerateReport(JsaReportFilter filter)
Entries = g.OrderByDescending(e => e.Timestamp).ToList(),
JobExists = job != null
};
- })
+ });
+
+ // Handle standalone entries (JobId = Guid.Empty) - each gets its own group
+ var standaloneGroups = filteredEntries
+ .Where(e => e.JobId == Guid.Empty)
+ .Select(e => new JsaReportGroup
+ {
+ JobId = Guid.Empty,
+ JobTitle = e.JobTitle,
+ Company = e.Company,
+ JobUrl = e.JobUrl,
+ Source = "Standalone Entry",
+ LatestActivity = e.Timestamp,
+ Entries = new List { e },
+ JobExists = false
+ });
+
+ // Combine and sort all groups
+ var groups = jobGroups.Concat(standaloneGroups)
.OrderByDescending(g => g.LatestActivity)
.ToList();
@@ -167,7 +187,7 @@ public byte[] ExportToExcel(List groups, JsaReportSummary summar
// Detail sheet
var detailSheet = workbook.Worksheets.Add("Job Search Activity");
- var headers = new[] { "Job Title", "Company/Advertiser", "Job Site", "Date", "Time", "Activity", "Details", "Old Value", "New Value", "Job Posting URL", "App Link" };
+ var headers = new[] { "Job Title", "Company/Advertiser", "Job Site", "Date", "Time", "Activity", "Details", "Old Value", "New Value", "Contact", "Reason", "Result", "Job Posting URL", "App Link" };
for (int i = 0; i < headers.Length; i++)
{
detailSheet.Cell(1, i + 1).Value = headers[i];
@@ -189,20 +209,23 @@ public byte[] ExportToExcel(List groups, JsaReportSummary summar
detailSheet.Cell(row, 7).Value = entry.Details ?? "";
detailSheet.Cell(row, 8).Value = entry.OldValue ?? "";
detailSheet.Cell(row, 9).Value = entry.NewValue ?? "";
+ detailSheet.Cell(row, 10).Value = entry.ContactName ?? "";
+ detailSheet.Cell(row, 11).Value = entry.ContactReason ?? "";
+ detailSheet.Cell(row, 12).Value = entry.ContactResult ?? "";
if (!string.IsNullOrEmpty(group.JobUrl))
{
- detailSheet.Cell(row, 10).SetHyperlink(new XLHyperlink(group.JobUrl));
- detailSheet.Cell(row, 10).Value = group.JobUrl;
- detailSheet.Cell(row, 10).Style.Font.FontColor = XLColor.Blue;
+ detailSheet.Cell(row, 13).SetHyperlink(new XLHyperlink(group.JobUrl));
+ detailSheet.Cell(row, 13).Value = group.JobUrl;
+ detailSheet.Cell(row, 13).Style.Font.FontColor = XLColor.Blue;
}
if (group.JobExists)
{
var appLink = $"{appBaseUrl.TrimEnd('/')}/?jobId={group.JobId}";
- detailSheet.Cell(row, 11).SetHyperlink(new XLHyperlink(appLink));
- detailSheet.Cell(row, 11).Value = "Open in App";
- detailSheet.Cell(row, 11).Style.Font.FontColor = XLColor.Blue;
+ detailSheet.Cell(row, 14).SetHyperlink(new XLHyperlink(appLink));
+ detailSheet.Cell(row, 14).Value = "Open in App";
+ detailSheet.Cell(row, 14).Style.Font.FontColor = XLColor.Blue;
}
row++;
@@ -219,10 +242,6 @@ public byte[] ExportToExcel(List groups, JsaReportSummary summar
public byte[] ExportToPdf(List groups, JsaReportSummary summary, string appBaseUrl)
{
- // Ensure PDFsharp can resolve system fonts (required for the base PDFsharp package)
- if (GlobalFontSettings.FontResolver is not WindowsFontResolver)
- GlobalFontSettings.FontResolver = new WindowsFontResolver();
-
var document = new PdfDocument();
document.Info.Title = "JSA Job Search Activity Report";
document.Info.Author = "Job Tracker";
@@ -315,8 +334,9 @@ void NextPage()
// ===== Job groups =====
foreach (var group in groups)
{
- // Estimate height: job header + optional url + table header + 1 row + padding
- double est = 20 + (string.IsNullOrEmpty(group.JobUrl) ? 0 : 11) + hdrRowH + rowH + 20;
+ // Estimate height: job header + optional url/app-link line + table header + 1 row + padding
+ bool hasUrlLine = !string.IsNullOrEmpty(group.JobUrl) || group.JobExists;
+ double est = 20 + (hasUrlLine ? 11 : 0) + hdrRowH + rowH + 20;
if (NeedPage(est)) NextPage();
// Job title + company
@@ -339,41 +359,88 @@ void NextPage()
}
y += 16;
- // URL
- if (!string.IsNullOrEmpty(group.JobUrl))
+ // URL (clickable)
+ if (!string.IsNullOrEmpty(group.JobUrl) && IsHttpUrl(group.JobUrl))
+ {
+ var urlText = Truncate(group.JobUrl, 100);
+ gfx.DrawString(urlText, fontUrl, XBrushes.RoyalBlue, new XPoint(ml, y + 7));
+ var urlW = gfx.MeasureString(urlText, fontUrl).Width;
+ page.AddWebLink(new PdfRectangle(new XPoint(ml, pageHeight - y - 10), new XPoint(ml + urlW, pageHeight - y)), group.JobUrl);
+ // "Open in App" link next to URL
+ if (group.JobExists)
+ {
+ var appLink = $"{appBaseUrl.TrimEnd('/')}/?jobId={group.JobId}";
+ var openText = " | Open in App";
+ gfx.DrawString(openText, fontUrl, XBrushes.SteelBlue, new XPoint(ml + urlW + 2, y + 7));
+ var openW = gfx.MeasureString(openText, fontUrl).Width;
+ page.AddWebLink(new PdfRectangle(new XPoint(ml + urlW + 2, pageHeight - y - 10), new XPoint(ml + urlW + 2 + openW, pageHeight - y)), appLink);
+ }
+ y += 11;
+ }
+ else if (!string.IsNullOrEmpty(group.JobUrl))
{
- gfx.DrawString(Truncate(group.JobUrl, 120), fontUrl, XBrushes.RoyalBlue, new XPoint(ml, y + 7));
+ gfx.DrawString(Truncate(group.JobUrl, 100), fontUrl, XBrushes.RoyalBlue, new XPoint(ml, y + 7));
+ y += 11;
+ }
+ else if (group.JobExists)
+ {
+ // No job URL but job exists — show "Open in App" standalone
+ var appLink = $"{appBaseUrl.TrimEnd('/')}/?jobId={group.JobId}";
+ gfx.DrawString("Open in App", fontUrl, XBrushes.SteelBlue, new XPoint(ml, y + 7));
+ var linkW = gfx.MeasureString("Open in App", fontUrl).Width;
+ page.AddWebLink(new PdfRectangle(new XPoint(ml, pageHeight - y - 10), new XPoint(ml + linkW, pageHeight - y)), appLink);
y += 11;
}
- // Table header
- if (NeedPage(hdrRowH + rowH)) NextPage();
- double x = ml;
- gfx.DrawRectangle(new XSolidBrush(XColor.FromArgb(210, 222, 240)), ml, y, contentWidth, hdrRowH);
- for (int i = 0; i < colH.Length; i++)
+ // Draw table header (reused on continuation pages)
+ void DrawTableHeader()
{
- gfx.DrawString(colH[i], fontTableHeader, XBrushes.Black,
- new XRect(x + 4, y + 1, colW[i] - 8, hdrRowH), XStringFormats.CenterLeft);
- x += colW[i];
+ if (NeedPage(hdrRowH + rowH)) NextPage();
+ double hx = ml;
+ gfx.DrawRectangle(new XSolidBrush(XColor.FromArgb(210, 222, 240)), ml, y, contentWidth, hdrRowH);
+ for (int i = 0; i < colH.Length; i++)
+ {
+ gfx.DrawString(colH[i], fontTableHeader, XBrushes.Black,
+ new XRect(hx + 4, y + 1, colW[i] - 8, hdrRowH), XStringFormats.CenterLeft);
+ hx += colW[i];
+ }
+ y += hdrRowH;
}
- y += hdrRowH;
+
+ DrawTableHeader();
// Table rows
bool alt = false;
foreach (var entry in group.Entries)
{
- if (NeedPage(rowH)) NextPage();
+ if (NeedPage(rowH))
+ {
+ NextPage();
+ // Re-draw job title and table header on continuation page
+ gfx.DrawString($"{Truncate(group.JobTitle, 60)} (cont.)", fontJobHeader, XBrushes.DimGray, new XPoint(ml, y + 10));
+ y += 16;
+ DrawTableHeader();
+ alt = false;
+ }
if (alt)
gfx.DrawRectangle(new XSolidBrush(XColor.FromArgb(245, 246, 252)), ml, y, contentWidth, rowH);
- x = ml;
+ double x = ml;
+ var details = entry.Details ?? "";
+ if (!string.IsNullOrEmpty(entry.ContactName))
+ details += $" | Contact: {entry.ContactName}";
+ if (!string.IsNullOrEmpty(entry.ContactReason))
+ details += $" | Reason: {entry.ContactReason}";
+ if (!string.IsNullOrEmpty(entry.ContactResult))
+ details += $" | Result: {entry.ContactResult}";
+
string[] cells =
{
entry.Timestamp.ToString("dd/MM/yyyy"),
entry.Timestamp.ToString("HH:mm"),
GetActionTypeDisplay(entry.ActionType),
- Truncate(entry.Details ?? "", 75),
+ Truncate(details, 75),
!string.IsNullOrEmpty(entry.OldValue) && !string.IsNullOrEmpty(entry.NewValue)
? $"{entry.OldValue} -> {entry.NewValue}" : ""
};
@@ -416,6 +483,12 @@ private static string Truncate(string text, int max)
return text[..(max - 3)] + "...";
}
+ private static bool IsHttpUrl(string url)
+ {
+ return Uri.TryCreate(url, UriKind.Absolute, out var uri)
+ && (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps);
+ }
+
public byte[] ExportToWord(List groups, JsaReportSummary summary, string appBaseUrl)
{
using var ms = new MemoryStream();
@@ -477,7 +550,7 @@ public byte[] ExportToWord(List groups, JsaReportSummary summary
// Header row
var headerRow = new TableRow();
- foreach (var h in new[] { "Date", "Time", "Activity", "Details", "Change" })
+ foreach (var h in new[] { "Date", "Time", "Activity", "Details", "Contact", "Change" })
{
headerRow.AppendChild(CreateTableCell(h, true, "D0D8E8"));
}
@@ -485,11 +558,14 @@ public byte[] ExportToWord(List groups, JsaReportSummary summary
foreach (var entry in group.Entries)
{
+ var contactInfo = string.Join("; ", new[] { entry.ContactName, entry.ContactReason, entry.ContactResult }
+ .Where(s => !string.IsNullOrEmpty(s)));
var dataRow = new TableRow();
dataRow.AppendChild(CreateTableCell(entry.Timestamp.ToString("dd/MM/yyyy")));
dataRow.AppendChild(CreateTableCell(entry.Timestamp.ToString("HH:mm")));
dataRow.AppendChild(CreateTableCell(GetActionTypeDisplay(entry.ActionType)));
dataRow.AppendChild(CreateTableCell(entry.Details ?? ""));
+ dataRow.AppendChild(CreateTableCell(contactInfo));
dataRow.AppendChild(CreateTableCell(
!string.IsNullOrEmpty(entry.OldValue) && !string.IsNullOrEmpty(entry.NewValue)
? $"{entry.OldValue} -> {entry.NewValue}" : ""));
@@ -564,6 +640,7 @@ public static string GetActionTypeDisplay(HistoryActionType action)
HistoryActionType.ApplicationStageChanged => "Stage Change",
HistoryActionType.InterestChanged => "Interest",
HistoryActionType.SuitabilityChanged => "Suitability",
+ HistoryActionType.ContactDiscussion => "Contact/Discussion",
_ => action.ToString()
};
}
@@ -605,6 +682,7 @@ public class JsaReportSummary
public class WindowsFontResolver : IFontResolver
{
private static readonly string FontDir = Environment.GetFolderPath(Environment.SpecialFolder.Fonts);
+ private static readonly ConcurrentDictionary FontCache = new();
private static readonly Dictionary FontFiles = new(StringComparer.OrdinalIgnoreCase)
{
@@ -618,22 +696,24 @@ public class WindowsFontResolver : IFontResolver
familyName = "Arial";
int index = (isBold ? 1 : 0) + (isItalic ? 2 : 0);
- var files = FontFiles[familyName];
string key = $"{familyName}#{index}";
return new FontResolverInfo(key);
}
public byte[]? GetFont(string faceName)
{
- var parts = faceName.Split('#');
- string family = parts[0];
- int index = parts.Length > 1 ? int.Parse(parts[1]) : 0;
+ return FontCache.GetOrAdd(faceName, static key =>
+ {
+ var parts = key.Split('#');
+ string family = parts[0];
+ int index = parts.Length > 1 ? int.Parse(parts[1]) : 0;
- if (!FontFiles.TryGetValue(family, out var files))
- return null;
+ if (!FontFiles.TryGetValue(family, out var files))
+ return null;
- string fileName = files[Math.Min(index, files.Length - 1)];
- string path = Path.Combine(FontDir, fileName);
- return File.Exists(path) ? File.ReadAllBytes(path) : null;
+ string fileName = files[Math.Min(index, files.Length - 1)];
+ string path = Path.Combine(FontDir, fileName);
+ return File.Exists(path) ? File.ReadAllBytes(path) : null;
+ });
}
}
diff --git a/wwwroot/js/crawlPagesRunner.js b/wwwroot/js/crawlPagesRunner.js
index 12b9891..bf01962 100644
--- a/wwwroot/js/crawlPagesRunner.js
+++ b/wwwroot/js/crawlPagesRunner.js
@@ -8,6 +8,7 @@ window.crawlPagesRunner = {
if (!this._win || this._win.closed) {
this._win = window.open(url, '_blank');
if (!this._win) return false;
+ try { this._win.opener = null; } catch { }
} else {
this._win.location = url;
try { this._win.focus(); } catch { }
@@ -30,8 +31,12 @@ window.crawlPagesRunner = {
window.jobTracker = {
shutdown: function () {
document.title = 'JobTracker - Shut Down';
- // Send shutdown request BEFORE destroying the DOM / Blazor circuit
- fetch('/api/shutdown', { method: 'POST' }).catch(function () { /* server may drop connection */ });
+ // Send shutdown request reliably — sendBeacon survives page unload; fetch+keepalive as fallback
+ if (navigator.sendBeacon) {
+ navigator.sendBeacon('/api/shutdown');
+ } else {
+ fetch('/api/shutdown', { method: 'POST', keepalive: true }).catch(function () { });
+ }
// Show goodbye message after request is dispatched
document.body.innerHTML = 'JobTracker has shut downYou can close this tab. ';
setTimeout(function () { window.close(); }, 500);
|