From a12789ed9072258334ce53f7b1c2b32647433e4e Mon Sep 17 00:00:00 2001 From: violet Date: Thu, 10 Sep 2026 15:18:42 -0400 Subject: [PATCH 1/2] search: default Browse Bills to the Relevance sort The default was "Sort by Most Recent Testimony", a pure recency sort in which text relevance plays no part. A query like "liquor" expands through the legislative synonym set to "alcohol" and matches hundreds of bills in their full text, and under that sort the page led with whichever weak body-text match had the newest testimony: the budget, a tax-cap bill, a tenant bill. Relevance is now the first option and the default. Its index name is the bare "bills" collection rather than "bills/sort/", and BillSearch pins billsRelevanceSort as the adapter's sort_by: the adapter falls back to that whenever the index name has no sort segment. That keeps the _eval clause out of every Browse Bills URL, which is keyed by index name, so a default page reads bills[query]=liquor. The other options keep their sort in the index name and override the pinned one as before. The eval-shared billsSearchParams is unchanged, so the harness sends exactly what it did. The default index name lives next to the sort options, and BillSearch and billSearchByTopicLink both key on it instead of a hard-coded string; the topic links previously carried the old latestTestimonyAt key and would otherwise have landed on the page with no refinements. billsRelevanceSort itself is unchanged and the bills golden set scores byte-identical to the bills-order-demotion baseline. The empty-query landing page now orders non-procedural bills by testimony count, the sort's tiebreaker. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01QeZFi8CjMyJixcm5CbGUzq --- components/links.tsx | 8 +++++--- components/search/bills/BillSearch.tsx | 17 +++++++++++------ components/search/bills/useBillSort.tsx | 19 ++++++++++++++----- components/search/searchParams.ts | 17 +++++++++-------- tests/e2e/browse-bills.spec.ts | 2 +- 5 files changed, 40 insertions(+), 23 deletions(-) diff --git a/components/links.tsx b/components/links.tsx index 0482a64fc..a044d7926 100644 --- a/components/links.tsx +++ b/components/links.tsx @@ -4,6 +4,7 @@ import { BillTopic, CurrentCommittee } from "../functions/src/bills/types" import { Testimony } from "components/db/testimony" import { Bill, MemberContent } from "./db" import { formatBillId } from "./formatting" +import { billsDefaultIndex } from "./search/bills/useBillSort" import { TFunction } from "next-i18next" type LinkProps = PropsWithChildren< @@ -145,9 +146,10 @@ export const twitterShareLink = (publication: Testimony, t: TFunction) => { } export const billSearchByTopicLink = (court: number, topic: BillTopic) => { + // Routed uiState is keyed by the page's index name. const params = { - "bills/sort/latestTestimonyAt:desc[multiselectHierarchicalMenu][topics.lvl1][0]": `${topic.category} > ${topic.topic}`, - "bills/sort/latestTestimonyAt:desc[refinementList][court][0]": `${court}` + [`${billsDefaultIndex}[multiselectHierarchicalMenu][topics.lvl1][0]`]: `${topic.category} > ${topic.topic}`, + [`${billsDefaultIndex}[refinementList][court][0]`]: `${court}` } - return `/bills?${new URLSearchParams(params).toString()}` + return `${maple.billSearch()}?${new URLSearchParams(params).toString()}` } diff --git a/components/search/bills/BillSearch.tsx b/components/search/bills/BillSearch.tsx index 6ee7bdfb8..bfc49538b 100644 --- a/components/search/bills/BillSearch.tsx +++ b/components/search/bills/BillSearch.tsx @@ -23,14 +23,20 @@ import { BillHit } from "./BillHit" import { useBillRefinements } from "./useBillRefinements" import { SortBy, SortByWithConfigurationItem } from "../SortBy" import { getServerConfig, VirtualFilters } from "../common" -import { billsSearchParams } from "../searchParams" -import { useBillSort } from "./useBillSort" +import { billsRelevanceSort, billsSearchParams } from "../searchParams" +import { billsDefaultIndex, useBillSort } from "./useBillSort" import { FC, useState } from "react" import { pathToSearchState, searchStateToUrl } from "../routingHelpers" const searchClient = new TypesenseInstantSearchAdapter({ server: getServerConfig(), - additionalSearchParameters: billsSearchParams + // sort_by only reaches Typesense under the default option, whose index name + // (billsDefaultIndex) has no sort segment; every other option's + // "bills/sort/" index name overrides it in the adapter. + additionalSearchParameters: { + ...billsSearchParams, + sort_by: billsRelevanceSort + } }).searchClient const extractLastSegmentOfRefinements = (items: any[]) => { @@ -61,13 +67,12 @@ const extractLastSegmentOfRefinements = (items: any[]) => { export const BillSearch = () => { const items = useBillSort() - const initialSortByValue = items[0].value return ( ": the adapter only reads a + * sort from an index name that has a sort segment, and otherwise uses the + * sort_by BillSearch.tsx pins on the adapter. That keeps the long relevance + * clause out of Browse Bills URLs, where the index name is the qs key of the + * routed uiState (it would vanish entirely under InstantSearch's singleIndex + * state mapping, which the router does not use yet). BillSearch.tsx keys the + * page on it and links.tsx keys deep links on it. + */ +export const billsDefaultIndex = "bills" export const useBillSort = () => { const now = useRef(new Date().getTime()) @@ -12,12 +21,12 @@ export const useBillSort = () => { const items: SortByWithConfigurationItem[] = useMemo( () => [ { - label: t("sort_by.most_recent_testimony"), - value: "bills/sort/latestTestimonyAt:desc" + label: t("sort_by.relevance"), + value: billsDefaultIndex }, { - label: t("sort_by.relevance"), - value: `bills/sort/${billsRelevanceSort}` + label: t("sort_by.most_recent_testimony"), + value: "bills/sort/latestTestimonyAt:desc" }, { label: t("sort_by.testimony_count"), diff --git a/components/search/searchParams.ts b/components/search/searchParams.ts index fa3206471..7296933f4 100644 --- a/components/search/searchParams.ts +++ b/components/search/searchParams.ts @@ -74,9 +74,10 @@ export const billsSearchParams = { exclude_fields: "body,numberVariants" } satisfies SearchParameters -/** The app's "Relevance" sort option (see useBillSort.tsx). The eval harness - * must use this sort; the UI's default latestTestimonyAt:desc sort does not - * measure text relevance. +/** The app's "Relevance" sort option and the Browse Bills default (see + * useBillSort.tsx; BillSearch.tsx pins it as the adapter's sort_by). The eval + * harness must use this sort; every other bills sort is a date or a count and + * does not measure text relevance. * * The `_eval` clause sorts every Order and Extension Order below every other * document. They are procedural — "Extension Order - Education", "Order @@ -98,11 +99,11 @@ export const billsSearchParams = { * legislationType refinement (useBillRefinements.tsx) is how a searcher who * wants them gets them back, which is why the facet ships with this sort. * - * Written as two `!=` clauses rather than the equivalent `!=[...]` list form: - * this string doubles as the InstantSearch index name, which travels through - * the URL as a qs key, and qs parses percent-encoded square brackets in a key - * as nesting — mangling the routed uiState so a reload or shared link loses - * the query, refinements and sort. No other character here is special to qs. + * Written as two `!=` clauses rather than the equivalent `!=[...]` list form. + * A sort embedded in an InstantSearch index name becomes a qs key in the URL, + * and qs parses percent-encoded square brackets in a key as nesting, mangling + * the routed uiState. This string no longer rides in the index name, but the + * hearings and testimony relevance sorts do, so all three stay bracket-free. */ export const billsRelevanceSort = "_eval(legislationType:!=`Order` && legislationType:!=`Extension Order`):desc,_text_match:desc,testimonyCount:desc" diff --git a/tests/e2e/browse-bills.spec.ts b/tests/e2e/browse-bills.spec.ts index a409e6075..a63240d4e 100644 --- a/tests/e2e/browse-bills.spec.ts +++ b/tests/e2e/browse-bills.spec.ts @@ -65,8 +65,8 @@ test.describe("Search result test", () => { }) // Array of sorting test configurations -// Need to add test for sort by relevant const sortingTests: string[] = [ + "Sort by Relevance", "Sort by Testimony Count", "Sort by Cosponsor Count", "Sort by Next Hearing Date", From eac7de8b718bf6a45c36563bc7ba4d6a5f57b75b Mon Sep 17 00:00:00 2001 From: violet Date: Thu, 10 Sep 2026 15:50:36 -0400 Subject: [PATCH 2/2] search: default Browse Testimony to the Relevance sort Same change as for bills, same mechanism. Relevance is the first option and the default, keyed on the bare "publishedTestimony" collection with testimonyRelevanceSort pinned as the adapter's sort_by, so the sort string stays out of the page's URLs. Newest and Oldest keep their sort in the index name and override it as before. The landing page does not change: with no query every document's text match is equal, so the relevance sort falls through to its publishedAt tiebreak and reads newest-first exactly as the old default did. With a query, weak matches no longer win on date alone. Hearings keep their date-windowed default, since "past, newest first" is a view with a time filter rather than a sort. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01QeZFi8CjMyJixcm5CbGUzq --- components/search/searchParams.ts | 7 +++-- .../search/testimony/TestimonySearch.tsx | 29 ++++++++++++++----- tests/e2e/page_objects/testimony.ts | 1 - tests/e2e/testimony.spec.ts | 27 ++++++++--------- 4 files changed, 39 insertions(+), 25 deletions(-) diff --git a/components/search/searchParams.ts b/components/search/searchParams.ts index 7296933f4..dd08234ac 100644 --- a/components/search/searchParams.ts +++ b/components/search/searchParams.ts @@ -103,7 +103,7 @@ export const billsSearchParams = { * A sort embedded in an InstantSearch index name becomes a qs key in the URL, * and qs parses percent-encoded square brackets in a key as nesting, mangling * the routed uiState. This string no longer rides in the index name, but the - * hearings and testimony relevance sorts do, so all three stay bracket-free. + * hearings relevance sort does, so all three stay bracket-free. */ export const billsRelevanceSort = "_eval(legislationType:!=`Order` && legislationType:!=`Extension Order`):desc,_text_match:desc,testimonyCount:desc" @@ -130,8 +130,9 @@ export const testimonySearchParams = { exclude_fields: "billIdVariants" } satisfies SearchParameters -/** The app's "Relevance" sort option (see useTestimonySort in - * testimony/TestimonySearch.tsx), and the sort the eval harness must use. +/** The app's "Relevance" sort option and the Browse Testimony default (see + * useTestimonySort in testimony/TestimonySearch.tsx, which pins it as the + * adapter's sort_by), and the sort the eval harness must use. */ export const testimonyRelevanceSort = "_text_match:desc,publishedAt:desc" diff --git a/components/search/testimony/TestimonySearch.tsx b/components/search/testimony/TestimonySearch.tsx index 9c8e4e751..d1e7120f1 100644 --- a/components/search/testimony/TestimonySearch.tsx +++ b/components/search/testimony/TestimonySearch.tsx @@ -32,15 +32,33 @@ import { FollowContext, OrgFollowStatus } from "components/shared/FollowContext" import { pathToSearchState, searchStateToUrl } from "../routingHelpers" import { useTranslation } from "next-i18next" +/** The InstantSearch index name of the default (Relevance) sort: the bare + * collection, with the sort pinned on the adapter below, the same way Browse + * Bills does it (see billsDefaultIndex in bills/useBillSort.tsx). With no + * query every document's text match is equal, so the landing page falls + * through to the sort's publishedAt:desc tiebreak and reads newest-first. + */ +export const testimonyDefaultIndex = "publishedTestimony" + const searchClient = new TypesenseInstantSearchAdapter({ server: getServerConfig(), - additionalSearchParameters: testimonySearchParams + // sort_by only reaches Typesense under the default option, whose index name + // has no sort segment; the other options' "publishedTestimony/sort/" + // index names override it in the adapter. + additionalSearchParameters: { + ...testimonySearchParams, + sort_by: testimonyRelevanceSort + } }).searchClient export const useTestimonySort = () => { const { t } = useTranslation("search") const items: SortByItem[] = useMemo( () => [ + { + label: t("sort_by.relevance"), + value: testimonyDefaultIndex + }, { label: t("sort_by.newest"), value: "publishedTestimony/sort/publishedAt:desc" @@ -48,10 +66,6 @@ export const useTestimonySort = () => { { label: t("sort_by.oldest"), value: "publishedTestimony/sort/publishedAt:asc" - }, - { - label: t("sort_by.relevance"), - value: `publishedTestimony/sort/${testimonyRelevanceSort}` } ], [t] @@ -60,13 +74,12 @@ export const useTestimonySort = () => { } export const TestimonySearch = () => { - const initialSortByValue = useTestimonySort()[0].value return ( Old").click() await this.page .getByText(/Sort by/i) .first() diff --git a/tests/e2e/testimony.spec.ts b/tests/e2e/testimony.spec.ts index 94a1c56ea..c9c871a0d 100644 --- a/tests/e2e/testimony.spec.ts +++ b/tests/e2e/testimony.spec.ts @@ -163,18 +163,19 @@ test.describe("Testimony Filtering", () => { }) }) -test.describe("Testimony Sorting", () => { - test("should sort by new -> old", async ({ page }) => { - const testimonyPage = new TestimonyPage(page) - await testimonyPage.sort("Sort by New -> Old") - const sortValue = page.getByText("Sort by New -> Old", { exact: true }) - await expect(sortValue).toBeVisible() - }) +const sortingTests: string[] = [ + "Sort by Relevance", + "Sort by Newest", + "Sort by Oldest" +] - test("should sort by old -> new", async ({ page }) => { - const testimonyPage = new TestimonyPage(page) - await testimonyPage.sort("Sort by Old -> New") - const sortValue = page.getByText("Sort by Old -> New", { exact: true }) - await expect(sortValue).toBeVisible() - }) +test.describe("Testimony Sorting", () => { + for (const option of sortingTests) { + test(`should sort testimony by ${option}`, async ({ page }) => { + const testimonyPage = new TestimonyPage(page) + await testimonyPage.sort(option) + const sortValue = page.getByText(option, { exact: true }) + await expect(sortValue).toBeVisible() + }) + } })