diff --git a/JobTrackerApi.Tests/AnalyticsSalaryTests.cs b/JobTrackerApi.Tests/AnalyticsSalaryTests.cs new file mode 100644 index 0000000..1773a49 --- /dev/null +++ b/JobTrackerApi.Tests/AnalyticsSalaryTests.cs @@ -0,0 +1,31 @@ +using JobTrackerApi.Models; +using JobTrackerApi.Services; +using JobTrackerApi.Tests.TestSupport; +using Xunit; + +namespace JobTrackerApi.Tests; + +public sealed class AnalyticsSalaryTests +{ + [Fact] + public async Task Salary_insights_keep_currency_and_period_groups_separate() + { + await using var db = TestHostFactory.CreateInMemoryDb("user-1"); + var company = new Company { Name = "Acme", OwnerUserId = "user-1" }; + db.Companies.Add(company); + db.JobApplications.AddRange( + new JobApplication { OwnerUserId = "user-1", Company = company, JobTitle = "A", Status = "Applied", SalaryMin = 600_000, SalaryMax = 800_000, SalaryCurrency = "NOK", SalaryPeriod = "year" }, + new JobApplication { OwnerUserId = "user-1", Company = company, JobTitle = "B", Status = "Applied", SalaryMin = 700_000, SalaryMax = 900_000, SalaryCurrency = "NOK", SalaryPeriod = "year" }, + new JobApplication { OwnerUserId = "user-1", Company = company, JobTitle = "C", Status = "Applied", SalaryMin = 50, SalaryMax = 70, SalaryCurrency = "EUR", SalaryPeriod = "hour" }); + await db.SaveChangesAsync(); + + var result = await new AnalyticsService(db).GetAnalyticsOverviewAsync(default); + + Assert.Equal(2, result.SalaryInsights.Count); + var nok = Assert.Single(result.SalaryInsights, x => x.Currency == "NOK" && x.Period == "year"); + Assert.Equal(2, nok.Count); + Assert.Equal(600_000, nok.Minimum); + Assert.Equal(900_000, nok.Maximum); + Assert.Equal(750_000, nok.AverageMidpoint); + } +} diff --git a/JobTrackerApi/Services/AnalyticsService.cs b/JobTrackerApi/Services/AnalyticsService.cs index 5555066..c1982de 100644 --- a/JobTrackerApi/Services/AnalyticsService.cs +++ b/JobTrackerApi/Services/AnalyticsService.cs @@ -85,7 +85,11 @@ namespace JobTrackerApi.Services j.SavedAt, j.CompanyId, CompanyName = j.Company.Name, - CompanySource = j.Company.Source + CompanySource = j.Company.Source, + j.SalaryMin, + j.SalaryMax, + j.SalaryCurrency, + j.SalaryPeriod }) .ToListAsync(cancellationToken); @@ -176,6 +180,23 @@ namespace JobTrackerApi.Services .Select(p => new StageDurationDto(p.Stage, p.MedianDays, p.Count)) .ToList(); + var salaryInsights = activeJobs + .Where(j => (j.SalaryMin is not null || j.SalaryMax is not null) + && !string.IsNullOrWhiteSpace(j.SalaryCurrency) + && !string.IsNullOrWhiteSpace(j.SalaryPeriod)) + .GroupBy(j => new { Currency = j.SalaryCurrency!.ToUpperInvariant(), Period = j.SalaryPeriod!.ToLowerInvariant() }) + .Select(g => new SalaryInsightDto( + g.Key.Currency, + g.Key.Period, + g.Count(), + g.Min(j => j.SalaryMin ?? j.SalaryMax!.Value), + g.Max(j => j.SalaryMax ?? j.SalaryMin!.Value), + Math.Round(g.Average(j => ((j.SalaryMin ?? j.SalaryMax!.Value) + (j.SalaryMax ?? j.SalaryMin!.Value)) / 2m), 0))) + .OrderByDescending(x => x.Count) + .ThenBy(x => x.Currency) + .ThenBy(x => x.Period) + .ToList(); + return new AnalyticsOverviewDto( Funnel: funnel, ResponseRateBySource: responseRateBySource, @@ -183,7 +204,8 @@ namespace JobTrackerApi.Services MedianDaysToFirstResponse: medianDays, TotalResponses: activeJobs.Count(j => j.ResponseReceived || j.ResponseDate is not null), TotalActive: activeJobs.Count, - TimeInStage: timeInStage + TimeInStage: timeInStage, + SalaryInsights: salaryInsights ); } } diff --git a/Models/AnalyticsDtos.cs b/Models/AnalyticsDtos.cs index 7c014ff..2919c14 100644 --- a/Models/AnalyticsDtos.cs +++ b/Models/AnalyticsDtos.cs @@ -21,6 +21,8 @@ namespace JobTrackerApi.Models public sealed record StageDurationDto(string Stage, double MedianDays, int Count); + public sealed record SalaryInsightDto(string Currency, string Period, int Count, decimal Minimum, decimal Maximum, decimal AverageMidpoint); + public sealed record AnalyticsOverviewDto( List Funnel, List ResponseRateBySource, @@ -28,6 +30,7 @@ namespace JobTrackerApi.Models double? MedianDaysToFirstResponse, int TotalResponses, int TotalActive, - List TimeInStage + List TimeInStage, + List SalaryInsights ); } diff --git a/docs/product/roadmap.md b/docs/product/roadmap.md index 03522fa..a601890 100644 --- a/docs/product/roadmap.md +++ b/docs/product/roadmap.md @@ -309,7 +309,7 @@ Features: Possible future features: - Interview preparation. -- Salary insights. +- Salary insights. ✅ Dashboard groups comparable tracked ranges by currency and pay period (2026-07-31). - Career analytics. - Skill recommendations. - Learning paths. diff --git a/job-tracker-ui/src/components/DashboardView.tsx b/job-tracker-ui/src/components/DashboardView.tsx index d09d333..4546107 100644 --- a/job-tracker-ui/src/components/DashboardView.tsx +++ b/job-tracker-ui/src/components/DashboardView.tsx @@ -50,6 +50,7 @@ type OverviewAnalytics = { totalResponses: number; totalActive: number; timeInStage?: { stage: string; medianDays: number; count: number }[]; + salaryInsights?: { currency: string; period: string; count: number; minimum: number; maximum: number; averageMidpoint: number }[]; }; type TagTrendResponse = { months: string[]; series: { tag: string; counts: number[] }[] }; @@ -528,6 +529,28 @@ export default function DashboardView() { ) : null} + + {(overview?.salaryInsights?.length ?? 0) > 0 ? ( + + + Salary insights + Comparable ranges from jobs in your tracker. Currencies and pay periods stay separate. + + {overview!.salaryInsights!.map((item) => { + const money = new Intl.NumberFormat(undefined, { style: "currency", currency: item.currency, maximumFractionDigits: 0 }); + return ( + + {item.currency} · per {item.period} · {item.count} {item.count === 1 ? "job" : "jobs"} + {money.format(item.averageMidpoint)} + Range {money.format(item.minimum)}–{money.format(item.maximum)} + + ); + })} + + + + ) : null} + {!summaryResource.loading && !summaryResource.error ? (