From 1d32754f76be0d3bc17b7eb4241f5f1baded2cbe Mon Sep 17 00:00:00 2001 From: Mephistic Date: Tue, 8 Sep 2026 18:28:47 -0400 Subject: [PATCH 1/4] feat: Add individual hearing page that uses the Hearings API - just getting a front-end on this to test the quality of transcripts for now --- .env.example | 1 + components/bootstrap.ts | 1 + components/hearing/DDHearingDetails.tsx | 121 ++++++++++++++++++++++ components/hearing/digitalDemocracyApi.ts | 87 ++++++++++++++++ pages/api/hearings/[hid]/utterances.ts | 28 +++++ pages/hearings/[hid].tsx | 45 ++++++++ public/locales/en/hearing.json | 1 + 7 files changed, 284 insertions(+) create mode 100644 components/hearing/DDHearingDetails.tsx create mode 100644 components/hearing/digitalDemocracyApi.ts create mode 100644 pages/api/hearings/[hid]/utterances.ts create mode 100644 pages/hearings/[hid].tsx diff --git a/.env.example b/.env.example index 25add1752..ddd0f5698 100644 --- a/.env.example +++ b/.env.example @@ -2,3 +2,4 @@ TEST_ADMIN_USERNAME=your_admin_email@example.com TEST_ADMIN_PASSWORD=your_admin_password APP_API_URL=http://localhost:3000 +DIGITAL_DEMOCRACY_API_KEY=your_digital_democracy_api_key diff --git a/components/bootstrap.ts b/components/bootstrap.ts index 1fa768584..f231fb79b 100644 --- a/components/bootstrap.ts +++ b/components/bootstrap.ts @@ -4,6 +4,7 @@ export { default as Alert } from "react-bootstrap/Alert" export { default as Badge } from "react-bootstrap/Badge" export { default as Button } from "react-bootstrap/Button" export { default as Card } from "react-bootstrap/Card" +export { default as Carousel } from "react-bootstrap/Carousel" export { default as Col } from "react-bootstrap/Col" export { default as Collapse } from "react-bootstrap/Collapse" export { default as Container } from "react-bootstrap/Container" diff --git a/components/hearing/DDHearingDetails.tsx b/components/hearing/DDHearingDetails.tsx new file mode 100644 index 000000000..34926afff --- /dev/null +++ b/components/hearing/DDHearingDetails.tsx @@ -0,0 +1,121 @@ +import { useTranslation } from "next-i18next" +import { useEffect, useState } from "react" +import styled from "styled-components" +import { Carousel, Col, Container, Row } from "../bootstrap" +import { Back } from "../shared/CommonComponents" +import { + DDHearing, + DDUtterance, + speakerName +} from "./digitalDemocracyApi" + +const VideoWrapper = styled.div` + max-width: 700px; + margin: 0 auto; +` + +/* padding-top % is relative to VideoWrapper's width, so max-width must live there, not here */ +const VideoParent = styled.div` + position: relative; + width: 100%; + padding-top: 56.25%; /* For 16:9 aspect ratio */ + overflow: hidden; +` + +const VideoChild = styled.video` + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + border: none; +` + +const TranscriptContainer = styled(Container)` + background-color: var(--maple-surface-base); +` + +const TranscriptRow = styled(Row)` + &:nth-child(even) { + background-color: white; + } + &:nth-child(odd) { + background-color: var(--maple-surface-transcript-stripe); + } +` + +const Speaker = styled.div` + font-weight: 600; + color: var(--maple-text-strong); +` + +export const DDHearingDetails = ({ hearing }: { hearing: DDHearing }) => { + const { t } = useTranslation(["common", "hearing"]) + const [utterances, setUtterances] = useState(null) + + useEffect(() => { + let cancelled = false + ;(async function () { + const res = await fetch(`/api/hearings/${hearing.hid}/utterances`) + if (!res.ok || cancelled) return + const body: { utterances: DDUtterance[] } = await res.json() + if (!cancelled) setUtterances(body.utterances) + })() + return () => { + cancelled = true + } + }, [hearing.hid]) + + const videos = [...hearing.hearing_videos].sort( + (a, b) => (a.start_time ?? a.uid) - (b.start_time ?? b.uid) + ) + + return ( + + + + {t("back_to_hearings")} + + + +

{hearing.title}

