feat: add salary insights
CI and Deploy / test (push) Successful in 2m41s
CI and Deploy / deploy (push) Successful in 58s

This commit is contained in:
cesnimda
2026-07-31 00:15:38 +02:00
parent af3cf1cdaa
commit 235d22c059
5 changed files with 83 additions and 4 deletions
@@ -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);
}
}
+24 -2
View File
@@ -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
);
}
}
+4 -1
View File
@@ -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<FunnelStagePoint> Funnel,
List<ResponseRatePoint> ResponseRateBySource,
@@ -28,6 +30,7 @@ namespace JobTrackerApi.Models
double? MedianDaysToFirstResponse,
int TotalResponses,
int TotalActive,
List<StageDurationDto> TimeInStage
List<StageDurationDto> TimeInStage,
List<SalaryInsightDto> SalaryInsights
);
}
+1 -1
View File
@@ -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.
@@ -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}
</Box>
{(overview?.salaryInsights?.length ?? 0) > 0 ? (
<Box sx={{ mt: 2 }}>
<SectionCard>
<Typography variant="h6">Salary insights</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.5, mb: 1.5 }}>Comparable ranges from jobs in your tracker. Currencies and pay periods stay separate.</Typography>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "repeat(3, 1fr)" }, gap: 1.5 }}>
{overview!.salaryInsights!.map((item) => {
const money = new Intl.NumberFormat(undefined, { style: "currency", currency: item.currency, maximumFractionDigits: 0 });
return (
<Box key={item.currency + item.period} sx={{ p: 1.5, borderRadius: 3, backgroundColor: alpha(theme.palette.primary.main, 0.05) }}>
<Typography variant="caption" color="text.secondary">{item.currency} · per {item.period} · {item.count} {item.count === 1 ? "job" : "jobs"}</Typography>
<Typography variant="h6" sx={{ mt: 0.5 }}>{money.format(item.averageMidpoint)}</Typography>
<Typography variant="body2" color="text.secondary">Range {money.format(item.minimum)}{money.format(item.maximum)}</Typography>
</Box>
);
})}
</Box>
</SectionCard>
</Box>
) : null}
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", xl: "1.15fr 0.85fr" }, gap: 2, mt: 2 }}>
{!summaryResource.loading && !summaryResource.error ? (
<SectionCard>