From c176445769672102561c0871812eaddde242f01d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 28 Feb 2026 02:33:37 +0000 Subject: [PATCH] Add section headings to user interest displays and improve matched interest messaging MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Look up section headings from usc_section (with usc_content fallback) to display alongside USC citations throughout the interest feature: - Interest list page shows heading next to each section slug (e.g. "t42/s1395 — Health Insurance for Aged") - Interest badge tooltip on bills shows truncated interest text and section headings - Interest feed widget shows matched section headings per bill https://claude.ai/code/session_01E6gr5QEwpnm6P9FbKisbdi --- .../src/app/_home/widgets/interestFeed.tsx | 108 ++++++++---- .../congress/bills/[billId]/interestBadge.tsx | 56 +++++- hillstack/src/app/user/interests/page.tsx | 30 +++- hillstack/src/server/api/routers/user.ts | 160 +++++++++++++++++- 4 files changed, 311 insertions(+), 43 deletions(-) diff --git a/hillstack/src/app/_home/widgets/interestFeed.tsx b/hillstack/src/app/_home/widgets/interestFeed.tsx index 863c50f..60303f3 100644 --- a/hillstack/src/app/_home/widgets/interestFeed.tsx +++ b/hillstack/src/app/_home/widgets/interestFeed.tsx @@ -9,6 +9,24 @@ import { DashboardWidgetContent } from './'; type InterestBill = RouterOutputs['user']['interestLegislation'][number]; +function matchedSummary(bill: InterestBill): string { + const headings = Object.values(bill.matched_headings ?? {}); + if (headings.length > 0) { + const display = headings.slice(0, 2).join(', '); + const extra = headings.length > 2 ? ` +${headings.length - 2}` : ''; + return `${display}${extra}`; + } + const idents = bill.matched_idents ?? []; + if (idents.length > 0) { + const slugs = idents + .slice(0, 2) + .map((id: string) => id.split('/').slice(3).join('/')); + const extra = idents.length > 2 ? ` +${idents.length - 2}` : ''; + return `${slugs.join(', ')}${extra}`; + } + return ''; +} + export function InterestFeed() { const { data: session } = useSession(); const { data, isLoading, isError } = api.user.interestLegislation.useQuery( @@ -76,40 +94,66 @@ export function InterestFeed() { > {data && ( - {data.slice(0, 8).map((bill: InterestBill) => ( - - - - { + const summary = matchedSummary(bill); + return ( + + + - - {bill.title} - - - {`${bill.session_number}th · `} - {bill.chamber === 'house' - ? 'H.R.' - : 'S.'} - {` #${bill.number}`} - - - - - - ))} + + + {bill.title} + + + + {`${bill.session_number}th · `} + {bill.chamber === 'house' + ? 'H.R.' + : 'S.'} + {` #${bill.number}`} + + {summary && ( + + · {summary} + + )} + + + + + + ); + })} )} diff --git a/hillstack/src/app/congress/bills/[billId]/interestBadge.tsx b/hillstack/src/app/congress/bills/[billId]/interestBadge.tsx index 0821438..6e01cae 100644 --- a/hillstack/src/app/congress/bills/[billId]/interestBadge.tsx +++ b/hillstack/src/app/congress/bills/[billId]/interestBadge.tsx @@ -1,10 +1,15 @@ 'use client'; import TrackChangesIcon from '@mui/icons-material/TrackChanges'; -import { Chip, Tooltip } from '@mui/material'; +import { Box, Chip, Tooltip, Typography } from '@mui/material'; import { useSession } from 'next-auth/react'; import { api } from '~/trpc/react'; +function truncateInterest(text: string, maxLen = 60): string { + if (text.length <= maxLen) return text; + return `${text.slice(0, maxLen).trimEnd()}…`; +} + export function InterestBadge({ legislationId }: { legislationId: number }) { const { data: session } = useSession(); @@ -15,10 +20,53 @@ export function InterestBadge({ legislationId }: { legislationId: number }) { if (!session || !data?.matches) return null; + const hasHeadings = Object.keys(data.matchedHeadings).length > 0; + const interestLabel = data.interestText + ? truncateInterest(data.interestText) + : ''; + + const tooltipContent = ( + + {interestLabel && ( + + Related to your interest in "{interestLabel}" + + )} + {hasHeadings ? ( + + {data.matchedIdents.slice(0, 5).map((ident: string) => { + const heading = data.matchedHeadings[ident]; + const slug = ident.split('/').slice(3).join('/'); + return ( +
  • + + {heading + ? `${slug} — ${heading}` + : slug} + +
  • + ); + })} + {data.matchedIdents.length > 5 && ( +
  • + + +{data.matchedIdents.length - 5} more + +
  • + )} +
    + ) : ( + + Touches: {data.matchedIdents.slice(0, 3).join(', ')} + {data.matchedIdents.length > 3 && + ` +${data.matchedIdents.length - 3} more`} + + )} +
    + ); + return ( - + } diff --git a/hillstack/src/app/user/interests/page.tsx b/hillstack/src/app/user/interests/page.tsx index a525bab..ab2fe8c 100644 --- a/hillstack/src/app/user/interests/page.tsx +++ b/hillstack/src/app/user/interests/page.tsx @@ -79,6 +79,9 @@ export default function InterestsPage() { }, }); + const sectionHeadings: Record = + interest?.sectionHeadings ?? {}; + const rawMatches: MatchItem[] = ( interest?.user_interest_usc_content ?? [] ).map((m) => ({ @@ -213,6 +216,12 @@ export default function InterestsPage() { ?.split('/') .slice(3) .join('/') ?? ''; + const heading = + match.usc_ident + ? sectionHeadings[ + match.usc_ident + ] + : undefined; return ( - + {sectionSlug} + {heading && ( + + — {heading} + + )} {match.match_source === 'manual' && ( )} diff --git a/hillstack/src/server/api/routers/user.ts b/hillstack/src/server/api/routers/user.ts index d3a97ba..ca1d53e 100644 --- a/hillstack/src/server/api/routers/user.ts +++ b/hillstack/src/server/api/routers/user.ts @@ -214,7 +214,7 @@ export const userRouter = createTRPCRouter({ // ── Interest procedures ────────────────────────────────────────────────── interestGet: privateProcedure.query(async ({ ctx }) => { - return ctx.db.user_interest.findFirst({ + const interest = await ctx.db.user_interest.findFirst({ where: { user_id: ctx.user.email }, include: { user_interest_usc_content: { @@ -222,6 +222,59 @@ export const userRouter = createTRPCRouter({ }, }, }); + + if ( + !interest || + interest.user_interest_usc_content.length === 0 + ) { + return interest + ? { ...interest, sectionHeadings: {} as Record } + : null; + } + + const idents = interest.user_interest_usc_content + .map((m) => m.usc_ident) + .filter((id): id is string => Boolean(id)); + + if (idents.length === 0) { + return { ...interest, sectionHeadings: {} as Record }; + } + + // Look up section headings from usc_section + const sectionRows = await ctx.db.usc_section.findMany({ + where: { usc_ident: { in: idents } }, + select: { usc_ident: true, heading: true, section_display: true }, + distinct: ['usc_ident'], + orderBy: { version_id: 'desc' }, + }); + + const sectionHeadings: Record = {}; + for (const row of sectionRows) { + if (row.usc_ident && row.heading) { + sectionHeadings[row.usc_ident] = row.heading; + } + } + + // For idents not found in usc_section, try usc_content + const missingIdents = idents.filter((id) => !sectionHeadings[id]); + if (missingIdents.length > 0) { + const contentRows = await ctx.db.usc_content.findMany({ + where: { + usc_ident: { in: missingIdents }, + heading: { not: null }, + }, + select: { usc_ident: true, heading: true, section_display: true }, + distinct: ['usc_ident'], + orderBy: { version_id: 'desc' }, + }); + for (const row of contentRows) { + if (row.usc_ident && row.heading) { + sectionHeadings[row.usc_ident] = row.heading; + } + } + } + + return { ...interest, sectionHeadings }; }), interestSave: privateProcedure @@ -422,6 +475,7 @@ export const userRouter = createTRPCRouter({ .map((_, i) => `uc.usc_ident ILIKE $${i + 1}`) .join(' OR '); + // Collect matched ident per bill using array_agg type LegislationRow = { legislation_id: bigint; title: string; @@ -430,6 +484,7 @@ export const userRouter = createTRPCRouter({ legislation_type: string; chamber: string; effective_date: Date | null; + matched_idents: string[]; }; const rows = await ctx.db.$queryRawUnsafe( @@ -440,7 +495,8 @@ export const userRouter = createTRPCRouter({ c.session_number, l.legislation_type, l.chamber, - MIN(lv.effective_date) AS effective_date + MIN(lv.effective_date) AS effective_date, + ARRAY_AGG(DISTINCT uc.usc_ident) AS matched_idents FROM usc_content uc JOIN usc_content_diff ucd ON ucd.usc_content_id = uc.usc_content_id JOIN legislation_version lv ON lv.version_id = ucd.version_id @@ -453,10 +509,56 @@ export const userRouter = createTRPCRouter({ ...identsWithWildcard, ); + // Gather all matched idents to look up headings in bulk + const allMatchedIdents = [ + ...new Set(rows.flatMap((r) => r.matched_idents ?? [])), + ]; + + const headingsMap: Record = {}; + if (allMatchedIdents.length > 0) { + const sectionRows = await ctx.db.usc_section.findMany({ + where: { usc_ident: { in: allMatchedIdents } }, + select: { usc_ident: true, heading: true }, + distinct: ['usc_ident'], + orderBy: { version_id: 'desc' }, + }); + for (const row of sectionRows) { + if (row.usc_ident && row.heading) { + headingsMap[row.usc_ident] = row.heading; + } + } + + const missingIdents = allMatchedIdents.filter( + (id) => !headingsMap[id], + ); + if (missingIdents.length > 0) { + const contentRows = await ctx.db.usc_content.findMany({ + where: { + usc_ident: { in: missingIdents }, + heading: { not: null }, + }, + select: { usc_ident: true, heading: true }, + distinct: ['usc_ident'], + orderBy: { version_id: 'desc' }, + }); + for (const row of contentRows) { + if (row.usc_ident && row.heading) { + headingsMap[row.usc_ident] = row.heading; + } + } + } + } + // BigInt → number for JSON serialisation return rows.map((r) => ({ ...r, legislation_id: Number(r.legislation_id), + matched_idents: r.matched_idents ?? [], + matched_headings: Object.fromEntries( + (r.matched_idents ?? []) + .filter((id) => headingsMap[id]) + .map((id) => [id, headingsMap[id]]), + ), })); }), @@ -473,11 +575,18 @@ export const userRouter = createTRPCRouter({ }, }); + const emptyResult = { + matches: false, + matchedIdents: [] as string[], + matchedHeadings: {} as Record, + interestText: interest?.interest_text ?? '', + }; + if ( !interest || interest.user_interest_usc_content.length === 0 ) { - return { matches: false, matchedIdents: [] as string[] }; + return emptyResult; } const idents = interest.user_interest_usc_content @@ -485,7 +594,7 @@ export const userRouter = createTRPCRouter({ .filter((id): id is string => Boolean(id)); if (idents.length === 0) { - return { matches: false, matchedIdents: [] as string[] }; + return emptyResult; } const identsWithWildcard = idents.map((id) => `${id}%`); @@ -506,9 +615,50 @@ export const userRouter = createTRPCRouter({ ...identsWithWildcard, ); + const matchedIdents = rows.map((r) => r.usc_ident); + + // Look up section headings for the matched idents + const matchedHeadings: Record = {}; + if (matchedIdents.length > 0) { + const sectionRows = await ctx.db.usc_section.findMany({ + where: { usc_ident: { in: matchedIdents } }, + select: { usc_ident: true, heading: true }, + distinct: ['usc_ident'], + orderBy: { version_id: 'desc' }, + }); + for (const row of sectionRows) { + if (row.usc_ident && row.heading) { + matchedHeadings[row.usc_ident] = row.heading; + } + } + + // Fall back to usc_content for any missing + const missingIdents = matchedIdents.filter( + (id) => !matchedHeadings[id], + ); + if (missingIdents.length > 0) { + const contentRows = await ctx.db.usc_content.findMany({ + where: { + usc_ident: { in: missingIdents }, + heading: { not: null }, + }, + select: { usc_ident: true, heading: true }, + distinct: ['usc_ident'], + orderBy: { version_id: 'desc' }, + }); + for (const row of contentRows) { + if (row.usc_ident && row.heading) { + matchedHeadings[row.usc_ident] = row.heading; + } + } + } + } + return { matches: rows.length > 0, - matchedIdents: rows.map((r) => r.usc_ident), + matchedIdents, + matchedHeadings, + interestText: interest.interest_text ?? '', }; }), });