Skip to content

Commit 7bb12bc

Browse files
authored
Merge pull request codeforboston#1961 from jicruz96/browse-hearings
Browse hearings page
2 parents 497f65a + 3eacfb9 commit 7bb12bc

26 files changed

Lines changed: 1492 additions & 27 deletions

components/Footer/Footer.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import CustomDropdown, {
1212
} from "components/Footer/CustomFooterDropdown"
1313
import { FooterContainer } from "./FooterContainer"
1414
import { NEWSLETTER_SIGNUP_URL } from "components/common"
15+
import { flags } from "../featureFlags"
1516

1617
export type PageFooterProps = {
1718
children?: any
@@ -217,6 +218,11 @@ const BrowseList = () => {
217218
<BrowseHeader href="/testimony">
218219
{t("navigation.browseTestimony")}
219220
</BrowseHeader>
221+
{flags().hearingsAndTranscriptions ? (
222+
<BrowseHeader href="/hearings">
223+
{t("navigation.browseHearings")}
224+
</BrowseHeader>
225+
) : null}
220226
<BrowseHeader href="/bills">{t("navigation.browseBills")}</BrowseHeader>
221227
</>
222228
)

components/Navbar.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,13 @@ import styled from "styled-components"
55
import { useMediaQuery } from "usehooks-ts"
66
import { SignInWithButton, signOutAndRedirectToHome, useAuth } from "./auth"
77
import { Col, Container, Dropdown, Nav, Navbar, NavDropdown } from "./bootstrap"
8+
import { flags } from "./featureFlags"
89

910
import {
1011
Avatar,
1112
NavbarLinkAI,
1213
NavbarLinkBills,
14+
NavbarLinkHearings,
1315
NavbarLinkEditProfile,
1416
NavbarLinkEffective,
1517
NavbarLinkFAQ,
@@ -79,6 +81,9 @@ const MobileNav: React.FC<React.PropsWithChildren<unknown>> = () => {
7981
return (
8082
<Nav className="my-4">
8183
<NavbarLinkBills handleClick={closeNav} />
84+
{flags().hearingsAndTranscriptions ? (
85+
<NavbarLinkHearings handleClick={closeNav} />
86+
) : null}
8287
<NavbarLinkTestimony handleClick={closeNav} />
8388
{authenticated ? <NavbarLinkNewsfeed handleClick={closeNav} /> : <></>}
8489
<NavDropdown className={"navLink-primary"} title={t("about")}>
@@ -192,6 +197,7 @@ const DesktopNav: React.FC<React.PropsWithChildren<unknown>> = () => {
192197
<div className={`align-self-center ms-3`}>
193198
<Nav>
194199
<NavbarLinkBills />
200+
{flags().hearingsAndTranscriptions ? <NavbarLinkHearings /> : null}
195201
</Nav>
196202
</div>
197203

components/NavbarComponents.tsx

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,27 @@ export const NavbarLinkBills: React.FC<
7474
)
7575
}
7676

77+
export const NavbarLinkHearings: React.FC<
78+
React.PropsWithChildren<{
79+
handleClick?: any
80+
other?: any
81+
}>
82+
> = ({ handleClick, other }) => {
83+
const isMobile = useMediaQuery("(max-width: 768px)")
84+
const { t } = useTranslation(["common", "auth"])
85+
return (
86+
<Nav.Item onClick={handleClick}>
87+
<NavLink
88+
className={isMobile ? "navLink-primary" : "text-white-50"}
89+
href="/hearings"
90+
{...other}
91+
>
92+
{t("navigation.browseHearings")}
93+
</NavLink>
94+
</Nav.Item>
95+
)
96+
}
97+
7798
export const NavbarLinkEditProfile: React.FC<
7899
React.PropsWithChildren<{
79100
handleClick?: any

components/bootstrap.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// re-exports of all the bootstrap components we use. This ensures we only pull
22
// in the components we use and makes it easy to reuse across the app.
33
export { default as Alert } from "react-bootstrap/Alert"
4+
export { default as Badge } from "react-bootstrap/Badge"
45
export { default as Button } from "react-bootstrap/Button"
56
export { default as Card } from "react-bootstrap/Card"
67
export { default as Col } from "react-bootstrap/Col"

components/db/events.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,11 @@ type SpecialEvent = BaseEvent & {
4242
}
4343
type SpecialEventContent = BaseContent
4444

45-
type Hearing = BaseEvent & { type: "hearing"; content: HearingContent }
45+
type Hearing = BaseEvent & {
46+
type: "hearing"
47+
content: HearingContent
48+
committeeChairs: string[]
49+
}
4650
type HearingContent = BaseContent & {
4751
Description: string
4852
Name: string

components/search/courtSessions.ts

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
export type CourtSession = {
2+
number: number
3+
firstYear: number
4+
secondYear: number
5+
label: string
6+
isCurrent?: boolean
7+
}
8+
9+
const rawSessions: CourtSession[] = [
10+
{
11+
number: 194,
12+
firstYear: 2025,
13+
secondYear: 2026,
14+
label: "194th (Current)",
15+
isCurrent: true
16+
},
17+
{
18+
number: 193,
19+
firstYear: 2023,
20+
secondYear: 2024,
21+
label: "193rd (2023 - 2024)"
22+
},
23+
{
24+
number: 192,
25+
firstYear: 2021,
26+
secondYear: 2022,
27+
label: "192nd (2021 - 2022)"
28+
}
29+
]
30+
31+
export const COURT_SESSIONS: CourtSession[] = rawSessions.sort(
32+
(a, b) => b.number - a.number
33+
)
34+
35+
export const COURT_BY_NUMBER: Record<number, CourtSession | undefined> =
36+
COURT_SESSIONS.reduce<Record<number, CourtSession | undefined>>(
37+
(acc, session) => {
38+
acc[session.number] = session
39+
return acc
40+
},
41+
{}
42+
)
43+
44+
export const CURRENT_COURT_NUMBER =
45+
COURT_SESSIONS.find(session => session.isCurrent)?.number ??
46+
COURT_SESSIONS[0]?.number ??
47+
0
48+
49+
const ordinalSuffix = (value: number) => {
50+
const mod100 = value % 100
51+
if (mod100 >= 11 && mod100 <= 13) return `${value}th`
52+
switch (value % 10) {
53+
case 1:
54+
return `${value}st`
55+
case 2:
56+
return `${value}nd`
57+
case 3:
58+
return `${value}rd`
59+
default:
60+
return `${value}th`
61+
}
62+
}
63+
64+
export const formatCourtFilterLabel = (number: number) => {
65+
const session = COURT_BY_NUMBER[number]
66+
if (!session) return String(number)
67+
if (session.label) return session.label
68+
if (session.isCurrent)
69+
return `${ordinalSuffix(number)} (Current ${session.firstYear} - ${
70+
session.secondYear
71+
})`
72+
return `${ordinalSuffix(number)} (${session.firstYear} - ${
73+
session.secondYear
74+
})`
75+
}
76+
77+
export const formatCourtSubtitle = (number: number) => {
78+
const session = COURT_BY_NUMBER[number]
79+
if (!session) return `${ordinalSuffix(number)} Session`
80+
const range = `${session.firstYear} - ${session.secondYear}`
81+
if (session.isCurrent) {
82+
return `Current Session: ${range}`
83+
}
84+
return `${ordinalSuffix(number)} Session: ${range}`
85+
}
86+
87+
export const getCourtSession = (number: number) => COURT_BY_NUMBER[number]
Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
import Link from "next/link"
2+
import { useRouter } from "next/router"
3+
import { useTranslation } from "next-i18next"
4+
import styled from "styled-components"
5+
import { Card, Badge } from "../../bootstrap"
6+
import { Highlight } from "react-instantsearch"
7+
import { HearingHitData } from "./HearingSearch"
8+
import {
9+
useState,
10+
useMemo,
11+
KeyboardEvent,
12+
MouseEvent,
13+
useCallback
14+
} from "react"
15+
16+
const StyledCard = styled(Card)`
17+
border: none;
18+
border-radius: 4px;
19+
margin-bottom: 0.75rem;
20+
overflow: hidden;
21+
cursor: pointer;
22+
outline-color: var(--bs-blue);
23+
outline-style: solid;
24+
outline-width: 0;
25+
transition: outline-width 0.1s;
26+
27+
font-size: 0.85rem;
28+
29+
&:hover {
30+
outline-width: 2px;
31+
}
32+
33+
&:active {
34+
outline-width: 4px;
35+
}
36+
37+
.card-body {
38+
padding: 0.85rem 1rem;
39+
}
40+
`
41+
42+
const SectionLabel = styled.span`
43+
color: var(--bs-blue);
44+
font-weight: 600;
45+
margin-right: 0.5rem;
46+
`
47+
48+
export const HearingHit = ({ hit }: { hit: HearingHitData }) => {
49+
const { t } = useTranslation(["search", "hearing"])
50+
const router = useRouter()
51+
const startsAt = new Date(hit.startsAt)
52+
const scheduleDate = t("schedule_date", { ns: "hearing", date: startsAt })
53+
const scheduleTime = t("schedule_time", { ns: "hearing", date: startsAt })
54+
const chairNames = hit.chairNames ?? []
55+
const topics = hit.agendaTopics ?? []
56+
const bills = useMemo(() => {
57+
const numbers = hit.billNumbers ?? []
58+
const slugs = hit.billSlugs ?? []
59+
return numbers.map((number, index) => ({
60+
number,
61+
slug: slugs[index]!
62+
}))
63+
}, [hit.billNumbers, hit.billSlugs])
64+
65+
const navigateToHearing = useCallback(() => {
66+
void router.push(`/hearing/${hit.eventId}`)
67+
}, [hit.eventId, router])
68+
69+
const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
70+
if (event.key === "Enter" || event.key === " ") {
71+
event.preventDefault()
72+
navigateToHearing()
73+
}
74+
}
75+
76+
const handleClick = (event: MouseEvent<HTMLDivElement>) => {
77+
event.preventDefault()
78+
navigateToHearing()
79+
}
80+
81+
return (
82+
<StyledCard
83+
role="link"
84+
tabIndex={0}
85+
className="w-100"
86+
onClick={handleClick}
87+
onKeyDown={handleKeyDown}
88+
aria-label={hit.title}
89+
>
90+
<Card.Body className="bg-white">
91+
<div className="d-flex flex-column gap-2">
92+
<div className="d-flex flex-wrap gap-2 align-items-center justify-content-between">
93+
<div className="d-flex flex-column">
94+
<span className="text-uppercase fw-semibold text-secondary">
95+
{scheduleDate}
96+
</span>
97+
<span className="text-secondary">{scheduleTime}</span>
98+
</div>
99+
{hit.hasVideo ? (
100+
<Badge bg="success" pill>
101+
{t("video_available", { ns: "search" })}
102+
</Badge>
103+
) : null}
104+
</div>
105+
106+
<div>
107+
<Card.Title as="h6" className="mb-1">
108+
<Highlight attribute="title" hit={hit} />
109+
</Card.Title>
110+
{hit.description ? (
111+
<p className="mb-0 text-muted">
112+
<Highlight attribute="description" hit={hit} />
113+
</p>
114+
) : null}
115+
</div>
116+
117+
{hit.locationName || hit.locationCity ? (
118+
<div>
119+
<SectionLabel>
120+
{t("location_label", { ns: "search" })}
121+
</SectionLabel>
122+
<span>
123+
{hit.locationName ?? hit.locationCity}
124+
{hit.locationName && hit.locationCity
125+
? ` · ${hit.locationCity}`
126+
: ""}
127+
</span>
128+
</div>
129+
) : null}
130+
131+
{chairNames.length ? (
132+
<div className="d-flex align-items-center gap-2">
133+
<SectionLabel>{t("chairs", { ns: "hearing" })}</SectionLabel>
134+
{<span>{chairNames.join(", ")}</span>}
135+
</div>
136+
) : null}
137+
138+
{topics.length ? (
139+
<div>
140+
<SectionLabel>{t("agenda_label", { ns: "search" })}</SectionLabel>
141+
<span>{topics.join(", ")}</span>
142+
</div>
143+
) : null}
144+
145+
<BillsSection bills={bills} />
146+
</div>
147+
</Card.Body>
148+
</StyledCard>
149+
)
150+
}
151+
152+
const BillsSection = ({
153+
bills
154+
}: {
155+
bills: { number: string; slug: string }[]
156+
}) => {
157+
const { t } = useTranslation("search")
158+
const [showAllBills, setShowAllBills] = useState(false)
159+
160+
if (!bills.length) return null
161+
162+
const visibleCount = showAllBills ? bills.length : Math.min(7, bills.length)
163+
const visibleBills = bills.slice(0, visibleCount)
164+
const remaining = bills.length - visibleCount
165+
166+
return (
167+
<div>
168+
<SectionLabel>{t("bills_label")}</SectionLabel>
169+
<span>
170+
{visibleBills.map((bill, index) => {
171+
const isLastVisible = index === visibleBills.length - 1
172+
const shouldShowComma =
173+
!isLastVisible || (!showAllBills && remaining > 0)
174+
175+
return (
176+
<span key={`${bill.slug}-${bill.number}-${index}`}>
177+
<Link href={`/bills/${bill.slug}`} legacyBehavior>
178+
<a
179+
className="text-decoration-none"
180+
onClick={event => event.stopPropagation()}
181+
onKeyDown={event => {
182+
if (event.key === "Enter" || event.key === " ") {
183+
event.stopPropagation()
184+
}
185+
}}
186+
>
187+
{bill.number}
188+
</a>
189+
</Link>
190+
{shouldShowComma ? ", " : ""}
191+
</span>
192+
)
193+
})}
194+
{!showAllBills && remaining > 0 ? (
195+
<button
196+
type="button"
197+
className="btn btn-link p-0 align-baseline"
198+
onClick={event => {
199+
event.stopPropagation()
200+
setShowAllBills(true)
201+
}}
202+
>
203+
{t("more_bills", { count: remaining })}
204+
</button>
205+
) : null}
206+
</span>
207+
</div>
208+
)
209+
}

0 commit comments

Comments
 (0)