Unify recommendations across shifts AND jobs
Recommendations only scored open shifts, but almost all roles — doctors especially — exist as استخدام (jobs), not dated shifts, and the only shifts are a handful of nurse shifts. So a visitor who prefers «پزشک» got nurse-shift recommendations (scored by city/freshness) because there were no doctor shifts to surface. Now the engine scores BOTH shifts and job openings: role/city/facility/pay/freshness apply to each, behavioral affinities are derived from shift AND job interest events, and the merged top-N is returned. Recommendation can now carry a Shift or a JobOpening; the card renders either (job → /Jobs/Details with employment type + salary; shift → unchanged with hour-bar). Cold start interleaves the freshest of both. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,32 +1,60 @@
|
|||||||
@model JobsMedical.Web.Services.Recommendation
|
@model JobsMedical.Web.Services.Recommendation
|
||||||
@{
|
@{
|
||||||
var s = Model.Shift;
|
var isJob = Model.IsJob;
|
||||||
var (badgeClass, typeLabel) = s.ShiftType switch
|
var role = isJob ? Model.Job!.Role?.Name : Model.Shift!.Role?.Name;
|
||||||
|
var fac = isJob ? Model.Job!.Facility : Model.Shift!.Facility;
|
||||||
|
var gender = isJob ? Model.Job!.GenderRequirement : Model.Shift!.GenderRequirement;
|
||||||
|
var url = isJob ? $"/Jobs/Details/{Model.Job!.Id}" : $"/Shifts/Details/{Model.Shift!.Id}";
|
||||||
|
string empLabel(JobsMedical.Web.Models.EmploymentType t) => t switch
|
||||||
{
|
{
|
||||||
ShiftType.Day => ("badge-day", "صبح"),
|
JobsMedical.Web.Models.EmploymentType.PartTime => "پارهوقت",
|
||||||
ShiftType.Evening => ("badge-evening", "عصر"),
|
JobsMedical.Web.Models.EmploymentType.Contract => "قراردادی",
|
||||||
ShiftType.Night => ("badge-night", "شب"),
|
JobsMedical.Web.Models.EmploymentType.Plan => "طرح",
|
||||||
_ => ("badge-oncall", "آنکال"),
|
_ => "تماموقت",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
<a class="card card-pad shift-card" asp-page="/Shifts/Details" asp-route-id="@s.Id">
|
<a class="card card-pad shift-card" href="@url">
|
||||||
<div class="row" style="justify-content: space-between;">
|
<div class="row" style="justify-content: space-between;">
|
||||||
<span class="facility">@(s.Role?.Name ?? "شیفت")</span>
|
<span class="facility">@(role ?? (isJob ? "استخدام" : "شیفت"))</span>
|
||||||
<span class="badge @badgeClass">@typeLabel</span>
|
@if (isJob)
|
||||||
|
{
|
||||||
|
<span class="badge badge-job">استخدام</span>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var s = Model.Shift!;
|
||||||
|
var (badgeClass, typeLabel) = s.ShiftType switch
|
||||||
|
{
|
||||||
|
ShiftType.Day => ("badge-day", "صبح"),
|
||||||
|
ShiftType.Evening => ("badge-evening", "عصر"),
|
||||||
|
ShiftType.Night => ("badge-night", "شب"),
|
||||||
|
_ => ("badge-oncall", "آنکال"),
|
||||||
|
};
|
||||||
|
<span class="badge @badgeClass">@typeLabel</span>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
@if (s.GenderRequirement != Gender.Any)
|
@if (gender != Gender.Any)
|
||||||
{
|
{
|
||||||
<span class="badge badge-gender">@JalaliDate.GenderLabel(s.GenderRequirement)</span>
|
<span class="badge badge-gender">@JalaliDate.GenderLabel(gender)</span>
|
||||||
}
|
}
|
||||||
@if (JobsMedical.Web.Services.SeoJsonLd.HasRealEmployer(s.Facility))
|
@if (JobsMedical.Web.Services.SeoJsonLd.HasRealEmployer(fac))
|
||||||
{
|
{
|
||||||
<span>🏥 @s.Facility?.Name</span>
|
<span>🏥 @fac?.Name</span>
|
||||||
}
|
}
|
||||||
<span>📍 @s.Facility?.City?.Name</span>
|
<span>📍 @fac?.City?.Name</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="row">📅 @JalaliDate.WeekDayName(s.Date)، @JalaliDate.ToLongDate(s.Date) — 🕐 @JalaliDate.Time(s.StartTime)</div>
|
|
||||||
<partial name="_HourBar" model="s" />
|
@if (isJob)
|
||||||
|
{
|
||||||
|
<div class="row">💼 @empLabel(Model.Job!.EmploymentType)</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var s = Model.Shift!;
|
||||||
|
<div class="row">📅 @JalaliDate.WeekDayName(s.Date)، @JalaliDate.ToLongDate(s.Date) — 🕐 @JalaliDate.Time(s.StartTime)</div>
|
||||||
|
<partial name="_HourBar" model="s" />
|
||||||
|
}
|
||||||
|
|
||||||
@* The "why" — what makes a pattern engine trustworthy: every pick is explained. *@
|
@* The "why" — what makes a pattern engine trustworthy: every pick is explained. *@
|
||||||
<div class="rec-reasons">
|
<div class="rec-reasons">
|
||||||
@@ -37,7 +65,17 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="foot">
|
<div class="foot">
|
||||||
<span class="pay">@JalaliDate.PayLabel(s.PayType, s.PayAmount, s.SharePercent)</span>
|
<span class="pay">
|
||||||
|
@if (isJob)
|
||||||
|
{
|
||||||
|
@(Model.Job!.SalaryMin is long m ? JalaliDate.ToPersianDigits(m.ToString("#,0")) + " تومان" : "توافقی")
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var s = Model.Shift!;
|
||||||
|
@JalaliDate.PayLabel(s.PayType, s.PayAmount, s.SharePercent)
|
||||||
|
}
|
||||||
|
</span>
|
||||||
<span class="btn btn-outline" style="padding: 6px 14px;">جزئیات</span>
|
<span class="btn btn-outline" style="padding: 6px 14px;">جزئیات</span>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -4,14 +4,19 @@ using Microsoft.EntityFrameworkCore;
|
|||||||
|
|
||||||
namespace JobsMedical.Web.Services;
|
namespace JobsMedical.Web.Services;
|
||||||
|
|
||||||
public record Recommendation(Shift Shift, double Score, List<string> Reasons);
|
/// <summary>A recommended opportunity — either an open <see cref="Shift"/> or an open
|
||||||
|
/// <see cref="JobOpening"/>. Exactly one of the two is set.</summary>
|
||||||
|
public record Recommendation(double Score, List<string> Reasons, Shift? Shift = null, JobOpening? Job = null)
|
||||||
|
{
|
||||||
|
public bool IsJob => Job is not null;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Stage 1 of the recommendation engine: a transparent, rule-based pattern engine.
|
/// Stage 1 of the recommendation engine: a transparent, rule-based pattern engine. It scores open
|
||||||
/// It scores open shifts against a visitor's explicit preferences AND their recent behavior
|
/// opportunities — BOTH shifts AND job openings — against a visitor's explicit preferences and their
|
||||||
/// (which roles/facilities/shift-types they keep engaging with), and returns the top matches
|
/// recent behavior, and returns the top matches each with a human-readable reason. Covering jobs (not
|
||||||
/// each with a human-readable reason. No ML/AI infra required — works from the first visit,
|
/// just shifts) matters because most roles — especially doctors — exist as استخدام, not dated shifts;
|
||||||
/// and every result is explainable. Behavioral data logged now feeds the ML stages later.
|
/// a shift-only feed would only ever recommend the handful of (mostly nurse) shifts.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class RecommendationService
|
public class RecommendationService
|
||||||
{
|
{
|
||||||
@@ -24,7 +29,6 @@ public class RecommendationService
|
|||||||
_interest = interest;
|
_interest = interest;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tunable weights — the whole point of a pattern engine is that these are legible.
|
|
||||||
private const double WRolePref = 40, WRoleBehavior = 15;
|
private const double WRolePref = 40, WRoleBehavior = 15;
|
||||||
private const double WCityPref = 20;
|
private const double WCityPref = 20;
|
||||||
private const double WShiftTypePref = 15, WShiftTypeBehavior = 8;
|
private const double WShiftTypePref = 15, WShiftTypeBehavior = 8;
|
||||||
@@ -36,64 +40,69 @@ public class RecommendationService
|
|||||||
public async Task<List<Recommendation>> GetForVisitorAsync(int take = 6)
|
public async Task<List<Recommendation>> GetForVisitorAsync(int take = 6)
|
||||||
{
|
{
|
||||||
var today = DateOnly.FromDateTime(DateTime.UtcNow);
|
var today = DateOnly.FromDateTime(DateTime.UtcNow);
|
||||||
|
var jobCutoff = Scraping.ListingPolicy.JobCutoffUtc;
|
||||||
var prefs = await _interest.GetPreferencesAsync();
|
var prefs = await _interest.GetPreferencesAsync();
|
||||||
var events = await _interest.RecentEventsAsync(150);
|
var events = await _interest.RecentEventsAsync(150);
|
||||||
|
|
||||||
var candidates = await _db.Shifts
|
var shifts = await _db.Shifts
|
||||||
.Include(s => s.Facility).ThenInclude(f => f.City)
|
.Include(s => s.Facility).ThenInclude(f => f.City).Include(s => s.Role)
|
||||||
.Include(s => s.Role)
|
.Where(s => s.Status == ShiftStatus.Open && s.Date >= today).ToListAsync();
|
||||||
.Where(s => s.Status == ShiftStatus.Open && s.Date >= today)
|
var jobs = await _db.JobOpenings
|
||||||
.ToListAsync();
|
.Include(j => j.Facility).ThenInclude(f => f.City).Include(j => j.Role)
|
||||||
|
.Where(j => j.Status == ShiftStatus.Open && j.CreatedAt >= jobCutoff).ToListAsync();
|
||||||
|
|
||||||
// Cold start: no preferences and no behavior → just show the freshest opportunities.
|
// Cold start: freshest of both, interleaved (jobs lead — that's where the volume/roles are).
|
||||||
if (prefs is null && events.Count == 0)
|
if (prefs is null && events.Count == 0)
|
||||||
{
|
{
|
||||||
return candidates
|
var cj = jobs.OrderByDescending(j => j.CreatedAt)
|
||||||
.OrderBy(s => s.Date).ThenBy(s => s.StartTime)
|
.Select(j => new Recommendation(0, new List<string> { "جدیدترین فرصتها" }, Job: j));
|
||||||
.Take(take)
|
var cs = shifts.OrderBy(s => s.Date).ThenBy(s => s.StartTime)
|
||||||
.Select(s => new Recommendation(s, 0, new() { "جدیدترین فرصتها" }))
|
.Select(s => new Recommendation(0, new List<string> { "جدیدترین فرصتها" }, Shift: s));
|
||||||
.ToList();
|
return Interleave(cj, cs).Take(take).ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Derive behavioral affinities from the event log (shift events only — jobs are separate).
|
// Behavioral affinities — derived from BOTH shift and job events (role/facility span both;
|
||||||
var shiftEvents = events.Where(e => e.ShiftId is not null).ToList();
|
// shift-type is shift-only). Look up each engaged item's role/facility/type once.
|
||||||
var eventShiftIds = shiftEvents.Select(e => e.ShiftId!.Value).Distinct().ToList();
|
var positive = new[] { InterestEventType.View, InterestEventType.Click, InterestEventType.Save, InterestEventType.Apply };
|
||||||
var eventShifts = candidates.Where(s => eventShiftIds.Contains(s.Id))
|
var negative = new[] { InterestEventType.Dismiss, InterestEventType.HideFacility };
|
||||||
.Concat(await _db.Shifts.Include(s => s.Role)
|
|
||||||
.Where(s => eventShiftIds.Contains(s.Id)).ToListAsync())
|
|
||||||
.DistinctBy(s => s.Id)
|
|
||||||
.ToDictionary(s => s.Id);
|
|
||||||
|
|
||||||
var positive = new[] { InterestEventType.View, InterestEventType.Click,
|
var sIds = events.Where(e => e.ShiftId is not null).Select(e => e.ShiftId!.Value).Distinct().ToList();
|
||||||
InterestEventType.Save, InterestEventType.Apply };
|
var jIds = events.Where(e => e.JobOpeningId is not null).Select(e => e.JobOpeningId!.Value).Distinct().ToList();
|
||||||
|
var sMeta = (await _db.Shifts.Where(s => sIds.Contains(s.Id))
|
||||||
|
.Select(s => new { s.Id, s.RoleId, s.FacilityId, s.ShiftType }).ToListAsync()).ToDictionary(x => x.Id);
|
||||||
|
var jMeta = (await _db.JobOpenings.Where(j => jIds.Contains(j.Id))
|
||||||
|
.Select(j => new { j.Id, j.RoleId, j.FacilityId }).ToListAsync()).ToDictionary(x => x.Id);
|
||||||
|
|
||||||
var roleAffinity = TopBy(shiftEvents, positive, eventShifts, s => s.RoleId);
|
var roleCount = new Dictionary<int, int>();
|
||||||
var shiftTypeAffinity = TopBy(shiftEvents, positive, eventShifts, s => (int)s.ShiftType);
|
var facCount = new Dictionary<int, int>();
|
||||||
var facilityAffinity = TopBy(shiftEvents, positive, eventShifts, s => s.FacilityId);
|
var stCount = new Dictionary<int, int>();
|
||||||
|
var dismissedFacilities = new HashSet<int>();
|
||||||
|
foreach (var e in events)
|
||||||
|
{
|
||||||
|
int roleId, facId; int? st = null;
|
||||||
|
if (e.ShiftId is int si && sMeta.TryGetValue(si, out var sm)) { roleId = sm.RoleId; facId = sm.FacilityId; st = (int)sm.ShiftType; }
|
||||||
|
else if (e.JobOpeningId is int ji && jMeta.TryGetValue(ji, out var jm)) { roleId = jm.RoleId; facId = jm.FacilityId; }
|
||||||
|
else continue;
|
||||||
|
|
||||||
var dismissedFacilities = shiftEvents
|
if (positive.Contains(e.EventType))
|
||||||
.Where(e => e.EventType is InterestEventType.Dismiss or InterestEventType.HideFacility)
|
{
|
||||||
.Select(e => eventShifts.TryGetValue(e.ShiftId!.Value, out var s) ? s.FacilityId : 0)
|
Bump(roleCount, roleId); Bump(facCount, facId);
|
||||||
.Where(id => id != 0).ToHashSet();
|
if (st is int t) Bump(stCount, t);
|
||||||
|
}
|
||||||
|
else if (negative.Contains(e.EventType)) dismissedFacilities.Add(facId);
|
||||||
|
}
|
||||||
|
var roleAffinity = Top3(roleCount);
|
||||||
|
var facilityAffinity = Top3(facCount);
|
||||||
|
var shiftTypeAffinity = Top3(stCount);
|
||||||
|
|
||||||
var results = new List<Recommendation>();
|
var results = new List<Recommendation>();
|
||||||
foreach (var s in candidates)
|
|
||||||
|
foreach (var s in shifts)
|
||||||
{
|
{
|
||||||
// Skip listings whose gender requirement conflicts with the person's gender.
|
if (GenderConflicts(prefs, s.GenderRequirement)) continue;
|
||||||
if (prefs?.Gender is Gender pg && pg != Gender.Any
|
double score = 0; var reasons = new List<string>();
|
||||||
&& s.GenderRequirement != Gender.Any && s.GenderRequirement != pg)
|
ScoreCommon(ref score, reasons, prefs, s.RoleId, s.Role?.Name, s.Facility.CityId, s.Facility.City?.Name,
|
||||||
continue;
|
s.FacilityId, s.Facility?.Name, roleAffinity, facilityAffinity, dismissedFacilities);
|
||||||
|
|
||||||
double score = 0;
|
|
||||||
var reasons = new List<string>();
|
|
||||||
|
|
||||||
if (prefs?.RoleId is int pr && pr == s.RoleId)
|
|
||||||
{ score += WRolePref; reasons.Add($"متناسب با نقش مورد علاقه شما ({s.Role.Name})"); }
|
|
||||||
else if (roleAffinity.Contains(s.RoleId))
|
|
||||||
{ score += WRoleBehavior; reasons.Add($"چون به فرصتهای «{s.Role.Name}» علاقه نشان دادی"); }
|
|
||||||
|
|
||||||
if (prefs?.CityId is int pc && pc == s.Facility.CityId)
|
|
||||||
{ score += WCityPref; reasons.Add($"در شهر مورد علاقه شما ({s.Facility.City.Name})"); }
|
|
||||||
|
|
||||||
if (prefs?.PreferredShiftType is ShiftType pst && pst == s.ShiftType)
|
if (prefs?.PreferredShiftType is ShiftType pst && pst == s.ShiftType)
|
||||||
{ score += WShiftTypePref; reasons.Add($"نوع شیفت دلخواه شما ({ShiftTypeLabel(s.ShiftType)})"); }
|
{ score += WShiftTypePref; reasons.Add($"نوع شیفت دلخواه شما ({ShiftTypeLabel(s.ShiftType)})"); }
|
||||||
@@ -103,41 +112,71 @@ public class RecommendationService
|
|||||||
if (prefs?.MinPay is long min && s.PayAmount is long pay && pay >= min)
|
if (prefs?.MinPay is long min && s.PayAmount is long pay && pay >= min)
|
||||||
{ score += WPayMeetsExpectation; reasons.Add("حقوق بالاتر از حد انتظار شما"); }
|
{ score += WPayMeetsExpectation; reasons.Add("حقوق بالاتر از حد انتظار شما"); }
|
||||||
|
|
||||||
if (facilityAffinity.Contains(s.FacilityId))
|
|
||||||
{ score += WFacilityAffinity; reasons.Add($"از مرکزی که قبلاً به آن علاقه نشان دادی ({s.Facility.Name})"); }
|
|
||||||
|
|
||||||
if (dismissedFacilities.Contains(s.FacilityId))
|
|
||||||
score -= PenaltyDismissedFacility;
|
|
||||||
|
|
||||||
// Sooner shifts and freshly posted ones get a small nudge.
|
|
||||||
var daysOut = s.Date.DayNumber - today.DayNumber;
|
var daysOut = s.Date.DayNumber - today.DayNumber;
|
||||||
if (daysOut <= 3) score += WSoon;
|
if (daysOut <= 3) score += WSoon;
|
||||||
if ((DateTime.UtcNow - s.CreatedAt).TotalDays <= 2) score += WFreshness;
|
if ((DateTime.UtcNow - s.CreatedAt).TotalDays <= 2) score += WFreshness;
|
||||||
|
|
||||||
if (reasons.Count == 0) reasons.Add("پیشنهاد بر اساس فعالیت شما");
|
if (reasons.Count == 0) reasons.Add("پیشنهاد بر اساس فعالیت شما");
|
||||||
results.Add(new Recommendation(s, score, reasons));
|
results.Add(new Recommendation(score, reasons, Shift: s));
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var j in jobs)
|
||||||
|
{
|
||||||
|
if (GenderConflicts(prefs, j.GenderRequirement)) continue;
|
||||||
|
double score = 0; var reasons = new List<string>();
|
||||||
|
ScoreCommon(ref score, reasons, prefs, j.RoleId, j.Role?.Name, j.Facility.CityId, j.Facility.City?.Name,
|
||||||
|
j.FacilityId, j.Facility?.Name, roleAffinity, facilityAffinity, dismissedFacilities);
|
||||||
|
|
||||||
|
if (prefs?.MinPay is long min && j.SalaryMin is long pay && pay >= min)
|
||||||
|
{ score += WPayMeetsExpectation; reasons.Add("حقوق بالاتر از حد انتظار شما"); }
|
||||||
|
if ((DateTime.UtcNow - j.CreatedAt).TotalDays <= 2) score += WFreshness;
|
||||||
|
|
||||||
|
if (reasons.Count == 0) reasons.Add("پیشنهاد بر اساس فعالیت شما");
|
||||||
|
results.Add(new Recommendation(score, reasons, Job: j));
|
||||||
}
|
}
|
||||||
|
|
||||||
return results
|
return results
|
||||||
.Where(r => r.Score > 0)
|
.Where(r => r.Score > 0)
|
||||||
.OrderByDescending(r => r.Score).ThenBy(r => r.Shift.Date)
|
.OrderByDescending(r => r.Score)
|
||||||
.Take(take)
|
.Take(take)
|
||||||
.ToList();
|
.ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Keys the visitor engaged with most (positive events), top 3.</summary>
|
/// <summary>Role + city + facility scoring shared by shifts and jobs.</summary>
|
||||||
private static HashSet<int> TopBy(
|
private static void ScoreCommon(ref double score, List<string> reasons, UserPreferences? prefs,
|
||||||
List<InterestEvent> events, InterestEventType[] positive,
|
int roleId, string? roleName, int cityId, string? cityName, int facilityId, string? facilityName,
|
||||||
Dictionary<int, Shift> shiftById, Func<Shift, int> key)
|
HashSet<int> roleAffinity, HashSet<int> facilityAffinity, HashSet<int> dismissedFacilities)
|
||||||
{
|
{
|
||||||
return events
|
if (prefs?.RoleId is int pr && pr == roleId)
|
||||||
.Where(e => e.ShiftId is not null && positive.Contains(e.EventType)
|
{ score += WRolePref; reasons.Add($"متناسب با نقش مورد علاقه شما ({roleName})"); }
|
||||||
&& shiftById.ContainsKey(e.ShiftId.Value))
|
else if (roleAffinity.Contains(roleId))
|
||||||
.GroupBy(e => key(shiftById[e.ShiftId!.Value]))
|
{ score += WRoleBehavior; reasons.Add($"چون به فرصتهای «{roleName}» علاقه نشان دادی"); }
|
||||||
.OrderByDescending(g => g.Count())
|
|
||||||
.Take(3)
|
if (prefs?.CityId is int pc && pc == cityId)
|
||||||
.Select(g => g.Key)
|
{ score += WCityPref; reasons.Add($"در شهر مورد علاقه شما ({cityName})"); }
|
||||||
.ToHashSet();
|
|
||||||
|
if (facilityAffinity.Contains(facilityId))
|
||||||
|
{ score += WFacilityAffinity; reasons.Add($"از مرکزی که قبلاً به آن علاقه نشان دادی ({facilityName})"); }
|
||||||
|
|
||||||
|
if (dismissedFacilities.Contains(facilityId)) score -= PenaltyDismissedFacility;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool GenderConflicts(UserPreferences? prefs, Gender req)
|
||||||
|
=> prefs?.Gender is Gender pg && pg != Gender.Any && req != Gender.Any && req != pg;
|
||||||
|
|
||||||
|
private static void Bump(Dictionary<int, int> d, int k) => d[k] = d.GetValueOrDefault(k) + 1;
|
||||||
|
private static HashSet<int> Top3(Dictionary<int, int> d) => d.OrderByDescending(k => k.Value).Take(3).Select(k => k.Key).ToHashSet();
|
||||||
|
|
||||||
|
private static IEnumerable<Recommendation> Interleave(IEnumerable<Recommendation> a, IEnumerable<Recommendation> b)
|
||||||
|
{
|
||||||
|
using var ea = a.GetEnumerator();
|
||||||
|
using var eb = b.GetEnumerator();
|
||||||
|
bool ha = ea.MoveNext(), hb = eb.MoveNext();
|
||||||
|
while (ha || hb)
|
||||||
|
{
|
||||||
|
if (ha) { yield return ea.Current; ha = ea.MoveNext(); }
|
||||||
|
if (hb) { yield return eb.Current; hb = eb.MoveNext(); }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string ShiftTypeLabel(ShiftType t) => t switch
|
private static string ShiftTypeLabel(ShiftType t) => t switch
|
||||||
|
|||||||
Reference in New Issue
Block a user