diff --git a/apps/expo/src/app/bill-sponsor-profile.tsx b/apps/expo/src/app/bill-sponsor-profile.tsx index 642a828..d638b99 100644 --- a/apps/expo/src/app/bill-sponsor-profile.tsx +++ b/apps/expo/src/app/bill-sponsor-profile.tsx @@ -72,7 +72,11 @@ export default function BillSponsorProfileScreen() { showsVerticalScrollIndicator={false} > - + Primary sponsor {sponsor.name} {sponsor.role} @@ -111,6 +115,7 @@ export default function BillSponsorProfileScreen() { ? `Introduced ${formatDate(bill.introducedDate)}` : undefined, thumbnailUrl: bill.thumbnailUrl, + imageUri: bill.imageUri, }} onPress={() => router.push({ diff --git a/apps/expo/src/components/ui/ContentCard.tsx b/apps/expo/src/components/ui/ContentCard.tsx index 255c225..182e6eb 100644 --- a/apps/expo/src/components/ui/ContentCard.tsx +++ b/apps/expo/src/components/ui/ContentCard.tsx @@ -95,11 +95,17 @@ export function ContentCard({ {item.status ? ( - {item.status} + + {item.status} + ) : ( )} - {item.updated ? {item.updated} : null} + {item.updated ? ( + + {item.updated} + + ) : null} ); @@ -177,12 +183,18 @@ const s = StyleSheet.create({ marginBottom: 12, }, bottom: { - flexDirection: "row", - alignItems: "center", - justifyContent: "space-between", + alignItems: "flex-start", + gap: 4, + marginTop: 1, + }, + status: { + width: "100%", + fontFamily: fontBody.semibold, + fontSize: 12.5, + lineHeight: 17, }, - status: { fontFamily: fontBody.semibold, fontSize: 12.5 }, updated: { + width: "100%", fontFamily: "AlbertSans-Medium", fontSize: 12, color: colors.textSecondary, diff --git a/apps/expo/src/components/ui/primitives.tsx b/apps/expo/src/components/ui/primitives.tsx index 5fe959f..19ca192 100644 --- a/apps/expo/src/components/ui/primitives.tsx +++ b/apps/expo/src/components/ui/primitives.tsx @@ -12,6 +12,7 @@ import { TouchableOpacity, View, } from "react-native"; +import { Image } from "expo-image"; import type { IconName } from "./Icon"; import type { ContentTypeKey } from "~/styles"; @@ -53,24 +54,46 @@ export function Avatar({ name = "JA", size = 44, color = colors.bill, + imageUri, }: { name?: string; size?: number; color?: string; + imageUri?: string; }) { + const [failedImageUri, setFailedImageUri] = useState(); + return ( - - {name} - + {imageUri && failedImageUri !== imageUri ? ( + setFailedImageUri(imageUri)} + /> + ) : ( + + {name} + + )} ); } diff --git a/packages/api/src/lib/elected-officials.test.ts b/packages/api/src/lib/elected-officials.test.ts index 6f44daf..d780105 100644 --- a/packages/api/src/lib/elected-officials.test.ts +++ b/packages/api/src/lib/elected-officials.test.ts @@ -5,6 +5,7 @@ import type { DivisionByAddressResponse } from "./civic"; import { extractDistricts, parseCsv, + selectFederalOfficialByName, selectOfficials, } from "./elected-officials"; import { canUseDevelopmentMocks } from "./places"; @@ -144,3 +145,35 @@ void test("selectOfficials returns the address-scoped federal and state delegati ["sen-1", "sen-2", "house", "state-sen", "assembly"], ); }); + +void test("selectFederalOfficialByName matches chamber and first/last name", () => { + const row = (overrides: Record) => ({ + id: "id", + name: "Name", + current_party: "Republican", + current_district: "TX-5", + current_chamber: "lower", + image: "https://example.com/headshot.jpg", + email: "", + links: "https://example.com", + capitol_address: "", + capitol_voice: "", + district_address: "", + district_voice: "", + ...overrides, + }); + + const official = selectFederalOfficialByName( + [ + row({ id: "senator", name: "Lance Gooden", current_chamber: "upper" }), + row({ id: "representative", name: "Lance E. Gooden" }), + ], + "Rep. Lance Gooden", + "House", + ); + + assert.ok(official); + assert.equal(official.id, "representative"); + assert.equal(official.image, "https://example.com/headshot.jpg"); + assert.equal(official.district, "5"); +}); diff --git a/packages/api/src/lib/elected-officials.ts b/packages/api/src/lib/elected-officials.ts index ef43b30..f4d9d9c 100644 --- a/packages/api/src/lib/elected-officials.ts +++ b/packages/api/src/lib/elected-officials.ts @@ -221,6 +221,57 @@ function normalizeOfficial( }; } +function personNameKey(value: string): string { + return value + .normalize("NFKD") + .replace(/[\u0300-\u036f]/g, "") + .toLowerCase() + .replace(/\b(?:rep(?:resentative)?|sen(?:ator)?)\b\.?/g, "") + .replace(/[^a-z0-9]+/g, " ") + .trim(); +} + +/** Match a current federal lawmaker without requiring the user's address. */ +export function selectFederalOfficialByName( + rows: PeopleRow[], + name: string, + chamber?: string | null, +): ElectedOfficial | undefined { + const expectedChamber = + chamber?.toLowerCase() === "senate" ? "upper" : "lower"; + const nameKey = personNameKey(name); + const edgeNameKey = (value: string) => { + const parts = personNameKey(value).split(" ").filter(Boolean); + return [parts[0], parts.at(-1)].filter(Boolean).join(" "); + }; + const row = rows.find( + (person) => + person.current_chamber === expectedChamber && + (personNameKey(person.name) === nameKey || + edgeNameKey(person.name) === edgeNameKey(name)), + ); + if (!row) return undefined; + + const district = + expectedChamber === "lower" + ? row.current_district.split("-").at(-1) + : undefined; + return normalizeOfficial( + row, + expectedChamber === "upper" ? "U.S. Senator" : "U.S. Representative", + "country", + district, + ); +} + +/** Load and find a current federal lawmaker without the user's address. */ +export async function getFederalOfficialByName( + name: string, + chamber?: string | null, +): Promise { + return selectFederalOfficialByName(await getPeople("us"), name, chamber); +} + export function selectOfficials( federalRows: PeopleRow[], stateRows: PeopleRow[], diff --git a/packages/api/src/router/content.ts b/packages/api/src/router/content.ts index 817947d..f2849de 100644 --- a/packages/api/src/router/content.ts +++ b/packages/api/src/router/content.ts @@ -13,6 +13,7 @@ import { } from "@acme/db/schema"; import { parseBillSponsor, sponsorRole } from "../lib/bill-sponsor"; +import { getFederalOfficialByName } from "../lib/elected-officials"; import { protectedProcedure, publicProcedure } from "../trpc"; const SAVED_CONTENT_TYPES = [ @@ -668,26 +669,40 @@ export const contentRouter = { if (!bill) throw new Error(`Bill with id ${input.billId} not found`); if (!bill.sponsor) return null; - const sponsoredBills = await db - .select({ - id: Bill.id, - title: Bill.title, - description: Bill.description, - summary: Bill.summary, - billNumber: Bill.billNumber, - status: Bill.status, - thumbnailUrl: Bill.thumbnailUrl, - introducedDate: Bill.introducedDate, - }) - .from(Bill) - .where(eq(Bill.sponsor, bill.sponsor)) - .orderBy(desc(Bill.introducedDate), desc(Bill.createdAt)) - .limit(20); + const sponsorIdentity = parseBillSponsor(bill.sponsor); + const [sponsoredBillRows, official] = await Promise.all([ + db + .select({ + id: Bill.id, + title: Bill.title, + description: Bill.description, + summary: Bill.summary, + billNumber: Bill.billNumber, + status: Bill.status, + thumbnailUrl: Bill.thumbnailUrl, + introducedDate: Bill.introducedDate, + }) + .from(Bill) + .where(eq(Bill.sponsor, bill.sponsor)) + .orderBy(desc(Bill.introducedDate), desc(Bill.createdAt)) + .limit(20), + getFederalOfficialByName(sponsorIdentity.name, bill.chamber).catch( + () => undefined, + ), + ]); + const sponsoredBills = await attachVideoImages( + sponsoredBillRows.map((item) => ({ + ...item, + type: "bill" as const, + thumbnailUrl: item.thumbnailUrl ?? undefined, + })), + ); return { sponsor: { - ...parseBillSponsor(bill.sponsor), + ...sponsorIdentity, role: sponsorRole(bill.chamber), + imageUrl: official?.image, }, sourceUrl: bill.url, sponsoredBills: sponsoredBills.map((item) => ({ @@ -696,7 +711,8 @@ export const contentRouter = { description: item.description ?? item.summary ?? "", billNumber: item.billNumber, status: item.status ?? undefined, - thumbnailUrl: item.thumbnailUrl ?? undefined, + thumbnailUrl: item.thumbnailUrl, + imageUri: item.imageUri, introducedDate: item.introducedDate?.toISOString(), })), };