Typeahead: search descriptions + show highlighted body snippet (fixes empty mmt dropdown)
CI/CD / CI · dotnet build (push) Successful in 2m6s
CI/CD / Deploy · hamkadr (push) Successful in 2m34s

The suggest endpoint only matched role/city/tags/facility, so a term that
lives only in the ad body (e.g. mmt) returned nothing and the dropdown
never opened — even though /Search found it. Now each type also ILIKEs the
description, and the dropdown's sub-line is a snippet windowed around the
match (client highlights it). Title is bold; body wraps to 2 lines.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-08 22:06:15 +03:30
parent 1e96526bd9
commit 6cf7c6b573
2 changed files with 38 additions and 12 deletions
+34 -9
View File
@@ -369,21 +369,46 @@ app.MapGet("/search/suggest", async (string? q, AppDbContext db) =>
var jobCut = JobsMedical.Web.Services.Scraping.ListingPolicy.JobCutoffUtc;
var talentCut = JobsMedical.Web.Services.Scraping.ListingPolicy.TalentCutoffUtc;
var shifts = await db.Shifts
// Plain (un-marked) snippet around the first occurrence of the term — the client highlights it.
static string? Snip(string? text, string term, string? fallback)
{
if (!string.IsNullOrWhiteSpace(text))
{
var flat = System.Text.RegularExpressions.Regex.Replace(text, @"\s+", " ").Trim();
var i = flat.IndexOf(term, StringComparison.OrdinalIgnoreCase);
if (i >= 0)
{
var start = Math.Max(0, i - 40);
var end = Math.Min(flat.Length, i + term.Length + 40);
return (start > 0 ? "…" : "") + flat.Substring(start, end - start) + (end < flat.Length ? "…" : "");
}
}
return fallback;
}
var shiftRows = await db.Shifts
.Where(s => s.Status == ShiftStatus.Open && s.Date >= today &&
(EF.Functions.ILike(s.Facility.Name, like) || EF.Functions.ILike(s.Role.Name, like) || EF.Functions.ILike(s.SpecialtyRequired, like)))
(EF.Functions.ILike(s.Facility.Name, like) || EF.Functions.ILike(s.Role.Name, like)
|| EF.Functions.ILike(s.SpecialtyRequired, like) || EF.Functions.ILike(s.Description ?? "", like)))
.OrderByDescending(s => s.CreatedAt).Take(5)
.Select(s => new SuggestItem("شیفت", s.Role.Name + " — " + s.Facility.Name, "/Shifts/Details/" + s.Id, s.Facility.City.Name + " · " + s.SpecialtyRequired)).ToListAsync();
var jobs = await db.JobOpenings
.Select(s => new { s.Id, Role = s.Role.Name, Fac = s.Facility.Name, City = s.Facility.City.Name, s.Description }).ToListAsync();
var shifts = shiftRows.Select(s => new SuggestItem("شیفت", s.Role + " — " + s.Fac, "/Shifts/Details/" + s.Id, Snip(s.Description, term, s.City))).ToList();
var jobRows = await db.JobOpenings
.Where(j => j.Status == ShiftStatus.Open && j.CreatedAt >= jobCut &&
(EF.Functions.ILike(j.Title, like) || EF.Functions.ILike(j.Facility.Name, like) || EF.Functions.ILike(j.Role.Name, like)))
(EF.Functions.ILike(j.Title, like) || EF.Functions.ILike(j.Facility.Name, like)
|| EF.Functions.ILike(j.Role.Name, like) || EF.Functions.ILike(j.Description ?? "", like)))
.OrderByDescending(j => j.CreatedAt).Take(5)
.Select(j => new SuggestItem("استخدام", j.Title, "/Jobs/Details/" + j.Id, j.Facility.Name + " · " + j.Facility.City.Name)).ToListAsync();
var talent = await db.TalentListings
.Select(j => new { j.Id, j.Title, Fac = j.Facility.Name, City = j.Facility.City.Name, j.Description }).ToListAsync();
var jobs = jobRows.Select(j => new SuggestItem("استخدام", j.Title, "/Jobs/Details/" + j.Id, Snip(j.Description, term, j.Fac + " · " + j.City))).ToList();
var talentRows = await db.TalentListings
.Where(t => t.Status == ShiftStatus.Open && t.CreatedAt >= talentCut &&
(EF.Functions.ILike(t.Tags ?? "", like) || EF.Functions.ILike(t.Role.Name, like) || EF.Functions.ILike(t.City.Name, like) || EF.Functions.ILike(t.PersonName ?? "", like)))
(EF.Functions.ILike(t.Tags ?? "", like) || EF.Functions.ILike(t.Role.Name, like) || EF.Functions.ILike(t.City.Name, like)
|| EF.Functions.ILike(t.PersonName ?? "", like) || EF.Functions.ILike(t.Description ?? "", like)))
.OrderByDescending(t => t.CreatedAt).Take(5)
.Select(t => new SuggestItem("آماده‌به‌کار", (t.PersonName ?? t.Role.Name) + " — " + t.City.Name, "/Talent/Details/" + t.Id, t.Tags)).ToListAsync();
.Select(t => new { t.Id, t.PersonName, Role = t.Role.Name, City = t.City.Name, t.Tags, t.Description }).ToListAsync();
var talent = talentRows.Select(t => new SuggestItem("آماده‌به‌کار", (t.PersonName ?? t.Role) + " — " + t.City, "/Talent/Details/" + t.Id, Snip(t.Description ?? t.Tags, term, t.Tags))).ToList();
// round-robin merge so all three types appear, capped at 5
var merged = new List<SuggestItem>();
+4 -3
View File
@@ -294,9 +294,10 @@ mark { background: #fff3bf; color: inherit; padding: 0 2px; border-radius: 3px;
.nav-search-results a:hover { background: var(--primary-soft); }
.nav-search-results .ns-type { flex: 0 0 auto; font-size: 11px; font-weight: 700; color: var(--primary-dark);
background: var(--primary-soft); border-radius: 6px; padding: 2px 7px; }
.nav-search-results .ns-text { flex: 1; display: flex; flex-direction: column; min-width: 0; }
.nav-search-results .ns-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.nav-search-results .ns-sub { font-size: 11px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.nav-search-results .ns-text { flex: 1; display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.nav-search-results .ns-label { font-weight: 800; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.nav-search-results .ns-sub { font-size: 11.5px; color: var(--muted); line-height: 1.5;
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
/* ES-style matched snippet shown under a search-result card */
.search-snippet { font-size: 12.5px; color: var(--muted); line-height: 1.6; margin: 4px 0 2px;
background: var(--bg); border-inline-start: 3px solid var(--primary-soft); padding: 5px 9px; border-radius: 6px; }