From 83e6430a246aecf847aaf8c614c0208ff444d919 Mon Sep 17 00:00:00 2001 From: cesnimda Date: Thu, 2 Jul 2026 22:15:37 +0200 Subject: [PATCH] feat: structured salary fields (min/max/currency/period) Adds SalaryMin/SalaryMax/SalaryCurrency/SalaryPeriod alongside the existing free-text Salary field (kept for back-compat and display). - JobApplication model + idempotent column bridging for SQLite and MySQL - Create/Update DTOs with NormalizeSalary (clamps negatives, swaps inverted min/max, uppercases currency, whitelists period) - JobApplicationDto exposes the fields; CSV export gains 4 columns - UI: add/edit dialogs get min/max/currency/period inputs; job table renders a formatted range via shared salary.ts formatter (falls back to free-text when structured values are absent) - EN/NB translations; backend + full frontend suites green Co-Authored-By: Claude Fable 5 --- .../JobApplicationsEndpointBehaviorTests.cs | 109 ++++++++++++++++++ JobTrackerApi/Controllers/ExportController.cs | 8 ++ .../Controllers/JobApplicationsController.cs | 37 ++++++ .../StartupInitializationExtensions.cs | 10 ++ Models/JobApplication.cs | 6 + job-tracker-ui/src/components/AddJobModal.tsx | 17 +++ .../src/components/EditJobDialog.tsx | 21 ++++ job-tracker-ui/src/components/JobTable.tsx | 5 +- job-tracker-ui/src/i18n/translations.ts | 14 +++ job-tracker-ui/src/salary.ts | 21 ++++ job-tracker-ui/src/types.ts | 4 + 11 files changed, 250 insertions(+), 2 deletions(-) create mode 100644 job-tracker-ui/src/salary.ts diff --git a/JobTrackerApi.Tests/JobApplicationsEndpointBehaviorTests.cs b/JobTrackerApi.Tests/JobApplicationsEndpointBehaviorTests.cs index fee8f7b..16f74c0 100644 --- a/JobTrackerApi.Tests/JobApplicationsEndpointBehaviorTests.cs +++ b/JobTrackerApi.Tests/JobApplicationsEndpointBehaviorTests.cs @@ -56,6 +56,115 @@ public sealed class JobApplicationsEndpointBehaviorTests Assert.Contains("Profile page", badRequest.Value?.ToString()); } + [Fact] + public async Task Create_normalizes_structured_salary() + { + await using var db = CreateDb(); + var company = new Company { Name = "Acme", OwnerUserId = "user-1" }; + db.Companies.Add(company); + await db.SaveChangesAsync(); + + var controller = CreateController(db, "user-1"); + var request = new JobApplicationsController.CreateJobApplicationRequest( + JobTitle: "Backend Dev", + CompanyId: company.Id, + Status: null, + Location: null, + Salary: "60-70k", + SalaryMin: 70000m, // min > max on purpose: normalization swaps them + SalaryMax: 60000m, + SalaryCurrency: " nok ", + SalaryPeriod: "YEAR", + NextAction: null, + FollowUpAt: null, + Notes: null, + Description: null, + TranslatedDescription: null, + DescriptionLanguage: null, + Tags: null, + Deadline: null, + CoverLetterText: null, + JobUrl: null, + DateApplied: null, + FeedbackRequestedAt: null, + HasResume: null, + HasCoverLetter: null, + HasPortfolio: null, + HasOtherAttachment: null); + + var result = await controller.Create(request, CancellationToken.None); + + Assert.NotNull(result); + var saved = await db.JobApplications.FirstAsync(); + Assert.Equal(60000m, saved.SalaryMin); + Assert.Equal(70000m, saved.SalaryMax); + Assert.Equal("NOK", saved.SalaryCurrency); + Assert.Equal("year", saved.SalaryPeriod); + Assert.Equal("60-70k", saved.Salary); + } + + [Fact] + public async Task Update_drops_invalid_salary_period_and_negative_values() + { + 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 = "Backend Dev", + CompanyId = company.Id, + OwnerUserId = "user-1", + SalaryMin = 50000m, + SalaryMax = 60000m, + SalaryCurrency = "NOK", + SalaryPeriod = "year", + }; + db.JobApplications.Add(job); + await db.SaveChangesAsync(); + + var controller = CreateController(db, "user-1"); + var request = new JobApplicationsController.UpdateJobApplicationRequest( + JobTitle: "Backend Dev", + CompanyId: company.Id, + Status: "Applied", + ResponseReceived: false, + ResponseDate: null, + Location: null, + Salary: null, + SalaryMin: -5m, + SalaryMax: null, + SalaryCurrency: "", + SalaryPeriod: "fortnight", + NextAction: null, + FollowUpAt: null, + HasResume: null, + HasCoverLetter: null, + HasPortfolio: null, + HasOtherAttachment: null, + Notes: null, + Description: null, + TranslatedDescription: null, + DescriptionLanguage: null, + Tags: null, + Deadline: null, + CoverLetterText: null, + JobUrl: null, + DateApplied: null, + FeedbackRequestedAt: null, + StatusChangedAt: null); + + var result = await controller.Update(job.Id, request, CancellationToken.None); + + Assert.IsType(result); + var saved = await db.JobApplications.FirstAsync(); + Assert.Null(saved.SalaryMin); + Assert.Null(saved.SalaryMax); + Assert.Null(saved.SalaryCurrency); + Assert.Null(saved.SalaryPeriod); + } + private static JobApplicationsController CreateController(JobTrackerContext db, string userId) { var summarizer = new Mock(); diff --git a/JobTrackerApi/Controllers/ExportController.cs b/JobTrackerApi/Controllers/ExportController.cs index 8af165c..2c1ba03 100644 --- a/JobTrackerApi/Controllers/ExportController.cs +++ b/JobTrackerApi/Controllers/ExportController.cs @@ -58,6 +58,10 @@ namespace JobTrackerApi.Controllers "DateApplied", "Location", "Salary", + "SalaryMin", + "SalaryMax", + "SalaryCurrency", + "SalaryPeriod", "NextAction", "FollowUpAt", "JobUrl", @@ -76,6 +80,10 @@ namespace JobTrackerApi.Controllers Esc(j.DateApplied.ToString("o")), Esc(j.Location), Esc(j.Salary), + Esc(j.SalaryMin?.ToString(System.Globalization.CultureInfo.InvariantCulture)), + Esc(j.SalaryMax?.ToString(System.Globalization.CultureInfo.InvariantCulture)), + Esc(j.SalaryCurrency), + Esc(j.SalaryPeriod), Esc(j.NextAction), Esc(j.FollowUpAt?.ToString("o")), Esc(j.JobUrl), diff --git a/JobTrackerApi/Controllers/JobApplicationsController.cs b/JobTrackerApi/Controllers/JobApplicationsController.cs index 58f6884..dc9b788 100644 --- a/JobTrackerApi/Controllers/JobApplicationsController.cs +++ b/JobTrackerApi/Controllers/JobApplicationsController.cs @@ -749,6 +749,10 @@ Canonical profile: Deadline: job.Deadline, Location: job.Location, Salary: job.Salary, + SalaryMin: job.SalaryMin, + SalaryMax: job.SalaryMax, + SalaryCurrency: job.SalaryCurrency, + SalaryPeriod: job.SalaryPeriod, NextAction: job.NextAction, FollowUpAt: job.FollowUpAt, FeedbackRequestedAt: job.FeedbackRequestedAt, @@ -1081,6 +1085,10 @@ Canonical profile: DateTime? Deadline, string? Location, string? Salary, + decimal? SalaryMin, + decimal? SalaryMax, + string? SalaryCurrency, + string? SalaryPeriod, string? NextAction, DateTime? FollowUpAt, DateTime? FeedbackRequestedAt, @@ -1349,6 +1357,10 @@ Canonical profile: string? Status, string? Location, string? Salary, + decimal? SalaryMin, + decimal? SalaryMax, + string? SalaryCurrency, + string? SalaryPeriod, string? NextAction, DateTime? FollowUpAt, string? Notes, @@ -1367,6 +1379,22 @@ Canonical profile: bool? HasOtherAttachment ); + private static (decimal? Min, decimal? Max, string? Currency, string? Period) NormalizeSalary( + decimal? min, decimal? max, string? currency, string? period) + { + if (min is < 0) min = null; + if (max is < 0) max = null; + if (min.HasValue && max.HasValue && min > max) (min, max) = (max, min); + + var cur = (currency ?? "").Trim().ToUpperInvariant(); + if (cur.Length > 8) cur = cur[..8]; + + var per = (period ?? "").Trim().ToLowerInvariant(); + if (per is not ("year" or "month" or "hour")) per = ""; + + return (min, max, cur.Length == 0 ? null : cur, per.Length == 0 ? null : per); + } + [HttpPost] public async Task> Create([FromBody] CreateJobApplicationRequest request, CancellationToken cancellationToken) { @@ -1409,6 +1437,9 @@ Canonical profile: ResponseDate = null, }; + (job.SalaryMin, job.SalaryMax, job.SalaryCurrency, job.SalaryPeriod) = + NormalizeSalary(request.SalaryMin, request.SalaryMax, request.SalaryCurrency, request.SalaryPeriod); + // Generate and persist a short summary at creation time to avoid repeated model calls. try { @@ -1447,6 +1478,10 @@ Canonical profile: DateTime? ResponseDate, string? Location, string? Salary, + decimal? SalaryMin, + decimal? SalaryMax, + string? SalaryCurrency, + string? SalaryPeriod, string? NextAction, DateTime? FollowUpAt, bool? HasResume, @@ -1487,6 +1522,8 @@ Canonical profile: job.ResponseDate = request.ResponseDate; job.Location = string.IsNullOrWhiteSpace(request.Location) ? null : request.Location.Trim(); job.Salary = string.IsNullOrWhiteSpace(request.Salary) ? null : request.Salary.Trim(); + (job.SalaryMin, job.SalaryMax, job.SalaryCurrency, job.SalaryPeriod) = + NormalizeSalary(request.SalaryMin, request.SalaryMax, request.SalaryCurrency, request.SalaryPeriod); job.NextAction = string.IsNullOrWhiteSpace(request.NextAction) ? null : request.NextAction.Trim(); job.FollowUpAt = request.FollowUpAt; job.FeedbackRequestedAt = request.FeedbackRequestedAt; diff --git a/JobTrackerApi/Services/StartupInitializationExtensions.cs b/JobTrackerApi/Services/StartupInitializationExtensions.cs index fb76114..d71fff0 100644 --- a/JobTrackerApi/Services/StartupInitializationExtensions.cs +++ b/JobTrackerApi/Services/StartupInitializationExtensions.cs @@ -484,6 +484,12 @@ public static class StartupInitializationExtensions EnsureColumn(conn, "JobApplications", "LastReminderEmailSentAt", "ALTER TABLE JobApplications ADD COLUMN LastReminderEmailSentAt TEXT NULL;"); EnsureColumn(conn, "JobApplications", "RecruiterMessageDraft", "ALTER TABLE JobApplications ADD COLUMN RecruiterMessageDraft TEXT NULL;"); + // Structured salary fields (EF maps decimal to TEXT on SQLite). + EnsureColumn(conn, "JobApplications", "SalaryMin", "ALTER TABLE JobApplications ADD COLUMN SalaryMin TEXT NULL;"); + EnsureColumn(conn, "JobApplications", "SalaryMax", "ALTER TABLE JobApplications ADD COLUMN SalaryMax TEXT NULL;"); + EnsureColumn(conn, "JobApplications", "SalaryCurrency", "ALTER TABLE JobApplications ADD COLUMN SalaryCurrency TEXT NULL;"); + EnsureColumn(conn, "JobApplications", "SalaryPeriod", "ALTER TABLE JobApplications ADD COLUMN SalaryPeriod TEXT NULL;"); + // Ensure ownership columns exist even on non-legacy DBs. EnsureColumn(conn, "Companies", "OwnerUserId", "ALTER TABLE Companies ADD COLUMN OwnerUserId TEXT NULL;"); EnsureColumn(conn, "JobApplications", "OwnerUserId", "ALTER TABLE JobApplications ADD COLUMN OwnerUserId TEXT NULL;"); @@ -607,6 +613,10 @@ public static class StartupInitializationExtensions EnsureMySqlColumn(conn, "JobApplications", "DeletedAt", "ALTER TABLE `JobApplications` ADD COLUMN `DeletedAt` datetime NULL;"); EnsureMySqlColumn(conn, "JobApplications", "Location", "ALTER TABLE `JobApplications` ADD COLUMN `Location` longtext NULL;"); EnsureMySqlColumn(conn, "JobApplications", "Salary", "ALTER TABLE `JobApplications` ADD COLUMN `Salary` longtext NULL;"); + EnsureMySqlColumn(conn, "JobApplications", "SalaryMin", "ALTER TABLE `JobApplications` ADD COLUMN `SalaryMin` decimal(18,2) NULL;"); + EnsureMySqlColumn(conn, "JobApplications", "SalaryMax", "ALTER TABLE `JobApplications` ADD COLUMN `SalaryMax` decimal(18,2) NULL;"); + EnsureMySqlColumn(conn, "JobApplications", "SalaryCurrency", "ALTER TABLE `JobApplications` ADD COLUMN `SalaryCurrency` varchar(8) NULL;"); + EnsureMySqlColumn(conn, "JobApplications", "SalaryPeriod", "ALTER TABLE `JobApplications` ADD COLUMN `SalaryPeriod` varchar(16) NULL;"); EnsureMySqlColumn(conn, "JobApplications", "NextAction", "ALTER TABLE `JobApplications` ADD COLUMN `NextAction` longtext NULL;"); EnsureMySqlColumn(conn, "JobApplications", "FollowUpAt", "ALTER TABLE `JobApplications` ADD COLUMN `FollowUpAt` datetime NULL;"); EnsureMySqlColumn(conn, "JobApplications", "FeedbackRequestedAt", "ALTER TABLE `JobApplications` ADD COLUMN `FeedbackRequestedAt` datetime NULL;"); diff --git a/Models/JobApplication.cs b/Models/JobApplication.cs index dc8d3b6..729c6ef 100644 --- a/Models/JobApplication.cs +++ b/Models/JobApplication.cs @@ -13,6 +13,12 @@ public class JobApplication public DateTime DateApplied { get; set; } = DateTime.UtcNow; public string? Location { get; set; } public string? Salary { get; set; } + + // Structured salary; the free-text Salary field is kept for display/back-compat. + public decimal? SalaryMin { get; set; } + public decimal? SalaryMax { get; set; } + public string? SalaryCurrency { get; set; } // e.g. "NOK", "GBP", "EUR" + public string? SalaryPeriod { get; set; } // "year" | "month" | "hour" public string? NextAction { get; set; } public DateTime? FollowUpAt { get; set; } public DateTime? FeedbackRequestedAt { get; set; } diff --git a/job-tracker-ui/src/components/AddJobModal.tsx b/job-tracker-ui/src/components/AddJobModal.tsx index 9d6accf..c0668d9 100644 --- a/job-tracker-ui/src/components/AddJobModal.tsx +++ b/job-tracker-ui/src/components/AddJobModal.tsx @@ -118,6 +118,10 @@ export default function AddJobModal({ open, onClose, onCreated }: Props) { const [status, setStatus] = useState<(typeof STATUS_OPTIONS)[number]>("Applied"); const [location, setLocation] = useState(""); const [salary, setSalary] = useState(""); + const [salaryMin, setSalaryMin] = useState(""); + const [salaryMax, setSalaryMax] = useState(""); + const [salaryCurrency, setSalaryCurrency] = useState(""); + const [salaryPeriod, setSalaryPeriod] = useState(""); const [jobUrl, setJobUrl] = useState(""); const [deadline, setDeadline] = useState(""); @@ -291,6 +295,10 @@ export default function AddJobModal({ open, onClose, onCreated }: Props) { status, location, salary, + salaryMin: salaryMin.trim() ? Number(salaryMin) : null, + salaryMax: salaryMax.trim() ? Number(salaryMax) : null, + salaryCurrency: salaryCurrency.trim() || null, + salaryPeriod: salaryPeriod || null, nextAction: null, followUpAt: null, jobUrl, @@ -482,6 +490,15 @@ export default function AddJobModal({ open, onClose, onCreated }: Props) { setLocation(e.target.value)} sx={FIELD_SX} /> setSalary(e.target.value)} sx={FIELD_SX} /> + setSalaryMin(e.target.value)} sx={FIELD_SX} /> + setSalaryMax(e.target.value)} sx={FIELD_SX} /> + setSalaryCurrency(e.target.value)} sx={FIELD_SX} inputProps={{ maxLength: 8 }} /> + setSalaryPeriod(e.target.value)} sx={FIELD_SX} InputLabelProps={{ shrink: true }}> + + + + + new Date().toISOString().slice(0, 10)); const [location, setLocation] = useState(""); const [salary, setSalary] = useState(""); + const [salaryMin, setSalaryMin] = useState(""); + const [salaryMax, setSalaryMax] = useState(""); + const [salaryCurrency, setSalaryCurrency] = useState(""); + const [salaryPeriod, setSalaryPeriod] = useState(""); const [nextAction, setNextAction] = useState(""); const [followUpAt, setFollowUpAt] = useState(""); const [jobUrl, setJobUrl] = useState(""); @@ -110,6 +114,10 @@ export default function EditJobDialog({ open, jobId, onClose, onSaved }: Props) setDateApplied(toDateInputValue(j.dateApplied)); setLocation(j.location ?? ""); setSalary(j.salary ?? ""); + setSalaryMin(j.salaryMin != null ? String(j.salaryMin) : ""); + setSalaryMax(j.salaryMax != null ? String(j.salaryMax) : ""); + setSalaryCurrency(j.salaryCurrency ?? ""); + setSalaryPeriod(j.salaryPeriod ?? ""); setNextAction((j as any).nextAction ?? ""); setFollowUpAt((j as any).followUpAt ? toDateInputValue((j as any).followUpAt) : ""); setJobUrl(j.jobUrl ?? ""); @@ -144,6 +152,10 @@ export default function EditJobDialog({ open, jobId, onClose, onSaved }: Props) responseDate: responseReceived && responseDate ? responseDate : null, location: location.trim() || null, salary: salary.trim() || null, + salaryMin: salaryMin.trim() ? Number(salaryMin) : null, + salaryMax: salaryMax.trim() ? Number(salaryMax) : null, + salaryCurrency: salaryCurrency.trim() || null, + salaryPeriod: salaryPeriod || null, nextAction: nextAction.trim() || null, followUpAt: followUpAt || null, hasResume, @@ -210,6 +222,15 @@ export default function EditJobDialog({ open, jobId, onClose, onSaved }: Props) setLocation(e.target.value)} sx={FIELD_SX} /> setSalary(e.target.value)} sx={FIELD_SX} /> + setSalaryMin(e.target.value)} sx={FIELD_SX} /> + setSalaryMax(e.target.value)} sx={FIELD_SX} /> + setSalaryCurrency(e.target.value)} sx={FIELD_SX} inputProps={{ maxLength: 8 }} /> + setSalaryPeriod(e.target.value)} sx={FIELD_SX} InputLabelProps={{ shrink: true }}> + + + + + setDeadline(toPickerIso(value))} slotProps={{ textField: PICKER_TEXT_FIELD_PROPS }} /> setDescriptionLanguage(e.target.value)} sx={FIELD_SX} /> diff --git a/job-tracker-ui/src/components/JobTable.tsx b/job-tracker-ui/src/components/JobTable.tsx index 4970332..d0586ce 100644 --- a/job-tracker-ui/src/components/JobTable.tsx +++ b/job-tracker-ui/src/components/JobTable.tsx @@ -44,6 +44,7 @@ import { api } from "../api"; import ViewStateNotice from "./ViewStateNotice"; import { useCompanies } from "../hooks/useCompanies"; import { useDebouncedValue } from "../hooks/useDebouncedValue"; +import { formatSalary } from "../salary"; import JobDetailsDialog from "./JobDetailsDialog"; import EditJobDialog from "./EditJobDialog"; import { useToast } from "../toast"; @@ -584,7 +585,7 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col {t("addJobModalSalary")} - {job.salary ?? "-"} + {formatSalary(job) ?? "-"} @@ -727,7 +728,7 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col {t("jobTableLocation")}{job.location ?? "-"} - {t("addJobModalSalary")}{job.salary ?? "-"} + {t("addJobModalSalary")}{formatSalary(job) ?? "-"} {t("settingsColumnJobUrl")}{job.jobUrl ? {t("jobTableOpenListing")} : "-"} {t("jobTableSkills")}{detailTags.length ? detailTags.map((tag) => ) : {t("jobTableNoTags")}} {t("jobTableOverview")}{overview || t("jobTableNoSummaryYet")} diff --git a/job-tracker-ui/src/i18n/translations.ts b/job-tracker-ui/src/i18n/translations.ts index 1769666..fdf618f 100644 --- a/job-tracker-ui/src/i18n/translations.ts +++ b/job-tracker-ui/src/i18n/translations.ts @@ -77,6 +77,13 @@ export const translations = { addJobModalStatus: "Status", addJobModalJobTitle: "Job title", addJobModalSalary: "Salary", + salaryMinLabel: "Salary min", + salaryMaxLabel: "Salary max", + salaryCurrencyLabel: "Currency", + salaryPeriodLabel: "Per", + salaryPeriodYear: "Year", + salaryPeriodMonth: "Month", + salaryPeriodHour: "Hour", addJobModalDeadline: "Deadline", addJobModalDescriptionOriginal: "Description (original)", addJobModalTranslatedDescription: "Translated description ({language})", @@ -987,6 +994,13 @@ export const translations = { addJobModalStatus: "Status", addJobModalJobTitle: "Stillingstittel", addJobModalSalary: "Lønn", + salaryMinLabel: "Lønn fra", + salaryMaxLabel: "Lønn til", + salaryCurrencyLabel: "Valuta", + salaryPeriodLabel: "Per", + salaryPeriodYear: "År", + salaryPeriodMonth: "Måned", + salaryPeriodHour: "Time", addJobModalDeadline: "Frist", addJobModalDescriptionOriginal: "Beskrivelse (original)", addJobModalTranslatedDescription: "Oversatt beskrivelse ({language})", diff --git a/job-tracker-ui/src/salary.ts b/job-tracker-ui/src/salary.ts new file mode 100644 index 0000000..e5586ab --- /dev/null +++ b/job-tracker-ui/src/salary.ts @@ -0,0 +1,21 @@ +import { JobApplication } from "./types"; + +type SalaryFields = Pick; + +const PERIOD_SUFFIX: Record = { year: "yr", month: "mo", hour: "hr" }; + +/** Structured salary when present ("60 000–70 000 NOK/yr"), otherwise the free-text field. */ +export function formatSalary(job: SalaryFields): string | null { + const { salaryMin, salaryMax, salaryCurrency, salaryPeriod } = job; + if (salaryMin == null && salaryMax == null) { + return job.salary?.trim() || null; + } + + const fmt = (value: number) => value.toLocaleString(); + const range = salaryMin != null && salaryMax != null && salaryMin !== salaryMax + ? `${fmt(salaryMin)}–${fmt(salaryMax)}` + : fmt((salaryMin ?? salaryMax) as number); + const currency = salaryCurrency ? ` ${salaryCurrency}` : ""; + const period = salaryPeriod ? `/${PERIOD_SUFFIX[salaryPeriod] ?? salaryPeriod}` : ""; + return `${range}${currency}${period}`; +} diff --git a/job-tracker-ui/src/types.ts b/job-tracker-ui/src/types.ts index 0623420..fd70fc3 100644 --- a/job-tracker-ui/src/types.ts +++ b/job-tracker-ui/src/types.ts @@ -89,6 +89,10 @@ export interface JobApplication { dateApplied: string; location?: string; salary?: string; + salaryMin?: number | null; + salaryMax?: number | null; + salaryCurrency?: string | null; + salaryPeriod?: string | null; nextAction?: string; followUpAt?: string; feedbackRequestedAt?: string;