From 48dfef517b03801288cf44be1ee41aa790cd8818 Mon Sep 17 00:00:00 2001 From: CJ Rivas Date: Sun, 12 Jul 2026 10:20:23 -0400 Subject: [PATCH] TKW: add house rank filter to `/houses`, combinable with status - `Rank` dropdown filters by Royal, Lordly, Knightly, Other, Exiled, Extinct - backed by `?rank=` query param; ANDs with the existing status filter - `facetFiltered` folds status + rank into one set for count and region grouping - rank change resets to page 1 - export `HouseRank` type from `HouseRankSchema` in `lib/schemas.ts` - `.rankFilter`/`.rankLabel` styles matching the page-size control --- app/houses/page.tsx | 1 + .../FilteredHouseList.module.scss | 15 ++ .../FilteredHouseList.test.tsx | 130 ++++++++++++++++++ .../FilteredHouseList/FilteredHouseList.tsx | 78 +++++++++-- lib/schemas.ts | 1 + 5 files changed, 214 insertions(+), 11 deletions(-) diff --git a/app/houses/page.tsx b/app/houses/page.tsx index eb9f8b5b..0c2dff7a 100644 --- a/app/houses/page.tsx +++ b/app/houses/page.tsx @@ -32,6 +32,7 @@ export default async function HousesPage() { region, regionLabel: regionLabel(region), extinct: h.frontmatter.status === "extinct", + rank: h.frontmatter.rank, }; }) .sort(compareByName); diff --git a/components/FilteredHouseList/FilteredHouseList.module.scss b/components/FilteredHouseList/FilteredHouseList.module.scss index 465e9d59..26f64daa 100644 --- a/components/FilteredHouseList/FilteredHouseList.module.scss +++ b/components/FilteredHouseList/FilteredHouseList.module.scss @@ -20,6 +20,21 @@ margin-left: auto; } +.rankFilter { + display: flex; + align-items: center; + gap: 0.4rem; + font-family: var(--font-ui); + font-size: 0.85rem; + color: var(--ink-faded); + letter-spacing: 0.5px; +} + +.rankLabel { + font-variant: small-caps; + letter-spacing: 1.5px; +} + .regions { display: grid; gap: 0.75rem; diff --git a/components/FilteredHouseList/FilteredHouseList.test.tsx b/components/FilteredHouseList/FilteredHouseList.test.tsx index 05508e10..b7a43eff 100644 --- a/components/FilteredHouseList/FilteredHouseList.test.tsx +++ b/components/FilteredHouseList/FilteredHouseList.test.tsx @@ -727,6 +727,136 @@ describe("FilteredHouseList status filter", () => { }); }); +describe("FilteredHouseList rank filter", () => { + const ranked: HouseItem[] = [ + { + slug: "stark", + name: "Stark", + region: "north", + regionLabel: "The North", + rank: "lordly", + }, + { + slug: "clegane", + name: "Clegane", + region: "westerlands", + regionLabel: "The Westerlands", + rank: "knightly", + }, + { + slug: "reyne", + name: "Reyne", + region: "westerlands", + regionLabel: "The Westerlands", + rank: "lordly", + extinct: true, + }, + ]; + + it("exposes a house-rank filter", () => { + renderWithNuqs(); + expect(screen.getByRole("combobox", { name: /house rank/i })).toBeDefined(); + }); + + it("shows every house regardless of rank by default", () => { + renderWithNuqs(); + expect(screen.getAllByRole("link").length).toBe(3); + }); + + it("shows only houses of the selected rank", () => { + renderWithNuqs(); + fireEvent.change(screen.getByRole("combobox", { name: /house rank/i }), { + target: { value: "lordly" }, + }); + const cards = screen.getAllByRole("link"); + expect(cards.map((c) => c.textContent)).toEqual( + expect.arrayContaining([ + expect.stringContaining("Stark"), + expect.stringContaining("Reyne"), + ]), + ); + expect(cards.length).toBe(2); + }); + + it("combines the rank filter with the status filter", () => { + renderWithNuqs(); + fireEvent.change(screen.getByRole("combobox", { name: /house rank/i }), { + target: { value: "lordly" }, + }); + fireEvent.click(screen.getByRole("button", { name: /extinct houses/i })); + const cards = screen.getAllByRole("link"); + expect(cards.length).toBe(1); + expect(cards[0].textContent).toContain("Reyne"); + }); + + it("reflects the rank filter in the total count", () => { + renderWithNuqs(); + fireEvent.change(screen.getByRole("combobox", { name: /house rank/i }), { + target: { value: "knightly" }, + }); + expect(screen.getByText("1 house")).toBeDefined(); + }); + + it("hydrates the rank filter from ?rank=lordly on mount", () => { + renderWithNuqs(, { + searchParams: "?rank=lordly", + }); + expect(screen.getAllByRole("link").length).toBe(2); + const select = screen.getByRole("combobox", { + name: /house rank/i, + }) as HTMLSelectElement; + expect(select.value).toBe("lordly"); + }); + + it("writes ?rank=lordly when a rank is selected", async () => { + const { onUrlUpdate } = renderWithNuqs( + , + ); + fireEvent.change(screen.getByRole("combobox", { name: /house rank/i }), { + target: { value: "lordly" }, + }); + await flushNuqs(); + expect(lastQueryString(onUrlUpdate)).toBe("?rank=lordly"); + }); + + it("removes ?rank= when the filter returns to Any rank", async () => { + const { onUrlUpdate } = renderWithNuqs( + , + { searchParams: "?rank=lordly" }, + ); + fireEvent.change(screen.getByRole("combobox", { name: /house rank/i }), { + target: { value: "all" }, + }); + await flushNuqs(); + expect(lastQueryString(onUrlUpdate)).toBe(""); + }); + + it("resets ?page= when the rank filter changes", async () => { + const { onUrlUpdate } = renderWithNuqs( + , + { searchParams: "?page=2" }, + ); + fireEvent.change(screen.getByRole("combobox", { name: /house rank/i }), { + target: { value: "lordly" }, + }); + await flushNuqs(); + expect(lastSearchParams(onUrlUpdate).get("rank")).toBe("lordly"); + expect(lastSearchParams(onUrlUpdate).get("page")).toBeNull(); + }); + + it("applies the rank filter inside region grouping", () => { + renderWithNuqs(); + fireEvent.click(screen.getByRole("button", { name: /group by region/i })); + fireEvent.change(screen.getByRole("combobox", { name: /house rank/i }), { + target: { value: "knightly" }, + }); + expect( + screen.getByRole("button", { name: /the westerlands/i }), + ).toBeDefined(); + expect(screen.queryByRole("button", { name: /the north/i })).toBeNull(); + }); +}); + describe("FilteredHouseList view toggle", () => { it("starts in grid view by default", () => { const { container } = renderWithNuqs(); diff --git a/components/FilteredHouseList/FilteredHouseList.tsx b/components/FilteredHouseList/FilteredHouseList.tsx index 4aa6722e..7f6592ba 100644 --- a/components/FilteredHouseList/FilteredHouseList.tsx +++ b/components/FilteredHouseList/FilteredHouseList.tsx @@ -16,6 +16,7 @@ import { type ViewMode, } from "@/components/ViewToggle"; import { filterByName } from "@/lib/search"; +import type { HouseRank } from "@/lib/schemas"; import { cx } from "@/lib/cx"; import { compareByName } from "@/lib/collections"; import { REGION_SLUGS, regionLabel } from "@/lib/regions"; @@ -38,6 +39,7 @@ export type HouseItem = { region: string | null; regionLabel: string | null; extinct?: boolean; + rank?: HouseRank; }; type Props = { @@ -93,6 +95,33 @@ const STATUS_OPTIONS = [ { value: "extinct" as const, label: "Extinct houses", icon: }, ]; +type RankFilter = + "all" | "royal" | "lordly" | "knightly" | "other" | "exiled" | "extinct"; + +const RANK_FILTERS = [ + "all", + "royal", + "lordly", + "knightly", + "other", + "exiled", + "extinct", +] as const; + +const RANK_OPTIONS: { value: RankFilter; label: string }[] = [ + { value: "all", label: "Any rank" }, + { value: "royal", label: "Royal" }, + { value: "lordly", label: "Lordly" }, + { value: "knightly", label: "Knightly" }, + { value: "other", label: "Other" }, + { value: "exiled", label: "Exiled" }, + { value: "extinct", label: "Extinct" }, +]; + +function isRankFilter(value: string): value is RankFilter { + return (RANK_FILTERS as readonly string[]).includes(value); +} + export function FilteredHouseList({ items, pageSize = DEFAULT_PAGE_SIZE, @@ -108,6 +137,10 @@ export function FilteredHouseList({ "status", parseAsStringLiteral(STATUS_FILTERS).withDefault("all"), ); + const [rank, setRank] = useQueryState( + "rank", + parseAsStringLiteral(RANK_FILTERS).withDefault("all"), + ); const [userValue, setUserValue] = useState(undefined); const [userDebounced, setUserDebounced] = useState( @@ -175,18 +208,20 @@ export function FilteredHouseList({ return dir === "desc" ? arr.reverse() : arr; }, [items, dir]); - const statusFiltered = useMemo( - () => + const facetFiltered = useMemo(() => { + const byStatus = status === "all" ? sorted : sorted.filter((item) => status === "extinct" ? !!item.extinct : !item.extinct, - ), - [sorted, status], - ); - - const filtered = filterByName(statusFiltered, debounced); - const total = statusFiltered.length; + ); + return rank === "all" + ? byStatus + : byStatus.filter((item) => item.rank === rank); + }, [sorted, status, rank]); + + const filtered = filterByName(facetFiltered, debounced); + const total = facetFiltered.length; const matching = filtered.length; const hasQuery = debounced.trim().length > 0; const noun = total === 1 ? "house" : "houses"; @@ -205,13 +240,13 @@ export function FilteredHouseList({ const known = REGION_SLUGS.map((slug) => ({ slug, label: regionLabel(slug) ?? slug, - items: statusFiltered.filter((item) => item.region === slug), + items: facetFiltered.filter((item) => item.region === slug), })).filter((group) => group.items.length > 0); - const other = statusFiltered.filter((item) => item.region === null); + const other = facetFiltered.filter((item) => item.region === null); return other.length > 0 ? [...known, { slug: "other", label: "Other Houses", items: other }] : known; - }, [statusFiltered]); + }, [facetFiltered]); const writePage = (next: number) => { setParams({ page: next }); @@ -231,6 +266,12 @@ export function FilteredHouseList({ setParams({ page: 1 }); }; + const handleRankChange = (next: string) => { + if (!isRankFilter(next)) return; + setRank(next === "all" ? null : next); + setParams({ page: 1 }); + }; + const listClass = cx(styles.list, view === "list" && styles.listView); const renderCard = ({ @@ -335,6 +376,21 @@ export function FilteredHouseList({ /> )}
+ ; export type Landmass = z.infer; export type Castle = z.infer; export type House = z.infer; +export type HouseRank = z.infer; export type HouseInfoEntry = z.infer; export type Character = z.infer; export type Event = z.infer;