Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion apps/expo/src/app/bill-sponsor-profile.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,11 @@ export default function BillSponsorProfileScreen() {
showsVerticalScrollIndicator={false}
>
<Card style={s.hero}>
<Avatar name={sponsor.initials} size={76} />
<Avatar
name={sponsor.initials}
imageUri={sponsor.imageUrl}
size={88}
/>
<Text style={s.eyebrow}>Primary sponsor</Text>
<Text style={s.name}>{sponsor.name}</Text>
<Text style={s.role}>{sponsor.role}</Text>
Expand Down Expand Up @@ -111,6 +115,7 @@ export default function BillSponsorProfileScreen() {
? `Introduced ${formatDate(bill.introducedDate)}`
: undefined,
thumbnailUrl: bill.thumbnailUrl,
imageUri: bill.imageUri,
}}
onPress={() =>
router.push({
Expand Down
24 changes: 18 additions & 6 deletions apps/expo/src/components/ui/ContentCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -95,11 +95,17 @@ export function ContentCard({
</View>
<View style={s.bottom}>
{item.status ? (
<Text style={[s.status, { color: t.color }]}>{item.status}</Text>
<Text style={[s.status, { color: t.color }]} numberOfLines={2}>
{item.status}
</Text>
) : (
<View />
)}
{item.updated ? <Text style={s.updated}>{item.updated}</Text> : null}
{item.updated ? (
<Text style={s.updated} numberOfLines={1}>
{item.updated}
</Text>
) : null}
</View>
</TouchableOpacity>
);
Expand Down Expand Up @@ -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,
Expand Down
43 changes: 33 additions & 10 deletions apps/expo/src/components/ui/primitives.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<string>();

return (
<View
style={[s.avatar, { width: size, height: size, borderRadius: size / 2 }]}
style={[
s.avatar,
{
width: size,
height: size,
borderRadius: size / 2,
overflow: "hidden",
},
]}
>
<Text
style={{
fontFamily: "InriaSerif-Bold",
fontSize: size * 0.36,
color,
}}
>
{name}
</Text>
{imageUri && failedImageUri !== imageUri ? (
<Image
source={{ uri: imageUri }}
style={StyleSheet.absoluteFill}
contentFit="cover"
transition={200}
onError={() => setFailedImageUri(imageUri)}
/>
) : (
<Text
style={{
fontFamily: "InriaSerif-Bold",
fontSize: size * 0.36,
color,
}}
>
{name}
</Text>
)}
</View>
);
}
Expand Down
33 changes: 33 additions & 0 deletions packages/api/src/lib/elected-officials.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type { DivisionByAddressResponse } from "./civic";
import {
extractDistricts,
parseCsv,
selectFederalOfficialByName,
selectOfficials,
} from "./elected-officials";
import { canUseDevelopmentMocks } from "./places";
Expand Down Expand Up @@ -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<string, string>) => ({
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");
});
51 changes: 51 additions & 0 deletions packages/api/src/lib/elected-officials.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ElectedOfficial | undefined> {
return selectFederalOfficialByName(await getPeople("us"), name, chamber);
}

export function selectOfficials(
federalRows: PeopleRow[],
stateRows: PeopleRow[],
Expand Down
50 changes: 33 additions & 17 deletions packages/api/src/router/content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down Expand Up @@ -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) => ({
Expand All @@ -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(),
})),
};
Expand Down
Loading