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
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
1 change: 1 addition & 0 deletions components/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
169 changes: 169 additions & 0 deletions components/hearing/DDHearingDetails.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
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 { 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<number, string> = {
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;
`

/* 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);
max-height: 500px;
overflow-y: auto;
`

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<DDUtterance[] | null>(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 (
<Container className="mt-3 mb-3">
<Row className="mb-3">
<Col>
<Back href="/hearings">{t("back_to_hearings")}</Back>
</Col>
</Row>

<h1>{hearing.title}</h1>

<Row>
<Col className="col-md-8 mt-4">
{videos.length > 0 ? (
<Carousel
className="mt-3"
interval={null}
indicators={videos.length > 1}
controls={videos.length > 1}
>
{videos.map(video => (
<Carousel.Item key={video.uid}>
<VideoWrapper>
<VideoParent>
<VideoChild src={video.video_url} controls muted />
</VideoParent>
</VideoWrapper>
</Carousel.Item>
))}
</Carousel>
) : null}

<TranscriptContainer className="mt-4 rounded">
{utterances === null ? (
<div className="py-2 px-2">
{t("transcript_loading", { ns: "hearing" })}
</div>
) : (
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 (
<TranscriptRow className="py-2 px-2" key={utterance.uid}>
<Speaker>
{memberId ? (
<Internal href={`/legislators/194/${memberId}`}>
{name}
</Internal>
) : (
name
)}
</Speaker>
<div>{utterance.content}</div>
</TranscriptRow>
)
})
)}
</TranscriptContainer>
</Col>

<div className="col-md-4">
<HearingSidebar
activeVideo={0}
billsInAgenda={null}
committeeCode={COMMITTEE_CODE}
generalCourtNumber={GENERAL_COURT_NUMBER}
hearingDate={hearing.date}
transcripts={null}
/>
</div>
</Row>
</Container>
)
}
87 changes: 87 additions & 0 deletions components/hearing/digitalDemocracyApi.ts
Original file line number Diff line number Diff line change
@@ -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<DDHearing | null> {
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<DDUtterance[]> {
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(" ")
}
28 changes: 28 additions & 0 deletions pages/api/hearings/[hid]/utterances.ts
Original file line number Diff line number Diff line change
@@ -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 })
}
45 changes: 45 additions & 0 deletions pages/hearings/[hid].tsx
Original file line number Diff line number Diff line change
@@ -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 <DDHearingDetails hearing={hearing} />
}
})

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"
]))
}
}
}
1 change: 1 addition & 0 deletions public/locales/en/hearing.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading