Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 3 additions & 9 deletions apps/api/src/app/(site)/[locale]/members/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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
Expand All @@ -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 (
Expand Down
23 changes: 23 additions & 0 deletions apps/api/src/server/members/search.ts
Original file line number Diff line number Diff line change
@@ -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),
);
}
41 changes: 41 additions & 0 deletions apps/api/tests/unit/member-search.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});