feat: add salary insights
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user