+ + {videos.length > 0 ? ( + 1} + controls={videos.length > 1} + > + {videos.map(video => ( + + + + + + + + ))} + + ) : null} + + + {utterances === null ? ( +
+ {t("transcript_loading", { ns: "hearing" })} +
+ ) : ( + utterances.map(utterance => ( + + + {speakerName(utterance) ?? + t("unknown_speaker", { ns: "hearing" })} + +
{utterance.content}
+
+ )) + )} +
+
+ ) +} diff --git a/components/hearing/digitalDemocracyApi.ts b/components/hearing/digitalDemocracyApi.ts new file mode 100644 index 000000000..de288629a --- /dev/null +++ b/components/hearing/digitalDemocracyApi.ts @@ -0,0 +1,87 @@ +const API_KEY = process.env.DIGITAL_DEMOCRACY_API_KEY ?? "" + +const BASE_URL = "https://api.digitaldemocracy.org" + +export type DDHearingVideo = { + file_id: string + uid: number + video_url: string + // Not present on all responses; falls back to `uid` ordering when absent. + start_time?: number +} + +export type DDHearing = { + hid: number + title: string + date: string + session_year: string + state: string + hearing_video_thumbnail: string | null + hearing_videos: DDHearingVideo[] +} + +type DDHearingResponse = { + data: { + hearing: DDHearing + agenda: unknown[] + } +} + +export type DDUtterance = { + content: string + first: string | null + last: string | null + person_type: string + pid: number | null + hid: number + uid: number + file_id: string + timestamp: number + date: number +} + +type DDUtterancesResponse = { + data: { + utterances: DDUtterance[] + } + page: number + per_page: number + total_pages: number + total_results: number +} + +function headers() { + return { "x-api-key": API_KEY } +} + +export async function fetchDDHearing( + hid: string | number +): Promise { + const res = await fetch(`${BASE_URL}/legacy/ma/hearing/${hid}`, { + headers: headers() + }) + if (!res.ok) return null + + const body: DDHearingResponse = await res.json() + return body.data?.hearing ?? null +} + +export async function fetchDDUtterances( + hid: string | number +): Promise { + const res = await fetch( + `${BASE_URL}/legacy/ma/elasticsearch/utterances?hid=${hid}&per_page=500`, + { headers: headers() } + ) + if (!res.ok) return [] + + const body: DDUtterancesResponse = await res.json() + const utterances = body.data?.utterances ?? [] + return [...utterances].sort((a, b) => a.timestamp - b.timestamp) +} + +export function speakerName(utterance: DDUtterance): string | null { + const { first, last } = utterance + if (!first && !last) return null + return [first, last].filter(Boolean).join(" ") +} diff --git a/pages/api/hearings/[hid]/utterances.ts b/pages/api/hearings/[hid]/utterances.ts new file mode 100644 index 000000000..a46f6dc8e --- /dev/null +++ b/pages/api/hearings/[hid]/utterances.ts @@ -0,0 +1,28 @@ +import { NextApiRequest, NextApiResponse } from "next" +import { z } from "zod" +import { fetchDDUtterances } from "components/hearing/digitalDemocracyApi" + +const QuerySchema = z.object({ hid: z.coerce.number() }) + +export default async function handler( + req: NextApiRequest, + res: NextApiResponse +) { + if (req.method !== "GET") { + res.status(404).end() + return + } + + const query = QuerySchema.safeParse(req.query) + if (!query.success) { + res.status(400).json({ error: "Invalid hid" }) + return + } + + const utterances = await fetchDDUtterances(query.data.hid) + res.setHeader( + "Cache-Control", + "public, s-maxage=3600, stale-while-revalidate=3600" + ) + res.status(200).json({ utterances }) +} diff --git a/pages/hearings/[hid].tsx b/pages/hearings/[hid].tsx new file mode 100644 index 000000000..5fa569f8a --- /dev/null +++ b/pages/hearings/[hid].tsx @@ -0,0 +1,45 @@ +import { GetServerSideProps } from "next" +import { serverSideTranslations } from "next-i18next/serverSideTranslations" +import { z } from "zod" +import { createPage } from "../../components/page" +import { DDHearingDetails } from "components/hearing/DDHearingDetails" +import { + DDHearing, + fetchDDHearing +} from "components/hearing/digitalDemocracyApi" + +const Query = z.object({ hid: z.coerce.number() }) + +export default createPage<{ hearing: DDHearing }>({ + titleI18nKey: "navigation.hearing", + Page: ({ hearing }) => { + return + } +}) + +export const getServerSideProps: GetServerSideProps = async ctx => { + ctx.res.setHeader( + "Cache-Control", + "public, s-maxage=3600, stale-while-revalidate=3600" + ) + + const locale = ctx.locale ?? ctx.defaultLocale ?? "en" + + const query = Query.safeParse(ctx.query) + if (!query.success) return { notFound: true } + + const hearing = await fetchDDHearing(query.data.hid) + if (!hearing) return { notFound: true } + + return { + props: { + hearing, + ...(await serverSideTranslations(locale, [ + "auth", + "common", + "footer", + "hearing" + ])) + } + } +} diff --git a/public/locales/en/hearing.json b/public/locales/en/hearing.json index bfd82a69c..48f1c2087 100644 --- a/public/locales/en/hearing.json +++ b/public/locales/en/hearing.json @@ -28,6 +28,7 @@ "see_less": "See less", "senate_chair": "Senate Chair", "transcript_loading": "Loading transcript for this hearing...", + "unknown_speaker": "Unknown speaker", "video_and_transcription_feature_callout": "Hearing Video + Transcription", "view_bill": "View Bill Details", "view_votes": "View Committee Votes", From 3f941707824617b698bf32c1b7d64f6dcbc2850c Mon Sep 17 00:00:00 2001 From: Mephistic Date: Tue, 8 Sep 2026 18:46:07 -0400 Subject: [PATCH 2/4] testing: Use a static map of the unique speakers to map legislators from the DD API with member legislators from the MA Legislature API. These 10 were selected manually just for testing purposes - for production, we would need to work out this mapping for all legislators --- components/hearing/DDHearingDetails.tsx | 46 ++++++++++++++++++++----- 1 file changed, 37 insertions(+), 9 deletions(-) diff --git a/components/hearing/DDHearingDetails.tsx b/components/hearing/DDHearingDetails.tsx index 34926afff..febb1e013 100644 --- a/components/hearing/DDHearingDetails.tsx +++ b/components/hearing/DDHearingDetails.tsx @@ -3,12 +3,26 @@ import { useEffect, useState } from "react" import styled from "styled-components" import { Carousel, Col, Container, Row } from "../bootstrap" import { Back } from "../shared/CommonComponents" +import { Internal } from "../links" import { DDHearing, DDUtterance, speakerName } from "./digitalDemocracyApi" +const ddApiPersonIdToMemberId: Record = { + 211022: "PRF0", // Paul Feeney + 211000: "MSD1", // Michael Day + 211028: "RCF0", // Ryan Fattman + 210993: "K_H1", // Kate Hogan + 210961: "DTV1", // David Vieira + 210945: "CFF0", // Cindy Friedman + 210928: "BRF0", // Barry Finegold + 210931: "BPC0", // Brendan Crighton + 210924: "AHP1", // Alice Peisch + 210964: "FAM1", // Frank Moran +} + const VideoWrapper = styled.div` max-width: 700px; margin: 0 auto; @@ -105,15 +119,29 @@ export const DDHearingDetails = ({ hearing }: { hearing: DDHearing }) => { {t("transcript_loading", { ns: "hearing" })} ) : ( - utterances.map(utterance => ( - - - {speakerName(utterance) ?? - t("unknown_speaker", { ns: "hearing" })} - -
{utterance.content}
-
- )) + utterances.map(utterance => { + const name = + speakerName(utterance) ?? t("unknown_speaker", { ns: "hearing" }) + const memberId = + utterance.person_type === "legislator" && utterance.pid !== null + ? ddApiPersonIdToMemberId[utterance.pid] + : undefined + + return ( + + + {memberId ? ( + + {name} + + ) : ( + name + )} + +
{utterance.content}
+
+ ) + }) )} From 2b6b0356e0afb7d794afbbe8e9cdca3768029cf5 Mon Sep 17 00:00:00 2001 From: Mephistic Date: Tue, 8 Sep 2026 18:53:42 -0400 Subject: [PATCH 3/4] Adding hearing committee sidebar (hard-coded for testing) and a scroll to the transcipts box --- components/hearing/DDHearingDetails.tsx | 124 ++++++++++++++---------- 1 file changed, 74 insertions(+), 50 deletions(-) diff --git a/components/hearing/DDHearingDetails.tsx b/components/hearing/DDHearingDetails.tsx index febb1e013..3123aa3fb 100644 --- a/components/hearing/DDHearingDetails.tsx +++ b/components/hearing/DDHearingDetails.tsx @@ -4,12 +4,17 @@ import styled from "styled-components" import { Carousel, Col, Container, Row } from "../bootstrap" import { Back } from "../shared/CommonComponents" import { Internal } from "../links" +import { HearingSidebar } from "./HearingSidebar" import { DDHearing, DDUtterance, speakerName } from "./digitalDemocracyApi" +// Hardcoded for hearing 279802 for testing; production needs a real hid -> committee/general court lookup +const COMMITTEE_CODE = "SJ42" +const GENERAL_COURT_NUMBER = "194" + const ddApiPersonIdToMemberId: Record = { 211022: "PRF0", // Paul Feeney 211000: "MSD1", // Michael Day @@ -47,6 +52,8 @@ const VideoChild = styled.video` const TranscriptContainer = styled(Container)` background-color: var(--maple-surface-base); + max-height: 500px; + overflow-y: auto; ` const TranscriptRow = styled(Row)` @@ -94,56 +101,73 @@ export const DDHearingDetails = ({ hearing }: { hearing: DDHearing }) => {

