Compare commits
35 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fc356012e6 | |||
| e90835b51e | |||
| 4b38f7c164 | |||
| 63e0300788 | |||
| 1badff1437 | |||
| aa19edbc49 | |||
| 96b9489d49 | |||
| af420a7ad1 | |||
| 3d5ab8f32c | |||
| c53d7978bb | |||
| 919f61dde6 | |||
| 0ca8c95372 | |||
| b8f8569e6e | |||
| 490c5b803e | |||
| 39266c0935 | |||
| eed9b1fa80 | |||
| e5e2c65709 | |||
| 2989a6fa2c | |||
| 824251d328 | |||
| b8ec268736 | |||
| 6cb593ab5c | |||
| 31373be841 | |||
| e1e508988a | |||
| 316ef9ac1a | |||
| d61dd6310b | |||
| 3bd7b4b7e4 | |||
| 30bb6a942d | |||
| fb11469a48 | |||
| 5a9245cf74 | |||
| 2996441f52 | |||
| bd51c245d3 | |||
| a1a3736cc4 | |||
| ae3505b877 | |||
| 695fbd6d21 | |||
| 45cbc8b1ab |
@@ -13,6 +13,16 @@ AI_SERVICE_BASE_URL=http://ai-service:8001
|
||||
OLLAMA_BASE_URL=http://ollama:11434
|
||||
OLLAMA_MODEL=qwen2.5:7b
|
||||
|
||||
# AI provider for the heavy /cv/* calls: ollama (default, local) | gemini | groq.
|
||||
# /summarize always stays local (distilbart). To offload a weak production GPU,
|
||||
# set AI_PROVIDER=gemini (or groq) and provide the matching key below.
|
||||
# Keys are read from the environment only — never commit real keys.
|
||||
AI_PROVIDER=ollama
|
||||
GEMINI_API_KEY=
|
||||
GEMINI_MODEL=gemini-2.0-flash
|
||||
GROQ_API_KEY=
|
||||
GROQ_MODEL=llama-3.3-70b-versatile
|
||||
|
||||
# Optional: only needed if you want the UI to call a non-default API base URL.
|
||||
# In production the UI defaults to `/api`.
|
||||
REACT_APP_API_BASE_URL=
|
||||
|
||||
@@ -13,10 +13,22 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: '9.0.x'
|
||||
- name: Setup .NET (resilient)
|
||||
shell: bash
|
||||
# actions/setup-dotnet on this single self-hosted runner intermittently
|
||||
# leaves a partial extraction in the shared tool-cache ("tar: Cannot open:
|
||||
# File exists") or corrupts the SDK download. Install into a clean private
|
||||
# dir via dotnet-install.sh and retry once on failure, mirroring the
|
||||
# npm ci / NuGet retries elsewhere in this workflow.
|
||||
run: |
|
||||
install() {
|
||||
curl -fsSL https://dot.net/v1/dotnet-install.sh -o /tmp/dotnet-install.sh
|
||||
rm -rf "$HOME/.dotnet"
|
||||
bash /tmp/dotnet-install.sh --channel 9.0 --install-dir "$HOME/.dotnet"
|
||||
}
|
||||
install || ( echo "dotnet install failed ($?) — retrying once..." && install )
|
||||
echo "$HOME/.dotnet" >> "$GITHUB_PATH"
|
||||
"$HOME/.dotnet/dotnet" --info
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
@@ -39,7 +51,12 @@ jobs:
|
||||
run: |
|
||||
node -v
|
||||
npm -v
|
||||
npm ci --no-audit --no-fund
|
||||
# npm ci occasionally segfaults on the runner (SIGSEGV/139, a memory/native
|
||||
# flake). Retry once with a clean node_modules before failing the job.
|
||||
npm ci --no-audit --no-fund \
|
||||
|| ( echo "npm ci failed ($?) — cleaning node_modules and retrying once..." \
|
||||
&& rm -rf node_modules \
|
||||
&& npm ci --no-audit --no-fund )
|
||||
|
||||
- name: Test frontend
|
||||
working-directory: job-tracker-ui
|
||||
|
||||
@@ -55,6 +55,20 @@ namespace JobTrackerApi.Data
|
||||
modelBuilder.Entity<JobApplication>()
|
||||
.HasIndex(j => j.OwnerUserId);
|
||||
|
||||
// Owner-prefixed composite indexes for the tenant-scoped hot paths. Every
|
||||
// JobApplication query is scoped by the OwnerUserId global filter first, then
|
||||
// filtered by IsDeleted (list/board/stats/analytics) or FollowUpAt (reminders).
|
||||
// Status is intentionally excluded from the index because Pomelo maps the
|
||||
// unbounded string column to longtext, which MariaDB cannot index without a
|
||||
// prefix length. The actual index DDL is applied idempotently in
|
||||
// StartupInitializationExtensions (this repo provisions schema via that
|
||||
// reconciler, not via the EF ModelSnapshot, which is stale).
|
||||
modelBuilder.Entity<JobApplication>()
|
||||
.HasIndex(j => new { j.OwnerUserId, j.IsDeleted });
|
||||
|
||||
modelBuilder.Entity<JobApplication>()
|
||||
.HasIndex(j => new { j.OwnerUserId, j.FollowUpAt });
|
||||
|
||||
modelBuilder.Entity<Company>()
|
||||
.HasIndex(c => c.OwnerUserId);
|
||||
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
using JobTrackerApi.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace JobTrackerApi.Tests;
|
||||
|
||||
public sealed class EmailStatusClassifierTests
|
||||
{
|
||||
[Fact]
|
||||
public void Detects_rejection()
|
||||
{
|
||||
var s = EmailStatusClassifier.Classify("Your application", "Thank you for your time. Unfortunately, we have decided not to proceed with your application.");
|
||||
Assert.NotNull(s);
|
||||
Assert.Equal("Rejected", s!.SuggestedStatus);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Detects_offer()
|
||||
{
|
||||
var s = EmailStatusClassifier.Classify("Great news", "We are pleased to offer you the position of Backend Engineer.");
|
||||
Assert.NotNull(s);
|
||||
Assert.Equal("Offer", s!.SuggestedStatus);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Detects_interview_invite()
|
||||
{
|
||||
var s = EmailStatusClassifier.Classify("Next steps", "We would like to invite you to interview next week. What is your availability for a call?");
|
||||
Assert.NotNull(s);
|
||||
Assert.Equal("Interview", s!.SuggestedStatus);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Rejection_wins_over_interview_mention()
|
||||
{
|
||||
// A rejection email that references the interview the candidate had must classify as Rejected.
|
||||
var s = EmailStatusClassifier.Classify(
|
||||
"Update on your application",
|
||||
"Thank you for taking the time to interview with us. Unfortunately, we will not be moving forward.");
|
||||
Assert.NotNull(s);
|
||||
Assert.Equal("Rejected", s!.SuggestedStatus);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Weak_interview_cue_is_low_confidence()
|
||||
{
|
||||
var s = EmailStatusClassifier.Classify("Coding challenge", "Please complete this take-home assessment.");
|
||||
Assert.NotNull(s);
|
||||
Assert.Equal("Interview", s!.SuggestedStatus);
|
||||
Assert.Equal("low", s.Confidence);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Returns_null_for_neutral_email()
|
||||
{
|
||||
Assert.Null(EmailStatusClassifier.Classify("Re: question", "Thanks for the info, that answers my question about the parking."));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Handles_empty_input()
|
||||
=> Assert.Null(EmailStatusClassifier.Classify(null, null));
|
||||
}
|
||||
@@ -38,6 +38,42 @@ public sealed class JobApplicationsAuthorizationTests
|
||||
Assert.IsType<NotFoundResult>(result.Result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetMatchScore_returns_not_found_for_other_users_job()
|
||||
{
|
||||
var dbName = Guid.NewGuid().ToString();
|
||||
await using var ownerDb = CreateDb(dbName, "owner-1");
|
||||
var company = new Company { Name = "Acme", OwnerUserId = "owner-1" };
|
||||
ownerDb.Companies.Add(company);
|
||||
await ownerDb.SaveChangesAsync();
|
||||
ownerDb.JobApplications.Add(new JobApplication { JobTitle = "Secret", CompanyId = company.Id, OwnerUserId = "owner-1", Description = "C# .NET" });
|
||||
await ownerDb.SaveChangesAsync();
|
||||
var jobId = await ownerDb.JobApplications.Select(j => j.Id).FirstAsync();
|
||||
|
||||
await using var attackerDb = CreateDb(dbName, "other-user");
|
||||
var result = await CreateController(attackerDb).GetMatchScore(jobId, CancellationToken.None);
|
||||
|
||||
Assert.IsType<NotFoundResult>(result.Result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetStatusSuggestion_returns_not_found_for_other_users_job()
|
||||
{
|
||||
var dbName = Guid.NewGuid().ToString();
|
||||
await using var ownerDb = CreateDb(dbName, "owner-1");
|
||||
var company = new Company { Name = "Acme", OwnerUserId = "owner-1" };
|
||||
ownerDb.Companies.Add(company);
|
||||
await ownerDb.SaveChangesAsync();
|
||||
ownerDb.JobApplications.Add(new JobApplication { JobTitle = "Secret", CompanyId = company.Id, OwnerUserId = "owner-1" });
|
||||
await ownerDb.SaveChangesAsync();
|
||||
var jobId = await ownerDb.JobApplications.Select(j => j.Id).FirstAsync();
|
||||
|
||||
await using var attackerDb = CreateDb(dbName, "other-user");
|
||||
var result = await CreateController(attackerDb).GetStatusSuggestion(jobId, CancellationToken.None);
|
||||
|
||||
Assert.IsType<NotFoundResult>(result.Result);
|
||||
}
|
||||
|
||||
private static JobTrackerContext CreateDb(string dbName, string? userId)
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<JobTrackerContext>()
|
||||
|
||||
@@ -56,6 +56,68 @@ public sealed class JobApplicationsEndpointBehaviorTests
|
||||
Assert.Contains("Profile page", badRequest.Value?.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Status_suggestion_from_latest_inbound_rejection()
|
||||
{
|
||||
await using var db = CreateDb();
|
||||
var company = new Company { Name = "Acme", OwnerUserId = "user-1" };
|
||||
db.Companies.Add(company);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var job = new JobApplication { JobTitle = "Dev", CompanyId = company.Id, OwnerUserId = "user-1", Status = "Applied" };
|
||||
db.JobApplications.Add(job);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
db.Correspondences.Add(new Correspondence
|
||||
{
|
||||
JobApplicationId = job.Id,
|
||||
From = "Company",
|
||||
Direction = "inbound",
|
||||
Subject = "Update",
|
||||
Content = "Unfortunately, we have decided not to proceed.",
|
||||
Date = DateTime.Now,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var controller = CreateController(db, "user-1");
|
||||
var result = await controller.GetStatusSuggestion(job.Id, CancellationToken.None);
|
||||
|
||||
var ok = Assert.IsType<OkObjectResult>(result.Result);
|
||||
var dto = Assert.IsType<JobApplicationsController.StatusSuggestionDto>(ok.Value);
|
||||
Assert.True(dto.HasSuggestion);
|
||||
Assert.Equal("Rejected", dto.SuggestedStatus);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Status_suggestion_suppressed_when_already_in_stage()
|
||||
{
|
||||
await using var db = CreateDb();
|
||||
var company = new Company { Name = "Acme", OwnerUserId = "user-1" };
|
||||
db.Companies.Add(company);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var job = new JobApplication { JobTitle = "Dev", CompanyId = company.Id, OwnerUserId = "user-1", Status = "Rejected" };
|
||||
db.JobApplications.Add(job);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
db.Correspondences.Add(new Correspondence
|
||||
{
|
||||
JobApplicationId = job.Id,
|
||||
From = "Company",
|
||||
Direction = "inbound",
|
||||
Content = "Unfortunately, we will not be moving forward.",
|
||||
Date = DateTime.Now,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var controller = CreateController(db, "user-1");
|
||||
var result = await controller.GetStatusSuggestion(job.Id, CancellationToken.None);
|
||||
|
||||
var ok = Assert.IsType<OkObjectResult>(result.Result);
|
||||
var dto = Assert.IsType<JobApplicationsController.StatusSuggestionDto>(ok.Value);
|
||||
Assert.False(dto.HasSuggestion);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Match_score_scores_job_against_profile_cv()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
using JobTrackerApi.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace JobTrackerApi.Tests;
|
||||
|
||||
public sealed class StageAnalyticsTests
|
||||
{
|
||||
private static readonly DateTime Now = new(2026, 7, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
[Fact]
|
||||
public void Computes_median_days_per_active_stage()
|
||||
{
|
||||
var jobs = new[]
|
||||
{
|
||||
new StageOccupancy("Applied", Now.AddDays(-10)),
|
||||
new StageOccupancy("Applied", Now.AddDays(-20)),
|
||||
new StageOccupancy("Applied", Now.AddDays(-30)),
|
||||
new StageOccupancy("Interview", Now.AddDays(-4)),
|
||||
};
|
||||
|
||||
var result = StageAnalytics.TimeInStage(jobs, Now);
|
||||
|
||||
var applied = Assert.Single(result, p => p.Stage == "Applied");
|
||||
Assert.Equal(20, applied.MedianDays);
|
||||
Assert.Equal(3, applied.Count);
|
||||
|
||||
var interview = Assert.Single(result, p => p.Stage == "Interview");
|
||||
Assert.Equal(4, interview.MedianDays);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Excludes_closed_and_success_stages()
|
||||
{
|
||||
var jobs = new[]
|
||||
{
|
||||
new StageOccupancy("Offer", Now.AddDays(-5)),
|
||||
new StageOccupancy("Rejected", Now.AddDays(-5)),
|
||||
new StageOccupancy("Ghosted", Now.AddDays(-5)),
|
||||
};
|
||||
|
||||
Assert.Empty(StageAnalytics.TimeInStage(jobs, Now));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Normalizes_legacy_status_and_orders_by_pipeline()
|
||||
{
|
||||
var jobs = new[]
|
||||
{
|
||||
new StageOccupancy("Interviewing", Now.AddDays(-3)),
|
||||
new StageOccupancy("Applied", Now.AddDays(-1)),
|
||||
new StageOccupancy("Waiting", Now.AddDays(-2)),
|
||||
};
|
||||
|
||||
var result = StageAnalytics.TimeInStage(jobs, Now);
|
||||
|
||||
Assert.Equal(new[] { "Applied", "Waiting", "Interview" }, result.Select(p => p.Stage).ToArray());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Empty_input_returns_empty()
|
||||
=> Assert.Empty(StageAnalytics.TimeInStage(Array.Empty<StageOccupancy>(), Now));
|
||||
}
|
||||
@@ -641,12 +641,17 @@ public sealed class GmailController : ControllerBase
|
||||
|
||||
var threadMessages = await _gmail.ListThreadMessagesAsync(ownerUserId, request.ThreadId.Trim(), cancellationToken);
|
||||
var distinctMessageIds = threadMessages.Select(message => message.Id).Where(static id => !string.IsNullOrWhiteSpace(id)).Distinct(StringComparer.Ordinal).ToList();
|
||||
// Batch the "already imported?" check with a single query instead of one
|
||||
// AnyAsync per message (N+1), mirroring RelinkThread below.
|
||||
var existingMessageIds = await _db.Correspondences
|
||||
.Where(message => message.JobApplicationId == job.Id && message.ExternalMessageId != null && distinctMessageIds.Contains(message.ExternalMessageId))
|
||||
.Select(message => message.ExternalMessageId!)
|
||||
.ToListAsync(cancellationToken);
|
||||
var imported = 0;
|
||||
var skipped = 0;
|
||||
foreach (var messageId in distinctMessageIds)
|
||||
{
|
||||
var existing = await _db.Correspondences.AnyAsync(message => message.JobApplicationId == job.Id && message.ExternalMessageId == messageId, cancellationToken);
|
||||
if (existing)
|
||||
if (existingMessageIds.Contains(messageId, StringComparer.Ordinal))
|
||||
{
|
||||
skipped++;
|
||||
continue;
|
||||
|
||||
@@ -23,9 +23,10 @@ namespace JobTrackerApi.Controllers
|
||||
private readonly ILogger<JobApplicationsController> _logger;
|
||||
private readonly ICvTemplateRenderer _cvTemplateRenderer;
|
||||
private readonly ICvPdfExporter _cvPdfExporter;
|
||||
private readonly AnalyticsService _analytics;
|
||||
private readonly IJobCvMatchService _matchService;
|
||||
|
||||
public JobApplicationsController(JobTrackerContext db, ISummarizerService summarizer, IAppEmailSender email, UserManager<ApplicationUser> users, ILogger<JobApplicationsController> logger, ICvTemplateRenderer? cvTemplateRenderer = null, ICvPdfExporter? cvPdfExporter = null, IJobCvMatchService? matchService = null)
|
||||
public JobApplicationsController(JobTrackerContext db, ISummarizerService summarizer, IAppEmailSender email, UserManager<ApplicationUser> users, ILogger<JobApplicationsController> logger, ICvTemplateRenderer? cvTemplateRenderer = null, ICvPdfExporter? cvPdfExporter = null, AnalyticsService? analytics = null, IJobCvMatchService? matchService = null)
|
||||
{
|
||||
_db = db;
|
||||
_summarizer = summarizer;
|
||||
@@ -34,6 +35,7 @@ namespace JobTrackerApi.Controllers
|
||||
_logger = logger;
|
||||
_cvTemplateRenderer = cvTemplateRenderer ?? new CvTemplateRenderer();
|
||||
_cvPdfExporter = cvPdfExporter ?? new ThrowingCvPdfExporter();
|
||||
_analytics = analytics ?? new AnalyticsService(db);
|
||||
_matchService = matchService ?? new JobCvMatchService();
|
||||
}
|
||||
|
||||
@@ -1405,9 +1407,7 @@ Canonical profile:
|
||||
if (title.Length == 0) return BadRequest("Job title is required.");
|
||||
if (request.CompanyId <= 0) return BadRequest("Valid companyId is required.");
|
||||
|
||||
var companyOk = await _db.Companies.AnyAsync(c => c.Id == request.CompanyId, cancellationToken);
|
||||
if (!companyOk) return BadRequest("companyId does not exist.");
|
||||
|
||||
// Scoped by the Company query filter, so this also rejects another user's companyId.
|
||||
var companyExists = await _db.Companies.AnyAsync(c => c.Id == request.CompanyId, cancellationToken);
|
||||
if (!companyExists) return BadRequest("companyId does not exist.");
|
||||
|
||||
@@ -1604,6 +1604,57 @@ Canonical profile:
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
public sealed record StatusSuggestionDto(
|
||||
bool HasSuggestion,
|
||||
string? SuggestedStatus,
|
||||
string? CurrentStatus,
|
||||
string? Signal,
|
||||
string? Confidence,
|
||||
DateTime? MessageDate,
|
||||
string? MessageSubject);
|
||||
|
||||
/// <summary>
|
||||
/// Suggests a pipeline status from the job's most recent inbound message (e.g. an interview
|
||||
/// invite or rejection). Deterministic and always human-confirmed via PATCH .../status.
|
||||
/// </summary>
|
||||
[HttpGet("{id:int}/status-suggestion")]
|
||||
public async Task<ActionResult<StatusSuggestionDto>> GetStatusSuggestion([FromRoute] int id, CancellationToken cancellationToken)
|
||||
{
|
||||
var job = await _db.JobApplications.FirstOrDefaultAsync(j => j.Id == id, cancellationToken);
|
||||
if (job is null) return NotFound();
|
||||
|
||||
var none = new StatusSuggestionDto(false, null, job.Status, null, null, null, null);
|
||||
|
||||
var latestInbound = await _db.Correspondences
|
||||
.AsNoTracking()
|
||||
.Where(c => c.JobApplicationId == id
|
||||
&& c.Direction != "outbound"
|
||||
&& c.From != "Me")
|
||||
.OrderByDescending(c => c.Date)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
if (latestInbound is null) return Ok(none);
|
||||
|
||||
var suggestion = EmailStatusClassifier.Classify(latestInbound.Subject, latestInbound.Content);
|
||||
if (suggestion is null) return Ok(none);
|
||||
|
||||
// Don't nag when the job is already in (or past) the suggested stage.
|
||||
var currentOrder = JobPipeline.OrderOf(job.Status);
|
||||
var suggestedOrder = JobPipeline.OrderOf(suggestion.SuggestedStatus);
|
||||
if (JobPipeline.Normalize(job.Status) == suggestion.SuggestedStatus || currentOrder >= suggestedOrder)
|
||||
{
|
||||
return Ok(none);
|
||||
}
|
||||
|
||||
return Ok(new StatusSuggestionDto(
|
||||
HasSuggestion: true,
|
||||
SuggestedStatus: suggestion.SuggestedStatus,
|
||||
CurrentStatus: job.Status,
|
||||
Signal: suggestion.Signal,
|
||||
Confidence: suggestion.Confidence,
|
||||
MessageDate: latestInbound.Date,
|
||||
MessageSubject: latestInbound.Subject));
|
||||
}
|
||||
|
||||
|
||||
[HttpPost("{id:int}/refresh-ai")]
|
||||
public async Task<ActionResult<JobApplicationDto>> RefreshAi([FromRoute] int id, CancellationToken cancellationToken)
|
||||
@@ -1782,46 +1833,9 @@ Canonical profile:
|
||||
return Ok(all);
|
||||
}
|
||||
|
||||
public sealed record JobStats(
|
||||
int Total,
|
||||
int Active,
|
||||
int Deleted,
|
||||
Dictionary<string, int> ByStatus,
|
||||
int AppliedLast30Days,
|
||||
double AverageDaysSinceApplied
|
||||
);
|
||||
|
||||
[HttpGet("stats")]
|
||||
public async Task<ActionResult<JobStats>> GetStats(CancellationToken cancellationToken)
|
||||
{
|
||||
var now = DateTime.Now;
|
||||
|
||||
var all = await _db.JobApplications
|
||||
.AsNoTracking()
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var active = all.Where(j => !j.IsDeleted).ToList();
|
||||
|
||||
var byStatus = active
|
||||
.GroupBy(j => string.IsNullOrWhiteSpace(j.Status) ? "Unknown" : j.Status)
|
||||
.OrderByDescending(g => g.Count())
|
||||
.ToDictionary(g => g.Key, g => g.Count());
|
||||
|
||||
var appliedLast30Days = active.Count(j => (now - j.DateApplied).TotalDays <= 30);
|
||||
|
||||
var avgDays = active.Count == 0
|
||||
? 0
|
||||
: active.Average(j => Math.Max(0, (now - j.DateApplied).TotalDays));
|
||||
|
||||
return Ok(new JobStats(
|
||||
Total: all.Count,
|
||||
Active: active.Count,
|
||||
Deleted: all.Count - active.Count,
|
||||
ByStatus: byStatus,
|
||||
AppliedLast30Days: appliedLast30Days,
|
||||
AverageDaysSinceApplied: Math.Round(avgDays, 1)
|
||||
));
|
||||
}
|
||||
=> Ok(await _analytics.GetStatsAsync(cancellationToken));
|
||||
public sealed record AnalyticsPoint(string Month, int Applied, int Responses);
|
||||
|
||||
[HttpGet("analytics")]
|
||||
@@ -2018,19 +2032,8 @@ Canonical profile:
|
||||
return Ok(outList);
|
||||
}
|
||||
|
||||
public sealed record FunnelStagePoint(string Label, int Count);
|
||||
public sealed record ResponseRatePoint(string Label, int Total, int Responses, double Rate);
|
||||
public sealed record CompanyActivityPoint(int CompanyId, string Company, int Count, int Responses, double ResponseRate);
|
||||
public sealed record TagTrendSeries(string Tag, List<int> Counts);
|
||||
public sealed record TagTrendPoint(string Month, List<int> Counts);
|
||||
public sealed record AnalyticsOverviewDto(
|
||||
List<FunnelStagePoint> Funnel,
|
||||
List<ResponseRatePoint> ResponseRateBySource,
|
||||
List<CompanyActivityPoint> TopCompanies,
|
||||
double? MedianDaysToFirstResponse,
|
||||
int TotalResponses,
|
||||
int TotalActive
|
||||
);
|
||||
public sealed record DuplicateCandidateDto(int Id, string JobTitle, string Company, string? JobUrl, string Status, DateTime DateApplied, string Reason);
|
||||
public sealed record DuplicateCheckResult(bool HasDuplicates, List<DuplicateCandidateDto> Matches);
|
||||
public sealed record FollowUpDraftDto(string Subject, string Body, string Reason, DateTime SuggestedSendOn, string ContextSummary, List<string> ContextSignals, string? ThreadSubject, string? LastCorrespondenceFrom, DateTime? LastCorrespondenceAt);
|
||||
@@ -2796,75 +2799,7 @@ Candidate master CV:
|
||||
|
||||
[HttpGet("analytics-overview")]
|
||||
public async Task<ActionResult<AnalyticsOverviewDto>> GetAnalyticsOverview(CancellationToken cancellationToken)
|
||||
{
|
||||
var activeJobs = await _db.JobApplications
|
||||
.AsNoTracking()
|
||||
.Include(j => j.Company)
|
||||
.Where(j => !j.IsDeleted)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var funnelMap = new Dictionary<string, int>
|
||||
{
|
||||
["Applied"] = activeJobs.Count(j => string.Equals(j.Status, "Applied", StringComparison.OrdinalIgnoreCase)),
|
||||
["Interview"] = activeJobs.Count(j => string.Equals(j.Status, "Interview", StringComparison.OrdinalIgnoreCase) || string.Equals(j.Status, "Interviewing", StringComparison.OrdinalIgnoreCase)),
|
||||
["Offer"] = activeJobs.Count(j => string.Equals(j.Status, "Offer", StringComparison.OrdinalIgnoreCase)),
|
||||
["Rejected"] = activeJobs.Count(j => string.Equals(j.Status, "Rejected", StringComparison.OrdinalIgnoreCase)),
|
||||
["Ghosted"] = activeJobs.Count(j => string.Equals(j.Status, "Ghosted", StringComparison.OrdinalIgnoreCase)),
|
||||
};
|
||||
|
||||
var funnel = funnelMap.Select(x => new FunnelStagePoint(x.Key, x.Value)).ToList();
|
||||
|
||||
var responseRateBySource = activeJobs
|
||||
.GroupBy(j => string.IsNullOrWhiteSpace(j.Company?.Source) ? "Unknown source" : j.Company!.Source!.Trim())
|
||||
.Select(g => new ResponseRatePoint(
|
||||
g.Key,
|
||||
g.Count(),
|
||||
g.Count(x => x.ResponseReceived || x.ResponseDate is not null),
|
||||
Math.Round(g.Count(x => x.ResponseReceived || x.ResponseDate is not null) * 100d / Math.Max(1, g.Count()), 1)
|
||||
))
|
||||
.OrderByDescending(x => x.Total)
|
||||
.ThenByDescending(x => x.Rate)
|
||||
.Take(6)
|
||||
.ToList();
|
||||
|
||||
var topCompanies = activeJobs
|
||||
.GroupBy(j => new { j.CompanyId, Name = j.Company.Name })
|
||||
.Select(g => new CompanyActivityPoint(
|
||||
g.Key.CompanyId,
|
||||
g.Key.Name,
|
||||
g.Count(),
|
||||
g.Count(x => x.ResponseReceived || x.ResponseDate is not null),
|
||||
Math.Round(g.Count(x => x.ResponseReceived || x.ResponseDate is not null) * 100d / Math.Max(1, g.Count()), 1)
|
||||
))
|
||||
.OrderByDescending(x => x.Count)
|
||||
.ThenByDescending(x => x.ResponseRate)
|
||||
.Take(8)
|
||||
.ToList();
|
||||
|
||||
var responseDays = activeJobs
|
||||
.Where(j => (j.ResponseReceived || j.ResponseDate is not null) && j.ResponseDate is not null)
|
||||
.Select(j => Math.Max(0, (j.ResponseDate!.Value - j.DateApplied).TotalDays))
|
||||
.OrderBy(x => x)
|
||||
.ToList();
|
||||
|
||||
double? medianDays = null;
|
||||
if (responseDays.Count > 0)
|
||||
{
|
||||
var mid = responseDays.Count / 2;
|
||||
medianDays = responseDays.Count % 2 == 0
|
||||
? Math.Round((responseDays[mid - 1] + responseDays[mid]) / 2d, 1)
|
||||
: Math.Round(responseDays[mid], 1);
|
||||
}
|
||||
|
||||
return Ok(new AnalyticsOverviewDto(
|
||||
Funnel: funnel,
|
||||
ResponseRateBySource: responseRateBySource,
|
||||
TopCompanies: topCompanies,
|
||||
MedianDaysToFirstResponse: medianDays,
|
||||
TotalResponses: activeJobs.Count(j => j.ResponseReceived || j.ResponseDate is not null),
|
||||
TotalActive: activeJobs.Count
|
||||
));
|
||||
}
|
||||
=> Ok(await _analytics.GetAnalyticsOverviewAsync(cancellationToken));
|
||||
|
||||
[HttpGet("tag-trends")]
|
||||
public async Task<ActionResult<TagTrendResponse>> GetTagTrends(
|
||||
|
||||
@@ -9,7 +9,14 @@ COPY Models/ Models/
|
||||
COPY JobTrackerApi/ JobTrackerApi/
|
||||
COPY JobTrackerBackend/ JobTrackerBackend/
|
||||
|
||||
RUN dotnet publish JobTrackerApi/JobTrackerApi.csproj -c Release -o /app/publish /p:UseAppHost=false
|
||||
# Retry once after clearing NuGet caches. Transient download corruption on the
|
||||
# build host can trip NU3008 ("package integrity check failed / has changed since
|
||||
# it was signed") while restoring a transitive package; clearing the caches and
|
||||
# re-downloading resolves it.
|
||||
RUN dotnet publish JobTrackerApi/JobTrackerApi.csproj -c Release -o /app/publish /p:UseAppHost=false \
|
||||
|| ( echo "Publish failed — clearing NuGet caches and retrying once..." \
|
||||
&& dotnet nuget locals all --clear \
|
||||
&& dotnet publish JobTrackerApi/JobTrackerApi.csproj -c Release -o /app/publish /p:UseAppHost=false )
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS runtime
|
||||
|
||||
|
||||
@@ -156,6 +156,7 @@ builder.Services.AddHttpClient("ai-service", client =>
|
||||
});
|
||||
|
||||
builder.Services.AddMemoryCache();
|
||||
builder.Services.AddScoped<AnalyticsService>();
|
||||
builder.Services.AddSingleton<ISummarizerService, SummarizerService>();
|
||||
builder.Services.AddSingleton<IJobCvMatchService, JobCvMatchService>();
|
||||
builder.Services.AddSingleton<ICvAiClassifier, CvAiClassifier>();
|
||||
@@ -165,6 +166,10 @@ builder.Services.AddScoped<IGmailOAuthService, GmailOAuthService>();
|
||||
builder.Services.AddSingleton<IGmailJobMatchingService, GmailJobMatchingService>();
|
||||
builder.Services.AddSingleton<IGmailCorrespondenceEnrichmentService, NoOpGmailCorrespondenceEnrichmentService>();
|
||||
|
||||
// Provider-neutral email seam (multi-provider: Gmail today; Microsoft Graph / IMAP / manual next).
|
||||
builder.Services.AddScoped<JobTrackerApi.Services.EmailProviders.IEmailProvider, JobTrackerApi.Services.EmailProviders.GmailProvider>();
|
||||
builder.Services.AddScoped<JobTrackerApi.Services.EmailProviders.IEmailProviderRegistry, JobTrackerApi.Services.EmailProviders.EmailProviderRegistry>();
|
||||
|
||||
builder.Services.AddIdentityCore<ApplicationUser>(options =>
|
||||
{
|
||||
options.User.RequireUniqueEmail = true;
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using JobTrackerApi.Data;
|
||||
using JobTrackerApi.Models;
|
||||
|
||||
namespace JobTrackerApi.Services
|
||||
{
|
||||
/// <summary>
|
||||
/// Read-only analytics/statistics aggregation extracted from JobApplicationsController.
|
||||
/// Uses the tenant-scoped <see cref="JobTrackerContext"/>, so the global OwnerUserId
|
||||
/// query filters apply automatically. Behaviour is identical to the former inline
|
||||
/// controller methods (GetStats / GetAnalyticsOverview).
|
||||
/// </summary>
|
||||
public sealed class AnalyticsService
|
||||
{
|
||||
private readonly JobTrackerContext _db;
|
||||
|
||||
public AnalyticsService(JobTrackerContext db)
|
||||
{
|
||||
_db = db;
|
||||
}
|
||||
|
||||
public async Task<JobStats> GetStatsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var now = DateTime.Now;
|
||||
|
||||
// Project to only the columns the stats need instead of materialising full
|
||||
// JobApplication rows (which drag large Description/TranslatedDescription/
|
||||
// TailoredCvText/Notes blobs). Aggregation stays in memory over a small
|
||||
// per-tenant set.
|
||||
var all = await _db.JobApplications
|
||||
.AsNoTracking()
|
||||
.Select(j => new { j.IsDeleted, j.Status, j.DateApplied })
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var active = all.Where(j => !j.IsDeleted).ToList();
|
||||
|
||||
var byStatus = active
|
||||
.GroupBy(j => string.IsNullOrWhiteSpace(j.Status) ? "Unknown" : j.Status)
|
||||
.OrderByDescending(g => g.Count())
|
||||
.ToDictionary(g => g.Key, g => g.Count());
|
||||
|
||||
var appliedLast30Days = active.Count(j => (now - j.DateApplied).TotalDays <= 30);
|
||||
|
||||
var avgDays = active.Count == 0
|
||||
? 0
|
||||
: active.Average(j => Math.Max(0, (now - j.DateApplied).TotalDays));
|
||||
|
||||
return new JobStats(
|
||||
Total: all.Count,
|
||||
Active: active.Count,
|
||||
Deleted: all.Count - active.Count,
|
||||
ByStatus: byStatus,
|
||||
AppliedLast30Days: appliedLast30Days,
|
||||
AverageDaysSinceApplied: Math.Round(avgDays, 1)
|
||||
);
|
||||
}
|
||||
|
||||
public async Task<AnalyticsOverviewDto> GetAnalyticsOverviewAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
// Project to only the fields the overview needs instead of Include-ing full
|
||||
// Company + JobApplication rows (avoids loading large description/CV blobs).
|
||||
var activeJobs = await _db.JobApplications
|
||||
.AsNoTracking()
|
||||
.Where(j => !j.IsDeleted)
|
||||
.Select(j => new
|
||||
{
|
||||
j.Id,
|
||||
j.Status,
|
||||
j.ResponseReceived,
|
||||
j.ResponseDate,
|
||||
j.DateApplied,
|
||||
j.CompanyId,
|
||||
CompanyName = j.Company.Name,
|
||||
CompanySource = j.Company.Source
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
// Funnel = distribution across canonical stages, driven by the pipeline (one source
|
||||
// of truth, so it includes every stage and normalizes legacy spellings).
|
||||
var normalizedByStage = activeJobs
|
||||
.GroupBy(j => JobPipeline.Normalize(j.Status))
|
||||
.ToDictionary(g => g.Key, g => g.Count());
|
||||
var funnel = JobPipeline.Stages
|
||||
.Select(stage => new FunnelStagePoint(stage.Key, normalizedByStage.TryGetValue(stage.Key, out var c) ? c : 0))
|
||||
.ToList();
|
||||
|
||||
var responseRateBySource = activeJobs
|
||||
.GroupBy(j => string.IsNullOrWhiteSpace(j.CompanySource) ? "Unknown source" : j.CompanySource!.Trim())
|
||||
.Select(g => new ResponseRatePoint(
|
||||
g.Key,
|
||||
g.Count(),
|
||||
g.Count(x => x.ResponseReceived || x.ResponseDate is not null),
|
||||
Math.Round(g.Count(x => x.ResponseReceived || x.ResponseDate is not null) * 100d / Math.Max(1, g.Count()), 1)
|
||||
))
|
||||
.OrderByDescending(x => x.Total)
|
||||
.ThenByDescending(x => x.Rate)
|
||||
.Take(6)
|
||||
.ToList();
|
||||
|
||||
var topCompanies = activeJobs
|
||||
.GroupBy(j => new { j.CompanyId, Name = j.CompanyName })
|
||||
.Select(g => new CompanyActivityPoint(
|
||||
g.Key.CompanyId,
|
||||
g.Key.Name,
|
||||
g.Count(),
|
||||
g.Count(x => x.ResponseReceived || x.ResponseDate is not null),
|
||||
Math.Round(g.Count(x => x.ResponseReceived || x.ResponseDate is not null) * 100d / Math.Max(1, g.Count()), 1)
|
||||
))
|
||||
.OrderByDescending(x => x.Count)
|
||||
.ThenByDescending(x => x.ResponseRate)
|
||||
.Take(8)
|
||||
.ToList();
|
||||
|
||||
var responseDays = activeJobs
|
||||
.Where(j => (j.ResponseReceived || j.ResponseDate is not null) && j.ResponseDate is not null)
|
||||
.Select(j => Math.Max(0, (j.ResponseDate!.Value - j.DateApplied).TotalDays))
|
||||
.OrderBy(x => x)
|
||||
.ToList();
|
||||
|
||||
double? medianDays = null;
|
||||
if (responseDays.Count > 0)
|
||||
{
|
||||
var mid = responseDays.Count / 2;
|
||||
medianDays = responseDays.Count % 2 == 0
|
||||
? Math.Round((responseDays[mid - 1] + responseDays[mid]) / 2d, 1)
|
||||
: Math.Round(responseDays[mid], 1);
|
||||
}
|
||||
|
||||
// Time-in-stage: for each active job, when did it enter its current stage? Use the most
|
||||
// recent StatusChanged event into that stage, else its applied date.
|
||||
var activeIds = activeJobs.Select(j => j.Id).ToList();
|
||||
var statusChanges = await _db.JobEvents
|
||||
.AsNoTracking()
|
||||
.Where(e => e.Type == "StatusChanged" && activeIds.Contains(e.JobApplicationId))
|
||||
.Select(e => new { e.JobApplicationId, e.NewValue, e.At })
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var lastEntryByJob = statusChanges
|
||||
.GroupBy(e => e.JobApplicationId)
|
||||
.ToDictionary(g => g.Key, g => g.ToList());
|
||||
|
||||
var occupancy = activeJobs.Select(job =>
|
||||
{
|
||||
var current = JobPipeline.Normalize(job.Status);
|
||||
DateTime enteredAt = job.DateApplied;
|
||||
if (lastEntryByJob.TryGetValue(job.Id, out var changes))
|
||||
{
|
||||
var lastIntoCurrent = changes
|
||||
.Where(e => JobPipeline.Normalize(e.NewValue) == current)
|
||||
.OrderByDescending(e => e.At)
|
||||
.FirstOrDefault();
|
||||
if (lastIntoCurrent is not null) enteredAt = lastIntoCurrent.At;
|
||||
}
|
||||
return new StageOccupancy(current, enteredAt.ToUniversalTime());
|
||||
});
|
||||
|
||||
var timeInStage = StageAnalytics.TimeInStage(occupancy, DateTime.UtcNow)
|
||||
.Select(p => new StageDurationDto(p.Stage, p.MedianDays, p.Count))
|
||||
.ToList();
|
||||
|
||||
return new AnalyticsOverviewDto(
|
||||
Funnel: funnel,
|
||||
ResponseRateBySource: responseRateBySource,
|
||||
TopCompanies: topCompanies,
|
||||
MedianDaysToFirstResponse: medianDays,
|
||||
TotalResponses: activeJobs.Count(j => j.ResponseReceived || j.ResponseDate is not null),
|
||||
TotalActive: activeJobs.Count,
|
||||
TimeInStage: timeInStage
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
using JobTrackerApi.Services;
|
||||
|
||||
namespace JobTrackerApi.Services.EmailProviders
|
||||
{
|
||||
/// <summary>
|
||||
/// Gmail implementation of <see cref="IEmailProvider"/>. Adapts the existing
|
||||
/// <see cref="IGmailOAuthService"/> (Gmail REST client) to the provider-neutral contract,
|
||||
/// mapping Gmail DTOs to the neutral shapes.
|
||||
/// </summary>
|
||||
public sealed class GmailProvider : IEmailProvider
|
||||
{
|
||||
private readonly IGmailOAuthService _gmail;
|
||||
|
||||
public GmailProvider(IGmailOAuthService gmail)
|
||||
{
|
||||
_gmail = gmail;
|
||||
}
|
||||
|
||||
public string ProviderKey => "gmail";
|
||||
|
||||
public async Task<EmailConnectionInfo?> GetConnectionAsync(string ownerUserId, CancellationToken cancellationToken)
|
||||
{
|
||||
var connection = await _gmail.GetConnectionAsync(ownerUserId, cancellationToken);
|
||||
return connection is null ? null : new EmailConnectionInfo("gmail", connection.GmailAddress ?? "");
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<EmailMessageSummary>> SearchAsync(string ownerUserId, string? query, int maxResults, CancellationToken cancellationToken)
|
||||
{
|
||||
var messages = await _gmail.ListMessagesAsync(ownerUserId, query, maxResults, cancellationToken);
|
||||
return messages.Select(ToSummary).ToList();
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<EmailMessageSummary>> ListThreadMessagesAsync(string ownerUserId, string threadId, CancellationToken cancellationToken)
|
||||
{
|
||||
var messages = await _gmail.ListThreadMessagesAsync(ownerUserId, threadId, cancellationToken);
|
||||
return messages.Select(ToSummary).ToList();
|
||||
}
|
||||
|
||||
public async Task<EmailMessageDetail> GetMessageAsync(string ownerUserId, string messageId, CancellationToken cancellationToken)
|
||||
{
|
||||
var detail = await _gmail.GetMessageAsync(ownerUserId, messageId, cancellationToken);
|
||||
var attachments = detail.Attachments
|
||||
.Select(a => new EmailAttachmentRef(a.FileName, a.MimeType, a.SizeBytes, a.GmailAttachmentId, a.Inline))
|
||||
.ToList();
|
||||
|
||||
return new EmailMessageDetail(
|
||||
detail.Id,
|
||||
detail.ThreadId,
|
||||
detail.Subject,
|
||||
detail.From,
|
||||
detail.To,
|
||||
detail.Date,
|
||||
detail.Snippet,
|
||||
detail.BodyText,
|
||||
detail.BodyHtml,
|
||||
detail.Labels,
|
||||
attachments);
|
||||
}
|
||||
|
||||
private static EmailMessageSummary ToSummary(GmailMessageSummary m)
|
||||
=> new(m.Id, m.ThreadId, m.Subject, m.From, m.To, m.Date, m.Snippet);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
namespace JobTrackerApi.Services.EmailProviders
|
||||
{
|
||||
/// <summary>
|
||||
/// Provider-neutral email operations so job correspondence can be sourced from Gmail,
|
||||
/// Microsoft Graph, generic IMAP, or manual/free-text entry behind a single seam.
|
||||
/// See docs/remaster/PRODUCT_DIRECTION.md (multi-provider email). Gmail is the first
|
||||
/// implementation (<see cref="GmailProvider"/>); the controller migration and additional
|
||||
/// providers land in follow-up slices.
|
||||
/// </summary>
|
||||
public interface IEmailProvider
|
||||
{
|
||||
/// <summary>Stable key: "gmail" | "microsoft" | "imap" | "manual".</summary>
|
||||
string ProviderKey { get; }
|
||||
|
||||
/// <summary>The user's active connection for this provider, or null if not connected.</summary>
|
||||
Task<EmailConnectionInfo?> GetConnectionAsync(string ownerUserId, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Search the user's mailbox. <paramref name="query"/> is provider-specific syntax.</summary>
|
||||
Task<IReadOnlyList<EmailMessageSummary>> SearchAsync(string ownerUserId, string? query, int maxResults, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>All messages in a thread/conversation.</summary>
|
||||
Task<IReadOnlyList<EmailMessageSummary>> ListThreadMessagesAsync(string ownerUserId, string threadId, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Full message content (body + attachments metadata).</summary>
|
||||
Task<EmailMessageDetail> GetMessageAsync(string ownerUserId, string messageId, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public sealed record EmailConnectionInfo(string ProviderKey, string Address);
|
||||
|
||||
public sealed record EmailMessageSummary(string Id, string ThreadId, string Subject, string From, string To, DateTimeOffset? Date, string Snippet);
|
||||
|
||||
public sealed record EmailAttachmentRef(string? FileName, string? MimeType, long? SizeBytes, string? ExternalAttachmentId, bool Inline);
|
||||
|
||||
public sealed record EmailMessageDetail(
|
||||
string Id,
|
||||
string ThreadId,
|
||||
string Subject,
|
||||
string From,
|
||||
string To,
|
||||
DateTimeOffset? Date,
|
||||
string Snippet,
|
||||
string BodyText,
|
||||
string? BodyHtml,
|
||||
IReadOnlyList<string> Labels,
|
||||
IReadOnlyList<EmailAttachmentRef> Attachments);
|
||||
|
||||
/// <summary>Resolves a registered <see cref="IEmailProvider"/> by its key.</summary>
|
||||
public interface IEmailProviderRegistry
|
||||
{
|
||||
IReadOnlyList<IEmailProvider> All { get; }
|
||||
IEmailProvider? Get(string? providerKey);
|
||||
}
|
||||
|
||||
public sealed class EmailProviderRegistry : IEmailProviderRegistry
|
||||
{
|
||||
private readonly Dictionary<string, IEmailProvider> _byKey;
|
||||
|
||||
public EmailProviderRegistry(IEnumerable<IEmailProvider> providers)
|
||||
{
|
||||
All = providers.ToList();
|
||||
_byKey = All.ToDictionary(p => p.ProviderKey, StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
public IReadOnlyList<IEmailProvider> All { get; }
|
||||
|
||||
public IEmailProvider? Get(string? providerKey)
|
||||
=> !string.IsNullOrWhiteSpace(providerKey) && _byKey.TryGetValue(providerKey, out var p) ? p : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
namespace JobTrackerApi.Services
|
||||
{
|
||||
public sealed record EmailStatusSuggestion(string SuggestedStatus, string Signal, string Confidence);
|
||||
|
||||
/// <summary>
|
||||
/// Deterministic email → pipeline-status classifier. Scans subject/body for outcome signals and
|
||||
/// suggests a canonical status. No AI: instant, reproducible, and safe (the user always confirms).
|
||||
/// Priority matters — a rejection email often still mentions "interview", so rejection wins.
|
||||
/// </summary>
|
||||
public static class EmailStatusClassifier
|
||||
{
|
||||
// Ordered highest-priority first. Each stage lists lowercase phrases to look for.
|
||||
private static readonly (string Status, string Confidence, string[] Phrases)[] Rules =
|
||||
{
|
||||
("Rejected", "high", new[]
|
||||
{
|
||||
"regret to inform", "we regret", "unfortunately, we", "not moving forward",
|
||||
"not be moving forward", "decided not to proceed", "will not be proceeding",
|
||||
"not to proceed", "not been selected", "will not be progressing",
|
||||
"unable to offer", "position has been filled", "no longer being considered",
|
||||
"decided to move forward with other", "pursue other candidates",
|
||||
"not to move forward", "were not successful", "was not successful",
|
||||
}),
|
||||
("Offer", "high", new[]
|
||||
{
|
||||
"pleased to offer", "delighted to offer", "happy to offer", "offer of employment",
|
||||
"job offer", "we would like to offer", "formal offer", "extend an offer",
|
||||
"offer letter", "excited to offer",
|
||||
}),
|
||||
("Interview", "medium", new[]
|
||||
{
|
||||
"invite you to interview", "invite you to an interview", "schedule an interview",
|
||||
"would like to invite you", "phone screen", "phone interview", "video interview",
|
||||
"technical interview", "next steps in the", "your availability for a call",
|
||||
"availability for an interview", "set up a call", "set up an interview",
|
||||
"meet the team", "book a time", "invitation to interview", "interview invitation",
|
||||
"like to speak with you", "move to the interview",
|
||||
}),
|
||||
};
|
||||
|
||||
// Weaker single-word cues only fire when no strong phrase matched (kept low-confidence).
|
||||
private static readonly string[] InterviewWeakCues = { "interview", "assessment", "coding challenge", "take-home" };
|
||||
|
||||
public static EmailStatusSuggestion? Classify(string? subject, string? body)
|
||||
{
|
||||
var text = $"{subject}\n{body}".ToLowerInvariant();
|
||||
if (string.IsNullOrWhiteSpace(text)) return null;
|
||||
|
||||
foreach (var (status, confidence, phrases) in Rules)
|
||||
{
|
||||
var hit = phrases.FirstOrDefault(p => text.Contains(p, StringComparison.Ordinal));
|
||||
if (hit is not null) return new EmailStatusSuggestion(status, hit, confidence);
|
||||
}
|
||||
|
||||
var weak = InterviewWeakCues.FirstOrDefault(c => text.Contains(c, StringComparison.Ordinal));
|
||||
if (weak is not null) return new EmailStatusSuggestion("Interview", weak, "low");
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
namespace JobTrackerApi.Services
|
||||
{
|
||||
public sealed record StageDurationPoint(string Stage, int Order, double MedianDays, int Count);
|
||||
|
||||
/// <summary>One job's position: its canonical stage and when it entered that stage.</summary>
|
||||
public sealed record StageOccupancy(string Status, DateTime EnteredStageAtUtc);
|
||||
|
||||
/// <summary>
|
||||
/// Pure time-in-stage analytics: for each active pipeline stage, the median number of days
|
||||
/// the jobs currently sitting there have been waiting. Closed stages (Rejected/Ghosted) and
|
||||
/// the terminal success stage (Offer) are excluded — "how long has this been stuck" only
|
||||
/// makes sense for stages you still act on.
|
||||
/// </summary>
|
||||
public static class StageAnalytics
|
||||
{
|
||||
public static List<StageDurationPoint> TimeInStage(IEnumerable<StageOccupancy> jobs, DateTime nowUtc)
|
||||
{
|
||||
var byStage = jobs
|
||||
.Select(j => (Stage: JobPipeline.Normalize(j.Status), Days: Math.Max(0, (nowUtc - j.EnteredStageAtUtc).TotalDays)))
|
||||
.Where(x => JobPipeline.Stages.Any(s => s.Key == x.Stage && s.Category == PipelineCategory.Active))
|
||||
.GroupBy(x => x.Stage);
|
||||
|
||||
var points = new List<StageDurationPoint>();
|
||||
foreach (var group in byStage)
|
||||
{
|
||||
var days = group.Select(x => x.Days).OrderBy(x => x).ToList();
|
||||
points.Add(new StageDurationPoint(
|
||||
Stage: group.Key,
|
||||
Order: JobPipeline.OrderOf(group.Key),
|
||||
MedianDays: Median(days),
|
||||
Count: days.Count));
|
||||
}
|
||||
|
||||
return points.OrderBy(p => p.Order).ToList();
|
||||
}
|
||||
|
||||
private static double Median(IReadOnlyList<double> sorted)
|
||||
{
|
||||
if (sorted.Count == 0) return 0;
|
||||
var mid = sorted.Count / 2;
|
||||
var median = sorted.Count % 2 == 0 ? (sorted[mid - 1] + sorted[mid]) / 2d : sorted[mid];
|
||||
return Math.Round(median, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -505,6 +505,16 @@ public static class StartupInitializationExtensions
|
||||
EnsureColumn(conn, "Attachments", "Purpose", "ALTER TABLE Attachments ADD COLUMN Purpose TEXT NULL;");
|
||||
EnsureColumn(conn, "Attachments", "UseForAi", "ALTER TABLE Attachments ADD COLUMN UseForAi INTEGER NOT NULL DEFAULT 1;");
|
||||
|
||||
// Hot-path composite indexes for tenant-scoped list/board/stats/analytics
|
||||
// (OwnerUserId + IsDeleted) and reminders (OwnerUserId + FollowUpAt). Guarded
|
||||
// on table existence: on a brand-new DB the table is created by Migrate()
|
||||
// below, so the index is picked up on the next start.
|
||||
if (HasTable(conn, "JobApplications"))
|
||||
{
|
||||
Exec(conn, """CREATE INDEX IF NOT EXISTS "IX_JobApplications_OwnerUserId_IsDeleted" ON "JobApplications" ("OwnerUserId", "IsDeleted");""");
|
||||
Exec(conn, """CREATE INDEX IF NOT EXISTS "IX_JobApplications_OwnerUserId_FollowUpAt" ON "JobApplications" ("OwnerUserId", "FollowUpAt");""");
|
||||
}
|
||||
|
||||
// Ensure data folder exists before creating/opening SQLite files.
|
||||
Directory.CreateDirectory(paths.DataRoot);
|
||||
}
|
||||
@@ -829,6 +839,22 @@ public static class StartupInitializationExtensions
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
// Hot-path composite indexes for tenant-scoped list/board/stats/analytics
|
||||
// (OwnerUserId + IsDeleted) and reminders (OwnerUserId + FollowUpAt).
|
||||
if (!MySqlIndexExists(conn, "JobApplications", "IX_JobApplications_OwnerUserId_IsDeleted"))
|
||||
{
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "CREATE INDEX `IX_JobApplications_OwnerUserId_IsDeleted` ON `JobApplications` (`OwnerUserId`, `IsDeleted`);";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
if (!MySqlIndexExists(conn, "JobApplications", "IX_JobApplications_OwnerUserId_FollowUpAt"))
|
||||
{
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "CREATE INDEX `IX_JobApplications_OwnerUserId_FollowUpAt` ON `JobApplications` (`OwnerUserId`, `FollowUpAt`);";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
if (!MySqlIndexExists(conn, "CvUploadArtifacts", "IX_CvUploadArtifacts_OwnerUserId_UploadedAtUtc"))
|
||||
{
|
||||
using var cmd = conn.CreateCommand();
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace JobTrackerApi.Models
|
||||
{
|
||||
// Read-only analytics/statistics response DTOs. Extracted from
|
||||
// JobApplicationsController so the aggregation logic can live in AnalyticsService.
|
||||
public sealed record JobStats(
|
||||
int Total,
|
||||
int Active,
|
||||
int Deleted,
|
||||
Dictionary<string, int> ByStatus,
|
||||
int AppliedLast30Days,
|
||||
double AverageDaysSinceApplied
|
||||
);
|
||||
|
||||
public sealed record FunnelStagePoint(string Label, int Count);
|
||||
|
||||
public sealed record ResponseRatePoint(string Label, int Total, int Responses, double Rate);
|
||||
|
||||
public sealed record CompanyActivityPoint(int CompanyId, string Company, int Count, int Responses, double ResponseRate);
|
||||
|
||||
public sealed record StageDurationDto(string Stage, double MedianDays, int Count);
|
||||
|
||||
public sealed record AnalyticsOverviewDto(
|
||||
List<FunnelStagePoint> Funnel,
|
||||
List<ResponseRatePoint> ResponseRateBySource,
|
||||
List<CompanyActivityPoint> TopCompanies,
|
||||
double? MedianDaysToFirstResponse,
|
||||
int TotalResponses,
|
||||
int TotalActive,
|
||||
List<StageDurationDto> TimeInStage
|
||||
);
|
||||
}
|
||||
@@ -12,6 +12,8 @@ Job Tracker is a simple, self-hosted app for tracking job applications with a Re
|
||||
- History/event trail per application (created, status changes, follow-up set, delete/restore)
|
||||
- Export jobs to JSON/CSV + daily scheduled JSON export
|
||||
- Optional “job import” preview from supported job sites (plugins) + optional translation to English
|
||||
- Quick-capture bookmarklet (Settings) + installable PWA with a mobile share-target: both open `/?add=<page url>` to pre-fill Add Job from any posting
|
||||
- Note: no offline service-worker cache is bundled by design (the app is deployed frequently; an aggressive cache would risk serving stale builds). The manifest provides installability and share-to-capture without it.
|
||||
- Optional local AI service for short/full descriptions
|
||||
- Optional Google sign-in (Google ID tokens) to protect the API
|
||||
|
||||
@@ -205,6 +207,8 @@ Authentication:
|
||||
- Returns totals, counts by status, applied-last-30-days, and average days since applied.
|
||||
- `GET /api/jobapplications/{id}/match-score`
|
||||
- Deterministic CV↔job keyword-coverage score (0–100) with matched/missing keywords and per-CV-section coverage. No AI calls: results are instant and reproducible. Requires profile CV text/structure and a job description. (The AI narrative equivalent is `GET /api/jobapplications/{id}/candidate-fit`.)
|
||||
- `GET /api/jobapplications/{id}/status-suggestion`
|
||||
- Deterministic status suggestion derived from the job's most recent inbound message (interview invite / offer / rejection). Returns a forward-only suggestion (`hasSuggestion`, `suggestedStatus`, `signal`, …) or `hasSuggestion: false`. Applying it is a normal `PATCH .../status` — always user-confirmed.
|
||||
- `DELETE /api/jobapplications/{id}`
|
||||
- Soft-deletes an application (`IsDeleted=true`); records a `Deleted` event.
|
||||
- `POST /api/jobapplications/{id}/restore`
|
||||
|
||||
+3
-1
@@ -61,7 +61,9 @@ fi
|
||||
# Force recreation so updated port mappings, env vars, and container config always apply on deploy.
|
||||
compose up -d --force-recreate --remove-orphans backend frontend
|
||||
if [ "$DEPLOY_BUILD_AI_SERVICE" = "true" ]; then
|
||||
compose up -d --force-recreate ai-service ollama
|
||||
# Ollama is opt-in (compose "bundled-ollama" profile). Deploys reuse an
|
||||
# existing/shared Ollama via OLLAMA_BASE_URL instead of starting a duplicate.
|
||||
compose up -d --force-recreate ai-service
|
||||
fi
|
||||
|
||||
if [ -n "${OLLAMA_MODEL:-}" ]; then
|
||||
|
||||
+18
-2
@@ -54,6 +54,9 @@ services:
|
||||
frontend:
|
||||
build:
|
||||
context: ./job-tracker-ui
|
||||
# fork-ts-checker (CRA's build type-checker) needs more than Docker's default
|
||||
# 64MB /dev/shm; too little causes a SIGSEGV during `npm run build`.
|
||||
shm_size: '1gb'
|
||||
args:
|
||||
- REACT_APP_GOOGLE_CLIENT_ID=${AUTH_GOOGLE_CLIENT_ID}
|
||||
# Optional override; default in production is `/api`
|
||||
@@ -72,12 +75,21 @@ services:
|
||||
context: ./tools/summarizer
|
||||
dockerfile: Dockerfile
|
||||
environment:
|
||||
# Point at an existing/shared Ollama by setting OLLAMA_BASE_URL in .env
|
||||
# (e.g. http://<host-ip>:11435). The in-compose ollama service below is
|
||||
# opt-in via the "bundled-ollama" profile, so it is NOT started by default
|
||||
# and no duplicate Ollama container is created.
|
||||
- OLLAMA_BASE_URL=${OLLAMA_BASE_URL:-http://ollama:11434}
|
||||
- OLLAMA_MODEL=${OLLAMA_MODEL:-qwen2.5:7b}
|
||||
# AI provider for heavy /cv/* calls: ollama (default) | gemini | groq.
|
||||
# Set AI_PROVIDER=gemini + GEMINI_API_KEY in prod to offload a weak local GPU.
|
||||
- AI_PROVIDER=${AI_PROVIDER:-ollama}
|
||||
- GEMINI_API_KEY=${GEMINI_API_KEY:-}
|
||||
- GEMINI_MODEL=${GEMINI_MODEL:-gemini-2.0-flash}
|
||||
- GROQ_API_KEY=${GROQ_API_KEY:-}
|
||||
- GROQ_MODEL=${GROQ_MODEL:-llama-3.3-70b-versatile}
|
||||
ports:
|
||||
- "8001:8001"
|
||||
depends_on:
|
||||
- ollama
|
||||
networks:
|
||||
- default
|
||||
- shared_services
|
||||
@@ -88,7 +100,11 @@ services:
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
|
||||
# Opt-in only: start with `docker compose --profile bundled-ollama up`.
|
||||
# Left out of the default set so deploys reuse an existing/shared Ollama
|
||||
# (configured via OLLAMA_BASE_URL) instead of spinning up a duplicate.
|
||||
ollama:
|
||||
profiles: ["bundled-ollama"]
|
||||
image: ollama/ollama:latest
|
||||
ports:
|
||||
- "11434:11434"
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
# Merge Request — Wave 0 quick wins + Tier-1/Tier-2 features
|
||||
|
||||
**Branch:** `chore/wave0-quick-wins` → `main`
|
||||
**Scope:** 24 commits · 62 files · +3,165 / −489
|
||||
**Status:** all tests green (backend 135, frontend 23 suites / 54 tests), production build compiles.
|
||||
|
||||
> Prepared for human review. Do **not** auto-merge. One operator action is required after merge
|
||||
> (DataProtection key rotation — see *Known limitations*).
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
Delivers the first two roadmap tiers plus the engineering-health groundwork, developed as small
|
||||
conventional commits. Two design principles run through it:
|
||||
|
||||
1. **Deterministic over "AI-guessy."** Match scoring, status suggestions, and pipeline logic are
|
||||
pure/deterministic — instant, reproducible, and safe (the user confirms every state change). This
|
||||
directly answers the market's most common complaint (hallucinated/generic AI output).
|
||||
2. **One pathway, not two.** The bookmarklet and the PWA share-target feed a single `/?add=` capture
|
||||
flow rather than parallel implementations.
|
||||
|
||||
## What's included
|
||||
|
||||
**Engineering health (Wave 0)**
|
||||
- `security:` untracked committed DataProtection keys + daily exports; removed dead legacy controllers.
|
||||
- `feat:` automated daily SQLite backups (`VACUUM INTO`, retention, startup catch-up) — prod previously
|
||||
had **no** automated backup on Linux.
|
||||
- `ci:` run the **entire** frontend suite (the old whitelist was hiding 3 broken suites, now fixed).
|
||||
- `feat:` dev-only OpenAPI at `/openapi/v1.json`; `feat:` structured salary fields.
|
||||
|
||||
**Tier-1 features**
|
||||
- **Match score** (`GET /jobapplications/{id}/match-score`) — deterministic CV↔job keyword coverage
|
||||
(0–100) + matched/missing keywords + section coverage. Instant panel on the Candidate Fit tab.
|
||||
- **Canonical pipeline** — `JobPipeline` single source of truth; status normalized on write (custom
|
||||
values preserved); UI deduped across 5 files; `GET .../pipeline`.
|
||||
- **Analytics v2** — time-in-stage medians (from `StatusChanged` history) + funnel driven by the
|
||||
pipeline (fixes a bug that omitted the Waiting stage).
|
||||
- **Status suggestions** — deterministic email→status classifier surfaced as a human-confirmed banner.
|
||||
|
||||
**Tier-2 features**
|
||||
- **Bookmarklet** quick-capture (Settings) reusing `jobimport/preview`.
|
||||
- **Installable PWA** with a mobile share-target into the same capture flow.
|
||||
|
||||
**Quality**
|
||||
- Phase-6 security review (`docs/SECURITY_REPORT.md`): tenant isolation on new endpoints verified +
|
||||
regression-tested; no injection/ReDoS; dev-only OpenAPI.
|
||||
- Bug fixes: `SkillTagger` C#/.NET regex (silently missed those skills everywhere), a React
|
||||
stale-closure, a duplicated DB query, and 3 pre-existing hidden test failures.
|
||||
|
||||
## Test coverage added
|
||||
|
||||
New pure/unit-tested services: `JobCvMatchService` (7), `JobPipeline` (14), `StageAnalytics` (4),
|
||||
`EmailStatusClassifier` (7). New endpoint integration + authorization tests (match-score,
|
||||
status-suggestion). New frontend tests: match-score panel, status-suggestion banner, pipeline,
|
||||
quick-capture, capture-url resolution.
|
||||
|
||||
## Docs
|
||||
|
||||
New: `docs/SYSTEM_OVERVIEW.md`, `docs/PRODUCT_RESEARCH.md`, `docs/ROADMAP.md`,
|
||||
`docs/SECURITY_REPORT.md`. README updated with the new endpoints, backup/pipeline config, and
|
||||
quick-capture/PWA notes.
|
||||
|
||||
## Known limitations / follow-ups
|
||||
|
||||
- **ACTION REQUIRED (security):** the removed DataProtection key XMLs remain in git **history**.
|
||||
Rotate them on the production host after merge (see `SECURITY_REPORT.md` §6).
|
||||
- **Per-user custom pipeline stages** were deliberately deferred (unproven demand; large surface).
|
||||
- **No offline service worker** by design — the app deploys frequently and an aggressive cache would
|
||||
risk serving stale builds. The PWA is installable and share-capable without it.
|
||||
- Not yet done (future branches): interview hub (M3), contacts CRM (M4), god-controller decomposition,
|
||||
performance pass, Vite migration.
|
||||
|
||||
## Reviewer notes
|
||||
|
||||
- Repo quirk: controllers/services compile via the `JobTrackerBackend` library, **not** the
|
||||
`JobTrackerApi` host project (see `docs/SYSTEM_OVERVIEW.md` §2).
|
||||
- All AI-adjacent features are deterministic and make no model calls.
|
||||
@@ -0,0 +1,122 @@
|
||||
# SECURITY_REPORT.md — Session Change Review
|
||||
|
||||
> Phase 6 deliverable. Scope: security review of the changes made in this work session
|
||||
> (Wave 0 + roadmap H1–H4), plus confirmation that the tenant-isolation model still holds.
|
||||
> Date: 2026-07-03. Complements the prior standalone assessments in
|
||||
> `docs/security-assessments/` (M013 adversarial, M014 remediation, M015 authorization replay).
|
||||
|
||||
This is **not** a full re-audit of the whole application — those live in `docs/security-assessments/`.
|
||||
It is a focused review of the new/changed surface so nothing shipped this session introduces a regression.
|
||||
|
||||
---
|
||||
|
||||
## 1. Summary
|
||||
|
||||
No new vulnerabilities were introduced. Tenant isolation on the new endpoints is carried by the
|
||||
existing `JobTrackerContext` global query filters and is now covered by regression tests. One latent
|
||||
correctness issue (a routable background-service method) was closed, and leaked runtime secrets were
|
||||
removed from version control (rotation recommended — see §6).
|
||||
|
||||
| Severity | Count | Items |
|
||||
|---|---|---|
|
||||
| Critical | 0 | — |
|
||||
| High | 0 | — |
|
||||
| Medium | 1 (mitigated) | DataProtection keys present in git history (untracked this session; rotation recommended) |
|
||||
| Low / hardening | 3 | see §5 |
|
||||
|
||||
---
|
||||
|
||||
## 2. New/changed attack surface reviewed
|
||||
|
||||
| Change | Surface | Verdict |
|
||||
|---|---|---|
|
||||
| `GET /jobapplications/{id}/match-score` | route int id; reads own CV + job | Safe — tenant-scoped |
|
||||
| `GET /jobapplications/{id}/status-suggestion` | route int id; reads own correspondence | Safe — tenant-scoped |
|
||||
| `GET /jobapplications/pipeline` | none (static metadata) | Safe |
|
||||
| `PATCH .../status`, Create/Update (status normalization) | user string → `JobPipeline.Normalize` | Safe — no injection, values stored parameterized |
|
||||
| Structured salary fields | numeric + short strings, `NormalizeSalary` | Safe — clamps negatives, whitelists period |
|
||||
| Automated DB backup (`VACUUM INTO`) | server-controlled path | Safe — see §4 |
|
||||
| Dev OpenAPI (`/openapi/v1.json`) | schema | Safe — `Development` environment only |
|
||||
| `EmailStatusClassifier` | reads stored correspondence text | Safe — deterministic, no eval/injection |
|
||||
|
||||
---
|
||||
|
||||
## 3. OWASP-oriented checklist for the new code
|
||||
|
||||
- **A01 Broken Access Control** — The two new data endpoints load the job via
|
||||
`_db.JobApplications.FirstOrDefaultAsync(j => j.Id == id)`, which is filtered by the global
|
||||
query filter `CurrentUserId != null && OwnerUserId == CurrentUserId` (deny-on-null, hardened in
|
||||
M013-2). A cross-user id returns `NotFound`, not another tenant's data. The correspondence lookup
|
||||
in `status-suggestion` and the `JobEvent` lookup in analytics are likewise filtered through their
|
||||
parent's owner. **Verified by `JobApplicationsAuthorizationTests` (match-score + status-suggestion).**
|
||||
- **A03 Injection** — All new persistence goes through EF Core parameterized queries. The only raw
|
||||
SQL added is `VACUUM INTO '<path>'` with a fully server-derived path (see §4). No string
|
||||
concatenation of user input into queries.
|
||||
- **A03 ReDoS** — New regexes (`JobCvMatchService.TokenPattern`, the revised `SkillTagger` C#/.NET
|
||||
patterns with fixed-width look-behinds) are linear with no catastrophic backtracking.
|
||||
- **A04 Insecure Design** — Status suggestions and match scoring are deterministic and
|
||||
**human-confirmed** (a status only changes when the user clicks). No automated outbound actions.
|
||||
- **A05 Security Misconfiguration** — OpenAPI is exposed only under `IsDevelopment()`; production
|
||||
deployments (`ASPNETCORE_ENVIRONMENT=Production`) do not serve it.
|
||||
- **A08 Data Integrity** — `JobPipeline.Normalize` canonicalizes status on write but preserves
|
||||
unknown custom values (no silent data loss).
|
||||
- **A09 Logging** — No secrets or PII added to logs by the new code.
|
||||
|
||||
---
|
||||
|
||||
## 4. Database backup — path handling
|
||||
|
||||
`SqliteDatabaseBackupRunner` runs `VACUUM INTO '<target>'`. The target is
|
||||
`<Data:Root>/backups/jobtracker_backup_<UTC-timestamp>.db` — no user input reaches it — and single
|
||||
quotes are escaped defensively. Backups contain the full database (sensitive) and are written to the
|
||||
same data volume as the live DB, i.e. the same trust boundary; they are git-ignored. For defense in
|
||||
depth, operators should ship backups off-host with transport encryption and restrict volume
|
||||
permissions. **Recommendation (low):** document an off-host, encrypted backup rotation in the
|
||||
deployment guide.
|
||||
|
||||
---
|
||||
|
||||
## 5. Low / hardening findings
|
||||
|
||||
1. **match-score input size (low).** `GetMatchScore` does not cap job-description length before
|
||||
tokenizing. Descriptions are bounded in practice (imported/typed), and the algorithm is linear, so
|
||||
this is not a DoS, but a defensive cap (e.g. 50 KB) would be prudent.
|
||||
2. **New read endpoints are not rate-limited (low).** `match-score`/`status-suggestion` are cheap and
|
||||
deterministic (no AI, one indexed query), and auth-gated in production, so abuse potential is low.
|
||||
Consider a general authenticated-read limiter if the API is exposed publicly.
|
||||
3. **status-suggestion is conservative for custom statuses (informational).** A job in a non-canonical
|
||||
custom status (pipeline order = max) never receives a suggestion. This is safe (fails closed) but
|
||||
slightly under-surfaces; acceptable given custom statuses are rare.
|
||||
|
||||
---
|
||||
|
||||
## 6. Secrets hygiene (actioned this session)
|
||||
|
||||
- Committed ASP.NET **DataProtection key XML files** (`keys/`, `JobTrackerApi/keys/`) and daily export
|
||||
JSON were removed from tracking and added to `.gitignore`
|
||||
(commit `security: untrack DataProtection keys and runtime exports…`).
|
||||
- **These key files remain in git history.** DataProtection keys sign auth/session artifacts, so
|
||||
**rotating them on the production host is recommended** (generate fresh keys; the app regenerates the
|
||||
key ring in the persisted `keys/` directory on next start). Until rotated, anyone with history access
|
||||
could read the old key material.
|
||||
- Local `.env` remains git-ignored; `appsettings.Development.json` contains only `CHANGE_ME_*`
|
||||
placeholders. No live secrets are tracked.
|
||||
|
||||
---
|
||||
|
||||
## 7. Confirmed intact from prior assessments
|
||||
|
||||
Spot-checked that the M013–M015 remediations are still in force after this session's changes:
|
||||
|
||||
- Owner query filters still deny on null `CurrentUserId` (`Data/JobTrackerContext.cs`).
|
||||
- Local JWT still requires a concrete subject claim (`LocalAuthIdentity`, `Program.cs`).
|
||||
- Job-import SSRF guard (DNS resolution + private-range rejection, redirects disabled) untouched.
|
||||
- CSRF double-submit middleware and CORS allowlist untouched.
|
||||
|
||||
---
|
||||
|
||||
## 8. Retest
|
||||
|
||||
All backend tests pass (135), including the two new tenant-isolation tests for the new endpoints.
|
||||
No fix in this report required code changes beyond what already landed; the residual **action for the
|
||||
operator is DataProtection key rotation** (§6).
|
||||
@@ -0,0 +1,83 @@
|
||||
# Memory Leak Report — Job Tracker
|
||||
|
||||
**Date:** 2026-07-05
|
||||
**Investigator role:** Senior Performance Engineer (memory/browser internals/full-stack)
|
||||
**Verdict:** **No confirmed memory leak.** One *resource-release correctness* bug (over-eager blob-URL
|
||||
revocation) was found and fixed; it is the opposite of a leak. See [ROOT_CAUSE_ANALYSIS.md](ROOT_CAUSE_ANALYSIS.md)
|
||||
and [PERFORMANCE_IMPROVEMENTS.md](PERFORMANCE_IMPROVEMENTS.md).
|
||||
|
||||
> Method & honesty note. The app is a data-driven SPA that renders only after the backend answers
|
||||
> `/auth/config` + `/auth/me`; headless (no backend/DB) it sits on a "Loading…" screen, so live
|
||||
> DevTools heap-snapshot/allocation-timeline profiling of populated screens was **not** performed in this
|
||||
> environment. Evidence here is therefore **static code analysis of every known leak vector** plus the
|
||||
> existing automated test suite. Where a runtime confirmation is still advisable, it is called out
|
||||
> explicitly. Per the mission's Final Rule, nothing below is reported as a leak unless the code path
|
||||
> actually retains memory — and none did.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1–2 — Does a leak exist? Can it be reproduced?
|
||||
|
||||
No leak was reproduced or evidenced. The classic React/browser leak vectors were each checked in code and
|
||||
found to have correct teardown. "Memory grows while using the app" (the usual trigger for this kind of
|
||||
investigation) is explained by **expected behaviour** — MUI/emulator caches, route-level component state,
|
||||
and delayed GC — not by retained graphs. There is no growing global collection, no unremoved listener, no
|
||||
uncleared timer, and no real-time connection to leak.
|
||||
|
||||
## Phase 3 / 3.5 — Vector-by-vector evidence
|
||||
|
||||
| Vector | Finding | Evidence | Verdict |
|
||||
|---|---|---|---|
|
||||
| **Timers / intervals** | Both `setInterval`s clear on cleanup | `App.tsx:154-155` (reminders, 60s → `clearInterval`); `ProfilePage.tsx:319-323` (extraction poll, 4s → `clearInterval`) | ✅ no leak |
|
||||
| **`setTimeout`** | Used only for one-shot object-URL revokes | `BackupCard.tsx:29`, `Attachments.tsx:193`, `ImportExportJobs.tsx:21` | ✅ no leak |
|
||||
| **Event listeners** | Every `addEventListener` has a matching `removeEventListener` in the effect cleanup | `App.tsx:174-175` (auth-changed), `App.tsx:185-186` (keydown), `CropImageDialog.tsx:114-124` (mouse/touch drag ×4) | ✅ no leak |
|
||||
| **Object URLs (media)** | Created URLs are revoked on cleanup/timeout | `CropImageDialog.tsx:59/65`, `Attachments.tsx:111/181/193/201`, `BackupCard.tsx:18/29`, `ImportExportJobs.tsx:16/21`, `JobDetailsDialog.tsx:507/514`, `ProfilePage.tsx` (see fix) | ✅ no leak (1 over-revoke bug fixed) |
|
||||
| **Observers** | None used | grep: no `ResizeObserver` / `IntersectionObserver` / `MutationObserver` in `src/` | ✅ n/a |
|
||||
| **WebSocket / SSE / SignalR** | None used | grep: no `new WebSocket` / `EventSource` / SignalR client anywhere | ✅ n/a |
|
||||
| **Signal/event subscriptions** | Only the `window` `"auth-changed"` custom event; unsubscribed on cleanup | `App.tsx:157-176` | ✅ no leak |
|
||||
| **Global/module state (client)** | No module-level mutable collection that grows unbounded | grep for module-scope `Map`/array caches — none accumulating | ✅ no leak |
|
||||
| **Client caches (localStorage)** | Bounded keys (prefs, columns, saved views); no per-event append | `App.tsx`, `SettingsView.tsx`, `SavedViewsMenu.tsx`, `themePrefs.ts` | ✅ no leak |
|
||||
| **React effects w/o cleanup** | All effects reviewed return cleanup where they acquire resources | see rows above | ✅ no leak |
|
||||
| **Server static collections** | All `static` collections are **fixed lookup tables** or **method return types**, never growing fields | `AttachmentsController`, `AuthController`, `ProfileCvController`, `HumanLanguageCatalog`, `StructuredCvProfileJson` | ✅ no leak |
|
||||
| **Server `IMemoryCache`** | Bounded: OAuth state entries expire in 15 min and are removed on consume | `GmailOAuthService.cs:72` (`TimeSpan.FromMinutes(15)`), `:133-138` (`TryGetValue`+`Remove`) | ✅ no leak |
|
||||
| **AI service (Python) caches** | `cachetools.TTLCache` (bounded by TTL + maxsize) | `tools/summarizer/app.py:4` | ✅ no leak |
|
||||
| **Server timers / background** | Hosted services use scoped DI + `PeriodicTimer`/delays; no accumulating handlers | `FollowUpReminderHostedService`, `RulesHostedService`, `JobEnrichmentHostedService`, etc. | ✅ no leak |
|
||||
|
||||
## Phase 3.5 — Repeated/duplicate work audit
|
||||
|
||||
- **Reminders poll** (`App.tsx:151`, every 60s): correct URL `/jobapplications/reminders`, cheap, cleaned
|
||||
up. (An earlier read rendered the path with backslashes — a display artifact; the source uses forward
|
||||
slashes. **No bug.**)
|
||||
- **Extraction-run poll** (`ProfilePage.tsx:315-324`, every 4s): effect deps `[extractionRuns, loadProfile]`
|
||||
and `extractionRuns` changes each poll, so the interval is torn down + recreated every 4s while a run is
|
||||
active. **Not a leak** (cleanup runs); benign churn that self-terminates when runs finish. Minor — see
|
||||
[PERFORMANCE_IMPROVEMENTS.md](PERFORMANCE_IMPROVEMENTS.md).
|
||||
- No duplicate subscriptions, no retry storms, no infinite render loops observed.
|
||||
|
||||
## Phase 4 — Root cause
|
||||
No leak → no leak root cause. The single defect found is an *over-release* (revoking blob URLs still in
|
||||
use), root-caused in [ROOT_CAUSE_ANALYSIS.md](ROOT_CAUSE_ANALYSIS.md).
|
||||
|
||||
## Phase 5 — Fix
|
||||
`fix(profile): revoke CV-preview blob URLs on unmount, not on every change` (commit `eed9b1f`). Smallest
|
||||
change: track the carousel in a ref and revoke only on unmount.
|
||||
|
||||
## Phase 6 — Verification
|
||||
`profile-page.test.tsx` passes **5/5** with an adequate test timeout after the fix. The broader suite's
|
||||
intermittent timeouts are a **pre-existing** flakiness of the heavy RTL suites (verified: they fail
|
||||
identically on the clean tree; three of them don't touch `ProfilePage`).
|
||||
|
||||
## Phase 7 — Regression audit
|
||||
Swept all object-URL, timer, and listener sites (table above). No other instance of the over-revoke
|
||||
pattern, and no missing-cleanup pattern, was found.
|
||||
|
||||
## Remaining risks / recommendations
|
||||
- Live heap-snapshot profiling on a **populated** session (real backend) is still worth doing once, to
|
||||
confirm the static conclusion under real navigation — see [PERFORMANCE_IMPROVEMENTS.md](PERFORMANCE_IMPROVEMENTS.md).
|
||||
- Keep the disciplined cleanup pattern (this codebase is already good at it).
|
||||
|
||||
## Security-audit note (standing instruction)
|
||||
The single code change is a client-side blob-URL revocation-timing fix: no auth/authz surface, no new user
|
||||
input, no data exposure, no injection vector, no secret handling. Nothing for the security lens to flag.
|
||||
Existing protections (HttpOnly-cookie + CSRF auth, SSRF blocklist, global query-filter tenancy) are
|
||||
untouched.
|
||||
@@ -0,0 +1,72 @@
|
||||
# Performance Improvements — Job Tracker
|
||||
|
||||
**Companion to:** [MEMORY_LEAK_REPORT.md](MEMORY_LEAK_REPORT.md) · [ROOT_CAUSE_ANALYSIS.md](ROOT_CAUSE_ANALYSIS.md)
|
||||
|
||||
## Changes made (this pass)
|
||||
| Change | File | Effect | Verified |
|
||||
|---|---|---|---|
|
||||
| **Stop an infinite render loop on every list view** — hold `load` in a ref in `useViewResource` so `reload`/the fetch effect keep a stable identity | `job-tracker-ui/src/hooks/useViewResource.ts` | Fixes "Maximum update depth exceeded" on `/jobs` (and any `DashboardView`/`RemindersView`/`CompaniesTable` view whose caller passes an inline `load`) — pegged the CPU/renderer | **Runtime-confirmed**: `/jobs` went from a render storm (renderer frozen, 100s of errors) to 0 console errors in a live 2s window and a clean render; `workflow-trust-signals` (drives `JobTable`→`useViewResource`) passes |
|
||||
| **Stop the infinite `/auth/me` request loop** — make `clearAuthClientState` emit `auth-changed` only on a real signed-in→out transition | `job-tracker-ui/src/auth.ts` | Eliminates a runaway request storm (100+ `GET /auth/me` and climbing) that ran continuously whenever the user was logged out | **Runtime-confirmed** in a live stack: `/auth/me` count 100+ & growing → 0 and stable after fix |
|
||||
| Revoke CV-preview blob URLs on unmount only (ref-based), not on every carousel change | `job-tracker-ui/src/pages/ProfilePage.tsx` | Fixes broken previews on multi-template decks; still frees URLs on unmount | `profile-page.test.tsx` 5/5 |
|
||||
|
||||
### Runtime finding — self-triggering auth loop (the most impactful issue found)
|
||||
Only visible with a running backend (static analysis could not surface it). Sequence: the axios response
|
||||
interceptor (`api.ts`) calls `clearAuthClientState()` on **every** 401; that dispatched `"auth-changed"`;
|
||||
the `App` handler re-fetched `/auth/me`; that 401'd again → interceptor → `clearAuthClientState()` →
|
||||
`"auth-changed"` → … an unbounded loop that hammered the server and spun the client on the login page and
|
||||
after any session expiry. Fix: `clearAuthClientState` now only emits when it actually removes a stored user
|
||||
key (idempotent), so repeated 401s can't re-trigger the fetch. This is a CPU/network/battery drain and a
|
||||
self-inflicted request flood, not a memory leak — but squarely in the Phase 3.5 "infinite polling / retry
|
||||
loop / duplicate requests" scope, and the single highest-value fix from the whole investigation.
|
||||
|
||||
> Context: this was the only defect found in a full resource audit. The codebase already practises
|
||||
> disciplined cleanup (timers cleared, listeners removed, object URLs revoked), so there was no leak to
|
||||
> fix — see the main report.
|
||||
|
||||
## Recommended (low-severity, optional)
|
||||
|
||||
### 1. Stabilise the extraction-run poll — *minor*
|
||||
`ProfilePage.tsx:315-324` recreates its 4s interval on every poll because `extractionRuns` is in the deps
|
||||
and changes each tick. It's harmless (cleanup runs; it stops when runs finish) but churns. If touched:
|
||||
poll on a stable trigger (e.g. a boolean `hasActiveRuns` in deps, or read runs from a ref inside the
|
||||
interval) so the interval is created once per active-window.
|
||||
|
||||
### 2. One live heap-snapshot pass on a populated session — *verification, not a fix*
|
||||
The static audit is strong, but a single DevTools confirmation closes the loop:
|
||||
1. Run the real stack (backend on `:5202` + a seeded DB) and sign in.
|
||||
2. DevTools → Memory → take a heap snapshot.
|
||||
3. Navigate `/dashboard → /jobs → open a job dialog → close → /profile → build a CV deck → back`, ×5.
|
||||
4. Force GC, take a second snapshot, **Comparison** view.
|
||||
5. Expect: node/listener/detached counts return to baseline (sawtooth), not monotonic growth. Sort
|
||||
retained size by constructor; look for `Detached HTMLElement`, growing `Array`/`Map`, or listener
|
||||
counts that never fall.
|
||||
|
||||
Also cheap and useful: `performance.memory.usedJSHeapSize` (Chromium) logged across the loop, or a
|
||||
Playwright script that repeats the navigation and asserts heap stays bounded.
|
||||
|
||||
### 3. Guard async setState after unmount — *defensive, not a current leak*
|
||||
Several components `await api…().then(setState)`. React 18 no-ops setState on unmounted components (just a
|
||||
dev warning historically), so this is not a leak, but for long CV/AI calls consider an `AbortController`
|
||||
on the request (cancels the in-flight network work on unmount) — improves responsiveness and avoids wasted
|
||||
work more than memory.
|
||||
|
||||
## Prevention — keep leaks from creeping in
|
||||
- **Lint:** enable `react-hooks/exhaustive-deps` (surfaces the exact wrong-deps class that caused the one
|
||||
bug here) and consider `react-hooks/react-compiler` checks.
|
||||
- **Rule of thumb:** any effect that *acquires* a resource (listener, timer, object URL, observer,
|
||||
subscription, connection) must return a cleanup that releases exactly that resource. "Release once on
|
||||
unmount" ⇒ empty-deps effect + a ref for current state — never a value in the deps array.
|
||||
- **Object URLs:** pair every `createObjectURL` with a `revokeObjectURL` in the *same* owner; prefer
|
||||
revoking on unmount/replace, never on unrelated re-renders.
|
||||
- **Server caches:** every `IMemoryCache.Set` must carry an absolute/sliding expiration (as
|
||||
`GmailOAuthService` correctly does); if the app grows to heavy caching, set a `SizeLimit`.
|
||||
- **No unbounded static state:** keep `static` collections to fixed lookup tables (as today); never
|
||||
accumulate per-request data in a static field.
|
||||
- **CI:** the heavy RTL suites are timeout-flaky under load — raising `testTimeout` (e.g. 15–20s) or
|
||||
reducing jest worker contention would make regressions (including any future leak-guard tests) reliably
|
||||
visible instead of hidden behind flakes.
|
||||
|
||||
## Security-audit note (standing instruction)
|
||||
The applied change carries no security surface (client-side URL lifetime only). The recommendations above
|
||||
introduce none either; if #3 (AbortController) is implemented, ensure aborted requests don't leave
|
||||
partial writes — not applicable to the read-only CV export/preview calls here.
|
||||
@@ -0,0 +1,67 @@
|
||||
# Root Cause Analysis — Job Tracker resource audit
|
||||
|
||||
**Companion to:** [MEMORY_LEAK_REPORT.md](MEMORY_LEAK_REPORT.md)
|
||||
|
||||
## Summary
|
||||
There is **no memory leak** to root-cause. The investigation surfaced exactly one defect — an
|
||||
**over-eager blob-URL revocation** in the CV PDF carousel — which is a *release-too-early* bug, the
|
||||
inverse of a leak. This document root-causes that defect and explains why the "app memory grows" symptom
|
||||
does **not** indicate a leak here.
|
||||
|
||||
## The one defect — over-revoked preview URLs
|
||||
|
||||
### What the code did (before)
|
||||
`job-tracker-ui/src/pages/ProfilePage.tsx`:
|
||||
```ts
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
pdfCarousel.forEach((item) => item.pdfUrl && URL.revokeObjectURL(item.pdfUrl));
|
||||
};
|
||||
}, [pdfCarousel]); // <-- deps on pdfCarousel
|
||||
```
|
||||
A cleanup with `[pdfCarousel]` deps runs its teardown **before every re-run**, i.e. on *every* change to
|
||||
`pdfCarousel`, not just on unmount.
|
||||
|
||||
### Why it broke
|
||||
`buildPdfCarousel()` seeds all templates, then `savePdfToCarousel()` replaces each seed **in place**, one
|
||||
`setPdfCarousel` call at a time (`ProfilePage.tsx:400-410`). Trace with templates A, B, C:
|
||||
|
||||
1. `[A₁, B₀, C₀]` (A built, B/C seeds without URLs) — cleanup revoked prior `[A₀,B₀,C₀]` (no URLs). OK.
|
||||
2. `[A₁, B₁, C₀]` (B built) — cleanup runs on the **previous** array `[A₁,B₀,C₀]` → **revokes `A₁`'s URL**,
|
||||
but `A₁` is still present in the new array and still shown when the user flips the carousel to A.
|
||||
3. `[A₁, B₁, C₁]` (C built) — cleanup revokes `[A₁,B₁,C₀]` → revokes `B₁` too.
|
||||
|
||||
**Result:** after building an N-template deck, every preview except the **last** points at a revoked
|
||||
(broken) blob URL.
|
||||
|
||||
### Root cause
|
||||
Wrong effect dependency scope: a resource that should be released **once, on unmount** was tied to a
|
||||
value-change dependency, so React's "cleanup-before-next-run" semantics turned it into a per-change
|
||||
revoke. Compounded by the fact that legitimate drop paths already revoke explicitly
|
||||
(`savePdfToCarousel` replace at `:402-403`, `resetPdfCarousel` clear at `:378-384`), making the effect's
|
||||
revocation redundant *and* destructive.
|
||||
|
||||
### Why it is not a leak
|
||||
On unmount the effect *did* revoke the current array (deps capture the latest value), so URLs were freed.
|
||||
The bug wastes nothing and retains nothing — it releases too **eagerly**. It is a correctness bug
|
||||
(broken previews), filed here because Phase 3.5 explicitly covers "image/media resources … released".
|
||||
|
||||
### Fix (commit `eed9b1f`)
|
||||
Track the carousel in a ref; revoke **only on unmount** (empty-deps effect). Drop paths keep their
|
||||
explicit revokes. Verified: `profile-page.test.tsx` 5/5.
|
||||
|
||||
## Why the "memory grows" symptom is not a leak here
|
||||
Per the mission's Final Rule, distinguishing the four causes:
|
||||
- **Expected caching** — MUI emotion style cache, `react-scripts` dev tooling, and route component state
|
||||
grow then plateau; not unbounded.
|
||||
- **Delayed GC** — detached nodes from closed dialogs/pages are collected on the next major GC, not
|
||||
instantly; a rising sawtooth is normal.
|
||||
- **Browser behaviour** — bfcache, image decode buffers, and devtools retention inflate numbers in a way
|
||||
unrelated to app code.
|
||||
- **Genuine leak** — would require a retained root (listener, timer, global ref, live connection). None
|
||||
exists in this codebase (see the vector table in the main report).
|
||||
|
||||
## Contributing (non-defect) observations
|
||||
- **Extraction-poll churn** (`ProfilePage.tsx:315-324`): interval recreated every 4s while a run is
|
||||
active because `extractionRuns` is in the deps and mutates each poll. Harmless; optionally stabilise
|
||||
(see improvements doc).
|
||||
@@ -1,25 +1,40 @@
|
||||
{
|
||||
"short_name": "JobTrack",
|
||||
"name": "JobTrack — Job Application Tracker",
|
||||
"icons": [
|
||||
{
|
||||
"src": "favicon.ico",
|
||||
"sizes": "64x64 32x32 24x24 16x16",
|
||||
"type": "image/x-icon"
|
||||
},
|
||||
{
|
||||
"src": "logo192.png",
|
||||
"type": "image/png",
|
||||
"sizes": "192x192"
|
||||
},
|
||||
{
|
||||
"src": "logo512.png",
|
||||
"type": "image/png",
|
||||
"sizes": "512x512"
|
||||
}
|
||||
],
|
||||
"start_url": ".",
|
||||
"display": "standalone",
|
||||
"theme_color": "#0b1224",
|
||||
"background_color": "#0b1224"
|
||||
"short_name": "Jobbjakt",
|
||||
"name": "Jobbjakt — Job Application Tracker",
|
||||
"description": "Track and manage your job applications, tailor CVs, and stay on top of follow-ups.",
|
||||
"id": "/",
|
||||
"scope": "/",
|
||||
"start_url": ".",
|
||||
"display": "standalone",
|
||||
"orientation": "portrait-primary",
|
||||
"categories": ["productivity", "business"],
|
||||
"theme_color": "#15803d",
|
||||
"background_color": "#0b1224",
|
||||
"icons": [
|
||||
{
|
||||
"src": "favicon.ico",
|
||||
"sizes": "64x64 32x32 24x24 16x16",
|
||||
"type": "image/x-icon"
|
||||
},
|
||||
{
|
||||
"src": "logo192.png",
|
||||
"type": "image/png",
|
||||
"sizes": "192x192",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "logo512.png",
|
||||
"type": "image/png",
|
||||
"sizes": "512x512",
|
||||
"purpose": "any maskable"
|
||||
}
|
||||
],
|
||||
"share_target": {
|
||||
"action": "/",
|
||||
"method": "GET",
|
||||
"params": {
|
||||
"url": "add",
|
||||
"text": "addtext"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
<svg width="1440" height="900" viewBox="0 0 1440 900" xmlns="http://www.w3.org/2000/svg" font-family="Inter, 'Segoe UI', system-ui, sans-serif">
|
||||
<defs>
|
||||
<linearGradient id="side" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#0f172a"/><stop offset="1" stop-color="#111a33"/></linearGradient>
|
||||
<linearGradient id="ac" x1="0" y1="0" x2="1" y2="0"><stop offset="0" stop-color="#6366f1"/><stop offset="1" stop-color="#22d3ee"/></linearGradient>
|
||||
<filter id="sh" x="-20%" y="-20%" width="140%" height="140%"><feDropShadow dx="0" dy="8" stdDeviation="14" flood-color="#0f172a" flood-opacity="0.10"/></filter>
|
||||
</defs>
|
||||
<rect width="1440" height="900" fill="#f4f6fb"/>
|
||||
|
||||
<!-- sidebar -->
|
||||
<rect width="248" height="900" fill="url(#side)"/>
|
||||
<g transform="translate(28,40)">
|
||||
<rect x="0" y="0" width="32" height="32" rx="8" fill="url(#ac)"/><path d="M8 16 l5 5 l10 -11" stroke="#0b1020" stroke-width="3.2" fill="none" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<text x="44" y="22" fill="#fff" font-size="20" font-weight="700">JobTrack</text>
|
||||
</g>
|
||||
<g transform="translate(20,120)" font-size="15">
|
||||
<rect x="0" y="0" width="208" height="44" rx="10" fill="#6366f1" opacity="0.18"/><rect x="18" y="16" width="12" height="12" rx="3" fill="#a5b4fc"/><text x="42" y="28" fill="#c7d2fe" font-weight="600">Dashboard</text>
|
||||
<text x="20" y="88" fill="#94a3b8">Applications</text>
|
||||
<text x="20" y="140" fill="#94a3b8">Pipeline</text>
|
||||
<text x="20" y="192" fill="#94a3b8">Reminders</text>
|
||||
<text x="20" y="244" fill="#94a3b8">Correspondence</text>
|
||||
<text x="20" y="296" fill="#94a3b8">Companies</text>
|
||||
<text x="20" y="348" fill="#94a3b8">Profile & CV</text>
|
||||
</g>
|
||||
<g transform="translate(20,820)"><rect width="208" height="52" rx="10" fill="#ffffff" opacity="0.06"/><circle cx="30" cy="26" r="15" fill="#6366f1"/><text x="30" y="31" fill="#fff" font-size="13" text-anchor="middle" font-weight="700">DC</text><text x="56" y="23" fill="#e2e8f0" font-size="13">dj@cesnimda.co.uk</text><text x="56" y="40" fill="#64748b" font-size="11">Personal workspace</text></g>
|
||||
|
||||
<!-- header -->
|
||||
<g transform="translate(288,44)">
|
||||
<text x="0" y="26" fill="#0f172a" font-size="28" font-weight="800">Dashboard</text>
|
||||
<text x="0" y="52" fill="#64748b" font-size="15">Your job search at a glance — 34 active applications</text>
|
||||
<rect x="740" y="6" width="180" height="42" rx="10" fill="#fff" filter="url(#sh)"/><text x="762" y="32" fill="#64748b" font-size="14">Last 30 days ▾</text>
|
||||
<rect x="936" y="6" width="168" height="42" rx="10" fill="#0f172a"/><text x="1020" y="32" fill="#fff" font-size="14" text-anchor="middle" font-weight="600">+ Add job</text>
|
||||
</g>
|
||||
|
||||
<!-- KPI row -->
|
||||
<g transform="translate(288,120)">
|
||||
<g filter="url(#sh)"><rect width="266" height="112" rx="14" fill="#fff"/><text x="22" y="36" fill="#64748b" font-size="14">Active applications</text><text x="22" y="80" fill="#0f172a" font-size="38" font-weight="800">34</text><text x="150" y="80" fill="#16a34a" font-size="14">▲ 8 this week</text></g>
|
||||
<g transform="translate(282,0)" filter="url(#sh)"><rect width="266" height="112" rx="14" fill="#fff"/><text x="22" y="36" fill="#64748b" font-size="14">Response rate</text><text x="22" y="80" fill="#0f172a" font-size="38" font-weight="800">28%</text><text x="150" y="80" fill="#16a34a" font-size="14">▲ 4%</text></g>
|
||||
<g transform="translate(564,0)" filter="url(#sh)"><rect width="266" height="112" rx="14" fill="#fff"/><text x="22" y="36" fill="#64748b" font-size="14">Interviews</text><text x="22" y="80" fill="#0f172a" font-size="38" font-weight="800">6</text><text x="150" y="80" fill="#64748b" font-size="14">2 upcoming</text></g>
|
||||
<g transform="translate(846,0)" filter="url(#sh)"><rect width="270" height="112" rx="14" fill="#fff"/><text x="22" y="36" fill="#64748b" font-size="14">Needs follow-up</text><text x="22" y="80" fill="#dc2626" font-size="38" font-weight="800">3</text><text x="150" y="80" fill="#dc2626" font-size="14">overdue</text></g>
|
||||
</g>
|
||||
|
||||
<!-- response trend chart -->
|
||||
<g transform="translate(288,256)" filter="url(#sh)">
|
||||
<rect width="700" height="300" rx="14" fill="#fff"/>
|
||||
<text x="24" y="40" fill="#0f172a" font-size="17" font-weight="700">Applications & responses</text>
|
||||
<g stroke="#eef2f7" stroke-width="1">
|
||||
<line x1="24" y1="90" x2="676" y2="90"/><line x1="24" y1="150" x2="676" y2="150"/><line x1="24" y1="210" x2="676" y2="210"/><line x1="24" y1="255" x2="676" y2="255"/>
|
||||
</g>
|
||||
<polyline points="40,240 140,200 240,210 340,150 440,160 540,110 640,120" fill="none" stroke="#6366f1" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<polyline points="40,252 140,244 240,238 340,224 440,214 540,196 640,182" fill="none" stroke="#22c55e" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<g fill="#6366f1"><circle cx="340" cy="150" r="4.5"/><circle cx="540" cy="110" r="4.5"/></g>
|
||||
<g font-size="12" fill="#94a3b8"><text x="34" y="278">Wk1</text><text x="134" y="278">Wk2</text><text x="234" y="278">Wk3</text><text x="334" y="278">Wk4</text><text x="434" y="278">Wk5</text><text x="534" y="278">Wk6</text><text x="628" y="278">Wk7</text></g>
|
||||
<g font-size="12"><rect x="500" y="16" width="12" height="12" rx="3" fill="#6366f1"/><text x="518" y="26" fill="#64748b">Applied</text><rect x="590" y="16" width="12" height="12" rx="3" fill="#22c55e"/><text x="608" y="26" fill="#64748b">Responses</text></g>
|
||||
</g>
|
||||
|
||||
<!-- time in stage -->
|
||||
<g transform="translate(1004,256)" filter="url(#sh)">
|
||||
<rect width="404" height="300" rx="14" fill="#fff"/>
|
||||
<text x="24" y="40" fill="#0f172a" font-size="17" font-weight="700">Median time in stage</text>
|
||||
<g font-size="13" fill="#334155">
|
||||
<text x="24" y="82">Applied → Waiting</text><rect x="24" y="92" width="356" height="10" rx="5" fill="#eef2f7"/><rect x="24" y="92" width="120" height="10" rx="5" fill="#6366f1"/><text x="352" y="86" fill="#64748b">2d</text>
|
||||
<text x="24" y="132">Waiting → Interview</text><rect x="24" y="142" width="356" height="10" rx="5" fill="#eef2f7"/><rect x="24" y="142" width="300" height="10" rx="5" fill="#818cf8"/><text x="348" y="136" fill="#64748b">9d</text>
|
||||
<text x="24" y="182">Interview → Offer</text><rect x="24" y="192" width="356" height="10" rx="5" fill="#eef2f7"/><rect x="24" y="192" width="200" height="10" rx="5" fill="#22d3ee"/><text x="348" y="186" fill="#64748b">6d</text>
|
||||
</g>
|
||||
<text x="24" y="240" fill="#0f172a" font-size="15" font-weight="700">Top skill demand</text>
|
||||
<g font-size="12"><rect x="24" y="252" width="70" height="26" rx="13" fill="#eef2ff"/><text x="59" y="269" fill="#4338ca" text-anchor="middle">React ·18</text>
|
||||
<rect x="102" y="252" width="70" height="26" rx="13" fill="#eef2ff"/><text x="137" y="269" fill="#4338ca" text-anchor="middle">Azure ·12</text>
|
||||
<rect x="180" y="252" width="86" height="26" rx="13" fill="#eef2ff"/><text x="223" y="269" fill="#4338ca" text-anchor="middle">Docker ·10</text>
|
||||
<rect x="274" y="252" width="64" height="26" rx="13" fill="#eef2ff"/><text x="306" y="269" fill="#4338ca" text-anchor="middle">SQL ·9</text></g>
|
||||
</g>
|
||||
|
||||
<!-- reminders strip -->
|
||||
<g transform="translate(288,580)" filter="url(#sh)">
|
||||
<rect width="1120" height="278" rx="14" fill="#fff"/>
|
||||
<text x="24" y="40" fill="#0f172a" font-size="17" font-weight="700">Needs your attention</text>
|
||||
<g transform="translate(24,60)">
|
||||
<g><rect width="1072" height="60" rx="10" fill="#fef2f2"/><circle cx="28" cy="30" r="6" fill="#dc2626"/><text x="52" y="27" fill="#0f172a" font-size="15" font-weight="600">Senior Frontend Engineer · Vercel</text><text x="52" y="46" fill="#64748b" font-size="13">No reply in 9 days — follow-up drafted from your last thread</text><rect x="900" y="16" width="150" height="30" rx="8" fill="#0f172a"/><text x="975" y="36" fill="#fff" font-size="13" text-anchor="middle">Review draft</text></g>
|
||||
<g transform="translate(0,72)"><rect width="1072" height="60" rx="10" fill="#fffbeb"/><circle cx="28" cy="30" r="6" fill="#f59e0b"/><text x="52" y="27" fill="#0f172a" font-size="15" font-weight="600">Platform Engineer · Finn.no</text><text x="52" y="46" fill="#64748b" font-size="13">Interview tomorrow 14:00 — prep pack ready</text><rect x="900" y="16" width="150" height="30" rx="8" fill="#eef2ff"/><text x="975" y="36" fill="#4338ca" font-size="13" text-anchor="middle">Open workspace</text></g>
|
||||
<g transform="translate(0,144)"><rect width="1072" height="60" rx="10" fill="#f0fdf4"/><circle cx="28" cy="30" r="6" fill="#22c55e"/><text x="52" y="27" fill="#0f172a" font-size="15" font-weight="600">Backend Developer · NAV</text><text x="52" y="46" fill="#64748b" font-size="13">Offer received — compare against saved package</text><rect x="900" y="16" width="150" height="30" rx="8" fill="#dcfce7"/><text x="975" y="36" fill="#166534" font-size="13" text-anchor="middle">View offer</text></g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 8.8 KiB |
@@ -0,0 +1,55 @@
|
||||
<svg width="1440" height="900" viewBox="0 0 1440 900" xmlns="http://www.w3.org/2000/svg" font-family="Inter, 'Segoe UI', system-ui, sans-serif">
|
||||
<defs>
|
||||
<linearGradient id="ac" x1="0" y1="0" x2="1" y2="0"><stop offset="0" stop-color="#6366f1"/><stop offset="1" stop-color="#22d3ee"/></linearGradient>
|
||||
<filter id="c" x="-30%" y="-30%" width="160%" height="160%"><feDropShadow dx="0" dy="4" stdDeviation="8" flood-color="#1e293b" flood-opacity="0.10"/></filter>
|
||||
</defs>
|
||||
<rect width="1440" height="900" fill="#f4f6fb"/>
|
||||
|
||||
<!-- header -->
|
||||
<g transform="translate(48,44)">
|
||||
<rect x="0" y="0" width="30" height="30" rx="8" fill="url(#ac)"/><path d="M8 15 l4 4 l10 -10" stroke="#0b1020" stroke-width="3" fill="none" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<text x="40" y="22" fill="#0f172a" font-size="22" font-weight="800">Pipeline</text>
|
||||
<text x="150" y="22" fill="#94a3b8" font-size="15">Drag cards to move a job between stages</text>
|
||||
<rect x="1150" y="-4" width="192" height="40" rx="10" fill="#fff" filter="url(#c)"/><text x="1170" y="21" fill="#64748b" font-size="14">Search & filter…</text>
|
||||
</g>
|
||||
|
||||
<!-- columns -->
|
||||
<g transform="translate(48,110)">
|
||||
<!-- column template values -->
|
||||
<!-- Applied -->
|
||||
<g transform="translate(0,0)">
|
||||
<rect width="256" height="740" rx="14" fill="#eef2f7"/>
|
||||
<circle cx="24" cy="30" r="6" fill="#6366f1"/><text x="40" y="35" fill="#0f172a" font-size="15" font-weight="700">Applied</text><text x="220" y="35" fill="#94a3b8" font-size="14" text-anchor="end">12</text>
|
||||
<g transform="translate(14,52)" filter="url(#c)"><rect width="228" height="112" rx="12" fill="#fff"/><rect width="4" height="112" rx="2" fill="#6366f1"/><text x="18" y="30" fill="#0f172a" font-size="15" font-weight="700">Frontend Engineer</text><text x="18" y="52" fill="#64748b" font-size="13">Vercel · Remote</text><rect x="18" y="66" width="66" height="22" rx="11" fill="#eef2ff"/><text x="51" y="81" fill="#4338ca" font-size="11" text-anchor="middle">React</text><rect x="90" y="66" width="60" height="22" rx="11" fill="#eef2ff"/><text x="120" y="81" fill="#4338ca" font-size="11" text-anchor="middle">TS</text><text x="18" y="104" fill="#94a3b8" font-size="12">Applied 3d ago</text></g>
|
||||
<g transform="translate(14,176)" filter="url(#c)"><rect width="228" height="96" rx="12" fill="#fff"/><rect width="4" height="96" rx="2" fill="#6366f1"/><text x="18" y="30" fill="#0f172a" font-size="15" font-weight="700">Product Engineer</text><text x="18" y="52" fill="#64748b" font-size="13">Linear · Remote</text><text x="18" y="80" fill="#94a3b8" font-size="12">Applied 5d ago · CV 81%</text></g>
|
||||
<g transform="translate(14,284)" filter="url(#c)"><rect width="228" height="96" rx="12" fill="#fff"/><rect width="4" height="96" rx="2" fill="#6366f1"/><text x="18" y="30" fill="#0f172a" font-size="15" font-weight="700">Fullstack Dev</text><text x="18" y="52" fill="#64748b" font-size="13">Cognite · Oslo</text><text x="18" y="80" fill="#94a3b8" font-size="12">Applied 1w ago</text></g>
|
||||
</g>
|
||||
<!-- Waiting -->
|
||||
<g transform="translate(276,0)">
|
||||
<rect width="256" height="740" rx="14" fill="#eef2f7"/>
|
||||
<circle cx="24" cy="30" r="6" fill="#818cf8"/><text x="40" y="35" fill="#0f172a" font-size="15" font-weight="700">Waiting</text><text x="220" y="35" fill="#94a3b8" font-size="14" text-anchor="end">9</text>
|
||||
<g transform="translate(14,52)" filter="url(#c)"><rect width="228" height="112" rx="12" fill="#fff"/><rect width="4" height="112" rx="2" fill="#818cf8"/><text x="18" y="30" fill="#0f172a" font-size="15" font-weight="700">Platform Engineer</text><text x="18" y="52" fill="#64748b" font-size="13">Finn.no · Oslo</text><rect x="18" y="66" width="150" height="22" rx="11" fill="#fef9c3"/><text x="24" y="81" fill="#854d0e" font-size="11">⏳ Reply due in 2d</text><text x="18" y="104" fill="#94a3b8" font-size="12">Emailed 5d ago</text></g>
|
||||
<g transform="translate(14,176)" filter="url(#c)"><rect width="228" height="96" rx="12" fill="#fff"/><rect width="4" height="96" rx="2" fill="#f59e0b"/><text x="18" y="30" fill="#0f172a" font-size="15" font-weight="700">DevOps Engineer</text><text x="18" y="52" fill="#64748b" font-size="13">Kahoot! · Oslo</text><rect x="18" y="66" width="120" height="22" rx="11" fill="#fee2e2"/><text x="24" y="81" fill="#991b1b" font-size="11">⚠ Follow up now</text></g>
|
||||
</g>
|
||||
<!-- Interview -->
|
||||
<g transform="translate(552,0)">
|
||||
<rect width="256" height="740" rx="14" fill="#eef2f7"/>
|
||||
<circle cx="24" cy="30" r="6" fill="#22d3ee"/><text x="40" y="35" fill="#0f172a" font-size="15" font-weight="700">Interview</text><text x="220" y="35" fill="#94a3b8" font-size="14" text-anchor="end">4</text>
|
||||
<g transform="translate(14,52)" filter="url(#c)"><rect width="228" height="128" rx="12" fill="#fff"/><rect width="4" height="128" rx="2" fill="#22d3ee"/><text x="18" y="30" fill="#0f172a" font-size="15" font-weight="700">Sr. Backend Dev</text><text x="18" y="52" fill="#64748b" font-size="13">NAV · Oslo</text><rect x="18" y="66" width="180" height="22" rx="11" fill="#cffafe"/><text x="24" y="81" fill="#155e75" font-size="11">📅 Tomorrow 14:00</text><rect x="18" y="94" width="90" height="22" rx="11" fill="#f0fdf4"/><text x="24" y="109" fill="#166534" font-size="11">Prep ready</text></g>
|
||||
<g transform="translate(14,192)" filter="url(#c)"><rect width="228" height="96" rx="12" fill="#fff"/><rect width="4" height="96" rx="2" fill="#22d3ee"/><text x="18" y="30" fill="#0f172a" font-size="15" font-weight="700">Cloud Engineer</text><text x="18" y="52" fill="#64748b" font-size="13">Visma · Bergen</text><text x="18" y="80" fill="#94a3b8" font-size="12">Round 2 scheduled</text></g>
|
||||
</g>
|
||||
<!-- Offer -->
|
||||
<g transform="translate(828,0)">
|
||||
<rect width="256" height="740" rx="14" fill="#eef2f7"/>
|
||||
<circle cx="24" cy="30" r="6" fill="#22c55e"/><text x="40" y="35" fill="#0f172a" font-size="15" font-weight="700">Offer</text><text x="220" y="35" fill="#94a3b8" font-size="14" text-anchor="end">2</text>
|
||||
<g transform="translate(14,52)" filter="url(#c)"><rect width="228" height="112" rx="12" fill="#fff"/><rect width="4" height="112" rx="2" fill="#22c55e"/><text x="18" y="30" fill="#0f172a" font-size="15" font-weight="700">Backend Developer</text><text x="18" y="52" fill="#64748b" font-size="13">NAV · Oslo</text><rect x="18" y="66" width="150" height="22" rx="11" fill="#dcfce7"/><text x="24" y="81" fill="#166534" font-size="11">🎉 720k NOK / yr</text><text x="18" y="104" fill="#94a3b8" font-size="12">Respond by Fri</text></g>
|
||||
</g>
|
||||
<!-- Closed -->
|
||||
<g transform="translate(1104,0)">
|
||||
<rect width="240" height="740" rx="14" fill="#eef2f7"/>
|
||||
<circle cx="24" cy="30" r="6" fill="#94a3b8"/><text x="40" y="35" fill="#0f172a" font-size="15" font-weight="700">Rejected / Ghosted</text><text x="216" y="35" fill="#94a3b8" font-size="14" text-anchor="end">7</text>
|
||||
<g transform="translate(14,52)" filter="url(#c)"><rect width="212" height="88" rx="12" fill="#fff" opacity="0.75"/><rect width="4" height="88" rx="2" fill="#94a3b8"/><text x="18" y="30" fill="#475569" font-size="15" font-weight="700">Data Engineer</text><text x="18" y="52" fill="#94a3b8" font-size="13">Spotify · Remote</text><text x="18" y="74" fill="#cbd5e1" font-size="12">Rejected · logged</text></g>
|
||||
<g transform="translate(14,152)" filter="url(#c)"><rect width="212" height="88" rx="12" fill="#fff" opacity="0.75"/><rect width="4" height="88" rx="2" fill="#cbd5e1"/><text x="18" y="30" fill="#475569" font-size="15" font-weight="700">iOS Engineer</text><text x="18" y="52" fill="#94a3b8" font-size="13">Tise · Oslo</text><text x="18" y="74" fill="#cbd5e1" font-size="12">Ghosted · 30d</text></g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 7.7 KiB |
@@ -0,0 +1,102 @@
|
||||
<svg width="1440" height="900" viewBox="0 0 1440 900" xmlns="http://www.w3.org/2000/svg" font-family="Inter, 'Segoe UI', system-ui, sans-serif">
|
||||
<defs>
|
||||
<linearGradient id="ac" x1="0" y1="0" x2="1" y2="0"><stop offset="0" stop-color="#6366f1"/><stop offset="1" stop-color="#22d3ee"/></linearGradient>
|
||||
<filter id="c" x="-30%" y="-30%" width="160%" height="160%"><feDropShadow dx="0" dy="6" stdDeviation="12" flood-color="#1e293b" flood-opacity="0.10"/></filter>
|
||||
</defs>
|
||||
<rect width="1440" height="900" fill="#f4f6fb"/>
|
||||
|
||||
<!-- top bar -->
|
||||
<g transform="translate(48,40)">
|
||||
<text x="0" y="16" fill="#94a3b8" font-size="14">Pipeline / Waiting /</text>
|
||||
<text x="0" y="52" fill="#0f172a" font-size="30" font-weight="800">Platform Engineer</text>
|
||||
<text x="330" y="52" fill="#64748b" font-size="18">· Finn.no</text>
|
||||
<rect x="0" y="70" width="112" height="30" rx="15" fill="#fef9c3"/><text x="56" y="90" fill="#854d0e" font-size="13" text-anchor="middle">● Waiting</text>
|
||||
<text x="128" y="90" fill="#94a3b8" font-size="14">Oslo · 780–920k NOK · Applied 5 days ago</text>
|
||||
</g>
|
||||
|
||||
<!-- tabs -->
|
||||
<g transform="translate(48,148)" font-size="14">
|
||||
<text x="0" y="0" fill="#4338ca" font-weight="700">Overview</text><rect x="-4" y="10" width="66" height="3" rx="2" fill="#6366f1"/>
|
||||
<text x="90" y="0" fill="#64748b">Correspondence</text>
|
||||
<text x="230" y="0" fill="#64748b">Attachments</text>
|
||||
<text x="352" y="0" fill="#64748b">Candidate Fit</text>
|
||||
<text x="470" y="0" fill="#64748b">Timeline</text>
|
||||
</g>
|
||||
|
||||
<!-- left: summary + description -->
|
||||
<g transform="translate(48,180)" filter="url(#c)">
|
||||
<rect width="600" height="300" rx="14" fill="#fff"/>
|
||||
<text x="24" y="40" fill="#0f172a" font-size="17" font-weight="700">AI summary</text>
|
||||
<rect x="480" y="24" width="96" height="26" rx="13" fill="#eef2ff"/><text x="528" y="41" fill="#4338ca" font-size="12" text-anchor="middle">↻ regenerate</text>
|
||||
<text x="24" y="72" fill="#475569" font-size="14">Platform team building internal developer tooling on Kubernetes.</text>
|
||||
<text x="24" y="96" fill="#475569" font-size="14">Owns CI/CD, observability, and cloud cost. Strong Go + Terraform</text>
|
||||
<text x="24" y="120" fill="#475569" font-size="14">focus; hybrid, 2 days in Oslo office.</text>
|
||||
<line x1="24" y1="146" x2="576" y2="146" stroke="#eef2f7"/>
|
||||
<text x="24" y="178" fill="#0f172a" font-size="15" font-weight="700">Next action</text>
|
||||
<rect x="24" y="192" width="552" height="52" rx="10" fill="#fff7ed"/><circle cx="46" cy="218" r="6" fill="#f59e0b"/>
|
||||
<text x="66" y="214" fill="#0f172a" font-size="14" font-weight="600">Follow up on application status</text>
|
||||
<text x="66" y="234" fill="#94a3b8" font-size="12">Due in 2 days · draft prepared from your last email</text>
|
||||
<rect x="452" y="202" width="112" height="32" rx="8" fill="#0f172a"/><text x="508" y="223" fill="#fff" font-size="13" text-anchor="middle">Review draft</text>
|
||||
<text x="24" y="278" fill="#64748b" font-size="13">Skills detected: Go · Kubernetes · Terraform · AWS · CI/CD</text>
|
||||
</g>
|
||||
|
||||
<!-- left lower: correspondence -->
|
||||
<g transform="translate(48,500)" filter="url(#c)">
|
||||
<rect width="600" height="358" rx="14" fill="#fff"/>
|
||||
<text x="24" y="40" fill="#0f172a" font-size="17" font-weight="700">Correspondence</text>
|
||||
<rect x="410" y="22" width="166" height="28" rx="8" fill="#eef2ff"/><text x="493" y="41" fill="#4338ca" font-size="12" text-anchor="middle">Linked Gmail thread ✓</text>
|
||||
<g transform="translate(24,58)">
|
||||
<rect width="552" height="82" rx="10" fill="#f8fafc"/><circle cx="26" cy="26" r="14" fill="#6366f1"/><text x="26" y="31" fill="#fff" font-size="12" text-anchor="middle" font-weight="700">R</text>
|
||||
<text x="52" y="24" fill="#0f172a" font-size="14" font-weight="600">Recruiter · Finn.no</text><text x="530" y="24" fill="#94a3b8" font-size="12" text-anchor="end">5d ago</text>
|
||||
<text x="52" y="46" fill="#64748b" font-size="13">Thanks for applying! We're reviewing and will be in touch</text><text x="52" y="64" fill="#64748b" font-size="13">within two weeks.</text>
|
||||
</g>
|
||||
<g transform="translate(24,150)">
|
||||
<rect width="552" height="70" rx="10" fill="#eef2ff"/><circle cx="26" cy="26" r="14" fill="#0f172a"/><text x="26" y="31" fill="#fff" font-size="12" text-anchor="middle" font-weight="700">You</text>
|
||||
<text x="52" y="24" fill="#0f172a" font-size="14" font-weight="600">You · sent reply</text><text x="530" y="24" fill="#94a3b8" font-size="12" text-anchor="end">5d ago</text>
|
||||
<text x="52" y="46" fill="#64748b" font-size="13">Thank you — looking forward to hearing about next steps.</text>
|
||||
</g>
|
||||
<rect x="24" y="234" width="552" height="46" rx="10" fill="#f0fdf4"/><text x="40" y="262" fill="#166534" font-size="13">↻ Thread auto-refreshes — new replies appear here without re-importing</text>
|
||||
<line x1="24" y1="298" x2="576" y2="298" stroke="#eef2f7"/>
|
||||
<rect x="24" y="312" width="440" height="34" rx="8" fill="#f1f5f9"/><text x="40" y="334" fill="#94a3b8" font-size="13">Draft a grounded follow-up…</text>
|
||||
<rect x="476" y="312" width="100" height="34" rx="8" fill="url(#ac)"/><text x="526" y="334" fill="#0b1020" font-size="13" text-anchor="middle" font-weight="700">AI draft</text>
|
||||
</g>
|
||||
|
||||
<!-- right: match + attachments + tailor -->
|
||||
<g transform="translate(672,180)" filter="url(#c)">
|
||||
<rect width="720" height="300" rx="14" fill="#fff"/>
|
||||
<text x="28" y="40" fill="#0f172a" font-size="17" font-weight="700">Candidate fit — keyword coverage</text>
|
||||
<circle cx="110" cy="150" r="64" fill="none" stroke="#e2e8f0" stroke-width="16"/>
|
||||
<circle cx="110" cy="150" r="64" fill="none" stroke="#22c55e" stroke-width="16" stroke-linecap="round" stroke-dasharray="309 402" transform="rotate(-90 110 150)"/>
|
||||
<text x="110" y="146" fill="#0f172a" font-size="34" font-weight="800" text-anchor="middle">77%</text>
|
||||
<text x="110" y="172" fill="#64748b" font-size="12" text-anchor="middle">coverage</text>
|
||||
<text x="110" y="238" fill="#94a3b8" font-size="12" text-anchor="middle">deterministic · explainable</text>
|
||||
<g transform="translate(230,72)" font-size="13">
|
||||
<text x="0" y="0" fill="#166534" font-weight="700">Matched</text>
|
||||
<g><rect x="0" y="12" width="80" height="26" rx="13" fill="#dcfce7"/><text x="40" y="29" fill="#166534" text-anchor="middle">AWS</text></g>
|
||||
<g><rect x="88" y="12" width="84" height="26" rx="13" fill="#dcfce7"/><text x="130" y="29" fill="#166534" text-anchor="middle">CI/CD</text></g>
|
||||
<g><rect x="180" y="12" width="110" height="26" rx="13" fill="#dcfce7"/><text x="235" y="29" fill="#166534" text-anchor="middle">Terraform</text></g>
|
||||
<g><rect x="300" y="12" width="70" height="26" rx="13" fill="#dcfce7"/><text x="335" y="29" fill="#166534" text-anchor="middle">Go</text></g>
|
||||
<text x="0" y="78" fill="#991b1b" font-weight="700">Missing — add if you have it</text>
|
||||
<g><rect x="0" y="90" width="118" height="26" rx="13" fill="#fee2e2"/><text x="59" y="107" fill="#991b1b" text-anchor="middle">Kubernetes ✕</text></g>
|
||||
<g><rect x="126" y="90" width="96" height="26" rx="13" fill="#fee2e2"/><text x="174" y="107" fill="#991b1b" text-anchor="middle">Grafana ✕</text></g>
|
||||
<rect x="0" y="132" width="220" height="42" rx="10" fill="url(#ac)"/><text x="110" y="159" fill="#0b1020" font-size="14" font-weight="700" text-anchor="middle">✨ Tailor my CV for this role</text>
|
||||
<rect x="234" y="132" width="150" height="42" rx="10" fill="none" stroke="#cbd5e1"/><text x="309" y="159" fill="#475569" font-size="14" text-anchor="middle">Keep original</text>
|
||||
</g>
|
||||
</g>
|
||||
|
||||
<!-- attachments -->
|
||||
<g transform="translate(672,500)" filter="url(#c)">
|
||||
<rect width="720" height="358" rx="14" fill="#fff"/>
|
||||
<text x="28" y="40" fill="#0f172a" font-size="17" font-weight="700">Attachments</text>
|
||||
<rect x="560" y="22" width="132" height="30" rx="8" fill="#eef2ff"/><text x="626" y="42" fill="#4338ca" font-size="12" text-anchor="middle">⤒ Drop files</text>
|
||||
<g transform="translate(28,60)">
|
||||
<g><rect width="330" height="88" rx="12" fill="#f8fafc" stroke="#e2e8f0"/><rect x="16" y="20" width="40" height="48" rx="6" fill="#6366f1"/><text x="36" y="49" fill="#fff" font-size="11" text-anchor="middle">CV</text><text x="70" y="38" fill="#0f172a" font-size="14" font-weight="600">CV_Platform_v3.pdf</text><text x="70" y="60" fill="#94a3b8" font-size="12">Tailored · 214 KB · submitted</text><rect x="70" y="66" width="70" height="16" rx="8" fill="#dcfce7"/></g>
|
||||
<g transform="translate(350,0)"><rect width="330" height="88" rx="12" fill="#f8fafc" stroke="#e2e8f0"/><rect x="16" y="20" width="40" height="48" rx="6" fill="#22d3ee"/><text x="36" y="49" fill="#083344" font-size="10" text-anchor="middle">DOC</text><text x="70" y="38" fill="#0f172a" font-size="14" font-weight="600">Cover_Letter.pdf</text><text x="70" y="60" fill="#94a3b8" font-size="12">AI-assisted · 98 KB</text></g>
|
||||
<g transform="translate(0,104)"><rect width="330" height="88" rx="12" fill="#f8fafc" stroke="#e2e8f0"/><rect x="16" y="20" width="40" height="48" rx="6" fill="#f59e0b"/><text x="36" y="49" fill="#fff" font-size="10" text-anchor="middle">PNG</text><text x="70" y="38" fill="#0f172a" font-size="14" font-weight="600">Portfolio.png</text><text x="70" y="60" fill="#94a3b8" font-size="12">1.2 MB</text></g>
|
||||
<g transform="translate(350,104)"><rect width="330" height="88" rx="12" fill="#fff" stroke="#cbd5e1" stroke-dasharray="5 5"/><text x="165" y="44" fill="#94a3b8" font-size="13" text-anchor="middle">Drag & drop or click</text><text x="165" y="64" fill="#cbd5e1" font-size="12" text-anchor="middle">resume · cover letter · portfolio</text></g>
|
||||
</g>
|
||||
<line x1="28" y1="268" x2="692" y2="268" stroke="#eef2f7"/>
|
||||
<text x="28" y="300" fill="#0f172a" font-size="14" font-weight="700">Checklist</text>
|
||||
<g font-size="13" transform="translate(28,320)"><text x="0" y="0" fill="#16a34a">✓ Resume</text><text x="110" y="0" fill="#16a34a">✓ Cover letter</text><text x="250" y="0" fill="#16a34a">✓ Portfolio</text><text x="370" y="0" fill="#cbd5e1">○ References</text></g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 10 KiB |
@@ -28,10 +28,12 @@ import JobTable from "./components/JobTable";
|
||||
import type { JobTableColumns } from "./components/JobTable";
|
||||
import { I18nProvider, useI18n } from "./i18n/I18nProvider";
|
||||
import LoginPage from "./pages/LoginPage";
|
||||
import LandingPage from "./pages/LandingPage";
|
||||
import ForgotPasswordPage from "./pages/ForgotPasswordPage";
|
||||
import ResetPasswordPage from "./pages/ResetPasswordPage";
|
||||
import RouteErrorPage from "./pages/RouteErrorPage";
|
||||
import { api } from "./api";
|
||||
import { resolveCaptureUrl } from "./captureUrl";
|
||||
import { clearAuthClientState, setAuthUserKey } from "./auth";
|
||||
import AppShell, { NavItem } from "./layout/AppShell";
|
||||
import { clearAccentColor, getAccentColor, getThemeModePref, setAccentColor, setThemeModePref, ThemeModePref } from "./themePrefs";
|
||||
@@ -109,6 +111,7 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo
|
||||
const compactHeaderActions = useMediaQuery("(max-width:767.95px)");
|
||||
|
||||
const [addOpen, setAddOpen] = useState(false);
|
||||
const [captureUrl, setCaptureUrl] = useState<string | undefined>(undefined);
|
||||
const [quickOpen, setQuickOpen] = useState(false);
|
||||
const [refreshToken, setRefreshToken] = useState(0);
|
||||
const [requireAuth, setRequireAuth] = useState<boolean | null>(null);
|
||||
@@ -124,6 +127,19 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo
|
||||
useEffect(() => {
|
||||
api.get<AuthConfig>("/auth/config").then((r) => setRequireAuth(Boolean(r.data?.requireAuth))).catch(() => setRequireAuth(false));
|
||||
}, []);
|
||||
|
||||
// Quick-capture target: bookmarklet (/?add=<url>) or PWA share (url in `add`, or a link
|
||||
// embedded in shared `addtext`). Opens Add Job pre-filled and strips the params.
|
||||
useEffect(() => {
|
||||
const url = resolveCaptureUrl(location.search);
|
||||
if (!url) return;
|
||||
setCaptureUrl(url);
|
||||
setAddOpen(true);
|
||||
const params = new URLSearchParams(location.search);
|
||||
params.delete("add");
|
||||
params.delete("addtext");
|
||||
navigate({ pathname: location.pathname, search: params.toString() }, { replace: true });
|
||||
}, [location.search, location.pathname, navigate]);
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
api.get<MeResponse>("/auth/me")
|
||||
@@ -288,7 +304,7 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo
|
||||
</AppShell>
|
||||
|
||||
<Suspense fallback={null}>
|
||||
<AddJobModal open={addOpen} onClose={() => setAddOpen(false)} onCreated={() => { setRefreshToken((t) => t + 1); }} />
|
||||
<AddJobModal open={addOpen} initialUrl={captureUrl} onClose={() => { setAddOpen(false); setCaptureUrl(undefined); }} onCreated={() => { setRefreshToken((t) => t + 1); }} />
|
||||
<QuickCommandDialog open={quickOpen} onClose={() => setQuickOpen(false)} onNavigate={(to) => navigate(to)} onOpenAddJob={() => setAddOpen(true)} />
|
||||
</Suspense>
|
||||
</>
|
||||
@@ -329,6 +345,7 @@ export default function App() {
|
||||
});
|
||||
|
||||
const router = useMemo(() => createBrowserRouter([
|
||||
{ path: "/", element: <LandingPage />, errorElement: <RouteErrorPage /> },
|
||||
{ path: "/login", element: <LoginPage />, errorElement: <RouteErrorPage /> },
|
||||
{ path: "/forgot-password", element: <ForgotPasswordPage />, errorElement: <RouteErrorPage /> },
|
||||
{ path: "/reset-password", element: <ResetPasswordPage />, errorElement: <RouteErrorPage /> },
|
||||
|
||||
@@ -82,8 +82,14 @@ export function setAuthUserKey(value: string | null | undefined, emit = true) {
|
||||
}
|
||||
|
||||
export function clearAuthClientState(emit = true) {
|
||||
// Only emit "auth-changed" when this call actually transitions from
|
||||
// "signed in" to "signed out". The response interceptor calls this on every
|
||||
// 401; without this guard each 401 re-dispatches "auth-changed", which
|
||||
// re-fetches /auth/me, which 401s again — an infinite request loop whenever
|
||||
// the user is logged out (login page, expired session).
|
||||
const had = safeGet(window.localStorage, AUTH_USER_KEY) != null;
|
||||
safeRemove(window.localStorage, AUTH_USER_KEY);
|
||||
if (emit) emitAuthChanged();
|
||||
if (emit && had) emitAuthChanged();
|
||||
}
|
||||
|
||||
export function getCsrfToken(): string | null {
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { resolveCaptureUrl } from './captureUrl';
|
||||
|
||||
describe('resolveCaptureUrl', () => {
|
||||
test('reads the bookmarklet add param', () => {
|
||||
expect(resolveCaptureUrl('?add=https%3A%2F%2Fexample.com%2Fjob')).toBe('https://example.com/job');
|
||||
});
|
||||
|
||||
test('extracts a url embedded in shared text', () => {
|
||||
expect(resolveCaptureUrl('?addtext=' + encodeURIComponent('Cool role here https://example.com/job/42 apply now')))
|
||||
.toBe('https://example.com/job/42');
|
||||
});
|
||||
|
||||
test('prefers add over addtext', () => {
|
||||
expect(resolveCaptureUrl('?add=https%3A%2F%2Fa.com&addtext=' + encodeURIComponent('https://b.com')))
|
||||
.toBe('https://a.com');
|
||||
});
|
||||
|
||||
test('returns null when there is no url', () => {
|
||||
expect(resolveCaptureUrl('')).toBeNull();
|
||||
expect(resolveCaptureUrl('?addtext=' + encodeURIComponent('just some text, no link'))).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
// Resolves the quick-capture URL from query params produced by the bookmarklet (`add`)
|
||||
// or the PWA share-target (a link in `add`, or embedded in shared `addtext`).
|
||||
export function resolveCaptureUrl(search: string): string | null {
|
||||
const params = new URLSearchParams(search);
|
||||
const add = params.get("add");
|
||||
if (add) return add;
|
||||
const addText = params.get("addtext");
|
||||
if (addText) return addText.match(/https?:\/\/\S+/)?.[0] ?? null;
|
||||
return null;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import { DatePicker } from "@mui/x-date-pickers/DatePicker";
|
||||
|
||||
@@ -37,6 +37,7 @@ interface Props {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onCreated: () => void;
|
||||
initialUrl?: string;
|
||||
}
|
||||
|
||||
type DuplicateCandidate = {
|
||||
@@ -97,7 +98,7 @@ function normalizeLanguage(value?: string | null) {
|
||||
return raw;
|
||||
}
|
||||
|
||||
export default function AddJobModal({ open, onClose, onCreated }: Props) {
|
||||
export default function AddJobModal({ open, onClose, onCreated, initialUrl }: Props) {
|
||||
const { toast } = useToast();
|
||||
const { t, language } = useI18n();
|
||||
|
||||
@@ -137,6 +138,21 @@ export default function AddJobModal({ open, onClose, onCreated }: Props) {
|
||||
setCompanies(cachedCompanies);
|
||||
}, [cachedCompanies]);
|
||||
|
||||
// Quick-capture: when opened with a URL (from the bookmarklet), prefill and auto-import once.
|
||||
const autoImportedUrlRef = useRef<string | null>(null);
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
autoImportedUrlRef.current = null;
|
||||
return;
|
||||
}
|
||||
const url = initialUrl?.trim();
|
||||
if (!url || autoImportedUrlRef.current === url) return;
|
||||
autoImportedUrlRef.current = url;
|
||||
setJobUrl(url);
|
||||
void importFromUrl(url);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open, initialUrl]);
|
||||
|
||||
const resetForm = () => {
|
||||
setCompany(null);
|
||||
setCompanyInput("");
|
||||
@@ -223,16 +239,17 @@ export default function AddJobModal({ open, onClose, onCreated }: Props) {
|
||||
}
|
||||
};
|
||||
|
||||
const importFromUrl = async () => {
|
||||
const importFromUrl = async (urlArg?: string) => {
|
||||
if (importing) return;
|
||||
if (!jobUrl.trim()) {
|
||||
const url = (urlArg ?? jobUrl).trim();
|
||||
if (!url) {
|
||||
toast(t("addJobModalPasteUrlFirst"), "warning");
|
||||
return;
|
||||
}
|
||||
|
||||
setImporting(true);
|
||||
try {
|
||||
const res = await api.post<JobImportResult>("/jobimport/preview", { url: jobUrl.trim() });
|
||||
const res = await api.post<JobImportResult>("/jobimport/preview", { url });
|
||||
const r = res.data;
|
||||
if (!r?.success) throw new Error(r?.error || t("addJobModalImportFailed"));
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ import { api } from "../api";
|
||||
import ViewStateNotice from "./ViewStateNotice";
|
||||
import { getUserKeyFromToken } from "../themePrefs";
|
||||
import { useI18n } from "../i18n/I18nProvider";
|
||||
import { statusLabel } from "../pipeline";
|
||||
import { buildWorkflowPath, getWorkflowAction } from "../jobWorkflowSignals";
|
||||
import { JobApplication } from "../types";
|
||||
import { useViewResource } from "../hooks/useViewResource";
|
||||
@@ -49,6 +50,7 @@ type OverviewAnalytics = {
|
||||
medianDaysToFirstResponse?: number | null;
|
||||
totalResponses: number;
|
||||
totalActive: number;
|
||||
timeInStage?: { stage: string; medianDays: number; count: number }[];
|
||||
};
|
||||
type TagTrendResponse = { months: string[]; series: { tag: string; counts: number[] }[] };
|
||||
|
||||
@@ -453,7 +455,7 @@ export default function DashboardView() {
|
||||
return (
|
||||
<Box key={item.label}>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", mb: 0.5, gap: 1 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 700 }}>{item.label}</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 700 }}>{statusLabel(t, item.label)}</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>{item.count}</Typography>
|
||||
</Box>
|
||||
<LinearProgress
|
||||
@@ -474,6 +476,22 @@ export default function DashboardView() {
|
||||
})}
|
||||
</Stack>
|
||||
|
||||
{overview?.timeInStage?.length ? (
|
||||
<Box sx={{ mt: 2.25 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 800, mb: 1 }}>{t("dashboardTimeInStageTitle")}</Typography>
|
||||
<Stack spacing={0.75}>
|
||||
{overview.timeInStage.map((item) => (
|
||||
<Box key={item.stage} sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 1 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 700 }}>{statusLabel(t, item.stage)}</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>
|
||||
{t("dashboardTimeInStageValue", { days: item.medianDays, count: item.count })}
|
||||
</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
) : null}
|
||||
|
||||
<Box sx={{ mt: 2.25, p: 1.5, borderRadius: 3, backgroundColor: alpha(theme.palette.primary.main, 0.05) }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 800 }}>{summaryView.topSource?.label ?? t("dashboardResponseSources")}</Typography>
|
||||
<Typography variant="h5" sx={{ fontWeight: 950, mt: 0.5 }}>{summaryView.topSource ? `${summaryView.topSource.rate}%` : "—"}</Typography>
|
||||
|
||||
@@ -18,9 +18,11 @@ import {
|
||||
TextField,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import { alpha } from "@mui/material/styles";
|
||||
|
||||
import { api, getApiErrorMessage } from "../api";
|
||||
import { ApplicationPackageResponse, CandidateFit, FocusPlanResponse, FollowUpDraft, InterviewPrepResponse, JobApplication, MatchScore, ReadinessResponse, TailoredCvDraft } from "../types";
|
||||
import { ApplicationPackageResponse, CandidateFit, FocusPlanResponse, FollowUpDraft, InterviewPrepResponse, JobApplication, MatchScore, ReadinessResponse, StatusSuggestion, TailoredCvDraft } from "../types";
|
||||
import { statusLabel } from "../pipeline";
|
||||
import { useToast } from "../toast";
|
||||
import { useDialogActions } from "../dialogs";
|
||||
import { emptyTailoredCvDraft, joinLines, normalizeTailoredCvDraft, splitLines } from "../tailoredCvDraft";
|
||||
@@ -172,6 +174,8 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
|
||||
const [candidateFit, setCandidateFit] = useState<CandidateFit | null>(null);
|
||||
const [matchScore, setMatchScore] = useState<MatchScore | null>(null);
|
||||
const [loadingMatchScore, setLoadingMatchScore] = useState(false);
|
||||
const [statusSuggestion, setStatusSuggestion] = useState<StatusSuggestion | null>(null);
|
||||
const [applyingStatusSuggestion, setApplyingStatusSuggestion] = useState(false);
|
||||
const [focusPlan, setFocusPlan] = useState<FocusPlanResponse | null>(null);
|
||||
const [loadingCandidateFit, setLoadingCandidateFit] = useState(false);
|
||||
const [loadingFocusPlan, setLoadingFocusPlan] = useState(false);
|
||||
@@ -205,6 +209,7 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
|
||||
setFollowUpDraft(null);
|
||||
setCandidateFit(null);
|
||||
setMatchScore(null);
|
||||
setStatusSuggestion(null);
|
||||
setFocusPlan(null);
|
||||
setInterviewPrep(null);
|
||||
setReadiness(null);
|
||||
@@ -303,6 +308,31 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
|
||||
}).catch(() => setMatchScore(null)).finally(() => setLoadingMatchScore(false));
|
||||
}, [open, jobId, tab, matchScore, matchScoreCache]);
|
||||
|
||||
// Suggest a status move from the latest inbound email when the workspace opens.
|
||||
useEffect(() => {
|
||||
if (!open || !jobId) return;
|
||||
let cancelled = false;
|
||||
api.get<StatusSuggestion>(`/jobapplications/${jobId}/status-suggestion`)
|
||||
.then((r) => { if (!cancelled) setStatusSuggestion(r.data?.hasSuggestion ? r.data : null); })
|
||||
.catch(() => { if (!cancelled) setStatusSuggestion(null); });
|
||||
return () => { cancelled = true; };
|
||||
}, [open, jobId]);
|
||||
|
||||
const applyStatusSuggestion = async () => {
|
||||
if (!jobId || !statusSuggestion?.suggestedStatus) return;
|
||||
setApplyingStatusSuggestion(true);
|
||||
try {
|
||||
await api.patch(`/jobapplications/${jobId}/status`, { status: statusSuggestion.suggestedStatus });
|
||||
setJob((prev) => prev ? { ...prev, status: statusSuggestion.suggestedStatus! } : prev);
|
||||
setStatusSuggestion(null);
|
||||
toast(t("statusSuggestionApplied"), "success");
|
||||
} catch (error: any) {
|
||||
toast(getApiErrorMessage(error, t("statusSuggestionFailed")), "error");
|
||||
} finally {
|
||||
setApplyingStatusSuggestion(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !jobId || tab !== 6 || focusPlan) return;
|
||||
const cacheKey = `${jobId}:focus-plan:${selectedAttachmentCsv || "none"}`;
|
||||
@@ -621,6 +651,25 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
|
||||
|
||||
{attachmentPicker}
|
||||
|
||||
{statusSuggestion?.hasSuggestion ? (
|
||||
<Box sx={{ p: 1.5, mb: 2, borderRadius: 3, border: "1px solid", borderColor: "warning.main", backgroundColor: (theme) => alpha(theme.palette.warning.main, 0.08), display: "flex", justifyContent: "space-between", alignItems: "center", gap: 1.5, flexWrap: "wrap" }}>
|
||||
<Box>
|
||||
<Typography variant="body2" sx={{ fontWeight: 800 }}>
|
||||
{t("statusSuggestionTitle", { status: statusLabel(t, statusSuggestion.suggestedStatus ?? "") })}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: "text.secondary" }}>
|
||||
{t("statusSuggestionReason", { signal: statusSuggestion.signal ?? "", current: statusLabel(t, statusSuggestion.currentStatus ?? "") })}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: "flex", gap: 1 }}>
|
||||
<Button size="small" variant="contained" color="warning" disabled={applyingStatusSuggestion} onClick={() => void applyStatusSuggestion()}>
|
||||
{t("statusSuggestionApply", { status: statusLabel(t, statusSuggestion.suggestedStatus ?? "") })}
|
||||
</Button>
|
||||
<Button size="small" variant="text" onClick={() => setStatusSuggestion(null)}>{t("statusSuggestionDismiss")}</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
) : null}
|
||||
|
||||
{tab === 0 && (
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 2 }}>
|
||||
<Box sx={{ gridColumn: "1 / -1", display: "flex", justifyContent: "space-between", alignItems: "center", gap: 1, flexWrap: "wrap" }}>
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import React, { useEffect, useRef } from "react";
|
||||
|
||||
import { Box, Paper, TextField, Typography } from "@mui/material";
|
||||
|
||||
import { useI18n } from "../i18n/I18nProvider";
|
||||
import { useToast } from "../toast";
|
||||
|
||||
/** The bookmarklet opens the app at /?add=<current page url>, which triggers quick-capture. */
|
||||
function buildBookmarklet(origin: string): string {
|
||||
// Kept as a single minified expression; opens a small popup so the user's tab is undisturbed.
|
||||
return `javascript:void(window.open('${origin}/?add='+encodeURIComponent(location.href),'jobbjakt','width=520,height=720'))`;
|
||||
}
|
||||
|
||||
export default function QuickCaptureCard() {
|
||||
const { t } = useI18n();
|
||||
const { toast } = useToast();
|
||||
const linkRef = useRef<HTMLAnchorElement>(null);
|
||||
const origin = typeof window !== "undefined" ? window.location.origin : "";
|
||||
const bookmarklet = buildBookmarklet(origin);
|
||||
|
||||
// React refuses to render javascript: hrefs, so set it directly on the DOM node.
|
||||
useEffect(() => {
|
||||
if (linkRef.current) linkRef.current.setAttribute("href", bookmarklet);
|
||||
}, [bookmarklet]);
|
||||
|
||||
return (
|
||||
<Paper sx={{ p: 2 }}>
|
||||
<Typography sx={{ fontWeight: 950, mb: 0.5 }}>{t("settingsQuickCaptureTitle")}</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary", mb: 1.5 }}>{t("settingsQuickCaptureSubtitle")}</Typography>
|
||||
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1.5, flexWrap: "wrap", mb: 1.5 }}>
|
||||
<Box
|
||||
component="a"
|
||||
ref={linkRef}
|
||||
onClick={(e: React.MouseEvent) => {
|
||||
// Clicking (vs dragging) shouldn't navigate; the value is meant to be dragged to the bar.
|
||||
e.preventDefault();
|
||||
toast(t("settingsQuickCaptureDragHint"), "info");
|
||||
}}
|
||||
sx={{
|
||||
display: "inline-block",
|
||||
px: 2,
|
||||
py: 1,
|
||||
borderRadius: 2,
|
||||
border: "1px solid",
|
||||
borderColor: "primary.main",
|
||||
color: "primary.main",
|
||||
fontWeight: 800,
|
||||
textDecoration: "none",
|
||||
cursor: "grab",
|
||||
userSelect: "none",
|
||||
}}
|
||||
>
|
||||
{t("settingsQuickCaptureButton")}
|
||||
</Box>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>{t("settingsQuickCaptureDragHint")}</Typography>
|
||||
</Box>
|
||||
|
||||
<TextField
|
||||
label={t("settingsQuickCaptureManual")}
|
||||
value={bookmarklet}
|
||||
fullWidth
|
||||
size="small"
|
||||
InputProps={{ readOnly: true }}
|
||||
onFocus={(e) => e.target.select()}
|
||||
/>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
@@ -24,6 +24,7 @@ import ImportExportJobs from "./ImportExportJobs";
|
||||
import GoogleAuthCard from "./GoogleAuthCard";
|
||||
import RulesSettingsCard from "./RulesSettingsCard";
|
||||
import BackupCard from "./BackupCard";
|
||||
import QuickCaptureCard from "./QuickCaptureCard";
|
||||
import AuthStatusCard from "./AuthStatusCard";
|
||||
import { ThemeModePref } from "../themePrefs";
|
||||
import { useI18n } from "../i18n/I18nProvider";
|
||||
@@ -45,7 +46,7 @@ function TabPanel({ value, index, children }: { value: number; index: number; ch
|
||||
return <Box sx={{ mt: 2 }}>{children}</Box>;
|
||||
}
|
||||
|
||||
const ACCENTS = ["#15803d", "#16a34a", "#22c55e", "#0f766e", "#2563eb", "#65a30d", "#8b5cf6", "#f97316"];
|
||||
const ACCENTS = ["#6366f1", "#22d3ee", "#2563eb", "#8b5cf6", "#15803d", "#16a34a", "#0f766e", "#f97316"];
|
||||
const NOTIFICATION_PREFS_KEY = "settings.notificationPrefs";
|
||||
|
||||
type NotificationPrefs = {
|
||||
@@ -297,6 +298,8 @@ export default function SettingsView({
|
||||
|
||||
<ImportExportJobs />
|
||||
</Paper>
|
||||
|
||||
<QuickCaptureCard />
|
||||
</Box>
|
||||
</TabPanel>
|
||||
|
||||
|
||||
@@ -157,6 +157,11 @@ export const translations = {
|
||||
settingsOpenReminderInbox: "Open reminders",
|
||||
settingsReviewJobs: "Review jobs",
|
||||
settingsNotificationsTitle: "Notification settings",
|
||||
settingsQuickCaptureTitle: "Quick capture bookmarklet",
|
||||
settingsQuickCaptureSubtitle: "Drag this button to your bookmarks bar. On any job posting, click it to open Add Job pre-filled from that page.",
|
||||
settingsQuickCaptureButton: "+ Save to Jobbjakt",
|
||||
settingsQuickCaptureDragHint: "Drag me to your bookmarks bar",
|
||||
settingsQuickCaptureManual: "Or copy the bookmarklet code",
|
||||
settingsNotificationsBody: "Choose which reminders should show up in your workflow. SMTP delivery can be checked from the system page.",
|
||||
settingsNotificationsDelivery: "SMTP delivery and test mail live under Admin → System → Settings.",
|
||||
settingsNotificationsFollowUpReminders: "Email reminders for follow-ups",
|
||||
@@ -335,6 +340,8 @@ export const translations = {
|
||||
dashboardApplicationActivity: "Application activity",
|
||||
dashboardMonthlyApplicationsResponses: "Monthly applications versus responses.",
|
||||
dashboardConversionFunnelTitle: "Conversion funnel",
|
||||
dashboardTimeInStageTitle: "Median time in stage",
|
||||
dashboardTimeInStageValue: "{days}d · {count} active",
|
||||
dashboardResponseSources: "Response sources",
|
||||
dashboardTopCompaniesByActivity: "Top companies by activity",
|
||||
dashboardTopSkills: "Top skills",
|
||||
@@ -779,6 +786,12 @@ export const translations = {
|
||||
jobDetailsTabFocusPlan: "Focus plan",
|
||||
jobDetailsTabInterviewPrep: "Interview prep",
|
||||
jobDetailsTabHistory: "History",
|
||||
statusSuggestionTitle: "This email looks like a move to {status}",
|
||||
statusSuggestionReason: "Matched \"{signal}\" · currently {current}",
|
||||
statusSuggestionApply: "Move to {status}",
|
||||
statusSuggestionDismiss: "Dismiss",
|
||||
statusSuggestionApplied: "Status updated.",
|
||||
statusSuggestionFailed: "Could not update status.",
|
||||
jobDetailsTailoredCvMode: "Generation mode",
|
||||
jobDetailsGenerationDefault: "Balanced",
|
||||
jobDetailsGenerationConcise: "Concise",
|
||||
@@ -1088,6 +1101,11 @@ export const translations = {
|
||||
settingsOpenReminderInbox: "Åpne påminnelser",
|
||||
settingsReviewJobs: "Gå til jobber",
|
||||
settingsNotificationsTitle: "Varslingsinnstillinger",
|
||||
settingsQuickCaptureTitle: "Hurtiglagring (bokmerke)",
|
||||
settingsQuickCaptureButton: "+ Lagre til Jobbjakt",
|
||||
settingsQuickCaptureSubtitle: "Dra denne knappen til bokmerkelinjen. På en stillingsannonse klikker du på den for å åpne Legg til jobb forhåndsutfylt fra siden.",
|
||||
settingsQuickCaptureDragHint: "Dra meg til bokmerkelinjen",
|
||||
settingsQuickCaptureManual: "Eller kopier bokmerkekoden",
|
||||
settingsNotificationsBody: "Velg hvilke påminnelser som skal vises i arbeidsflyten din. SMTP-levering kan kontrolleres fra systemsiden.",
|
||||
settingsNotificationsDelivery: "SMTP-levering og test-epost ligger under Admin → System → Innstillinger.",
|
||||
settingsNotificationsFollowUpReminders: "E-postpåminnelser for oppfølginger",
|
||||
@@ -1266,6 +1284,8 @@ export const translations = {
|
||||
dashboardApplicationActivity: "Søknadsaktivitet",
|
||||
dashboardMonthlyApplicationsResponses: "Månedlige søknader versus svar.",
|
||||
dashboardConversionFunnelTitle: "Konverteringstrakt",
|
||||
dashboardTimeInStageTitle: "Median tid i fase",
|
||||
dashboardTimeInStageValue: "{days}d · {count} aktive",
|
||||
dashboardResponseSources: "Svar etter kilde",
|
||||
dashboardTopCompaniesByActivity: "Topp selskaper etter aktivitet",
|
||||
dashboardTopSkills: "Topp ferdigheter",
|
||||
@@ -1710,6 +1730,12 @@ export const translations = {
|
||||
jobDetailsTabFocusPlan: "Fokusplan",
|
||||
jobDetailsTabInterviewPrep: "Intervjuforberedelse",
|
||||
jobDetailsTabHistory: "Historikk",
|
||||
statusSuggestionTitle: "Denne e-posten ser ut som en overgang til {status}",
|
||||
statusSuggestionReason: "Traff \"{signal}\" · nå {current}",
|
||||
statusSuggestionApply: "Flytt til {status}",
|
||||
statusSuggestionDismiss: "Avvis",
|
||||
statusSuggestionApplied: "Status oppdatert.",
|
||||
statusSuggestionFailed: "Kunne ikke oppdatere status.",
|
||||
jobDetailsTailoredCvMode: "Genereringsmodus",
|
||||
jobDetailsGenerationDefault: "Balansert",
|
||||
jobDetailsGenerationConcise: "Kortfattet",
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
|
||||
import { Box, Button, Container, Stack, Typography } from "@mui/material";
|
||||
import { alpha } from "@mui/material/styles";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import DashboardIcon from "@mui/icons-material/SpaceDashboardOutlined";
|
||||
import AlarmIcon from "@mui/icons-material/NotificationsActiveOutlined";
|
||||
import MatchIcon from "@mui/icons-material/FactCheckOutlined";
|
||||
import MailIcon from "@mui/icons-material/MarkEmailReadOutlined";
|
||||
import AttachIcon from "@mui/icons-material/DescriptionOutlined";
|
||||
import InsightsIcon from "@mui/icons-material/InsightsOutlined";
|
||||
|
||||
import { api } from "../api";
|
||||
|
||||
const BRAND_DARK = "#0b1020";
|
||||
const BRAND_PANEL = "#111a33";
|
||||
|
||||
const FEATURES: { icon: React.ReactNode; title: string; body: string }[] = [
|
||||
{ icon: <DashboardIcon />, title: "Centralized pipeline", body: "Track every application across Applied, Waiting, Interview, Offer, Rejected and Ghosted — drag to update." },
|
||||
{ icon: <AlarmIcon />, title: "Smart follow-ups", body: "Reminders surface what needs attention next, with a grounded draft ready to review and send." },
|
||||
{ icon: <MatchIcon />, title: "Honest CV match", body: "A deterministic keyword-coverage score with matched vs missing skills — not an opaque black box." },
|
||||
{ icon: <MailIcon />, title: "Email correspondence", body: "Link Gmail threads to a job; new replies appear automatically without re-importing." },
|
||||
{ icon: <AttachIcon />, title: "Attachments & docs", body: "Keep resumes, cover letters and portfolios versioned per application, right where you need them." },
|
||||
{ icon: <InsightsIcon />, title: "Dashboard & insights", body: "Response rates, funnel, time-in-stage and skill demand across your whole search." },
|
||||
];
|
||||
|
||||
const STEPS: { n: number; title: string; body: string }[] = [
|
||||
{ n: 1, title: "Import", body: "Paste a job URL or use the bookmarklet — we parse the role into structured fields." },
|
||||
{ n: 2, title: "Match", body: "See how your CV covers the role: matched keywords and the gaps to close." },
|
||||
{ n: 3, title: "Tailor", body: "AI drafts a tailored CV and cover letter — you review every word before it goes out." },
|
||||
{ n: 4, title: "Track", body: "Move it through the pipeline; documents, notes and history stay attached." },
|
||||
{ n: 5, title: "Follow up", body: "Linked email threads and reminders keep momentum with grounded replies." },
|
||||
{ n: 6, title: "Analyze", body: "See what's working — response rate, funnel and time-in-stage — and focus your effort." },
|
||||
];
|
||||
|
||||
const PRICING: { name: string; price: string; cadence: string; highlight: boolean; features: string[]; cta: string }[] = [
|
||||
{ name: "Free", price: "£0", cadence: "forever", highlight: false, cta: "Get started", features: ["Unlimited job tracking & pipeline", "One-click capture (bookmarklet + PWA)", "Deterministic CV↔job match score", "3 AI CV tailors / month"] },
|
||||
{ name: "Pro", price: "£9", cadence: "/ month · billed monthly or yearly", highlight: true, cta: "Start Pro", features: ["Everything in Free", "Unlimited AI CV & cover-letter tailoring", "CV versions + factuality guardrail", "Gmail correspondence CRM", "Analytics drill-downs"] },
|
||||
{ name: "Bring your own key", price: "£3", cadence: "/ month + your AI key", highlight: false, cta: "Get started", features: ["Everything in Pro", "Use your own Gemini / Groq key", "Unlimited AI at provider cost", "Privacy-first & self-host friendly"] },
|
||||
];
|
||||
|
||||
export default function LandingPage() {
|
||||
const navigate = useNavigate();
|
||||
const [checking, setChecking] = useState(true);
|
||||
|
||||
// If the visitor already has a session, send them straight into the app.
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
api
|
||||
.get("/auth/me")
|
||||
.then(() => { if (active) navigate("/jobs", { replace: true }); })
|
||||
.catch(() => { if (active) setChecking(false); });
|
||||
return () => { active = false; };
|
||||
}, [navigate]);
|
||||
|
||||
if (checking) {
|
||||
return (
|
||||
<Box sx={{ minHeight: "100vh", display: "grid", placeItems: "center", bgcolor: BRAND_DARK }}>
|
||||
<Typography sx={{ color: "#94a3b8" }}>Loading…</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const gradientText = {
|
||||
background: "linear-gradient(90deg,#6366f1,#22d3ee)",
|
||||
WebkitBackgroundClip: "text",
|
||||
WebkitTextFillColor: "transparent",
|
||||
} as const;
|
||||
|
||||
return (
|
||||
<Box sx={{ bgcolor: "background.default" }}>
|
||||
{/* Top bar */}
|
||||
<Box sx={{ position: "sticky", top: 0, zIndex: 10, bgcolor: alpha(BRAND_DARK, 0.85), backdropFilter: "blur(8px)", borderBottom: `1px solid ${alpha("#ffffff", 0.08)}` }}>
|
||||
<Container maxWidth="lg">
|
||||
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ height: 64 }}>
|
||||
<Stack direction="row" alignItems="center" spacing={1.25}>
|
||||
<Box sx={{ width: 30, height: 30, borderRadius: "8px", background: "linear-gradient(135deg,#6366f1,#22d3ee)", display: "grid", placeItems: "center", color: BRAND_DARK, fontWeight: 900 }}>✓</Box>
|
||||
<Typography sx={{ color: "#fff", fontWeight: 800, fontSize: 20 }}>JobTrack</Typography>
|
||||
</Stack>
|
||||
<Button variant="contained" onClick={() => navigate("/login")} sx={{ background: "linear-gradient(90deg,#6366f1,#22d3ee)", color: "#0b1020", fontWeight: 700 }}>
|
||||
Sign in
|
||||
</Button>
|
||||
</Stack>
|
||||
</Container>
|
||||
</Box>
|
||||
|
||||
{/* Hero */}
|
||||
<Box sx={{ background: `radial-gradient(1200px 500px at 80% -10%, ${alpha("#6366f1", 0.35)}, transparent), linear-gradient(180deg, ${BRAND_DARK}, ${BRAND_PANEL})`, color: "#fff", py: { xs: 8, md: 12 } }}>
|
||||
<Container maxWidth="lg">
|
||||
<Box sx={{ maxWidth: 760 }}>
|
||||
<Box sx={{ display: "inline-block", px: 1.5, py: 0.5, borderRadius: 999, bgcolor: alpha("#ffffff", 0.08), color: "#a5b4fc", fontSize: 13, fontWeight: 600, letterSpacing: 0.5, mb: 3 }}>
|
||||
AI-ASSISTED JOB SEARCH WORKSPACE
|
||||
</Box>
|
||||
<Typography component="h1" sx={{ fontWeight: 800, fontSize: { xs: 40, md: 60 }, lineHeight: 1.05, mb: 2 }}>
|
||||
Run your job search without losing <Box component="span" sx={gradientText}>the thread</Box>.
|
||||
</Typography>
|
||||
<Typography sx={{ color: "#94a3b8", fontSize: { xs: 17, md: 20 }, mb: 4 }}>
|
||||
Import a role, tailor your CV, track every application, and keep recruiter correspondence tied to the
|
||||
right job — all in one focused workspace. Assistive, never autonomous: you approve every draft.
|
||||
</Typography>
|
||||
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
|
||||
<Button size="large" variant="contained" onClick={() => navigate("/login")} sx={{ background: "linear-gradient(90deg,#6366f1,#22d3ee)", color: "#0b1020", fontWeight: 800, px: 4, py: 1.25 }}>
|
||||
Get started
|
||||
</Button>
|
||||
<Button size="large" variant="outlined" href="#features" sx={{ color: "#e2e8f0", borderColor: alpha("#ffffff", 0.25), px: 3, py: 1.25 }}>
|
||||
See features
|
||||
</Button>
|
||||
</Stack>
|
||||
<Typography sx={{ color: "#64748b", fontSize: 14, mt: 3 }}>
|
||||
React · TypeScript · ASP.NET Core · EF Core · FastAPI AI · Gmail
|
||||
</Typography>
|
||||
</Box>
|
||||
</Container>
|
||||
</Box>
|
||||
|
||||
{/* Product preview */}
|
||||
<Container maxWidth="lg" sx={{ py: { xs: 6, md: 9 } }}>
|
||||
<Box sx={{ textAlign: "center", mb: 5 }}>
|
||||
<Typography sx={{ color: "primary.main", fontWeight: 700, letterSpacing: 1, fontSize: 13 }}>SEE IT IN ACTION</Typography>
|
||||
<Typography component="h2" sx={{ fontWeight: 800, fontSize: { xs: 26, md: 34 }, mt: 1 }}>Your whole search, at a glance</Typography>
|
||||
</Box>
|
||||
<Box sx={{ borderRadius: 3, overflow: "hidden", border: "1px solid", borderColor: "divider", boxShadow: 10, mb: 3, bgcolor: "background.paper" }}>
|
||||
<Box component="img" src="/mockups/dashboard.svg" alt="JobTrack dashboard — KPIs, funnel, response trend and follow-ups" sx={{ width: "100%", display: "block" }} />
|
||||
</Box>
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 3 }}>
|
||||
<Box sx={{ borderRadius: 3, overflow: "hidden", border: "1px solid", borderColor: "divider", boxShadow: 6, bgcolor: "background.paper" }}>
|
||||
<Box component="img" src="/mockups/pipeline.svg" alt="Drag-and-drop pipeline board" sx={{ width: "100%", display: "block" }} />
|
||||
</Box>
|
||||
<Box sx={{ borderRadius: 3, overflow: "hidden", border: "1px solid", borderColor: "divider", boxShadow: 6, bgcolor: "background.paper" }}>
|
||||
<Box component="img" src="/mockups/workspace.svg" alt="Per-job workspace with match score, correspondence and attachments" sx={{ width: "100%", display: "block" }} />
|
||||
</Box>
|
||||
</Box>
|
||||
<Typography sx={{ textAlign: "center", color: "text.secondary", fontSize: 13, mt: 2 }}>Interface preview.</Typography>
|
||||
</Container>
|
||||
|
||||
{/* Features */}
|
||||
<Container id="features" maxWidth="lg" sx={{ py: { xs: 7, md: 10 } }}>
|
||||
<Box sx={{ textAlign: "center", mb: 6 }}>
|
||||
<Typography sx={{ color: "primary.main", fontWeight: 700, letterSpacing: 1, fontSize: 13 }}>WHAT IT DOES</Typography>
|
||||
<Typography component="h2" sx={{ fontWeight: 800, fontSize: { xs: 28, md: 38 }, mt: 1 }}>One workspace for the whole search</Typography>
|
||||
<Typography sx={{ color: "text.secondary", fontSize: 18, mt: 1.5 }}>
|
||||
Everything from a single import to the final offer — no more spreadsheets and scattered inboxes.
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr", md: "1fr 1fr 1fr" }, gap: 3 }}>
|
||||
{FEATURES.map((f) => (
|
||||
<Box key={f.title} sx={{ p: 3, borderRadius: 3, border: "1px solid", borderColor: "divider", bgcolor: "background.paper", transition: "box-shadow .2s, transform .2s", "&:hover": { boxShadow: 6, transform: "translateY(-2px)" } }}>
|
||||
<Box sx={{ width: 48, height: 48, borderRadius: 2.5, display: "grid", placeItems: "center", bgcolor: alpha("#6366f1", 0.12), color: "primary.main", mb: 2 }}>{f.icon}</Box>
|
||||
<Typography sx={{ fontWeight: 700, fontSize: 19, mb: 0.75 }}>{f.title}</Typography>
|
||||
<Typography sx={{ color: "text.secondary", fontSize: 15 }}>{f.body}</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Container>
|
||||
|
||||
{/* How it works */}
|
||||
<Box sx={{ background: `linear-gradient(180deg, ${BRAND_DARK}, ${BRAND_PANEL})`, color: "#fff", py: { xs: 7, md: 10 } }}>
|
||||
<Container maxWidth="lg">
|
||||
<Box sx={{ textAlign: "center", mb: 6 }}>
|
||||
<Typography sx={{ color: "#a5b4fc", fontWeight: 700, letterSpacing: 1, fontSize: 13 }}>HOW IT WORKS</Typography>
|
||||
<Typography component="h2" sx={{ fontWeight: 800, fontSize: { xs: 28, md: 38 }, mt: 1 }}>From a link to an offer</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr", md: "1fr 1fr 1fr" }, gap: 3 }}>
|
||||
{STEPS.map((s) => (
|
||||
<Box key={s.n} sx={{ p: 3, borderRadius: 3, border: `1px solid ${alpha("#ffffff", 0.1)}`, bgcolor: alpha("#ffffff", 0.03) }}>
|
||||
<Box sx={{ width: 40, height: 40, borderRadius: 999, display: "grid", placeItems: "center", background: "linear-gradient(135deg,#6366f1,#22d3ee)", color: "#0b1020", fontWeight: 900, mb: 1.5 }}>{s.n}</Box>
|
||||
<Typography sx={{ fontWeight: 700, fontSize: 18, mb: 0.5 }}>{s.title}</Typography>
|
||||
<Typography sx={{ color: "#94a3b8", fontSize: 15 }}>{s.body}</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Container>
|
||||
</Box>
|
||||
|
||||
{/* Pricing */}
|
||||
<Container id="pricing" maxWidth="lg" sx={{ py: { xs: 7, md: 10 } }}>
|
||||
<Box sx={{ textAlign: "center", mb: 6 }}>
|
||||
<Typography sx={{ color: "primary.main", fontWeight: 700, letterSpacing: 1, fontSize: 13 }}>PRICING</Typography>
|
||||
<Typography component="h2" sx={{ fontWeight: 800, fontSize: { xs: 28, md: 38 }, mt: 1 }}>Honest, simple pricing</Typography>
|
||||
<Typography sx={{ color: "text.secondary", fontSize: 18, mt: 1.5 }}>
|
||||
Billed monthly or yearly — never by the week. Cancel anytime.
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "repeat(3, 1fr)" }, gap: 3, alignItems: "start" }}>
|
||||
{PRICING.map((tier) => (
|
||||
<Box
|
||||
key={tier.name}
|
||||
sx={{
|
||||
p: 3.5,
|
||||
borderRadius: 3,
|
||||
position: "relative",
|
||||
bgcolor: "background.paper",
|
||||
border: "2px solid",
|
||||
borderColor: tier.highlight ? "primary.main" : "divider",
|
||||
boxShadow: tier.highlight ? 8 : 0,
|
||||
}}
|
||||
>
|
||||
{tier.highlight && (
|
||||
<Box sx={{ position: "absolute", top: -13, left: 24, px: 1.5, py: 0.5, borderRadius: 999, background: "linear-gradient(90deg,#6366f1,#22d3ee)", color: "#0b1020", fontSize: 12, fontWeight: 800 }}>
|
||||
Most popular
|
||||
</Box>
|
||||
)}
|
||||
<Typography sx={{ fontWeight: 700, fontSize: 18 }}>{tier.name}</Typography>
|
||||
<Stack direction="row" alignItems="baseline" spacing={0.75} sx={{ my: 1.5 }}>
|
||||
<Typography sx={{ fontWeight: 900, fontSize: 40, lineHeight: 1 }}>{tier.price}</Typography>
|
||||
<Typography sx={{ color: "text.secondary", fontSize: 14 }}>{tier.cadence}</Typography>
|
||||
</Stack>
|
||||
<Stack spacing={1.25} sx={{ my: 2.5 }}>
|
||||
{tier.features.map((f) => (
|
||||
<Stack key={f} direction="row" spacing={1.25} alignItems="flex-start">
|
||||
<Box sx={{ color: "success.main", fontWeight: 900, lineHeight: 1.4 }}>✓</Box>
|
||||
<Typography sx={{ fontSize: 15, color: "text.secondary" }}>{f}</Typography>
|
||||
</Stack>
|
||||
))}
|
||||
</Stack>
|
||||
<Button
|
||||
fullWidth
|
||||
variant={tier.highlight ? "contained" : "outlined"}
|
||||
onClick={() => navigate("/login")}
|
||||
sx={tier.highlight ? { background: "linear-gradient(90deg,#6366f1,#22d3ee)", color: "#0b1020", fontWeight: 800 } : { fontWeight: 700 }}
|
||||
>
|
||||
{tier.cta}
|
||||
</Button>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
<Typography sx={{ textAlign: "center", color: "text.secondary", fontSize: 13, mt: 3 }}>
|
||||
Prices indicative — assistive, never autonomous: you always review and send. No auto-apply spam.
|
||||
</Typography>
|
||||
</Container>
|
||||
|
||||
{/* CTA */}
|
||||
<Container maxWidth="lg" sx={{ py: { xs: 7, md: 10 } }}>
|
||||
<Box sx={{ borderRadius: 4, p: { xs: 4, md: 6 }, background: "linear-gradient(120deg,#0f172a,#1e293b)", color: "#fff", display: "flex", flexDirection: { xs: "column", md: "row" }, alignItems: { md: "center" }, justifyContent: "space-between", gap: 3 }}>
|
||||
<Box>
|
||||
<Typography sx={{ fontWeight: 800, fontSize: { xs: 24, md: 30 }, mb: 1 }}>Ready to organize your search?</Typography>
|
||||
<Typography sx={{ color: "#94a3b8", fontSize: 17 }}>Sign in to start tracking applications, tailoring CVs, and following up with intent.</Typography>
|
||||
</Box>
|
||||
<Button size="large" variant="contained" onClick={() => navigate("/login")} sx={{ background: "linear-gradient(90deg,#6366f1,#22d3ee)", color: "#0b1020", fontWeight: 800, px: 4, py: 1.25, whiteSpace: "nowrap" }}>
|
||||
Sign in →
|
||||
</Button>
|
||||
</Box>
|
||||
</Container>
|
||||
|
||||
{/* Footer */}
|
||||
<Box sx={{ borderTop: "1px solid", borderColor: "divider", py: 4 }}>
|
||||
<Container maxWidth="lg">
|
||||
<Stack direction={{ xs: "column", sm: "row" }} justifyContent="space-between" alignItems="center" spacing={1}>
|
||||
<Typography sx={{ color: "text.secondary", fontSize: 14 }}>© {new Date().getFullYear()} JobTrack — a focused workspace for the modern job search.</Typography>
|
||||
<Button variant="text" onClick={() => navigate("/login")} sx={{ fontWeight: 700 }}>Sign in</Button>
|
||||
</Stack>
|
||||
</Container>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -267,15 +267,27 @@ export default function ProfilePage() {
|
||||
const [currentPassword, setCurrentPassword] = useState("");
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
|
||||
// Keep a ref to the latest carousel so the unmount cleanup can revoke the
|
||||
// outstanding preview object URLs without re-running on every change.
|
||||
const pdfCarouselRef = useRef<PdfCarouselItem[]>([]);
|
||||
useEffect(() => {
|
||||
pdfCarouselRef.current = pdfCarousel;
|
||||
}, [pdfCarousel]);
|
||||
|
||||
useEffect(() => {
|
||||
// Revoke any remaining preview object URLs only on unmount. Per-change
|
||||
// revocation is already handled explicitly in savePdfToCarousel (replace) and
|
||||
// resetPdfCarousel (clear); doing it here on every pdfCarousel change revoked
|
||||
// URLs that were still referenced by other items in the deck, breaking their
|
||||
// previews.
|
||||
return () => {
|
||||
pdfCarousel.forEach((item) => {
|
||||
pdfCarouselRef.current.forEach((item) => {
|
||||
if (item.pdfUrl) {
|
||||
window.URL.revokeObjectURL(item.pdfUrl);
|
||||
}
|
||||
});
|
||||
};
|
||||
}, [pdfCarousel]);
|
||||
}, []);
|
||||
|
||||
const loadProfile = useCallback(async () => {
|
||||
setLoading(true);
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import React from 'react';
|
||||
import '@testing-library/jest-dom';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import { ToastProvider } from './toast';
|
||||
import { I18nProvider } from './i18n/I18nProvider';
|
||||
import { api } from './api';
|
||||
|
||||
// Avoid pulling the date-fns v4 ESM adapter into Jest; the picker isn't under test here.
|
||||
jest.mock('@mui/x-date-pickers/DatePicker', () => ({
|
||||
DatePicker: ({ label }: any) => <div>{label}</div>,
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line import/first
|
||||
import AddJobModal from './components/AddJobModal';
|
||||
|
||||
jest.setTimeout(15000);
|
||||
|
||||
jest.mock('./api', () => ({
|
||||
api: {
|
||||
get: jest.fn(() => Promise.resolve({ data: [] })),
|
||||
post: jest.fn(() => Promise.resolve({ data: {} })),
|
||||
put: jest.fn(() => Promise.resolve({ data: {} })),
|
||||
patch: jest.fn(() => Promise.resolve({ data: {} })),
|
||||
delete: jest.fn(() => Promise.resolve({ data: {} })),
|
||||
interceptors: { request: { use: jest.fn() }, response: { use: jest.fn() } },
|
||||
},
|
||||
getApiErrorMessage: jest.fn(() => 'error'),
|
||||
}));
|
||||
|
||||
const mockedApi = api as jest.Mocked<typeof api>;
|
||||
|
||||
function renderModal(initialUrl?: string) {
|
||||
return render(
|
||||
<ToastProvider>
|
||||
<I18nProvider>
|
||||
<AddJobModal open initialUrl={initialUrl} onClose={() => {}} onCreated={() => {}} />
|
||||
</I18nProvider>
|
||||
</ToastProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockedApi.get.mockResolvedValue({ data: [] } as any);
|
||||
mockedApi.post.mockImplementation((url: string) => {
|
||||
if (url === '/jobimport/preview') {
|
||||
return Promise.resolve({ data: { success: true, title: 'Imported Backend Role', company: 'Acme', location: 'Oslo', description: 'desc', tags: ['C#'] } } as any);
|
||||
}
|
||||
return Promise.resolve({ data: {} } as any);
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => jest.clearAllMocks());
|
||||
|
||||
test('auto-imports from initialUrl and prefills the form', async () => {
|
||||
renderModal('https://example.com/jobs/123');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedApi.post).toHaveBeenCalledWith('/jobimport/preview', { url: 'https://example.com/jobs/123' });
|
||||
});
|
||||
|
||||
expect(await screen.findByDisplayValue('Imported Backend Role')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('does not auto-import when no initialUrl is given', async () => {
|
||||
renderModal(undefined);
|
||||
|
||||
// Wait for the modal to render, then confirm no import was triggered.
|
||||
expect(await screen.findByRole('dialog')).toBeInTheDocument();
|
||||
expect(mockedApi.post).not.toHaveBeenCalledWith('/jobimport/preview', expect.anything());
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
import React from 'react';
|
||||
import '@testing-library/jest-dom';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { ConfirmProvider } from './confirm';
|
||||
import { PromptProvider } from './prompt';
|
||||
import { ToastProvider } from './toast';
|
||||
import { I18nProvider } from './i18n/I18nProvider';
|
||||
import JobDetailsDialog from './components/JobDetailsDialog';
|
||||
import { api } from './api';
|
||||
|
||||
jest.setTimeout(15000);
|
||||
|
||||
jest.mock('./api', () => ({
|
||||
api: {
|
||||
get: jest.fn(),
|
||||
post: jest.fn(() => Promise.resolve({ data: {} })),
|
||||
put: jest.fn(() => Promise.resolve({ data: {} })),
|
||||
patch: jest.fn(() => Promise.resolve({ data: {} })),
|
||||
delete: jest.fn(() => Promise.resolve({ data: {} })),
|
||||
interceptors: { request: { use: jest.fn() }, response: { use: jest.fn() } },
|
||||
},
|
||||
getApiErrorMessage: jest.fn(() => 'error'),
|
||||
}));
|
||||
|
||||
const mockedApi = api as jest.Mocked<typeof api>;
|
||||
|
||||
function renderDialog() {
|
||||
return render(
|
||||
<ToastProvider>
|
||||
<I18nProvider>
|
||||
<ConfirmProvider>
|
||||
<PromptProvider>
|
||||
<JobDetailsDialog open jobId={42} onClose={() => {}} />
|
||||
</PromptProvider>
|
||||
</ConfirmProvider>
|
||||
</I18nProvider>
|
||||
</ToastProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockedApi.get.mockImplementation((url: string) => {
|
||||
if (url === '/jobapplications/42') {
|
||||
return Promise.resolve({ data: { id: 42, jobTitle: 'Backend Developer', status: 'Applied', dateApplied: new Date().toISOString(), daysSince: 3, company: { name: 'Acme' } } } as any);
|
||||
}
|
||||
if (url === '/jobapplications/42/status-suggestion') {
|
||||
return Promise.resolve({ data: { hasSuggestion: true, suggestedStatus: 'Interview', currentStatus: 'Applied', signal: 'schedule an interview', confidence: 'medium' } } as any);
|
||||
}
|
||||
if (url === '/auth/me') return Promise.resolve({ data: { roles: [] } } as any);
|
||||
if (url === '/jobapplications/42/history') return Promise.resolve({ data: [] } as any);
|
||||
if (url === '/attachments/42') return Promise.resolve({ data: [] } as any);
|
||||
return Promise.resolve({ data: {} } as any);
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test('status suggestion banner appears and applies via PATCH', async () => {
|
||||
renderDialog();
|
||||
|
||||
expect(await screen.findByText(/looks like a move to interview/i)).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: /move to interview/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedApi.patch).toHaveBeenCalledWith('/jobapplications/42/status', { status: 'Interview' });
|
||||
});
|
||||
});
|
||||
|
||||
test('no banner when there is no suggestion', async () => {
|
||||
mockedApi.get.mockImplementation((url: string) => {
|
||||
if (url === '/jobapplications/42') {
|
||||
return Promise.resolve({ data: { id: 42, jobTitle: 'Backend Developer', status: 'Applied', dateApplied: new Date().toISOString(), daysSince: 3, company: { name: 'Acme' } } } as any);
|
||||
}
|
||||
if (url === '/jobapplications/42/status-suggestion') {
|
||||
return Promise.resolve({ data: { hasSuggestion: false } } as any);
|
||||
}
|
||||
if (url === '/auth/me') return Promise.resolve({ data: { roles: [] } } as any);
|
||||
if (url === '/jobapplications/42/history') return Promise.resolve({ data: [] } as any);
|
||||
if (url === '/attachments/42') return Promise.resolve({ data: [] } as any);
|
||||
return Promise.resolve({ data: {} } as any);
|
||||
});
|
||||
|
||||
renderDialog();
|
||||
|
||||
expect(await screen.findByText(/backend developer/i)).toBeInTheDocument();
|
||||
expect(screen.queryByText(/looks like a move to/i)).not.toBeInTheDocument();
|
||||
});
|
||||
@@ -24,7 +24,7 @@ function buildLightPalette(accentColor: string): PaletteLike {
|
||||
const disabledBackground = "#E4E1E6";
|
||||
|
||||
return {
|
||||
primary: buildPrimary(accentColor || "#15803D"),
|
||||
primary: buildPrimary(accentColor || "#6366F1"),
|
||||
secondary: {
|
||||
lighter: "#E0E0FF",
|
||||
light: "#C3C4E4",
|
||||
@@ -78,9 +78,11 @@ function buildLightPalette(accentColor: string): PaletteLike {
|
||||
disabled,
|
||||
},
|
||||
divider,
|
||||
background: { default: background, paper: background },
|
||||
// Soft grey app background with white paper gives the layered dashboard look
|
||||
// from the product mockups; cards/inputs (paper) sit above it.
|
||||
background: { default: "#F4F6FB", paper: background },
|
||||
action: {
|
||||
hover: alpha(accentColor || "#15803D", 0.05),
|
||||
hover: alpha(accentColor || "#6366F1", 0.05),
|
||||
disabled: alpha(disabled, 0.6),
|
||||
disabledBackground: alpha(disabledBackground, 0.9),
|
||||
},
|
||||
@@ -99,7 +101,7 @@ function buildDarkPalette(accentColor: string): PaletteLike {
|
||||
const disabledBackground = alpha("#FFFFFF", 0.08);
|
||||
|
||||
return {
|
||||
primary: buildPrimary(accentColor || "#15803D"),
|
||||
primary: buildPrimary(accentColor || "#6366F1"),
|
||||
secondary: {
|
||||
lighter: alpha(secondaryMain, 0.22),
|
||||
light: alpha(secondaryMain, 0.14),
|
||||
@@ -155,7 +157,7 @@ function buildDarkPalette(accentColor: string): PaletteLike {
|
||||
divider,
|
||||
background: { default: bg, paper },
|
||||
action: {
|
||||
hover: alpha(accentColor || "#15803D", 0.16),
|
||||
hover: alpha(accentColor || "#6366F1", 0.16),
|
||||
disabled: alpha("#FFFFFF", 0.5),
|
||||
disabledBackground,
|
||||
},
|
||||
@@ -216,7 +218,7 @@ export const getTheme = (_mode: "light" | "dark", accentColor: string) => {
|
||||
light: { palette: lightPalette, customShadows: buildCustomShadows(lightPalette) },
|
||||
dark: { palette: darkPalette, customShadows: buildCustomShadows(darkPalette) },
|
||||
},
|
||||
shape: { borderRadius: 8 },
|
||||
shape: { borderRadius: 10 },
|
||||
typography: buildTypography() as any,
|
||||
} as any) as any;
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ export function setThemeModePref(v: ThemeModePref) {
|
||||
export function getAccentColor(): string {
|
||||
const raw = window.localStorage.getItem(k("accentColor"));
|
||||
if (raw && /^#[0-9a-fA-F]{6}$/.test(raw)) return raw;
|
||||
return "#15803d";
|
||||
return "#6366f1";
|
||||
}
|
||||
|
||||
export function setAccentColor(v: string) {
|
||||
|
||||
@@ -138,6 +138,16 @@ export interface MatchScoreSectionCoverage {
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface StatusSuggestion {
|
||||
hasSuggestion: boolean;
|
||||
suggestedStatus?: string | null;
|
||||
currentStatus?: string | null;
|
||||
signal?: string | null;
|
||||
confidence?: string | null;
|
||||
messageDate?: string | null;
|
||||
messageSubject?: string | null;
|
||||
}
|
||||
|
||||
export interface MatchScore {
|
||||
score: number;
|
||||
band: string;
|
||||
|
||||
+105
-48
@@ -26,6 +26,18 @@ OCR_LANGUAGES = "eng"
|
||||
IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp"}
|
||||
OLLAMA_BASE_URL = os.getenv("OLLAMA_BASE_URL", "http://127.0.0.1:11434").rstrip("/")
|
||||
OLLAMA_MODEL = os.getenv("OLLAMA_MODEL", "")
|
||||
|
||||
# AI provider router. Structured /cv/* calls (the heavy ones) dispatch through the
|
||||
# active provider so production can offload a weak local GPU to a cloud provider.
|
||||
# Default stays "ollama" so the service works keyless/local. /summarize stays local
|
||||
# (distilbart) regardless of this setting.
|
||||
AI_PROVIDER = (os.getenv("AI_PROVIDER", "ollama").strip().lower() or "ollama")
|
||||
GEMINI_API_KEY = os.getenv("GEMINI_API_KEY", "").strip()
|
||||
GEMINI_MODEL = os.getenv("GEMINI_MODEL", "gemini-2.0-flash").strip()
|
||||
GEMINI_BASE_URL = os.getenv("GEMINI_BASE_URL", "https://generativelanguage.googleapis.com").rstrip("/")
|
||||
GROQ_API_KEY = os.getenv("GROQ_API_KEY", "").strip()
|
||||
GROQ_MODEL = os.getenv("GROQ_MODEL", "llama-3.3-70b-versatile").strip()
|
||||
GROQ_BASE_URL = os.getenv("GROQ_BASE_URL", "https://api.groq.com/openai/v1").rstrip("/")
|
||||
SKIP_MODEL_LOAD = os.getenv("AI_SERVICE_SKIP_MODEL_LOAD", "") == "1"
|
||||
EAGER_MODEL_LOAD = os.getenv("AI_SERVICE_EAGER_MODEL_LOAD", "") == "1"
|
||||
|
||||
@@ -174,6 +186,8 @@ async def health():
|
||||
"model_disabled": MODEL_DISABLED,
|
||||
"summarize_available": MODEL_LOADED and not MODEL_DISABLED,
|
||||
"model_load_error": MODEL_LOAD_ERROR,
|
||||
"ai_provider": AI_PROVIDER,
|
||||
"ai_provider_configured": _provider_configured(),
|
||||
**_ollama_status(),
|
||||
}
|
||||
|
||||
@@ -390,37 +404,106 @@ def _model_summarize(text: str, max_length: int, min_length: int) -> str:
|
||||
return tokenizer.decode(outputs[0], skip_special_tokens=True).strip()
|
||||
|
||||
|
||||
def _ollama_generate_json(prompt: str):
|
||||
_PROVIDER_DISPLAY = {"ollama": "Ollama", "gemini": "Gemini", "groq": "Groq"}
|
||||
|
||||
|
||||
def _provider_display(provider: str) -> str:
|
||||
return _PROVIDER_DISPLAY.get(provider, provider or "AI provider")
|
||||
|
||||
|
||||
def _provider_configured() -> bool:
|
||||
if AI_PROVIDER == "gemini":
|
||||
return bool(GEMINI_API_KEY)
|
||||
if AI_PROVIDER == "groq":
|
||||
return bool(GROQ_API_KEY)
|
||||
return bool(OLLAMA_MODEL)
|
||||
|
||||
|
||||
def _http_post_json(url: str, payload: dict, headers: dict, timeout: int) -> dict:
|
||||
data = json.dumps(payload).encode("utf-8")
|
||||
req = urllib_request.Request(
|
||||
url,
|
||||
data=data,
|
||||
headers={"Content-Type": "application/json", **headers},
|
||||
method="POST",
|
||||
)
|
||||
with urllib_request.urlopen(req, timeout=timeout) as response:
|
||||
return json.loads(response.read().decode("utf-8"))
|
||||
|
||||
|
||||
def _ollama_generate(prompt: str, *, json_mode: bool, temperature: float, timeout: int) -> str:
|
||||
if not OLLAMA_MODEL:
|
||||
raise HTTPException(status_code=503, detail="OLLAMA_MODEL is not configured.")
|
||||
|
||||
payload = json.dumps({
|
||||
payload = {
|
||||
"model": OLLAMA_MODEL,
|
||||
"prompt": prompt,
|
||||
"stream": False,
|
||||
"format": "json",
|
||||
"options": {"temperature": 0.1}
|
||||
}).encode("utf-8")
|
||||
"options": {"temperature": temperature},
|
||||
}
|
||||
if json_mode:
|
||||
payload["format"] = "json"
|
||||
body = _http_post_json(f"{OLLAMA_BASE_URL}/api/generate", payload, {}, timeout)
|
||||
return (body.get("response") or "").strip()
|
||||
|
||||
req = urllib_request.Request(
|
||||
f"{OLLAMA_BASE_URL}/api/generate",
|
||||
data=payload,
|
||||
headers={"Content-Type": "application/json"},
|
||||
method="POST",
|
||||
)
|
||||
|
||||
def _gemini_generate(prompt: str, *, json_mode: bool, temperature: float, timeout: int) -> str:
|
||||
if not GEMINI_API_KEY:
|
||||
raise HTTPException(status_code=503, detail="GEMINI_API_KEY is not configured.")
|
||||
generation_config = {"temperature": temperature}
|
||||
if json_mode:
|
||||
generation_config["responseMimeType"] = "application/json"
|
||||
payload = {
|
||||
"contents": [{"role": "user", "parts": [{"text": prompt}]}],
|
||||
"generationConfig": generation_config,
|
||||
}
|
||||
# Pass the key via header (not the URL query string, which can leak into logs).
|
||||
url = f"{GEMINI_BASE_URL}/v1beta/models/{GEMINI_MODEL}:generateContent"
|
||||
body = _http_post_json(url, payload, {"x-goog-api-key": GEMINI_API_KEY}, timeout)
|
||||
candidates = body.get("candidates") or []
|
||||
if not candidates:
|
||||
return ""
|
||||
parts = (candidates[0].get("content") or {}).get("parts") or []
|
||||
return "".join(part.get("text", "") for part in parts).strip()
|
||||
|
||||
|
||||
def _groq_generate(prompt: str, *, json_mode: bool, temperature: float, timeout: int) -> str:
|
||||
if not GROQ_API_KEY:
|
||||
raise HTTPException(status_code=503, detail="GROQ_API_KEY is not configured.")
|
||||
payload = {
|
||||
"model": GROQ_MODEL,
|
||||
"messages": [{"role": "user", "content": prompt}],
|
||||
"temperature": temperature,
|
||||
}
|
||||
if json_mode:
|
||||
payload["response_format"] = {"type": "json_object"}
|
||||
url = f"{GROQ_BASE_URL}/chat/completions"
|
||||
body = _http_post_json(url, payload, {"Authorization": f"Bearer {GROQ_API_KEY}"}, timeout)
|
||||
choices = body.get("choices") or []
|
||||
if not choices:
|
||||
return ""
|
||||
return ((choices[0].get("message") or {}).get("content") or "").strip()
|
||||
|
||||
|
||||
def _provider_generate(prompt: str, *, json_mode: bool, temperature: float, timeout: int) -> str:
|
||||
provider = AI_PROVIDER
|
||||
try:
|
||||
with urllib_request.urlopen(req, timeout=120) as response:
|
||||
body = json.loads(response.read().decode("utf-8"))
|
||||
if provider == "gemini":
|
||||
return _gemini_generate(prompt, json_mode=json_mode, temperature=temperature, timeout=timeout)
|
||||
if provider == "groq":
|
||||
return _groq_generate(prompt, json_mode=json_mode, temperature=temperature, timeout=timeout)
|
||||
return _ollama_generate(prompt, json_mode=json_mode, temperature=temperature, timeout=timeout)
|
||||
except HTTPException:
|
||||
raise
|
||||
except HTTPError as ex:
|
||||
raise HTTPException(status_code=502, detail=f"Ollama request failed with {ex.code}.")
|
||||
raise HTTPException(status_code=502, detail=f"{_provider_display(provider)} request failed with {ex.code}.")
|
||||
except URLError as ex:
|
||||
raise HTTPException(status_code=503, detail=f"Ollama is unreachable: {ex.reason}.")
|
||||
raise HTTPException(status_code=503, detail=f"{_provider_display(provider)} is unreachable: {ex.reason}.")
|
||||
|
||||
raw = (body.get("response") or "").strip()
|
||||
|
||||
def _ollama_generate_json(prompt: str):
|
||||
raw = _provider_generate(prompt, json_mode=True, temperature=0.1, timeout=120)
|
||||
if not raw:
|
||||
raise HTTPException(status_code=502, detail="Ollama returned an empty response.")
|
||||
|
||||
raise HTTPException(status_code=502, detail=f"{_provider_display(AI_PROVIDER)} returned an empty response.")
|
||||
try:
|
||||
return json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
@@ -428,39 +511,13 @@ def _ollama_generate_json(prompt: str):
|
||||
end = raw.rfind("}")
|
||||
if start >= 0 and end > start:
|
||||
return json.loads(raw[start:end + 1])
|
||||
raise HTTPException(status_code=502, detail="Ollama did not return valid JSON.")
|
||||
raise HTTPException(status_code=502, detail=f"{_provider_display(AI_PROVIDER)} did not return valid JSON.")
|
||||
|
||||
|
||||
def _ollama_generate_text(prompt: str) -> str:
|
||||
if not OLLAMA_MODEL:
|
||||
raise HTTPException(status_code=503, detail="OLLAMA_MODEL is not configured.")
|
||||
|
||||
payload = json.dumps({
|
||||
"model": OLLAMA_MODEL,
|
||||
"prompt": prompt,
|
||||
"stream": False,
|
||||
"options": {"temperature": 0.2}
|
||||
}).encode("utf-8")
|
||||
|
||||
req = urllib_request.Request(
|
||||
f"{OLLAMA_BASE_URL}/api/generate",
|
||||
data=payload,
|
||||
headers={"Content-Type": "application/json"},
|
||||
method="POST",
|
||||
)
|
||||
|
||||
try:
|
||||
with urllib_request.urlopen(req, timeout=180) as response:
|
||||
body = json.loads(response.read().decode("utf-8"))
|
||||
except HTTPError as ex:
|
||||
raise HTTPException(status_code=502, detail=f"Ollama request failed with {ex.code}.")
|
||||
except URLError as ex:
|
||||
raise HTTPException(status_code=503, detail=f"Ollama is unreachable: {ex.reason}.")
|
||||
|
||||
raw = (body.get("response") or "").strip()
|
||||
raw = _provider_generate(prompt, json_mode=False, temperature=0.2, timeout=180)
|
||||
if not raw:
|
||||
raise HTTPException(status_code=502, detail="Ollama returned an empty rewrite.")
|
||||
|
||||
raise HTTPException(status_code=502, detail=f"{_provider_display(AI_PROVIDER)} returned an empty rewrite.")
|
||||
return raw
|
||||
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import importlib
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
@@ -141,3 +142,106 @@ def test_classify_block_defaults_missing_section_to_other(monkeypatch):
|
||||
assert payload["bullets"] == []
|
||||
assert payload["summary"] == []
|
||||
assert payload["skills"] == []
|
||||
|
||||
|
||||
# --- AI provider router -------------------------------------------------------
|
||||
|
||||
class _FakeResponse:
|
||||
def __init__(self, payload):
|
||||
self._data = json.dumps(payload).encode("utf-8")
|
||||
|
||||
def read(self):
|
||||
return self._data
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc):
|
||||
return False
|
||||
|
||||
|
||||
def _install_fake_urlopen(monkeypatch, module, response_payload, captured):
|
||||
def fake_urlopen(req, timeout=None):
|
||||
captured["url"] = req.full_url
|
||||
captured["headers"] = {k.lower(): v for k, v in req.header_items()}
|
||||
captured["body"] = json.loads(req.data.decode("utf-8"))
|
||||
return _FakeResponse(response_payload)
|
||||
|
||||
monkeypatch.setattr(module.urllib_request, "urlopen", fake_urlopen)
|
||||
|
||||
|
||||
def test_provider_defaults_to_ollama_and_is_unchanged(monkeypatch):
|
||||
monkeypatch.delenv("AI_PROVIDER", raising=False)
|
||||
monkeypatch.setenv("OLLAMA_BASE_URL", "http://ollama-host:11434")
|
||||
module = load_app_module(monkeypatch, ollama_model="qwen2.5:7b")
|
||||
assert module.AI_PROVIDER == "ollama"
|
||||
|
||||
captured = {}
|
||||
_install_fake_urlopen(monkeypatch, module, {"response": '{"score": 7}'}, captured)
|
||||
|
||||
assert module._ollama_generate_json("hi") == {"score": 7}
|
||||
assert captured["url"] == "http://ollama-host:11434/api/generate"
|
||||
assert captured["body"]["model"] == "qwen2.5:7b"
|
||||
assert captured["body"]["format"] == "json"
|
||||
assert captured["body"]["options"]["temperature"] == 0.1
|
||||
|
||||
|
||||
def test_provider_gemini_dispatch(monkeypatch):
|
||||
monkeypatch.setenv("AI_PROVIDER", "gemini")
|
||||
monkeypatch.setenv("GEMINI_API_KEY", "test-key")
|
||||
monkeypatch.setenv("GEMINI_MODEL", "gemini-2.0-flash")
|
||||
module = load_app_module(monkeypatch)
|
||||
|
||||
captured = {}
|
||||
payload = {"candidates": [{"content": {"parts": [{"text": '{"score": 9}'}]}}]}
|
||||
_install_fake_urlopen(monkeypatch, module, payload, captured)
|
||||
|
||||
assert module._ollama_generate_json("hi") == {"score": 9}
|
||||
assert "generativelanguage" in captured["url"]
|
||||
assert "gemini-2.0-flash:generateContent" in captured["url"]
|
||||
assert "key=" not in captured["url"] # key must not be in the URL
|
||||
assert captured["headers"].get("x-goog-api-key") == "test-key"
|
||||
assert captured["body"]["generationConfig"]["responseMimeType"] == "application/json"
|
||||
|
||||
|
||||
def test_provider_groq_dispatch(monkeypatch):
|
||||
monkeypatch.setenv("AI_PROVIDER", "groq")
|
||||
monkeypatch.setenv("GROQ_API_KEY", "test-key")
|
||||
module = load_app_module(monkeypatch)
|
||||
|
||||
captured = {}
|
||||
payload = {"choices": [{"message": {"content": "rewritten CV text"}}]}
|
||||
_install_fake_urlopen(monkeypatch, module, payload, captured)
|
||||
|
||||
assert module._ollama_generate_text("rewrite this") == "rewritten CV text"
|
||||
assert captured["url"].endswith("/chat/completions")
|
||||
assert captured["headers"].get("authorization") == "Bearer test-key"
|
||||
assert captured["body"]["messages"][0]["content"] == "rewrite this"
|
||||
|
||||
|
||||
def test_provider_missing_cloud_key_raises_503(monkeypatch):
|
||||
monkeypatch.setenv("AI_PROVIDER", "gemini")
|
||||
monkeypatch.delenv("GEMINI_API_KEY", raising=False)
|
||||
module = load_app_module(monkeypatch)
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
try:
|
||||
module._ollama_generate_json("hi")
|
||||
except HTTPException as ex:
|
||||
assert ex.status_code == 503
|
||||
assert "GEMINI_API_KEY" in ex.detail
|
||||
else:
|
||||
raise AssertionError("expected HTTPException for missing GEMINI_API_KEY")
|
||||
|
||||
|
||||
def test_health_reports_active_provider(monkeypatch):
|
||||
monkeypatch.setenv("AI_PROVIDER", "gemini")
|
||||
monkeypatch.setenv("GEMINI_API_KEY", "test-key")
|
||||
module = load_app_module(monkeypatch)
|
||||
client = TestClient(module.app)
|
||||
|
||||
payload = client.get("/health").json()
|
||||
|
||||
assert payload["ai_provider"] == "gemini"
|
||||
assert payload["ai_provider_configured"] is True
|
||||
|
||||
Reference in New Issue
Block a user