diff --git a/apps/api/src/app/(site)/[locale]/members/page.tsx b/apps/api/src/app/(site)/[locale]/members/page.tsx index 1bba438..896f2db 100644 --- a/apps/api/src/app/(site)/[locale]/members/page.tsx +++ b/apps/api/src/app/(site)/[locale]/members/page.tsx @@ -5,6 +5,7 @@ import { notFound } from "next/navigation"; import { MemberAvatar } from "@/components/ui/member-avatar"; import { getDictionary, isLocale, type Locale, localePath } from "@/lib/i18n"; import { toPublicMemberDto } from "@/server/members/dto"; +import { memberMatchesQuery } from "@/server/members/search"; import { listDirectoryMembers } from "@/server/members/service"; export const dynamic = "force-dynamic"; @@ -29,7 +30,7 @@ export default async function MembersPage({ const dict = getDictionary(activeLocale); const query = await searchParams; - const q = typeof query.q === "string" ? query.q.trim().toLowerCase() : ""; + const q = typeof query.q === "string" ? query.q : ""; const skill = typeof query.skill === "string" && (SKILLS as readonly string[]).includes(query.skill) ? query.skill @@ -40,14 +41,7 @@ export default async function MembersPage({ if (skill !== undefined && !(member.skills as string[]).includes(skill)) { return false; } - if (q.length === 0) { - return true; - } - return ( - member.displayName.toLowerCase().includes(q) || - member.githubUsername.toLowerCase().includes(q) || - (member.headline ?? "").toLowerCase().includes(q) - ); + return memberMatchesQuery(member, q); }); return ( diff --git a/apps/api/src/server/members/search.ts b/apps/api/src/server/members/search.ts new file mode 100644 index 0000000..c556fa0 --- /dev/null +++ b/apps/api/src/server/members/search.ts @@ -0,0 +1,23 @@ +type SearchableMember = { + displayName: string; + githubUsername: string; + headline: string | null; +}; + +// Devanagari text can reach us precomposed or decomposed (e.g. a nukta or +// vowel sign typed as a separate code point), and the two forms are different +// strings to `includes`. Normalizing both sides to NFC makes a Nepali name +// match however the member or the searcher's keyboard encoded it. +export function normalizeSearchText(value: string): string { + return value.normalize("NFC").trim().toLocaleLowerCase(); +} + +export function memberMatchesQuery(member: SearchableMember, query: string): boolean { + const q = normalizeSearchText(query); + if (q.length === 0) { + return true; + } + return [member.displayName, member.githubUsername, member.headline ?? ""].some((field) => + normalizeSearchText(field).includes(q), + ); +} diff --git a/apps/api/tests/unit/member-search.test.ts b/apps/api/tests/unit/member-search.test.ts new file mode 100644 index 0000000..7d98412 --- /dev/null +++ b/apps/api/tests/unit/member-search.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest"; + +import { memberMatchesQuery } from "@/server/members/search"; + +const member = { + displayName: "सुनिता श्रेष्ठ", + githubUsername: "SunitaS", + headline: "Frontend developer", +}; + +describe("memberMatchesQuery", () => { + it("matches a Devanagari display name", () => { + expect(memberMatchesQuery(member, "सुनिता")).toBe(true); + expect(memberMatchesQuery(member, "श्रेष्ठ")).toBe(true); + }); + + it("matches a Devanagari name typed in decomposed form", () => { + // "ऩ" (U+0929) is canonically equal to "न" + nukta (U+0928 U+093C). + const stored = { ...member, displayName: "ऩेपाल" }; + expect(memberMatchesQuery(stored, "ऩ")).toBe(true); + }); + + it("matches a stored decomposed name from a precomposed query", () => { + const stored = { ...member, displayName: "ऩेपाल" }; + expect(memberMatchesQuery(stored, "ऩे")).toBe(true); + }); + + it("does not match an unrelated Devanagari name", () => { + expect(memberMatchesQuery(member, "राम")).toBe(false); + }); + + it("stays case-insensitive for Latin fields and ignores surrounding spaces", () => { + expect(memberMatchesQuery(member, " sunitas ")).toBe(true); + expect(memberMatchesQuery(member, "FRONTEND")).toBe(true); + }); + + it("matches everyone for an empty query and tolerates a null headline", () => { + expect(memberMatchesQuery({ ...member, headline: null }, "")).toBe(true); + expect(memberMatchesQuery({ ...member, headline: null }, "developer")).toBe(false); + }); +});