bb736d1183
New JobPipeline: ordered canonical stages (Applied, Waiting, Interview, Offer, Rejected, Ghosted) with category grouping and a Normalize() that canonicalizes casing and known synonyms (Interviewing->Interview, declined->Rejected, ...) while preserving unknown custom statuses. - normalize status on every write path (Create/Update/PATCH status) so the stored value stays canonical without destroying custom values - GET /api/jobapplications/pipeline exposes the ordered stages so the UI renders from one source instead of duplicated hardcoded lists - 14 unit tests; full backend suite green (120) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
76 lines
3.1 KiB
C#
76 lines
3.1 KiB
C#
namespace JobTrackerApi.Services
|
|
{
|
|
public enum PipelineCategory
|
|
{
|
|
Active,
|
|
Success,
|
|
Closed,
|
|
}
|
|
|
|
public sealed record PipelineStage(string Key, int Order, PipelineCategory Category);
|
|
|
|
/// <summary>
|
|
/// Canonical job-application pipeline: the single source of truth for the ordered set of
|
|
/// statuses, their grouping, and how free-text/legacy values normalize onto them.
|
|
/// Status remains a free-text column so custom values are never destroyed; this only
|
|
/// canonicalizes casing and known synonyms.
|
|
/// </summary>
|
|
public static class JobPipeline
|
|
{
|
|
public const string DefaultStatus = "Applied";
|
|
|
|
public static readonly IReadOnlyList<PipelineStage> Stages = new List<PipelineStage>
|
|
{
|
|
new("Applied", 1, PipelineCategory.Active),
|
|
new("Waiting", 2, PipelineCategory.Active),
|
|
new("Interview", 3, PipelineCategory.Active),
|
|
new("Offer", 4, PipelineCategory.Success),
|
|
new("Rejected", 5, PipelineCategory.Closed),
|
|
new("Ghosted", 6, PipelineCategory.Closed),
|
|
};
|
|
|
|
private static readonly Dictionary<string, string> Canonical =
|
|
Stages.ToDictionary(s => s.Key, s => s.Key, StringComparer.OrdinalIgnoreCase);
|
|
|
|
// Legacy/synonym spellings that should collapse onto a canonical stage.
|
|
private static readonly Dictionary<string, string> Aliases = new(StringComparer.OrdinalIgnoreCase)
|
|
{
|
|
["interviewing"] = "Interview",
|
|
["interviews"] = "Interview",
|
|
["interviewed"] = "Interview",
|
|
["in interview"] = "Interview",
|
|
["awaiting response"] = "Waiting",
|
|
["awaiting"] = "Waiting",
|
|
["in progress"] = "Waiting",
|
|
["pending"] = "Waiting",
|
|
["no response"] = "Ghosted",
|
|
["no reply"] = "Ghosted",
|
|
["declined"] = "Rejected",
|
|
};
|
|
|
|
/// <summary>
|
|
/// Returns the canonical status for a raw value: trims, matches a stage case-insensitively,
|
|
/// or maps a known synonym. Unknown non-empty values are preserved (trimmed) so custom
|
|
/// statuses survive. Empty/whitespace becomes the default stage.
|
|
/// </summary>
|
|
public static string Normalize(string? status)
|
|
{
|
|
var trimmed = (status ?? string.Empty).Trim();
|
|
if (trimmed.Length == 0) return DefaultStatus;
|
|
if (Canonical.TryGetValue(trimmed, out var canonical)) return canonical;
|
|
if (Aliases.TryGetValue(trimmed, out var alias)) return alias;
|
|
return trimmed;
|
|
}
|
|
|
|
public static bool IsCanonical(string? status)
|
|
=> !string.IsNullOrWhiteSpace(status) && Canonical.ContainsKey(status.Trim());
|
|
|
|
public static int OrderOf(string? status)
|
|
{
|
|
var normalized = Normalize(status);
|
|
var stage = Stages.FirstOrDefault(s => string.Equals(s.Key, normalized, StringComparison.OrdinalIgnoreCase));
|
|
return stage?.Order ?? int.MaxValue; // custom statuses sort last
|
|
}
|
|
}
|
|
}
|