diff --git a/frontend/src/__tests__/ArmorMembers.test.js b/frontend/src/__tests__/ArmorMembers.test.js
new file mode 100644
index 0000000..4de1dbd
--- /dev/null
+++ b/frontend/src/__tests__/ArmorMembers.test.js
@@ -0,0 +1,301 @@
+import React from "react";
+import { render, screen, fireEvent, waitFor } from "@testing-library/react";
+import { MemoryRouter, useLocation } from "react-router-dom";
+import ArmorMembers from "../landing_page/landing_page_armor/ArmorMembers";
+
+const HEADER = ["First Name", "Last Name", "Company Name", "Job Title", "LinkedIn"];
+
+// 30 members so the unfiltered dataset spans 3 pages at pageSize=10. The
+// non-"Other Co" members are deliberately scattered across page 1 (idx 0/7),
+// page 2 (idx 15), and page 3 (idx 20/29) so full-dataset-filtering bugs
+// (only filtering the visible page) are caught.
+function buildRows() {
+ const special = {
+ 0: ["Anote", "Founder / CEO"],
+ 7: ["Google", "Product Manager"],
+ 15: ["Anote", "Data Scientist"],
+ 20: ["Google", "Senior Product Manager"],
+ 29: ["Anote", "Founder & CEO"], // equivalent title group to idx 0
+ };
+ const rows = [];
+ for (let i = 0; i < 30; i++) {
+ const [company, title] = special[i] || ["Other Co", "Engineer"];
+ rows.push([`Member${i}`, `Test${i}`, company, title, ""]);
+ }
+ return rows;
+}
+
+const SHEET_VALUES = [HEADER, ...buildRows()];
+
+function LocationProbe() {
+ const location = useLocation();
+ return
{location.search}
;
+}
+
+function renderMembers(initialEntries = ["/community/members"]) {
+ return render(
+
+
+
+
+ );
+}
+
+async function waitForLoaded() {
+ await screen.findByRole("heading", { name: "Community Members" });
+}
+
+function openDrawer() {
+ fireEvent.click(screen.getByRole("button", { name: /Open filters/ }));
+}
+
+function getCompanyCheckbox(label) {
+ return screen.getByRole("checkbox", { name: label });
+}
+
+beforeEach(() => {
+ global.fetch = jest.fn().mockResolvedValue({
+ ok: true,
+ json: async () => ({ values: SHEET_VALUES }),
+ });
+});
+
+afterEach(() => {
+ jest.restoreAllMocks();
+});
+
+test("shows a loading state while the sheet is being fetched", () => {
+ global.fetch.mockReturnValue(new Promise(() => {}));
+ renderMembers();
+ expect(screen.getByText("Loading...")).toBeInTheDocument();
+});
+
+test("shows an error state when the fetch fails", async () => {
+ global.fetch.mockRejectedValue(new Error("Network down"));
+ renderMembers();
+ await screen.findByText(/Error: Network down/i);
+});
+
+test("opens the filter drawer on click and closes on Escape and via the close button", async () => {
+ renderMembers();
+ await waitForLoaded();
+
+ expect(screen.queryByRole("dialog", { name: "Filter members" })).not.toBeInTheDocument();
+
+ openDrawer();
+ expect(screen.getByRole("dialog", { name: "Filter members" })).toBeInTheDocument();
+
+ fireEvent.keyDown(document, { key: "Escape" });
+ await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
+
+ openDrawer();
+ fireEvent.click(screen.getByRole("button", { name: "Close filters" }));
+ await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
+});
+
+test("shows Company options with full-dataset counts, not just the current page", async () => {
+ renderMembers();
+ await waitForLoaded();
+ openDrawer();
+
+ expect(getCompanyCheckbox("Anote")).toBeInTheDocument();
+ expect(screen.getByLabelText("3 members")).toBeInTheDocument();
+ expect(getCompanyCheckbox("Google")).toBeInTheDocument();
+ expect(getCompanyCheckbox("Other Co")).toBeInTheDocument();
+});
+
+test("shows normalized Job Title options with merged counts, without merging distinct seniority levels", async () => {
+ renderMembers();
+ await waitForLoaded();
+ openDrawer();
+
+ // "Founder / CEO" merges idx0 ("Founder / CEO") + idx29 ("Founder & CEO") = 2
+ expect(getCompanyCheckbox("Founder / CEO")).toBeInTheDocument();
+ expect(getCompanyCheckbox("Product Manager")).toBeInTheDocument();
+ expect(getCompanyCheckbox("Senior Product Manager")).toBeInTheDocument();
+ // both distinct options must exist independently (no accidental merge)
+ expect(getCompanyCheckbox("Product Manager")).not.toBe(getCompanyCheckbox("Senior Product Manager"));
+});
+
+test("searches within the Company section", async () => {
+ renderMembers();
+ await waitForLoaded();
+ openDrawer();
+
+ fireEvent.change(screen.getByPlaceholderText("Search companies…"), { target: { value: "goog" } });
+ expect(getCompanyCheckbox("Google")).toBeInTheDocument();
+ expect(screen.queryByRole("checkbox", { name: "Anote" })).not.toBeInTheDocument();
+});
+
+test("searches within the Job Title section", async () => {
+ renderMembers();
+ await waitForLoaded();
+ openDrawer();
+
+ fireEvent.change(screen.getByPlaceholderText("Search job titles…"), { target: { value: "senior" } });
+ expect(getCompanyCheckbox("Senior Product Manager")).toBeInTheDocument();
+ expect(screen.queryByRole("checkbox", { name: "Product Manager" })).not.toBeInTheDocument();
+});
+
+test("selecting multiple companies uses OR logic and updates the Filter button count", async () => {
+ renderMembers();
+ await waitForLoaded();
+ openDrawer();
+
+ fireEvent.click(getCompanyCheckbox("Anote"));
+ fireEvent.click(getCompanyCheckbox("Google"));
+ fireEvent.click(screen.getByRole("button", { name: "Close filters" }));
+
+ await screen.findByText("Showing 5 members"); // Anote(3) + Google(2)
+ expect(screen.getByRole("button", { name: /2 active/ })).toHaveTextContent("Filter (2)");
+});
+
+test("selecting multiple job titles uses OR logic within the section", async () => {
+ renderMembers();
+ await waitForLoaded();
+ openDrawer();
+
+ fireEvent.click(getCompanyCheckbox("Product Manager"));
+ fireEvent.click(getCompanyCheckbox("Senior Product Manager"));
+ fireEvent.click(screen.getByRole("button", { name: "Close filters" }));
+
+ await screen.findByText("Showing 2 members");
+});
+
+test("AND logic between Company and Job Title sections", async () => {
+ renderMembers();
+ await waitForLoaded();
+ openDrawer();
+
+ fireEvent.click(getCompanyCheckbox("Google"));
+ fireEvent.click(getCompanyCheckbox("Product Manager"));
+ fireEvent.click(screen.getByRole("button", { name: "Close filters" }));
+
+ // Only idx7 (Google + Product Manager); idx20 is Google + Senior Product Manager.
+ await screen.findByText("Showing 1 member");
+ expect(screen.getByText("Member7")).toBeInTheDocument();
+});
+
+test("combines Company + Job Title filters with the general search box", async () => {
+ renderMembers();
+ await waitForLoaded();
+ openDrawer();
+
+ fireEvent.click(getCompanyCheckbox("Anote"));
+ fireEvent.click(screen.getByRole("button", { name: "Close filters" }));
+ fireEvent.change(screen.getByLabelText("Search members"), { target: { value: "Member29" } });
+
+ await screen.findByText("Showing 1 member", {}, { timeout: 2000 });
+ expect(screen.getByText("Member29")).toBeInTheDocument();
+});
+
+test("resets pagination to page 1 when a filter changes", async () => {
+ renderMembers(["/community/members?page=2"]);
+ await waitForLoaded();
+ expect(screen.getByLabelText("Current page")).toHaveValue(2);
+
+ openDrawer();
+ fireEvent.click(getCompanyCheckbox("Other Co"));
+
+ await waitFor(() => expect(screen.getByLabelText("Current page")).toHaveValue(1));
+});
+
+test("preserves active filters while navigating between pages", async () => {
+ renderMembers();
+ await waitForLoaded();
+ openDrawer();
+ fireEvent.click(getCompanyCheckbox("Other Co")); // 25 matches -> 3 pages
+ fireEvent.click(screen.getByRole("button", { name: "Close filters" }));
+
+ await screen.findByText("Showing 25 members");
+ fireEvent.click(screen.getByLabelText("Next page"));
+
+ await waitFor(() => expect(screen.getByLabelText("Current page")).toHaveValue(2));
+ expect(screen.getByRole("button", { name: "Remove Company filter: Other Co" })).toBeInTheDocument();
+});
+
+test("clears all filters via the drawer's Clear all action", async () => {
+ renderMembers();
+ await waitForLoaded();
+ openDrawer();
+ fireEvent.click(getCompanyCheckbox("Anote"));
+ fireEvent.click(getCompanyCheckbox("Founder / CEO"));
+ await screen.findByText("Showing 2 members"); // idx0 + idx29 (equivalent title group)
+
+ fireEvent.click(screen.getByRole("button", { name: "Clear all" }));
+
+ await screen.findByText("Showing 30 members");
+ expect(screen.queryByRole("button", { name: /Remove Company filter/ })).not.toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "Open filters" })).toHaveTextContent("Filter");
+});
+
+test("shows an empty state with a working clear action when nothing matches", async () => {
+ // Hydrated directly via URL: Company and Job Title cross-filter each other,
+ // so a real user can't sequentially check two mutually-exclusive options in
+ // one drawer session (selecting either one hides the other's now-zero-count
+ // option). Loading both at once via URL exercises the same "zero matches,
+ // but selections stay visible" path a shared link would hit.
+ renderMembers(["/community/members?company=Google&jobTitle=Data%20Scientist"]);
+ await waitForLoaded();
+
+ await screen.findByText("No members match the selected filters.");
+ expect(screen.getByRole("button", { name: "Remove Company filter: Google" })).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "Remove Job Title filter: Data Scientist" })).toBeInTheDocument();
+
+ openDrawer();
+ expect(getCompanyCheckbox("Google")).toBeChecked();
+ expect(getCompanyCheckbox("Data Scientist")).toBeChecked();
+ fireEvent.click(screen.getByRole("button", { name: "Close filters" }));
+
+ fireEvent.click(screen.getByRole("button", { name: "Clear filters" }));
+ await screen.findByText("Showing 30 members");
+});
+
+test("removing an individual chip clears only that filter", async () => {
+ renderMembers();
+ await waitForLoaded();
+ openDrawer();
+ fireEvent.click(getCompanyCheckbox("Anote"));
+ fireEvent.click(getCompanyCheckbox("Founder / CEO"));
+ fireEvent.click(screen.getByRole("button", { name: "Close filters" }));
+ await screen.findByText("Showing 2 members"); // idx0 + idx29 (equivalent title group)
+
+ fireEvent.click(screen.getByRole("button", { name: "Remove Job Title filter: Founder / CEO" }));
+
+ await screen.findByText("Showing 3 members"); // back to just Company: Anote
+ expect(screen.getByRole("button", { name: "Remove Company filter: Anote" })).toBeInTheDocument();
+});
+
+test("hydrates filters and page from the URL, and syncs the URL on change", async () => {
+ renderMembers(["/community/members?company=Anote&jobTitle=Founder%20%2F%20CEO"]);
+ await waitForLoaded();
+
+ await screen.findByText("Showing 2 members"); // idx0 + idx29
+ expect(screen.getByRole("button", { name: /2 active/ })).toHaveTextContent("Filter (2)");
+ expect(screen.getByRole("button", { name: "Remove Company filter: Anote" })).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "Remove Job Title filter: Founder / CEO" })).toBeInTheDocument();
+
+ const search = screen.getByTestId("location-search").textContent;
+ expect(search).toContain("company=Anote");
+ expect(search).toContain("jobTitle=Founder");
+
+ openDrawer();
+ fireEvent.click(screen.getByRole("button", { name: "Clear all" }));
+ await waitFor(() => expect(screen.getByTestId("location-search").textContent).toBe(""));
+});
+
+test("filters apply to the full dataset, not only the currently visible page", async () => {
+ renderMembers();
+ await waitForLoaded();
+ openDrawer();
+
+ fireEvent.click(getCompanyCheckbox("Anote"));
+ fireEvent.click(screen.getByRole("button", { name: "Close filters" }));
+
+ await screen.findByText("Showing 3 members");
+ // idx0 (page 1), idx15 (page 2), idx29 (page 3) would be unreachable
+ // together under naive current-page-only filtering.
+ expect(screen.getByText("Member0")).toBeInTheDocument();
+ expect(screen.getByText("Member15")).toBeInTheDocument();
+ expect(screen.getByText("Member29")).toBeInTheDocument();
+});
diff --git a/frontend/src/__tests__/jobTitleNormalization.test.js b/frontend/src/__tests__/jobTitleNormalization.test.js
new file mode 100644
index 0000000..3c26e3a
--- /dev/null
+++ b/frontend/src/__tests__/jobTitleNormalization.test.js
@@ -0,0 +1,94 @@
+import {
+ cleanTitle,
+ normalizeSeparators,
+ jobTitleGroupKey,
+ canonicalJobTitleDisplay,
+} from "../landing_page/landing_page_armor/jobTitleNormalization";
+
+describe("cleanTitle", () => {
+ test("trims and collapses whitespace only", () => {
+ expect(cleanTitle(" Founder / CEO ")).toBe("Founder / CEO");
+ expect(cleanTitle(null)).toBe("");
+ expect(cleanTitle(undefined)).toBe("");
+ });
+});
+
+describe("normalizeSeparators", () => {
+ test("collapses &, +, comma, and 'and' into a single canonical separator", () => {
+ expect(normalizeSeparators("Founder & CEO")).toBe("Founder / CEO");
+ expect(normalizeSeparators("Founder + CEO")).toBe("Founder / CEO");
+ expect(normalizeSeparators("Founder and CEO")).toBe("Founder / CEO");
+ expect(normalizeSeparators("Founder, CEO")).toBe("Founder / CEO");
+ expect(normalizeSeparators("Founder / CEO")).toBe("Founder / CEO");
+ });
+
+ test("does not touch titles with no separators", () => {
+ expect(normalizeSeparators("Product Manager")).toBe("Product Manager");
+ expect(normalizeSeparators("Data Scientist")).toBe("Data Scientist");
+ });
+
+ test("does not merge different words just because they're related", () => {
+ // These should remain distinguishable strings — normalizeSeparators only
+ // rewrites separators, it never removes or substitutes words.
+ expect(normalizeSeparators("Senior Product Manager")).toBe("Senior Product Manager");
+ expect(normalizeSeparators("Product Marketing Manager")).toBe("Product Marketing Manager");
+ expect(normalizeSeparators("Engineering Manager")).toBe("Engineering Manager");
+ });
+});
+
+describe("jobTitleGroupKey", () => {
+ test("groups equivalent CEO-title variants under the same key", () => {
+ const key = jobTitleGroupKey("Founder / CEO");
+ expect(jobTitleGroupKey("Founder & CEO")).toBe(key);
+ expect(jobTitleGroupKey("Founder + CEO")).toBe(key);
+ expect(jobTitleGroupKey("founder and ceo")).toBe(key);
+ expect(jobTitleGroupKey(" FOUNDER and CEO ")).toBe(key);
+ });
+
+ test("is case-insensitive", () => {
+ expect(jobTitleGroupKey("Product Manager")).toBe(jobTitleGroupKey("PRODUCT MANAGER"));
+ expect(jobTitleGroupKey("Product Manager")).toBe(jobTitleGroupKey("product manager"));
+ });
+
+ test("does NOT merge distinct seniority levels", () => {
+ expect(jobTitleGroupKey("Product Manager")).not.toBe(jobTitleGroupKey("Senior Product Manager"));
+ });
+
+ test("does NOT merge distinct roles that share a word", () => {
+ expect(jobTitleGroupKey("Product Manager")).not.toBe(jobTitleGroupKey("Product Marketing Manager"));
+ expect(jobTitleGroupKey("Data Scientist")).not.toBe(jobTitleGroupKey("Data Engineer"));
+ expect(jobTitleGroupKey("Engineer")).not.toBe(jobTitleGroupKey("Engineering Manager"));
+ });
+
+ test("does NOT merge a single role with a combined role sharing a word", () => {
+ expect(jobTitleGroupKey("Founder")).not.toBe(jobTitleGroupKey("CEO"));
+ expect(jobTitleGroupKey("CEO")).not.toBe(jobTitleGroupKey("Founder / CEO"));
+ expect(jobTitleGroupKey("Founder")).not.toBe(jobTitleGroupKey("Founder / CEO"));
+ });
+
+ test("blank/null input yields an empty key", () => {
+ expect(jobTitleGroupKey("")).toBe("");
+ expect(jobTitleGroupKey(null)).toBe("");
+ expect(jobTitleGroupKey(" ")).toBe("");
+ });
+
+ test("treats common placeholder values as blank", () => {
+ expect(jobTitleGroupKey("N/A")).toBe("");
+ expect(jobTitleGroupKey("na")).toBe("");
+ expect(jobTitleGroupKey("None")).toBe("");
+ expect(jobTitleGroupKey("-")).toBe("");
+ });
+});
+
+describe("canonicalJobTitleDisplay", () => {
+ test("returns the curated display for known equivalence groups", () => {
+ expect(canonicalJobTitleDisplay(jobTitleGroupKey("Founder & CEO"), "whatever")).toBe("Founder / CEO");
+ expect(canonicalJobTitleDisplay(jobTitleGroupKey("Founder CEO"), "whatever")).toBe("Founder / CEO");
+ });
+
+ test("falls back to the provided display when there is no explicit mapping", () => {
+ expect(canonicalJobTitleDisplay(jobTitleGroupKey("Product Manager"), "Product Manager")).toBe(
+ "Product Manager"
+ );
+ });
+});
diff --git a/frontend/src/__tests__/memberFacets.test.js b/frontend/src/__tests__/memberFacets.test.js
new file mode 100644
index 0000000..e352243
--- /dev/null
+++ b/frontend/src/__tests__/memberFacets.test.js
@@ -0,0 +1,131 @@
+import { computeMemberView, companyGroupKey, memberJobTitleGroupKey } from "../landing_page/landing_page_armor/memberFacets";
+import { jobTitleGroupKey } from "../landing_page/landing_page_armor/jobTitleNormalization";
+
+function m(firstName, company, title) {
+ return { firstName, lastName: "Test", company, title, linkedin: "" };
+}
+
+const members = [
+ m("Alice", "Anote", "Founder / CEO"),
+ m("Bob", " anote ", "Data Scientist"), // dup company, different case/whitespace
+ m("Carol", "OpenAI", "Founder & CEO"), // equivalent title, different company
+ m("Dave", "OpenAI", "Research Engineer"),
+ m("Erin", "", ""), // blank company/title — must be excluded from facets
+ m("Frank", "Google", "Product Manager"),
+ m("Grace", "Google", "Senior Product Manager"), // distinct seniority — must not merge
+];
+
+describe("computeMemberView — facet building", () => {
+ test("builds company facets across the full dataset with blank values excluded", () => {
+ const { companyFacets } = computeMemberView(members, {});
+ const values = companyFacets.map((f) => f.display);
+ expect(values).toEqual(expect.arrayContaining(["Anote", "OpenAI", "Google"]));
+ expect(values).not.toContain("");
+ });
+
+ test("treats N/A-style placeholder values as blank, excluding them from options", () => {
+ const withPlaceholders = [
+ ...members,
+ m("Henry", "N/A", "Engineer"),
+ m("Ivy", "n/a", "NA"),
+ ];
+ const { companyFacets, jobTitleFacets } = computeMemberView(withPlaceholders, {});
+ expect(companyFacets.some((f) => f.display.toLowerCase() === "n/a")).toBe(false);
+ expect(jobTitleFacets.some((f) => f.display.toLowerCase() === "na")).toBe(false);
+ });
+
+ test("dedupes company casing/whitespace but keeps the combined count", () => {
+ const { companyFacets } = computeMemberView(members, {});
+ const anote = companyFacets.find((f) => f.key === companyGroupKey("Anote"));
+ expect(anote.count).toBe(2); // Alice + Bob
+ });
+
+ test("does not merge distinct seniority levels in job title facets", () => {
+ const { jobTitleFacets } = computeMemberView(members, {});
+ const pm = jobTitleFacets.find((f) => f.display === "Product Manager");
+ const seniorPm = jobTitleFacets.find((f) => f.display === "Senior Product Manager");
+ expect(pm.count).toBe(1);
+ expect(seniorPm.count).toBe(1);
+ });
+
+ test("merges equivalent job-title formatting into one canonical group with combined count", () => {
+ const { jobTitleFacets } = computeMemberView(members, {});
+ const ceoGroup = jobTitleFacets.find((f) => f.display === "Founder / CEO");
+ expect(ceoGroup.count).toBe(2); // Alice ("Founder / CEO") + Carol ("Founder & CEO")
+ });
+});
+
+describe("computeMemberView — filtering (AND across sections, OR within a section)", () => {
+ test("filters by a single selected company", () => {
+ const { filteredMembers } = computeMemberView(members, {
+ selectedCompanyKeys: [companyGroupKey("Anote")],
+ });
+ expect(filteredMembers.map((x) => x.firstName).sort()).toEqual(["Alice", "Bob"]);
+ });
+
+ test("OR logic within the company section", () => {
+ const { filteredMembers } = computeMemberView(members, {
+ selectedCompanyKeys: [companyGroupKey("Anote"), companyGroupKey("Google")],
+ });
+ expect(filteredMembers.map((x) => x.firstName).sort()).toEqual(["Alice", "Bob", "Frank", "Grace"]);
+ });
+
+ test("AND logic between company and job title sections", () => {
+ const { filteredMembers } = computeMemberView(members, {
+ selectedCompanyKeys: [companyGroupKey("OpenAI")],
+ selectedJobTitleKeys: [jobTitleGroupKey("Founder / CEO")],
+ });
+ expect(filteredMembers.map((x) => x.firstName)).toEqual(["Carol"]);
+ });
+
+ test("combines filters with the general search", () => {
+ const { filteredMembers } = computeMemberView(members, {
+ query: "data",
+ selectedCompanyKeys: [companyGroupKey("Anote")],
+ });
+ expect(filteredMembers.map((x) => x.firstName)).toEqual(["Bob"]);
+ });
+});
+
+describe("computeMemberView — faceted counts respect other active filters", () => {
+ test("company counts respect the selected job title (not the company selection itself)", () => {
+ const { companyFacets } = computeMemberView(members, {
+ selectedJobTitleKeys: [jobTitleGroupKey("Founder / CEO")],
+ });
+ const anote = companyFacets.find((f) => f.key === companyGroupKey("Anote"));
+ const openai = companyFacets.find((f) => f.key === companyGroupKey("OpenAI"));
+ const google = companyFacets.find((f) => f.key === companyGroupKey("Google"));
+ expect(anote.count).toBe(1); // only Alice has "Founder / CEO" at Anote
+ expect(openai.count).toBe(1); // only Carol
+ expect(google).toBeUndefined(); // no Google member has that title, and it's not selected
+ });
+
+ test("job title counts respect the selected company (not the job title selection itself)", () => {
+ const { jobTitleFacets } = computeMemberView(members, {
+ selectedCompanyKeys: [companyGroupKey("Google")],
+ });
+ const pm = jobTitleFacets.find((f) => f.display === "Product Manager");
+ const seniorPm = jobTitleFacets.find((f) => f.display === "Senior Product Manager");
+ const ceo = jobTitleFacets.find((f) => f.display === "Founder / CEO");
+ expect(pm.count).toBe(1);
+ expect(seniorPm.count).toBe(1);
+ expect(ceo).toBeUndefined();
+ });
+
+ test("a selected option remains visible (with count 0) even if another filter zeroes it out", () => {
+ const { companyFacets } = computeMemberView(members, {
+ selectedCompanyKeys: [companyGroupKey("Google")],
+ selectedJobTitleKeys: [jobTitleGroupKey("Founder / CEO")], // no Google member has this title
+ });
+ const google = companyFacets.find((f) => f.key === companyGroupKey("Google"));
+ expect(google).toBeDefined();
+ expect(google.count).toBe(0);
+ });
+});
+
+describe("memberJobTitleGroupKey", () => {
+ test("derives the same key as jobTitleGroupKey for a member's raw title", () => {
+ const member = m("X", "Y", "Founder & CEO");
+ expect(memberJobTitleGroupKey(member)).toBe(jobTitleGroupKey("Founder & CEO"));
+ });
+});
diff --git a/frontend/src/landing_page/landing_page_armor/ArmorMembers.js b/frontend/src/landing_page/landing_page_armor/ArmorMembers.js
index 65da91d..bbcb090 100644
--- a/frontend/src/landing_page/landing_page_armor/ArmorMembers.js
+++ b/frontend/src/landing_page/landing_page_armor/ArmorMembers.js
@@ -1,25 +1,78 @@
-import React, { useEffect, useState } from "react";
+import React, { useEffect, useMemo, useRef, useState } from "react";
+import { useSearchParams } from "react-router-dom";
import classNames from "classnames";
import SEO from "../../util/SEO";
import ShareButton from "./ShareButton";
+import FilterDrawer from "./FilterDrawer";
+import useDebouncedValue from "../../util/useDebouncedValue";
+import {
+ computeMemberView,
+ companyGroupKey,
+ memberJobTitleGroupKey,
+ normalizeValue,
+} from "./memberFacets";
+import { normalizeSeparators, jobTitleGroupKey } from "./jobTitleNormalization";
const SHEET_ID = "1IREd2vCxo7rDGOoDUYtzoywVGLqqUFCsu3qGsA0HsMc";
const API_KEY = "AIzaSyCjxXVDGAolugKgrTXpJ0HmAjL0lLxLN1E";
const SHEET_NAME = "Sheet-1";
+function parseCsvParam(searchParams, name) {
+ const raw = searchParams.get(name);
+ if (!raw) return [];
+ return raw
+ .split(",")
+ .map((v) => v.trim())
+ .filter(Boolean);
+}
+
+function parsePageParam(searchParams) {
+ const raw = parseInt(searchParams.get("page"), 10);
+ return Number.isFinite(raw) && raw >= 1 ? raw : 1;
+}
+
+// Resolves the display label for a selected key, checking the current facet
+// list first and falling back to a fresh lookup against the full dataset.
+// Computed synchronously during render (not via effect) so URL/chip labels
+// never show a raw internal key, even on the very first render after a
+// URL-hydrated filter selection.
+function displayForKey(key, facets, members, kind) {
+ const found = facets.find((f) => f.key === key);
+ if (found) return found.display;
+ const match = members.find((m) =>
+ kind === "company" ? companyGroupKey(m.company) === key : memberJobTitleGroupKey(m) === key
+ );
+ if (match) return kind === "company" ? normalizeValue(match.company) : normalizeSeparators(match.title);
+ return key;
+}
+
function ArmorMembers() {
const [members, setMembers] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
- const [query, setQuery] = useState("");
- const [page, setPage] = useState(1);
+ const [searchParams, setSearchParams] = useSearchParams();
+ const [isDrawerOpen, setIsDrawerOpen] = useState(false);
+
+ const [rawQuery, setRawQuery] = useState(() => searchParams.get("search") || "");
+ const [selectedCompanyKeys, setSelectedCompanyKeys] = useState(() =>
+ parseCsvParam(searchParams, "company").map((v) => companyGroupKey(v))
+ );
+ const [selectedJobTitleKeys, setSelectedJobTitleKeys] = useState(() =>
+ parseCsvParam(searchParams, "jobTitle").map((v) => jobTitleGroupKey(v))
+ );
+ const [page, setPage] = useState(() => parsePageParam(searchParams));
const pageSize = 10;
+ const debouncedQuery = useDebouncedValue(rawQuery, 250);
+ const isFirstFilterRun = useRef(true);
+
useEffect(() => {
+ const controller = new AbortController();
+
const fetchData = async () => {
try {
const url = `https://sheets.googleapis.com/v4/spreadsheets/${SHEET_ID}/values/${encodeURIComponent(SHEET_NAME)}?key=${API_KEY}`;
- const res = await fetch(url);
+ const res = await fetch(url, { signal: controller.signal });
const data = await res.json();
if (!data.values || data.values.length < 2) {
@@ -29,7 +82,7 @@ function ArmorMembers() {
const headers = data.values[0];
const rows = data.values.slice(1);
- const parsed = rows.map(row => {
+ const parsed = rows.map((row) => {
const obj = {};
headers.forEach((header, idx) => {
obj[header] = row[idx] || "";
@@ -45,29 +98,96 @@ function ArmorMembers() {
setMembers(parsed);
} catch (err) {
- setError(err.message);
+ if (err.name !== "AbortError") {
+ setError(err.message);
+ }
} finally {
setLoading(false);
}
};
fetchData();
+
+ return () => controller.abort();
}, []);
- const filtered = members.filter((m) => {
- const q = query.toLowerCase();
- return (
- m.firstName.toLowerCase().includes(q) ||
- m.lastName.toLowerCase().includes(q) ||
- m.company.toLowerCase().includes(q) ||
- m.title.toLowerCase().includes(q)
- );
- });
+ const { filteredMembers, companyFacets, jobTitleFacets } = useMemo(
+ () =>
+ computeMemberView(members, {
+ query: debouncedQuery,
+ selectedCompanyKeys,
+ selectedJobTitleKeys,
+ }),
+ [members, debouncedQuery, selectedCompanyKeys, selectedJobTitleKeys]
+ );
+
+ const pages = Math.ceil(filteredMembers.length / pageSize);
+ const slice = filteredMembers.slice((page - 1) * pageSize, page * pageSize);
+
+ const activeFilterCount = selectedCompanyKeys.length + selectedJobTitleKeys.length;
+ const hasActiveFilters = activeFilterCount > 0;
+
+ const selectedCompanyChips = useMemo(
+ () => selectedCompanyKeys.map((key) => ({ key, display: displayForKey(key, companyFacets, members, "company") })),
+ [selectedCompanyKeys, companyFacets, members]
+ );
+ const selectedJobTitleChips = useMemo(
+ () => selectedJobTitleKeys.map((key) => ({ key, display: displayForKey(key, jobTitleFacets, members, "jobTitle") })),
+ [selectedJobTitleKeys, jobTitleFacets, members]
+ );
+
+ // Reset to page 1 whenever a filter changes — but not on the initial mount,
+ // where `page` may have been hydrated from the URL.
+ useEffect(() => {
+ if (isFirstFilterRun.current) {
+ isFirstFilterRun.current = false;
+ return;
+ }
+ setPage(1);
+ }, [debouncedQuery, selectedCompanyKeys, selectedJobTitleKeys]);
+
+ // Clamp page into range once the filtered dataset (and therefore `pages`)
+ // is known, e.g. a shared URL with ?page=999 against a narrower filter.
+ useEffect(() => {
+ if (pages > 0 && page > pages) {
+ setPage(pages);
+ }
+ }, [pages, page]);
+
+ // Keep the URL in sync with search/filters/page, omitting empty params.
+ // Skipped while the dataset hasn't loaded yet — before then, facet display
+ // labels for URL-hydrated selections can't be resolved, so syncing early
+ // would briefly overwrite the URL with a lower-quality fallback (the raw
+ // internal key) instead of the proper display casing.
+ useEffect(() => {
+ if (loading) return;
+ const params = {};
+ if (debouncedQuery) params.search = debouncedQuery;
+ if (selectedCompanyChips.length > 0) {
+ params.company = selectedCompanyChips.map((c) => c.display).join(",");
+ }
+ if (selectedJobTitleChips.length > 0) {
+ params.jobTitle = selectedJobTitleChips.map((c) => c.display).join(",");
+ }
+ if (page > 1) params.page = String(page);
+ setSearchParams(params, { replace: true });
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [debouncedQuery, selectedCompanyChips, selectedJobTitleChips, page, loading]);
+
+ const toggleCompany = (key) => {
+ setSelectedCompanyKeys((prev) => (prev.includes(key) ? prev.filter((k) => k !== key) : [...prev, key]));
+ };
+ const toggleJobTitle = (key) => {
+ setSelectedJobTitleKeys((prev) => (prev.includes(key) ? prev.filter((k) => k !== key) : [...prev, key]));
+ };
- const pages = Math.ceil(filtered.length / pageSize);
- const slice = filtered.slice((page - 1) * pageSize, page * pageSize);
+ const clearAllFilters = () => {
+ setRawQuery("");
+ setSelectedCompanyKeys([]);
+ setSelectedJobTitleKeys([]);
+ };
- if (loading) return
Loading...
;
+ if (loading) return
Loading...
;
if (error) return
Error: {error}
;
return (
@@ -77,110 +197,199 @@ function ArmorMembers() {
description="Browse and connect with AI builders, researchers, founders, and operators across the Anote community."
path="/community/members"
/>
-
+ );
+}
diff --git a/frontend/src/landing_page/landing_page_armor/jobTitleNormalization.js b/frontend/src/landing_page/landing_page_armor/jobTitleNormalization.js
new file mode 100644
index 0000000..bca61e0
--- /dev/null
+++ b/frontend/src/landing_page/landing_page_armor/jobTitleNormalization.js
@@ -0,0 +1,86 @@
+// Job-title normalization for the Members filter drawer.
+//
+// Goal: merge titles that differ only in punctuation/formatting (the SAME set
+// of words joined differently), while never merging titles that use different
+// words — even if they look related. Normalization is used only for grouping
+// and filtering; the member's original stored title is never overwritten.
+//
+// How it works:
+// 1. `cleanTitle` — trims/collapses whitespace only. Never touches words.
+// 2. `normalizeSeparators` — rewrites punctuation/word separators (&, +, ",",
+// the word "and") into a single canonical " / " separator. This is safe
+// to apply unconditionally because it only changes HOW words are joined,
+// never WHICH words are present — so "Product Manager" and
+// "Senior Product Manager" can never collide (different word sets),
+// but "Founder & CEO" and "Founder and CEO" correctly collapse to the
+// same separator-normalized string.
+// 3. `jobTitleGroupKey` — lowercases the separator-normalized string. This
+// is the identity used for grouping/matching, so casing differences
+// ("CEO" vs "ceo") never produce duplicate groups.
+// 4. CANONICAL_JOB_TITLE_MAP — an explicit, hand-maintained lookup from
+// group key -> preferred display label, for cases a generic separator
+// swap can't infer on its own (e.g. "Founder CEO" with no punctuation
+// at all still means the combined role). Add new rows here as new
+// equivalent variants are discovered in the data — each entry should
+// be a deliberate judgment call, not a pattern match.
+//
+// Explicitly NOT done here: substring matching, fuzzy/similarity merging, or
+// collapsing distinct roles/seniority levels (e.g. "Product Manager" and
+// "Senior Product Manager" are different word sets and are never merged).
+
+export function cleanTitle(raw) {
+ return (raw || "").toString().replace(/\s+/g, " ").trim();
+}
+
+const SEPARATOR_PATTERN = /\s*(?:&|\+|,)\s*/g;
+const AND_PATTERN = /\s+and\s+/gi;
+const REPEATED_SLASH_PATTERN = /(?:\s*\/\s*)+/g;
+
+export function normalizeSeparators(raw) {
+ const cleaned = cleanTitle(raw);
+ if (!cleaned) return "";
+ return cleaned
+ .replace(SEPARATOR_PATTERN, " / ")
+ .replace(AND_PATTERN, " / ")
+ .replace(REPEATED_SLASH_PATTERN, " / ")
+ .replace(/\s+/g, " ")
+ .trim();
+}
+
+// Placeholder values people type instead of leaving the field blank —
+// treated the same as blank/null for filter-option purposes. Checked against
+// the raw cleaned string too (before separator normalization), since "N/A"
+// would otherwise become "n / a" and no longer match "na".
+const PLACEHOLDER_VALUES = new Set(["n/a", "na", "none", "null", "undefined", "-", "--", "."]);
+
+export function jobTitleGroupKey(raw) {
+ const rawLower = cleanTitle(raw).toLowerCase();
+ if (PLACEHOLDER_VALUES.has(rawLower)) return "";
+ const key = normalizeSeparators(raw).toLowerCase();
+ return PLACEHOLDER_VALUES.has(key) ? "" : key;
+}
+
+// key (from jobTitleGroupKey) -> canonical display label.
+// Each entry documents *why* it's an equivalence, not just a pattern.
+export const CANONICAL_JOB_TITLE_MAP = {
+ // Founder + CEO in any punctuation/order — same combined role.
+ "founder / ceo": "Founder / CEO",
+ "ceo / founder": "Founder / CEO",
+ "founder ceo": "Founder / CEO", // bare space, no punctuation at all
+
+ // Co-Founder is treated as its own distinct role from Founder (implies
+ // multiple founders), so it gets its own canonical bucket rather than
+ // merging into "Founder / CEO".
+ "co-founder / ceo": "Co-Founder / CEO",
+ "co-founder ceo": "Co-Founder / CEO",
+
+ "president / ceo": "President / CEO",
+ "president ceo": "President / CEO",
+};
+
+// Given a group key and a fallback display (typically the most common
+// separator-normalized casing seen in the dataset for that key), return the
+// label to show in the UI.
+export function canonicalJobTitleDisplay(key, fallbackDisplay) {
+ return CANONICAL_JOB_TITLE_MAP[key] || fallbackDisplay;
+}
diff --git a/frontend/src/landing_page/landing_page_armor/memberFacets.js b/frontend/src/landing_page/landing_page_armor/memberFacets.js
new file mode 100644
index 0000000..ff7ba97
--- /dev/null
+++ b/frontend/src/landing_page/landing_page_armor/memberFacets.js
@@ -0,0 +1,158 @@
+// Pure filtering + faceted-count engine for the Members page.
+//
+// The whole member dataset is already loaded into the browser in one shot
+// (see ArmorMembers.js), so all of this runs against the complete dataset,
+// not just the currently visible page.
+import { jobTitleGroupKey, normalizeSeparators, canonicalJobTitleDisplay } from "./jobTitleNormalization";
+
+export function normalizeValue(value) {
+ return (value || "").toString().trim();
+}
+
+export function normalizeForCompare(value) {
+ return normalizeValue(value).toLowerCase();
+}
+
+export function matchesGeneralSearch(member, query) {
+ const q = normalizeForCompare(query);
+ if (!q) return true;
+ return (
+ normalizeForCompare(member.firstName).includes(q) ||
+ normalizeForCompare(member.lastName).includes(q) ||
+ normalizeForCompare(member.company).includes(q) ||
+ normalizeForCompare(member.title).includes(q)
+ );
+}
+
+// Placeholder values people type instead of leaving the field blank —
+// treated the same as blank/null for filter-option purposes.
+const PLACEHOLDER_VALUES = new Set(["n/a", "na", "none", "null", "undefined", "-", "--", "."]);
+
+export function companyGroupKey(rawCompany) {
+ const normalized = normalizeForCompare(rawCompany);
+ return PLACEHOLDER_VALUES.has(normalized) ? "" : normalized;
+}
+
+export function memberJobTitleGroupKey(member) {
+ return jobTitleGroupKey(member.title);
+}
+
+// Builds { key -> count } style facet groups from a list of members, using
+// caller-supplied key/display functions. Shared by company and job-title
+// faceting so the "pick the most common casing as the display label" logic
+// isn't duplicated.
+function buildFacetGroups(members, { getKey, getDisplayCandidate, getCanonicalDisplay }) {
+ const keyToVariantCounts = new Map(); // key -> Map(displayVariant -> count)
+
+ members.forEach((member) => {
+ const key = getKey(member);
+ if (!key) return;
+ const variant = getDisplayCandidate(member);
+ if (!keyToVariantCounts.has(key)) {
+ keyToVariantCounts.set(key, new Map());
+ }
+ const variantCounts = keyToVariantCounts.get(key);
+ variantCounts.set(variant, (variantCounts.get(variant) || 0) + 1);
+ });
+
+ const groups = [];
+ keyToVariantCounts.forEach((variantCounts, key) => {
+ let bestVariant = null;
+ let bestCount = -1;
+ let total = 0;
+ variantCounts.forEach((count, variant) => {
+ total += count;
+ if (count > bestCount) {
+ bestCount = count;
+ bestVariant = variant;
+ }
+ });
+ const display = getCanonicalDisplay ? getCanonicalDisplay(key, bestVariant) : bestVariant;
+ groups.push({ key, display, count: total });
+ });
+
+ return groups;
+}
+
+function buildCompanyGroups(members) {
+ return buildFacetGroups(members, {
+ getKey: (m) => companyGroupKey(m.company),
+ getDisplayCandidate: (m) => normalizeValue(m.company),
+ });
+}
+
+function buildJobTitleGroups(members) {
+ return buildFacetGroups(members, {
+ getKey: (m) => memberJobTitleGroupKey(m),
+ getDisplayCandidate: (m) => normalizeSeparators(m.title),
+ getCanonicalDisplay: canonicalJobTitleDisplay,
+ });
+}
+
+function sortFacetGroups(groups) {
+ return [...groups].sort((a, b) => {
+ if (b.count !== a.count) return b.count - a.count;
+ return a.display.localeCompare(b.display, undefined, { sensitivity: "base" });
+ });
+}
+
+// Selected values are matched against members via each group's key, so
+// selections stay correct regardless of the exact display casing.
+function memberMatchesSelection(memberKey, selectedKeys) {
+ if (!selectedKeys || selectedKeys.length === 0) return true;
+ return selectedKeys.includes(memberKey);
+}
+
+// Computes the filtered member list plus faceted Company/Job Title option
+// lists in one pass. Facets are computed against "every other active filter
+// except the section's own selection" (standard faceted-search behavior),
+// and any currently-selected value is kept visible even if its count under
+// the other filters drops to zero.
+export function computeMemberView(members, { query, selectedCompanyKeys = [], selectedJobTitleKeys = [] } = {}) {
+ const searchFiltered = members.filter((m) => matchesGeneralSearch(m, query));
+
+ const companyFacetBase = searchFiltered.filter((m) =>
+ memberMatchesSelection(memberJobTitleGroupKey(m), selectedJobTitleKeys)
+ );
+ const jobTitleFacetBase = searchFiltered.filter((m) =>
+ memberMatchesSelection(companyGroupKey(m.company), selectedCompanyKeys)
+ );
+
+ let companyFacets = sortFacetGroups(buildCompanyGroups(companyFacetBase));
+ let jobTitleFacets = sortFacetGroups(buildJobTitleGroups(jobTitleFacetBase));
+
+ companyFacets = companyFacets.filter(
+ (g) => g.count > 0 || selectedCompanyKeys.includes(g.key)
+ );
+ jobTitleFacets = jobTitleFacets.filter(
+ (g) => g.count > 0 || selectedJobTitleKeys.includes(g.key)
+ );
+
+ // A selected value might not appear at all in the current facet base
+ // (e.g. its count truly is zero and it wasn't in buildCompanyGroups because
+ // no member in companyFacetBase has that company). Backfill it from the
+ // FULL dataset so it never silently disappears from the panel.
+ companyFacets = backfillMissingSelections(companyFacets, selectedCompanyKeys, buildCompanyGroups(members));
+ jobTitleFacets = backfillMissingSelections(jobTitleFacets, selectedJobTitleKeys, buildJobTitleGroups(members));
+
+ const filteredMembers = searchFiltered.filter(
+ (m) =>
+ memberMatchesSelection(companyGroupKey(m.company), selectedCompanyKeys) &&
+ memberMatchesSelection(memberJobTitleGroupKey(m), selectedJobTitleKeys)
+ );
+
+ return { filteredMembers, companyFacets, jobTitleFacets };
+}
+
+function backfillMissingSelections(facets, selectedKeys, fullDatasetGroups) {
+ const present = new Set(facets.map((f) => f.key));
+ const missing = selectedKeys.filter((key) => !present.has(key));
+ if (missing.length === 0) return facets;
+
+ const fullByKey = new Map(fullDatasetGroups.map((g) => [g.key, g]));
+ const backfilled = missing.map((key) => {
+ const known = fullByKey.get(key);
+ return known ? { ...known, count: 0 } : { key, display: key, count: 0 };
+ });
+ return sortFacetGroups([...facets, ...backfilled]);
+}
diff --git a/frontend/src/setupTests.js b/frontend/src/setupTests.js
new file mode 100644
index 0000000..9cff928
--- /dev/null
+++ b/frontend/src/setupTests.js
@@ -0,0 +1,3 @@
+// jest-dom adds custom jest matchers for asserting on DOM nodes.
+// https://github.com/testing-library/jest-dom
+import "@testing-library/jest-dom";
diff --git a/frontend/src/util/useDebouncedValue.js b/frontend/src/util/useDebouncedValue.js
new file mode 100644
index 0000000..242febe
--- /dev/null
+++ b/frontend/src/util/useDebouncedValue.js
@@ -0,0 +1,14 @@
+import { useEffect, useState } from "react";
+
+// Returns `value`, but only updates after it has been stable for `delayMs`.
+// Used to avoid re-filtering/re-rendering on every keystroke.
+export default function useDebouncedValue(value, delayMs = 250) {
+ const [debounced, setDebounced] = useState(value);
+
+ useEffect(() => {
+ const timer = setTimeout(() => setDebounced(value), delayMs);
+ return () => clearTimeout(timer);
+ }, [value, delayMs]);
+
+ return debounced;
+}
diff --git a/frontend/src/util/useFocusTrap.js b/frontend/src/util/useFocusTrap.js
new file mode 100644
index 0000000..01c4e03
--- /dev/null
+++ b/frontend/src/util/useFocusTrap.js
@@ -0,0 +1,50 @@
+import { useEffect, useRef } from "react";
+
+const FOCUSABLE_SELECTOR =
+ 'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
+
+// Traps Tab/Shift+Tab focus within `containerRef` while `isActive` is true,
+// and restores focus to whatever was focused before activation once it ends.
+export default function useFocusTrap(containerRef, isActive) {
+ const previouslyFocused = useRef(null);
+
+ useEffect(() => {
+ if (!isActive) return undefined;
+
+ previouslyFocused.current = document.activeElement;
+ const container = containerRef.current;
+
+ const focusFirst = () => {
+ const focusable = container?.querySelectorAll(FOCUSABLE_SELECTOR);
+ if (focusable && focusable.length > 0) {
+ focusable[0].focus();
+ }
+ };
+ focusFirst();
+
+ const handleKeyDown = (e) => {
+ if (e.key !== "Tab" || !container) return;
+ const focusable = Array.from(container.querySelectorAll(FOCUSABLE_SELECTOR));
+ if (focusable.length === 0) return;
+
+ const first = focusable[0];
+ const last = focusable[focusable.length - 1];
+
+ if (e.shiftKey && document.activeElement === first) {
+ e.preventDefault();
+ last.focus();
+ } else if (!e.shiftKey && document.activeElement === last) {
+ e.preventDefault();
+ first.focus();
+ }
+ };
+
+ document.addEventListener("keydown", handleKeyDown);
+ return () => {
+ document.removeEventListener("keydown", handleKeyDown);
+ if (previouslyFocused.current && previouslyFocused.current.focus) {
+ previouslyFocused.current.focus();
+ }
+ };
+ }, [isActive, containerRef]);
+}