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.SavedAt,
|
||||||
j.CompanyId,
|
j.CompanyId,
|
||||||
CompanyName = j.Company.Name,
|
CompanyName = j.Company.Name,
|
||||||
CompanySource = j.Company.Source
|
CompanySource = j.Company.Source,
|
||||||
|
j.SalaryMin,
|
||||||
|
j.SalaryMax,
|
||||||
|
j.SalaryCurrency,
|
||||||
|
j.SalaryPeriod
|
||||||
})
|
})
|
||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
@@ -176,6 +180,23 @@ namespace JobTrackerApi.Services
|
|||||||
.Select(p => new StageDurationDto(p.Stage, p.MedianDays, p.Count))
|
.Select(p => new StageDurationDto(p.Stage, p.MedianDays, p.Count))
|
||||||
.ToList();
|
.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(
|
return new AnalyticsOverviewDto(
|
||||||
Funnel: funnel,
|
Funnel: funnel,
|
||||||
ResponseRateBySource: responseRateBySource,
|
ResponseRateBySource: responseRateBySource,
|
||||||
@@ -183,7 +204,8 @@ namespace JobTrackerApi.Services
|
|||||||
MedianDaysToFirstResponse: medianDays,
|
MedianDaysToFirstResponse: medianDays,
|
||||||
TotalResponses: activeJobs.Count(j => j.ResponseReceived || j.ResponseDate is not null),
|
TotalResponses: activeJobs.Count(j => j.ResponseReceived || j.ResponseDate is not null),
|
||||||
TotalActive: activeJobs.Count,
|
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 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(
|
public sealed record AnalyticsOverviewDto(
|
||||||
List<FunnelStagePoint> Funnel,
|
List<FunnelStagePoint> Funnel,
|
||||||
List<ResponseRatePoint> ResponseRateBySource,
|
List<ResponseRatePoint> ResponseRateBySource,
|
||||||
@@ -28,6 +30,7 @@ namespace JobTrackerApi.Models
|
|||||||
double? MedianDaysToFirstResponse,
|
double? MedianDaysToFirstResponse,
|
||||||
int TotalResponses,
|
int TotalResponses,
|
||||||
int TotalActive,
|
int TotalActive,
|
||||||
List<StageDurationDto> TimeInStage
|
List<StageDurationDto> TimeInStage,
|
||||||
|
List<SalaryInsightDto> SalaryInsights
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -309,7 +309,7 @@ Features:
|
|||||||
Possible future features:
|
Possible future features:
|
||||||
|
|
||||||
- Interview preparation.
|
- Interview preparation.
|
||||||
- Salary insights.
|
- Salary insights. ✅ Dashboard groups comparable tracked ranges by currency and pay period (2026-07-31).
|
||||||
- Career analytics.
|
- Career analytics.
|
||||||
- Skill recommendations.
|
- Skill recommendations.
|
||||||
- Learning paths.
|
- Learning paths.
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ type OverviewAnalytics = {
|
|||||||
totalResponses: number;
|
totalResponses: number;
|
||||||
totalActive: number;
|
totalActive: number;
|
||||||
timeInStage?: { stage: string; medianDays: number; count: 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[] }[] };
|
type TagTrendResponse = { months: string[]; series: { tag: string; counts: number[] }[] };
|
||||||
|
|
||||||
@@ -528,6 +529,28 @@ export default function DashboardView() {
|
|||||||
) : null}
|
) : null}
|
||||||
</Box>
|
</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 }}>
|
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", xl: "1.15fr 0.85fr" }, gap: 2, mt: 2 }}>
|
||||||
{!summaryResource.loading && !summaryResource.error ? (
|
{!summaryResource.loading && !summaryResource.error ? (
|
||||||
<SectionCard>
|
<SectionCard>
|
||||||
|
|||||||
Reference in New Issue
Block a user