Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Components/Pages/BackgroundJobs.razor
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,7 @@ else
JobCategory.PipelineAutomation => "bi-arrow-repeat",
JobCategory.EmailIntegration => "bi-envelope",
JobCategory.Maintenance => "bi-wrench",
JobCategory.JsaActivity => "bi-clipboard-check",
_ => "bi-gear"
};

Expand All @@ -201,6 +202,7 @@ else
"JobCrawl" => "Searches LinkedIn for new job listings matching your saved search queries and adds them automatically.",
"NoReplyCheck" => "Marks applied jobs as 'No reply' if no status change has occurred within the configured no-reply days threshold (Settings > Pipeline).",
"ScheduledBackup" => "Creates a ZIP backup of all JSON data files in the Data/Backups/ folder. Old backups are pruned to keep the most recent 10.",
"JsaActivityMode" => "Runs for up to 4 hours per session, picking a random new/unchecked job every 1–3 minutes and marking it as unsuitable to simulate JSA job search activity.",
_ => ""
};

Expand Down
2 changes: 1 addition & 1 deletion Portable/JobTracker-x64.zip.001
Git LFS file not shown
4 changes: 2 additions & 2 deletions Portable/JobTracker-x64.zip.002
Git LFS file not shown
2 changes: 1 addition & 1 deletion Portable/JobTracker-x86.zip.001
Git LFS file not shown
4 changes: 2 additions & 2 deletions Portable/JobTracker-x86.zip.002
Git LFS file not shown
81 changes: 81 additions & 0 deletions Services/JsaActivityModeJob.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
using JobTracker.Models;

namespace JobTracker.Services;

public class JsaActivityModeJob
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly ILogger<JsaActivityModeJob> _logger;

/// <summary>Maximum duration the job will run before stopping.</summary>
private static readonly TimeSpan MaxRunDuration = TimeSpan.FromHours(4);

public JsaActivityModeJob(IServiceScopeFactory scopeFactory, ILogger<JsaActivityModeJob> logger)
{
_scopeFactory = scopeFactory;
_logger = logger;
}

public async Task RunAsync(CancellationToken ct = default)
{
_logger.LogInformation("[JsaActivityMode] Starting — will run for up to {Hours} hours", MaxRunDuration.TotalHours);

var deadline = DateTime.Now.Add(MaxRunDuration);
var random = new Random();
int totalMarked = 0;

while (DateTime.Now < deadline && !ct.IsCancellationRequested)
{
// Wait a random period between 1 and 3 minutes
var delayMinutes = 1 + (random.NextDouble() * 2);
var delay = TimeSpan.FromMinutes(delayMinutes);
_logger.LogDebug("[JsaActivityMode] Waiting {Delay:F1} minutes before next action", delay.TotalMinutes);

try
{
await Task.Delay(delay, ct);
}
catch (OperationCanceledException)
{
break;
}

if (ct.IsCancellationRequested || DateTime.Now >= deadline)
break;

using var scope = _scopeFactory.CreateScope();
var jobService = scope.ServiceProvider.GetRequiredService<JobListingService>();
var authService = scope.ServiceProvider.GetRequiredService<AuthService>();

var users = authService.GetAllUsers();

foreach (var user in users)
{
var allJobs = jobService.GetAllJobListings(user.Id);

// "Browse" tab = not applied, not possible, not unsuitable, not archived
var browseJobs = allJobs
.Where(j => !j.HasApplied
&& j.Suitability == SuitabilityStatus.NotChecked
&& !j.IsArchived)
.ToList();

if (browseJobs.Count == 0)
{
_logger.LogInformation("[JsaActivityMode] No browse jobs left for user {User}, skipping", user.Email);
continue;
}

var pick = browseJobs[random.Next(browseJobs.Count)];
jobService.SetSuitabilityStatus(pick.Id, SuitabilityStatus.Unsuitable, HistoryChangeSource.Manual,
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This job changes suitability automatically but records the history source as Manual, which can make the audit trail misleading (it will look like a user action). Consider using HistoryChangeSource.System (or a dedicated source) so downstream views/reports can distinguish automated activity from real user clicks.

Suggested change
jobService.SetSuitabilityStatus(pick.Id, SuitabilityStatus.Unsuitable, HistoryChangeSource.Manual,
jobService.SetSuitabilityStatus(pick.Id, SuitabilityStatus.Unsuitable, HistoryChangeSource.System,

Copilot uses AI. Check for mistakes.
forUserId: user.Id);
totalMarked++;

_logger.LogInformation("[JsaActivityMode] Marked \"{Title}\" at {Company} as unsuitable ({Remaining} browse jobs remaining)",
pick.Title, pick.Company, browseJobs.Count - 1);
}
}

_logger.LogInformation("[JsaActivityMode] Finished — marked {Total} jobs as unsuitable over the session", totalMarked);
}
}
25 changes: 24 additions & 1 deletion Services/LocalBackgroundService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ public enum JobCategory
JobDiscovery,
PipelineAutomation,
EmailIntegration,
Maintenance
Maintenance,
JsaActivity
}

