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 ?? '',
};
}),
});