From 5bb0c2b8ec17457f49fa1e293c2d678a354e0865 Mon Sep 17 00:00:00 2001 From: Jack Levy Date: Mon, 2 Mar 2026 12:48:49 -0500 Subject: [PATCH] feat: accordion sections, search, and filters on following page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Each section (Bills, Members, Topics) collapses/expands independently, open by default - Search input per section filters by bill label/title, member name, or topic string - Chamber filter for bills, party filter for members — dropdowns only appear when more than one value is present in the loaded data - useQueries batch-fetches bill/member data at page level for filtering; shares React Query cache with individual rows so no extra API calls Authored-By: Jack Levy --- frontend/app/following/page.tsx | 310 +++++++++++++++++++++++--------- 1 file changed, 224 insertions(+), 86 deletions(-) diff --git a/frontend/app/following/page.tsx b/frontend/app/following/page.tsx index b6b9dc6..9a9f732 100644 --- a/frontend/app/following/page.tsx +++ b/frontend/app/following/page.tsx @@ -1,20 +1,19 @@ "use client"; +import { useState } from "react"; +import { useQueries } from "@tanstack/react-query"; import Link from "next/link"; -import { Heart, ExternalLink, X } from "lucide-react"; +import { ChevronDown, ChevronRight, ExternalLink, Heart, Search, X } from "lucide-react"; import { useFollows, useRemoveFollow } from "@/lib/hooks/useFollows"; -import { useBill } from "@/lib/hooks/useBills"; -import { useMember } from "@/lib/hooks/useMembers"; +import { billsAPI, membersAPI } from "@/lib/api"; import { FollowButton } from "@/components/shared/FollowButton"; import { billLabel, chamberBadgeColor, cn, formatDate, partyBadgeColor } from "@/lib/utils"; import type { Follow } from "@/lib/types"; -// ── Bill row ──────────────────────────────────────────────────────────────── +// ── Bill row ───────────────────────────────────────────────────────────────── -function BillRow({ follow }: { follow: Follow }) { - const { data: bill } = useBill(follow.follow_value); +function BillRow({ follow, bill }: { follow: Follow; bill?: ReturnType extends Promise ? T : never }) { const label = bill ? billLabel(bill.bill_type, bill.bill_number) : follow.follow_value; - return (
@@ -46,35 +45,27 @@ function BillRow({ follow }: { follow: Follow }) { ); } -// ── Member row ─────────────────────────────────────────────────────────────── - -function MemberRow({ follow, onRemove }: { follow: Follow; onRemove: () => void }) { - const { data: member } = useMember(follow.follow_value); +// ── Member row ──────────────────────────────────────────────────────────────── +function MemberRow({ follow, member, onRemove }: { + follow: Follow; + member?: ReturnType extends Promise ? T : never; + onRemove: () => void; +}) { return (
- {/* Photo */}
{member?.photo_url ? ( - {member.name} + {member.name} ) : (
{member ? member.name[0] : "?"}
)}
- - {/* Info */}
- + {member?.name ?? follow.follow_value} {member?.party && ( @@ -86,44 +77,111 @@ function MemberRow({ follow, onRemove }: { follow: Follow; onRemove: () => void {(member?.chamber || member?.state || member?.district) && (

{[member.chamber, member.state, member.district ? `District ${member.district}` : null] - .filter(Boolean) - .join(" · ")} + .filter(Boolean).join(" · ")}

)} {member?.official_url && ( - + Official site )}
- - {/* Unfollow */} -
); } -// ── Page ───────────────────────────────────────────────────────────────────── +// ── Section accordion wrapper ───────────────────────────────────────────────── + +function Section({ title, count, children }: { title: string; count: number; children: React.ReactNode }) { + const [open, setOpen] = useState(true); + return ( +
+ + {open && children} +
+ ); +} + +// ── Page ────────────────────────────────────────────────────────────────────── export default function FollowingPage() { const { data: follows = [], isLoading } = useFollows(); const remove = useRemoveFollow(); + const [billSearch, setBillSearch] = useState(""); + const [billChamber, setBillChamber] = useState(""); + const [memberSearch, setMemberSearch] = useState(""); + const [memberParty, setMemberParty] = useState(""); + const [topicSearch, setTopicSearch] = useState(""); + const bills = follows.filter((f) => f.follow_type === "bill"); const members = follows.filter((f) => f.follow_type === "member"); const topics = follows.filter((f) => f.follow_type === "topic"); + // Batch-fetch bill + member data at page level so filters have access to titles/names. + // Uses the same query keys as BillRow/MemberRow — React Query deduplicates, no extra calls. + const billQueries = useQueries({ + queries: bills.map((f) => ({ + queryKey: ["bill", f.follow_value], + queryFn: () => billsAPI.get(f.follow_value), + staleTime: 2 * 60 * 1000, + })), + }); + + const memberQueries = useQueries({ + queries: members.map((f) => ({ + queryKey: ["member", f.follow_value], + queryFn: () => membersAPI.get(f.follow_value), + staleTime: 10 * 60 * 1000, + })), + }); + + // Filter bills + const filteredBills = bills.filter((f, i) => { + const bill = billQueries[i]?.data; + if (billChamber && bill?.chamber?.toLowerCase() !== billChamber.toLowerCase()) return false; + if (billSearch) { + const q = billSearch.toLowerCase(); + const label = bill ? billLabel(bill.bill_type, bill.bill_number).toLowerCase() : ""; + const title = (bill?.short_title || bill?.title || "").toLowerCase(); + const id = f.follow_value.toLowerCase(); + if (!label.includes(q) && !title.includes(q) && !id.includes(q)) return false; + } + return true; + }); + + // Filter members + const filteredMembers = members.filter((f, i) => { + const member = memberQueries[i]?.data; + if (memberParty && member?.party !== memberParty) return false; + if (memberSearch) { + const q = memberSearch.toLowerCase(); + const name = (member?.name || f.follow_value).toLowerCase(); + if (!name.includes(q)) return false; + } + return true; + }); + + // Filter topics + const filteredTopics = topics.filter((f) => + !topicSearch || f.follow_value.toLowerCase().includes(topicSearch.toLowerCase()) + ); + + // Unique parties and chambers from loaded data for filter dropdowns + const loadedChambers = [...new Set(billQueries.map((q) => q.data?.chamber).filter(Boolean))] as string[]; + const loadedParties = [...new Set(memberQueries.map((q) => q.data?.party).filter(Boolean))] as string[]; + if (isLoading) return
Loading...
; return ( @@ -136,57 +194,137 @@ export default function FollowingPage() {
{/* Bills */} -
-

Bills ({bills.length})

- {!bills.length ? ( -

No bills followed yet.

- ) : ( -
- {bills.map((f) => )} -
- )} -
+
+
+ {/* Search + filter bar */} + {bills.length > 0 && ( +
+
+ + setBillSearch(e.target.value)} + className="w-full pl-8 pr-3 py-1.5 text-sm bg-card border border-border rounded-md focus:outline-none focus:ring-1 focus:ring-primary" + /> +
+ {loadedChambers.length > 1 && ( + + )} +
+ )} + + {!bills.length ? ( +

No bills followed yet.

+ ) : !filteredBills.length ? ( +

No bills match your filters.

+ ) : ( + filteredBills.map((f, i) => { + const originalIndex = bills.indexOf(f); + return ; + }) + )} +
+
{/* Members */} -
-

Members ({members.length})

- {!members.length ? ( -

No members followed yet.

- ) : ( -
- {members.map((f) => ( - remove.mutate(f.id)} /> - ))} -
- )} -
+
+
+ {members.length > 0 && ( +
+
+ + setMemberSearch(e.target.value)} + className="w-full pl-8 pr-3 py-1.5 text-sm bg-card border border-border rounded-md focus:outline-none focus:ring-1 focus:ring-primary" + /> +
+ {loadedParties.length > 1 && ( + + )} +
+ )} + + {!members.length ? ( +

No members followed yet.

+ ) : !filteredMembers.length ? ( +

No members match your filters.

+ ) : ( + filteredMembers.map((f, i) => { + const originalIndex = members.indexOf(f); + return ( + remove.mutate(f.id)} + /> + ); + }) + )} +
+
{/* Topics */} -
-

Topics ({topics.length})

- {!topics.length ? ( -

No topics followed yet.

- ) : ( -
- {topics.map((f) => ( -
- - {f.follow_value.replace(/-/g, " ")} - - -
- ))} -
- )} -
+
+
+ {topics.length > 0 && ( +
+ + setTopicSearch(e.target.value)} + className="w-full pl-8 pr-3 py-1.5 text-sm bg-card border border-border rounded-md focus:outline-none focus:ring-1 focus:ring-primary" + /> +
+ )} + + {!topics.length ? ( +

No topics followed yet.

+ ) : !filteredTopics.length ? ( +

No topics match your search.

+ ) : ( +
+ {filteredTopics.map((f) => ( +
+ + {f.follow_value.replace(/-/g, " ")} + + +
+ ))} +
+ )} +
+
); }