public class BackgroundJobStatus
Expand Down Expand Up @@ -55,9 +56,13 @@ public class LocalBackgroundService : BackgroundService

// Maintenance
["ScheduledBackup"] = ("Scheduled Backup", JobCategory.Maintenance, TimeSpan.FromHours(24)),

// JSA Activity (only visible when data path contains "passp")
["JsaActivityMode"] = ("JSA Activity Mode", JobCategory.JsaActivity, TimeSpan.FromHours(12)),
};

private readonly Dictionary<string, BackgroundJobStatus> _jobStatuses = new();
private readonly bool _jsaActivityEnabled;
private DateTime? _startedAt;

public static string GetCategoryDisplayName(JobCategory category) => category switch
Expand All @@ -67,6 +72,7 @@ public class LocalBackgroundService : BackgroundService
JobCategory.PipelineAutomation => "Pipeline Automation",
JobCategory.EmailIntegration => "Email Integration",
JobCategory.Maintenance => "Maintenance",
JobCategory.JsaActivity => "JSA Activity",
_ => category.ToString()
};

Expand All @@ -77,6 +83,7 @@ public class LocalBackgroundService : BackgroundService
JobCategory.PipelineAutomation => "Automatically update job statuses based on rules",
JobCategory.EmailIntegration => "Process incoming emails and send notifications",
JobCategory.Maintenance => "System maintenance and data backup tasks",
JobCategory.JsaActivity => "Automatically review new jobs to simulate JSA job search activity",
_ => ""
};

Expand All @@ -86,10 +93,16 @@ public LocalBackgroundService(IServiceScopeFactory scopeFactory, ILogger<LocalBa
_logger = logger;
_configPath = Path.Combine(env.ContentRootPath, "Data", "background-jobs.json");

// JSA Activity Mode is only available when the data directory contains "passp"
_jsaActivityEnabled = _configPath.Contains("passp", StringComparison.OrdinalIgnoreCase);

Comment on lines 94 to +98
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Feature-gating via _configPath.Contains("passp") is brittle and can enable/disable the job based on an incidental substring in the install path. If this is meant to be a controllable feature flag, prefer an explicit config setting (appsettings/env var) or a dedicated marker file in the Data directory; also the comment says “data directory” but the check is on the full config file path.

Suggested change
_configPath = Path.Combine(env.ContentRootPath, "Data", "background-jobs.json");
// JSA Activity Mode is only available when the data directory contains "passp"
_jsaActivityEnabled = _configPath.Contains("passp", StringComparison.OrdinalIgnoreCase);
var dataDirectory = Path.Combine(env.ContentRootPath, "Data");
_configPath = Path.Combine(dataDirectory, "background-jobs.json");
// JSA Activity Mode is only available when the Data directory contains the explicit marker file.
_jsaActivityEnabled = File.Exists(Path.Combine(dataDirectory, "jsa-activity.enabled"));

Copilot uses AI. Check for mistakes.
// Initialize defaults (disabled until user enables them)
var hasConfig = File.Exists(_configPath);
foreach (var (key, (name, category, interval)) in JobDefaults)
{
if (key == "JsaActivityMode" && !_jsaActivityEnabled)
continue;

_jobStatuses[key] = new BackgroundJobStatus
{
Name = name,
Expand Down Expand Up @@ -187,6 +200,9 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
RunLoop("EmailCheck", RunEmailCheck, stoppingToken),
};

if (_jsaActivityEnabled)
tasks = [.. tasks, RunLoop("JsaActivityMode", RunJsaActivityMode, stoppingToken)];

Comment on lines +203 to +205
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

JsaActivityModeJob supports cancellation via CancellationToken, but RunLoop/RunJsaActivityMode don’t pass the stoppingToken through, meaning the host may be unable to shut down promptly (this job can run up to 4 hours). Consider updating RunLoop to accept a token-aware delegate (e.g., Func<CancellationToken, Task>) and call job.RunAsync(ct) so shutdown cancels the session.

Copilot uses AI. Check for mistakes.
await Task.WhenAll(tasks);
}

Expand Down Expand Up @@ -404,4 +420,11 @@ private async Task RunEmailCheck()
var job = scope.ServiceProvider.GetRequiredService<EmailCheckJob>();
await job.RunAsync();
}

private async Task RunJsaActivityMode()
{
using var scope = _scopeFactory.CreateScope();
var job = scope.ServiceProvider.GetRequiredService<JsaActivityModeJob>();
await job.RunAsync();
Comment on lines +424 to +428
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

RunJsaActivityMode resolves JsaActivityModeJob from DI, but the job is not registered in Program.cs (no AddTransient/AddScoped entry). This will throw at runtime the first time the loop runs. Register JsaActivityModeJob in the service container the same way other background jobs are registered.

Copilot uses AI. Check for mistakes.
}
}
Loading