{hearing.title}

- {videos.length > 0 ? ( - 1} - controls={videos.length > 1} - > - {videos.map(video => ( - - - - - - - - ))} - - ) : null} - - - {utterances === null ? ( -
- {t("transcript_loading", { ns: "hearing" })} -
- ) : ( - utterances.map(utterance => { - const name = - speakerName(utterance) ?? t("unknown_speaker", { ns: "hearing" }) - const memberId = - utterance.person_type === "legislator" && utterance.pid !== null - ? ddApiPersonIdToMemberId[utterance.pid] - : undefined - - return ( - - - {memberId ? ( - - {name} - - ) : ( - name - )} - -
{utterance.content}
-
- ) - }) - )} -
+ + + {videos.length > 0 ? ( + 1} + controls={videos.length > 1} + > + {videos.map(video => ( + + + + + + + + ))} + + ) : null} + + + {utterances === null ? ( +
+ {t("transcript_loading", { ns: "hearing" })} +
+ ) : ( + utterances.map(utterance => { + const name = + speakerName(utterance) ?? + t("unknown_speaker", { ns: "hearing" }) + const memberId = + utterance.person_type === "legislator" && + utterance.pid !== null + ? ddApiPersonIdToMemberId[utterance.pid] + : undefined + + return ( + + + {memberId ? ( + + {name} + + ) : ( + name + )} + +
{utterance.content}
+
+ ) + }) + )} +
+ + +
+ +
+
) } From 28edc471d82fe807223705f26e51914f463b6eae Mon Sep 17 00:00:00 2001 From: Mephistic Date: Tue, 8 Sep 2026 19:12:30 -0400 Subject: [PATCH 4/4] fix(prettier): run prettier --- components/hearing/DDHearingDetails.tsx | 26 +++++++++++-------------- 1 file changed, 11 insertions(+), 15 deletions(-) diff --git a/components/hearing/DDHearingDetails.tsx b/components/hearing/DDHearingDetails.tsx index 3123aa3fb..c9b4e1177 100644 --- a/components/hearing/DDHearingDetails.tsx +++ b/components/hearing/DDHearingDetails.tsx @@ -5,27 +5,23 @@ import { Carousel, Col, Container, Row } from "../bootstrap" import { Back } from "../shared/CommonComponents" import { Internal } from "../links" import { HearingSidebar } from "./HearingSidebar" -import { - DDHearing, - DDUtterance, - speakerName -} from "./digitalDemocracyApi" +import { DDHearing, DDUtterance, speakerName } from "./digitalDemocracyApi" // Hardcoded for hearing 279802 for testing; production needs a real hid -> committee/general court lookup const COMMITTEE_CODE = "SJ42" const GENERAL_COURT_NUMBER = "194" const ddApiPersonIdToMemberId: Record = { - 211022: "PRF0", // Paul Feeney - 211000: "MSD1", // Michael Day - 211028: "RCF0", // Ryan Fattman - 210993: "K_H1", // Kate Hogan - 210961: "DTV1", // David Vieira - 210945: "CFF0", // Cindy Friedman - 210928: "BRF0", // Barry Finegold - 210931: "BPC0", // Brendan Crighton - 210924: "AHP1", // Alice Peisch - 210964: "FAM1", // Frank Moran + 211022: "PRF0", // Paul Feeney + 211000: "MSD1", // Michael Day + 211028: "RCF0", // Ryan Fattman + 210993: "K_H1", // Kate Hogan + 210961: "DTV1", // David Vieira + 210945: "CFF0", // Cindy Friedman + 210928: "BRF0", // Barry Finegold + 210931: "BPC0", // Brendan Crighton + 210924: "AHP1", // Alice Peisch + 210964: "FAM1" // Frank Moran } const VideoWrapper = styled.div`