diff --git a/.github/workflows/repo-checks.yml b/.github/workflows/repo-checks.yml
index 07445efc0..85786ce0e 100644
--- a/.github/workflows/repo-checks.yml
+++ b/.github/workflows/repo-checks.yml
@@ -67,6 +67,13 @@ jobs:
with:
path: /home/runner/.cache/firebase/emulators
key: ${{ runner.os }}-firebase-emulators-${{ hashFiles('~/.cache/firebase/emulators/**') }}
+ - name: Smoke Test Firebase Admin CLI
+ run: >
+ ./node_modules/.bin/firebase --project demo-dtp emulators:exec
+ --only auth,firestore
+ --import tests/integration/exportedTestData
+ "yarn firebase-admin run-script backfillTestimonyBallotQuestionId --env local"
+
- name: Run Integration Tests
run: >
yarn test:integration-ci
@@ -74,3 +81,5 @@ jobs:
tests/integration/auth.test.ts
tests/integration/moderation.test.ts
tests/integration/profile.test.ts
+ tests/integration/ballotQuestions.test.ts
+ tests/integration/backfillTestimonyBallotQuestionId.test.ts
diff --git a/ballotQuestions/2024/23-12.yaml b/ballotQuestions/2024/23-12.yaml
new file mode 100644
index 000000000..e7163e81a
--- /dev/null
+++ b/ballotQuestions/2024/23-12.yaml
@@ -0,0 +1,31 @@
+# ballotQuestions/23-12.yaml
+id: "23-12"
+billId: "H4254"
+title: "Minimum Wage for Tipped Workers"
+court: 193
+electionYear: 2024
+type: initiative_statute
+ballotStatus: rejected
+ballotQuestionNumber: 5
+relatedBillIds: []
+description: null
+atAGlance: null
+fullSummary: |-
+ This proposed law would gradually increase the minimum hourly wage an
+ employer must pay a tipped worker, over the course of five years, on the following
+ schedule: - To 64% of the state minimum wage on January 1, 2025; - To 73% of
+ the state minimum wage on January 1, 2026; - To 82% of the state minimum wage
+ on January 1, 2027; - To 91% of the state minimum wage on January 1, 2028; and
+ - To 100% of the state minimum wage on January 1, 2029. The proposed law
+ would require employers to continue to pay tipped workers the difference between
+ the state minimum wage and the total amount a tipped worker receives in hourly
+ wages plus tips through the end of 2028. The proposed law would also permit
+ employers to calculate this difference over the entire weekly or bi-weekly payroll
+ period. The requirement to pay this difference would cease when the required
+ hourly wage for tipped workers would become 100% of the state minimum wage
+ on January 1, 2029. Under the proposed law, if an employer pays its workers an
+ hourly wage that is at least the state minimum wage, the employer would be
+ permitted to administer a “tip pool” that combines all the tips given by customers to
+ tipped workers and distributes them among all the workers, including non-tipped
+ workers.
+pdfUrl: "https://malegislature.gov/Bills/193/H4254.pdf"
diff --git a/ballotQuestions/2024/23-13.yaml b/ballotQuestions/2024/23-13.yaml
new file mode 100644
index 000000000..e395fabe3
--- /dev/null
+++ b/ballotQuestions/2024/23-13.yaml
@@ -0,0 +1,66 @@
+# ballotQuestions/23-13.yaml
+id: "23-13"
+billId: "H4255"
+title: "Limited Legalization and Regulation of Certain Natural Psychedelic Substances"
+court: 193
+electionYear: 2024
+type: initiative_statute
+ballotStatus: rejected
+ballotQuestionNumber: 4
+relatedBillIds: []
+description: null
+atAGlance: null
+fullSummary: |-
+ This proposed law would allow persons aged 21 and older to grow, possess, and
+ use certain natural psychedelic substances in certain circumstances. The psychedelic
+ substances allowed would be two substances found in mushrooms (psilocybin and
+ psilocyn) and three substances found in plants (dimethyltryptamine, mescaline, and
+ ibogaine). These substances could be purchased at an approved location for use under the
+ supervision of a licensed facilitator. This proposed law would otherwise prohibit any retail
+ sale of natural psychedelic substances. This proposed law would also provide for the
+ regulation and taxation of these psychedelic substances.
+
+ This proposed law would license and regulate facilities offering supervised use of
+ these psychedelic substances and provide for the taxation of proceeds from those facilities’
+ sales of psychedelic substances. It would also allow persons aged 21 and older to grow
+ these psychedelic substances in a 12-foot by 12-foot area at their home and use these
+ psychedelic substances at their home. This proposed law would authorize persons aged 21
+ or older to possess up to one gram of psilocybin, one gram of psilocyn, one gram of
+ dimethyltryptamine, 18 grams of mescaline, and 30 grams of ibogaine (“personal use
+ amount”), in addition to whatever they might grow at their home, and to give away up to
+ the personal use amount to a person aged 21 or over.
+
+ This proposed law would create a Natural Psychedelic Substances Commission of
+ five members appointed by the Governor, Attorney General, and Treasurer which would
+ administer the law governing the use and distribution of these psychedelic substances. The
+ Commission would adopt regulations governing licensing qualifications, security,
+ recordkeeping, education and training, health and safety requirements, testing, and age
+ verification. This proposed law would also create a Natural Psychedelic Substances
+ Advisory Board of 20 members appointed by the Governor, Attorney General, and
+ Treasurer which would study and make recommendations to the Commission on the
+ regulation and taxation of these psychedelic substances.
+
+ This proposed law would allow cities and towns to reasonably restrict the time,
+ place, and manner of the operation of licensed facilities offering psychedelic substances,
+ but cities and towns could not ban those facilities or their provision of these substances.
+ The proceeds of sales of psychedelic substances at licensed facilities would be
+ subject to the state sales tax and an additional excise tax of 15 percent. In addition, a city or
+ town could impose a separate tax of up to two percent. Revenue received from the
+ additional state excise tax, license application fees, and civil penalties for violations of this
+ proposed law would be deposited in a Natural Psychedelic Substances Regulation Fund
+ and would be used, subject to appropriation, for administration of this proposed law.
+ Using the psychedelic substances as permitted by this proposed law could not be a
+ basis to deny a person medical care or public assistance, impose discipline by a
+ professional licensing board, or enter adverse orders in child custody cases absent clear
+ and convincing evidence that the activities created an unreasonable danger to the safety of
+ a minor child.
+
+ This proposed law would not affect existing laws regarding the operation of motor
+ vehicles while under the influence, or the ability of employers to enforce workplace
+ policies restricting the consumption of these psychedelic substances by employees. This
+ proposed law would allow property owners to prohibit the use, display, growing,
+ processing, or sale of these psychedelic substances on their premises. State and local governments could continue to restrict the possession and use of these psychedelic
+ substances in public buildings or at schools.
+
+ This proposed law would take effect on December 15, 2024
+pdfUrl: "https://malegislature.gov/Bills/193/H4255.pdf"
diff --git a/ballotQuestions/2024/23-25.yaml b/ballotQuestions/2024/23-25.yaml
new file mode 100644
index 000000000..994a67a51
--- /dev/null
+++ b/ballotQuestions/2024/23-25.yaml
@@ -0,0 +1,59 @@
+# ballotQuestions/23-25.yaml
+id: "23-25"
+billId: "H4253"
+title: "Unionization for Transportation Network Drivers"
+court: 193
+electionYear: 2024
+type: initiative_statute
+ballotStatus: accepted
+ballotQuestionNumber: 3
+relatedBillIds: []
+description: null
+atAGlance: null
+fullSummary: |-
+ The proposed law would provide Transportation Network Drivers (“Drivers”) with the
+ right to form unions (“Driver Organizations”) to collectively bargain with Transportation
+ Network Companies (“Companies”)-which are companies that use a digital network to connect
+ riders to drivers for pre-arranged transportation-to create negotiated recommendations
+ concerning wages, benefits and terms and conditions of work. Drivers would not be required to
+ engage in any union activities. Companies would be allowed to form multi-Company
+ associations to represent them when negotiating with Driver Organizations. The state would
+ supervise the labor activities permitted by the proposed law and would have responsibility for
+ approving or disapproving the negotiated recommendations. The proposed law would define
+ certain activities by a Company or a Driver Organization to be unfair work practices. The
+ proposed law would establish a hearing process for the state Employment Relations Board
+ (“Board”) to follow when a Company or Driver Organization is charged with an unfair work
+ practice. The proposed law would permit the Board to take action, including awarding
+ compensation to adversely affected Drivers, if it found that an unfair work practice had been
+ committed. The proposed law would provide for an appeal of a Board decision to the state
+ Appeals Court.
+
+ This proposed law also would establish a procedure for determining which Drivers are
+ Active Drivers, meaning that they completed more than the median number of rides in the
+ previous six months. The proposed law would establish procedures for the Board to determine
+ that a Driver Organization has signed authorizations from at least five percent of Active Drivers,
+ entitling the Driver Organization to a list of Active Drivers; to designate a Driver Organization
+ as the exclusive bargaining representative for all Drivers based on signed authorizations from at
+ least twenty-five percent of Active Drivers; to resolve disputes over exclusive bargaining status,
+ including through elections; and to decertify a Driver Organization from exclusive bargaining
+ status. A Driver Organization that has been designated the exclusive bargaining representative
+ would have the exclusive right to represent the Drivers and to receive voluntary membership
+ dues deductions. Once the Board determined that a Driver Organization was the exclusive
+ bargaining representative for all Drivers, the Companies would be required to bargain with that
+ Driver Organization concerning wages, benefits and terms and conditions of work. Once the
+ Driver Organization and Companies reached agreement on wages, benefits, and the terms and
+ conditions of work, that agreement would be voted upon by all Drivers who has completed at
+ least 100 trips the previous quarter. If approved by a majority of votes cast, the recommendations
+ would be submitted to the state Secretary of Labor for approval and if approved, would be
+ effective for three years. The proposed law would establish procedures for the mediation and
+ arbitration if the Driver Organization and Companies failed to reach agreement within a certain
+ period of time. An arbitrator would consider factors set forth in the proposed law, including
+ whether the wages of Drivers would be enough so that Drivers would not need to rely upon any
+ public benefits. The proposed law also sets out procedures for the Secretary of Labor’s review
+ and approval of recommendations negotiated by a Driver Organization and the Companies and
+ for judicial review of the Secretary’s decision. The proposed law states that neither its
+ provisions, an agreement nor a determination by the Secretary would be able to lessen labor
+ standards established by other laws. If there were any conflict between the proposed law and existing Massachusetts labor relations law, the proposed law would prevail. The Board would
+ make rules and regulations as appropriate to effectuate the proposed law. The proposed law
+ states that, if any of its parts were declared invalid, the other parts would stay in effect.
+pdfUrl: "https://malegislature.gov/Bills/193/H4253.pdf"
diff --git a/ballotQuestions/2024/23-26.yaml b/ballotQuestions/2024/23-26.yaml
new file mode 100644
index 000000000..2f11b924e
--- /dev/null
+++ b/ballotQuestions/2024/23-26.yaml
@@ -0,0 +1,22 @@
+# ballotQuestions/23-26.yaml
+id: "23-26"
+billId: "H4252"
+title: "Elimination of MCAS as High School Graduation Requirement"
+court: 193
+electionYear: 2024
+type: initiative_statute
+ballotStatus: accepted
+ballotQuestionNumber: 2
+relatedBillIds: []
+description: null
+atAGlance: null
+fullSummary: |-
+ This proposed law would eliminate the requirement that a student pass the Massachusetts
+ Comprehensive Assessment System (MCAS) tests (or other statewide or district-wide
+ assessments) in mathematics, science and technology, and English in order to receive a high
+ school diploma. Instead, in order for a student to receive a high school diploma, the proposed
+ law would require the student to complete coursework certified by the student’s district as
+ demonstrating mastery of the competencies contained in the state academic standards in
+ mathematics, science and technology, and English, as well as any additional areas determined by
+ the Board of Elementary and Secondary Education.
+pdfUrl: "https://malegislature.gov/Bills/193/H4252.pdf"
diff --git a/ballotQuestions/2024/23-34.yaml b/ballotQuestions/2024/23-34.yaml
new file mode 100644
index 000000000..b44f46fb7
--- /dev/null
+++ b/ballotQuestions/2024/23-34.yaml
@@ -0,0 +1,14 @@
+# ballotQuestions/23-34.yaml
+id: "23-34"
+billId: "H4251"
+title: "State Auditor’s Authority to Audit the Legislature"
+court: 193
+electionYear: 2024
+type: initiative_statute
+ballotStatus: accepted
+ballotQuestionNumber: 1
+relatedBillIds: []
+description: null
+atAGlance: null
+fullSummary: "This proposed law would specify that the State Auditor has the authority to audit the Legislature."
+pdfUrl: "https://malegislature.gov/Bills/193/H4251.pdf"
diff --git a/ballotQuestions/2026/25-03.yaml b/ballotQuestions/2026/25-03.yaml
new file mode 100644
index 000000000..ac49665e7
--- /dev/null
+++ b/ballotQuestions/2026/25-03.yaml
@@ -0,0 +1,24 @@
+# ballotQuestions/25-03.yaml
+id: "25-03"
+billId: "H5000"
+title: "To allow single-family homes on small lots in areas with adequate infrastructure"
+court: 194
+electionYear: 2026
+type: initiative_statute
+ballotStatus: expectedOnBallot
+ballotQuestionNumber: null
+relatedBillIds: []
+description: null
+atAGlance: null
+fullSummary: |-
+ This proposed law would allow single-family homes to be built in a residentially zoned
+ area as long as the land on which it is to be constructed is at least 5,000 square feet, has at least
+ 50 feet of land bordering the street, road, or public way that it faces, and has access to public
+ sewer and water services.
+
+ The proposed law would allow cities and towns to reasonably regulate certain aspects of
+ those single-family homes, including their height, distance from neighboring buildings, open
+ space, parking requirements, and whether they can be rented out on a short-term basis. The
+ proposed law would also allow the Executive Office of Housing and Livable Communities to
+ issue guidance or regulations to administer the proposed law.
+pdfUrl: "https://malegislature.gov/Bills/194/H5000.pdf"
diff --git a/ballotQuestions/2026/25-08.yaml b/ballotQuestions/2026/25-08.yaml
new file mode 100644
index 000000000..cf8f4cba6
--- /dev/null
+++ b/ballotQuestions/2026/25-08.yaml
@@ -0,0 +1,42 @@
+# ballotQuestions/25-08.yaml
+id: "25-08"
+billId: "H5001"
+title: "To election day registration"
+court: 194
+electionYear: 2026
+type: initiative_statute
+ballotStatus: expectedOnBallot
+ballotQuestionNumber: null
+relatedBillIds: []
+description: null
+atAGlance: null
+fullSummary: |-
+ This proposed law would permit eligible individuals to register to vote or update their
+ voter registration address on Election Day.
+
+ An individual who is eligible to vote could register to vote on Election Day by going to
+ the polling place in the precinct where they live during voting hours and presenting proof of
+ residency and signing a written oath. Proof of residency could be a valid photo identification, or
+ documentation showing the individual’s name and the address where the individual resides, such
+ as a current utility bill, bank statement, government check, residential lease, wireless telephone
+ statement, paycheck, current student fee statement or other document from a post-secondary
+ school, or another government document or correspondence. The written oath would require the
+ individual to certify that they are a citizen of the United States, are at least 18 years old, are not
+ legally prohibited from voting, and have not and will not vote in the same election at another
+ location. The oath would require the individual to acknowledge that providing false information
+ is a felony punishable by not more than 5 years imprisonment or a fine of not more than $10,000,
+ or both.
+
+ If an individual did not present proof of residency, they would be allowed to cast a
+ provisional ballot, which would be counted only if the individual returned to provide the required
+ information before the close of polls for a municipal election; within two days after a state
+ primary; or within six days after a state election.
+
+ Individuals who register to vote on Election Day would be registered to vote in future
+ elections as well as in the election taking place that day.
+
+ Individuals who are already registered to vote would not be able to change their political
+ party affiliation on Election Day.
+
+ The proposed law would take effect on January 1, 2028.
+pdfUrl: "https://malegislature.gov/Bills/194/H5001.pdf"
diff --git a/ballotQuestions/2026/25-10.yaml b/ballotQuestions/2026/25-10.yaml
new file mode 100644
index 000000000..8e6c59998
--- /dev/null
+++ b/ballotQuestions/2026/25-10.yaml
@@ -0,0 +1,38 @@
+# ballotQuestions/25-10.yaml
+id: "25-10"
+billId: "H5002"
+title: "To restore a sensible marijuana policy"
+court: 194
+electionYear: 2026
+type: initiative_statute
+ballotStatus: expectedOnBallot
+ballotQuestionNumber: null
+relatedBillIds: []
+description: null
+atAGlance: null
+fullSummary: |-
+ The proposed law would change the type and amount of marijuana that may legally be
+ possessed in Massachusetts by repealing the laws that legalize, regulate, and tax the retail sale of
+ adult recreational use marijuana in Massachusetts. The proposed law would also permit persons
+ 21 years of age and older to possess 1 ounce or less of marijuana including no more than 5 grams
+ in the form of concentrate, and to gift or transfer to another person 21 years of age and older 1
+ ounce or less of marijuana including no more than 5 grams in the form of concentrate. The
+ proposed law would also impose a civil penalty of $100 and forfeiture of the marijuana for the
+ possession of marijuana between the weight of 1 and 2 ounces.
+
+ For persons 21 years of age and younger, the proposed law would make the possession of
+ 2 ounces or less of marijuana a civil infraction subject to a $100 fine, forfeiture of the marijuana,
+ completion of a drug awareness program and community service, and notification to their parents
+ or legal guardian of the offense and penalties.
+
+ The proposed law would allow currently licensed adult recreational marijuana businesses to apply
+ on an expedited basis to become a licensed medical marijuana dispensary and to sell their remaining
+ inventory of adult recreational marijuana to medical marijuana dispensaries. The proposed law would
+ retain the Cannabis Control Commission but modify its authority so it would regulate only the medical
+ marijuana market.
+
+ The proposed law states that, if any of its parts were declared invalid, the other parts would stay
+ in effect.
+
+ The proposed law would take effect on January 1, 2028.
+pdfUrl: "https://malegislature.gov/Bills/194/H5002.pdf"
diff --git a/ballotQuestions/2026/25-12.yaml b/ballotQuestions/2026/25-12.yaml
new file mode 100644
index 000000000..1453443ef
--- /dev/null
+++ b/ballotQuestions/2026/25-12.yaml
@@ -0,0 +1,25 @@
+# ballotQuestions/25-12.yaml
+id: "25-12"
+billId: "H5003"
+title: "To implement all-party state primaries"
+court: 194
+electionYear: 2026
+type: initiative_statute
+ballotStatus: expectedOnBallot
+ballotQuestionNumber: null
+relatedBillIds: []
+description: null
+atAGlance: null
+fullSummary: |-
+ This proposed law would eliminate political party primaries for state elections and
+ instead establish a system where there would be a single, all-party primary in which all
+ candidates, regardless of their party affiliation, would be listed on one ballot, and voters could
+ vote for any candidate on the ballot. The two candidates receiving the most votes in the primary
+ would advance to the general election ballot.
+
+ This proposed law would require candidates for governor and lieutenant governor to run
+ and be listed jointly on the ballot in the primary.
+
+ This proposed law would provide political party status to any group whose candidates for
+ any statewide office received at least 3% of the ballots cast in the state primary.
+pdfUrl: "https://malegislature.gov/Bills/194/H5003.pdf"
diff --git a/ballotQuestions/2026/25-14.yaml b/ballotQuestions/2026/25-14.yaml
new file mode 100644
index 000000000..e22a3b2b6
--- /dev/null
+++ b/ballotQuestions/2026/25-14.yaml
@@ -0,0 +1,20 @@
+# ballotQuestions/25-14.yaml
+id: "25-14"
+billId: "H5004"
+title: "To improve access to public records"
+court: 194
+electionYear: 2026
+type: initiative_statute
+ballotStatus: expectedOnBallot
+ballotQuestionNumber: null
+relatedBillIds: []
+description: null
+atAGlance: null
+fullSummary: |-
+ This proposed law would make most records held by the Legislature and the Office of the
+ Governor public records under the Massachusetts Public Records Law. This proposed law would
+ exempt documents related to the development of public policy and communications between
+ legislators and their constituents, if those communications are reasonably related to a
+ constituent’s request for assistance in obtaining government-provided benefits or services or
+ interacting with a government agency.
+pdfUrl: "https://malegislature.gov/Bills/194/H5004.pdf"
diff --git a/ballotQuestions/2026/25-15.yaml b/ballotQuestions/2026/25-15.yaml
new file mode 100644
index 000000000..4ae8eb31c
--- /dev/null
+++ b/ballotQuestions/2026/25-15.yaml
@@ -0,0 +1,53 @@
+# ballotQuestions/25-15.yaml
+id: "25-15"
+billId: "H5005"
+title: "To protect water and nature"
+court: 194
+electionYear: 2026
+type: initiative_statute
+ballotStatus: expectedOnBallot
+ballotQuestionNumber: null
+relatedBillIds: []
+description: null
+atAGlance: null
+fullSummary: |-
+ This proposed law would establish a Nature for All Fund that, subject to appropriation by
+ the Legislature, would receive 50% of state taxes collected from the sale and use of sporting
+ goods, recreational vehicles, and golf courses for the first year of its operation. After July 1,
+ 2028, the Nature for All Fund would begin receiving, subject to appropriation by the Legislature,
+ 100% of state taxes collected on the sale and use of sporting goods, recreational vehicles, and
+ golf courses. The sales tax revenue received by the Nature for All Fund would exclude sales tax
+ revenue transferred to the Massachusetts Bay Transportation Authority State and Local
+ Contribution Fund and the School Modernization and Reconstruction Trust Fund. The proposed
+ law would allow the state Executive Office of Energy and Environmental Affairs to spend the
+ money in the Nature for All Fund for natural resource conservation.
+
+ The proposed law would allow public and private donations to the Nature for All Fund.
+ The proposed law would prevent the state comptroller from transferring surplus funds in the
+ Nature for All Fund at the end of the fiscal year. It would also allow state agencies,
+ municipalities, public charities involved in natural resource conservation, tribal governments,
+ and other regional public entities to receive money from the Nature for All Fund.
+ Natural resource conservation would include the conservation or restoration of land to
+ protect drinking water, streams, rivers, lakes, coasts, farms, forests, connectivity between open
+ spaces, and lands and natural resources of indigenous cultural significance. Natural resource
+ conservation would also include the creation, improvement, and management of parks, trails,
+ greenspaces or outdoor recreation access.
+
+ The proposed law would establish a 15-member Nature for All Board that consists of five
+ state officials and ten members of the public appointed by the Governor. The proposed law
+ would require the ten members of the public to include representatives of underserved
+ communities and indigenous peoples and at least one person with expertise or experience in
+ natural resource conservation. The proposed law would allow the state Executive Office of
+ Energy and Environmental Affairs to spend money from the Nature for All Fund to hire staff to
+ manage the fund. The proposed law would also require the Nature for All Board to establish
+ rules about how the money in the Nature for All Fund should be spent, including rules regarding
+ alignment with environmental justice principles, access to and restoration of lands and natural
+ resources of indigenous cultural significance, promotion of affordable housing development, and
+ other matters regarding spending and bond issuance.
+
+ The proposed law would require the state Executive Office of Energy and Environmental
+ Affairs to submit an annual report to various state committees regarding the funds spent to buy or
+ improve land in cities and towns containing environmental justice populations.
+
+ The proposed law would take effect on July 1, 2027.
+pdfUrl: "https://malegislature.gov/Bills/194/H5005.pdf"
diff --git a/ballotQuestions/2026/25-17.yaml b/ballotQuestions/2026/25-17.yaml
new file mode 100644
index 000000000..c441e9e3b
--- /dev/null
+++ b/ballotQuestions/2026/25-17.yaml
@@ -0,0 +1,27 @@
+# ballotQuestions/25-17.yaml
+id: "25-17"
+billId: "H5006"
+title: "To limiting state tax collection growth and returning surpluses to taxpayers"
+court: 194
+electionYear: 2026
+type: initiative_statute
+ballotStatus: expectedOnBallot
+ballotQuestionNumber: null
+relatedBillIds: []
+description: null
+atAGlance: null
+fullSummary: |-
+ This proposed law would change the limit on how much revenue the state can collect in a
+ given year. The proposal would limit state revenue in a given year to the net amount of state
+ revenue from the year before, increased by a rate equal to the average growth of wages and
+ salaries in Massachusetts over the most recent three years. If revenue collected by the state in a
+ given year exceeds the limit, the excess amount would be refunded to taxpayers the following
+ year. The proposed law would include all revenue from the surtax on incomes over $1 million
+ when calculating the revenue limit and when determining whether state revenue exceeds the
+ limit.
+
+ The provisions of the proposed law would all be effective as of July 1, 2027.
+
+ The proposed law states that, if any of its parts were declared invalid, the other parts
+ would stay in effect.
+pdfUrl: "https://malegislature.gov/Bills/194/H5006.pdf"
diff --git a/ballotQuestions/2026/25-18.yaml b/ballotQuestions/2026/25-18.yaml
new file mode 100644
index 000000000..7bb26ea8f
--- /dev/null
+++ b/ballotQuestions/2026/25-18.yaml
@@ -0,0 +1,22 @@
+# ballotQuestions/25-18.yaml
+id: "25-18"
+billId: "H5007"
+title: "To reducing the state personal income tax rate from 5% to 4%"
+court: 194
+electionYear: 2026
+type: initiative_statute
+ballotStatus: expectedOnBallot
+ballotQuestionNumber: null
+relatedBillIds: []
+description: null
+atAGlance: null
+fullSummary: |-
+ This proposed law would, over a period of three years, lower the tax rates on (1) personal
+ taxable income consisting of interest and dividends, and (2) personal taxable income other than
+ interest, dividends or capital gain income, such as wages and salaries. Both tax rates were 5.00%
+ for tax year 2024. The proposed law would set both tax rates at 4.67% for tax year 2027, 4.33%
+ for tax year 2028, and 4.00% beginning in tax year 2029.
+
+ The proposed law states that, if any of its parts were declared invalid, the other parts
+ would stay in effect.
+pdfUrl: "https://malegislature.gov/Bills/194/H5007.pdf"
diff --git a/ballotQuestions/2026/25-21.yaml b/ballotQuestions/2026/25-21.yaml
new file mode 100644
index 000000000..dcc6ff5c2
--- /dev/null
+++ b/ballotQuestions/2026/25-21.yaml
@@ -0,0 +1,23 @@
+# ballotQuestions/25-21.yaml
+id: "25-21"
+billId: "H5008"
+title: "To protect tenants by limiting rent increases"
+court: 194
+electionYear: 2026
+type: initiative_statute
+ballotStatus: expectedOnBallot
+ballotQuestionNumber: null
+relatedBillIds: []
+description: null
+atAGlance: null
+fullSummary: |-
+ This proposed law would limit the annual rent increase for residential units in
+ Massachusetts to the annual increase in the Consumer Price Index for a 12-month period, or 5%,
+ whichever is lower. The law would not apply to units in owner-occupied buildings with four or
+ fewer units; units that are subject to regulation by a public authority; units rented to transient
+ guests for periods of less than 14 days; units operated for educational, religious, or non-profit
+ purposes; and units that received their residential certificate of occupancy within the last 10
+ years. The rent in place for a unit as of January 31, 2026, would serve as the base rent for the
+ annual rent increase limit. A violation of this law would be a violation of the state consumer
+ protection law.
+pdfUrl: "https://malegislature.gov/Bills/194/H5008.pdf"
diff --git a/ballotQuestions/2026/25-22.yaml b/ballotQuestions/2026/25-22.yaml
new file mode 100644
index 000000000..a69b13e9d
--- /dev/null
+++ b/ballotQuestions/2026/25-22.yaml
@@ -0,0 +1,18 @@
+# ballotQuestions/25-22.yaml
+id: "25-22"
+billId: "H5009"
+title: "To labor relations policies for committee for public counsel services employees"
+court: 194
+electionYear: 2026
+type: initiative_statute
+ballotStatus: expectedOnBallot
+ballotQuestionNumber: null
+relatedBillIds: []
+description: null
+atAGlance: null
+fullSummary: |-
+ This proposed law would specify that employees of the Committee for Public Counsel
+ Services (“CPCS”) are permitted to engage in collective bargaining with their employer. It would
+ also require CPCS, after executing a collective bargaining agreement, to request the
+ appropriation necessary to fund such agreement from the Governor.
+pdfUrl: "https://malegislature.gov/Bills/194/H5009.pdf"
diff --git a/ballotQuestions/2026/25-37.yaml b/ballotQuestions/2026/25-37.yaml
new file mode 100644
index 000000000..5abb2c8fd
--- /dev/null
+++ b/ballotQuestions/2026/25-37.yaml
@@ -0,0 +1,58 @@
+# ballotQuestions/25-37.yaml
+id: "25-37"
+billId: "H5010"
+title: "To reform and regulate legislative stipends"
+court: 194
+electionYear: 2026
+type: initiative_statute
+ballotStatus: expectedOnBallot
+ballotQuestionNumber: null
+relatedBillIds: []
+description: null
+atAGlance: null
+fullSummary: |-
+ This proposed law would change the method for calculating stipends paid to certain state
+ legislators on top of their base salaries.
+
+ Under the proposed law, legislators would receive stipends, subject to appropriation,
+ based on their leadership positions and/or committee membership. The Senate President and
+ Speaker of the House (Group 1) would receive a stipend of up to 75% of their base salaries. The
+ floor leaders of the two largest parties in each house of the legislature and the chairs of each
+ house’s ways and means committee (Group 2) would receive a stipend of up to 50% of their base
+ salaries. The assistant and second assistant floor leaders of the two largest parties in each house,
+ the third assistant floor leaders of the minority party in each house, and the vice chairs and
+ ranking minority members of each house’s ways and means committee (Group 3) and the chairs
+ of eligible committees (Group 4) would receive a stipend of up to 33% of their base salaries.
+ Legislators who are not in Groups 1-4 who are members of an eligible committee would receive
+ a stipend of up to 20% of their base salaries. A committee would be “eligible” under the
+ proposed law if it was established by the joint rules of the House and Senate and had more than
+ 50 bills referred to it before March 1 of the first year of the legislative session.
+
+ The proposed law would provide a further 20% stipend to three categories of senators: (1)
+ any senator in Group 2 or Group 3 who is a member of one or more eligible committees, (2) any
+ senator in Group 4 who is a member of more than one eligible committee, or (3) any senator not
+ in Groups 1-4 who is a member of more than four eligible committees.
+
+ Under the proposed law, no senator could receive a stipend for more than two positions,
+ and no representative could receive a stipend for more than one position.
+
+ This proposed law would also establish various terms and conditions for the payment of
+ legislative compensation. A Group 4 leader would receive 50% of the leader’s stipend in
+ biweekly paychecks; the leader would receive the other 50% in the last paycheck of the year if
+ the leader’s eligible committee had achieved compliance that year. Under the proposed law,
+ “compliance” would mean that, on or before the first Monday in December (in the first year of
+ the legislative session) or on or before the last Friday in May (in the second year of the session),
+ an eligible committee had (1) held a public hearing and public mark-up session on each bill
+ referred to it before a specified cutoff date and (2) approved all of its reports by a majority vote
+ at a public meeting with a quorum present. A Group 1-3 leader would receive 50% of the
+ leader’s stipend in biweekly paychecks; the leader would receive the other 50%, multiplied by
+ the percentage of eligible committees achieving compliance in that year, in the last paycheck of
+ the year. The proposed law would require the House and Senate clerks to jointly certify
+ compliance and to calculate the compliance percentage each legislative year. Except as otherwise
+ provided, legislators would receive their compensation on a biweekly basis.
+
+ Legislators who served in a qualifying position for less than the full biennial session
+ would receive prorated stipends.
+
+ The proposed law would take effect on January 6, 2027.
+pdfUrl: "https://malegislature.gov/Bills/194/H5010.pdf"
diff --git a/components/CommentModal/Attachment.tsx b/components/CommentModal/Attachment.tsx
index f65d4905c..1593ccaed 100644
--- a/components/CommentModal/Attachment.tsx
+++ b/components/CommentModal/Attachment.tsx
@@ -9,11 +9,13 @@ import { useTranslation } from "next-i18next"
export function Attachment({
attachment,
className,
- confirmRemove = false
+ confirmRemove = false,
+ label
}: {
attachment: UseDraftTestimonyAttachment
className?: string
confirmRemove?: boolean
+ label?: string
}) {
const { upload, error, id } = attachment
const [key, setKey] = useState(0),
@@ -35,7 +37,7 @@ export function Attachment({
return (
-
+
{id ? (
) : (
@@ -62,14 +64,16 @@ const formatSize = (size: number) => {
}
const Label = ({
- attachment: { status }
+ attachment: { status },
+ label
}: {
attachment: UseDraftTestimonyAttachment
+ label?: string
}) => {
const { t } = useTranslation("attachment")
return (
- {t("provide_testimony_as_file")}
+ {label ?? t("provide_testimony_as_file")}
{status === "loading" && }
{status === "error" && (
diff --git a/components/EditProfilePage/FollowingTab.tsx b/components/EditProfilePage/FollowingTab.tsx
index 0c5a33e0d..e7f6f9ba3 100644
--- a/components/EditProfilePage/FollowingTab.tsx
+++ b/components/EditProfilePage/FollowingTab.tsx
@@ -1,10 +1,16 @@
+import { flags } from "components/featureFlags"
+import { dbService } from "components/db/api"
import { useBill } from "components/db"
import { formatBillId } from "components/formatting"
import { Internal } from "components/links"
-import { FollowBillButton } from "components/shared/FollowButton"
+import {
+ FollowBallotQuestionButton,
+ FollowBillButton
+} from "components/shared/FollowButton"
import { collection, onSnapshot, query, where } from "firebase/firestore"
import { useTranslation } from "next-i18next"
import { ComponentProps, useEffect, useMemo, useState } from "react"
+import { useAsync } from "react-async-hook"
import { useAuth } from "../auth"
import { Alert, Col, Row, Spinner } from "../bootstrap"
import { firestore } from "../firebase"
@@ -16,6 +22,7 @@ import {
export function FollowingTab({ className }: { className?: string }) {
const { t } = useTranslation("editProfile")
+ const followedBallotQuestions = useFollowedBallotQuestions()
return (
<>
+ {flags().ballotQuestions && (
+
+ )}
> => useTopicSubscription("bill")
+const useFollowedBallotQuestions = (): LoadableItemsState<
+ ComponentProps
+> => useTopicSubscription("ballotQuestion")
+
const useFollowedUsers = (): LoadableItemsState<
ComponentProps
> => useTopicSubscription("testimony")
function useTopicSubscription(
- type: "bill" | "testimony"
+ type: "bill" | "ballotQuestion" | "testimony"
): LoadableItemsState {
const [state, setState] = useState>({
items: [],
@@ -59,7 +78,12 @@ function useTopicSubscription(
: null,
[uid]
)
- const topicKey = type === "bill" ? "billLookup" : "userLookup"
+ const topicKey =
+ type === "bill"
+ ? "billLookup"
+ : type === "ballotQuestion"
+ ? "ballotQuestionLookup"
+ : "userLookup"
useEffect(() => {
if (!subscriptionRef || !uid) return
@@ -88,7 +112,7 @@ function useTopicSubscription(
)
return () => unsubscribe()
- }, [subscriptionRef, uid, type])
+ }, [subscriptionRef, uid, type, topicKey, t])
return state
}
@@ -123,3 +147,46 @@ function FollowedBillCard({
)
}
+
+function FollowedBallotQuestionCard({
+ ballotQuestionId,
+ court
+}: {
+ ballotQuestionId: string
+ court: number
+}) {
+ const { t } = useTranslation("editProfile")
+ const {
+ loading,
+ error,
+ result: bq
+ } = useAsync(
+ () => dbService().getBallotQuestion({ id: ballotQuestionId }),
+ [ballotQuestionId]
+ )
+ if (loading) return
+ if (error) return {t("content.error")}
+ if (!bq) return null
+
+ const label =
+ bq.ballotQuestionNumber != null
+ ? `Question ${bq.ballotQuestionNumber}`
+ : bq.description ?? ballotQuestionId
+
+ return (
+
+
+
+ {label}
+
+
+ {bq.title ?? bq.description}
+
+
+
+
+
+
+
+ )
+}
diff --git a/components/EditProfilePage/PhoneVerificationModal.tsx b/components/EditProfilePage/PhoneVerificationModal.tsx
index 718dbc299..a08c78370 100644
--- a/components/EditProfilePage/PhoneVerificationModal.tsx
+++ b/components/EditProfilePage/PhoneVerificationModal.tsx
@@ -130,6 +130,10 @@ export default function PhoneVerificationModal({
setVerifying(true)
try {
await confirmationResult.confirm(code.trim())
+
+ // Force fresh token to ensure context.auth is populated
+ await auth.currentUser?.getIdToken(true)
+
if (completePhoneVerification.execute) {
await completePhoneVerification.execute()
}
diff --git a/components/Footer/Footer.tsx b/components/Footer/Footer.tsx
index bbe2ada39..97bc9b9e9 100644
--- a/components/Footer/Footer.tsx
+++ b/components/Footer/Footer.tsx
@@ -224,6 +224,11 @@ const BrowseList = () => {
) : null}
{t("navigation.browseBills")}
+ {flags().ballotQuestions ? (
+
+ {t("navigation.browseBallotQuestions")}
+
+ ) : null}
>
)
}
diff --git a/components/InTheNews/InTheNews.tsx b/components/InTheNews/InTheNews.tsx
new file mode 100644
index 000000000..fc6f91b82
--- /dev/null
+++ b/components/InTheNews/InTheNews.tsx
@@ -0,0 +1,209 @@
+import { useState } from "react"
+import { Col, Row, Container, Badge, Spinner } from "../bootstrap"
+import Tab from "react-bootstrap/Tab"
+import Nav from "react-bootstrap/Nav"
+import Dropdown from "react-bootstrap/Dropdown"
+import { useMediaQuery } from "usehooks-ts"
+import { useTranslation } from "next-i18next"
+import { NewsCard } from "./NewsCard"
+import { NewsType, NewsItem, useNews } from "components/db/news"
+
+type NewsFeedProps = {
+ type: NewsType
+ newsItems: NewsItem[]
+}
+
+type TabCounts = {
+ media: number
+ awards: number
+ books: number
+}
+
+const NewsFeed = ({ type, newsItems }: NewsFeedProps) => {
+ return (
+
+ {newsItems
+ .filter(item => item.type === type)
+ .map((item, index) => (
+
+ ))}
+
+ )
+}
+
+export const InTheNews = () => {
+ const { t } = useTranslation("inTheNews")
+ const isMobile = useMediaQuery("(max-width: 768px)")
+ const { result: newsItems, loading } = useNews()
+
+ const counts: TabCounts | null = newsItems
+ ? {
+ media: newsItems.filter(item => item.type === "article").length,
+ awards: newsItems.filter(item => item.type === "award").length,
+ books: newsItems.filter(item => item.type === "book").length
+ }
+ : null
+
+ return (
+
+
+ {t("title")}
+
+
+
+ {isMobile ? (
+
+ ) : (
+
+ )}
+
+
+ {loading ? (
+
+
+ Loading...
+
+
+ ) : (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )}
+
+
+
+
+
+ )
+}
+
+const TabGroup = ({ counts }: { counts: TabCounts | null }) => {
+ const { t } = useTranslation("inTheNews")
+ return (
+
+
+
+
+
+
+ {t("media.title")}
+
+ {counts ? counts.media : 0}
+
+
+
+
+
+
+
+
+
+
+
+ {t("awards.title")}
+
+ {counts ? counts.awards : 0}
+
+
+
+
+
+
+
+
+
+
+
+ {t("books.title")}
+
+ {counts ? counts.books : 0}
+
+
+
+
+
+
+
+ )
+}
+
+const TabDropdown = ({ counts }: { counts: TabCounts | null }) => {
+ const { t } = useTranslation("inTheNews")
+ const [selectedTab, setSelectedTab] = useState("Media")
+
+ const handleTabClick = (tabTitle: string) => {
+ setSelectedTab(tabTitle)
+ }
+
+ return (
+
+
+
+
+ {selectedTab}
+
+
+ handleTabClick("Media")}
+ >
+ {t("media.title")}
+
+
+ handleTabClick("Awards")}
+ >
+ {t("awards.title")}
+
+
+ handleTabClick("Books")}
+ >
+ {t("books.title")}
+
+
+
+
+
+ )
+}
diff --git a/components/InTheNews/NewsCard.tsx b/components/InTheNews/NewsCard.tsx
new file mode 100644
index 000000000..cb545e354
--- /dev/null
+++ b/components/InTheNews/NewsCard.tsx
@@ -0,0 +1,68 @@
+import ArrowForward from "@mui/icons-material/ArrowForward"
+import { useTranslation } from "next-i18next"
+import { NewsItem } from "components/db"
+
+type NewsCardProps = {
+ newsItem: NewsItem
+}
+
+export const NewsCard = ({ newsItem }: NewsCardProps) => {
+ const { t } = useTranslation("inTheNews")
+ return (
+
+
+
+
+ {new Date(newsItem.publishDate).toLocaleDateString("en-US", {
+ year: "numeric",
+ month: "long",
+ day: "numeric",
+ timeZone: "UTC"
+ })}
+
+
+
{newsItem.author}
+
+
+
{newsItem.description}
+
+
+
+ )
+}
diff --git a/components/Navbar.tsx b/components/Navbar.tsx
index 029bcc35f..b9b470e09 100644
--- a/components/Navbar.tsx
+++ b/components/Navbar.tsx
@@ -1,5 +1,5 @@
import { useTranslation } from "next-i18next"
-import React, { useContext, useState } from "react"
+import React, { useState } from "react"
import Image from "react-bootstrap/Image"
import styled from "styled-components"
import { useMediaQuery } from "usehooks-ts"
@@ -9,6 +9,7 @@ import { flags } from "./featureFlags"
import {
Avatar,
+ NavbarLinkBallotQuestions,
NavbarLinkAI,
NavbarLinkBills,
NavbarLinkHearings,
@@ -16,6 +17,7 @@ import {
NavbarLinkEffective,
NavbarLinkFAQ,
NavbarLinkGoals,
+ NavbarLinkInTheNews,
NavbarLinkLogo,
NavbarLinkNewsfeed,
NavbarLinkProcess,
@@ -54,7 +56,7 @@ const MobileNav: React.FC> = () => {
const ProfileLinks = () => {
return (
-
+
{
closeNav()
@@ -81,6 +83,9 @@ const MobileNav: React.FC> = () => {
return (
+ {flags().ballotQuestions ? (
+
+ ) : null}
{flags().hearingsAndTranscriptions ? (
) : null}
@@ -92,6 +97,7 @@ const MobileNav: React.FC> = () => {
+
@@ -137,39 +143,63 @@ const MobileNav: React.FC> = () => {
expanded={isExpanded}
>
-
+
{isExpanded && whichMenu == "site" ? (
) : (
-
+
)}
-
+
{authenticated ? (
-
-
+
+
{isExpanded && whichMenu == "profile" ? (
) : (
)}
-
-
+
+
) : (
)}
@@ -187,39 +217,47 @@ const DesktopNav: React.FC> = () => {
return (
-
+
+ {flags().ballotQuestions ? (
+
+
+
+ ) : null}
+
{flags().hearingsAndTranscriptions ? (
-
+
) : (
<>>
)}
-
+
{authenticated ? (
-
+
) : (
<>>
)}
-
+
-
+
{t("about")}
@@ -228,13 +266,16 @@ const DesktopNav: React.FC> = () => {
+
-
+
-
+
{t("learn")}
@@ -248,26 +289,21 @@ const DesktopNav: React.FC> = () => {
{authenticated ? (
-
+
-
-
-
-
-
-
-
-
-
-
- {
- void signOutAndRedirectToHome()
- }}
- />
-
+
+
+
+ {
+ void signOutAndRedirectToHome()
+ }}
+ />
diff --git a/components/NavbarComponents.tsx b/components/NavbarComponents.tsx
index 458c56cb6..354a3bd5d 100644
--- a/components/NavbarComponents.tsx
+++ b/components/NavbarComponents.tsx
@@ -1,10 +1,57 @@
import { useTranslation } from "next-i18next"
+import { useRouter } from "next/router"
import Image from "react-bootstrap/Image"
import { useMediaQuery } from "usehooks-ts"
import { useAuth } from "./auth"
import { Nav, NavDropdown } from "./bootstrap"
import { useProfile } from "./db"
import { NavLink } from "./Navlink"
+import { Wrap } from "./links"
+
+const NavbarDropdownLink: React.FC<
+ React.PropsWithChildren<{
+ href: string
+ handleClick?: any
+ className?: string
+ other?: any
+ }>
+> = ({ href, handleClick, className, children, other }) => {
+ const router = useRouter()
+ const isActive =
+ router.asPath.split("?")[0] === href.split("?")[0] ||
+ router.pathname === href
+
+ return (
+
+
+ {children}
+
+
+ )
+}
+
+const NavbarDropdownAction: React.FC<
+ React.PropsWithChildren<{
+ handleClick?: any
+ className?: string
+ other?: any
+ }>
+> = ({ handleClick, className, children, other }) => (
+
+ {children}
+
+)
export const Avatar = () => {
const { t } = useTranslation("profile")
@@ -41,19 +88,43 @@ export const NavbarLinkAI: React.FC<
const isMobile = useMediaQuery("(max-width: 768px)")
const { t } = useTranslation(["common", "auth"])
return (
-
+
+ {t("navigation.ai")}
+
+ )
+}
+
+export const NavbarLinkBills: React.FC<
+ React.PropsWithChildren<{
+ handleClick?: any
+ other?: any
+ }>
+> = ({ handleClick, other }) => {
+ const isMobile = useMediaQuery("(max-width: 768px)")
+ const { t } = useTranslation(["common", "auth"])
+ return (
+
- {t("navigation.ai")}
+ {t("navigation.bills")}
-
+
)
}
-export const NavbarLinkBills: React.FC<
+export const NavbarLinkBallotQuestions: React.FC<
React.PropsWithChildren<{
handleClick?: any
other?: any
@@ -64,11 +135,15 @@ export const NavbarLinkBills: React.FC<
return (
- {t("navigation.browseBills")}
+ {t("navigation.ballotQuestions")}
)
@@ -85,11 +160,15 @@ export const NavbarLinkHearings: React.FC<
return (
- {t("navigation.browseHearings")}
+ {t("navigation.hearings")}
)
@@ -100,19 +179,29 @@ export const NavbarLinkEditProfile: React.FC<
handleClick?: any
other?: any
tab: string
+ dropdown?: boolean
}>
-> = ({ handleClick, other, tab }) => {
+> = ({ handleClick, other, tab, dropdown = false }) => {
const isMobile = useMediaQuery("(max-width: 768px)")
const { t } = useTranslation(["common", "auth", "profile"])
+ const href =
+ tab == "navigation.editProfile"
+ ? "/edit-profile/about-you"
+ : "/edit-profile/following"
+
+ if (dropdown && !isMobile) {
+ return (
+
+ {t(tab)}
+
+ )
+ }
+
return (
{t(tab)}
@@ -130,15 +219,14 @@ export const NavbarLinkEffective: React.FC<
const isMobile = useMediaQuery("(max-width: 768px)")
const { t } = useTranslation(["common", "auth"])
return (
-
-
- {t("navigation.aboutTestimony")}
-
-
+
+ {t("navigation.aboutTestimony")}
+
)
}
@@ -151,15 +239,14 @@ export const NavbarLinkFAQ: React.FC<
const isMobile = useMediaQuery("(max-width: 768px)")
const { t } = useTranslation(["common", "auth"])
return (
-
-
- {t("navigation.faq")}
-
-
+
+ {t("navigation.faq")}
+
)
}
@@ -172,15 +259,14 @@ export const NavbarLinkGoals: React.FC<
const isMobile = useMediaQuery("(max-width: 768px)")
const { t } = useTranslation(["common", "auth"])
return (
-
-
- {t("navigation.missionAndGoals")}
-
-
+
+ {t("navigation.missionAndGoals")}
+
)
}
@@ -223,7 +309,11 @@ export const NavbarLinkNewsfeed: React.FC<
return (
@@ -242,15 +332,14 @@ export const NavbarLinkProcess: React.FC<
const isMobile = useMediaQuery("(max-width: 768px)")
const { t } = useTranslation(["common", "auth"])
return (
-
-
- {t("navigation.legislativeProcess")}
-
-
+
+ {t("navigation.legislativeProcess")}
+
)
}
@@ -258,18 +347,33 @@ export const NavbarLinkSignOut: React.FC<
React.PropsWithChildren<{
handleClick?: any
other?: any
+ dropdown?: boolean
}>
-> = ({ handleClick, other }) => {
+> = ({ handleClick, other, dropdown = false }) => {
const isMobile = useMediaQuery("(max-width: 768px)")
const { t } = useTranslation(["common", "auth"])
+
+ if (dropdown && !isMobile) {
+ return (
+
+ {t("navigation.signOut")}
+
+ )
+ }
+
return (
-
- {t("navigation.signOut")}
-
+
+
+ {t("navigation.signOut")}
+
+
)
}
@@ -282,15 +386,14 @@ export const NavbarLinkSupport: React.FC<
const isMobile = useMediaQuery("(max-width: 768px)")
const { t } = useTranslation(["common", "auth"])
return (
-
-
- {t("navigation.supportMaple")}
-
-
+
+ {t("navigation.supportMaple")}
+
)
}
@@ -303,15 +406,14 @@ export const NavbarLinkTeam: React.FC<
const isMobile = useMediaQuery("(max-width: 768px)")
const { t } = useTranslation(["common", "auth"])
return (
-
-
- {t("navigation.team")}
-
-
+
+ {t("navigation.team")}
+
)
}
@@ -326,11 +428,15 @@ export const NavbarLinkTestimony: React.FC<
return (
- {t("navigation.browseTestimony")}
+ {t("navigation.testimony")}
)
@@ -338,19 +444,29 @@ export const NavbarLinkTestimony: React.FC<
export const NavbarLinkViewProfile: React.FC<
React.PropsWithChildren<{
+ handleClick?: any
other?: any
+ dropdown?: boolean
}>
-> = ({ other }) => {
+> = ({ handleClick, other, dropdown = false }) => {
const { user } = useAuth()
const userLink = "/profile?id=" + user?.uid
const isMobile = useMediaQuery("(max-width: 768px)")
const { t } = useTranslation(["common", "auth"])
+
+ if (dropdown && !isMobile) {
+ return (
+
+ {t("navigation.viewProfile")}
+
+ )
+ }
+
return (
{
- location.assign(userLink)
- }}
+ handleClick={handleClick}
+ href={userLink}
{...other}
>
{t("navigation.viewProfile")}
@@ -363,6 +479,26 @@ export const NavbarLinkWhyUse: React.FC<
handleClick?: any
other?: any
}>
+> = ({ handleClick, other }) => {
+ const isMobile = useMediaQuery("(max-width: 768px)")
+ const { t } = useTranslation(["common", "auth"])
+ return (
+
+ {t("navigation.whyUseMaple")}
+
+ )
+}
+
+export const NavbarLinkInTheNews: React.FC<
+ React.PropsWithChildren<{
+ handleClick?: any
+ other?: any
+ }>
> = ({ handleClick, other }) => {
const isMobile = useMediaQuery("(max-width: 768px)")
const { t } = useTranslation(["common", "auth"])
@@ -370,10 +506,10 @@ export const NavbarLinkWhyUse: React.FC<
- {t("navigation.whyUseMaple")}
+ {t("navigation.inTheNews")}
)
diff --git a/components/Newsfeed/Newsfeed.tsx b/components/Newsfeed/Newsfeed.tsx
index 469b58e58..4bca6718f 100644
--- a/components/Newsfeed/Newsfeed.tsx
+++ b/components/Newsfeed/Newsfeed.tsx
@@ -1,4 +1,5 @@
import ErrorPage from "next/error"
+import { flags } from "../featureFlags"
import { Timestamp } from "firebase/firestore"
import { useTranslation } from "next-i18next"
import { useEffect, useState } from "react"
@@ -28,6 +29,8 @@ export default function Newsfeed() {
const [isShowingOrgs, setIsShowingOrgs] = useState(true)
const [isShowingBills, setIsShowingBills] = useState(true)
+ const [isShowingBallotQuestions, setIsShowingBallotQuestions] =
+ useState(true)
const [allResults, setAllResults] = useState([])
const [filteredResults, setFilteredResults] = useState([])
@@ -36,11 +39,13 @@ export default function Newsfeed() {
const results = allResults.filter(result => {
if (isShowingOrgs && result.type == `testimony`) return true
if (isShowingBills && result.type == `bill`) return true
+ if (isShowingBallotQuestions && result.type == `ballotQuestion`)
+ return true
return false
})
setFilteredResults(results)
- }, [isShowingOrgs, isShowingBills, allResults])
+ }, [isShowingOrgs, isShowingBills, isShowingBallotQuestions, allResults])
const onOrgFilterChange = (isShowing: boolean) => {
setIsShowingOrgs(isShowing)
@@ -50,6 +55,10 @@ export default function Newsfeed() {
setIsShowingBills(isShowing)
}
+ const onBallotQuestionFilterChange = (isShowing: boolean) => {
+ setIsShowingBallotQuestions(isShowing)
+ }
+
useEffect(() => {
const fetchNotifications = async () => {
try {
@@ -74,8 +83,12 @@ export default function Newsfeed() {
onBillFilterChange={(isShowing: boolean) => {
onBillFilterChange(isShowing)
}}
+ onBallotQuestionFilterChange={(isShowing: boolean) => {
+ onBallotQuestionFilterChange(isShowing)
+ }}
isShowingOrgs={isShowingOrgs}
isShowingBills={isShowingBills}
+ isShowingBallotQuestions={isShowingBallotQuestions}
profile={profile}
/>
)
@@ -84,14 +97,18 @@ export default function Newsfeed() {
function FilterBoxes({
isShowingBills,
isShowingOrgs,
+ isShowingBallotQuestions,
onBillFilterChange,
onOrgFilterChange,
+ onBallotQuestionFilterChange,
profile
}: {
isShowingBills: boolean
isShowingOrgs: boolean
+ isShowingBallotQuestions: boolean
onBillFilterChange: any
onOrgFilterChange: any
+ onBallotQuestionFilterChange: any
profile: Profile
}) {
const { t } = useTranslation("common")
@@ -129,6 +146,22 @@ export default function Newsfeed() {
{t("bill_updates")}
+ {flags().ballotQuestions && (
+
+ {
+ onBallotQuestionFilterChange(event.target.checked)
+ }}
+ checked={isShowingBallotQuestions}
+ />
+
+ Ballot Question Updates
+
+
+ )}
>
@@ -222,12 +255,17 @@ export default function Newsfeed() {
header={element.header}
isBillMatch={element.isBillMatch}
isUserMatch={element.isUserMatch}
+ isBallotQuestionMatch={
+ element.isBallotQuestionMatch
+ }
position={element.position}
subheader={element.subheader}
timestamp={element.timestamp}
testimonyId={element.testimonyId}
type={element.type}
userRole={element.userRole}
+ ballotQuestionId={element.ballotQuestionId}
+ ballotStatus={element.ballotStatus}
isNewsfeed={"enable newsfeed specific subheading"}
/>
diff --git a/components/Newsfeed/NotificationProps.tsx b/components/Newsfeed/NotificationProps.tsx
index d071eb4d8..e0a5577ec 100644
--- a/components/Newsfeed/NotificationProps.tsx
+++ b/components/Newsfeed/NotificationProps.tsx
@@ -7,10 +7,11 @@ export type NotificationProps = {
court: string
createdAt: Timestamp
delivered: boolean
- header: string // Bill Title
+ header: string // Bill Title or BQ description
id: number
isBillMatch: boolean // Is subscribed to Bill
isUserMatch: boolean // is subscribed to User/Org
+ isBallotQuestionMatch?: boolean
position: string
subheader: string
testimonyId: string
@@ -18,6 +19,10 @@ export type NotificationProps = {
topicName: string
type: string
userRole: string
+ // Ballot question fields
+ ballotQuestionId?: string
+ ballotQuestionCourt?: number
+ ballotStatus?: string
}
export type Notifications = NotificationProps[]
diff --git a/components/NewsfeedCard/NewsfeedCard.test.tsx b/components/NewsfeedCard/NewsfeedCard.test.tsx
new file mode 100644
index 000000000..c53d727e7
--- /dev/null
+++ b/components/NewsfeedCard/NewsfeedCard.test.tsx
@@ -0,0 +1,51 @@
+import "@testing-library/jest-dom"
+import { Timestamp } from "firebase/firestore"
+import { render, screen } from "@testing-library/react"
+import type { ReactNode } from "react"
+import { NewsfeedCard } from "./NewsfeedCard"
+
+jest.mock("next-i18next", () => ({
+ useTranslation: () => ({ t: (key: string) => key })
+}))
+
+jest.mock("components/links", () => ({
+ Internal: ({
+ href,
+ children,
+ className
+ }: {
+ href: string
+ children: ReactNode
+ className?: string
+ }) => (
+
+ {children}
+
+ )
+}))
+
+describe("NewsfeedCard", () => {
+ it("renders ballot-question updates with the follow reason and destination link", () => {
+ render(
+
+ )
+
+ expect(screen.getByText("newsfeed.follow")).toBeInTheDocument()
+ expect(
+ screen.getAllByText("Question 1: Should we do the thing?")
+ ).toHaveLength(2)
+ expect(screen.getByText(/Status updated to:/)).toBeInTheDocument()
+ expect(screen.getByText("certified")).toBeInTheDocument()
+ expect(
+ screen.getByRole("link", { name: "View ballot question" })
+ ).toHaveAttribute("href", "/ballotQuestions/25-14")
+ })
+})
diff --git a/components/NewsfeedCard/NewsfeedCard.tsx b/components/NewsfeedCard/NewsfeedCard.tsx
index 1c07e91fb..48bcdd80c 100644
--- a/components/NewsfeedCard/NewsfeedCard.tsx
+++ b/components/NewsfeedCard/NewsfeedCard.tsx
@@ -1,7 +1,9 @@
import styled from "styled-components"
import { Timestamp } from "firebase/firestore"
import { Card as MapleCard } from "../Card/Card"
+import { flags } from "../featureFlags"
import {
+ NewsfeedBallotQuestionCardBody,
NewsfeedBillCardBody,
NewsfeedTestimonyCardBody
} from "./NewsfeedCardBody"
@@ -13,10 +15,13 @@ const Container = styled.div`
export const NewsfeedCard = (props: {
authorUid?: string
+ ballotQuestionId?: string
+ ballotStatus?: string
billId?: string
bodyText: string
court?: string
header: string
+ isBallotQuestionMatch?: boolean
isBillMatch?: boolean
isUserMatch?: boolean
position?: string
@@ -35,6 +40,7 @@ export const NewsfeedCard = (props: {
billId={props.billId}
court={props.court}
header={props.header}
+ isBallotQuestionMatch={props.isBallotQuestionMatch}
isBillMatch={props.isBillMatch}
isUserMatch={props.isUserMatch}
subheader={props.subheader}
@@ -64,6 +70,15 @@ export const NewsfeedCard = (props: {
type={props.type}
/>
)
+ } else if (props.type == `ballotQuestion` && flags().ballotQuestions) {
+ body = (
+
+ )
}
return (
diff --git a/components/NewsfeedCard/NewsfeedCardBody.tsx b/components/NewsfeedCard/NewsfeedCardBody.tsx
index 6c36e588b..f29188320 100644
--- a/components/NewsfeedCard/NewsfeedCardBody.tsx
+++ b/components/NewsfeedCard/NewsfeedCardBody.tsx
@@ -5,6 +5,41 @@ import { Col, Row } from "../bootstrap"
import { Internal } from "components/links"
import { truncateText } from "components/formatting"
+export const NewsfeedBallotQuestionCardBody = ({
+ ballotQuestionId,
+ ballotStatus,
+ header,
+ timestamp
+}: {
+ ballotQuestionId?: string
+ ballotStatus?: string
+ header?: string
+ timestamp?: string
+}) => {
+ const { t } = useTranslation("common")
+ return (
+
+ {ballotStatus && (
+
+ Status updated to: {ballotStatus}
+
+ )}
+
+ {t("newsfeed.actionTaken")}
+ {timestamp}
+
+ {ballotQuestionId && (
+
+ View ballot question
+
+ )}
+
+ )
+}
+
interface NewsfeedCardBodyProps {
billText?: string
position?: string
diff --git a/components/NewsfeedCard/NewsfeedCardTitle.tsx b/components/NewsfeedCard/NewsfeedCardTitle.tsx
index 69891a9d1..f1fbe86c2 100644
--- a/components/NewsfeedCard/NewsfeedCardTitle.tsx
+++ b/components/NewsfeedCard/NewsfeedCardTitle.tsx
@@ -13,6 +13,7 @@ interface NewsfeedCardTitleProps {
timestamp?: string
imgSrc?: string
inHeaderElement?: ReactElement
+ isBallotQuestionMatch?: boolean
isBillMatch?: boolean
isUserMatch?: boolean
type?: string
@@ -27,6 +28,7 @@ export const NewsfeedCardTitle = (props: NewsfeedCardTitleProps) => {
court,
header,
isBillMatch,
+ isBallotQuestionMatch,
isUserMatch,
subheader,
type,
@@ -50,6 +52,7 @@ export const NewsfeedCardTitle = (props: NewsfeedCardTitleProps) => {
{
}
const CardTitleFollowing = (props: NewsfeedCardTitleProps) => {
- const { billId, header, isBillMatch, isUserMatch, subheader, type } = props
+ const {
+ billId,
+ header,
+ isBallotQuestionMatch,
+ isBillMatch,
+ isUserMatch,
+ subheader,
+ type
+ } = props
const { t } = useTranslation("common")
if (type == ``) {
return <>>
+ } else if (type === `ballotQuestion`) {
+ return (
+ <>
+ {header && (
+
+ {isBallotQuestionMatch ? (
+ <>{t("newsfeed.follow")}>
+ ) : (
+ <>{t("newsfeed.notFollow")}>
+ )}
+ {header}
+
+ )}
+ >
+ )
} else if (type === `bill`) {
return (
<>
diff --git a/components/TestimonyCard/BillInfoHeader.tsx b/components/TestimonyCard/BillInfoHeader.tsx
index 29c092811..af0adea17 100644
--- a/components/TestimonyCard/BillInfoHeader.tsx
+++ b/components/TestimonyCard/BillInfoHeader.tsx
@@ -4,6 +4,10 @@ import { Testimony } from "components/db"
import { formatBillId } from "components/formatting"
import { PositionLabel } from "./PositionBug"
+function formatBallotQuestionLabel(ballotQuestionId: string) {
+ return `Ballot Question ${ballotQuestionId}`
+}
+
export const BillInfoHeader = ({
testimony,
billLink,
@@ -13,13 +17,26 @@ export const BillInfoHeader = ({
billLink: string
publishedDate: string
}) => {
+ const ballotQuestionId = testimony.ballotQuestionId ?? undefined
+ const policyLink = ballotQuestionId
+ ? `/ballotQuestions/${ballotQuestionId}`
+ : billLink
+ const policyLabel = ballotQuestionId
+ ? formatBallotQuestionLabel(ballotQuestionId)
+ : formatBillId(testimony.billId)
+ const policyTitle =
+ testimony.billTitle ||
+ (ballotQuestionId
+ ? formatBallotQuestionLabel(ballotQuestionId)
+ : "Bill Title")
+
return (
<>
-
- {formatBillId(testimony.billId)}
+
+ {policyLabel}
@@ -29,10 +46,7 @@ export const BillInfoHeader = ({
-
- {" "}
- {testimony.billTitle ? testimony.billTitle : "Bill Title"}
-
+ {policyTitle}
{publishedDate}
diff --git a/components/TestimonyCard/SortTestimonyDropDown.tsx b/components/TestimonyCard/SortTestimonyDropDown.tsx
index 9ab7a68ee..6c75448b6 100644
--- a/components/TestimonyCard/SortTestimonyDropDown.tsx
+++ b/components/TestimonyCard/SortTestimonyDropDown.tsx
@@ -1,18 +1,23 @@
-import { t } from "i18next"
import { Form } from "../bootstrap"
import { useTranslation } from "next-i18next"
export const SortTestimonyDropDown = ({
orderBy,
- setOrderBy
+ setOrderBy,
+ variant = "default"
}: {
orderBy?: string
setOrderBy: (order: string) => void
+ variant?: "default" | "ballotQuestion"
}) => {
const { t } = useTranslation("testimony")
return (
setOrderBy(e.target.value)}
>
diff --git a/components/TestimonyCard/Tabs.tsx b/components/TestimonyCard/Tabs.tsx
index 29d3de1b5..490cae9a1 100644
--- a/components/TestimonyCard/Tabs.tsx
+++ b/components/TestimonyCard/Tabs.tsx
@@ -35,10 +35,11 @@ export const Tab = (props: {
active: boolean
action?: () => void
onClick?: MouseEventHandler
+ variant?: "default" | "ballotQuestion"
}) => {
- const { label, onClick, active } = props
+ const { label, onClick, active, variant = "default" } = props
return (
-
+
{label}
)
@@ -48,13 +49,14 @@ export const Tabs = (props: {
childTabs: ReactElement[]
onChange: onClickEventFunction
selectedTab: number
+ variant?: "default" | "ballotQuestion"
}) => {
const containerRef = useRef(null)
const tabRefs = useRef>([])
- const { childTabs, onChange, selectedTab } = props
+ const { childTabs, onChange, selectedTab, variant = "default" } = props
const [sliderWidth, setSliderWidth] = useState(0)
const [viewportWidth, setViewportWidth] = useState(
- typeof window ? undefined : window.innerWidth
+ typeof window === "undefined" ? undefined : window.innerWidth
)
const [resizing, setResizing] = useState(false)
const [sliderPos, setSliderPos] = useState(0)
@@ -77,9 +79,11 @@ export const Tabs = (props: {
const selected = tabRefs.current[selectedTab - 1]
if (selected) {
setSliderWidth(selected.clientWidth)
- setSliderPos(selected.offsetLeft - 16)
+ setSliderPos(
+ selected.offsetLeft - (variant === "ballotQuestion" ? 0 : 16)
+ )
}
- }, [selectedTab, viewportWidth])
+ }, [selectedTab, viewportWidth, variant])
const tabs = childTabs?.map((child, index) => {
const handleClick = (e: Event) => {
@@ -92,17 +96,18 @@ export const Tabs = (props: {
(tabRefs.current[index] = el)} key={child.props.label}>
{React.cloneElement(child, {
active: child.props.value === selectedTab,
- onClick: handleClick
+ onClick: handleClick,
+ variant
})}
)
})
return (
-
- {tabs}
+
+ {tabs}
-
+
`
+ margin-bottom: ${props =>
+ props.variant === "ballotQuestion" ? "1.25rem" : "2%"};
+ position: relative;
`
-const TabsContainer = styled.div`
+const TabsContainer = styled.div<{ variant: "default" | "ballotQuestion" }>`
display: flex;
flex-direction: row;
- justify-content: space-around;
- margin: 0;
- margin-top: 15px;
+ gap: ${props => (props.variant === "ballotQuestion" ? "2rem" : "0")};
+ justify-content: ${props =>
+ props.variant === "ballotQuestion" ? "flex-start" : "space-around"};
+ margin: ${props =>
+ props.variant === "ballotQuestion" ? "0 0 0.75rem" : "0"};
+ margin-top: ${props => (props.variant === "ballotQuestion" ? "0" : "15px")};
+ overflow-x: ${props =>
+ props.variant === "ballotQuestion" ? "auto" : "visible"};
`
-const TabStyle = styled.div<{ active: boolean }>`
+const TabStyle = styled.div<{
+ active: boolean
+ variant: "default" | "ballotQuestion"
+}>`
background: none;
- color: ${props => (props.active ? "#C71E32" : "black")};
+ color: ${props =>
+ props.variant === "ballotQuestion"
+ ? props.active
+ ? "#c71e32"
+ : "#212529"
+ : props.active
+ ? "#C71E32"
+ : "black"};
border: none;
font: inherit;
- font-size: 1.5rem;
+ font-size: ${props =>
+ props.variant === "ballotQuestion" ? "0.95rem" : "1.5rem"};
+ font-weight: ${props =>
+ props.variant === "ballotQuestion" ? (props.active ? 700 : 500) : 400};
cursor: pointer;
outline: none;
+ white-space: ${props =>
+ props.variant === "ballotQuestion" ? "nowrap" : "normal"};
@media (max-width: 768px) {
- font-size: 1rem;
+ font-size: ${props =>
+ props.variant === "ballotQuestion" ? "0.95rem" : "1rem"};
+ }
+
+ p {
+ margin: ${props => (props.variant === "ballotQuestion" ? "0" : "initial")};
}
`
const TabSliderContainerStyle = styled.div`
@@ -144,17 +178,20 @@ const TabSliderContainerStyle = styled.div`
height: 1px;
position: absolute;
`
-const TabSliderTrackStyle = styled.div`
+const TabSliderTrackStyle = styled.div<{
+ variant: "default" | "ballotQuestion"
+}>`
background-color: #f1f1f1;
align-self: center;
- width: 75%;
+ width: ${props => (props.variant === "ballotQuestion" ? "100%" : "75%")};
height: 1px;
margin: 0;
padding: 0;
position: absolute;
z-index: 9;
- left: 50%;
- transform: translateX(-55%);
+ left: ${props => (props.variant === "ballotQuestion" ? "0" : "50%")};
+ transform: ${props =>
+ props.variant === "ballotQuestion" ? "none" : "translateX(-55%)"};
`
export const TabSliderStyle = styled.div<{
diff --git a/components/TestimonyCard/TestimonyItem.tsx b/components/TestimonyCard/TestimonyItem.tsx
index c619bb03b..96c1e6f38 100644
--- a/components/TestimonyCard/TestimonyItem.tsx
+++ b/components/TestimonyCard/TestimonyItem.tsx
@@ -44,11 +44,15 @@ const FooterButton = ({
export const TestimonyItem = ({
testimony,
isUser,
- onProfilePage
+ onProfilePage,
+ variant = "default",
+ allowEdit = true
}: {
testimony: Testimony
isUser: boolean
onProfilePage: boolean
+ variant?: "default" | "ballotQuestion"
+ allowEdit?: boolean
}) => {
const isMobile = useMediaQuery("(max-width: 768px)")
const publishedDate = testimony.publishedAt
@@ -64,9 +68,16 @@ export const TestimonyItem = ({
const reportMutation = useReportTestimony()
const didReport = reportMutation.isError || reportMutation.isSuccess
+ const { t } = useTranslation("testimony")
+ const isBallotQuestion = variant === "ballotQuestion"
+ const editLabel = isBallotQuestion
+ ? t("ballotQuestion.testimonyItem.edit")
+ : t("testimonyItem.edit")
const testimonyContent =
testimony.content ??
- "This draft has no content. Click Edit to add your testimony."
+ (isBallotQuestion
+ ? t("ballotQuestion.testimonyItem.emptyDraft")
+ : t("testimonyItem.emptyDraft"))
const snippetChars = 500
const [showAllTestimony, setShowAllTestimony] = useState(false)
@@ -75,15 +86,8 @@ export const TestimonyItem = ({
: trimContent(testimonyContent.slice(0, snippetChars), snippetChars)
const canExpand = snippet.length !== testimonyContent.length
- const { t } = useTranslation("testimony")
-
const IconSpacer = () => {
- /* this image does not appear to display anything, *
- * however it acts as a spacing element *
- * *
- * removing this image will throw off the alignment vs *
- * the nearby elements that contain visible icons */
-
+ if (isBallotQuestion) return null
return (
+
- {isMobile && isUser && (
+ {isMobile && isUser && allowEdit && (
<>
@@ -115,7 +130,7 @@ export const TestimonyItem = ({
>
)}
-
+
{onProfilePage ? (
)}
-
-
-
-
+
+
+
+
{canExpand && (
-
- {/* Current bug Issue #1564 makes this instance of IconSpacer hard to test *
- * Please revisit once #1564 is resolved */}
@@ -181,18 +193,23 @@ export const TestimonyItem = ({
- {isUser && !isMobile && (
+ {isUser && allowEdit && !isMobile && (
- {t("testimonyItem.edit")}
+ {editLabel}
diff --git a/components/TestimonyCard/ViewTestimony.test.tsx b/components/TestimonyCard/ViewTestimony.test.tsx
new file mode 100644
index 000000000..18457addf
--- /dev/null
+++ b/components/TestimonyCard/ViewTestimony.test.tsx
@@ -0,0 +1,50 @@
+import "@testing-library/jest-dom"
+import { render, screen } from "@testing-library/react"
+import ViewTestimony from "./ViewTestimony"
+
+jest.mock("next-i18next", () => ({
+ useTranslation: () => ({
+ t: (key: string) =>
+ ((
+ {
+ "ballotQuestion.viewTestimony.allPerspectives": "All Perspectives",
+ "ballotQuestion.viewTestimony.browsePerspectives":
+ "Browse Perspectives",
+ "ballotQuestion.viewTestimony.noPerspectives":
+ "There are no perspectives"
+ } as Record
+ )[key] ?? key)
+ })
+}))
+
+jest.mock("../auth", () => ({
+ useAuth: () => ({ user: null })
+}))
+
+const emptyListing = {
+ items: { result: [] },
+ setFilter: jest.fn(),
+ pagination: {
+ currentPage: 1,
+ itemsPerPage: 10,
+ hasPreviousPage: false,
+ hasNextPage: false
+ }
+} as any
+
+describe("ViewTestimony", () => {
+ it("keeps testimony copy for default testimony lists", () => {
+ render( )
+
+ expect(screen.getByText("All Testimonies")).toBeInTheDocument()
+ expect(screen.queryByText("All Perspectives")).not.toBeInTheDocument()
+ })
+
+ it("uses perspective copy for ballot-question testimony lists", () => {
+ render( )
+
+ expect(screen.getByText("All Perspectives")).toBeInTheDocument()
+ expect(screen.getByText("Browse Perspectives")).toBeInTheDocument()
+ expect(screen.getByText("There are no perspectives")).toBeInTheDocument()
+ })
+})
diff --git a/components/TestimonyCard/ViewTestimony.tsx b/components/TestimonyCard/ViewTestimony.tsx
index a16f6ca2a..f84b68dfa 100644
--- a/components/TestimonyCard/ViewTestimony.tsx
+++ b/components/TestimonyCard/ViewTestimony.tsx
@@ -24,6 +24,8 @@ const ViewTestimony = (
onProfilePage?: boolean
className?: string
isOrg?: boolean
+ variant?: "default" | "ballotQuestion"
+ allowEdit?: boolean
}
) => {
const {
@@ -33,10 +35,14 @@ const ViewTestimony = (
onProfilePage = false,
className,
pagination,
- isOrg
+ isOrg,
+ variant = "default",
+ allowEdit = true
} = props
const { user } = useAuth()
+ const { t } = useTranslation("testimony")
+ const isBallotQuestion = variant === "ballotQuestion"
const testimony = items.result ?? []
const [orderBy, setOrderBy] = useState()
const [activeTab, setActiveTab] = useState(1)
@@ -60,7 +66,11 @@ const ViewTestimony = (
const tabs = [
handleFilter("")}
@@ -81,7 +91,96 @@ const ViewTestimony = (
/>
]
- const { t } = useTranslation("testimony")
+ const listContent =
+ testimony.length > 0 ? (
+
+ {onProfilePage && (
+
+
+
+
+
+
+
+ )}
+
+
+ {testimony
+ .sort((a, b) =>
+ orderBy === "Oldest First"
+ ? a.publishedAt > b.publishedAt
+ ? 1
+ : -1
+ : a.publishedAt < b.publishedAt
+ ? 1
+ : -1
+ )
+ .map(t => (
+
+ ))}
+
+
+ {(pagination.hasPreviousPage || pagination.hasNextPage) && (
+
+ )}
+
+ ) : (
+
+ {t(
+ isBallotQuestion
+ ? "ballotQuestion.viewTestimony.noPerspectives"
+ : "viewTestimony.noTestimonies"
+ )}
+
+
+ )
+
+ if (variant === "ballotQuestion") {
+ return (
+
+ {!onProfilePage && (
+ <>
+
+
+
+
+ {t("ballotQuestion.viewTestimony.browsePerspectives")}
+
+
+
+
+
+
+ >
+ )}
+ {listContent}
+
+ )
+ }
return (
)}
-
- {testimony.length > 0 ? (
-
- {onProfilePage && (
-
-
-
-
-
-
-
- )}
-
- {testimony
- .sort((a, b) =>
- orderBy === "Oldest First"
- ? a.publishedAt > b.publishedAt
- ? 1
- : -1
- : a.publishedAt < b.publishedAt
- ? 1
- : -1
- )
- .map(t => (
-
- ))}
-
- {(pagination.hasPreviousPage || pagination.hasNextPage) && (
-
- )}
-
- ) : (
-
- {t("viewTestimony.noTestimonies")}
-
-
- )}
+ {listContent}
}
/>
@@ -156,6 +206,27 @@ const ViewTestimony = (
export default ViewTestimony
+const FeedShell = styled.div`
+ border: 1px solid #dee2e6;
+ border-radius: 0.75rem;
+ padding: 1.5rem;
+ box-shadow: 0 0.125rem 0.5rem rgba(15, 23, 42, 0.06);
+`
+
+const ControlsRow = styled(Row)`
+ row-gap: 0.75rem;
+`
+
+const BrowseTitle = styled.div`
+ font-size: 0.95rem;
+ font-weight: 700;
+`
+
+const FeedList = styled.div`
+ display: grid;
+ gap: 0;
+`
+
function ShowPaginationSummary({
totalTestimonies,
testimony,
diff --git a/components/ViewAttachment.tsx b/components/ViewAttachment.tsx
index 7d2897b47..423fadf4f 100644
--- a/components/ViewAttachment.tsx
+++ b/components/ViewAttachment.tsx
@@ -8,17 +8,25 @@ const StyledExternal = styled(External)`
`
export function ViewAttachment({ testimony }: { testimony?: Testimony }) {
- {
- const id = testimony?.attachmentId
- return id ? : null
- }
+ const id = testimony?.attachmentId
+ return id ? (
+
+ ) : null
}
-const Open = ({ id }: { id: string }) => {
+const Open = ({
+ id,
+ isBallotQuestion
+}: {
+ id: string
+ isBallotQuestion: boolean
+}) => {
const { t } = useTranslation("testimony")
const url = usePublishedTestimonyAttachment(id)
return url ? (
- {t("viewAttached")}
+
+ {t(isBallotQuestion ? "ballotQuestion.viewAttached" : "viewAttached")}
+
) : null
}
diff --git a/components/auth/SignInWithButton.tsx b/components/auth/SignInWithButton.tsx
index cdb0d008a..c244bef94 100644
--- a/components/auth/SignInWithButton.tsx
+++ b/components/auth/SignInWithButton.tsx
@@ -6,41 +6,50 @@ import { useTranslation } from "next-i18next"
interface Props {
label?: string
className?: string
+ buttonClassName?: string
}
-export default function SignInWithButton({ className }: Props) {
+export default function SignInWithButton({
+ label,
+ className,
+ buttonClassName
+}: Props) {
const { t } = useTranslation("auth")
const dispatch = useAppDispatch()
const setCurrentModal = (step: AuthFlowStep) =>
dispatch(authStepChanged(step))
return (
-
+
setCurrentModal("start")}
>
- {t("logInSignUp")}
+ {label ?? t("logInSignUp")}
)
}
-export function AltSignInWithButton({ className }: Props) {
+export function AltSignInWithButton({
+ label,
+ className,
+ buttonClassName
+}: Props) {
const { t } = useTranslation("auth")
const dispatch = useAppDispatch()
const setCurrentModal = (step: AuthFlowStep) =>
dispatch(authStepChanged(step))
return (
-
+
setCurrentModal("start")}
>
- {t("altLogInSignUp")}
+ {label ?? t("altLogInSignUp")}
)
diff --git a/components/ballotquestions/BallotQuestionDetails.test.tsx b/components/ballotquestions/BallotQuestionDetails.test.tsx
new file mode 100644
index 000000000..21a47c878
--- /dev/null
+++ b/components/ballotquestions/BallotQuestionDetails.test.tsx
@@ -0,0 +1,48 @@
+import "@testing-library/jest-dom"
+import { render, screen } from "@testing-library/react"
+import { BallotQuestionDetails } from "./BallotQuestionDetails"
+
+jest.mock("../db", () => ({
+ usePublishedTestimonyListing: () => ({
+ items: { result: [] },
+ pagination: {}
+ })
+}))
+
+jest.mock("./BallotQuestionHeader", () => ({
+ BallotQuestionHeader: () => Header
+}))
+
+jest.mock("./BallotQuestionNav", () => ({
+ BallotQuestionNav: ({ testimonyCount }: { testimonyCount?: number }) => (
+ Nav {testimonyCount}
+ )
+}))
+
+jest.mock("./OverviewTab", () => ({
+ OverviewTab: () => Overview
+}))
+
+jest.mock("./TestimoniesTab", () => ({
+ TestimoniesTab: () => Testimonies
+}))
+
+describe("BallotQuestionDetails", () => {
+ it("passes the testimony count through to the ballot-question nav", () => {
+ render(
+
+ )
+
+ expect(screen.getByText("Nav 3")).toBeInTheDocument()
+ })
+})
diff --git a/components/ballotquestions/BallotQuestionDetails.tsx b/components/ballotquestions/BallotQuestionDetails.tsx
new file mode 100644
index 000000000..3945c1880
--- /dev/null
+++ b/components/ballotquestions/BallotQuestionDetails.tsx
@@ -0,0 +1,81 @@
+import { useState } from "react"
+import { Container, Row, Col } from "react-bootstrap"
+import { BallotQuestion, Bill, usePublishedTestimonyListing } from "../db"
+import { BallotQuestionHeader } from "./BallotQuestionHeader"
+import { BallotQuestionNav } from "./BallotQuestionNav"
+import { OverviewTab } from "./OverviewTab"
+import { TestimoniesTab } from "./TestimoniesTab"
+import {
+ BallotQuestionTab,
+ BallotQuestionTestimonySummary,
+ Hearing,
+ getBallotQuestionPanelId,
+ getBallotQuestionTabId
+} from "./types"
+
+export const BallotQuestionDetails = ({
+ ballotQuestion,
+ bill,
+ hearings,
+ testimonySummary
+}: {
+ ballotQuestion: BallotQuestion
+ bill: Bill | null
+ hearings: Hearing[]
+ testimonySummary: BallotQuestionTestimonySummary
+}) => {
+ const [activeTab, setActiveTab] = useState("overview")
+ const testimony = usePublishedTestimonyListing({
+ ballotQuestionId: ballotQuestion.id
+ })
+
+ const renderContent = () => {
+ switch (activeTab) {
+ case "overview":
+ return (
+
+ )
+ case "testimonies":
+ return (
+
+ )
+ default:
+ return null
+ }
+ }
+
+ return (
+ <>
+
+
+
+
+
+
+
+
+ {renderContent()}
+
+
+
+
+ >
+ )
+}
diff --git a/components/ballotquestions/BallotQuestionHeader.tsx b/components/ballotquestions/BallotQuestionHeader.tsx
new file mode 100644
index 000000000..bddafedee
--- /dev/null
+++ b/components/ballotquestions/BallotQuestionHeader.tsx
@@ -0,0 +1,218 @@
+import Link from "next/link"
+import { Container, Row, Col } from "react-bootstrap"
+import { useAuth } from "../auth"
+import { BallotQuestion, Bill } from "../db"
+import { useFlags } from "../featureFlags"
+import { FollowBallotQuestionButton } from "../shared/FollowButton"
+import { QuestionTooltip } from "../tooltip"
+import { DescriptionBox } from "./DescriptionBox"
+import { getBallotQuestionStatusLabel } from "./status"
+import { YourTestimonyPanel } from "./YourTestimonyPanel"
+
+export const BallotQuestionHeader = ({
+ ballotQuestion,
+ bill
+}: {
+ ballotQuestion: BallotQuestion
+ bill: Bill | null
+}) => {
+ const { notifications } = useFlags()
+ const { user } = useAuth()
+ const statusLabel = getBallotQuestionStatusLabel(ballotQuestion.ballotStatus)
+ const questionNumberDisclaimer =
+ "Question numbers are assigned by the Secretary of State in the summer prior to an election"
+ const description = ballotQuestion.description
+ const hasDescription = Boolean(description)
+
+ const getTypeLabel = () => {
+ switch (ballotQuestion.type) {
+ case "initiative_statute":
+ return "Law"
+ case "initiative_constitutional":
+ return "Constitutional Initiative"
+ case "legislative_referral":
+ return "Legislative Referral"
+ case "constitutional_amendment":
+ return "Constitutional Amendment"
+ case "advisory":
+ return "Advisory Question"
+ default:
+ return "Ballot Question"
+ }
+ }
+
+ return (
+
+
+
+
+
+ ←
+ Back to ballot questions
+
+
+
+
+
+ {statusLabel}
+
+
+
+
+ Question{" "}
+ {ballotQuestion.ballotQuestionNumber != null ? (
+ ballotQuestion.ballotQuestionNumber
+ ) : (
+ <>
+ #
+
+ >
+ )}
+
+
+
+ {ballotQuestion.title || bill?.content.Title || ballotQuestion.id}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Take part
+
+
+ {notifications && user && (
+
+ )}
+
+
+
+
+
+
+ {(hasDescription || ballotQuestion.pdfUrl) && (
+
+ )}
+
+
+ )
+}
+
+function MetaFact({ label, value }: { label: string; value: string }) {
+ return (
+
+
+ {label}
+
+
+ {value}
+
+
+ )
+}
diff --git a/components/ballotquestions/BallotQuestionNav.tsx b/components/ballotquestions/BallotQuestionNav.tsx
new file mode 100644
index 000000000..f273843a8
--- /dev/null
+++ b/components/ballotquestions/BallotQuestionNav.tsx
@@ -0,0 +1,120 @@
+import { KeyboardEvent, useRef } from "react"
+import { BallotQuestionTab } from "./types"
+import {
+ BallotQuestionNavItem,
+ BallotQuestionTabButton
+} from "./BallotQuestionTabButton"
+
+export const BallotQuestionNav = ({
+ activeTab,
+ onTabChange,
+ testimonyCount
+}: {
+ activeTab: BallotQuestionTab | string
+ onTabChange: (tab: BallotQuestionTab) => void
+ testimonyCount?: number
+}) => {
+ const tabRefs = useRef>([])
+ const navItems: Array = [
+ { id: "overview", label: "Overview", enabled: true },
+ {
+ id: "testimonies",
+ label: "Perspectives",
+ enabled: true,
+ badge: testimonyCount
+ },
+ { id: "synthesis", label: "Synthesis & Insights", enabled: false },
+ { id: "for_against", label: "For & Against", enabled: false },
+ { id: "news", label: "News & Media", enabled: false },
+ { id: "academia", label: "Academia", enabled: false },
+ { id: "financials", label: "Campaign Financials", enabled: false },
+ { id: "map", label: "Map", enabled: false }
+ ]
+ const visibleItems = navItems.filter(item => item.enabled)
+
+ const moveFocus = (index: number) => {
+ const nextItem = visibleItems[index]
+ if (!nextItem) return
+ onTabChange(nextItem.id)
+ tabRefs.current[index]?.focus()
+ }
+
+ const handleKeyDown = (
+ event: KeyboardEvent,
+ currentIndex: number
+ ) => {
+ switch (event.key) {
+ case "ArrowDown":
+ case "ArrowRight":
+ event.preventDefault()
+ moveFocus((currentIndex + 1) % visibleItems.length)
+ break
+ case "ArrowUp":
+ case "ArrowLeft":
+ event.preventDefault()
+ moveFocus(
+ (currentIndex - 1 + visibleItems.length) % visibleItems.length
+ )
+ break
+ case "Home":
+ event.preventDefault()
+ moveFocus(0)
+ break
+ case "End":
+ event.preventDefault()
+ moveFocus(visibleItems.length - 1)
+ break
+ default:
+ break
+ }
+ }
+
+ return (
+
+
+
+ Explore
+
+
+ Move between the question overview and public perspectives.
+
+
+
+
+ {visibleItems.map((item, itemIndex) => {
+ const isActive = activeTab === item.id
+ return (
+
+ {
+ tabRefs.current[itemIndex] = element
+ }}
+ item={item}
+ isActive={isActive}
+ onSelect={onTabChange}
+ onKeyDown={event => handleKeyDown(event, itemIndex)}
+ />
+
+ )
+ })}
+
+
+ )
+}
diff --git a/components/ballotquestions/BallotQuestionTabButton.tsx b/components/ballotquestions/BallotQuestionTabButton.tsx
new file mode 100644
index 000000000..bca0ff285
--- /dev/null
+++ b/components/ballotquestions/BallotQuestionTabButton.tsx
@@ -0,0 +1,54 @@
+import { forwardRef, KeyboardEventHandler } from "react"
+import {
+ BallotQuestionTab,
+ getBallotQuestionPanelId,
+ getBallotQuestionTabId
+} from "./types"
+
+export type BallotQuestionNavItem = {
+ id: BallotQuestionTab
+ label: string
+ badge?: number
+}
+
+type BallotQuestionTabButtonProps = {
+ item: BallotQuestionNavItem
+ isActive: boolean
+ onSelect: (tab: BallotQuestionTab) => void
+ onKeyDown: KeyboardEventHandler
+}
+
+export const BallotQuestionTabButton = forwardRef<
+ HTMLButtonElement,
+ BallotQuestionTabButtonProps
+>(({ item, isActive, onSelect, onKeyDown }, ref) => {
+ return (
+ onSelect(item.id)}
+ onKeyDown={onKeyDown}
+ className={`ballot-question-nav-link rounded-3 px-3 py-3 d-flex align-items-center justify-content-between gap-3 small fw-medium h-100 w-100 text-start ${
+ isActive ? "is-active" : ""
+ }`}
+ >
+ {item.label}
+ {item.badge !== undefined && (
+
+ {item.badge}
+
+ )}
+
+ )
+})
+
+BallotQuestionTabButton.displayName = "BallotQuestionTabButton"
diff --git a/components/ballotquestions/BallotQuestionTabLinks.test.tsx b/components/ballotquestions/BallotQuestionTabLinks.test.tsx
new file mode 100644
index 000000000..45b0e33eb
--- /dev/null
+++ b/components/ballotquestions/BallotQuestionTabLinks.test.tsx
@@ -0,0 +1,161 @@
+import "@testing-library/jest-dom"
+import { fireEvent, render, screen } from "@testing-library/react"
+import { BallotQuestionNav } from "./BallotQuestionNav"
+import { OverviewTab } from "./OverviewTab"
+import { TestimoniesTab } from "./TestimoniesTab"
+
+jest.mock("../TestimonyCard/ViewTestimony", () => {
+ const MockViewTestimony = () =>
+
+ MockViewTestimony.displayName = "MockViewTestimony"
+
+ return MockViewTestimony
+})
+
+const ballotQuestion = {
+ id: "25-15",
+ ballotStatus: "expectedOnBallot",
+ ballotQuestionNumber: null,
+ atAGlance: null,
+ fullSummary: "Summary"
+} as any
+
+const ballotPhaseQuestion = {
+ ...ballotQuestion,
+ ballotStatus: "failedToAppear"
+} as any
+
+const bill = {
+ id: "H5005",
+ court: 194
+} as any
+
+describe("Ballot question tab links", () => {
+ it("does not show the corresponding bill link on the overview tab", () => {
+ render(
+
+ )
+
+ expect(screen.queryByText("View complete text")).not.toBeInTheDocument()
+ })
+
+ it("shows the bill submission link in expected-on-ballot phase", () => {
+ render(
+
+ )
+
+ const link = screen.getByRole("link", {
+ name: "related bill"
+ })
+
+ expect(link).toHaveAttribute("href", "/bills/194/H5005")
+ expect(
+ screen.getByText(/This question is expected on the ballot\./)
+ ).toBeInTheDocument()
+ expect(
+ screen.getByText(/You can review testimony on the related bill/)
+ ).toBeInTheDocument()
+ })
+
+ it("hides the bill submission note in terminal phases", () => {
+ render(
+
+ )
+
+ expect(screen.queryByRole("link", { name: "here" })).not.toBeInTheDocument()
+ expect(
+ screen.queryByText(/You can review testimony on the related bill/)
+ ).not.toBeInTheDocument()
+ expect(
+ screen.queryByText(/This question is expected on the ballot\./)
+ ).not.toBeInTheDocument()
+ })
+
+ it("hides unused ballot question tabs", () => {
+ render(
+
+ )
+
+ expect(screen.getByText("Overview")).toBeInTheDocument()
+ expect(screen.getByText("Perspectives")).toBeInTheDocument()
+ expect(screen.queryByText("Synthesis & Insights")).not.toBeInTheDocument()
+ expect(screen.queryByText("For & Against")).not.toBeInTheDocument()
+ expect(screen.queryByText("News & Media")).not.toBeInTheDocument()
+ expect(screen.queryByText("Academia")).not.toBeInTheDocument()
+ expect(screen.queryByText("Campaign Financials")).not.toBeInTheDocument()
+ expect(screen.queryByText("Map")).not.toBeInTheDocument()
+ })
+
+ it("exposes the ballot question navigation as tabs", () => {
+ render(
+
+ )
+
+ const tabs = screen.getAllByRole("tab")
+
+ expect(screen.getByRole("tablist")).toBeInTheDocument()
+ expect(tabs).toHaveLength(2)
+ expect(screen.getByRole("tab", { name: "Overview" })).toHaveAttribute(
+ "aria-selected",
+ "true"
+ )
+ expect(screen.getByRole("tab", { name: /Perspectives/ })).toHaveAttribute(
+ "aria-selected",
+ "false"
+ )
+ expect(screen.getByRole("tab", { name: "Overview" })).toHaveAttribute(
+ "aria-controls",
+ "ballot-question-panel-overview"
+ )
+ expect(screen.getByRole("tab", { name: /Perspectives/ })).toHaveAttribute(
+ "aria-controls",
+ "ballot-question-panel-testimonies"
+ )
+ })
+
+ it("supports arrow-key navigation between ballot question tabs", () => {
+ const onTabChange = jest.fn()
+
+ render(
+
+ )
+
+ fireEvent.keyDown(screen.getByRole("tab", { name: "Overview" }), {
+ key: "ArrowRight"
+ })
+
+ expect(onTabChange).toHaveBeenCalledWith("testimonies")
+ })
+})
diff --git a/components/ballotquestions/BrowseBallotQuestions.test.tsx b/components/ballotquestions/BrowseBallotQuestions.test.tsx
new file mode 100644
index 000000000..42f034812
--- /dev/null
+++ b/components/ballotquestions/BrowseBallotQuestions.test.tsx
@@ -0,0 +1,195 @@
+import "@testing-library/jest-dom"
+import { fireEvent, render, screen, waitFor } from "@testing-library/react"
+import type { ReactNode } from "react"
+import { BrowseBallotQuestions } from "./BrowseBallotQuestions"
+
+jest.mock("next-i18next", () => ({
+ useTranslation: () => ({
+ t: (key: string, params?: Record) => {
+ const messages: Record = {
+ ballot_question_search_label: "Search",
+ ballot_question_search_placeholder: "Search title or final summary",
+ ballot_question_filter_year: "Year",
+ ballot_question_filter_court: "Court",
+ ballot_question_filter_status: "Status",
+ ballot_question_all_years: "All years",
+ ballot_question_all_courts: "All courts",
+ ballot_question_all_statuses: "All statuses",
+ "ballot_question_status.expectedOnBallot": "Expected on ballot",
+ "ballot_question_status.qualifying": "Qualifying",
+ "ballot_question_status.certified": "Certified",
+ "ballot_question_status.failedToAppear": "Failed to appear on ballot",
+ "ballot_question_status.rejected": "Rejected",
+ "ballot_question_status.accepted": "Accepted",
+ ballot_question_results_summary: `Showing ${
+ params?.count ?? ""
+ } questions`,
+ ballot_question_reset_filters: "Reset filters",
+ ballot_question_no_results:
+ "No ballot questions match the current filters.",
+ ballot_question_document_id: `Document ID: ${params?.id ?? ""}`,
+ ballot_question_election_year: `Election ${params?.year ?? ""}`,
+ ballot_question_number: `Question ${params?.number ?? ""}`,
+ ballot_question_court: `Court ${params?.court ?? ""}`,
+ ballot_question_no_summary: "No summary available yet.",
+ "counts.endorsements.alt": "Endorsements",
+ "counts.neutral.alt": "Neutral",
+ "counts.oppose.alt": "Oppositions"
+ }
+ return messages[key] ?? key
+ }
+ })
+}))
+
+jest.mock("next/link", () => ({
+ __esModule: true,
+ default: ({
+ href,
+ children,
+ ...props
+ }: {
+ href: string
+ children: ReactNode
+ [key: string]: unknown
+ }) => (
+
+ {children}
+
+ )
+}))
+
+const items = [
+ {
+ id: "25-15",
+ title: "Nature for All",
+ fullSummary: "Conserves land and expands access to nature.",
+ electionYear: 2026,
+ court: 194,
+ ballotStatus: "rejected" as const,
+ ballotQuestionNumber: null,
+ endorseCount: 1,
+ neutralCount: 0,
+ opposeCount: 0
+ },
+ {
+ id: "25-22",
+ title: "Collective Bargaining",
+ fullSummary: "Public counsel labor rights question.",
+ electionYear: 2026,
+ court: 194,
+ ballotStatus: "expectedOnBallot" as const,
+ ballotQuestionNumber: 3,
+ endorseCount: 2,
+ neutralCount: 1,
+ opposeCount: 0
+ },
+ {
+ id: "23-12",
+ title: "Tipped Wage",
+ fullSummary: "Raises the tipped minimum wage over time.",
+ electionYear: 2024,
+ court: 193,
+ ballotStatus: "failedToAppear" as const,
+ ballotQuestionNumber: 1,
+ endorseCount: 3,
+ neutralCount: 0,
+ opposeCount: 1
+ }
+]
+
+const showFilters = () => {
+ fireEvent.click(screen.getByRole("button", { name: "Show filters" }))
+}
+
+describe("BrowseBallotQuestions", () => {
+ it("defaults to the current year and shows the result summary", () => {
+ render( )
+
+ showFilters()
+
+ expect(screen.getByRole("combobox", { name: "Year" })).toHaveValue("2026")
+ expect(screen.getByText("Showing 2 questions")).toBeInTheDocument()
+ expect(screen.getByText("Nature for All")).toBeInTheDocument()
+ expect(screen.getByText("Collective Bargaining")).toBeInTheDocument()
+ expect(screen.queryByText("Tipped Wage")).not.toBeInTheDocument()
+ expect(screen.getByText("Question #")).toBeInTheDocument()
+ expect(screen.getByText("Question 3")).toBeInTheDocument()
+ expect(
+ screen.getByText("Public counsel labor rights question.")
+ ).toBeInTheDocument()
+ })
+
+ it("can switch to another year and reset back to the default filters", () => {
+ render( )
+
+ showFilters()
+
+ fireEvent.change(screen.getByRole("combobox", { name: "Year" }), {
+ target: { value: "2024" }
+ })
+
+ expect(screen.getByText("Tipped Wage")).toBeInTheDocument()
+ expect(screen.queryByText("Nature for All")).not.toBeInTheDocument()
+ expect(
+ screen.getByRole("button", { name: "Reset filters" })
+ ).toBeInTheDocument()
+
+ fireEvent.click(screen.getByRole("button", { name: "Reset filters" }))
+
+ expect(screen.getByText("Nature for All")).toBeInTheDocument()
+ expect(screen.getByText("Collective Bargaining")).toBeInTheDocument()
+ expect(screen.queryByText("Tipped Wage")).not.toBeInTheDocument()
+ })
+
+ it("filters by court and status and searches title and final summary", async () => {
+ render( )
+
+ showFilters()
+
+ fireEvent.change(screen.getByRole("combobox", { name: "Status" }), {
+ target: { value: "expectedOnBallot" }
+ })
+
+ expect(screen.getByText("Collective Bargaining")).toBeInTheDocument()
+ expect(screen.queryByText("Nature for All")).not.toBeInTheDocument()
+
+ fireEvent.change(screen.getByRole("combobox", { name: "Court" }), {
+ target: { value: "194" }
+ })
+ fireEvent.change(screen.getByRole("searchbox", { name: "Search" }), {
+ target: { value: "labor rights" }
+ })
+
+ await waitFor(() => {
+ expect(screen.getByText("Collective Bargaining")).toBeInTheDocument()
+ })
+
+ fireEvent.change(screen.getByRole("searchbox", { name: "Search" }), {
+ target: { value: "nature" }
+ })
+
+ await waitFor(() => {
+ expect(
+ screen.getByText("No ballot questions match the current filters.")
+ ).toBeInTheDocument()
+ })
+ })
+
+ it("can search across older years after widening the year filter", async () => {
+ render( )
+
+ showFilters()
+
+ fireEvent.change(screen.getByRole("combobox", { name: "Year" }), {
+ target: { value: "all" }
+ })
+ fireEvent.change(screen.getByRole("searchbox", { name: "Search" }), {
+ target: { value: "tipped minimum wage" }
+ })
+
+ await waitFor(() => {
+ expect(screen.getByText("Tipped Wage")).toBeInTheDocument()
+ })
+ expect(screen.queryByText("Nature for All")).not.toBeInTheDocument()
+ })
+})
diff --git a/components/ballotquestions/BrowseBallotQuestions.tsx b/components/ballotquestions/BrowseBallotQuestions.tsx
new file mode 100644
index 000000000..eeb166e84
--- /dev/null
+++ b/components/ballotquestions/BrowseBallotQuestions.tsx
@@ -0,0 +1,671 @@
+import { Alert, Button, Card, Form, Image } from "components/bootstrap"
+import { useTranslation } from "next-i18next"
+import Link from "next/link"
+import { useDeferredValue, useState } from "react"
+import styled from "styled-components"
+import type { BallotQuestion } from "../db"
+import { maple } from "../links"
+import { QuestionTooltip } from "../tooltip"
+
+type BallotQuestionStatus = BallotQuestion["ballotStatus"]
+
+export type BallotQuestionBrowseItem = {
+ id: string
+ title: string
+ fullSummary: string
+ electionYear: number
+ court: number
+ ballotStatus: BallotQuestionStatus
+ ballotQuestionNumber: number | null
+ endorseCount: number
+ neutralCount: number
+ opposeCount: number
+}
+
+const BROWSE_SHADOWS = {
+ soft: "0 0.25rem 1rem rgba(15, 23, 42, 0.06)",
+ card: "0 0.3rem 1rem rgba(15, 23, 42, 0.06)",
+ hover: "0 0.65rem 1.35rem rgba(15, 23, 42, 0.12)"
+}
+
+const BROWSE_BORDERS = {
+ chrome: "#d9e2ec",
+ muted: "#dce5ee",
+ hover: "#bfd0e2"
+}
+
+const STATUS_STYLES: Record<
+ BallotQuestionStatus,
+ { background: string; color: string; border: string }
+> = {
+ legislature: {
+ background: "#fff4db",
+ color: "#7a4b00",
+ border: "#f6d58a"
+ },
+ qualifying: {
+ background: "#eef7ff",
+ color: "#16537e",
+ border: "#b9ddf8"
+ },
+ certified: {
+ background: "#e9f8ef",
+ color: "#17633a",
+ border: "#bee8cd"
+ },
+ ballot: {
+ background: "#e8efff",
+ color: "#1d3f8a",
+ border: "#c9d8ff"
+ },
+ enacted: {
+ background: "#e8f6ea",
+ color: "#1d5d2d",
+ border: "#c8e7cf"
+ },
+ failed: {
+ background: "#fde8ef",
+ color: "#902141",
+ border: "#f4bfd0"
+ },
+ withdrawn: {
+ background: "#f1f5f9",
+ color: "#475569",
+ border: "#d7e0ea"
+ },
+ expectedOnBallot: {
+ background: "#e8efff",
+ color: "#1d3f8a",
+ border: "#c9d8ff"
+ },
+ failedToAppear: {
+ background: "#f1f5f9",
+ color: "#475569",
+ border: "#d7e0ea"
+ },
+ rejected: {
+ background: "#fde8ef",
+ color: "#902141",
+ border: "#f4bfd0"
+ },
+ accepted: {
+ background: "#e8f6ea",
+ color: "#1d5d2d",
+ border: "#c8e7cf"
+ }
+}
+
+const Controls = styled.section<{ $expanded: boolean }>`
+ background: linear-gradient(
+ 180deg,
+ var(--bs-white) 0%,
+ var(--bs-body-bg) 100%
+ );
+ border: 1px solid ${BROWSE_BORDERS.chrome};
+ border-radius: var(--bs-border-radius-xl);
+ box-shadow: ${BROWSE_SHADOWS.soft};
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ gap: ${props => (props.$expanded ? "0.75rem" : "0")};
+ margin-bottom: 1.5rem;
+ padding: 1rem;
+ transition: all 0.3s ease;
+`
+
+const ControlsHeader = styled.div`
+ align-items: center;
+ display: flex;
+ justify-content: space-between;
+ width: 100%;
+ gap: 0.75rem;
+
+ @media (max-width: 576px) {
+ align-items: stretch;
+ flex-direction: column;
+ }
+`
+
+const ControlsSummary = styled.div`
+ flex: 1 1 auto;
+ min-width: 0;
+`
+
+const ControlsActions = styled.div`
+ align-items: center;
+ display: flex;
+ flex: 0 0 auto;
+ gap: 0.5rem;
+ justify-content: flex-end;
+ margin-left: auto;
+ flex-wrap: nowrap;
+
+ @media (max-width: 576px) {
+ margin-left: 0;
+ width: 100%;
+ }
+`
+
+const ControlsButton = styled(Button)`
+ flex: 0 0 auto;
+ padding: 0.375rem 0.5rem;
+ font-size: 0.9rem;
+ white-space: nowrap;
+ width: fit-content;
+
+ &:focus-visible {
+ outline: 2px solid var(--bs-blue);
+ outline-offset: 2px;
+ }
+`
+
+const ControlsGrid = styled.div<{ $expanded: boolean }>`
+ display: grid;
+ gap: 0.75rem;
+ grid-template-columns: minmax(0, 2.4fr) repeat(3, minmax(0, 1fr));
+ width: 100%;
+ max-height: ${props => (props.$expanded ? "500px" : "0")};
+ overflow: clip;
+ transform: translateY(${props => (props.$expanded ? "0" : "-10px")});
+ transition: max-height 0.3s ease-in-out, opacity 0.3s ease-in-out,
+ transform 0.3s ease-in-out;
+ opacity: ${props => (props.$expanded ? 1 : 0)};
+
+ @media (max-width: 992px) {
+ grid-template-columns: 1fr 1fr;
+ }
+
+ @media (max-width: 576px) {
+ grid-template-columns: 1fr;
+ }
+`
+
+const FilterLabel = styled(Form.Label)`
+ color: var(--bs-gray-700);
+ font-size: 0.82rem;
+ font-weight: 700;
+ margin-bottom: 0.35rem;
+`
+
+const ResultsSummary = styled.p.attrs({
+ role: "status",
+ "aria-live": "polite"
+})`
+ color: var(--bs-gray-600);
+ font-size: 0.95rem;
+ font-weight: 600;
+ margin: 0;
+`
+
+const List = styled.div.attrs({ role: "list" })`
+ display: grid;
+ gap: 1.15rem;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+
+ @media (max-width: 1100px) {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+
+ @media (max-width: 640px) {
+ grid-template-columns: 1fr;
+ }
+`
+
+const ListItem = styled.div.attrs({ role: "listitem" })`
+ min-width: 0;
+`
+
+const StyledCard = styled(Card)`
+ background: linear-gradient(
+ 180deg,
+ var(--bs-white) 0%,
+ var(--bs-body-bg) 100%
+ );
+ border: 1px solid ${BROWSE_BORDERS.chrome};
+ border-radius: var(--bs-border-radius-xl);
+ box-shadow: ${BROWSE_SHADOWS.card};
+ height: 100%;
+ overflow: hidden;
+ transition: transform 0.16s ease, box-shadow 0.16s ease,
+ border-color 0.16s ease;
+
+ .card-body {
+ display: flex;
+ flex-direction: column;
+ gap: 0.75rem;
+ height: 100%;
+ border-radius: var(--bs-border-radius-xl);
+ padding: 0.8rem 0.9rem;
+ }
+`
+
+const CardLink = styled(Link)`
+ color: inherit;
+ display: block;
+ height: 100%;
+ text-decoration: none;
+
+ &:hover ${StyledCard} {
+ border-color: ${BROWSE_BORDERS.hover};
+ box-shadow: ${BROWSE_SHADOWS.hover};
+ transform: translateY(-2px);
+ }
+
+ &:focus-visible {
+ border-radius: 1rem;
+ outline: 3px solid var(--bs-blue);
+ outline-offset: 4px;
+ }
+`
+
+const TopRow = styled.div`
+ align-items: flex-start;
+ display: flex;
+ gap: 0.75rem;
+ justify-content: space-between;
+`
+
+const StatusBadge = styled.span<{ $status: BallotQuestionStatus }>`
+ background: ${({ $status }) => STATUS_STYLES[$status].background};
+ border: 1px solid ${({ $status }) => STATUS_STYLES[$status].border};
+ border-radius: 999px;
+ color: ${({ $status }) => STATUS_STYLES[$status].color};
+ display: inline-flex;
+ font-size: 0.74rem;
+ font-weight: 700;
+ letter-spacing: 0.02em;
+ line-height: 1;
+ padding: 0.4rem 0.62rem;
+ white-space: nowrap;
+`
+
+const DocumentId = styled.span`
+ color: var(--bs-gray-600);
+ font-size: 0.76rem;
+ font-weight: 700;
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+ white-space: nowrap;
+`
+
+const TitleBlock = styled.div`
+ display: grid;
+ gap: 0.45rem;
+`
+
+const QuestionNumber = styled.h2`
+ color: var(--bs-dark-blue);
+ font-size: 1.3rem;
+ font-weight: 700;
+ line-height: 1.2;
+ margin: 0;
+`
+
+const QuestionTitle = styled.p`
+ color: var(--bs-gray-700);
+ font-size: 0.95rem;
+ font-weight: 700;
+ line-height: 1.4;
+ margin: 0;
+`
+
+const Summary = styled.p`
+ color: var(--bs-gray-700);
+ display: -webkit-box;
+ font-size: 0.98rem;
+ line-height: 1.66;
+ margin: 0;
+ min-height: 6.9rem;
+ overflow: hidden;
+ -webkit-box-orient: vertical;
+ -webkit-line-clamp: 4;
+`
+
+const MetaStack = styled.div`
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.45rem;
+`
+
+const MetaItem = styled.span`
+ background: var(--bs-body-bg);
+ border: 1px solid ${BROWSE_BORDERS.muted};
+ border-radius: 999px;
+ color: var(--bs-gray-700);
+ font-size: 0.8rem;
+ font-weight: 600;
+ padding: 0.24rem 0.55rem;
+`
+
+const FooterRow = styled.div`
+ align-items: center;
+ display: flex;
+ gap: 0.75rem;
+ justify-content: space-between;
+ margin-top: auto;
+ padding-top: 0.1rem;
+`
+
+const SentimentRow = styled.div`
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.5rem;
+`
+
+const SentimentStat = styled.span`
+ align-items: center;
+ background: var(--bs-body-bg);
+ border: 1px solid ${BROWSE_BORDERS.muted};
+ border-radius: 999px;
+ color: var(--bs-blue);
+ display: inline-flex;
+ font-size: 0.78rem;
+ font-weight: 700;
+ gap: 0.35rem;
+ padding: 0.32rem 0.55rem;
+
+ img {
+ height: 16px;
+ width: 16px;
+ }
+`
+
+const EmptyState = styled.div`
+ background: linear-gradient(
+ 180deg,
+ var(--bs-white) 0%,
+ var(--bs-body-bg) 100%
+ );
+ border: 1px solid ${BROWSE_BORDERS.chrome};
+ border-radius: 1rem;
+ box-shadow: ${BROWSE_SHADOWS.soft};
+ color: var(--bs-gray-700);
+ padding: 1rem;
+`
+
+export const BrowseBallotQuestions = ({
+ items,
+ currentYear
+}: {
+ items: BallotQuestionBrowseItem[]
+ currentYear: number
+}) => {
+ const { t } = useTranslation(["search", "testimony"])
+ const getStatusLabel = (status: BallotQuestionStatus) =>
+ t(`ballot_question_status.${status}`, { ns: "search" })
+ const [searchQuery, setSearchQuery] = useState("")
+ const [selectedYear, setSelectedYear] = useState(String(currentYear))
+ const [selectedCourt, setSelectedCourt] = useState("all")
+ const [selectedStatus, setSelectedStatus] = useState("all")
+ const [searchExpanded, setSearchExpanded] = useState(false)
+ const [showInfo, setShowInfo] = useState(true)
+ const deferredQuery = useDeferredValue(searchQuery.trim().toLowerCase())
+
+ if (items.length === 0) {
+ return (
+
+ {t("browse_ballot_questions_empty", {
+ ns: "search",
+ year: currentYear
+ })}
+
+ )
+ }
+
+ const yearOptions = Array.from(
+ new Set([...items.map(item => item.electionYear), currentYear])
+ ).sort((a, b) => b - a)
+ const courtOptions = Array.from(new Set(items.map(item => item.court))).sort(
+ (a, b) => b - a
+ )
+ const statusOptions = Array.from(
+ new Set(items.map(item => item.ballotStatus))
+ )
+
+ const filteredItems = items.filter(item => {
+ const matchesYear =
+ selectedYear === "all" || item.electionYear === Number(selectedYear)
+ const matchesCourt =
+ selectedCourt === "all" || item.court === Number(selectedCourt)
+ const matchesStatus =
+ selectedStatus === "all" || item.ballotStatus === selectedStatus
+ const matchesSearch =
+ deferredQuery.length === 0 ||
+ item.title.toLowerCase().includes(deferredQuery) ||
+ item.fullSummary.toLowerCase().includes(deferredQuery)
+
+ return matchesYear && matchesCourt && matchesStatus && matchesSearch
+ })
+
+ const hasActiveFilters =
+ searchQuery.length > 0 ||
+ selectedYear !== String(currentYear) ||
+ selectedCourt !== "all" ||
+ selectedStatus !== "all"
+
+ const resetFilters = () => {
+ setSearchQuery("")
+ setSelectedYear(String(currentYear))
+ setSelectedCourt("all")
+ setSelectedStatus("all")
+ }
+ const questionNumberDisclaimer =
+ "Question numbers are assigned by the Secretary of State in the summer prior to an election"
+
+ return (
+ <>
+ {showInfo && (
+ setShowInfo(false)}
+ className="mb-3 rounded-4 ballot-question-info-alert"
+ >
+ {questionNumberDisclaimer}
+
+ )}
+
+
+
+
+
+ {t("ballot_question_search_label", { ns: "search" })}
+
+
setSearchQuery(e.target.value)}
+ />
+
+
+
+
+ {t("ballot_question_filter_year", { ns: "search" })}
+
+
setSelectedYear(e.target.value)}
+ >
+
+ {t("ballot_question_all_years", { ns: "search" })}
+
+ {yearOptions.map(optionYear => (
+
+ {optionYear}
+
+ ))}
+
+
+
+
+
+ {t("ballot_question_filter_court", { ns: "search" })}
+
+
setSelectedCourt(e.target.value)}
+ >
+
+ {t("ballot_question_all_courts", { ns: "search" })}
+
+ {courtOptions.map(court => (
+
+ {t("ballot_question_court", { ns: "search", court })}
+
+ ))}
+
+
+
+
+
+ {t("ballot_question_filter_status", { ns: "search" })}
+
+
setSelectedStatus(e.target.value)}
+ >
+
+ {t("ballot_question_all_statuses", { ns: "search" })}
+
+ {statusOptions.map(status => (
+
+ {getStatusLabel(status)}
+
+ ))}
+
+
+
+
+
+
+
+ {t("ballot_question_results_summary", {
+ ns: "search",
+ count: filteredItems.length,
+ total: items.length
+ })}
+
+
+
+
+ {hasActiveFilters ? (
+
+ {t("ballot_question_reset_filters", { ns: "search" })}
+
+ ) : null}
+ setSearchExpanded(!searchExpanded)}
+ aria-controls="ballot-question-filters"
+ aria-expanded={searchExpanded}
+ >
+ {searchExpanded ? "Hide filters" : "Show filters"}
+
+
+
+
+
+ {filteredItems.length === 0 ? (
+
+ {t("ballot_question_no_results", { ns: "search" })}
+
+ ) : (
+
+ {filteredItems.map(item => (
+
+
+
+
+
+
+ {getStatusLabel(item.ballotStatus)}
+
+
+
+
+ {item.endorseCount}
+
+
+
+ {item.neutralCount}
+
+
+
+ {item.opposeCount}
+
+
+
+
+
+
+
+ {item.ballotQuestionNumber != null ? (
+ `Question ${item.ballotQuestionNumber}`
+ ) : (
+ <>
+ Question #
+
+ >
+ )}
+
+
+ {item.title}
+
+
+ {item.fullSummary ||
+ t("ballot_question_no_summary", { ns: "search" })}
+
+
+
+
+
+ ))}
+
+ )}
+ >
+ )
+}
diff --git a/components/ballotquestions/CommitteeHearing.test.tsx b/components/ballotquestions/CommitteeHearing.test.tsx
new file mode 100644
index 000000000..5ca93b602
--- /dev/null
+++ b/components/ballotquestions/CommitteeHearing.test.tsx
@@ -0,0 +1,46 @@
+import "@testing-library/jest-dom"
+import { render, screen } from "@testing-library/react"
+import { CommitteeHearing } from "./CommitteeHearing"
+
+const PAST_MS = new Date("2025-12-14T14:00:00Z").getTime()
+const FUTURE_MS = new Date("2030-06-01T10:00:00Z").getTime()
+
+describe("CommitteeHearing", () => {
+ beforeEach(() => {
+ jest.useFakeTimers()
+ jest.setSystemTime(new Date("2026-01-01T00:00:00Z"))
+ })
+
+ afterEach(() => {
+ jest.useRealTimers()
+ })
+
+ it("shows Occurred status for a past hearing", () => {
+ render( )
+ expect(screen.getByText(/Occurred/)).toBeInTheDocument()
+ })
+
+ it("shows Scheduled status for a future hearing", () => {
+ render( )
+ expect(screen.getByText(/Scheduled/)).toBeInTheDocument()
+ })
+
+ it("formats the hearing date", () => {
+ render( )
+ expect(screen.getByText(/December 14, 2025/)).toBeInTheDocument()
+ })
+
+ it("shows a hearing page link when an id is present", () => {
+ render(
+
+ )
+ expect(
+ screen.getByRole("link", { name: /Open hearing page/i })
+ ).toHaveAttribute("href", "/hearing/1")
+ })
+
+ it("hides the hearing page link when no hearing id is available", () => {
+ render( )
+ expect(screen.queryByRole("link")).not.toBeInTheDocument()
+ })
+})
diff --git a/components/ballotquestions/CommitteeHearing.tsx b/components/ballotquestions/CommitteeHearing.tsx
new file mode 100644
index 000000000..52e7ec6d3
--- /dev/null
+++ b/components/ballotquestions/CommitteeHearing.tsx
@@ -0,0 +1,82 @@
+import { DateTime } from "luxon"
+import { Image } from "components/bootstrap"
+import { Hearing } from "./types"
+
+export const CommitteeHearing = ({ hearing }: { hearing: Hearing }) => {
+ const startsAt = new Date(hearing.startsAt)
+ const now = new Date()
+ const isOccurred = startsAt < now
+ const status = isOccurred ? "Occurred" : "Scheduled"
+ const hearingId = hearing.id.replace(/^hearing-/, "")
+ const dateBadge = DateTime.fromJSDate(startsAt).toFormat("MMM d")
+
+ const dateStr = DateTime.fromJSDate(startsAt).toLocaleString({
+ year: "numeric",
+ month: "long",
+ day: "numeric",
+ hour: "2-digit",
+ minute: "2-digit"
+ })
+
+ return (
+
+
+
+
+
+
+
+
+
Committee Hearing
+
+ Committee hearings are public meetings where legislators reviewed
+ the proposed ballot question, asked questions, and heard testimony
+ from the public and experts. At this stage, the Legislature had
+ the opportunity to pass the proposal into law instead of sending
+ it to voters.
+
+
{dateStr}
+
+
+
+ {hearingId ? (
+
+ Open hearing page
+ ↗
+
+ ) : null}
+
+
+ )
+}
diff --git a/components/ballotquestions/DescriptionBox.tsx b/components/ballotquestions/DescriptionBox.tsx
new file mode 100644
index 000000000..6909ca572
--- /dev/null
+++ b/components/ballotquestions/DescriptionBox.tsx
@@ -0,0 +1,44 @@
+export const DescriptionBox = ({ description }: { description: string }) => {
+ return (
+
+
+
+ i
+
+
+
+ What this question would do
+
+
+ {description}
+
+
+
+
+ )
+}
diff --git a/components/ballotquestions/OverviewTab.tsx b/components/ballotquestions/OverviewTab.tsx
new file mode 100644
index 000000000..569c478bb
--- /dev/null
+++ b/components/ballotquestions/OverviewTab.tsx
@@ -0,0 +1,200 @@
+import type { ReactNode } from "react"
+import { Col, Row } from "react-bootstrap"
+import { BallotQuestion, Bill } from "../db"
+import { QuestionTooltip } from "../tooltip"
+import { CommitteeHearing } from "./CommitteeHearing"
+import { Hearing } from "./types"
+
+export const OverviewTab = ({
+ ballotQuestion,
+ bill,
+ hearings
+}: {
+ ballotQuestion: BallotQuestion
+ bill: Bill | null
+ hearings: Hearing[]
+}) => {
+ const sortedHearings = [...hearings].sort((a, b) => b.startsAt - a.startsAt)
+ const sectionCopyStyle = {
+ color: "#334155",
+ fontSize: "0.98rem",
+ lineHeight: 1.8,
+ maxWidth: "75ch"
+ } as const
+
+ return (
+
+
+
+
+
+
+
+
Overview
+
+ Understand the question, key details, and ballot context.
+
+
+
+
+
+ {ballotQuestion.atAGlance && ballotQuestion.atAGlance.length > 0 && (
+
+ Key Details
+
+ {ballotQuestion.atAGlance.map((item, idx) => (
+
+
+
+ {item.label}
+
+
+ {item.value}
+
+
+
+ ))}
+
+
+ )}
+
+ {ballotQuestion.fullSummary && (
+
+
+ Official summary by the Massachusetts Attorney General
+
+
+
+ {ballotQuestion.fullSummary.split(/\n\n+/).map((para, i) => (
+
+ {para}
+
+ ))}
+
+
+ )}
+
+ {bill &&
+ sortedHearings.length > 0 &&
+ sortedHearings.some(h => new Date(h.startsAt) < new Date()) && (
+
+ Committee Hearing
+
+ {sortedHearings
+ .filter(h => new Date(h.startsAt) < new Date())
+ .map(hearing => (
+
+ ))}
+
+
+ )}
+
+ )
+}
+
+function BallotGlyph() {
+ return (
+
+
+
+
+
+
+
+
+ )
+}
+
+function SectionCard({ children }: { children: ReactNode }) {
+ return (
+
+ )
+}
diff --git a/components/ballotquestions/TestimoniesTab.tsx b/components/ballotquestions/TestimoniesTab.tsx
new file mode 100644
index 000000000..c639e3363
--- /dev/null
+++ b/components/ballotquestions/TestimoniesTab.tsx
@@ -0,0 +1,167 @@
+import Link from "next/link"
+import { Image } from "react-bootstrap"
+import ViewTestimony from "../TestimonyCard/ViewTestimony"
+import { BallotQuestion, Bill, UsePublishedTestimonyListing } from "../db"
+import { isActiveBallotQuestionPhase } from "./status"
+import { BallotQuestionTestimonySummary } from "./types"
+
+export const TestimoniesTab = ({
+ ballotQuestion,
+ bill,
+ testimony,
+ testimonySummary
+}: {
+ ballotQuestion: BallotQuestion
+ bill: Bill | null
+ testimony: UsePublishedTestimonyListing
+ testimonySummary: BallotQuestionTestimonySummary
+}) => {
+ const isExpectedOnBallotPhase =
+ ballotQuestion.ballotStatus === "expectedOnBallot"
+ const allowEdit = isActiveBallotQuestionPhase(ballotQuestion.ballotStatus)
+ const totalLabel =
+ testimonySummary.testimonyCount === 1
+ ? "1 perspective"
+ : `${testimonySummary.testimonyCount} perspectives`
+
+ return (
+
+
+
+
+
+
+
+
+
Perspectives
+
{totalLabel}
+ {isExpectedOnBallotPhase && bill && (
+
+ You can review testimony on the related bill{" "}
+
+ here
+
+ .
+
+ )}
+
+
+
+
+
+
+
+
+
+ )
+}
+
+function SummaryItem({
+ label,
+ count,
+ icon,
+ color
+}: {
+ label: string
+ count: number
+ icon: string
+ color: string
+}) {
+ return (
+
+
+
+
+ {label}
+
+
+ {count}
+
+
+
+
+
+
+
+ )
+}
diff --git a/components/ballotquestions/YourTestimonyPanel.test.tsx b/components/ballotquestions/YourTestimonyPanel.test.tsx
new file mode 100644
index 000000000..df8df3591
--- /dev/null
+++ b/components/ballotquestions/YourTestimonyPanel.test.tsx
@@ -0,0 +1,60 @@
+import "@testing-library/jest-dom"
+import { render, screen } from "@testing-library/react"
+import { YourTestimonyPanel } from "./YourTestimonyPanel"
+
+jest.mock("../publish/hooks", () => ({
+ usePanelStatus: () => "ready",
+ usePublishState: () => ({
+ bill: null,
+ ballotQuestionId: null
+ })
+}))
+
+jest.mock("../publish/panel/EditTestimonyButton", () => ({
+ EditTestimonyButton: () => Edit perspective
+}))
+
+jest.mock("../publish/panel/TestimonyFormPanel", () => ({
+ TestimonyFormPanel: ({
+ ballotQuestionId,
+ variant
+ }: {
+ ballotQuestionId?: string
+ variant?: string
+ }) => (
+
+ )
+}))
+
+const bill = {
+ id: "H5005",
+ court: 194
+} as any
+
+const ballotQuestion = {
+ id: "25-15",
+ ballotStatus: "expectedOnBallot"
+} as any
+
+describe("YourTestimonyPanel", () => {
+ it("allows ballot question perspectives in expected-on-ballot phase", () => {
+ render( )
+
+ expect(screen.getByText("Your Perspective")).toBeInTheDocument()
+ expect(screen.getByTestId("testimony-form-panel")).toHaveAttribute(
+ "data-ballot-question-id",
+ "25-15"
+ )
+ expect(screen.getByTestId("testimony-form-panel")).toHaveAttribute(
+ "data-variant",
+ "ballotQuestion"
+ )
+ expect(
+ screen.queryByRole("link", { name: "Testify on the bill" })
+ ).not.toBeInTheDocument()
+ })
+})
diff --git a/components/ballotquestions/YourTestimonyPanel.tsx b/components/ballotquestions/YourTestimonyPanel.tsx
new file mode 100644
index 000000000..61431d963
--- /dev/null
+++ b/components/ballotquestions/YourTestimonyPanel.tsx
@@ -0,0 +1,79 @@
+import { BallotQuestion, Bill } from "../db"
+import { TestimonyFormPanel } from "../publish/panel/TestimonyFormPanel"
+import { EditTestimonyButton } from "../publish/panel/EditTestimonyButton"
+import {
+ isActiveBallotQuestionPhase,
+ isTerminalBallotQuestionPhase
+} from "./status"
+import { usePanelStatus, usePublishState } from "../publish/hooks"
+
+export const YourTestimonyPanel = ({
+ ballotQuestion,
+ bill
+}: {
+ ballotQuestion: BallotQuestion
+ bill: Bill | null
+}) => {
+ const isActivePhase = isActiveBallotQuestionPhase(ballotQuestion.ballotStatus)
+ const isTerminalPhase = isTerminalBallotQuestionPhase(
+ ballotQuestion.ballotStatus
+ )
+ const panelStatus = usePanelStatus()
+ const { bill: resolvedBill, ballotQuestionId: resolvedBallotQuestionId } =
+ usePublishState()
+ const showInlineEditButton =
+ isActivePhase &&
+ !!bill &&
+ resolvedBill?.id === bill.id &&
+ resolvedBallotQuestionId === ballotQuestion.id &&
+ (panelStatus === "published" || panelStatus === "editInProgress")
+
+ return (
+
+
+
+ Your Perspective
+
+ {showInlineEditButton && bill ? (
+
+ ) : null}
+
+ {isActivePhase && bill ? (
+ <>
+
+ >
+ ) : isTerminalPhase ? (
+
+ Perspectives are no longer being accepted for this ballot question.
+
+ ) : (
+
+ Perspectives are not available for this ballot question yet.
+
+ )}
+
+ )
+}
diff --git a/components/ballotquestions/status.ts b/components/ballotquestions/status.ts
new file mode 100644
index 000000000..5482d0a4f
--- /dev/null
+++ b/components/ballotquestions/status.ts
@@ -0,0 +1,43 @@
+import { BallotQuestion } from "../db"
+
+type BallotQuestionStatus = BallotQuestion["ballotStatus"]
+
+export const isActiveBallotQuestionPhase = (status: BallotQuestionStatus) =>
+ ["qualifying", "certified", "ballot", "expectedOnBallot"].includes(status)
+
+export const isTerminalBallotQuestionPhase = (status: BallotQuestionStatus) =>
+ [
+ "enacted",
+ "failed",
+ "withdrawn",
+ "failedToAppear",
+ "rejected",
+ "accepted"
+ ].includes(status)
+
+export const getBallotQuestionStatusLabel = (status: BallotQuestionStatus) => {
+ switch (status) {
+ case "legislature":
+ return "Before the legislature"
+ case "qualifying":
+ return "Qualifying"
+ case "certified":
+ return "Certified"
+ case "ballot":
+ return "On the ballot"
+ case "enacted":
+ return "Enacted"
+ case "failed":
+ return "Failed"
+ case "withdrawn":
+ return "Withdrawn"
+ case "expectedOnBallot":
+ return "Expected on ballot"
+ case "failedToAppear":
+ return "Failed to appear on ballot"
+ case "rejected":
+ return "Rejected"
+ case "accepted":
+ return "Accepted"
+ }
+}
diff --git a/components/ballotquestions/types.ts b/components/ballotquestions/types.ts
new file mode 100644
index 000000000..cdc9e0b9f
--- /dev/null
+++ b/components/ballotquestions/types.ts
@@ -0,0 +1,28 @@
+export type Hearing = {
+ id: string
+ videoURL?: string
+ startsAt: number // milliseconds since epoch, converted from Firestore Timestamp server-side
+}
+
+export type BallotQuestionTab =
+ | "overview"
+ | "testimonies"
+ | "synthesis"
+ | "for_against"
+ | "news"
+ | "academia"
+ | "financials"
+ | "map"
+
+export type BallotQuestionTestimonySummary = {
+ testimonyCount: number
+ endorseCount: number
+ neutralCount: number
+ opposeCount: number
+}
+
+export const getBallotQuestionTabId = (tab: BallotQuestionTab) =>
+ `ballot-question-tab-${tab}`
+
+export const getBallotQuestionPanelId = (tab: BallotQuestionTab) =>
+ `ballot-question-panel-${tab}`
diff --git a/components/bill/BillTestimonies.tsx b/components/bill/BillTestimonies.tsx
index b435bbd6a..6361749ea 100644
--- a/components/bill/BillTestimonies.tsx
+++ b/components/bill/BillTestimonies.tsx
@@ -14,12 +14,12 @@ export const BillTestimonies = (
})
return (
- <>
+
- >
+
)
}
diff --git a/components/db/api.ts b/components/db/api.ts
index 2c5305102..d3c17e76e 100644
--- a/components/db/api.ts
+++ b/components/db/api.ts
@@ -19,11 +19,16 @@ import { first } from "lodash"
import { Bill } from "./bills"
import { Profile } from "./profile"
import { Testimony } from "./testimony"
+import { matchesBallotQuestionScope } from "./testimony/ballotQuestionScope"
+import type { BallotQuestion } from "functions/src/ballotQuestions/types"
+
+export type { BallotQuestion }
export type TestimonyQuery = {
authorUid: string
billId: string
court: number
+ ballotQuestionId?: string
}
export type BillQuery = {
@@ -66,13 +71,15 @@ export class DbService {
getArchivedTestimony = async ({
authorUid,
billId,
- court
+ court,
+ ballotQuestionId
}: TestimonyQuery): Promise => {
const result = await this.getDocs(
query(
collection(firestore, `/users/${authorUid}/archivedTestimony`),
where("billId", "==", billId),
where("court", "==", court),
+ where("ballotQuestionId", "==", ballotQuestionId ?? null),
orderBy("version", "desc")
)
)
@@ -112,6 +119,22 @@ export class DbService {
return undefined
}
}
+
+ getBallotQuestion = ({
+ id
+ }: {
+ id: string
+ }): Promise =>
+ this.getDocData("ballotQuestions", id)
+
+ getBallotQuestions = async (): Promise => {
+ const result = await this.getDocs(
+ query(collection(firestore, "ballotQuestions"))
+ )
+ return result.docs
+ .map(snap => snap.data())
+ .filter(isNotNull) as BallotQuestion[]
+ }
}
export const api = createApi({
diff --git a/components/db/index.ts b/components/db/index.ts
index 8a30079c1..3fc6a012f 100644
--- a/components/db/index.ts
+++ b/components/db/index.ts
@@ -1,3 +1,4 @@
+export * from "./api"
export * from "./bills"
export * from "./createTableHook"
export * from "./members"
diff --git a/components/db/news.ts b/components/db/news.ts
index fb4dca211..8c8d587c2 100644
--- a/components/db/news.ts
+++ b/components/db/news.ts
@@ -1,4 +1,10 @@
-import { collection, getDocs, orderBy, Timestamp } from "firebase/firestore"
+import {
+ collection,
+ getDocs,
+ orderBy,
+ query,
+ Timestamp
+} from "firebase/firestore"
import { useAsync } from "react-async-hook"
import { firestore } from "../firebase"
@@ -17,7 +23,8 @@ export type NewsItem = {
export async function listNews(): Promise {
const newsRef = collection(firestore, "news")
- const result = await getDocs(newsRef)
+ const q = query(newsRef, orderBy("publishDate", "desc"))
+ const result = await getDocs(q)
return result.docs.map(d => ({ id: d.id, ...d.data() } as NewsItem))
}
diff --git a/components/db/testimony/ballotQuestionScope.ts b/components/db/testimony/ballotQuestionScope.ts
new file mode 100644
index 000000000..5d45e3ebc
--- /dev/null
+++ b/components/db/testimony/ballotQuestionScope.ts
@@ -0,0 +1,9 @@
+import { Testimony } from "./types"
+
+export function matchesBallotQuestionScope(
+ testimony: Pick | undefined,
+ ballotQuestionId?: string
+) {
+ const value = testimony?.ballotQuestionId ?? undefined
+ return ballotQuestionId ? value === ballotQuestionId : !value
+}
diff --git a/components/db/testimony/resolveTestimony.ts b/components/db/testimony/resolveTestimony.ts
index 6570647d6..12569c80f 100644
--- a/components/db/testimony/resolveTestimony.ts
+++ b/components/db/testimony/resolveTestimony.ts
@@ -7,36 +7,54 @@ import {
} from "firebase/firestore"
import { first } from "lodash"
import { firestore } from "../../firebase"
+import { matchesBallotQuestionScope } from "./ballotQuestionScope"
import { DraftTestimony, Testimony } from "./types"
/** Resolves the current draft and publication refs for a given user and bill. */
export const resolveBillTestimony = async (
uid: string,
court: number,
- billId: string
+ billId: string,
+ ballotQuestionId?: string
) => {
- const published = await getPublishedTestimony(uid, court, billId)
- const draft = await getDraftTestimony(uid, billId)
+ const published = await getPublishedTestimony(
+ uid,
+ court,
+ billId,
+ ballotQuestionId
+ )
+ const draft = await getDraftTestimony(uid, billId, ballotQuestionId)
return {
- draft: first(draft.docs)?.ref,
- publication: first(published.docs)?.ref
+ draft: first(draft)?.ref,
+ publication: first(published)?.ref
}
}
export const getBillTestimony = async (
uid: string,
court: number,
- billId: string
+ billId: string,
+ ballotQuestionId?: string
) => {
- const published = await getPublishedTestimony(uid, court, billId)
- const draft = await getDraftTestimony(uid, billId)
+ const published = await getPublishedTestimony(
+ uid,
+ court,
+ billId,
+ ballotQuestionId
+ )
+ const draft = await getDraftTestimony(uid, billId, ballotQuestionId)
return {
- draft: first(draft.docs)?.data() as DraftTestimony | undefined,
- publication: first(published.docs)?.data() as Testimony | undefined
+ draft: first(draft)?.data() as DraftTestimony | undefined,
+ publication: first(published)?.data() as Testimony | undefined
}
}
-function getPublishedTestimony(uid: string, court: number, billId: string) {
+function getPublishedTestimony(
+ uid: string,
+ court: number,
+ billId: string,
+ ballotQuestionId?: string
+) {
return getDocs(
query(
collectionGroup(firestore, "publishedTestimony"),
@@ -44,14 +62,26 @@ function getPublishedTestimony(uid: string, court: number, billId: string) {
where("billId", "==", billId),
where("court", "==", court)
)
+ ).then(snapshot =>
+ snapshot.docs.filter(doc =>
+ matchesBallotQuestionScope(doc.data(), ballotQuestionId)
+ )
)
}
-function getDraftTestimony(uid: string, billId: string) {
+function getDraftTestimony(
+ uid: string,
+ billId: string,
+ ballotQuestionId?: string
+) {
return getDocs(
query(
collection(firestore, `users/${uid}/draftTestimony`),
where("billId", "==", billId)
)
+ ).then(snapshot =>
+ snapshot.docs.filter(doc =>
+ matchesBallotQuestionScope(doc.data(), ballotQuestionId)
+ )
)
}
diff --git a/components/db/testimony/types.ts b/components/db/testimony/types.ts
index bcec3fb5d..b1eba858f 100644
--- a/components/db/testimony/types.ts
+++ b/components/db/testimony/types.ts
@@ -33,7 +33,8 @@ export const BaseTestimony = R({
position: Position,
content: Content,
attachmentId: Maybe(String),
- editReason: Maybe(String)
+ editReason: Maybe(String),
+ ballotQuestionId: Maybe(String)
})
export type BaseTestimony = Static
diff --git a/components/db/testimony/useEditTestimony.test.ts b/components/db/testimony/useEditTestimony.test.ts
index 89a730a43..5b22d6f76 100644
--- a/components/db/testimony/useEditTestimony.test.ts
+++ b/components/db/testimony/useEditTestimony.test.ts
@@ -138,6 +138,101 @@ describe("useEditTestimony", () => {
)
})
+ it("Separates ballot question testimony from bill testimony", async () => {
+ const ballotQuestionId = "25-14"
+
+ const legislativePub = testDb.doc(
+ `users/${uid}/publishedTestimony/${nanoid()}`
+ )
+ await legislativePub.create({
+ ...testimony,
+ id: legislativePub.id,
+ content: "legislative publication",
+ ballotQuestionId: null
+ })
+
+ const ballotPub = testDb.doc(`users/${uid}/publishedTestimony/${nanoid()}`)
+ await ballotPub.create({
+ ...testimony,
+ id: ballotPub.id,
+ content: "ballot publication",
+ ballotQuestionId
+ })
+
+ const legislativeDraft = testDb.doc(
+ `users/${uid}/draftTestimony/${nanoid()}`
+ )
+ await legislativeDraft.create({
+ ...draft,
+ content: "legislative draft",
+ ballotQuestionId: null
+ })
+
+ const ballotDraft = testDb.doc(`users/${uid}/draftTestimony/${nanoid()}`)
+ await ballotDraft.create({
+ ...draft,
+ content: "ballot draft",
+ ballotQuestionId
+ })
+
+ const { result: billResult } = renderHook(() =>
+ useEditTestimony(uid, court, billId)
+ )
+ await expectFinishLoading(billResult)
+
+ expect(billResult.current.draft).toMatchObject({
+ content: "legislative draft",
+ ballotQuestionId: null
+ })
+ expect(billResult.current.publication).toMatchObject({
+ content: "legislative publication",
+ ballotQuestionId: null
+ })
+
+ const { result: ballotResult } = renderHook(() =>
+ useEditTestimony(uid, court, billId, ballotQuestionId)
+ )
+ await expectFinishLoading(ballotResult)
+
+ expect(ballotResult.current.draft).toMatchObject({
+ content: "ballot draft",
+ ballotQuestionId
+ })
+ expect(ballotResult.current.publication).toMatchObject({
+ content: "ballot publication",
+ ballotQuestionId
+ })
+
+ await legislativePub.delete()
+ await ballotPub.delete()
+ await legislativeDraft.delete()
+ await ballotDraft.delete()
+ })
+
+ it("Persists ballotQuestionId when updating drafts", async () => {
+ const existingDraft = testDb.doc(`users/${uid}/draftTestimony/${nanoid()}`)
+ await existingDraft.create(draft)
+
+ const { result } = renderHook(() => useEditTestimony(uid, court, billId))
+ await expectFinishLoading(result)
+
+ await act(() =>
+ result.current.saveDraft.execute({
+ ...updatedDraft,
+ ballotQuestionId: "25-14"
+ })
+ )
+ await expectFinishLoading(result)
+
+ const saved = await existingDraft.get()
+ expect(saved.data()).toMatchObject({
+ content: updatedDraft.content,
+ ballotQuestionId: "25-14"
+ })
+
+ await existingDraft.delete()
+ })
+
it("Discards drafts", async () => {
const result = await renderAndDraft()
@@ -209,6 +304,5 @@ describe("useEditTestimony", () => {
})
async function expectFinishLoading(result: any) {
- expect(result.current.loading).toBeTruthy()
await waitFor(() => expect(result.current.loading).toBeFalsy())
}
diff --git a/components/db/testimony/useEditTestimony.ts b/components/db/testimony/useEditTestimony.ts
index 136b8b610..83c92d411 100644
--- a/components/db/testimony/useEditTestimony.ts
+++ b/components/db/testimony/useEditTestimony.ts
@@ -85,14 +85,16 @@ export interface UseEditTestimony {
export function useEditTestimony(
uid: string,
court: number,
- billId: string
+ billId: string,
+ ballotQuestionId?: string
): UseEditTestimony {
const [state, dispatch] = useReducer(reducer, {
draftLoading: true,
publicationLoading: true,
uid,
court,
- billId
+ billId,
+ ballotQuestionId
})
useTestimony(state, dispatch)
@@ -130,17 +132,17 @@ export function useEditTestimony(
}
function useTestimony(
- { uid, billId, draftRef, publicationRef, court }: State,
+ { uid, billId, draftRef, publicationRef, court, ballotQuestionId }: State,
dispatch: Dispatch
) {
useEffect(() => {
- resolveBillTestimony(uid, court, billId)
+ resolveBillTestimony(uid, court, billId, ballotQuestionId)
.then(({ draft, publication }) => {
dispatch({ type: "resolveDraft", id: draft?.id })
dispatch({ type: "resolvePublication", id: publication?.id })
})
.catch(error => dispatch({ type: "error", error }))
- }, [billId, court, dispatch, uid])
+ }, [ballotQuestionId, billId, court, dispatch, uid])
useEffect(() => {
if (draftRef)
@@ -219,9 +221,18 @@ type SaveDraftRequest = Pick<
| "attachmentId"
| "recipientMemberCodes"
| "editReason"
->
+> & { ballotQuestionId?: string | null }
function useSaveDraft(
- { draftRef, draftLoading, billId, uid, court, publication, draft }: State,
+ {
+ draftRef,
+ draftLoading,
+ billId,
+ ballotQuestionId,
+ uid,
+ court,
+ publication,
+ draft
+ }: State,
dispatch: Dispatch
) {
return useAsyncCallback(
@@ -231,7 +242,8 @@ function useSaveDraft(
content,
attachmentId,
recipientMemberCodes,
- editReason
+ editReason,
+ ballotQuestionId: requestBallotQuestionId
}: SaveDraftRequest) => {
if (draftLoading) {
return
@@ -243,7 +255,9 @@ function useSaveDraft(
position,
editReason: editReason ?? null,
recipientMemberCodes: recipientMemberCodes ?? null,
- attachmentId: attachmentId ?? null
+ attachmentId: attachmentId ?? null,
+ ballotQuestionId:
+ requestBallotQuestionId ?? ballotQuestionId ?? null
}
const result = await addDoc(
collection(firestore, `/users/${uid}/draftTestimony`),
@@ -259,11 +273,23 @@ function useSaveDraft(
attachmentId: attachmentId ?? null,
recipientMemberCodes: recipientMemberCodes ?? null,
editReason: editReason ?? null,
+ ballotQuestionId:
+ requestBallotQuestionId ?? ballotQuestionId ?? null,
publishedVersion: hasChanges ? deleteField() : undefined
})
}
},
- [billId, dispatch, draftLoading, draftRef, uid, court, draft, publication]
+ [
+ ballotQuestionId,
+ billId,
+ dispatch,
+ draftLoading,
+ draftRef,
+ uid,
+ court,
+ draft,
+ publication
+ ]
),
{ onError: error => dispatch({ type: "error", error }) }
)
@@ -273,6 +299,7 @@ type State = {
court: number
uid: string
billId: string
+ ballotQuestionId?: string
error?: Error
draft?: WorkingDraft
diff --git a/components/db/testimony/usePublishedTestimonyListing.ts b/components/db/testimony/usePublishedTestimonyListing.ts
index bc4d412b2..d164902a4 100644
--- a/components/db/testimony/usePublishedTestimonyListing.ts
+++ b/components/db/testimony/usePublishedTestimonyListing.ts
@@ -12,6 +12,7 @@ import { firestore } from "../../firebase"
import { nullableQuery } from "../common"
import { createTableHook } from "../createTableHook"
import { Testimony } from "./types"
+import { matchesBallotQuestionScope } from "./ballotQuestionScope"
type Refinement = {
senatorId?: string
@@ -20,19 +21,22 @@ type Refinement = {
uid?: string
court?: number
billId?: string
+ ballotQuestionId?: string
}
const initialRefinement = (
uid?: string,
court?: number,
- billId?: string
+ billId?: string,
+ ballotQuestionId?: string
): Refinement => ({
representativeId: undefined,
senatorId: undefined,
authorRole: undefined,
uid,
court,
- billId
+ billId,
+ ballotQuestionId
})
const useTable = createTableHook({
@@ -52,21 +56,25 @@ export type UsePublishedTestimonyListing = ReturnType<
export function usePublishedTestimonyListing({
uid,
court,
- billId
+ billId,
+ ballotQuestionId
}: {
uid?: string
court?: number
billId?: string
+ ballotQuestionId?: string
} = {}) {
const { pagination, items, refine, refinement } = useTable(
- initialRefinement(uid, court, billId)
+ initialRefinement(uid, court, billId, ballotQuestionId)
)
useEffect(() => {
if (refinement.uid !== uid) refine({ uid })
if (refinement.billId !== billId) refine({ billId })
if (refinement.court !== court) refine({ court })
- }, [billId, court, refine, refinement, uid])
+ if (refinement.ballotQuestionId !== ballotQuestionId)
+ refine({ ballotQuestionId })
+ }, [ballotQuestionId, billId, court, refine, refinement, uid])
return useMemo(() => {
return {
@@ -89,7 +97,8 @@ function getWhere({
authorRole,
representativeId,
court,
- senatorId
+ senatorId,
+ ballotQuestionId
}: Refinement): QueryConstraint[] {
const constraints: Parameters[] = []
const singularUserRoles: string[] = [
@@ -100,6 +109,8 @@ function getWhere({
]
if (uid) constraints.push(["authorUid", "==", uid])
if (billId) constraints.push(["billId", "==", billId])
+ if (ballotQuestionId)
+ constraints.push(["ballotQuestionId", "==", ballotQuestionId])
if (representativeId)
constraints.push(["representativeId", "==", representativeId])
if (senatorId) constraints.push(["senatorId", "==", senatorId])
@@ -128,5 +139,15 @@ async function listTestimony(
startAfterKey !== null && startAfter(startAfterKey)
)
)
- return result.docs.map(d => d.data() as Testimony)
+ return result.docs
+ .map(d => d.data() as Testimony)
+ .filter(testimony => {
+ if (refinement.ballotQuestionId)
+ return matchesBallotQuestionScope(
+ testimony,
+ refinement.ballotQuestionId
+ )
+ if (refinement.billId) return matchesBallotQuestionScope(testimony)
+ return true
+ })
}
diff --git a/components/featureFlags.ts b/components/featureFlags.ts
index 5e080a850..459d5efee 100644
--- a/components/featureFlags.ts
+++ b/components/featureFlags.ts
@@ -17,7 +17,9 @@ export const FeatureFlags = z.object({
/** Hearings and Transcriptions **/
hearingsAndTranscriptions: z.boolean().default(false),
/** Phone Verification UI changes **/
- phoneVerificationUI: z.boolean().default(false)
+ phoneVerificationUI: z.boolean().default(false),
+ /** Ballot Questions feature */
+ ballotQuestions: z.boolean().default(false)
})
export type FeatureFlags = z.infer
@@ -38,7 +40,8 @@ const defaults: Record = {
lobbyingTable: false,
showLLMFeatures: true,
hearingsAndTranscriptions: true,
- phoneVerificationUI: true
+ phoneVerificationUI: true,
+ ballotQuestions: true
},
production: {
testimonyDiffing: false,
@@ -48,7 +51,8 @@ const defaults: Record = {
lobbyingTable: false,
showLLMFeatures: true,
hearingsAndTranscriptions: true,
- phoneVerificationUI: false
+ phoneVerificationUI: false,
+ ballotQuestions: false
},
test: {
testimonyDiffing: false,
@@ -58,7 +62,8 @@ const defaults: Record = {
lobbyingTable: false,
showLLMFeatures: true,
hearingsAndTranscriptions: true,
- phoneVerificationUI: true
+ phoneVerificationUI: true,
+ ballotQuestions: false
}
}
diff --git a/components/firebase.ts b/components/firebase.ts
index 11287cbbd..974a53f1d 100644
--- a/components/firebase.ts
+++ b/components/firebase.ts
@@ -96,9 +96,9 @@ if (!initialized && process.env.NODE_ENV !== "production") {
/** Connect emulators according to `firebase.json` */
function connectEmulators() {
const host =
- process.env.NEXT_PUBLIC_EMULATOR_HOST ?? typeof window === "undefined"
- ? "firebase"
- : "127.0.0.1"
+ process.env.SERVER_EMULATOR_HOST ??
+ process.env.NEXT_PUBLIC_EMULATOR_HOST ??
+ "127.0.0.1"
connectFirestoreEmulator(firestore, host, 8080)
connectFunctionsEmulator(functions, host, 5001)
connectStorageEmulator(storage, host, 9199)
diff --git a/components/hearing/HearingDetails.tsx b/components/hearing/HearingDetails.tsx
index e6e482d34..1e7c4b1d7 100644
--- a/components/hearing/HearingDetails.tsx
+++ b/components/hearing/HearingDetails.tsx
@@ -2,7 +2,8 @@ import { useRouter } from "next/router"
import { Trans, useTranslation } from "next-i18next"
import { useEffect, useRef, useState } from "react"
import styled from "styled-components"
-import { Col, Container, Image, Row } from "../bootstrap"
+import { ButtonGroup } from "react-bootstrap"
+import { Col, Container, Image, Row, Button } from "../bootstrap"
import * as links from "../links"
import { committeeURL, External } from "../links"
import {
@@ -14,8 +15,10 @@ import { HearingSidebar } from "./HearingSidebar"
import {
HearingData,
Paragraph,
+ TranscriptData,
convertToString,
- fetchTranscriptionData
+ fetchTranscriptionData,
+ toVTT
} from "./hearing"
import { Transcriptions } from "./Transcriptions"
@@ -39,6 +42,34 @@ const VideoParent = styled.div`
overflow: hidden;
`
+const VideoButton = styled(Button)`
+ border: none;
+ background: transparent;
+ color: ${({ $active }) => ($active ? "#212529" : "#6c757d")};
+ font-weight: ${({ $active }) => ($active ? "600" : "500")};
+ padding: 0.75rem 1rem;
+ border-radius: 0;
+ position: relative;
+ transition: all 0.25s ease-in-out;
+
+ &:hover {
+ color: #212529;
+ background-color: rgba(0, 0, 0, 0.03);
+ }
+
+ &::after {
+ content: "";
+ position: absolute;
+ bottom: 0;
+ left: 50%;
+ width: ${({ $active }) => ($active ? "100%" : "0%")};
+ height: 2px;
+ background-color: #212529;
+ transition: all 0.3s ease-in-out;
+ transform: translateX(-50%);
+ }
+`
+
export const HearingDetails = ({
hearingData: {
billsInAgenda,
@@ -48,21 +79,97 @@ export const HearingDetails = ({
generalCourtNumber,
hearingDate,
hearingId,
- videoTranscriptionId,
- videoURL
+ videos
}
}: {
hearingData: HearingData
}) => {
const { t } = useTranslation(["common", "hearing"])
const router = useRouter()
+ const previousActive = useRef(null)
+ const routerReady = useRef(false)
+ const [activeVideo, setActiveVideo] = useState(0)
+ const [transcripts, setTranscripts] = useState<
+ (TranscriptData | null)[] | null
+ >(null)
- const [transcriptData, setTranscriptData] = useState(null)
- const [videoLoaded, setVideoLoaded] = useState(false)
+ // Important this occurs before router check; otherwise time will be improperly removed on first render
+ useEffect(() => {
+ if (
+ previousActive.current === null ||
+ previousActive.current === activeVideo
+ )
+ return
+ previousActive.current = activeVideo
+ if (activeVideo !== 0) {
+ router.replace(
+ {
+ pathname: router.pathname,
+ query: {
+ hearingId: hearingId,
+ v: activeVideo + 1
+ }
+ },
+ undefined,
+ { shallow: true }
+ )
+ } else {
+ router.replace(
+ {
+ pathname: router.pathname,
+ query: {
+ hearingId: hearingId
+ }
+ },
+ undefined,
+ { shallow: true }
+ )
+ }
+ }, [activeVideo])
- const handleVideoLoad = () => {
- setVideoLoaded(true)
- }
+ // Runs once
+ useEffect(() => {
+ if (!router.isReady || routerReady.current) return
+ routerReady.current = true
+
+ const query = router.query.v
+ if (typeof query !== "string") {
+ previousActive.current = activeVideo
+ return
+ }
+ const n = parseInt(query, 10)
+ if (!isNaN(n) && n >= 1 && n <= videos.length) {
+ setActiveVideo(n - 1)
+ previousActive.current = n - 1
+ }
+ }, [router.isReady])
+
+ useEffect(() => {
+ ;(async function () {
+ const transcripts = await Promise.all(
+ videos.map(v =>
+ v.transcriptionId ? fetchTranscriptionData(v.transcriptionId) : null
+ )
+ )
+ const result = transcripts.map((t, index) => {
+ if (!t) return null
+ const filename =
+ transcripts.length == 1
+ ? `hearing-${hearingId}`
+ : `hearing-${hearingId}-${index + 1}`
+ const vtt = toVTT(t)
+ const blob = new Blob([vtt], { type: "text/vtt" })
+
+ return {
+ title: videos[index].title,
+ transcript: t,
+ blob: blob,
+ filename: filename
+ }
+ })
+ setTranscripts(result)
+ })()
+ }, [videos])
const videoRef = useRef(null)
function setCurTimeVideo(value: number) {
@@ -78,14 +185,6 @@ export const HearingDetails = ({
}
}, [router.query.t, videoRef.current])
- useEffect(() => {
- ;(async function () {
- if (!videoTranscriptionId || transcriptData !== null) return
- const docList = await fetchTranscriptionData(videoTranscriptionId)
- setTranscriptData(docList)
- })()
- }, [videoTranscriptionId])
-
return (
@@ -94,7 +193,7 @@ export const HearingDetails = ({
- {transcriptData ? (
+ {videos.length ? (
{/* ButtonContainer contrains clickable area of link so that it doesn't exceed
the button and strech invisibly across the width of the page */}
@@ -128,7 +227,7 @@ export const HearingDetails = ({
- {transcriptData ? (
+ {transcripts !== null && transcripts.length > 0 ? (
>
)}
- {videoURL ? (
-
-
-
+ {videos.length > 1 ? (
+
+ {videos.map((video, index) => (
+ setActiveVideo(index)}
+ >
+ {video.title}
+
+ ))}
+
+ ) : (
+
+ )}
+
+ {videos.length > 0 ? (
+ <>
+
+
+
+ >
) : (
- {transcriptData
- ? t("no_video_on_file", { ns: "hearing" })
- : t("no_video_or_transcript", { ns: "hearing" })}
+ {t("no_video_or_transcript", { ns: "hearing" })}
)}
- {transcriptData ? (
+ {transcripts && transcripts.length > 0 ? (
- ) : videoURL ? (
+ ) : videos.length > 0 ? (
- {t("no_transcript_on_file", { ns: "hearing" })}
+ {t("transcript_loading", { ns: "hearing" })}
) : null}
diff --git a/components/hearing/HearingSidebar.tsx b/components/hearing/HearingSidebar.tsx
index a95b8a5a1..e57ffded3 100644
--- a/components/hearing/HearingSidebar.tsx
+++ b/components/hearing/HearingSidebar.tsx
@@ -9,7 +9,7 @@ import { firestore } from "../firebase"
import * as links from "../links"
import { billSiteURL, Internal } from "../links"
import { LabeledIcon } from "../shared"
-import { Paragraph, formatVTTTimestamp } from "./hearing"
+import { Paragraph, TranscriptData, formatVTTTimestamp } from "./hearing"
type Bill = {
BillNumber: string
@@ -114,19 +114,19 @@ const SidebarSubbody = styled.div`
`
export const HearingSidebar = ({
+ activeVideo,
billsInAgenda,
committeeCode,
generalCourtNumber,
hearingDate,
- hearingId,
- transcriptData
+ transcripts
}: {
+ activeVideo: number
billsInAgenda: any[] | null
committeeCode: string | null
generalCourtNumber: string | null
hearingDate: string | null
- hearingId: string
- transcriptData: Paragraph[] | null
+ transcripts: (TranscriptData | null)[] | null
}) => {
const { t } = useTranslation(["common", "hearing"])
@@ -186,35 +186,14 @@ export const HearingSidebar = ({
}, [committeeCode, generalCourtNumber])
useEffect(() => {
- setDownloadName(`hearing-${hearingId}.vtt`)
- }, [hearingId])
-
- useEffect(() => {
- if (!transcriptData) return
- const vttLines = ["WEBVTT", ""]
-
- transcriptData.forEach((paragraph, index) => {
- const cueNumber = index + 1
- const startTime = formatVTTTimestamp(paragraph.start)
- const endTime = formatVTTTimestamp(paragraph.end)
-
- vttLines.push(
- String(cueNumber),
- `${startTime} --> ${endTime}`,
- paragraph.text,
- ""
- )
- })
-
- const vtt = vttLines.join("\n")
- const blob = new Blob([vtt], { type: "text/vtt" })
- const url = URL.createObjectURL(blob)
+ if (!transcripts || !transcripts[activeVideo]) return
+ setDownloadName(transcripts[activeVideo]!.filename)
+ const url = URL.createObjectURL(transcripts[activeVideo]!.blob)
setDownloadURL(url)
-
return () => {
URL.revokeObjectURL(url)
}
- }, [transcriptData])
+ }, [activeVideo, transcripts])
useEffect(() => {
committeeCode && generalCourtNumber ? committeeData() : null
@@ -245,14 +224,21 @@ export const HearingSidebar = ({
) : (
<>>
)}
- {downloadURL !== "" ? (
+ {downloadURL !== "" &&
+ transcripts !== null &&
+ transcripts[activeVideo] !== null ? (
) : (
diff --git a/components/hearing/Transcriptions.tsx b/components/hearing/Transcriptions.tsx
index b451c0917..54a61e905 100644
--- a/components/hearing/Transcriptions.tsx
+++ b/components/hearing/Transcriptions.tsx
@@ -10,6 +10,7 @@ import styled from "styled-components"
import { Col, Container, Row } from "../bootstrap"
import {
Paragraph,
+ TranscriptData,
convertToString,
formatMilliseconds,
formatTotalSeconds
@@ -28,6 +29,10 @@ const ClearButton = styled(FontAwesomeIcon)`
cursor: pointer;
`
+const LegalContainer = styled(Container)`
+ background-color: white;
+`
+
const ResultNumText = styled.div`
position: absolute;
right: 4rem;
@@ -135,16 +140,16 @@ const TranscriptRow = styled(Row)`
const TranscriptRowActive = styled(Row)``
export const Transcriptions = ({
+ activeVideo,
hearingId,
- transcriptData,
+ transcripts,
setCurTimeVideo,
- videoLoaded,
videoRef
}: {
+ activeVideo: number
hearingId: string
- transcriptData: Paragraph[]
+ transcripts: (TranscriptData | null)[]
setCurTimeVideo: any
- videoLoaded: boolean
videoRef: any
}) => {
const { t } = useTranslation(["common", "hearing"])
@@ -188,32 +193,36 @@ export const Transcriptions = ({
}
useEffect(() => {
+ if (!transcripts[activeVideo]) return
+ setHighlightedId(-1)
+ containerRef.current.scrollTop = 0
+ setSearchTerm("")
+ }, [activeVideo])
+
+ useEffect(() => {
+ if (!transcripts[activeVideo]) return
setFilteredData(
- transcriptData.filter(el =>
+ transcripts[activeVideo]!.transcript.filter(el =>
el.text.toLowerCase().includes(searchTerm.toLowerCase())
)
)
- }, [transcriptData, searchTerm])
+ }, [activeVideo, searchTerm])
const router = useRouter()
const startTime = router.query.t
- const resultString: string = convertToString(startTime)
-
- let currentIndex = transcriptData.findIndex(
- element => parseInt(resultString, 10) < element.end / 1000
- )
// Set the initial scroll target when we have a startTime and transcripts
useEffect(() => {
- if (
- startTime &&
- transcriptData.length > 0 &&
- currentIndex !== -1 &&
- !hasScrolledToInitial.current
- ) {
+ const resultString: string = convertToString(startTime)
+ const currentIndex = transcripts[activeVideo]
+ ? transcripts[activeVideo]!.transcript.findIndex(
+ element => parseInt(resultString, 10) < element.end / 1000
+ )
+ : -1
+ if (startTime && currentIndex !== -1 && !hasScrolledToInitial.current) {
setInitialScrollTarget(currentIndex)
}
- }, [startTime, transcriptData, currentIndex])
+ }, [startTime, transcripts])
// Scroll to the initial target when the ref becomes available
useEffect(() => {
@@ -230,12 +239,13 @@ export const Transcriptions = ({
}, [initialScrollTarget, transcriptRefs.current.size, searchTerm])
useEffect(() => {
+ if (!transcripts[activeVideo]) return
const handleTimeUpdate = () => {
- videoLoaded
- ? (currentIndex = transcriptData.findIndex(
- element => videoRef.current.currentTime < element.end / 1000
- ))
- : null
+ if (videoRef.current.readyState < HTMLMediaElement.HAVE_CURRENT_DATA)
+ return
+ const currentIndex = filteredData.findIndex(
+ paragraph => videoRef.current.currentTime < paragraph.end / 1000
+ )
if (containerRef.current && currentIndex !== highlightedId) {
setHighlightedId(currentIndex)
if (currentIndex !== -1 && !searchTerm) {
@@ -245,18 +255,16 @@ export const Transcriptions = ({
}
const videoElement = videoRef.current
- videoLoaded
- ? videoElement.addEventListener("timeupdate", handleTimeUpdate)
- : null
+ if (!videoElement) return
+
+ videoElement.addEventListener("timeupdate", handleTimeUpdate)
return () => {
- videoLoaded
- ? videoElement.removeEventListener("timeupdate", handleTimeUpdate)
- : null
+ videoElement.removeEventListener("timeupdate", handleTimeUpdate)
}
- }, [highlightedId, transcriptData, videoLoaded, videoRef, searchTerm])
+ }, [highlightedId, activeVideo, filteredData, videoRef])
- return (
+ return transcripts[activeVideo] ? (
<>
(
>
+ ) : (
+
+ {t("no_transcript_on_file", { ns: "hearing" })}
+
)
}
// forwardRef must be updated for React 19 migration
const TranscriptItem = forwardRef(function TranscriptItem(
{
+ activeVideo,
element,
hearingId,
highlightedId,
@@ -325,6 +339,7 @@ const TranscriptItem = forwardRef(function TranscriptItem(
setCurTimeVideo,
searchTerm
}: {
+ activeVideo: number
element: Paragraph
hearingId: string
highlightedId: number
@@ -396,7 +411,9 @@ const TranscriptItem = forwardRef(function TranscriptItem(
{
@@ -54,6 +66,18 @@ export async function fetchHearingData(
? DateTime.fromISO(maybeDate, { zone: "America/New_York" }).toISO()
: null
+ const videos = docData.videos
+ ? docData.videos
+ : docData.videoURL
+ ? [
+ {
+ title: `Hearing ${hearingId}`,
+ url: docData.videoURL,
+ transcriptionId: docData.videoTranscriptionId ?? null
+ }
+ ]
+ : []
+
return {
billsInAgenda:
docData.content?.HearingAgendas[0]?.DocumentsInAgenda ?? null,
@@ -64,8 +88,7 @@ export async function fetchHearingData(
docData.content?.HearingHost?.GeneralCourtNumber ?? null,
hearingDate: hearingDate,
hearingId: hearingId,
- videoTranscriptionId: docData.videoTranscriptionId ?? null,
- videoURL: docData.videoURL ?? null
+ videos: videos
}
}
@@ -128,3 +151,22 @@ export function formatVTTTimestamp(ms: number): string {
return `${formattedHours}:${formattedMinutes}:${formattedSeconds}.${formattedMilliseconds}`
}
+
+export function toVTT(transcriptData: Paragraph[]): string {
+ const vttLines = ["WEBVTT", ""]
+
+ transcriptData.forEach((paragraph, index) => {
+ const cueNumber = index + 1
+ const startTime = formatVTTTimestamp(paragraph.start)
+ const endTime = formatVTTTimestamp(paragraph.end)
+
+ vttLines.push(
+ String(cueNumber),
+ `${startTime} --> ${endTime}`,
+ paragraph.text,
+ ""
+ )
+ })
+
+ return vttLines.join("\n")
+}
diff --git a/components/layout.tsx b/components/layout.tsx
index a8e072dc8..22df5e49b 100644
--- a/components/layout.tsx
+++ b/components/layout.tsx
@@ -52,14 +52,23 @@ export const Layout: React.FC> = ({
-
+
+ Skip to main content
+
+
- {children}
-
+
+ {children}
+
+
>
diff --git a/components/links.tsx b/components/links.tsx
index 429a6afb8..b6ac2cdd1 100644
--- a/components/links.tsx
+++ b/components/links.tsx
@@ -1,12 +1,14 @@
import Link from "next/link"
-import { forwardRef, PropsWithChildren } from "react"
+import { AnchorHTMLAttributes, forwardRef, PropsWithChildren } from "react"
import { BillTopic, CurrentCommittee } from "../functions/src/bills/types"
import { Testimony } from "components/db/testimony"
import { Bill, MemberContent } from "./db"
import { formatBillId } from "./formatting"
import { TFunction } from "next-i18next"
-type LinkProps = PropsWithChildren<{ href: string; className?: string }>
+type LinkProps = PropsWithChildren<
+ AnchorHTMLAttributes & { href: string }
+>
export const Internal = forwardRef(
({ href, children, className, ...rest }: LinkProps, ref) => {
@@ -69,6 +71,8 @@ export function siteUrl(path?: string) {
export const maple = {
home: () => "/",
billSearch: () => `/bills`,
+ ballotQuestionSearch: () => `/ballotQuestions`,
+ ballotQuestion: ({ id }: { id: string }) => `/ballotQuestions/${id}`,
bill: ({ court, id }: { court: number; id: string }) =>
`/bills/${court}/${id}`,
testimony: ({ publishedId }: { publishedId: string }) =>
@@ -76,12 +80,19 @@ export const maple = {
userTestimony: ({
authorUid,
billId,
- court
+ court,
+ ballotQuestionId
}: {
authorUid: string
billId: string
court: number
- }) => `/testimony/${authorUid}/${court}/${billId}`
+ ballotQuestionId?: string
+ }) =>
+ `/testimony/${authorUid}/${court}/${billId}${
+ ballotQuestionId
+ ? `?${new URLSearchParams({ ballotQuestionId }).toString()}`
+ : ""
+ }`
}
export function billSiteURL(billNumber: string, court: number) {
diff --git a/components/moderation/moderationComponents.test.tsx b/components/moderation/moderationComponents.test.tsx
index 2a4a42101..9abb6855a 100644
--- a/components/moderation/moderationComponents.test.tsx
+++ b/components/moderation/moderationComponents.test.tsx
@@ -5,9 +5,14 @@ import { cleanup, render, act } from "@testing-library/react"
import { screen } from "@testing-library/dom"
import userEvent from "@testing-library/user-event"
import { AdminContext } from "react-admin"
+import { BillInfoHeader } from "components/TestimonyCard/BillInfoHeader"
import { ReportModal } from "components/TestimonyCard/ReportModal"
import { RequestDeleteOwnTestimonyModal } from "components/TestimonyCard/ReportModal"
+jest.mock("next-i18next", () => ({
+ useTranslation: () => ({ t: (key: string) => key })
+}))
+
describe("report testimony modal", () => {
const setIsReporting = jest.fn()
const mutateReport = jest.fn()
@@ -93,3 +98,26 @@ describe("remove testimony", () => {
cleanup()
})
})
+
+describe("profile testimony header", () => {
+ it("links ballot-question testimony to the ballot question page", () => {
+ render(
+
+ )
+
+ const link = screen.getByRole("link", { name: "Ballot Question 25-14" })
+ expect(link.getAttribute("href")).toBe("/ballotQuestions/25-14")
+ expect(screen.getByText("A Test Ballot Question")).toBeTruthy()
+ })
+})
diff --git a/components/publish/ChooseStance.tsx b/components/publish/ChooseStance.tsx
index 34f7ce023..b588165d0 100644
--- a/components/publish/ChooseStance.tsx
+++ b/components/publish/ChooseStance.tsx
@@ -9,10 +9,10 @@ import { FormNavigation, Next } from "./NavigationButtons"
import { setPosition } from "./redux"
import { StepHeader } from "./StepHeader"
import { useMediaQuery } from "usehooks-ts"
-import { useTranslation } from "next-i18next"
+import { usePublishCopy } from "./hooks"
export const ChooseStance = styled(({ ...rest }) => {
- const { t } = useTranslation("testimony")
+ const { copy } = usePublishCopy()
const { position: currentPosition } = usePublishState()
const dispatch = useAppDispatch()
const isMobile = useMediaQuery("(max-width: 768px)")
@@ -24,7 +24,12 @@ export const ChooseStance = styled(({ ...rest }) => {
const hasPosition = Boolean(currentPosition)
return (
-
{t("submitTestimonyForm.chooseStance")}
+
+ {copy(
+ "submitTestimonyForm.chooseStance",
+ "ballotQuestion.submitTestimonyForm.chooseStance"
+ )}
+
diff --git a/components/publish/KeepNote.tsx b/components/publish/KeepNote.tsx
index 364cf821a..2041b89f2 100644
--- a/components/publish/KeepNote.tsx
+++ b/components/publish/KeepNote.tsx
@@ -3,10 +3,12 @@ import { useState } from "react"
import { Image, Button, Modal, Col, Row } from "../bootstrap"
import { Step } from "./redux"
import { Internal } from "components/links"
+import { usePublishCopy, usePublishMode } from "./hooks"
import { Trans, useTranslation } from "next-i18next"
export const KeepNote = (props: { currentStep: Step }) => {
const { t } = useTranslation("testimony")
+ const isBallotQuestion = usePublishMode() === "ballotQuestion"
return (
@@ -14,7 +16,11 @@ export const KeepNote = (props: { currentStep: Step }) => {
{props.currentStep == "selectLegislators" ||
props.currentStep == "write" ? (
-
+ isBallotQuestion ? (
+
+ ) : (
+
+ )
) : (
)}
@@ -23,7 +29,8 @@ export const KeepNote = (props: { currentStep: Step }) => {
}
export const KeepNoteMobile = () => {
- const { t } = useTranslation("testimony")
+ const { t, copy } = usePublishCopy()
+ const isBallotQuestion = usePublishMode() === "ballotQuestion"
const [showYourTestimony, setShowYourTestimony] = useState(false)
const [showPublishingToMAPLE, setShowPublishingToMAPLE] = useState(false)
@@ -37,13 +44,19 @@ export const KeepNoteMobile = () => {
- {t("submitTestimonyForm.keepNote.about")}
+ {copy(
+ "submitTestimonyForm.keepNote.about",
+ "ballotQuestion.submitTestimonyForm.keepNote.about"
+ )}
- {t("submitTestimonyForm.keepNote.howTestimoniesWork")}
+ {copy(
+ "submitTestimonyForm.keepNote.howTestimoniesWork",
+ "ballotQuestion.submitTestimonyForm.keepNote.howTestimoniesWork"
+ )}
@@ -59,11 +72,18 @@ export const KeepNoteMobile = () => {
- {t("submitTestimonyForm.keepNote.howTestimoniesWork")}
+ {copy(
+ "submitTestimonyForm.keepNote.howTestimoniesWork",
+ "ballotQuestion.submitTestimonyForm.keepNote.howTestimoniesWork"
+ )}
-
+ {isBallotQuestion ? (
+
+ ) : (
+
+ )}
@@ -108,27 +128,71 @@ export const YourTestimony = () => {
)
}
-export const PublishingToMAPLE = () => {
+export const BallotQuestionYourTestimony = () => {
const { t } = useTranslation("testimony")
+ return (
+
+
+
+
+
+ {t("submitTestimonyForm.keepNote.keepInMind")}
+
+
+
+ {t("ballotQuestion.submitTestimonyForm.keepNote.emailPreview")}
+
+
+ {t("ballotQuestion.submitTestimonyForm.keepNote.shareEmail")}
+
+
+ {t("ballotQuestion.submitTestimonyForm.keepNote.thankYou")}
+
+
+
+ )
+}
+
+export const PublishingToMAPLE = () => {
+ const { t, copy, isBallotQuestion } = usePublishCopy()
return (
- {t("submitTestimonyForm.keepNote.rulesHeader")}
+ {copy(
+ "submitTestimonyForm.keepNote.rulesHeader",
+ "ballotQuestion.submitTestimonyForm.keepNote.rulesHeader"
+ )}
- {t("submitTestimonyForm.keepNote.editLimit")}
+
+ {copy(
+ "submitTestimonyForm.keepNote.editLimit",
+ "ballotQuestion.submitTestimonyForm.keepNote.editLimit"
+ )}
+
]}
/>
- {t("submitTestimonyForm.keepNote.emailReminder")}
+ {!isBallotQuestion && (
+ {t("submitTestimonyForm.keepNote.emailReminder")}
+ )}
)
diff --git a/components/publish/ProgressBar.tsx b/components/publish/ProgressBar.tsx
index aedc05c9a..0ceeae24f 100644
--- a/components/publish/ProgressBar.tsx
+++ b/components/publish/ProgressBar.tsx
@@ -1,9 +1,9 @@
import { faCheck } from "@fortawesome/free-solid-svg-icons"
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"
import styled from "styled-components"
+import { usePublishCopy } from "./hooks"
import { isComplete, isCurrent, Step } from "./redux"
import { chipHeight, StepChip } from "./StepChip"
-import { useTranslation } from "next-i18next"
const Divider = styled.div`
height: 1px;
@@ -14,7 +14,7 @@ const Divider = styled.div`
export const ProgressBar = styled<{ currentStep: Step }>(
({ currentStep, ...rest }) => {
- const { t } = useTranslation("testimony")
+ const { copy } = usePublishCopy()
const renderStep = (idx: number, step: Step, label: string) => (
(
)
return (
- {renderStep(1, "position", t("submitTestimonyForm.chooseStance"))}
+ {renderStep(
+ 1,
+ "position",
+ copy(
+ "submitTestimonyForm.chooseStance",
+ "ballotQuestion.submitTestimonyForm.chooseStance"
+ )
+ )}
- {renderStep(2, "write", t("submitTestimonyForm.write.header"))}
+ {renderStep(
+ 2,
+ "write",
+ copy(
+ "submitTestimonyForm.write.header",
+ "ballotQuestion.submitTestimonyForm.write.header"
+ )
+ )}
- {renderStep(3, "publish", t("publish.confirmAndSend"))}
+ {renderStep(
+ 3,
+ "publish",
+ copy(
+ "publish.confirmAndSend",
+ "ballotQuestion.publish.confirmAndPublish"
+ )
+ )}
)
}
diff --git a/components/publish/PublishTestimony.tsx b/components/publish/PublishTestimony.tsx
index c0ed52e7b..3a0e59851 100644
--- a/components/publish/PublishTestimony.tsx
+++ b/components/publish/PublishTestimony.tsx
@@ -1,18 +1,22 @@
import { isRejected } from "@reduxjs/toolkit"
import { isEmpty } from "lodash"
+import { maple } from "components/links"
import Input, { TextArea } from "components/forms/Input"
import { useAsyncCallback } from "react-async-hook"
+import { useRouter } from "next/router"
import styled from "styled-components"
import { LoadingButton } from "../buttons"
import { useAppDispatch } from "../hooks"
import {
publishTestimonyAndProceed,
+ usePublishCopy,
+ usePublishMode,
useFormRedirection,
usePublishState,
useTestimonyEmail
} from "./hooks"
import * as nav from "./NavigationButtons"
-import { setEditReason } from "./redux"
+import { setEditReason, setShowThankYou } from "./redux"
import { SelectRecipients } from "./SelectRecipients"
import { ShareButtons } from "./ShareTestimony"
import { StepHeader } from "./StepHeader"
@@ -57,13 +61,20 @@ function usePublishTestimony() {
export const PublishTestimony = styled(({ ...rest }) => {
const { t } = useTranslation("testimony")
+ const { copy } = usePublishCopy()
+ const isBallotQuestion = usePublishMode() === "ballotQuestion"
const publish = usePublishTestimony(),
error = publish.publish.error
return (
-
{t("publish.confirmAndSend")}
-
+
+ {copy(
+ "publish.confirmAndSend",
+ "ballotQuestion.publish.confirmAndPublish"
+ )}
+
+ {!isBallotQuestion &&
}
@@ -79,7 +90,9 @@ export const PublishTestimony = styled(({ ...rest }) => {
left={
}
right={
}
/>
-
{t("publish.instructions")}
+
+ {copy("publish.instructions", "ballotQuestion.publish.instructions")}
+
)
})``
@@ -87,7 +100,7 @@ export const PublishTestimony = styled(({ ...rest }) => {
/** An orange notice with rounded corners that lists the testimony fields that the user has edited. */
const ChangeNotice = styled(props => {
const { position, content, attachmentId, publication } = usePublishState()
- const { t } = useTranslation("testimony")
+ const { t, isBallotQuestion } = usePublishCopy()
if (!publication) return null
const publishedAttachmentId = publication.draftAttachmentId || undefined
@@ -113,7 +126,11 @@ const ChangeNotice = styled(props => {
@@ -164,6 +181,16 @@ export const EditReason = styled(props => {
`
const PublishAndSend = ({ publish }: { publish: UsePublishTestimony }) => {
+ const isBallotQuestion = usePublishMode() === "ballotQuestion"
+
+ if (isBallotQuestion) {
+ return publish.canShare ? (
+
+ ) : (
+
+ )
+ }
+
if (publish.canShare) {
return
} else {
@@ -172,10 +199,11 @@ const PublishAndSend = ({ publish }: { publish: UsePublishTestimony }) => {
}
const PublishButton = ({ publish }: { publish: UsePublishTestimony }) => {
+ const isBallotQuestion = usePublishMode() === "ballotQuestion"
const { ready, mailToUrl } = useTestimonyEmail()
const { t } = useTranslation("testimony")
- if (!ready) return null
+ if (!isBallotQuestion && !ready) return null
return (
{
className="form-navigation-btn"
variant="danger"
onClick={() => {
- if (publish.hasRecipients)
+ if (!isBallotQuestion && publish.hasRecipients)
window.open(mailToUrl, "_blank", "noopener,noreferrer")
void publish.publish.execute()
}}
>
- {publish.hasRecipients
+ {isBallotQuestion && publish.publishedAndDraftChanged
+ ? t("ballotQuestion.publish.update")
+ : !isBallotQuestion && publish.hasRecipients
? t("publish.publishAndSend")
: t("publish.publish")}
)
}
+
+const FinishPublishedBallotQuestion = () => {
+ const { t } = useTranslation("testimony")
+ const { ballotQuestionId } = usePublishState()
+ const dispatch = useAppDispatch()
+ const router = useRouter()
+
+ if (!ballotQuestionId) return null
+
+ return (
+ {
+ dispatch(setShowThankYou(true))
+ void router.push(maple.ballotQuestion({ id: ballotQuestionId }))
+ }}
+ >
+ {t("publish.finishedBackToBallotQuestion")}
+
+ )
+}
diff --git a/components/publish/QuickInfo.tsx b/components/publish/QuickInfo.tsx
index 5343dcc20..7fc224b2c 100644
--- a/components/publish/QuickInfo.tsx
+++ b/components/publish/QuickInfo.tsx
@@ -3,10 +3,14 @@ import styled from "styled-components"
import { useMediaQuery } from "usehooks-ts"
import { Image } from "../bootstrap"
import { Bill, Profile } from "../db"
+import { usePublishCopy, usePublishState } from "./hooks"
import { useTranslation } from "next-i18next"
export function QuickInfo({ bill, profile }: { bill: Bill; profile: Profile }) {
const { t } = useTranslation("testimony")
+ const { copy } = usePublishCopy()
+ const { ballotQuestionId } = usePublishState()
+ const isBallotQuestion = Boolean(ballotQuestionId)
const {
content: { Title },
city,
@@ -16,9 +20,21 @@ export function QuickInfo({ bill, profile }: { bill: Bill; profile: Profile }) {
hasLegislators = Boolean(representative || senator)
return (
- {t("quickInfo.writingAbout")}
+
+ {copy(
+ "quickInfo.writingAbout",
+ "ballotQuestion.quickInfo.writingAbout"
+ )}
+
- {Title} ({t("quickInfo.bill")} {bill.id})
+ {Title}
+ {isBallotQuestion
+ ? ballotQuestionId
+ ? ` (${t("ballotQuestion.quickInfo.reference", {
+ ballotQuestionId
+ })})`
+ : ""
+ : ` (${t("quickInfo.bill")} ${bill.id})`}
{city && (
<>
@@ -28,14 +44,14 @@ export function QuickInfo({ bill, profile }: { bill: Bill; profile: Profile }) {
>
)}
-
- {committee && (
+ {!isBallotQuestion && }
+ {!isBallotQuestion && committee && (
<>
{t("quickInfo.committeeIs")}
{committee.name}
>
)}
- {hasLegislators && (
+ {!isBallotQuestion && hasLegislators && (
<>
{t("quickInfo.yourLegislatorsAre")}
{representative && (
diff --git a/components/publish/SelectRecipients.tsx b/components/publish/SelectRecipients.tsx
index 7b37bb0b0..243f0036c 100644
--- a/components/publish/SelectRecipients.tsx
+++ b/components/publish/SelectRecipients.tsx
@@ -13,7 +13,7 @@ import { useProfileState } from "../db/profile/redux"
import { useAppDispatch } from "../hooks"
import { Loading, MultiSearch } from "../legislatorSearch"
import { calloutLabels } from "./content"
-import { usePublishState, useTestimonyEmail } from "./hooks"
+import { usePublishMode, usePublishState, useTestimonyEmail } from "./hooks"
import {
addCommittee,
removeCommittee,
@@ -27,11 +27,10 @@ import {
import { isNotNull } from "components/utils"
import { useTranslation } from "next-i18next"
-export const SelectRecipients = styled(props => {
+const BillSelectRecipients = (props: { className?: string }) => {
useEmailRecipients()
const email = useTestimonyEmail()
const { t } = useTranslation("testimony")
-
const isMobile = useMediaQuery("(max-width: 1199px)")
return (
@@ -65,6 +64,13 @@ export const SelectRecipients = styled(props => {
)
+}
+
+export const SelectRecipients = styled(props => {
+ const mode = usePublishMode()
+ if (mode === "ballotQuestion") return null
+
+ return
})`
.label-callout {
font-size: 0.75rem;
diff --git a/components/publish/ShareTestimony.tsx b/components/publish/ShareTestimony.tsx
index b227340ba..080c428d3 100644
--- a/components/publish/ShareTestimony.tsx
+++ b/components/publish/ShareTestimony.tsx
@@ -5,7 +5,12 @@ import { useCallback, useState } from "react"
import styled from "styled-components"
import { Button, Modal } from "../bootstrap"
import { useAppDispatch } from "../hooks"
-import { useFormRedirection, usePublishState, useTestimonyEmail } from "./hooks"
+import {
+ useFormRedirection,
+ usePublishMode,
+ usePublishState,
+ useTestimonyEmail
+} from "./hooks"
import * as nav from "./NavigationButtons"
import { setShowThankYou } from "./redux"
import { SelectRecipients } from "./SelectRecipients"
@@ -17,7 +22,9 @@ import { useTranslation } from "next-i18next"
/** Allow sharing a user's published testimony. */
export const ShareTestimony = styled(({ ...rest }) => {
useFormRedirection()
+ const isBallotQuestion = usePublishMode() === "ballotQuestion"
const { t } = useTranslation("testimony")
+ if (isBallotQuestion) return null
return (
{t("publish.shareHeader")}
@@ -46,13 +53,17 @@ export const ShareButtons = ({
initialSent?: boolean
}) => {
const { t } = useTranslation("testimony")
- const { share, bill } = usePublishState()
+ const { share, bill, ballotQuestionId } = usePublishState()
const router = useRouter()
const dispatch = useAppDispatch()
- const redirectToBill = useCallback(() => {
+ const redirectToPolicy = useCallback(() => {
dispatch(setShowThankYou(true))
- router.push(maple.bill(bill!))
- }, [bill, dispatch, router])
+ router.push(
+ ballotQuestionId
+ ? maple.ballotQuestion({ id: ballotQuestionId })
+ : maple.bill(bill!)
+ )
+ }, [ballotQuestionId, bill, dispatch, router])
const [sent, setSent] = useState(initialSent)
const buttons = []
@@ -69,7 +80,7 @@ export const ShareButtons = ({
buttons.push(
)
}
@@ -79,9 +90,11 @@ export const ShareButtons = ({
- {t("publish.finishedBackToBill")}
+ {ballotQuestionId
+ ? t("publish.finishedBackToBallotQuestion")
+ : t("publish.finishedBackToBill")}
)
}
diff --git a/components/publish/SubmitTestimonyForm.tsx b/components/publish/SubmitTestimonyForm.tsx
index 6b6e418fd..8d82abc23 100644
--- a/components/publish/SubmitTestimonyForm.tsx
+++ b/components/publish/SubmitTestimonyForm.tsx
@@ -7,7 +7,7 @@ import { Col, Image, Row, Spinner, Collapse } from "../bootstrap"
import { Bill, Profile } from "../db"
import * as links from "../links"
import { ChooseStance } from "./ChooseStance"
-import { useFormInfo } from "./hooks"
+import { useFormInfo, usePublishCopy, usePublishState } from "./hooks"
import { KeepNote, KeepNoteMobile } from "./KeepNote"
import { ProgressBar } from "./ProgressBar"
import { PublishTestimony } from "./PublishTestimony"
@@ -78,6 +78,7 @@ const PolicyDetailsStyle = styled.div`
const PolicyDetails = ({ bill, profile }: { bill: Bill; profile: Profile }) => {
const { t } = useTranslation("testimony")
+ const { copy } = usePublishCopy()
const [isCollapsed, setCollapsed] = useState(false)
return (
@@ -97,7 +98,10 @@ const PolicyDetails = ({ bill, profile }: { bill: Bill; profile: Profile }) => {
) : (
- {t("submitTestimonyForm.viewMore")}
+ {copy(
+ "submitTestimonyForm.viewMore",
+ "ballotQuestion.submitTestimonyForm.viewMore"
+ )}
)}
@@ -116,6 +120,7 @@ export const Form = ({
synced: boolean
}) => {
const { t } = useTranslation("testimony")
+ const { ballotQuestionId } = usePublishState()
const content: Record = {
position: ,
selectLegislators: ,
@@ -124,14 +129,17 @@ export const Form = ({
share:
}
const isMobile = useMediaQuery("(max-width: 768px)")
+ const backHref = ballotQuestionId
+ ? links.maple.ballotQuestion({ id: ballotQuestionId })
+ : links.maple.bill(bill)
+ const backLabel = ballotQuestionId
+ ? t("submitTestimonyForm.backToBallotQuestion", { ballotQuestionId })
+ : t("submitTestimonyForm.backToBill", { billId: bill.id })
return (
-
- {t("submitTestimonyForm.backToBill", { billId: bill.id })}
+
+ {backLabel}
{isMobile && (step == "write" || step == "publish" || step == "share") ? (
@@ -144,15 +152,23 @@ export const Form = ({
}
const Overview = ({ className }: { className: string }) => {
- const { t } = useTranslation("testimony")
+ const { copy } = usePublishCopy()
return (
- {t("submitTestimonyForm.overview.title")}
+
+ {copy(
+ "submitTestimonyForm.overview.title",
+ "ballotQuestion.submitTestimonyForm.overview.title"
+ )}
+
- {t("submitTestimonyForm.overview.description")}
+ {copy(
+ "submitTestimonyForm.overview.description",
+ "ballotQuestion.submitTestimonyForm.overview.description"
+ )}
diff --git a/components/publish/TestimonyPreview.tsx b/components/publish/TestimonyPreview.tsx
index 93883eb1f..fa0caf74c 100644
--- a/components/publish/TestimonyPreview.tsx
+++ b/components/publish/TestimonyPreview.tsx
@@ -3,7 +3,7 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"
import clsx from "clsx"
import { AttachmentLink } from "components/CommentModal/Attachment"
import { TestimonyContent } from "components/testimony"
-import { ReactNode, useEffect, useRef, useState } from "react"
+import { ReactNode, useEffect, useState } from "react"
import styled from "styled-components"
import { CopyButton } from "../buttons"
import {
@@ -12,25 +12,73 @@ import {
getPublishedTestimonyAttachmentInfo,
AttachmentInfo
} from "../db"
-import { usePublishState, useTestimonyEmail } from "./hooks"
+import {
+ usePublishCopy,
+ usePublishMode,
+ usePublishState,
+ useTestimonyEmail
+} from "./hooks"
import { useTranslation, Trans } from "next-i18next"
export const positionActions = (
- t: (key: string) => string
+ isBallotQuestion: boolean
): Record => ({
neutral: (
-
- You are neutral on this bill
+
+ {isBallotQuestion ? (
+ <>
+ You are neutral on this ballot
+ question
+ >
+ ) : (
+ <>
+ You are neutral on this bill
+ >
+ )}
),
endorse: (
-
- You support this bill
+
+ {isBallotQuestion ? (
+ <>
+ You support this ballot question
+ >
+ ) : (
+ <>
+ You support this bill
+ >
+ )}
),
oppose: (
-
- You oppose this bill
+
+ {isBallotQuestion ? (
+ <>
+ You oppose this ballot question
+ >
+ ) : (
+ <>
+ You oppose this bill
+ >
+ )}
)
})
@@ -38,6 +86,10 @@ export const positionActions = (
export const CopyTestimony = styled(props => {
const email = useTestimonyEmail()
const { t } = useTranslation("testimony")
+ const mode = usePublishMode()
+
+ if (mode === "ballotQuestion" || !email.ready) return null
+
return (
{
export const YourTestimony = styled<{ type: "draft" | "published" }>(
({ className, children, type }) => {
- const { t } = useTranslation("testimony")
+ const { copy } = usePublishCopy()
return (
-
{t("yourTestimony.title")}
+
+ {copy("yourTestimony.title", "ballotQuestion.yourTestimony.title")}
+
@@ -72,7 +126,8 @@ export const YourTestimony = styled<{ type: "draft" | "published" }>(
export const TestimonyPreview = styled<{ type: "draft" | "published" }>(
props => {
const { draft, publication, authorUid } = usePublishState()
- const { t } = useTranslation("testimony")
+ const mode = usePublishMode()
+ const isBallotQuestion = mode === "ballotQuestion"
const { position, content, attachmentId } =
(props.type === "draft" ? draft : publication) ?? {}
@@ -90,7 +145,9 @@ export const TestimonyPreview = styled<{ type: "draft" | "published" }>(
return (
{position && (
-
{positionActions(t)[position]}
+
+ {positionActions(isBallotQuestion)[position]}
+
)}
{content && (
@@ -138,17 +195,3 @@ export const TestimonyPreview = styled<{ type: "draft" | "published" }>(
display: block;
}
`
-
-const clampString = (s: string | undefined, maxLength: number) => {
- if (!s) return undefined
-
- const words = s.split(" ")
- let length = 0
- for (let i = 0; i < words.length; i++) {
- length += words[i].length + (length > 0 ? 1 : 0)
- if (length > maxLength) {
- return words.slice(0, i).join(" ") + "…"
- }
- }
- return s
-}
diff --git a/components/publish/WriteTestimony.tsx b/components/publish/WriteTestimony.tsx
index 3d0e8e704..859785f08 100644
--- a/components/publish/WriteTestimony.tsx
+++ b/components/publish/WriteTestimony.tsx
@@ -10,7 +10,7 @@ import * as links from "../links"
import * as nav from "./NavigationButtons"
import { SelectRecipients } from "./SelectRecipients"
import { StepHeader } from "./StepHeader"
-import { useFormRedirection, usePublishState } from "./hooks"
+import { useFormRedirection, usePublishCopy, usePublishState } from "./hooks"
import { setAttachmentId, setContent } from "./redux"
import { Trans, useTranslation } from "next-i18next"
type TabKey = "text" | "import"
@@ -37,7 +37,7 @@ function useWriteTestimony() {
}
export const WriteTestimony = styled(props => {
- const { t } = useTranslation("testimony")
+ const { t, copy, isBallotQuestion } = usePublishCopy()
const write = useWriteTestimony()
// const tabs = useTabs(write)
@@ -45,8 +45,13 @@ export const WriteTestimony = styled(props => {
return (
-
{t("submitTestimonyForm.write.header")}
-
+
+ {copy(
+ "submitTestimonyForm.write.header",
+ "ballotQuestion.submitTestimonyForm.write.header"
+ )}
+
+ {!isBallotQuestion &&
}
@@ -55,15 +60,28 @@ export const WriteTestimony = styled(props => {
setContent={write.setContent}
content={write.content}
error={write.error}
- label={t("submitTestimonyForm.write.testimonyLabel")}
- placeholder={t("submitTestimonyForm.write.testimonyPlaceholder")}
- help={t("submitTestimonyForm.write.testimonyHelp")}
+ label={copy(
+ "submitTestimonyForm.write.testimonyLabel",
+ "ballotQuestion.submitTestimonyForm.write.testimonyLabel"
+ )}
+ placeholder={copy(
+ "submitTestimonyForm.write.testimonyPlaceholder",
+ "ballotQuestion.submitTestimonyForm.write.testimonyPlaceholder"
+ )}
+ help={copy(
+ "submitTestimonyForm.write.testimonyHelp",
+ "ballotQuestion.submitTestimonyForm.write.testimonyHelp"
+ )}
className="text-container"
/>
@@ -81,6 +99,10 @@ export const WriteTestimony = styled(props => {
className="mt-3"
attachment={write.attachment}
confirmRemove={true}
+ label={copy(
+ "submitTestimonyForm.write.attachmentLabel",
+ "ballotQuestion.submitTestimonyForm.write.attachmentLabel"
+ )}
/>
diff --git a/components/publish/hooks/index.ts b/components/publish/hooks/index.ts
index f56f4fc0e..ca131ba89 100644
--- a/components/publish/hooks/index.ts
+++ b/components/publish/hooks/index.ts
@@ -1,6 +1,8 @@
export * from "./navigation"
export * from "./publishTestimonyAndProceed"
export * from "./resolveBill"
+export * from "./usePublishCopy"
+export * from "./usePublishMode"
export * from "./useFormInfo"
export * from "./usePanelStatus"
export * from "./usePublishService"
diff --git a/components/publish/hooks/navigation.ts b/components/publish/hooks/navigation.ts
index 13bde48aa..436e3030e 100644
--- a/components/publish/hooks/navigation.ts
+++ b/components/publish/hooks/navigation.ts
@@ -3,13 +3,18 @@ import Router, { useRouter } from "next/router"
import { useEffect } from "react"
import { PublishState, resolveBill, usePublishState } from "."
import { createAppThunk, useAppDispatch } from "../../hooks"
+import { getPublishMode } from "../mode"
import { setStep, Step } from "../redux"
export const formUrl = (
billId: string,
court: number,
- step: Step = "position"
-) => `/submit-testimony?billId=${billId}&court=${court}&step=${step}`
+ step: Step = "position",
+ ballotQuestionId?: string
+) =>
+ `/submit-testimony?billId=${billId}&court=${court}&step=${step}${
+ ballotQuestionId ? `&ballotQuestionId=${ballotQuestionId}` : ""
+ }`
/** Changes to the appropriate form step if users access a step that is
* currently invalid (i.e. entering content before position, trying to share
@@ -30,13 +35,15 @@ export function useFormRedirection() {
}
type Validator = (state: PublishState) => Step | void
-function validateStep(state: PublishState): Step | void {
+export function validateStep(state: PublishState): Step | void {
return validators[state.step](state)
}
const validators: Record
= {
position() {},
selectLegislators(state) {
+ if (getPublishMode(state.ballotQuestionId) === "ballotQuestion")
+ return "write"
return this.write(state)
},
write({ position, errors }) {
@@ -48,6 +55,8 @@ const validators: Record = {
if (!state.content || state.errors.content) return "write"
},
share(state) {
+ if (getPublishMode(state.ballotQuestionId) === "ballotQuestion")
+ return "publish"
const { publication } = state
if (!publication) {
const formError = this.publish(state)
@@ -69,11 +78,16 @@ export const useSyncRouterAndStore = () => {
useEffect(() => {
dispatch(routeChanged())
- }, [router.query.billId, dispatch, router.query.step])
+ }, [
+ router.query.billId,
+ router.query.ballotQuestionId,
+ dispatch,
+ router.query.step
+ ])
useEffect(() => {
dispatch(storeChanged())
- }, [state.bill?.id, state.step, dispatch])
+ }, [state.bill?.id, state.ballotQuestionId, state.step, dispatch])
}
const routeChanged = createAppThunk(
@@ -81,9 +95,20 @@ const routeChanged = createAppThunk(
async (_, { getState, dispatch }) => {
const route = currentRoute()
- const billId = getState().publish.bill?.id
- if (route.billId && route.billId !== billId) {
- await dispatch(resolveBill({ court: route.court, billId: route.billId }))
+ const state = getState().publish
+ const billId = state.bill?.id
+ const ballotQuestionId = state.ballotQuestionId
+ if (
+ route.billId &&
+ (route.billId !== billId || route.ballotQuestionId !== ballotQuestionId)
+ ) {
+ await dispatch(
+ resolveBill({
+ court: route.court,
+ billId: route.billId,
+ ballotQuestionId: route.ballotQuestionId
+ })
+ )
}
const step = getState().publish.step
@@ -100,12 +125,19 @@ const storeChanged = createAppThunk(
billId = state.publish.bill?.id,
step = state.publish.step,
court = state.publish.bill?.court,
+ ballotQuestionId = state.publish.ballotQuestionId,
route = currentRoute()
- if (billId && !isEqual(route, { billId, court, step })) {
- Router.push(`?billId=${billId}&court=${court}&step=${step}`, undefined, {
- shallow: true
- })
+ if (billId && !isEqual(route, { billId, court, step, ballotQuestionId })) {
+ Router.push(
+ `?billId=${billId}&court=${court}&step=${step}${
+ ballotQuestionId ? `&ballotQuestionId=${ballotQuestionId}` : ""
+ }`,
+ undefined,
+ {
+ shallow: true
+ }
+ )
}
}
)
@@ -113,5 +145,6 @@ const storeChanged = createAppThunk(
const currentRoute = () => ({
court: numberOrUndefined(Router.query.court),
billId: stringOrUndefined(Router.query.billId),
- step: stringOrUndefined(Router.query.step)
+ step: stringOrUndefined(Router.query.step),
+ ballotQuestionId: stringOrUndefined(Router.query.ballotQuestionId)
})
diff --git a/components/publish/hooks/resolveBill.ts b/components/publish/hooks/resolveBill.ts
index e6e06f7be..ee8d9d4b4 100644
--- a/components/publish/hooks/resolveBill.ts
+++ b/components/publish/hooks/resolveBill.ts
@@ -1,14 +1,21 @@
import { Bill, getBill } from "../../db"
import { createAppThunk } from "../../hooks"
-import { setBill } from "../redux"
+import { setBill, setBallotQuestionId } from "../redux"
/** Configure the bill for which to provide testimony. */
export const resolveBill = createAppThunk(
"publish/resolveBill",
async (
- info: { billId?: string; court?: number; bill?: Bill },
+ info: {
+ billId?: string
+ court?: number
+ bill?: Bill
+ ballotQuestionId?: string
+ },
{ dispatch }
) => {
+ dispatch(setBallotQuestionId(info.ballotQuestionId))
+
let bill = info.bill
if (!bill) {
if (!info.billId || !info.court) throw Error("billId or bill required")
diff --git a/components/publish/hooks/useFormSync.test.ts b/components/publish/hooks/useFormSync.test.ts
new file mode 100644
index 000000000..3c6c2fbf0
--- /dev/null
+++ b/components/publish/hooks/useFormSync.test.ts
@@ -0,0 +1,40 @@
+import { hasMeaningfulDraftContent } from "./useFormSync"
+
+describe("hasMeaningfulDraftContent", () => {
+ it("treats ballotQuestionId-only form state as empty", () => {
+ expect(
+ hasMeaningfulDraftContent({
+ attachmentId: undefined,
+ content: undefined,
+ position: undefined,
+ recipientMemberCodes: undefined,
+ editReason: undefined,
+ ballotQuestionId: "25-14"
+ })
+ ).toBe(false)
+ })
+
+ it("treats authored testimony fields as meaningful content", () => {
+ expect(
+ hasMeaningfulDraftContent({
+ attachmentId: undefined,
+ content: "Testimony text",
+ position: undefined,
+ recipientMemberCodes: undefined,
+ editReason: undefined,
+ ballotQuestionId: "25-14"
+ })
+ ).toBe(true)
+
+ expect(
+ hasMeaningfulDraftContent({
+ attachmentId: undefined,
+ content: undefined,
+ position: "endorse",
+ recipientMemberCodes: undefined,
+ editReason: undefined,
+ ballotQuestionId: "25-14"
+ })
+ ).toBe(true)
+ })
+})
diff --git a/components/publish/hooks/useFormSync.ts b/components/publish/hooks/useFormSync.ts
index 21670868d..433192682 100644
--- a/components/publish/hooks/useFormSync.ts
+++ b/components/publish/hooks/useFormSync.ts
@@ -34,7 +34,7 @@ export function useFormSync(edit: Service) {
[saveDraft.execute]
)
- const empty = isEmpty(pickBy(form)),
+ const empty = !hasMeaningfulDraftContent(form),
loading = docsLoading || saveDraft?.loading,
exists = persisted !== undefined,
saved = isEqual(form, persisted) || (empty && !exists)
@@ -91,21 +91,49 @@ function useSyncTestimonyToStore(edit: UseEditTestimony) {
}
type DraftContent = ReturnType
+
+export function hasMeaningfulDraftContent({
+ ballotQuestionId,
+ ...draftContent
+}: DraftContent) {
+ return !isEmpty(pickBy(draftContent))
+}
+
function useFormDraft() {
- const { attachmentId, content, position, recipientMemberCodes, editReason } =
- usePublishState()
- return { attachmentId, content, position, recipientMemberCodes, editReason }
+ const {
+ attachmentId,
+ content,
+ position,
+ recipientMemberCodes,
+ editReason,
+ ballotQuestionId
+ } = usePublishState()
+ return {
+ attachmentId,
+ content,
+ position,
+ recipientMemberCodes,
+ editReason,
+ ballotQuestionId
+ }
}
function usePersistedDraft(draft?: WorkingDraft): DraftContent | undefined {
if (!draft) return
- const { attachmentId, content, position, recipientMemberCodes, editReason } =
- draft
+ const {
+ attachmentId,
+ content,
+ position,
+ recipientMemberCodes,
+ editReason,
+ ballotQuestionId
+ } = draft
return {
attachmentId: attachmentId ?? undefined,
content,
position,
recipientMemberCodes: recipientMemberCodes ?? undefined,
- editReason: editReason ?? undefined
+ editReason: editReason ?? undefined,
+ ballotQuestionId: ballotQuestionId ?? undefined
}
}
diff --git a/components/publish/hooks/usePublishCopy.ts b/components/publish/hooks/usePublishCopy.ts
new file mode 100644
index 000000000..7a7b320cb
--- /dev/null
+++ b/components/publish/hooks/usePublishCopy.ts
@@ -0,0 +1,20 @@
+import { useCallback } from "react"
+import { useTranslation } from "next-i18next"
+import { usePublishMode } from "./usePublishMode"
+
+export const usePublishCopy = () => {
+ const { t } = useTranslation("testimony")
+ const mode = usePublishMode()
+ const isBallotQuestion = mode === "ballotQuestion"
+
+ const copy = useCallback(
+ (
+ billKey: string,
+ ballotQuestionKey: string,
+ options?: Record
+ ) => t(isBallotQuestion ? ballotQuestionKey : billKey, options),
+ [isBallotQuestion, t]
+ )
+
+ return { t, copy, mode, isBallotQuestion }
+}
diff --git a/components/publish/hooks/usePublishMode.ts b/components/publish/hooks/usePublishMode.ts
new file mode 100644
index 000000000..dd85bca67
--- /dev/null
+++ b/components/publish/hooks/usePublishMode.ts
@@ -0,0 +1,5 @@
+import { getPublishMode } from "../mode"
+import { usePublishState } from "./usePublishState"
+
+export const usePublishMode = () =>
+ getPublishMode(usePublishState().ballotQuestionId)
diff --git a/components/publish/hooks/usePublishService.tsx b/components/publish/hooks/usePublishService.tsx
index 07ca80f93..c803e0fca 100644
--- a/components/publish/hooks/usePublishService.tsx
+++ b/components/publish/hooks/usePublishService.tsx
@@ -18,23 +18,32 @@ function Provider() {
billId = state.bill?.id,
court = state.bill?.court,
uid = state.authorUid,
- key = `${billId}-${uid}`
+ ballotQuestionId = state.ballotQuestionId,
+ key = `${billId}-${uid}-${ballotQuestionId}`
return billId && uid && court ? (
-
+
) : null
}
function Binder({
billId,
court,
- uid
+ uid,
+ ballotQuestionId
}: {
billId: string
court: number
uid: string
+ ballotQuestionId?: string
}) {
- const edit = useEditTestimony(uid, court, billId)
+ const edit = useEditTestimony(uid, court, billId, ballotQuestionId)
useBinding(edit)
useFormSync(edit)
diff --git a/components/publish/hooks/useTestimonyEmail.ts b/components/publish/hooks/useTestimonyEmail.ts
index ff56e5d7c..cabdd8dfa 100644
--- a/components/publish/hooks/useTestimonyEmail.ts
+++ b/components/publish/hooks/useTestimonyEmail.ts
@@ -7,9 +7,14 @@ import { usePublishState } from "./usePublishState"
/** Generates the email sent to legislators. */
export const useTestimonyEmail = () => {
- const { share, position, bill, content, authorUid } = usePublishState()
+ const { share, position, bill, content, authorUid, ballotQuestionId } =
+ usePublishState()
const { profile } = useProfile()
+ if (ballotQuestionId) {
+ return { ready: false } as const
+ }
+
const to = share.recipients
.map(r => `${r.Name} <${r.EmailAddress}>`)
.join(";"),
@@ -21,7 +26,12 @@ export const useTestimonyEmail = () => {
authorUid &&
bill &&
siteUrl(
- maple.userTestimony({ authorUid, billId: bill.id, court: bill.court })
+ maple.userTestimony({
+ authorUid,
+ billId: bill.id,
+ court: bill.court,
+ ballotQuestionId
+ })
),
cta = `You can see my full testimony at ${testimonyUrl}`,
ending = `Thank you for taking the time to read this email.\n\nSincerely,\n${
diff --git a/components/publish/mode.ts b/components/publish/mode.ts
new file mode 100644
index 000000000..33ee06d66
--- /dev/null
+++ b/components/publish/mode.ts
@@ -0,0 +1,7 @@
+export type PublishMode = "bill" | "ballotQuestion"
+
+export const getPublishMode = (ballotQuestionId?: string): PublishMode =>
+ ballotQuestionId ? "ballotQuestion" : "bill"
+
+export const isBallotQuestionMode = (ballotQuestionId?: string) =>
+ getPublishMode(ballotQuestionId) === "ballotQuestion"
diff --git a/components/publish/panel/EditTestimonyButton.tsx b/components/publish/panel/EditTestimonyButton.tsx
index f423a7d79..09f13542a 100644
--- a/components/publish/panel/EditTestimonyButton.tsx
+++ b/components/publish/panel/EditTestimonyButton.tsx
@@ -6,19 +6,24 @@ import { useTranslation } from "next-i18next"
export const EditTestimonyButton = ({
className,
billId,
- court
+ court,
+ ballotQuestionId
}: {
className?: string
billId: string
court: number
+ ballotQuestionId?: string
}) => {
const { t } = useTranslation("testimony")
- const url = formUrl(billId, court)
+ const url = formUrl(billId, court, "position", ballotQuestionId)
+ const editLabel = ballotQuestionId
+ ? t("ballotQuestion.testimonyItem.edit")
+ : t("testimonyItem.edit")
return (
{
+export const TestimonyFormPanel = ({
+ bill,
+ ballotQuestionId,
+ variant = "default"
+}: {
+ bill: Bill
+ ballotQuestionId?: string
+ variant?: PanelCtaVariant
+}) => {
const dispatch = useAppDispatch()
const authorUid = useAuth().user?.uid
useEffect(() => {
- dispatch(resolveBill({ bill }))
- }, [authorUid, bill, dispatch])
+ dispatch(resolveBill({ bill, ballotQuestionId }))
+ }, [authorUid, bill, ballotQuestionId, dispatch])
return (
-
+
)
}
-//create testimony ln 43. make a ternary same with
-const Panel = () => {
- //tempory check for isMobile to hide create/CompleteTestimony on mobile view
- const isMobile = useMediaQuery("(max-width: 768px)")
+
+const Panel = ({ variant }: { variant: PanelCtaVariant }) => {
const status = usePanelStatus()
- console.log({ status })
- // // TODO: remove
- // return
switch (status) {
case "loading":
- return null
+ return
case "signedOut":
- return
+ return
case "unverified":
- return
+ return
case "noTestimony":
- return
+ return
case "createInProgress":
- return
+ return
case "pendingUpgrade":
- return
+ return
default:
- return
+ return
}
}
+
+const LoadingPanel = ({ variant }: { variant: PanelCtaVariant }) => {
+ const { t } = useTranslation("testimony")
+ const loadingText =
+ variant === "ballotQuestion"
+ ? t("ballotQuestion.panel.loading")
+ : t("panel.loading")
+
+ return (
+
+
+ {loadingText}
+
+ )
+}
diff --git a/components/publish/panel/ThankYouModal.tsx b/components/publish/panel/ThankYouModal.tsx
index 4fba96de8..06fabee96 100644
--- a/components/publish/panel/ThankYouModal.tsx
+++ b/components/publish/panel/ThankYouModal.tsx
@@ -2,14 +2,13 @@ import { useCallback, useEffect, useRef, useState } from "react"
import styled from "styled-components"
import { Image, Modal } from "../../bootstrap"
import { useAppDispatch } from "../../hooks"
-import { usePublishState } from "../hooks"
+import { usePublishCopy, usePublishState } from "../hooks"
import { setShowThankYou } from "../redux"
-import { useTranslation } from "next-i18next"
const modalDurationMs = 2000
export const ThankYouModal = styled(({ ...rest }) => {
- const { t } = useTranslation("testimony")
+ const { copy } = usePublishCopy()
const show = usePublishState().showThankYou
const dispatch = useAppDispatch()
const timeout = useRef
(-1)
@@ -45,7 +44,9 @@ export const ThankYouModal = styled(({ ...rest }) => {
- {t("thankYouModal")}
+
+ {copy("thankYouModal", "ballotQuestion.thankYouModal")}
+
diff --git a/components/publish/panel/YourTestimony.tsx b/components/publish/panel/YourTestimony.tsx
index a369863fc..e53582e67 100644
--- a/components/publish/panel/YourTestimony.tsx
+++ b/components/publish/panel/YourTestimony.tsx
@@ -10,51 +10,75 @@ import { ArchiveTestimonyButton } from "./ArchiveTestimonyButton"
import { ArchiveTestimonyConfirmation } from "./ArchiveTestimonyConfirmation"
import { useTranslation } from "next-i18next"
-export const YourTestimony = () => {
- const synced = usePublishState().sync === "synced"
+export const YourTestimony = ({
+ variant = "default"
+}: {
+ variant?: "default" | "ballotQuestion"
+}) => {
+ const { ballotQuestionId, sync } = usePublishState()
+ const synced = sync === "synced"
return synced ? (
-
-
-
+
+ {!ballotQuestionId && (
+ <>
+
+
+ >
+ )}
) : null
}
-const MainPanel = styled(({ ...rest }) => {
- const { t } = useTranslation("testimony")
- const { draft, deleteTestimony, publication } = usePublishService() ?? {}
- const unpublishedDraft = hasDraftChanged(draft, publication)
- const [showConfirm, setShowConfirm] = useState(false)
- const bill = usePublishState().bill!
+const MainPanel = styled(
+ ({
+ variant = "default",
+ ...rest
+ }: {
+ variant?: "default" | "ballotQuestion"
+ className?: string
+ }) => {
+ const { t } = useTranslation("testimony")
+ const { draft, deleteTestimony, publication } = usePublishService() ?? {}
+ const unpublishedDraft = hasDraftChanged(draft, publication)
+ const [showConfirm, setShowConfirm] = useState(false)
+ const { bill, ballotQuestionId } = usePublishState()
+ const showHeader = variant !== "ballotQuestion"
+ if (!bill) return null
- return (
-
-
-
{t("yourTestimony.title")}
-
- {/*Delete testimony removed until ready */}
- {/*
setShowConfirm(s => !s)} /> */}
+ return (
+
+ {showHeader ? (
+ <>
+
+
{t("yourTestimony.title")}
+
+ {/*Delete testimony removed until ready */}
+ {/*
setShowConfirm(s => !s)} /> */}
+
+ {/*Delete testimony confirmation-dropdown removed until ready */}
+ {/*
setShowConfirm(false)}
+ archiveTestimony={deleteTestimony}
+ /> */}
+
+ >
+ ) : null}
+
+ {unpublishedDraft && (
+ {t("yourTestimony.draft")}
+ )}
- {/*Delete testimony confirmation-dropdown removed until ready */}
- {/* setShowConfirm(false)}
- archiveTestimony={deleteTestimony}
- /> */}
-
-
- {unpublishedDraft && (
- {t("yourTestimony.draft")}
- )}
-
- )
-})`
+ )
+ }
+)`
--previewPadding: 1rem;
background-color: white;
border-radius: 1rem;
@@ -126,9 +150,13 @@ const TwitterButton = (props: ClsProps) => {
const EmailButton = (props: ClsProps) => {
const { t } = useTranslation("testimony")
- const { publication, bill: { id: billId, court } = {} } = usePublishState()
+ const {
+ publication,
+ ballotQuestionId,
+ bill: { id: billId, court } = {}
+ } = usePublishState()
return publication ? (
-
+
{t("yourTestimony.emailCta")}
) : null
diff --git a/components/publish/panel/ctas.tsx b/components/publish/panel/ctas.tsx
index e205e6da7..5bb7c9793 100644
--- a/components/publish/panel/ctas.tsx
+++ b/components/publish/panel/ctas.tsx
@@ -6,6 +6,8 @@ import { Wrap } from "../../links"
import { formUrl, usePublishState } from "../hooks"
import { useTranslation } from "next-i18next"
+export type PanelCtaVariant = "default" | "ballotQuestion"
+
const Styled = styled.div`
display: flex;
flex-direction: column;
@@ -39,10 +41,28 @@ const Cta = ({
)
}
+const CompactPanel = ({
+ title,
+ cta
+}: {
+ title?: string
+ cta: ReactElement
+}) => (
+
+ {title && (
+
+ {title}
+
+ )}
+ {cta}
+
+)
+
const OpenForm = ({ label, ...props }: { label: string } & ButtonProps) => {
- const bill = usePublishState().bill!
+ const { bill, ballotQuestionId } = usePublishState()
+ if (!bill) return null
return (
-
+
{label}
@@ -50,68 +70,144 @@ const OpenForm = ({ label, ...props }: { label: string } & ButtonProps) => {
)
}
-export const CreateTestimony = () => {
+export const CreateTestimony = ({
+ variant = "default"
+}: {
+ variant?: PanelCtaVariant
+}) => {
const { t } = useTranslation("testimony")
-
- return (
- }
+ const namespace =
+ variant === "ballotQuestion" ? "ballotQuestion.panel" : "panel"
+ const cta = (
+
)
+
+ return variant === "ballotQuestion" ? (
+
+ ) : (
+
+ )
}
-export const CompleteTestimony = () => {
+export const CompleteTestimony = ({
+ variant = "default"
+}: {
+ variant?: PanelCtaVariant
+}) => {
const { t } = useTranslation("testimony")
+ const namespace =
+ variant === "ballotQuestion" ? "ballotQuestion.panel" : "panel"
+ const cta = (
+
+ )
- return (
+ return variant === "ballotQuestion" ? (
+
+ ) : (
- }
+ title={t(`${namespace}.completeTestimony.title`)}
+ cta={cta}
className="text-info"
/>
)
}
-export const SignedOut = () => {
+export const SignedOut = ({
+ variant = "default"
+}: {
+ variant?: PanelCtaVariant
+}) => {
const { t } = useTranslation("testimony")
+ const namespace =
+ variant === "ballotQuestion" ? "ballotQuestion.panel" : "panel"
+ const title =
+ variant === "ballotQuestion"
+ ? t(`${namespace}.createTestimony.title`)
+ : t("panel.signedOut.title")
+ const cta =
+ variant === "ballotQuestion" ? (
+
+ ) : (
+
+ )
- return } />
+ return variant === "ballotQuestion" ? (
+
+ ) : (
+
+ )
}
-export const UnverifiedEmail = () => {
+export const UnverifiedEmail = ({
+ variant = "default"
+}: {
+ variant?: PanelCtaVariant
+}) => {
const { t } = useTranslation("testimony")
const id = useAuth().user?.uid!
+ const cta = (
+
+
+ {t("panel.unverifiedEmail.label")}
+
+
+ )
- return (
-
- {t("panel.unverifiedEmail.label")}
-
- }
- />
+ return variant === "ballotQuestion" ? (
+
+ ) : (
+
)
}
-export const PendingUpgrade = () => {
+export const PendingUpgrade = ({
+ variant = "default"
+}: {
+ variant?: PanelCtaVariant
+}) => {
const { t } = useTranslation("testimony")
-
- return (
-
- {t("panel.pendingUpgrade.label")}
-
+ const cta = (
+
+ >
+ {t("panel.pendingUpgrade.label")}
+
+ )
+
+ return variant === "ballotQuestion" ? (
+
+ ) : (
+
)
}
diff --git a/components/publish/redux.test.ts b/components/publish/redux.test.ts
new file mode 100644
index 000000000..22af282d5
--- /dev/null
+++ b/components/publish/redux.test.ts
@@ -0,0 +1,147 @@
+import { configureStore } from "@reduxjs/toolkit"
+import type { AppDispatch } from "../store"
+import { validateStep } from "./hooks/navigation"
+import {
+ nextStep,
+ previousStep,
+ reducer as publish,
+ setContent,
+ setStep
+} from "./redux"
+
+type StoreOptions = {
+ ballotQuestionId?: string
+ step?: "position" | "selectLegislators" | "write" | "publish" | "share"
+ hasLegislators?: boolean
+}
+
+const makeStore = ({
+ ballotQuestionId,
+ step = "position",
+ hasLegislators = false
+}: StoreOptions = {}) => {
+ const profile = hasLegislators
+ ? ({ representative: { id: "rep" }, senator: { id: "sen" } } as any)
+ : undefined
+
+ const store = configureStore({
+ reducer: {
+ publish,
+ profile: (state = { loading: false, profile }) => state
+ },
+ preloadedState: {
+ publish: {
+ ...publish(undefined, { type: "@@INIT" }),
+ step,
+ ballotQuestionId
+ },
+ profile: { loading: false, profile }
+ } as any
+ })
+ return store as Omit & { dispatch: AppDispatch }
+}
+
+const currentStep = (store: ReturnType) =>
+ store.getState().publish.step
+
+const makePublishState = (overrides: Record = {}) =>
+ ({
+ ...publish(undefined, { type: "@@INIT" }),
+ errors: {},
+ sync: "synced",
+ ...overrides
+ } as any)
+
+describe("publish flow steps", () => {
+ it("keeps the existing bill flow order when legislators are not preloaded", async () => {
+ const store = makeStore()
+
+ await store.dispatch(nextStep())
+ expect(currentStep(store)).toBe("selectLegislators")
+
+ await store.dispatch(nextStep())
+ expect(currentStep(store)).toBe("write")
+
+ store.dispatch(setStep("publish"))
+ await store.dispatch(nextStep())
+ expect(currentStep(store)).toBe("share")
+ })
+
+ it("preserves the existing bill skip behavior when legislators are already known", async () => {
+ const store = makeStore({ hasLegislators: true })
+
+ await store.dispatch(nextStep())
+ expect(currentStep(store)).toBe("write")
+
+ await store.dispatch(previousStep())
+ expect(currentStep(store)).toBe("position")
+ })
+
+ it("uses the shorter ballot-question step order", async () => {
+ const store = makeStore({ ballotQuestionId: "25-14" })
+
+ await store.dispatch(nextStep())
+ expect(currentStep(store)).toBe("write")
+
+ await store.dispatch(nextStep())
+ expect(currentStep(store)).toBe("publish")
+
+ await store.dispatch(nextStep())
+ expect(currentStep(store)).toBe("publish")
+
+ await store.dispatch(previousStep())
+ expect(currentStep(store)).toBe("write")
+ })
+})
+
+describe("validateStep", () => {
+ it("uses perspective copy for ballot-question content validation", () => {
+ const store = makeStore({ ballotQuestionId: "25-14" })
+
+ store.dispatch(setContent(""))
+
+ expect(store.getState().publish.errors.content).toBe(
+ "Perspective content must not be empty"
+ )
+ })
+
+ it("redirects ballot-question deep links away from selectLegislators", () => {
+ expect(
+ validateStep(
+ makePublishState({
+ step: "selectLegislators",
+ ballotQuestionId: "25-14",
+ position: "endorse",
+ content: "Testimony"
+ })
+ )
+ ).toBe("write")
+ })
+
+ it("redirects unpublished ballot-question deep links away from share", () => {
+ expect(
+ validateStep(
+ makePublishState({
+ step: "share",
+ ballotQuestionId: "25-14",
+ position: "endorse",
+ content: "Testimony"
+ })
+ )
+ ).toBe("publish")
+ })
+
+ it("redirects published ballot-question deep links away from share", () => {
+ expect(
+ validateStep(
+ makePublishState({
+ step: "share",
+ ballotQuestionId: "25-14",
+ position: "endorse",
+ content: "Testimony",
+ publication: { id: "pub-1" }
+ })
+ )
+ ).toBe("publish")
+ })
+})
diff --git a/components/publish/redux.ts b/components/publish/redux.ts
index ec09f3cd3..a8878af33 100644
--- a/components/publish/redux.ts
+++ b/components/publish/redux.ts
@@ -3,6 +3,7 @@ import { createAppThunk } from "components/hooks"
import { indexOf, isEqual, uniqBy } from "lodash"
import { Literal as L, Static, Union } from "runtypes"
import { authChanged } from "../auth/redux"
+import { getPublishMode } from "./mode"
import {
Bill,
DraftTestimony,
@@ -28,6 +29,18 @@ export const Step = Union(
)
export type Step = Static
export const stepsInOrder = Step.alternatives.map(s => s.value)
+export const ballotQuestionStepsInOrder: Step[] = [
+ "position",
+ "write",
+ "publish"
+]
+
+const stepsInOrderForState = ({
+ ballotQuestionId
+}: Pick) =>
+ getPublishMode(ballotQuestionId) === "ballotQuestion"
+ ? ballotQuestionStepsInOrder
+ : stepsInOrder
export const isComplete = (current: Step, step: Step) => {
return !!current && stepsInOrder.indexOf(current) > stepsInOrder.indexOf(step)
@@ -58,6 +71,9 @@ export type State = {
/** Current bill */
bill?: Bill
+ /** Current ballot question ID (for ballot question testimony) */
+ ballotQuestionId?: string
+
/** Current step in the testimony form */
step: Step
@@ -140,7 +156,8 @@ export const {
setPublicationInfo,
setSyncState,
bindService,
- setBill
+ setBill,
+ setBallotQuestionId
}
} = createSlice({
name: "publish",
@@ -194,6 +211,9 @@ export const {
if (isEqual(state.bill, bill)) return state
return resetForm({ ...state, bill })
},
+ setBallotQuestionId(state, action: PayloadAction) {
+ state.ballotQuestionId = action.payload
+ },
setPublicationInfo(
state,
{ payload: info }: PayloadAction<{ authorUid?: string; bill?: Bill }>
@@ -319,22 +339,26 @@ const validateForm = ({
editReason,
publication,
draft,
- errors
+ errors,
+ ballotQuestionId
}: State) => {
+ const isBallotQuestion = Boolean(ballotQuestionId)
+ const contentNoun = isBallotQuestion ? "Perspective" : "Testimony"
+ const editNoun = isBallotQuestion ? "perspective" : "testimony"
const validated = Position.validate(position)
if (!validated.success) errors.position = "Invalid position"
else errors.position = undefined
- if (!content) errors.content = "Testimony content must not be empty"
+ if (!content) errors.content = `${contentNoun} content must not be empty`
else if (content && content.length > maxTestimonyLength)
- errors.content = "Testimony content is too long"
+ errors.content = `${contentNoun} content is too long`
else if (containsSocialSecurityNumber(content)) {
// TODO: include the offending number(s) in the error string.
- errors.content = "Testimony must not contain social security numbers"
+ errors.content = `${contentNoun} must not contain social security numbers`
} else errors.content = undefined
if (hasDraftChanged(draft, publication) && !editReason)
- errors.editReason = "You must provide a reason for editing your testimony"
+ errors.editReason = `You must provide a reason for editing your ${editNoun}`
else errors.editReason = undefined
}
@@ -342,6 +366,7 @@ const validateForm = ({
const resetForm = (state: State) => ({
...initialState,
bill: state.bill,
+ ballotQuestionId: state.ballotQuestionId,
authorUid: state.authorUid,
service: state.service
})
@@ -349,15 +374,17 @@ const resetForm = (state: State) => ({
export const nextStep = createAppThunk("publish/nextStep", async (_, api) => {
const {
profile: { profile },
- publish: { step }
+ publish
} = api.getState()
+ const { step } = publish
const hasLegislators = Boolean(profile?.representative && profile.senator)
+ const orderedSteps = stepsInOrderForState(publish)
- let i = indexOf(stepsInOrder, step)
- let nextStep = i !== -1 && stepsInOrder[i + 1]
+ let i = indexOf(orderedSteps, step)
+ let nextStep = i !== -1 && orderedSteps[i + 1]
if (nextStep === "selectLegislators" && hasLegislators)
- nextStep = stepsInOrder[i + 2]
+ nextStep = orderedSteps[i + 2]
if (nextStep) api.dispatch(setStep(nextStep))
})
@@ -367,15 +394,17 @@ export const previousStep = createAppThunk(
async (_, api) => {
const {
profile: { profile },
- publish: { step }
+ publish
} = api.getState()
+ const { step } = publish
const hasLegislators = Boolean(profile?.representative && profile.senator)
+ const orderedSteps = stepsInOrderForState(publish)
- let i = indexOf(stepsInOrder, step)
- let nextStep = stepsInOrder[i - 1]
+ let i = indexOf(orderedSteps, step)
+ let nextStep = orderedSteps[i - 1]
if (nextStep === "selectLegislators" && hasLegislators)
- nextStep = stepsInOrder[i - 2]
+ nextStep = orderedSteps[i - 2]
if (nextStep) api.dispatch(setStep(nextStep))
}
diff --git a/components/shared/FollowButton.tsx b/components/shared/FollowButton.tsx
index 701a79886..f6d5c236b 100644
--- a/components/shared/FollowButton.tsx
+++ b/components/shared/FollowButton.tsx
@@ -3,7 +3,7 @@ import { useTranslation } from "next-i18next"
import { useEffect, useContext, useMemo, useState } from "react"
import { Button } from "react-bootstrap"
import { useAuth } from "../auth"
-import { Bill } from "../db"
+import { BallotQuestion, Bill } from "../db"
import {
followsTopic,
followBill,
@@ -11,7 +11,10 @@ import {
unfollowBill,
unfollowProfile,
billTopicName,
- profileTopicName
+ profileTopicName,
+ ballotQuestionTopicName,
+ followBallotQuestion,
+ unfollowBallotQuestion
} from "./FollowingQueries"
import { FollowContext } from "./FollowContext"
import { Modal } from "components/bootstrap"
@@ -185,3 +188,22 @@ function ConfirmFollowModal({
)
}
+
+export function FollowBallotQuestionButton({
+ ballotQuestion
+}: {
+ ballotQuestion: BallotQuestion
+}) {
+ const uid = useAuth().user?.uid
+ const topicName = ballotQuestionTopicName(
+ ballotQuestion.court,
+ ballotQuestion.id
+ )
+ return (
+ followBallotQuestion(uid, ballotQuestion)}
+ unfollowAction={() => unfollowBallotQuestion(uid, ballotQuestion)}
+ />
+ )
+}
diff --git a/components/shared/FollowingQueries.tsx b/components/shared/FollowingQueries.tsx
index a73b688b6..30c488a7c 100644
--- a/components/shared/FollowingQueries.tsx
+++ b/components/shared/FollowingQueries.tsx
@@ -1,5 +1,5 @@
import { collection, deleteDoc, doc, getDoc, setDoc } from "firebase/firestore"
-import { Bill } from "../db"
+import { BallotQuestion, Bill } from "../db"
import { firestore } from "../firebase"
function getTopicRef(uid: string | undefined, topicName: string) {
@@ -12,6 +12,8 @@ function getTopicRef(uid: string | undefined, topicName: string) {
export const billTopicName = (court: number, billId: string) =>
`bill-${court}-${billId}`
export const profileTopicName = (profileId: string) => `testimony-${profileId}`
+export const ballotQuestionTopicName = (court: number, id: string) =>
+ `ballot-question-${court}-${id}`
export const followBill = async (uid: string | undefined, bill: Bill) => {
const topicName = billTopicName(bill.court, bill.id)
@@ -52,6 +54,34 @@ export const unfollowProfile = async (
profileId: string
) => await unfollowTopic(uid, profileTopicName(profileId))
+export const followBallotQuestion = async (
+ uid: string | undefined,
+ ballotQuestion: Pick
+) => {
+ const topicName = ballotQuestionTopicName(
+ ballotQuestion.court,
+ ballotQuestion.id
+ )
+ await setDoc(getTopicRef(uid, topicName), {
+ topicName,
+ uid,
+ ballotQuestionLookup: {
+ ballotQuestionId: ballotQuestion.id,
+ court: ballotQuestion.court
+ },
+ type: "ballotQuestion"
+ })
+}
+
+export const unfollowBallotQuestion = async (
+ uid: string | undefined,
+ ballotQuestion: Pick
+) =>
+ await unfollowTopic(
+ uid,
+ ballotQuestionTopicName(ballotQuestion.court, ballotQuestion.id)
+ )
+
export const followsTopic = async (
uid: string | undefined,
topicName: string
diff --git a/components/shared/StyledSharedComponents.tsx b/components/shared/StyledSharedComponents.tsx
index 76b8c5f5f..878158b37 100644
--- a/components/shared/StyledSharedComponents.tsx
+++ b/components/shared/StyledSharedComponents.tsx
@@ -2,6 +2,6 @@ import styled from "styled-components"
import { Row } from "../bootstrap"
export const Banner = styled(Row).attrs(props => ({
- className: `h3 text-center text-white justify-content-center p-3 bg-warning ${props.className}`,
+ className: `h3 text-center text-white justify-content-center p-3 bg-warning m-0 gx-0 w-100 ${props.className}`,
children: props.children
}))``
diff --git a/components/testimony/TestimonyDetailPage/BillTitle.test.tsx b/components/testimony/TestimonyDetailPage/BillTitle.test.tsx
new file mode 100644
index 000000000..3e24aa668
--- /dev/null
+++ b/components/testimony/TestimonyDetailPage/BillTitle.test.tsx
@@ -0,0 +1,45 @@
+import "@testing-library/jest-dom"
+import { render, screen } from "@testing-library/react"
+import type { ReactNode } from "react"
+import { BillTitle } from "./BillTitle"
+
+jest.mock("components/links", () => ({
+ Internal: ({ href, children }: { href: string; children: ReactNode }) => (
+ {children}
+ ),
+ maple: {
+ ballotQuestion: ({ id }: { id: string }) => `/ballotQuestions/${id}`,
+ bill: ({ court, id }: { court: number; id: string }) =>
+ `/bills/${court}/${id}`
+ }
+}))
+
+jest.mock("./testimonyDetailSlice", () => ({
+ useCurrentTestimonyDetails: () => ({
+ bill: {
+ id: "H123",
+ court: 194,
+ content: {
+ BillNumber: "H123",
+ Title: "A bill title"
+ }
+ },
+ ballotQuestion: {
+ id: "25-14",
+ title: "Should we do the thing?",
+ description: null
+ }
+ })
+}))
+
+describe("BillTitle", () => {
+ it("shows the ballot question title for ballot-question testimony", () => {
+ render( )
+
+ expect(
+ screen.getByRole("link", {
+ name: "Ballot Question 25-14: Should we do the thing?"
+ })
+ ).toHaveAttribute("href", "/ballotQuestions/25-14")
+ })
+})
diff --git a/components/testimony/TestimonyDetailPage/BillTitle.tsx b/components/testimony/TestimonyDetailPage/BillTitle.tsx
index 348bba0fa..b8c9cbb4f 100644
--- a/components/testimony/TestimonyDetailPage/BillTitle.tsx
+++ b/components/testimony/TestimonyDetailPage/BillTitle.tsx
@@ -3,13 +3,26 @@ import { Internal, maple } from "components/links"
import styled from "styled-components"
import { useCurrentTestimonyDetails } from "./testimonyDetailSlice"
+function formatBallotQuestionTitle(ballotQuestion: {
+ id: string
+ title?: string | null
+ description?: string | null
+}) {
+ const title = ballotQuestion.title ?? ballotQuestion.description
+ return title
+ ? `Ballot Question ${ballotQuestion.id}: ${title}`
+ : `Ballot Question ${ballotQuestion.id}`
+}
+
export const BillTitle = styled(props => {
- const { bill } = useCurrentTestimonyDetails()
+ const { bill, ballotQuestion } = useCurrentTestimonyDetails()
- const href = maple.bill(bill)
- const title = `${formatBillId(bill.content.BillNumber)}: ${
- bill.content.Title
- }`
+ const href = ballotQuestion
+ ? maple.ballotQuestion(ballotQuestion)
+ : maple.bill(bill)
+ const title = ballotQuestion
+ ? formatBallotQuestionTitle(ballotQuestion)
+ : `${formatBillId(bill.content.BillNumber)}: ${bill.content.Title}`
return (
diff --git a/components/testimony/TestimonyDetailPage/PolicyActions.test.tsx b/components/testimony/TestimonyDetailPage/PolicyActions.test.tsx
new file mode 100644
index 000000000..12d1648aa
--- /dev/null
+++ b/components/testimony/TestimonyDetailPage/PolicyActions.test.tsx
@@ -0,0 +1,127 @@
+import "@testing-library/jest-dom"
+import { fireEvent, render, screen, waitFor } from "@testing-library/react"
+import { FollowContext } from "components/shared/FollowContext"
+import { PolicyActions } from "./PolicyActions"
+
+const mockGetBallotQuestion = jest.fn()
+const mockFollowsTopic = jest.fn()
+const mockFollowBill = jest.fn()
+const mockUnfollowBill = jest.fn()
+const mockFollowBallotQuestion = jest.fn()
+const mockUnfollowBallotQuestion = jest.fn()
+
+jest.mock("next-i18next", () => ({
+ useTranslation: () => ({ t: (key: string) => key })
+}))
+
+jest.mock("components/featureFlags", () => ({
+ useFlags: () => ({
+ notifications: true
+ })
+}))
+
+jest.mock("components/auth", () => ({
+ useAuth: () => ({
+ user: { uid: "user-1" }
+ })
+}))
+
+jest.mock("components/db/api", () => ({
+ dbService: () => ({
+ getBallotQuestion: mockGetBallotQuestion
+ })
+}))
+
+jest.mock("./testimonyDetailSlice", () => ({
+ useCurrentTestimonyDetails: () => ({
+ bill: {
+ id: "H123",
+ court: 194
+ },
+ ballotQuestion: {
+ id: "25-14",
+ title: "Should we do the thing?",
+ description: null
+ },
+ revision: {
+ ballotQuestionId: "25-14"
+ }
+ })
+}))
+
+jest.mock("components/shared/FollowingQueries", () => ({
+ ballotQuestionTopicName: jest.fn(
+ (court: number, id: string) => `ballot-question-${court}-${id}`
+ ),
+ billTopicName: jest.fn((court: number, id: string) => `bill-${court}-${id}`),
+ followBallotQuestion: (...args: unknown[]) =>
+ mockFollowBallotQuestion(...args),
+ followBill: (...args: unknown[]) => mockFollowBill(...args),
+ followsTopic: (...args: unknown[]) => mockFollowsTopic(...args),
+ unfollowBallotQuestion: (...args: unknown[]) =>
+ mockUnfollowBallotQuestion(...args),
+ unfollowBill: (...args: unknown[]) => mockUnfollowBill(...args)
+}))
+
+jest.mock("components/publish", () => ({
+ formUrl: (
+ billId: string,
+ court: number,
+ page: string,
+ ballotQuestionId?: string
+ ) =>
+ `/submit-testimony?billId=${billId}&court=${court}&page=${page}${
+ ballotQuestionId ? `&ballotQuestionId=${ballotQuestionId}` : ""
+ }`
+}))
+
+describe("PolicyActions", () => {
+ beforeEach(() => {
+ jest.clearAllMocks()
+ mockFollowsTopic.mockResolvedValue(false)
+ mockFollowBill.mockResolvedValue(undefined)
+ mockUnfollowBill.mockResolvedValue(undefined)
+ mockFollowBallotQuestion.mockResolvedValue(undefined)
+ mockUnfollowBallotQuestion.mockResolvedValue(undefined)
+ mockGetBallotQuestion.mockResolvedValue({
+ ballotStatus: "ballot"
+ })
+ })
+
+ it("uses ballot-question follow behavior for ballot-question testimony", async () => {
+ render(
+
+
+
+ )
+
+ await waitFor(() => {
+ expect(mockFollowsTopic).toHaveBeenCalledWith(
+ "user-1",
+ "ballot-question-194-25-14"
+ )
+ })
+
+ expect(
+ screen.getByText("Follow Ballot Question 25-14: Should we do the thing?")
+ ).toBeInTheDocument()
+
+ fireEvent.click(
+ screen.getByText("Follow Ballot Question 25-14: Should we do the thing?")
+ )
+
+ await waitFor(() => {
+ expect(mockFollowBallotQuestion).toHaveBeenCalledWith("user-1", {
+ court: 194,
+ id: "25-14"
+ })
+ })
+ expect(mockFollowBill).not.toHaveBeenCalled()
+ })
+})
diff --git a/components/testimony/TestimonyDetailPage/PolicyActions.tsx b/components/testimony/TestimonyDetailPage/PolicyActions.tsx
index b79c1d3f9..959d48777 100644
--- a/components/testimony/TestimonyDetailPage/PolicyActions.tsx
+++ b/components/testimony/TestimonyDetailPage/PolicyActions.tsx
@@ -1,23 +1,43 @@
import { Card, ListItem, ListItemProps } from "components/Card"
+import { dbService } from "components/db/api"
import { useFlags } from "components/featureFlags"
import { formatBillId } from "components/formatting"
import { formUrl } from "components/publish"
-import { FC, ReactElement, useContext, useEffect } from "react"
+import { FC, ReactElement, useContext, useEffect, useState } from "react"
import { useCurrentTestimonyDetails } from "./testimonyDetailSlice"
import { useTranslation } from "next-i18next"
import { useAuth } from "components/auth"
-import { followsTopic } from "components/shared/FollowingQueries"
+import {
+ ballotQuestionTopicName,
+ billTopicName,
+ followBallotQuestion,
+ followBill,
+ followsTopic,
+ unfollowBallotQuestion,
+ unfollowBill
+} from "components/shared/FollowingQueries"
import { StyledImage } from "components/ProfilePage/StyledProfileComponents"
import { FollowContext } from "components/shared/FollowContext"
+import { isActiveBallotQuestionPhase } from "components/ballotquestions/status"
interface PolicyActionsProps {
className?: string
isUser?: boolean
isReporting: boolean
setReporting: (boolean: boolean) => void
- topicName: string
- followAction: () => Promise
- unfollowAction: () => Promise
+}
+
+function formatBallotQuestionPolicyLabel(
+ ballotQuestionId: string,
+ ballotQuestion?: {
+ title?: string | null
+ description?: string | null
+ } | null
+) {
+ const title = ballotQuestion?.title ?? ballotQuestion?.description
+ return title
+ ? `Ballot Question ${ballotQuestionId}: ${title}`
+ : `Ballot Question ${ballotQuestionId}`
}
const PolicyActionItem: FC> = props => (
@@ -28,19 +48,28 @@ export const PolicyActions: FC> = ({
className,
isUser,
isReporting,
- setReporting,
- topicName,
- followAction,
- unfollowAction
+ setReporting
}) => {
- const { bill } = useCurrentTestimonyDetails(),
+ const { bill, revision, ballotQuestion } = useCurrentTestimonyDetails(),
billLabel = formatBillId(bill.id)
const { notifications } = useFlags()
const { user } = useAuth()
const uid = user?.uid
+ const ballotQuestionId = revision.ballotQuestionId ?? undefined
+ const ballotQuestionTopic = ballotQuestionId
+ ? { court: bill.court, id: ballotQuestionId }
+ : null
+ const policyLabel = ballotQuestionTopic
+ ? formatBallotQuestionPolicyLabel(ballotQuestionTopic.id, ballotQuestion)
+ : billLabel
+ const topicName = ballotQuestionTopic
+ ? ballotQuestionTopicName(ballotQuestionTopic.court, ballotQuestionTopic.id)
+ : billTopicName(bill.court, bill.id)
const { followStatus, setFollowStatus } = useContext(FollowContext)
+ const [canEditBallotQuestionTestimony, setCanEditBallotQuestionTestimony] =
+ useState(!ballotQuestionId)
useEffect(() => {
uid
@@ -50,13 +79,53 @@ export const PolicyActions: FC> = ({
: null
}, [uid, topicName, setFollowStatus])
+ useEffect(() => {
+ if (!ballotQuestionId) {
+ setCanEditBallotQuestionTestimony(true)
+ return
+ }
+
+ if (ballotQuestion) {
+ setCanEditBallotQuestionTestimony(
+ isActiveBallotQuestionPhase(ballotQuestion.ballotStatus)
+ )
+ return
+ }
+
+ let active = true
+ dbService()
+ .getBallotQuestion({ id: ballotQuestionId })
+ .then(ballotQuestion => {
+ if (!active) return
+ setCanEditBallotQuestionTestimony(
+ !!ballotQuestion &&
+ isActiveBallotQuestionPhase(ballotQuestion.ballotStatus)
+ )
+ })
+ .catch(() => {
+ if (active) setCanEditBallotQuestionTestimony(false)
+ })
+
+ return () => {
+ active = false
+ }
+ }, [ballotQuestion, ballotQuestionId])
+
const FollowClick = async () => {
- await followAction()
+ if (ballotQuestionTopic) {
+ await followBallotQuestion(uid, ballotQuestionTopic)
+ } else {
+ await followBill(uid, bill)
+ }
setFollowStatus(prev => ({ ...prev, [topicName]: true }))
}
const UnfollowClick = async () => {
- await unfollowAction()
+ if (ballotQuestionTopic) {
+ await unfollowBallotQuestion(uid, ballotQuestionTopic)
+ } else {
+ await unfollowBill(uid, bill)
+ }
setFollowStatus(prev => ({ ...prev, [topicName]: false }))
}
@@ -76,7 +145,7 @@ export const PolicyActions: FC> = ({
handleClick(e)}
key="follow"
- billName={`${text} ${billLabel}`}
+ billName={`${text} ${policyLabel}`}
/>
)
items.push(
@@ -86,13 +155,14 @@ export const PolicyActions: FC> = ({
onClick={() => setReporting(!isReporting)}
/>
)
- items.push(
-
- )
+ if (canEditBallotQuestionTestimony)
+ items.push(
+
+ )
const { t } = useTranslation("testimony")
diff --git a/components/testimony/TestimonyDetailPage/TestimonyDetailPage.tsx b/components/testimony/TestimonyDetailPage/TestimonyDetailPage.tsx
index abba5800d..06f087915 100644
--- a/components/testimony/TestimonyDetailPage/TestimonyDetailPage.tsx
+++ b/components/testimony/TestimonyDetailPage/TestimonyDetailPage.tsx
@@ -15,11 +15,6 @@ import { TestimonyDetail } from "./TestimonyDetail"
import { VersionBanner } from "./TestimonyVersionBanner"
import { useAuth } from "components/auth"
import { useMediaQuery } from "usehooks-ts"
-import {
- billTopicName,
- followBill,
- unfollowBill
-} from "components/shared/FollowingQueries"
export const TestimonyDetailPage: FC> = () => {
const [isReporting, setIsReporting] = useState(false)
@@ -27,7 +22,6 @@ export const TestimonyDetailPage: FC> = () => {
const didReport = reportMutation.isError || reportMutation.isSuccess
const isMobile = useMediaQuery("(max-width: 768px)")
const { authorUid, revision } = useCurrentTestimonyDetails()
- const { bill } = useCurrentTestimonyDetails()
const uid = useAuth().user?.uid
const isUser = uid === authorUid
const { t } = useTranslation("testimony", { keyPrefix: "reportModal" })
@@ -52,9 +46,6 @@ export const TestimonyDetailPage: FC> = () => {
isUser={isUser}
isReporting={isReporting}
setReporting={setIsReporting}
- topicName={billTopicName(bill.court, bill.id)}
- followAction={() => followBill(uid, bill)}
- unfollowAction={() => unfollowBill(uid, bill)}
/>
)}
diff --git a/components/testimony/TestimonyDetailPage/testimonyDetailSlice.ts b/components/testimony/TestimonyDetailPage/testimonyDetailSlice.ts
index 06f60cc54..dda6fb2b4 100644
--- a/components/testimony/TestimonyDetailPage/testimonyDetailSlice.ts
+++ b/components/testimony/TestimonyDetailPage/testimonyDetailSlice.ts
@@ -1,5 +1,5 @@
import { AnyAction, createSlice, PayloadAction } from "@reduxjs/toolkit"
-import { Bill, Profile, Testimony } from "components/db"
+import { BallotQuestion, Bill, Profile, Testimony } from "components/db"
import { TestimonyQuery } from "components/db/api"
import { createAppSelector, useAppSelector } from "components/hooks"
import { check } from "components/utils"
@@ -11,6 +11,7 @@ export type PageQuery = TestimonyQuery & { version?: number }
export type PageData = {
testimony: Testimony
bill: Bill
+ ballotQuestion: BallotQuestion | null
author: (Profile & { uid: string }) | null
/** Archived testimony, in descending version order */
archive: Testimony[]
@@ -59,7 +60,7 @@ export const {
const selectTestimonyDetails = createAppSelector(({ testimonyDetail }) => {
const {
- data: { archive, bill, author, testimony },
+ data: { archive, bill, ballotQuestion, author, testimony },
selectedVersion,
...rest
} = check(testimonyDetail)
@@ -73,6 +74,7 @@ const selectTestimonyDetails = createAppSelector(({ testimonyDetail }) => {
authorLink: author && `/profile?id=${author.uid}`,
isEdited: revision.version > 1,
bill,
+ ballotQuestion,
author,
publishedId: testimony.id,
version: revision.version,
diff --git a/docs/ballot-questions-data-model.md b/docs/ballot-questions-data-model.md
new file mode 100644
index 000000000..b25e13f29
--- /dev/null
+++ b/docs/ballot-questions-data-model.md
@@ -0,0 +1,253 @@
+# Ballot Questions: Data Model
+
+## Background
+
+Massachusetts initiative petitions that qualify for the legislature are assigned H-bill numbers (H5000–H5010 in court 194) and referred to the **Special Joint Committee on Initiative Petitions (SJ42)**. They already exist in Maple's bill ingestion pipeline — the API treats them as regular bills with `PrimarySponsor: null`.
+
+The ballot question feature adds a thin `/ballotQuestions` collection that gives each petition its own URL and voter-facing metadata, without touching bills, hearings, or testimony.
+
+---
+
+## Firestore Collection: `/ballotQuestions/{id}`
+
+The document ID is the **petition number** (e.g. `25-14`), which is what mass.gov and voters recognize.
+
+```typescript
+interface BallotQuestion {
+ id: string // petition number: "25-14"
+ billId: string | null // H-bill in the existing bills collection: "H5004"; null for pre-legislature (future)
+ court: number // General Court when referred: 194
+ electionYear: number // Target election year: 2026
+ type:
+ | "initiative_statute"
+ | "initiative_constitutional"
+ | "legislative_referral"
+ | "constitutional_amendment"
+ | "advisory"
+ ballotStatus: "expectedOnBallot" | "failedToAppear" | "accepted" | "rejected"
+ ballotQuestionNumber: number | null // "Question 1" — null until SoS certifies
+ relatedBillIds: string[] // admin-curated, format: "194/H1234"
+
+ // Manually curated voter-facing content (all optional until ready)
+ description: string | null // "What this question would do" — short voter-friendly prose
+ atAGlance: { label: string; value: string }[] | null // "Key Details" bullet list
+ fullSummary: string | null // "Summary generated by the Attorney General as required by law
+ pdfUrl: string | null // Link to the initiative petition PDF
+}
+```
+
+### Manually curated content
+
+Four fields are written by hand in YAML and synced to Firestore. All are optional (`null` until ready).
+
+| Field | Figma element | Quality standard |
+| ------------- | ----------------------------- | ----------------------------------------------------------------------------------- |
+| `description` | "What this question would do" | 1–3 sentences of plain voter-friendly prose. Avoid legalese. |
+| `atAGlance` | "Key Details" bullets | Structured `label`/`value` pairs, 3–6 items. Scannable at a glance. |
+| `fullSummary` | "Final Summary" | Official voter-guide quality language. May be sourced from the initiative petition. |
+| `pdfUrl` | PDF link | Direct URL to the initiative petition PDF (usually from mass.gov). |
+
+Fields can be added to a YAML at any time and will be live after the next sync.
+
+---
+
+### `ballotStatus` lifecycle
+
+| Status | Meaning |
+| ------------------ | -------------------------------------------------------------------------------------------------------------------------------------- |
+| `expectedOnBallot` | Default status for ballot questions working their way through legislature or after legislature has failed to pass bill |
+| `failedToAppear` | Terminal; ballot question has been removed from the ballot for any reason (e.g., legal challenge, failure to collect signatures, etc.) |
+| `accepted` | Terminal; ballot has received a majority of 'yes' votes at election |
+| `rejected` | Terminal; ballot has received a majority of 'no' votes at election |
+
+The page at `/ballotQuestions/[id]` fetches both this document **and** the bill at `billId` — title, full text, hearing schedule, and testimony all come from the bill. Nothing is duplicated.
+
+---
+
+## How petitions are identified in the API
+
+Committee `SJ42` is stable across General Courts:
+
+```
+GET /GeneralCourts/{court}/Committees/SJ42
+ → DocumentsBeforeCommittee active petitions (current session)
+ → ReportedOutDocuments petitions the legislature has acted on
+```
+
+Both sets should have ballot question docs — past petitions remain discoverable.
+
+---
+
+## What requires manual entry
+
+The MA Legislature API does not expose:
+
+| Field | Reason |
+| ------------------------------------------- | -------------------------------------------------- |
+| `ballotStatus: "failedToAppear"` and beyond | Secretary of State process, not in legislature API |
+| `ballotQuestionNumber` | Assigned by SoS when certified, not in API |
+| Petition number (`25-14`) | Hearing descriptions are inconsistently formatted |
+
+All fields are admin-controlled. With ~11 petitions per 2-year cycle this is minimal effort.
+
+---
+
+## Source of truth: YAML files
+
+Ballot question documents are defined as YAML files committed to the repo:
+
+```
+ballotQuestions/2026/
+ 25-14.yaml
+ ...
+```
+
+```yaml
+# ballotQuestions/25-14.yaml
+id: "25-14"
+billId: "H5004"
+court: 194
+electionYear: 2026
+type: initiative_statute
+ballotStatus: expectedOnBallot
+ballotQuestionNumber: null
+relatedBillIds: []
+description: null
+atAGlance: null
+fullSummary: null
+pdfUrl: null
+```
+
+A sync script (`scripts/firebase-admin/syncBallotQuestions.ts`) upserts these to Firestore. Git history is the audit trail; PRs provide review before changes go live.
+
+### Running the sync script
+
+The script is a manual admin operation, run against whichever environment you need:
+
+```bash
+# Local emulator
+yarn firebase-admin -e local run-script syncBallotQuestions
+
+# Staging
+yarn firebase-admin -e dev run-script syncBallotQuestions
+
+# Production (requires GOOGLE_APPLICATION_CREDENTIALS or gcloud ADC)
+yarn firebase-admin -e prod run-script syncBallotQuestions
+```
+
+By default the script reads from `ballotQuestions/` at the repo root. To point it elsewhere:
+
+```bash
+yarn firebase-admin -e prod run-script syncBallotQuestions --dir=/path/to/yamls
+```
+
+The script validates each YAML against the `BallotQuestion` type (`functions/src/ballotQuestions/types.ts`) before writing and will throw on malformed input. Run it after adding or editing any YAML file.
+
+---
+
+## Testimony and ballot questions
+
+"Testimony" is a back-end term referring to the data type that is used consistently throughout MAPLE. Users will see "perspectve" language on the front-end only.
+
+Testimony has an optional `ballotQuestionId` field that distinguishes the two audience-distinct phases:
+
+- **`ballotQuestionId` absent** — legislative testimony, submitted via the bill page while `ballotStatus == "expectedOnBallot"`. Addressed to legislators.
+- **`ballotQuestionId` present** — electorate testimony, submitted via the ballot question page. Addressed to voters.
+
+The ballot question page shows no testimony from when `ballotQuestionId` is absent — it links to the bill page instead. Terminal states (`accepted`, `failedToAppear`, `rejected`) are read-only; no new testimony is accepted.
+
+The ballot question page does not display legislative bill testimony. It always displays ballot-question-scoped testimony (`ballotQuestionId`) only. During `expectedOnBallot`, that feed/count will typically be empty because electorate testimony is not yet accepted. Users who want to read testimony submitted to the legislature should click through to the corresponding bill page. Bill testimony and ballot-question testimony are still stored separately with no overlap.
+
+### Querying ballot question testimony
+
+```ts
+collection("publishedTestimony")
+ .where("ballotQuestionId", "==", "25-14")
+ .orderBy("publishedAt", "desc")
+```
+
+A composite index on `(ballotQuestionId ASC, publishedAt DESC)` is declared in `firestore.indexes.json`.
+
+---
+
+## What does NOT change
+
+- Bill documents — no new fields
+- Bill ingestion — no changes
+- Hearing sync — no changes
+- Firestore security rules for testimony
+- `testimonyCount` and related counters on Bill documents
+
+---
+
+## Security
+
+`firestore.rules` contains:
+
+```
+match /ballotQuestions/{id} {
+ allow read: if true;
+ allow write: if false;
+}
+```
+
+**Why `write: if false` is correct here**
+
+Firestore security rules only apply to browser clients (the Firebase JS SDK). The Admin SDK — used by the sync script — bypasses rules entirely. `write: if false` does not block the sync script; it blocks users from writing directly from their browser.
+
+We don't need an admin role check (like `request.auth.token.get("role", "user") == "admin"` used elsewhere) because the only writer is the sync script. There's no admin UI for ballot questions, so a browser write of any kind is wrong.
+
+---
+
+## Queries & Indexes
+
+### Listing page (`/ballotQuestions`)
+
+Fetch all ballot questions for the current election year:
+
+```ts
+collection("ballotQuestions").where("electionYear", "==", 2026)
+```
+
+Optionally filtered by status (e.g. "currently before legislature"):
+
+```ts
+collection("ballotQuestions")
+ .where("electionYear", "==", 2026)
+ .where("ballotStatus", "==", "expectedOnBallot")
+```
+
+### Detail page (`/ballotQuestions/25-14`)
+
+Direct document fetch — no query needed:
+
+```ts
+doc("ballotQuestions", "25-14")
+```
+
+Then a second fetch for the legislative data:
+
+```ts
+doc("generalCourts/194/bills", ballotQuestion.billId)
+```
+
+### Indexes
+
+Firestore auto-indexes single fields. Composite indexes (multiple fields in one query) must be declared in `firestore.indexes.json`.
+
+The listing query (`electionYear == 2026`) is a single equality filter — Firestore handles it automatically. The filtered listing adds a second field, so it needs a composite index.
+
+`firestore.indexes.json` already declares this index:
+
+```json
+{
+ "collectionGroup": "ballotQuestions",
+ "queryScope": "COLLECTION",
+ "fields": [
+ { "fieldPath": "electionYear", "order": "ASCENDING" },
+ { "fieldPath": "ballotStatus", "order": "ASCENDING" }
+ ]
+}
+```
+
+With ~11 documents Firestore would be fast without the index, but declaring it is correct practice and prevents a runtime error if someone adds `.orderBy()` later.
diff --git a/docs/ballot-questions-frontend.md b/docs/ballot-questions-frontend.md
new file mode 100644
index 000000000..1b2da3e33
--- /dev/null
+++ b/docs/ballot-questions-frontend.md
@@ -0,0 +1,278 @@
+# Ballot Questions — Frontend Architecture
+
+## Overview
+
+This document describes how to build the ballot question detail page (`/ballotQuestions/[id]`). The backend is complete (Firestore collection, security rules, indexes, types, sync script, db query methods). This document covers the UX architecture, data flow, component reuse map, and the UI changes needed when the page is built.
+
+---
+
+## Page layout: `/ballotQuestions/[id]`
+
+The page is divided into a header card and a two-column section below it. Only the **Overview** and **Testimonies** tabs need to be wired for the initial build. The remaining nav items (Synthesis & Insights, For & Against, News & Media, Academia, Campaign Financials, Map) are placeholders.
+
+```
+┌─────────────────────────────────────────────────────────────────┐
+│ « Return to ballot questions [Follow] │
+│ │
+│ Question N | Short name ┌──────────────────┐│
+│ Type: Law ⓘ │ Your Testimony ││
+│ │ ││
+│ Full title (large) │ [Create ││
+│ │ testimony] ││
+│ ┌──────────────────────────────────────┐ └──────────────────┘│
+│ │ What this question would do: │ │
+│ │ │ [PDF link] [Bill link]│
+│ └──────────────────────────────────────┘ │
+└─────────────────────────────────────────────────────────────────┘
+
+┌───────────────┬──────────────────────────────────────────────────┐
+│ Overview ● │ Overview │
+│ │ The ballot question at a high-level │
+│ Testimonies │ │
+│ 42 │ Key Details │
+│ │ ┌──────────────────────────────┐ │
+│ Synthesis & │ │ At a glance: │ │
+│ Insights │ │ • Who: ... │ │
+│ │ │ • How: ... │ │
+│ For & │ └──────────────────────────────┘ │
+│ Against │ │
+│ │ Final Summary ⓘ │
+│ News & │ │
+│ Media 20 │ │
+│ │ Committee Hearing │
+│ Academia 20 │ │
+│ │ • Status: Occurred / Scheduled │
+│ Campaign │ • Date: [date] │
+│ Financials │ │
+│ │ Watch the committee hearing here. [link] │
+│ Map │ │
+└───────────────┴──────────────────────────────────────────────────┘
+```
+
+---
+
+## Data model
+
+The voter-facing fields are already in `functions/src/ballotQuestions/types.ts`:
+
+```typescript
+interface BallotQuestion {
+ // ... existing fields ...
+ description: string | null // "What this question would do" — header card + DescriptionBox
+ atAGlance: { label: string; value: string }[] | null // Key Details bullet list in Overview
+ fullSummary: string | null // Final Summary section in Overview
+ pdfUrl: string | null // Link to the initiative petition PDF
+}
+```
+
+`billId` (already in the schema) provides the bill link in the header. `pdfUrl` is manually set in the YAML file for each petition. All four fields are nullable — render their sections only when the value is non-null.
+
+---
+
+## `getServerSideProps`
+
+```typescript
+const ballotQuestion = await dbService().getBallotQuestion({ id })
+if (!ballotQuestion) return { notFound: true }
+
+// billId can be null (pre-legislature); hide bill link and hearing section when null
+const bill = ballotQuestion.billId
+ ? await dbService().getBill({
+ court: ballotQuestion.court,
+ billId: ballotQuestion.billId
+ })
+ : null
+
+// Fetch hearing documents for the Overview tab.
+// bill.hearingIds contains eventId strings; documents are at /events/hearing-{eventId}.
+// SJ42 petitions typically have 0–1 hearings so fetching all is cheap.
+const hearings = bill?.hearingIds?.length
+ ? await Promise.all(
+ bill.hearingIds.map(id =>
+ dbService().getDocData("events", `hearing-${id}`)
+ )
+ ).then(results => results.filter(Boolean))
+ : []
+
+return {
+ props: { ballotQuestion, bill, hearings },
+ headers: { "Cache-Control": "s-maxage=60, stale-while-revalidate=300" }
+}
+```
+
+Cache is shorter than bills because `ballotStatus` changes during election season.
+
+---
+
+## Header card
+
+The header always shows:
+
+- Back link ("Return to ballot questions")
+- Follow button
+- Question number + short name (from `ballotQuestion.id` + type label)
+- Full title (from `bill.Title`, or a standalone title field if `billId` is null)
+- "What this question would do" description box (`ballotQuestion.description`)
+- **Your Testimony** panel (right side) — see testimony routing below
+- PDF link (`ballotQuestion.pdfUrl`) — open in new tab; hidden if null
+- Bill link — `/bills/{court}/{billId}` — hidden if `billId` is null
+
+---
+
+## Testimony routing by status
+
+The **Your Testimony** panel lives in the header card, not inside a tab. It is always visible.
+
+| `ballotStatus` | Your Testimony panel | Testimonies tab feed |
+| ---------------------------------- | ------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
+| `legislature` | "Testify on the bill →" link (no form here) | Ballot feed (`ballotQuestionId`). This will typically be empty / count `0` because ballot-question testimony is not yet accepted. |
+| `qualifying` | **Active** form (`ballotQuestionId` set) | Ballot feed (`ballotQuestionId`) |
+| `certified` | **Active** form (`ballotQuestionId` set) | Ballot feed (`ballotQuestionId`) |
+| `ballot` | **Active** form (`ballotQuestionId` set) | Ballot feed (`ballotQuestionId`) |
+| `enacted` / `failed` / `withdrawn` | No form. Read-only notice. | Ballot feed read-only |
+
+Active `ballotQuestionId` testimony phases: `qualifying | certified | ballot`.
+
+During `legislature`, the ballot question page should not embed bill testimony. It should still show ballot-question testimony counts/feed, which will normally be empty because electorate testimony is not yet available. If the user wants to read legislative testimony, they should click through to the corresponding bill page.
+
+---
+
+## Overview tab
+
+Three sections, in order:
+
+### 1. Key Details
+
+Renders `ballotQuestion.atAGlance` as a list of `label: value` pairs inside a card. This is manually curated in the YAML file.
+
+### 2. Final Summary
+
+Renders `ballotQuestion.fullSummary` as body text. Manually curated.
+
+### 3. Committee Hearing
+
+Reads from `hearings` (fetched server-side from `bill.hearingIds`). If no hearings exist, this section is hidden.
+
+For each relevant hearing, display:
+
+- **Status**: "Occurred" if `hearing.content.startsAt` is in the past, "Scheduled" if in the future
+- **Date**: formatted from `hearing.content.startsAt`
+- **Watch link**: "Watch the committee hearing here." linked to `hearing.videoURL` — hidden if no video
+
+Since ballot questions are always under SJ42 and typically have one hearing, render a single hearing block. If there are multiple, render them in reverse chronological order (most recent first).
+
+**Hearing data model recap:**
+
+- `bill.hearingIds?: string[]` — event IDs; doc path is `/events/hearing-{id}`
+- `bill.nextHearingAt?: Timestamp` — convenience field for upcoming hearing only (not sufficient alone — we need date + videoURL from the full document)
+- `hearing.videoURL?: string` — link for the "Watch" CTA
+- `hearing.content.startsAt` — determines "Occurred" vs. "Scheduled" status
+
+No new components are needed for hearing display — build a simple `CommitteeHearing` component local to `components/ballotquestions/`.
+
+---
+
+## Component reuse map
+
+| Component | File | Used where | Props |
+| --------------------------------- | ------- | --------------- | --------- |
+| `ViewTestimony` / `TestimonyItem` | various | Testimonies tab | unchanged |
+
+`SponsorsAndCommittees` is **not** reused here. The hearing display in Overview is custom (`CommitteeHearing`) because the Figma shows a richer layout (status badge, explanatory copy, video link) than what `SponsorsAndCommittees` renders.
+
+---
+
+## New files
+
+```
+pages/
+ ballotQuestions/
+ [id].tsx ← page entry point + getServerSideProps
+
+components/
+ ballotquestions/
+ BallotQuestionDetails.tsx ← top-level layout, receives { ballotQuestion, bill, hearings }
+ BallotQuestionHeader.tsx ← header card: title, description box, testimony panel, PDF/bill links
+ BallotQuestionNav.tsx ← vertical left nav (Overview, Testimonies, + placeholder items)
+ DescriptionBox.tsx ← "What this question would do" card
+ OverviewTab.tsx ← Key Details + Final Summary + CommitteeHearing
+ CommitteeHearing.tsx ← hearing status, date, and watch link
+ TestimoniesTab.tsx ← testimony feed, wired to usePublishedTestimonyListing
+ YourTestimonyPanel.tsx ← header-right testimony CTA, status-conditional (see routing table)
+```
+
+---
+
+## Existing files that need changes
+
+### `components/publish/panel/TestimonyFormPanel.tsx`
+
+Add an optional `ballotQuestionId` prop. When present, persist it into the draft write at `DraftTestimony.ballotQuestionId`.
+
+```typescript
+// YourTestimonyPanel — active ballot question phases
+
+
+// legislature phase — links to bill page instead, no form rendered here
+```
+
+### `functions/src/testimony/publishTestimony.ts`
+
+Two changes required in `PublishTestimonyTransaction`:
+
+**1. Validate `ballotQuestionId` in `resolveDraft()`**
+
+After the existing bill existence check (lines 175–183), add a parallel check for ballot question. `ballotQuestionId` is user-supplied and must be validated at publish time, same as `billId`:
+
+```typescript
+if (draft.ballotQuestionId) {
+ const bqSnap = await db
+ .doc(`/ballotQuestions/${draft.ballotQuestionId}`)
+ .get()
+ if (!bqSnap.exists) {
+ throw fail(
+ "failed-precondition",
+ `Draft testimony has invalid ballot question ID ${draft.ballotQuestionId}`
+ )
+ }
+}
+```
+
+**2. Propagate `ballotQuestionId` into `newPublication` in `run()`**
+
+The field is on `BaseTestimony` (which both `DraftTestimony` and `Testimony` extend) but is not currently copied from draft to publication. Add it to the `newPublication` object:
+
+```typescript
+const newPublication: Testimony = {
+ // ... existing fields ...
+ ballotQuestionId: this.draft.ballotQuestionId ?? null
+}
+```
+
+### `components/bill/BillTestimonies.tsx`
+
+Generalize to accept either `{ bill: Bill }` or `{ ballotQuestionId: string }` so the Testimonies tab can use it with a ballot question filter. Consider renaming to `Testimonies` (the bill-specific name is an accident of where it was first used).
+
+### `components/db/testimony/usePublishedTestimonyListing.ts`
+
+Add `ballotQuestionId` to `Refinement` and `getWhere()` (~5 lines). The Firestore index is already deployed. Change is purely additive — no existing call sites affected.
+
+```typescript
+// Refinement addition
+ballotQuestionId?: string
+
+// getWhere addition
+if (ballotQuestionId)
+ constraints.push(["ballotQuestionId", "==", ballotQuestionId])
+```
+
+---
+
+## What does NOT change
+
+- Testimony Firestore paths or collection structure
+- `testimonyCount` / position counts on `Bill` documents
+- How `TestimonyFormPanel` triggers the cloud function (draft ID passed to `publishTestimony` callable — no change to that invocation)
+- `ViewTestimony` renderer, `TestimonyItem` — reused as-is
+- All existing bill pages and their testimony behavior
+- Hearing sync — no changes to `/events` collection or ingestion
diff --git a/firestore.indexes.json b/firestore.indexes.json
index cdf966cf6..427795d41 100644
--- a/firestore.indexes.json
+++ b/firestore.indexes.json
@@ -18,6 +18,28 @@
}
]
},
+ {
+ "collectionGroup": "archivedTestimony",
+ "queryScope": "COLLECTION",
+ "fields": [
+ {
+ "fieldPath": "billId",
+ "order": "ASCENDING"
+ },
+ {
+ "fieldPath": "court",
+ "order": "ASCENDING"
+ },
+ {
+ "fieldPath": "ballotQuestionId",
+ "order": "ASCENDING"
+ },
+ {
+ "fieldPath": "version",
+ "order": "DESCENDING"
+ }
+ ]
+ },
{
"collectionGroup": "bills",
"queryScope": "COLLECTION",
@@ -727,6 +749,31 @@
"order": "ASCENDING"
}
]
+ },
+ {
+ "collectionGroup": "ballotQuestions",
+ "queryScope": "COLLECTION",
+ "fields": [
+ { "fieldPath": "electionYear", "order": "ASCENDING" },
+ { "fieldPath": "ballotStatus", "order": "ASCENDING" }
+ ]
+ },
+ {
+ "collectionGroup": "publishedTestimony",
+ "queryScope": "COLLECTION_GROUP",
+ "fields": [
+ { "fieldPath": "ballotQuestionId", "order": "ASCENDING" },
+ { "fieldPath": "publishedAt", "order": "DESCENDING" }
+ ]
+ },
+ {
+ "collectionGroup": "publishedTestimony",
+ "queryScope": "COLLECTION",
+ "fields": [
+ { "fieldPath": "billId", "order": "ASCENDING" },
+ { "fieldPath": "court", "order": "ASCENDING" },
+ { "fieldPath": "ballotQuestionId", "order": "ASCENDING" }
+ ]
}
],
"fieldOverrides": [
@@ -865,6 +912,17 @@
"queryScope": "COLLECTION_GROUP"
}
]
+ },
+ {
+ "collectionGroup": "publishedTestimony",
+ "fieldPath": "ballotQuestionId",
+ "ttl": false,
+ "indexes": [
+ {
+ "order": "ASCENDING",
+ "queryScope": "COLLECTION_GROUP"
+ }
+ ]
}
]
}
diff --git a/firestore.rules b/firestore.rules
index 978809c0f..a95586279 100644
--- a/firestore.rules
+++ b/firestore.rules
@@ -99,6 +99,10 @@ service cloud.firestore {
allow read, write: if request.auth.token.get("role", "user") == "admin"
}
}
+ match /ballotQuestions/{id} {
+ allow read: if true;
+ allow write: if false;
+ }
match /transcriptions/{tid} {
// public, read-only
allow read: if true
diff --git a/functions/src/ballotQuestions/types.ts b/functions/src/ballotQuestions/types.ts
new file mode 100644
index 000000000..cca6adeb4
--- /dev/null
+++ b/functions/src/ballotQuestions/types.ts
@@ -0,0 +1,61 @@
+import {
+ Array,
+ Literal as L,
+ Null,
+ Number,
+ Record,
+ Static,
+ String,
+ Union
+} from "runtypes"
+import { withDefaults } from "../common"
+
+const BallotQuestionStatus = Union(
+ L("legislature"),
+ L("qualifying"),
+ L("certified"),
+ L("ballot"),
+ L("enacted"),
+ L("failed"),
+ L("withdrawn"),
+ L("expectedOnBallot"),
+ L("failedToAppear"),
+ L("rejected"),
+ L("accepted")
+)
+
+export type BallotQuestion = Static
+export const BallotQuestion = withDefaults(
+ Record({
+ id: String,
+ billId: Union(String, Null),
+ court: Number,
+ electionYear: Number,
+ type: Union(
+ L("initiative_statute"),
+ L("initiative_constitutional"),
+ L("legislative_referral"),
+ L("constitutional_amendment"),
+ L("advisory")
+ ),
+ ballotStatus: BallotQuestionStatus,
+ ballotQuestionNumber: Union(Number, Null),
+ relatedBillIds: Array(String),
+ description: Union(String, Null),
+ atAGlance: Union(Array(Record({ label: String, value: String })), Null),
+ fullSummary: Union(String, Null),
+ pdfUrl: Union(String, Null),
+ title: Union(String, Null),
+ testimonyCount: Number,
+ endorseCount: Number,
+ neutralCount: Number,
+ opposeCount: Number
+ }),
+ {
+ title: null,
+ testimonyCount: 0,
+ endorseCount: 0,
+ neutralCount: 0,
+ opposeCount: 0
+ }
+)
diff --git a/functions/src/email/digestEmail.handlebars b/functions/src/email/digestEmail.handlebars
index c2ece56bf..017d59c95 100644
--- a/functions/src/email/digestEmail.handlebars
+++ b/functions/src/email/digestEmail.handlebars
@@ -9,6 +9,8 @@
{{> bills }}
+ {{> ballotQuestions }}
+
{{> users }}
{{> newsFeedLink }}
diff --git a/functions/src/email/partials/ballotQuestions/ballotQuestion.handlebars b/functions/src/email/partials/ballotQuestions/ballotQuestion.handlebars
new file mode 100644
index 000000000..b18508fcf
--- /dev/null
+++ b/functions/src/email/partials/ballotQuestions/ballotQuestion.handlebars
@@ -0,0 +1,31 @@
+
+
+
+
+
+
+ {{addCounts this.endorseCount this.neutralCount this.opposeCount}}
+
+
+
+ New {{pluralize this}}
+
+
+
+ {{this.endorseCount}} Endorse
+
+ {{this.neutralCount}} Neutral
+
+ {{this.opposeCount}} Oppose
+
+
+
+
diff --git a/functions/src/email/partials/ballotQuestions/ballotQuestions.handlebars b/functions/src/email/partials/ballotQuestions/ballotQuestions.handlebars
new file mode 100644
index 000000000..f94a70b0c
--- /dev/null
+++ b/functions/src/email/partials/ballotQuestions/ballotQuestions.handlebars
@@ -0,0 +1,45 @@
+
+
+
+ Ballot Questions
+
+
+
+
+ {{#each ballotQuestions}}
+ {{> ballotQuestion}}
+ {{else}}
+
+ {{> noUpdates }}
+
+ {{/each}}
+
+
+ {{#ifGreaterThan numBallotQuestionsWithNewTestimony 4}}
+
+
+ {{#ifGreaterThan numBallotQuestionsWithNewTestimony 5}}
+ {{minusFour numBallotQuestionsWithNewTestimony}} other ballot questions you follow received testimony since your last update
+ {{else}}
+ {{minusFour numBallotQuestionsWithNewTestimony}} other ballot question you follow received testimony since your last update
+ {{/ifGreaterThan}}
+
+
+
+ {{else}}
+ {{/ifGreaterThan}}
+
+
diff --git a/functions/src/index.ts b/functions/src/index.ts
index b396108d9..641255bf4 100644
--- a/functions/src/index.ts
+++ b/functions/src/index.ts
@@ -42,6 +42,7 @@ export {
} from "./testimony"
export {
publishNotifications,
+ populateBallotQuestionNotificationEvents,
populateBillHistoryNotificationEvents,
populateTestimonySubmissionNotificationEvents,
cleanupNotifications,
diff --git a/functions/src/notifications/deliverNotifications.test.ts b/functions/src/notifications/deliverNotifications.test.ts
new file mode 100644
index 000000000..79f9b5f7e
--- /dev/null
+++ b/functions/src/notifications/deliverNotifications.test.ts
@@ -0,0 +1,304 @@
+import { buildDigestData } from "./deliverNotifications"
+import { Timestamp } from "../firebase"
+
+// Mock firebase module
+jest.mock("../firebase", () => ({
+ auth: {},
+ db: {
+ collection: jest.fn()
+ },
+ Timestamp: {
+ fromDate: (d: Date) => ({ toDate: () => d, seconds: d.getTime() / 1000 }),
+ now: () => ({ toDate: () => new Date() })
+ }
+}))
+
+// Mock helpers
+jest.mock("./helpers", () => ({
+ getNotificationStartDate: jest.fn((_freq, now) => now),
+ getNextDigestAt: jest.fn()
+}))
+
+// Mock handlebars helpers
+jest.mock("../email/handlebarsHelpers", () => ({
+ prepareHandlebars: jest.fn()
+}))
+
+const makeTimestamp = (d: Date) => ({
+ toDate: () => d,
+ seconds: d.getTime() / 1000
+})
+
+const makeNotificationDoc = (fields: object) => ({
+ data: () => ({ notification: fields })
+})
+
+const mockDb = require("../firebase").db
+
+const setupCollection = (docs: object[]) => {
+ const get = jest
+ .fn()
+ .mockResolvedValue({ docs: docs.map(makeNotificationDoc) })
+ const queryBuilder: { where: jest.Mock; get: jest.Mock } = {
+ where: jest.fn(),
+ get
+ }
+ queryBuilder.where.mockReturnValue(queryBuilder)
+ mockDb.collection.mockReturnValue(queryBuilder)
+}
+
+const now = makeTimestamp(new Date("2025-03-01")) as unknown as typeof Timestamp
+
+describe("buildDigestData — bills", () => {
+ it("aggregates bill testimony by position", async () => {
+ setupCollection([
+ {
+ type: "testimony",
+ isBillMatch: true,
+ isUserMatch: false,
+ isBallotQuestionMatch: false,
+ billId: "H1234",
+ header: "A Bill",
+ court: "193",
+ position: "endorse",
+ timestamp: now,
+ authorUid: "user1",
+ subheader: "Alice",
+ bodyText: "",
+ testimonyId: "t1",
+ userRole: "user",
+ delivered: false,
+ ballotQuestionId: null,
+ ballotQuestionCourt: null
+ },
+ {
+ type: "testimony",
+ isBillMatch: true,
+ isUserMatch: false,
+ isBallotQuestionMatch: false,
+ billId: "H1234",
+ header: "A Bill",
+ court: "193",
+ position: "oppose",
+ timestamp: now,
+ authorUid: "user2",
+ subheader: "Bob",
+ bodyText: "",
+ testimonyId: "t2",
+ userRole: "user",
+ delivered: false,
+ ballotQuestionId: null,
+ ballotQuestionCourt: null
+ }
+ ])
+
+ const result = await buildDigestData("uid1", now as any, "Weekly")
+
+ expect(result.bills).toHaveLength(1)
+ expect(result.bills[0]).toMatchObject({
+ billId: "H1234",
+ endorseCount: 1,
+ opposeCount: 1,
+ neutralCount: 0
+ })
+ expect(result.numBillsWithNewTestimony).toBe(1)
+ })
+})
+
+describe("buildDigestData — ballot questions", () => {
+ it("aggregates ballot question testimony by position", async () => {
+ setupCollection([
+ {
+ type: "testimony",
+ isBillMatch: false,
+ isUserMatch: false,
+ isBallotQuestionMatch: true,
+ billId: "H0001",
+ header: "Question 1: Should we do the thing?",
+ court: "193",
+ position: "endorse",
+ timestamp: now,
+ authorUid: "user1",
+ subheader: "Alice",
+ bodyText: "",
+ testimonyId: "t1",
+ userRole: "user",
+ delivered: false,
+ ballotQuestionId: "bq-1",
+ ballotQuestionCourt: 193
+ },
+ {
+ type: "testimony",
+ isBillMatch: false,
+ isUserMatch: false,
+ isBallotQuestionMatch: true,
+ billId: "H0001",
+ header: "Question 1: Should we do the thing?",
+ court: "193",
+ position: "neutral",
+ timestamp: now,
+ authorUid: "user2",
+ subheader: "Bob",
+ bodyText: "",
+ testimonyId: "t2",
+ userRole: "user",
+ delivered: false,
+ ballotQuestionId: "bq-1",
+ ballotQuestionCourt: 193
+ }
+ ])
+
+ const result = await buildDigestData("uid1", now as any, "Weekly")
+
+ expect(result.ballotQuestions).toHaveLength(1)
+ expect(result.ballotQuestions[0]).toMatchObject({
+ ballotQuestionId: "bq-1",
+ description: "Question 1: Should we do the thing?",
+ endorseCount: 1,
+ neutralCount: 1,
+ opposeCount: 0
+ })
+ expect(result.numBallotQuestionsWithNewTestimony).toBe(1)
+ })
+
+ it("caps ballot questions at 4 and reports full count", async () => {
+ const bqDocs = Array.from({ length: 6 }, (_, i) => ({
+ type: "testimony",
+ isBillMatch: false,
+ isUserMatch: false,
+ isBallotQuestionMatch: true,
+ billId: "H0001",
+ header: `Question ${i + 1}`,
+ court: "193",
+ position: "endorse",
+ timestamp: now,
+ authorUid: `user${i}`,
+ subheader: `User ${i}`,
+ bodyText: "",
+ testimonyId: `t${i}`,
+ userRole: "user",
+ delivered: false,
+ ballotQuestionId: `bq-${i}`,
+ ballotQuestionCourt: 193
+ }))
+ setupCollection(bqDocs)
+
+ const result = await buildDigestData("uid1", now as any, "Weekly")
+
+ expect(result.ballotQuestions).toHaveLength(4)
+ expect(result.numBallotQuestionsWithNewTestimony).toBe(6)
+ })
+
+ it("returns empty ballot questions when there are no isBallotQuestionMatch notifications", async () => {
+ setupCollection([
+ {
+ type: "testimony",
+ isBillMatch: true,
+ isUserMatch: false,
+ isBallotQuestionMatch: false,
+ billId: "H1234",
+ header: "A Bill",
+ court: "193",
+ position: "endorse",
+ timestamp: now,
+ authorUid: "user1",
+ subheader: "Alice",
+ bodyText: "",
+ testimonyId: "t1",
+ userRole: "user",
+ delivered: false,
+ ballotQuestionId: null,
+ ballotQuestionCourt: null
+ }
+ ])
+
+ const result = await buildDigestData("uid1", now as any, "Weekly")
+
+ expect(result.ballotQuestions).toHaveLength(0)
+ expect(result.numBallotQuestionsWithNewTestimony).toBe(0)
+ })
+
+ it("skips ballot question notification if ballotQuestionId is null", async () => {
+ setupCollection([
+ {
+ type: "testimony",
+ isBillMatch: false,
+ isUserMatch: false,
+ isBallotQuestionMatch: true,
+ billId: "H0001",
+ header: "Question",
+ court: "193",
+ position: "endorse",
+ timestamp: now,
+ authorUid: "user1",
+ subheader: "Alice",
+ bodyText: "",
+ testimonyId: "t1",
+ userRole: "user",
+ delivered: false,
+ ballotQuestionId: null,
+ ballotQuestionCourt: null
+ }
+ ])
+
+ const result = await buildDigestData("uid1", now as any, "Weekly")
+
+ expect(result.ballotQuestions).toHaveLength(0)
+ })
+})
+
+describe("buildDigestData — users", () => {
+ it("aggregates user testimony across bills", async () => {
+ setupCollection([
+ {
+ type: "testimony",
+ isBillMatch: false,
+ isUserMatch: true,
+ isBallotQuestionMatch: false,
+ billId: "H1234",
+ header: "A Bill",
+ court: "193",
+ position: "endorse",
+ timestamp: now,
+ authorUid: "author1",
+ subheader: "Alice",
+ bodyText: "",
+ testimonyId: "t1",
+ userRole: "user",
+ delivered: false,
+ ballotQuestionId: null,
+ ballotQuestionCourt: null
+ },
+ {
+ type: "testimony",
+ isBillMatch: false,
+ isUserMatch: true,
+ isBallotQuestionMatch: false,
+ billId: "H5678",
+ header: "Another Bill",
+ court: "193",
+ position: "oppose",
+ timestamp: now,
+ authorUid: "author1",
+ subheader: "Alice",
+ bodyText: "",
+ testimonyId: "t2",
+ userRole: "user",
+ delivered: false,
+ ballotQuestionId: null,
+ ballotQuestionCourt: null
+ }
+ ])
+
+ const result = await buildDigestData("uid1", now as any, "Weekly")
+
+ expect(result.users).toHaveLength(1)
+ expect(result.users[0]).toMatchObject({
+ userId: "author1",
+ userName: "Alice",
+ newTestimonyCount: 2
+ })
+ expect(result.users[0].bills).toHaveLength(2)
+ expect(result.numUsersWithNewTestimony).toBe(1)
+ })
+})
diff --git a/functions/src/notifications/deliverNotifications.ts b/functions/src/notifications/deliverNotifications.ts
index 935cf4d5e..75cdba7e3 100644
--- a/functions/src/notifications/deliverNotifications.ts
+++ b/functions/src/notifications/deliverNotifications.ts
@@ -10,6 +10,7 @@ import {
import { startOfDay } from "date-fns"
import { TestimonySubmissionNotificationFields, Profile } from "./types"
import {
+ BallotQuestionDigest,
BillDigest,
NotificationEmailDigest,
Position,
@@ -22,12 +23,12 @@ const PROFILE_BATCH_SIZE = 50
const NUM_BILLS_TO_DISPLAY = 4
const NUM_USERS_TO_DISPLAY = 4
const NUM_TESTIMONIES_TO_DISPLAY = 6
+const NUM_BALLOT_QUESTIONS_TO_DISPLAY = 4
const EMAIL_TEMPLATE_PATH = "../email/digestEmail.handlebars"
const path = require("path")
const getVerifiedUserEmail = async (uid: string) => {
- // TODO: Try/catch is temporarily while troubleshooting the auth issue
try {
const userRecord = await auth.getUser(uid)
if (userRecord && userRecord.email && userRecord.emailVerified) {
@@ -42,7 +43,6 @@ const getVerifiedUserEmail = async (uid: string) => {
}
// TODO: Batching (at both user + email level)?
-// Going to wait until we have a better idea of the performance impact
const deliverEmailNotifications = async () => {
const now = Timestamp.fromDate(startOfDay(new Date()))
@@ -86,8 +86,6 @@ const deliverEmailNotifications = async () => {
return
}
- // TODO: Temporarily using email from the profile to test the non-auth issues
- // Should only use email from `auth` once that's working
const defaultEmail = profile.email || profile.contactInfo?.publicEmail
const verifiedEmail =
(await getVerifiedUserEmail(profileDoc.id)) || defaultEmail
@@ -106,10 +104,10 @@ const deliverEmailNotifications = async () => {
const batch = db.batch()
- // If there are no new notifications, don't send an email
if (
digestData.numBillsWithNewTestimony === 0 &&
- digestData.numUsersWithNewTestimony === 0
+ digestData.numUsersWithNewTestimony === 0 &&
+ digestData.numBallotQuestionsWithNewTestimony === 0
) {
console.log(
`No new notifications for ${profileDoc.id} - not sending email`
@@ -143,10 +141,8 @@ const deliverEmailNotifications = async () => {
)
})
- // Wait for all email documents to be created
await Promise.all(emailPromises)
- // Fetch the next batch of profiles
profilesSnapshot = await db
.collection("profiles")
.where("nextDigestAt", "<=", now)
@@ -158,8 +154,7 @@ const deliverEmailNotifications = async () => {
console.log(`Finished processing ${numProfilesProcessed} profiles`)
}
-// TODO: Unit tests
-const buildDigestData = async (
+export const buildDigestData = async (
userId: string,
now: Timestamp,
notificationFrequency: Frequency
@@ -175,6 +170,7 @@ const buildDigestData = async (
const billsById: { [billId: string]: BillDigest } = {}
const usersById: { [userId: string]: UserDigest } = {}
+ const ballotQuestionsById: { [bqId: string]: BallotQuestionDigest } = {}
notificationsSnapshot.docs.forEach(notificationDoc => {
const { notification } =
@@ -229,6 +225,35 @@ const buildDigestData = async (
}
}
}
+
+ if (notification.isBallotQuestionMatch && notification.ballotQuestionId) {
+ const bqId = notification.ballotQuestionId
+ if (ballotQuestionsById[bqId]) {
+ const bq = ballotQuestionsById[bqId]
+ switch (notification.position) {
+ case "endorse":
+ bq.endorseCount++
+ break
+ case "neutral":
+ bq.neutralCount++
+ break
+ case "oppose":
+ bq.opposeCount++
+ break
+ default:
+ console.error(`Unknown position: ${notification.position}`)
+ break
+ }
+ } else {
+ ballotQuestionsById[bqId] = {
+ ballotQuestionId: bqId,
+ description: notification.header,
+ endorseCount: notification.position === "endorse" ? 1 : 0,
+ neutralCount: notification.position === "neutral" ? 1 : 0,
+ opposeCount: notification.position === "oppose" ? 1 : 0
+ }
+ }
+ }
})
const bills = Object.values(billsById).sort((a, b) => {
@@ -249,26 +274,35 @@ const buildDigestData = async (
})
.sort((a, b) => b.newTestimonyCount - a.newTestimonyCount)
- const digestData = {
+ const ballotQuestions = Object.values(ballotQuestionsById).sort((a, b) => {
+ return (
+ b.endorseCount +
+ b.neutralCount +
+ b.opposeCount -
+ (a.endorseCount + a.neutralCount + a.opposeCount)
+ )
+ })
+
+ return {
notificationFrequency,
startDate: startDate.toDate(),
endDate: now.toDate(),
bills: bills.slice(0, NUM_BILLS_TO_DISPLAY),
numBillsWithNewTestimony: bills.length,
users: users.slice(0, NUM_USERS_TO_DISPLAY),
- numUsersWithNewTestimony: users.length
+ numUsersWithNewTestimony: users.length,
+ ballotQuestions: ballotQuestions.slice(0, NUM_BALLOT_QUESTIONS_TO_DISPLAY),
+ numBallotQuestionsWithNewTestimony: ballotQuestions.length
}
-
- return digestData
}
+// TODO: Unit tests
const renderToHtmlString = (digestData: NotificationEmailDigest) => {
+ // TODO: Can we move the compilation up so we only compile the template once?
const templateSource = fs.readFileSync(
path.join(__dirname, EMAIL_TEMPLATE_PATH),
"utf8"
)
- // TODO: Can we move the compilation up so we only compile the template
- // once per job run instead of once per email?
const compiledTemplate = handlebars.compile(templateSource)
return compiledTemplate(digestData)
}
diff --git a/functions/src/notifications/emailTypes.ts b/functions/src/notifications/emailTypes.ts
index 90c2efc30..3ddf6fc99 100644
--- a/functions/src/notifications/emailTypes.ts
+++ b/functions/src/notifications/emailTypes.ts
@@ -20,6 +20,13 @@ export type UserDigest = {
bills: BillResult[]
newTestimonyCount: number // displayed bills are capped at 6
}
+export type BallotQuestionDigest = {
+ ballotQuestionId: string
+ description: string
+ endorseCount: number
+ neutralCount: number
+ opposeCount: number
+}
export type NotificationEmailDigest = {
notificationFrequency: Frequency
startDate: Date
@@ -28,4 +35,6 @@ export type NotificationEmailDigest = {
numBillsWithNewTestimony: number
users: UserDigest[] // cap of 4
numUsersWithNewTestimony: number
+ ballotQuestions: BallotQuestionDigest[] // cap of 4
+ numBallotQuestionsWithNewTestimony: number
}
diff --git a/functions/src/notifications/index.ts b/functions/src/notifications/index.ts
index 04299a040..a1d14561a 100644
--- a/functions/src/notifications/index.ts
+++ b/functions/src/notifications/index.ts
@@ -1,5 +1,6 @@
// Import the functions
import { publishNotifications } from "./publishNotifications"
+import { populateBallotQuestionNotificationEvents } from "./populateBallotQuestionNotificationEvents"
import { populateBillHistoryNotificationEvents } from "./populateBillHistoryNotificationEvents"
import { populateTestimonySubmissionNotificationEvents } from "./populateTestimonySubmissionNotificationEvents"
import { cleanupNotifications } from "./cleanupNotifications"
@@ -9,6 +10,7 @@ import { updateUserNotificationFrequency } from "./updateUserNotificationFrequen
// Export the functions
export {
publishNotifications,
+ populateBallotQuestionNotificationEvents,
populateBillHistoryNotificationEvents,
populateTestimonySubmissionNotificationEvents,
cleanupNotifications,
diff --git a/functions/src/notifications/populateBallotQuestionNotificationEvents.test.ts b/functions/src/notifications/populateBallotQuestionNotificationEvents.test.ts
new file mode 100644
index 000000000..f5ff85772
--- /dev/null
+++ b/functions/src/notifications/populateBallotQuestionNotificationEvents.test.ts
@@ -0,0 +1,120 @@
+import { populateBallotQuestionNotificationEventsHandler } from "./populateBallotQuestionNotificationEvents"
+
+jest.mock("firebase-functions", () => ({
+ firestore: {
+ document: jest.fn().mockReturnValue({ onWrite: jest.fn() })
+ }
+}))
+
+jest.mock("../firebase", () => ({
+ db: {
+ collection: jest.fn()
+ },
+ Timestamp: {
+ now: jest.fn(() => ({ seconds: 1000 }))
+ }
+}))
+
+const mockDb = require("../firebase").db
+
+const mockAdd = jest.fn().mockResolvedValue({})
+const mockUpdate = jest.fn().mockResolvedValue({})
+const mockDoc = jest.fn().mockReturnValue({ update: mockUpdate })
+
+const setupCollection = (empty: boolean, docs: { id: string }[] = []) => {
+ const query = {
+ where: jest.fn(),
+ get: jest.fn().mockResolvedValue({ empty, docs }),
+ add: mockAdd,
+ doc: mockDoc
+ }
+ query.where.mockReturnValue(query)
+ mockDb.collection.mockReturnValue(query)
+}
+
+const makeBqData = (ballotStatus: string) => ({
+ court: 194,
+ ballotStatus,
+ description: "A test ballot question"
+})
+
+const makeSnapshot = (before: object | null, after: object | null) => ({
+ before: { exists: before !== null, data: () => before },
+ after: { exists: after !== null, data: () => after }
+})
+
+const makeContext = (id = "25-01") => ({ params: { id } })
+
+beforeEach(() => {
+ jest.clearAllMocks()
+})
+
+describe("populateBallotQuestionNotificationEventsHandler", () => {
+ it("does nothing when after snapshot does not exist", async () => {
+ const snapshot = makeSnapshot(makeBqData("legislature"), null)
+ await populateBallotQuestionNotificationEventsHandler(
+ snapshot as any,
+ makeContext() as any
+ )
+ expect(mockDb.collection).not.toHaveBeenCalled()
+ })
+
+ it("does nothing when ballotStatus is unchanged", async () => {
+ const snapshot = makeSnapshot(
+ makeBqData("legislature"),
+ makeBqData("legislature")
+ )
+ await populateBallotQuestionNotificationEventsHandler(
+ snapshot as any,
+ makeContext() as any
+ )
+ expect(mockDb.collection).not.toHaveBeenCalled()
+ })
+
+ it("creates a new notificationEvent when status changes and none exists", async () => {
+ setupCollection(true)
+
+ const snapshot = makeSnapshot(
+ makeBqData("legislature"),
+ makeBqData("qualifying")
+ )
+ await populateBallotQuestionNotificationEventsHandler(
+ snapshot as any,
+ makeContext() as any
+ )
+
+ expect(mockAdd).toHaveBeenCalledWith(
+ expect.objectContaining({
+ type: "ballotQuestion",
+ ballotQuestionId: "25-01",
+ ballotQuestionCourt: 194,
+ ballotStatus: "qualifying",
+ description: "A test ballot question"
+ })
+ )
+ expect(mockUpdate).not.toHaveBeenCalled()
+ })
+
+ it("updates existing notificationEvent when status changes and one exists", async () => {
+ const existingDocId = "existing-event-id"
+ setupCollection(false, [{ id: existingDocId }])
+
+ const snapshot = makeSnapshot(
+ makeBqData("qualifying"),
+ makeBqData("certified")
+ )
+ await populateBallotQuestionNotificationEventsHandler(
+ snapshot as any,
+ makeContext() as any
+ )
+
+ expect(mockDoc).toHaveBeenCalledWith(existingDocId)
+ expect(mockUpdate).toHaveBeenCalledWith(
+ expect.objectContaining({
+ ballotStatus: "certified",
+ description: "A test ballot question"
+ })
+ )
+ expect(mockAdd).not.toHaveBeenCalled()
+ })
+})
diff --git a/functions/src/notifications/populateBallotQuestionNotificationEvents.ts b/functions/src/notifications/populateBallotQuestionNotificationEvents.ts
new file mode 100644
index 000000000..fe15b11ec
--- /dev/null
+++ b/functions/src/notifications/populateBallotQuestionNotificationEvents.ts
@@ -0,0 +1,57 @@
+import * as functions from "firebase-functions"
+import { db, Timestamp } from "../firebase"
+
+export const populateBallotQuestionNotificationEventsHandler = async (
+ snapshot: functions.Change,
+ context: functions.EventContext
+) => {
+ if (!snapshot.after.exists) {
+ console.error("New snapshot does not exist")
+ return
+ }
+
+ const oldData = snapshot.before.data()
+ const newData = snapshot.after.data()
+
+ if (oldData?.ballotStatus === newData?.ballotStatus) {
+ console.log("ballotStatus unchanged, skipping notification event")
+ return
+ }
+
+ const { id } = context.params
+
+ const existingSnapshot = await db
+ .collection("/notificationEvents")
+ .where("type", "==", "ballotQuestion")
+ .where("ballotQuestionId", "==", id)
+ .get()
+
+ if (!existingSnapshot.empty) {
+ console.log("Updating existing ballot question notification event")
+ const docId = existingSnapshot.docs[0].id
+ await db
+ .collection("/notificationEvents")
+ .doc(docId)
+ .update({
+ ballotStatus: newData?.ballotStatus,
+ description: newData?.description ?? null,
+ updateTime: Timestamp.now()
+ })
+ } else {
+ console.log("Creating new ballot question notification event")
+ if (newData) {
+ await db.collection("/notificationEvents").add({
+ type: "ballotQuestion",
+ ballotQuestionId: id,
+ ballotQuestionCourt: newData.court,
+ ballotStatus: newData.ballotStatus,
+ description: newData.description ?? null,
+ updateTime: Timestamp.now()
+ })
+ }
+ }
+}
+
+export const populateBallotQuestionNotificationEvents = functions.firestore
+ .document("/ballotQuestions/{id}")
+ .onWrite(populateBallotQuestionNotificationEventsHandler)
diff --git a/functions/src/notifications/populateBillHistoryNotificationEvents.ts b/functions/src/notifications/populateBillHistoryNotificationEvents.ts
index 21f51ef8c..14d27d44d 100644
--- a/functions/src/notifications/populateBillHistoryNotificationEvents.ts
+++ b/functions/src/notifications/populateBillHistoryNotificationEvents.ts
@@ -45,8 +45,15 @@ export const populateBillHistoryNotificationEvents = functions.firestore
return
}
- const oldLength = oldData?.history.length
- const newLength = newData?.history.length
+ const oldLength = oldData?.history.length ?? 0
+ const newLength = newData?.history.length ?? 0
+
+ if (newLength < 1) {
+ console.log(
+ "New bill history empty, skipping notification event creation."
+ )
+ return
+ }
const historyChanged = oldLength !== newLength
console.log(`oldLength: ${oldLength}, newLength: ${newLength}`)
diff --git a/functions/src/notifications/populateTestimonySubmissionNotificationEvents.ts b/functions/src/notifications/populateTestimonySubmissionNotificationEvents.ts
index da229482e..7680c0a16 100644
--- a/functions/src/notifications/populateTestimonySubmissionNotificationEvents.ts
+++ b/functions/src/notifications/populateTestimonySubmissionNotificationEvents.ts
@@ -30,6 +30,11 @@ export const populateTestimonySubmissionNotificationEvents = functions.firestore
if (documentCreated) {
console.log("New document created")
+ const bqId: string | null = newData?.ballotQuestionId ?? null
+ const bqCourt: number | null = bqId
+ ? (newData?.court as number) ?? null
+ : null
+
const newNotificationEvent: TestimonySubmissionNotification = {
type: "testimony",
@@ -45,6 +50,9 @@ export const populateTestimonySubmissionNotificationEvents = functions.firestore
testimonyContent: newData?.content,
testimonyVersion: newData?.version,
+ ballotQuestionId: bqId,
+ ballotQuestionCourt: bqCourt,
+
updateTime: Timestamp.now()
}
diff --git a/functions/src/notifications/publishNotifications.ts b/functions/src/notifications/publishNotifications.ts
index 14908d03c..27724bfae 100644
--- a/functions/src/notifications/publishNotifications.ts
+++ b/functions/src/notifications/publishNotifications.ts
@@ -8,6 +8,8 @@ import * as functions from "firebase-functions"
import { getFirestore } from "firebase-admin/firestore"
import { Timestamp } from "../firebase"
import {
+ BallotQuestionUpdateNotification,
+ BallotQuestionUpdateNotificationFields,
BillHistoryUpdateNotification,
BillHistoryUpdateNotificationFields,
TestimonySubmissionNotification,
@@ -19,10 +21,14 @@ import { cloneDeep } from "lodash"
const db = getFirestore()
const createNotificationFields = (
- entity: BillHistoryUpdateNotification | TestimonySubmissionNotification
+ entity:
+ | BillHistoryUpdateNotification
+ | TestimonySubmissionNotification
+ | BallotQuestionUpdateNotification
):
| BillHistoryUpdateNotificationFields
- | TestimonySubmissionNotificationFields => {
+ | TestimonySubmissionNotificationFields
+ | BallotQuestionUpdateNotificationFields => {
switch (entity.type) {
case "bill":
if (entity.billHistory.length < 1) {
@@ -62,13 +68,32 @@ const createNotificationFields = (
type: "testimony",
isBillMatch: false,
isUserMatch: false,
+ isBallotQuestionMatch: false,
delivered: false,
authorUid: entity.userId,
testimonyId: entity.testimonyId,
- userRole: entity.userRole
+ userRole: entity.userRole,
+ ballotQuestionId: entity.ballotQuestionId ?? null,
+ ballotQuestionCourt: entity.ballotQuestionCourt ?? null
},
createdAt: Timestamp.now()
}
+ case "ballotQuestion":
+ return {
+ uid: "",
+ notification: {
+ type: "ballotQuestion",
+ ballotQuestionId: entity.ballotQuestionId,
+ ballotQuestionCourt: entity.ballotQuestionCourt,
+ ballotStatus: entity.ballotStatus,
+ header: entity.description,
+ timestamp: entity.updateTime,
+ isBallotQuestionMatch: true,
+ delivered: false
+ },
+ createdAt: Timestamp.now()
+ }
+
default:
console.log(`Invalid entity: ${entity}`)
throw new Error(`Invalid entity: ${entity}`)
@@ -83,6 +108,7 @@ export const publishNotifications = functions.firestore
const topic = snapshot?.after.data() as
| BillHistoryUpdateNotification
| TestimonySubmissionNotification
+ | BallotQuestionUpdateNotification
| undefined
if (!topic) {
@@ -98,22 +124,33 @@ export const publishNotifications = functions.firestore
console.log(`topic type: ${topic.type}`)
const handleNotifications = async (
- topic: BillHistoryUpdateNotification | TestimonySubmissionNotification
+ topic:
+ | BillHistoryUpdateNotification
+ | TestimonySubmissionNotification
+ | BallotQuestionUpdateNotification
) => {
const notificationFields = createNotificationFields(topic)
+ const topicNames =
+ topic.type === "bill"
+ ? [`bill-${topic.billCourt}-${topic.billId}`]
+ : topic.type === "ballotQuestion"
+ ? [
+ `ballot-question-${topic.ballotQuestionCourt}-${topic.ballotQuestionId}`
+ ]
+ : [
+ `testimony-${topic.userId}`,
+ `bill-${topic.billCourt}-${topic.billId}`,
+ ...(topic.ballotQuestionId && topic.ballotQuestionCourt != null
+ ? [
+ `ballot-question-${topic.ballotQuestionCourt}-${topic.ballotQuestionId}`
+ ]
+ : [])
+ ]
+
const topicNameSnapshot = await db
.collectionGroup("activeTopicSubscriptions")
- .where(
- "topicName",
- "in",
- topic.type === "bill"
- ? [`bill-${topic.billCourt}-${topic.billId}`]
- : [
- `testimony-${topic.userId}`,
- `bill-${topic.billCourt}-${topic.billId}`
- ]
- )
+ .where("topicName", "in", topicNames)
.get()
const users: { [uid: string]: any } = {}
@@ -133,9 +170,13 @@ export const publishNotifications = functions.firestore
// If the topic is a testimony and the user is not the author of the testimony
if (topic.type === "testimony" && topic.userId !== uid) {
initializeUserNotification(uid)
- users[uid].notification[
- type === "testimony" ? "isUserMatch" : "isBillMatch"
- ] = true
+ if (type === "testimony") {
+ users[uid].notification.isUserMatch = true
+ } else if (type === "ballotQuestion") {
+ users[uid].notification.isBallotQuestionMatch = true
+ } else {
+ users[uid].notification.isBillMatch = true
+ }
}
// If the topic is a bill
@@ -143,6 +184,11 @@ export const publishNotifications = functions.firestore
initializeUserNotification(uid)
users[uid].notification["isBillMatch"] = true
}
+
+ if (topic.type === "ballotQuestion") {
+ initializeUserNotification(uid)
+ users[uid].notification.isBallotQuestionMatch = true
+ }
})
// Iterate through users and set the notifications
diff --git a/functions/src/notifications/types.ts b/functions/src/notifications/types.ts
index b25278ec3..e748f24de 100644
--- a/functions/src/notifications/types.ts
+++ b/functions/src/notifications/types.ts
@@ -24,6 +24,8 @@ export type TestimonySubmissionNotification = Notification & {
testimonyPosition: string
testimonyContent: string
testimonyVersion: number
+ ballotQuestionId?: string | null
+ ballotQuestionCourt?: number | null
}
export interface TestimonySubmissionNotificationFields {
@@ -39,10 +41,13 @@ export interface TestimonySubmissionNotificationFields {
position: string
isBillMatch: boolean
isUserMatch: boolean
+ isBallotQuestionMatch: boolean
delivered: boolean
testimonyId: string
userRole: string
authorUid: string
+ ballotQuestionId?: string | null
+ ballotQuestionCourt?: number | null
}
createdAt: FirebaseFirestore.Timestamp
}
@@ -64,6 +69,30 @@ export interface BillHistoryUpdateNotificationFields {
createdAt: FirebaseFirestore.Timestamp
}
+export type BallotQuestionUpdateNotification = {
+ type: "ballotQuestion"
+ updateTime: Timestamp
+ ballotQuestionId: string
+ ballotQuestionCourt: number
+ ballotStatus: string
+ description: string | null
+}
+
+export interface BallotQuestionUpdateNotificationFields {
+ uid: string
+ notification: {
+ type: "ballotQuestion"
+ ballotQuestionId: string
+ ballotQuestionCourt: number
+ ballotStatus: string
+ header: string | null
+ timestamp: FirebaseFirestore.Timestamp
+ isBallotQuestionMatch: boolean
+ delivered: boolean
+ }
+ createdAt: FirebaseFirestore.Timestamp
+}
+
export interface Profile {
email?: string
notificationFrequency?: Frequency
diff --git a/functions/src/search/SearchIndexer.ts b/functions/src/search/SearchIndexer.ts
index bc409d239..b1488edba 100644
--- a/functions/src/search/SearchIndexer.ts
+++ b/functions/src/search/SearchIndexer.ts
@@ -23,10 +23,17 @@ export class SearchIndexer {
private collection: Collection | undefined
constructor(private readonly config: CollectionConfig) {
- const schemaHash = hash(config.schema, {
- algorithm: "md5",
- unorderedArrays: true
- })
+ const schemaHash = hash(
+ {
+ schema: config.schema,
+ filter: config.filter?.toString(),
+ convert: config.convert.toString()
+ },
+ {
+ algorithm: "md5",
+ unorderedArrays: true
+ }
+ )
this.collectionName = `${config.alias}_${schemaHash}`
}
diff --git a/functions/src/subscriptions/types.ts b/functions/src/subscriptions/types.ts
index 0360df3d7..6a45ff3ac 100644
--- a/functions/src/subscriptions/types.ts
+++ b/functions/src/subscriptions/types.ts
@@ -11,4 +11,9 @@ export interface TopicSubscription {
profileId: string
fullName: string
}
+
+ ballotQuestionLookup?: {
+ ballotQuestionId: string
+ court: number
+ }
}
diff --git a/functions/src/testimony/deleteTestimony.ts b/functions/src/testimony/deleteTestimony.ts
index 5e6e464ae..4ee1a210c 100644
--- a/functions/src/testimony/deleteTestimony.ts
+++ b/functions/src/testimony/deleteTestimony.ts
@@ -1,6 +1,7 @@
import { DocumentSnapshot } from "@google-cloud/firestore"
import { https, logger } from "firebase-functions"
import { Record } from "runtypes"
+import { BallotQuestion } from "../ballotQuestions/types"
import { Bill } from "../bills/types"
import {
checkAuth,
@@ -63,6 +64,8 @@ class DeleteTestimonyTransaction {
private publication!: Testimony
private billSnap!: DocumentSnapshot
private bill!: Bill
+ private ballotQuestion?: BallotQuestion
+ private ballotQuestionRef?: FirebaseFirestore.DocumentReference
private draftSnap?: DocumentSnapshot
constructor(
@@ -79,6 +82,7 @@ class DeleteTestimonyTransaction {
await this.loadPublication()
if (!this.publicationSnap.exists) return { deleted: false }
await this.loadBill()
+ await this.loadBallotQuestion()
await this.loadDraft()
const billUpdate: DocUpdate = {
@@ -91,6 +95,12 @@ class DeleteTestimonyTransaction {
}
this.t.update(this.billSnap.ref, billUpdate)
+ if (this.ballotQuestion && this.ballotQuestionRef) {
+ this.t.update(
+ this.ballotQuestionRef,
+ updateTestimonyCounts(this.ballotQuestion, this.publication, undefined)
+ )
+ }
this.t.delete(this.publicationSnap.ref)
if (this.draftSnap) this.t.update(this.draftSnap.ref, draftUpdate)
@@ -132,6 +142,17 @@ class DeleteTestimonyTransaction {
}
}
+ private async loadBallotQuestion() {
+ const ballotQuestionId = this.publication.ballotQuestionId ?? null
+ if (!ballotQuestionId) return
+
+ this.ballotQuestionRef = db.doc(`/ballotQuestions/${ballotQuestionId}`)
+ const snap = await this.t.get(this.ballotQuestionRef)
+ if (!snap.exists) return
+
+ this.ballotQuestion = BallotQuestion.checkWithDefaults(snap.data())
+ }
+
private async resolveNewLatestTestimony() {
const result = await this.t.get(
db
diff --git a/functions/src/testimony/publishTestimony.ts b/functions/src/testimony/publishTestimony.ts
index 33f0364ec..237610cd7 100644
--- a/functions/src/testimony/publishTestimony.ts
+++ b/functions/src/testimony/publishTestimony.ts
@@ -2,6 +2,7 @@ import { DocumentReference, DocumentSnapshot } from "@google-cloud/firestore"
import { https, logger } from "firebase-functions"
import { nanoid } from "nanoid"
import { Record } from "runtypes"
+import { BallotQuestion } from "../ballotQuestions/types"
import { Bill } from "../bills/types"
import { checkAuth, checkRequest, DocUpdate, fail, Id } from "../common"
import { db, FieldValue, Timestamp } from "../firebase"
@@ -58,8 +59,10 @@ class PublishTestimonyTransaction {
private draftSnap!: DocumentSnapshot
private draft!: DraftTestimony
+ private bqId!: string | null
private billSnap!: DocumentSnapshot
private bill!: Bill
+ private ballotQuestion?: BallotQuestion
private publicationRef!: DocumentReference
private currentPublication?: Testimony
private profile?: any
@@ -93,7 +96,8 @@ class PublishTestimonyTransaction {
attachmentId: this.attachments.id,
draftAttachmentId: this.attachments.draftId,
fullName: this.profile?.fullName ?? "Anonymous",
- public: this.profile?.public ?? true
+ public: this.profile?.public ?? true,
+ ballotQuestionId: this.bqId
}
if (this.profile?.representative?.id) {
newPublication.representativeId = this.profile.representative.id
@@ -113,6 +117,7 @@ class PublishTestimonyTransaction {
this.createArchive(newPublication)
this.updateDraft(newPublication)
this.updateBill(newPublication)
+ this.updateBallotQuestion(newPublication)
return {
publicationId: this.publicationRef.id,
@@ -153,6 +158,19 @@ class PublishTestimonyTransaction {
this.t.update(this.billSnap.ref, billTestimonyFields)
}
+ private updateBallotQuestion(newPublication: Testimony) {
+ if (!this.bqId || !this.ballotQuestion) return
+
+ const ballotQuestionFields: DocUpdate =
+ updateTestimonyCounts(
+ this.ballotQuestion,
+ this.currentPublication,
+ newPublication
+ )
+
+ this.t.update(db.doc(`/ballotQuestions/${this.bqId}`), ballotQuestionFields)
+ }
+
private async resolveDraft() {
const ref = db.doc(`/users/${this.uid}/draftTestimony/${this.draftId}`),
draftSnap = await this.t.get(ref)
@@ -172,20 +190,38 @@ class PublishTestimonyTransaction {
await this.checkValidCourt(draft.court)
- const billSnap = await db
- .doc(`/generalCourts/${draft.court}/bills/${draft.billId}`)
- .get()
+ const bqId = draft.ballotQuestionId ?? null
+ const billRef = db.doc(
+ `/generalCourts/${draft.court}/bills/${draft.billId}`
+ )
+ const bqRef = bqId !== null ? db.doc(`/ballotQuestions/${bqId}`) : null
+ const [billSnap, bqSnap] = await Promise.all([
+ this.t.get(billRef),
+ bqRef ? this.t.get(bqRef) : Promise.resolve(null)
+ ])
+
if (!billSnap.exists) {
throw fail(
"failed-precondition",
`Draft testimony has invalid bill ID ${draft.billId}`
)
}
+ if (bqSnap !== null && !bqSnap.exists) {
+ throw fail(
+ "failed-precondition",
+ `Draft testimony has invalid ballotQuestionId ${bqId}`
+ )
+ }
this.draft = draft
+ this.bqId = bqId
this.draftSnap = draftSnap
this.billSnap = billSnap
this.bill = Bill.checkWithDefaults(billSnap.data())
+ this.ballotQuestion =
+ bqSnap && bqSnap.exists
+ ? BallotQuestion.checkWithDefaults(bqSnap.data())
+ : undefined
}
private async resolveProfile() {
@@ -240,6 +276,7 @@ class PublishTestimonyTransaction {
.collection(`/users/${this.uid}/archivedTestimony`)
.where("billId", "==", this.draft.billId)
.where("court", "==", this.draft.court)
+ .where("ballotQuestionId", "==", this.bqId)
.orderBy("version", "desc")
.limit(1)
)
@@ -263,6 +300,7 @@ class PublishTestimonyTransaction {
.collection(`/users/${this.uid}/publishedTestimony`)
.where("billId", "==", this.draft.billId)
.where("court", "==", this.draft.court)
+ .where("ballotQuestionId", "==", this.bqId)
.limit(1)
)
diff --git a/functions/src/testimony/search.ts b/functions/src/testimony/search.ts
index 264c41eed..5947b7ae5 100644
--- a/functions/src/testimony/search.ts
+++ b/functions/src/testimony/search.ts
@@ -27,6 +27,7 @@ export const {
],
default_sorting_field: "publishedAt"
},
+ filter: data => !data.ballotQuestionId,
convert: data => {
const validation = Testimony.validateWithDefaults(data)
if (!validation.success) {
diff --git a/functions/src/testimony/types.ts b/functions/src/testimony/types.ts
index bf5e8e4b5..429ad08bb 100644
--- a/functions/src/testimony/types.ts
+++ b/functions/src/testimony/types.ts
@@ -24,7 +24,8 @@ const BaseTestimony = R({
),
attachmentId: Maybe(RtString),
/** Only present if testimony was edited (has a version greater than 1) */
- editReason: Maybe(RtString)
+ editReason: Maybe(RtString),
+ ballotQuestionId: Maybe(RtString)
})
export type Testimony = Static
diff --git a/functions/src/testimony/updateTestimonyCounts.test.ts b/functions/src/testimony/updateTestimonyCounts.test.ts
new file mode 100644
index 000000000..68c76210d
--- /dev/null
+++ b/functions/src/testimony/updateTestimonyCounts.test.ts
@@ -0,0 +1,63 @@
+import { updateTestimonyCounts } from "./updateTestimonyCounts"
+
+describe("updateTestimonyCounts", () => {
+ it("increments counts for a new publication", () => {
+ expect(
+ updateTestimonyCounts(
+ {
+ testimonyCount: 0,
+ endorseCount: 0,
+ neutralCount: 0,
+ opposeCount: 0
+ },
+ undefined,
+ { position: "endorse" }
+ )
+ ).toEqual({
+ testimonyCount: 1,
+ endorseCount: 1,
+ neutralCount: 0,
+ opposeCount: 0
+ })
+ })
+
+ it("swaps counts correctly when a testimony changes position", () => {
+ expect(
+ updateTestimonyCounts(
+ {
+ testimonyCount: 3,
+ endorseCount: 1,
+ neutralCount: 1,
+ opposeCount: 1
+ },
+ { position: "neutral" },
+ { position: "oppose" }
+ )
+ ).toEqual({
+ testimonyCount: 3,
+ endorseCount: 1,
+ neutralCount: 0,
+ opposeCount: 2
+ })
+ })
+
+ it("decrements counts for a deletion without going below zero", () => {
+ expect(
+ updateTestimonyCounts(
+ {
+ testimonyCount: 1,
+ endorseCount: 1,
+ neutralCount: 0,
+ opposeCount: 0
+ },
+ { position: "endorse" },
+ undefined
+ )
+ ).toEqual({
+ testimonyCount: 0,
+ endorseCount: 0,
+ neutralCount: 0,
+ opposeCount: 0
+ })
+ })
+})
diff --git a/functions/src/testimony/updateTestimonyCounts.ts b/functions/src/testimony/updateTestimonyCounts.ts
index 9c1a06e49..2970c13c0 100644
--- a/functions/src/testimony/updateTestimonyCounts.ts
+++ b/functions/src/testimony/updateTestimonyCounts.ts
@@ -1,19 +1,24 @@
-import { Bill } from "../bills/types"
import { countsByPositions, Testimony } from "./types"
-type FieldUpdate = Pick<
- Bill,
- "opposeCount" | "neutralCount" | "endorseCount" | "testimonyCount"
->
+export type TestimonyCountFields = {
+ testimonyCount: number
+ endorseCount: number
+ neutralCount: number
+ opposeCount: number
+}
-/** Updates bill fields that track testimony counts based on changes to the
- * user's testimony. */
+/** Updates testimony-count fields based on changes to the user's testimony. */
export function updateTestimonyCounts(
- { testimonyCount, endorseCount, neutralCount, opposeCount }: Bill,
- currentPublication?: Testimony,
- newPublication?: Testimony
-): FieldUpdate {
- const update: FieldUpdate = {
+ {
+ testimonyCount,
+ endorseCount,
+ neutralCount,
+ opposeCount
+ }: TestimonyCountFields,
+ currentPublication?: Pick,
+ newPublication?: Pick
+): TestimonyCountFields {
+ const update: TestimonyCountFields = {
testimonyCount,
endorseCount,
neutralCount,
diff --git a/package.json b/package.json
index 5a0cbc74d..b4b40980d 100644
--- a/package.json
+++ b/package.json
@@ -20,8 +20,8 @@
"dev:update": "yarn compose down -v && yarn compose build",
"dev:backfill": "sh ./scripts/backfill-dev.sh",
"emulators:start": "firebase --project demo-dtp emulators:start --only auth,functions,pubsub,firestore,storage --import tests/integration/exportedTestData",
- "typesense-admin": "ts-node -P tsconfig.script.json scripts/typesense-admin.ts --swc",
- "firebase-admin": "ts-node -P tsconfig.script.json scripts/firebase-admin --swc",
+ "typesense-admin": "ts-node --swc -P tsconfig.script.json scripts/typesense-admin.ts",
+ "firebase-admin": "ts-node --swc -P tsconfig.script.json scripts/firebase-admin",
"generate-stories": "ts-node -P tsconfig.script.json scripts/generate-stories",
"compose": "docker compose -f infra/docker-compose.yml",
"dev:functions": "yarn --cwd functions dev",
@@ -150,6 +150,8 @@
"@storybook/nextjs": "^7.6.4",
"@storybook/react": "^7.6.4",
"@storybook/testing-library": "^0.2.2",
+ "@swc/core": "^1.15.21",
+ "@swc/helpers": "^0.5.19",
"@testing-library/jest-dom": "^6.1.5",
"@testing-library/react": "^14.1.2",
"@testing-library/user-event": "^14.5.1",
diff --git a/pages/_document.tsx b/pages/_document.tsx
new file mode 100644
index 000000000..32503c2b4
--- /dev/null
+++ b/pages/_document.tsx
@@ -0,0 +1,30 @@
+import Document, {
+ DocumentContext,
+ DocumentInitialProps,
+ Head,
+ Html,
+ Main,
+ NextScript
+} from "next/document"
+
+class MapleDocument extends Document {
+ static async getInitialProps(
+ ctx: DocumentContext
+ ): Promise {
+ return Document.getInitialProps(ctx)
+ }
+
+ render() {
+ return (
+
+
+
+
+
+
+
+ )
+ }
+}
+
+export default MapleDocument
diff --git a/pages/about/in-the-news.tsx b/pages/about/in-the-news.tsx
new file mode 100644
index 000000000..1eff2c707
--- /dev/null
+++ b/pages/about/in-the-news.tsx
@@ -0,0 +1,17 @@
+import { createPage } from "../../components/page"
+import { InTheNews } from "../../components/InTheNews/InTheNews"
+import { createGetStaticTranslationProps } from "components/translations"
+
+export default createPage({
+ titleI18nKey: "titles.in_the_news",
+ Page: () => {
+ return
+ }
+})
+
+export const getStaticProps = createGetStaticTranslationProps([
+ "auth",
+ "common",
+ "footer",
+ "inTheNews"
+])
diff --git a/pages/ballotQuestions/[id].tsx b/pages/ballotQuestions/[id].tsx
new file mode 100644
index 000000000..15394d031
--- /dev/null
+++ b/pages/ballotQuestions/[id].tsx
@@ -0,0 +1,107 @@
+import { dbService } from "components/db/api"
+import { doc, getDoc } from "firebase/firestore"
+import { firestore } from "components/firebase"
+import { GetServerSideProps } from "next"
+import { flags } from "components/featureFlags"
+import { z } from "zod"
+import { BallotQuestionDetails } from "../../components/ballotquestions/BallotQuestionDetails"
+import {
+ BallotQuestionTestimonySummary,
+ Hearing
+} from "../../components/ballotquestions/types"
+import { BallotQuestion, Bill } from "../../components/db"
+import { createPage } from "../../components/page"
+import { usePublishService } from "../../components/publish/hooks"
+import { serverSideTranslations } from "next-i18next/serverSideTranslations"
+
+const Query = z.object({ id: z.string() })
+
+async function getHearing(id: string): Promise {
+ const snap = await getDoc(doc(firestore, "events", id))
+ if (!snap.exists()) return null
+ const data = snap.data()
+ return {
+ id,
+ videoURL: data.videoURL ?? undefined,
+ startsAt: data.startsAt?.toMillis() ?? 0
+ }
+}
+
+export default createPage<{
+ ballotQuestion: BallotQuestion
+ bill: Bill | null
+ hearings: Hearing[]
+ testimonySummary: BallotQuestionTestimonySummary
+}>({
+ titleI18nKey: "titles.ballotQuestion",
+ Page: ({ ballotQuestion, bill, hearings, testimonySummary }) => {
+ return (
+ <>
+
+
+ >
+ )
+ }
+})
+
+export const getServerSideProps: GetServerSideProps = async ctx => {
+ if (!flags().ballotQuestions) return { notFound: true }
+ ctx.res.setHeader(
+ "Cache-Control",
+ "public, s-maxage=60, stale-while-revalidate=300"
+ )
+
+ const locale = ctx.locale ?? ctx.defaultLocale ?? "en"
+
+ const query = Query.safeParse(ctx.query)
+ if (!query.success) return { notFound: true }
+
+ const ballotQuestion = await dbService().getBallotQuestion({
+ id: query.data.id
+ })
+ if (!ballotQuestion) return { notFound: true }
+
+ let bill: Bill | null = null
+ let hearings: Hearing[] = []
+ const testimonySummary: BallotQuestionTestimonySummary = {
+ testimonyCount: ballotQuestion.testimonyCount ?? 0,
+ endorseCount: ballotQuestion.endorseCount ?? 0,
+ neutralCount: ballotQuestion.neutralCount ?? 0,
+ opposeCount: ballotQuestion.opposeCount ?? 0
+ }
+
+ if (ballotQuestion.billId) {
+ bill =
+ (await dbService().getBill({
+ court: ballotQuestion.court,
+ billId: ballotQuestion.billId
+ })) ?? null
+
+ if (bill?.hearingIds?.length) {
+ hearings = await Promise.all(
+ bill.hearingIds.map(id => getHearing(id).catch(() => null))
+ ).then(results => results.filter((h): h is Hearing => h !== null))
+ }
+ }
+
+ return {
+ props: {
+ ballotQuestion: JSON.parse(JSON.stringify(ballotQuestion)),
+ bill: bill ? JSON.parse(JSON.stringify(bill)) : null,
+ hearings: JSON.parse(JSON.stringify(hearings)),
+ testimonySummary,
+ ...(await serverSideTranslations(locale, [
+ "auth",
+ "common",
+ "footer",
+ "testimony",
+ "profile"
+ ]))
+ }
+ }
+}
diff --git a/pages/ballotQuestions/index.tsx b/pages/ballotQuestions/index.tsx
new file mode 100644
index 000000000..60030e40d
--- /dev/null
+++ b/pages/ballotQuestions/index.tsx
@@ -0,0 +1,98 @@
+import { Container } from "components/bootstrap"
+import { dbService } from "components/db"
+import { BrowseBallotQuestions } from "components/ballotquestions/BrowseBallotQuestions"
+import type { BallotQuestionBrowseItem } from "components/ballotquestions/BrowseBallotQuestions"
+import { createPage } from "components/page"
+import type { BallotQuestion } from "components/db"
+import { useTranslation } from "next-i18next"
+import { serverSideTranslations } from "next-i18next/serverSideTranslations"
+import { GetServerSideProps } from "next"
+import { flags } from "components/featureFlags"
+
+type BrowseBallotQuestionsPageProps = {
+ currentYear: number
+ items: BallotQuestionBrowseItem[]
+}
+
+export default createPage({
+ titleI18nKey: "navigation.browseBallotQuestions",
+ Page: ({ currentYear, items }: BrowseBallotQuestionsPageProps) => {
+ const { t } = useTranslation("search")
+
+ return (
+
+ {t("browse_ballot_questions")}
+
+ {t("browse_ballot_questions_intro")}
+
+
+
+ )
+ }
+})
+
+export const getServerSideProps: GetServerSideProps<
+ BrowseBallotQuestionsPageProps
+> = async ({ locale, res }) => {
+ if (!flags().ballotQuestions) return { notFound: true }
+ const currentYear = new Date().getFullYear()
+ const ballotQuestions = (await dbService().getBallotQuestions()).sort(
+ sortBallotQuestions
+ )
+
+ const items = await Promise.all(
+ ballotQuestions.map(async ballotQuestion => {
+ const bill = ballotQuestion.billId
+ ? await dbService().getBill({
+ court: ballotQuestion.court,
+ billId: ballotQuestion.billId
+ })
+ : undefined
+
+ return {
+ id: ballotQuestion.id,
+ title:
+ ballotQuestion.title ??
+ bill?.content.Title ??
+ `Question ${ballotQuestion.ballotQuestionNumber ?? "#"}`,
+ fullSummary: ballotQuestion.fullSummary ?? "",
+ electionYear: ballotQuestion.electionYear,
+ court: ballotQuestion.court,
+ ballotStatus: ballotQuestion.ballotStatus,
+ ballotQuestionNumber: ballotQuestion.ballotQuestionNumber,
+ endorseCount: ballotQuestion.endorseCount ?? 0,
+ neutralCount: ballotQuestion.neutralCount ?? 0,
+ opposeCount: ballotQuestion.opposeCount ?? 0
+ }
+ })
+ )
+
+ res.setHeader("Cache-Control", "s-maxage=60, stale-while-revalidate=300")
+
+ return {
+ props: {
+ currentYear,
+ items,
+ ...(await serverSideTranslations(locale ?? "en", [
+ "auth",
+ "search",
+ "common",
+ "footer",
+ "testimony"
+ ]))
+ }
+ }
+}
+
+function sortBallotQuestions(a: BallotQuestion, b: BallotQuestion) {
+ if (a.electionYear !== b.electionYear) {
+ return b.electionYear - a.electionYear
+ }
+ if (a.court !== b.court) {
+ return b.court - a.court
+ }
+ const numberA = a.ballotQuestionNumber ?? Number.MAX_SAFE_INTEGER
+ const numberB = b.ballotQuestionNumber ?? Number.MAX_SAFE_INTEGER
+ if (numberA !== numberB) return numberA - numberB
+ return a.id.localeCompare(b.id)
+}
diff --git a/pages/submit-testimony.tsx b/pages/submit-testimony.tsx
index 909d94ee0..618c2391a 100644
--- a/pages/submit-testimony.tsx
+++ b/pages/submit-testimony.tsx
@@ -1,4 +1,8 @@
+import { useEffect, useState } from "react"
+import { useRouter } from "next/router"
import { requireAuth } from "../components/auth"
+import { isActiveBallotQuestionPhase } from "../components/ballotquestions/status"
+import { dbService } from "../components/db"
import { createPage } from "../components/page"
import {
usePublishService,
@@ -9,18 +13,73 @@ import { createGetStaticTranslationProps } from "components/translations"
export default createPage({
titleI18nKey: "titles.submit_testimony",
- Page: requireAuth(() => {
- useSyncRouterAndStore()
-
- return (
- <>
-
-
- >
- )
- })
+ Page: requireAuth(() => )
})
+function SubmitTestimonyPage() {
+ const router = useRouter()
+ const [isAllowed, setIsAllowed] = useState(false)
+
+ useEffect(() => {
+ if (!router.isReady) return
+
+ const ballotQuestionId =
+ typeof router.query.ballotQuestionId === "string"
+ ? router.query.ballotQuestionId
+ : undefined
+
+ if (!ballotQuestionId) {
+ setIsAllowed(true)
+ return
+ }
+
+ let active = true
+ setIsAllowed(false)
+
+ dbService()
+ .getBallotQuestion({ id: ballotQuestionId })
+ .then(ballotQuestion => {
+ if (!active) return
+
+ if (
+ ballotQuestion &&
+ isActiveBallotQuestionPhase(ballotQuestion.ballotStatus)
+ ) {
+ setIsAllowed(true)
+ return
+ }
+
+ void router.replace(
+ ballotQuestion
+ ? `/ballotQuestions/${ballotQuestionId}`
+ : "/ballotQuestions"
+ )
+ })
+ .catch(() => {
+ if (active) void router.replace("/ballotQuestions")
+ })
+
+ return () => {
+ active = false
+ }
+ }, [router])
+
+ if (!isAllowed) return null
+
+ return
+}
+
+function SubmitTestimonyContent() {
+ useSyncRouterAndStore()
+
+ return (
+ <>
+
+
+ >
+ )
+}
+
export const getStaticProps = createGetStaticTranslationProps([
"auth",
"attachment",
diff --git a/pages/testimony/[...testimonyDetail].tsx b/pages/testimony/[...testimonyDetail].tsx
index 0d621db98..894e00e52 100644
--- a/pages/testimony/[...testimonyDetail].tsx
+++ b/pages/testimony/[...testimonyDetail].tsx
@@ -25,11 +25,23 @@ export default createPage({
const useSyncQueryParameters = () => {
const { version } = useCurrentTestimonyDetails()
useEffect(() => {
- const parts: Array = Query.parse(Router.query).testimonyDetail
- const currentVersion = parts.pop()
+ const query = Query.parse(Router.query)
+ const parts: Array = [...query.testimonyDetail]
+ const currentVersion =
+ typeof parts[parts.length - 1] === "number" ? parts.pop() : undefined
+
parts.push(version)
if (currentVersion !== version)
- Router.push(`/testimony/${parts.join("/")}`, undefined, { shallow: true })
+ Router.push(
+ {
+ pathname: `/testimony/${parts.join("/")}`,
+ query: query.ballotQuestionId
+ ? { ballotQuestionId: query.ballotQuestionId }
+ : undefined
+ },
+ undefined,
+ { shallow: true }
+ )
}, [version])
}
@@ -43,6 +55,7 @@ const PublishedId = z.string().min(1),
BillId = z.string().min(1),
Version = z.coerce.number().positive(),
Query = z.object({
+ ballotQuestionId: z.string().min(1).optional(),
testimonyDetail: z.union([
z.tuple([PublishedId]),
z.tuple([PublishedId, Version]),
@@ -59,15 +72,25 @@ const parseQuery = (query: ParsedUrlQuery) => {
switch (params.length) {
case 1:
- return { params, publishedId: params[0] }
+ return {
+ params,
+ publishedId: params[0],
+ ballotQuestionId: q.data.ballotQuestionId
+ }
case 2:
- return { params, publishedId: params[0], version: params[1] }
+ return {
+ params,
+ publishedId: params[0],
+ version: params[1],
+ ballotQuestionId: q.data.ballotQuestionId
+ }
case 3:
return {
params,
authorUid: params[0],
court: params[1],
- billId: params[2]
+ billId: params[2],
+ ballotQuestionId: q.data.ballotQuestionId
}
case 4:
return {
@@ -75,7 +98,8 @@ const parseQuery = (query: ParsedUrlQuery) => {
authorUid: params[0],
court: params[1],
billId: params[2],
- version: params[3]
+ version: params[3],
+ ballotQuestionId: q.data.ballotQuestionId
}
}
}
@@ -88,6 +112,7 @@ const fetchDocs = async (q: NonNullable>) => {
let billId: string,
court: number,
authorUid: string,
+ ballotQuestionId: string | undefined,
archive: Testimony[],
testimony: Testimony
@@ -97,10 +122,21 @@ const fetchDocs = async (q: NonNullable>) => {
testimony = doc
;({ billId, court, authorUid } = testimony)
- archive = await db.getArchivedTestimony({ authorUid, billId, court })
+ ballotQuestionId = testimony.ballotQuestionId ?? undefined
+ archive = await db.getArchivedTestimony({
+ authorUid,
+ billId,
+ court,
+ ballotQuestionId
+ })
} else if (q.authorUid) {
- ;({ authorUid, billId, court } = q)
- archive = await db.getArchivedTestimony({ authorUid, billId, court })
+ ;({ authorUid, billId, court, ballotQuestionId } = q)
+ archive = await db.getArchivedTestimony({
+ authorUid,
+ billId,
+ court,
+ ballotQuestionId
+ })
const latest = first(archive)
if (!latest) return
@@ -110,11 +146,14 @@ const fetchDocs = async (q: NonNullable>) => {
}
const bill = await db.getBill({ billId, court }),
- author = await db.getProfile({ uid: authorUid })
+ author = await db.getProfile({ uid: authorUid }),
+ ballotQuestion = ballotQuestionId
+ ? await db.getBallotQuestion({ id: ballotQuestionId })
+ : null
if (!bill) return
- return { bill, author, archive, testimony }
+ return { bill, author, archive, testimony, ballotQuestion }
}
export const getServerSideProps = wrapper.getServerSideProps(
@@ -139,7 +178,14 @@ export const getServerSideProps = wrapper.getServerSideProps(
docs.testimony.version
].join("/")
return {
- redirect: { destination, permanent: false }
+ redirect: {
+ destination: q.ballotQuestionId
+ ? `${destination}?ballotQuestionId=${encodeURIComponent(
+ q.ballotQuestionId
+ )}`
+ : destination,
+ permanent: false
+ }
}
} else if (q.version > docs.testimony.version) return notFound
@@ -147,6 +193,7 @@ export const getServerSideProps = wrapper.getServerSideProps(
pageDataLoaded({
testimony: docs.testimony,
bill: docs.bill,
+ ballotQuestion: docs.ballotQuestion ?? null,
author: docs.author ?? null,
archive: docs.archive,
version: q.version
diff --git a/public/locales/en/common.json b/public/locales/en/common.json
index a0a3d5fe2..c63994723 100644
--- a/public/locales/en/common.json
+++ b/public/locales/en/common.json
@@ -106,7 +106,10 @@
"accountProfile": "Profile",
"additionalResources": "Additional Resources",
"ai": "How MAPLE Uses AI",
+ "ballotQuestions": "Ballot Questions",
+ "bills": "Bills",
"browseBills": "Browse Bills",
+ "browseBallotQuestions": "Browse Ballot Questions",
"browseHearings": "Browse Hearings",
"browseTestimony": "Browse Testimony",
"closeNavMenu": "Close Navigation Menu",
@@ -115,19 +118,25 @@
"editProfile": "Edit Profile",
"faq": "FAQ",
"followingTab": "Followed Content",
+ "hearings": "Hearings",
"home": "Home",
"learnAboutTestimony": "Learn About Testimony",
"legislativeProcess": "About the MA Legislative Process",
"logo": "Massachusetts Platform for Legislative Engagement home",
+ "main": "Main navigation",
"missionAndGoals": "Mission & Goals",
"newsfeed": "Newsfeed",
+ "openNavMenu": "Open Navigation Menu",
+ "openProfileMenu": "Open Profile Menu",
"team": "Team",
"privacyAndConduct": "Privacy Policy & Code of Conduct",
"signOut": "Sign Out",
"supportMaple": "Support MAPLE",
"aboutTestimony": "About Testimony",
+ "testimony": "Testimony",
"viewProfile": "View Profile",
"whyUseMaple": "Why Use MAPLE",
+ "inTheNews": "In The News",
"login": "Login"
},
"new_feature": "*NEW*",
@@ -175,11 +184,13 @@
"termsAndConditions": "Terms and Conditions",
"titles": {
"admin": "Admin",
+ "ballotQuestion": "Ballot Question",
"bill": "Bill",
"our_team": "Our Team",
"legislative_process": "How To Have Impact Through Legislative Testimony",
"submit_testimony": "Submit Testimony",
"support_maple": "How to Support MAPLE",
+ "in_the_news": "In The News",
"testimony": "Testimony",
"policies": "Policies",
"unsubscribe": "Unsubscribe",
diff --git a/public/locales/en/editProfile.json b/public/locales/en/editProfile.json
index f138871af..31dd5b39d 100644
--- a/public/locales/en/editProfile.json
+++ b/public/locales/en/editProfile.json
@@ -46,6 +46,7 @@
},
"follow": {
"bills": "Bills I Follow",
+ "ballotQuestions": "Ballot Questions I Follow",
"orgs": "Users I Follow",
"your_followers": "Your Followers",
"unfollow": "Unfollow",
diff --git a/public/locales/en/hearing.json b/public/locales/en/hearing.json
index b16347b7f..bfd82a69c 100644
--- a/public/locales/en/hearing.json
+++ b/public/locales/en/hearing.json
@@ -4,6 +4,7 @@
"chairs": "Chairs",
"committee_members": "Committee members",
"download_transcript": "Download transcript",
+ "download_transcript_x": "Download transcript for {{title}}",
"hearing_details": "Hearing details",
"house_chair": "House Chair",
"member": "Member",
@@ -11,7 +12,7 @@
"no": "No",
"no_record": "No Record",
"no_results_found": "No Search Results for ”{{searchTerm}}”",
- "no_transcript_on_file": "This hearing does not yet have a transcription on file",
+ "no_transcript_on_file": "This video does not yet have a transcription on file",
"no_video_on_file": "This hearing does not yet have a video on file",
"no_video_or_transcript": "This hearing does not yet have a video or transcript on file",
"no_vote": "No Vote Recorded",
@@ -26,6 +27,7 @@
"see_all": "See all",
"see_less": "See less",
"senate_chair": "Senate Chair",
+ "transcript_loading": "Loading transcript for this hearing...",
"video_and_transcription_feature_callout": "Hearing Video + Transcription",
"view_bill": "View Bill Details",
"view_votes": "View Committee Votes",
diff --git a/public/locales/en/inTheNews.json b/public/locales/en/inTheNews.json
new file mode 100644
index 000000000..5d1cb3243
--- /dev/null
+++ b/public/locales/en/inTheNews.json
@@ -0,0 +1,13 @@
+{
+ "title": "Media, Articles & Insights",
+ "readMoreButton": "READ MORE",
+ "media": {
+ "title": "Media"
+ },
+ "awards": {
+ "title": "Awards"
+ },
+ "books": {
+ "title": "Books"
+ }
+}
diff --git a/public/locales/en/profile.json b/public/locales/en/profile.json
index 7979a1696..34bfe4ce4 100644
--- a/public/locales/en/profile.json
+++ b/public/locales/en/profile.json
@@ -41,4 +41,4 @@
"large": "Large User Icon",
"small": "Small User Icon"
}
-}
\ No newline at end of file
+}
diff --git a/public/locales/en/search.json b/public/locales/en/search.json
index 2fa57a111..c9e6bd8a1 100644
--- a/public/locales/en/search.json
+++ b/public/locales/en/search.json
@@ -1,5 +1,37 @@
{
"another_term": "Try another search term",
+ "browse_ballot_questions": "Browse Ballot Questions",
+ "browse_ballot_questions_intro": "Track ballot questions by year, court, and status, then open each question for voter perspective, hearings, and context.",
+ "browse_ballot_questions_empty": "No ballot questions are available for {{year}}.",
+ "ballot_question_document_id": "Document ID: {{id}}",
+ "ballot_question_election_year": "Election {{year}}",
+ "ballot_question_number": "Question {{number}}",
+ "ballot_question_court": "Court {{court}}",
+ "ballot_question_search_label": "Search",
+ "ballot_question_search_placeholder": "Search title or final summary",
+ "ballot_question_filter_year": "Year",
+ "ballot_question_filter_court": "Court",
+ "ballot_question_filter_status": "Status",
+ "ballot_question_all_years": "All years",
+ "ballot_question_all_courts": "All courts",
+ "ballot_question_all_statuses": "All statuses",
+ "ballot_question_status": {
+ "legislature": "Before the legislature",
+ "qualifying": "Qualifying",
+ "certified": "Certified",
+ "ballot": "On the ballot",
+ "enacted": "Enacted",
+ "failed": "Failed",
+ "withdrawn": "Withdrawn",
+ "expectedOnBallot": "Expected on ballot",
+ "failedToAppear": "Failed to appear on ballot",
+ "rejected": "Rejected",
+ "accepted": "Accepted"
+ },
+ "ballot_question_results_summary": "Showing {{count}} questions",
+ "ballot_question_reset_filters": "Reset filters",
+ "ballot_question_no_summary": "No summary available yet.",
+ "ballot_question_no_results": "No ballot questions match the current filters.",
"browse_bills": "Browse Bills",
"filter": "Filter",
"topics": "Topics",
diff --git a/public/locales/en/testimony.json b/public/locales/en/testimony.json
index 6e9044b86..12da1f4b3 100644
--- a/public/locales/en/testimony.json
+++ b/public/locales/en/testimony.json
@@ -27,6 +27,7 @@
"title": "You Have Draft Testimony",
"label": "Complete Testimony"
},
+ "loading": "Loading your testimony...",
"pendingUpgrade": {
"title": "Pending Upgrade",
"label": "Your Organization Registration is Pending"
@@ -97,6 +98,7 @@
"viewLess": "View Less Details",
"viewMore": "View Bill Details",
"backToBill": "Back to Bill (Bill {{billId}})",
+ "backToBallotQuestion": "Back to Ballot Question ({{ballotQuestionId}})",
"overview": {
"title": "Write, Publish, and Send Your Testimony!",
"description": "Your voice matters. And it's important that you share it. MAPLE helps you 1) write testimony, 2) publish it to our community, and 3) send it to the right legislators so it can be formally considered. It's easy as 1-2-3!"
@@ -126,6 +128,7 @@
"testimonyLabel": "Testimony",
"testimonyPlaceholder": "Add your testimony here",
"testimonyHelp": "Testimony is limited to 10,000 characters.",
+ "attachmentLabel": "(Optional) Provide your testimony as a file attachment",
"tips": "Need help? Read our <0>testimony writing tips0>",
"codeOfConduct": "View our code of conduct",
"confirmRemoveAttachment": "Are you sure you want to remove your attachment?"
@@ -139,6 +142,7 @@
"expand": "Expand",
"moreDetails": "More Details",
"edit": "Edit Testimony",
+ "emptyDraft": "This draft has no content. Click Edit to add your testimony.",
"rescind": "Rescind",
"toast": {
"successful": {
@@ -162,6 +166,7 @@
},
"viewAttached": "View Attached Testimony PDF",
"viewTestimony": {
+ "allTestimonies": "All Testimonies",
"showing": "Showing",
"showing1": "Showing 1 - ",
"outOf": " out of ",
@@ -172,6 +177,75 @@
"draft": "Draft",
"emailCta": "Email Your Published Testimony"
},
+ "ballotQuestion": {
+ "panel": {
+ "createTestimony": {
+ "title": "Share Your Perspective",
+ "label": "Share Your Perspective"
+ },
+ "completeTestimony": {
+ "title": "Complete Your Perspective",
+ "label": "Complete Your Perspective"
+ },
+ "loading": "Loading your perspective..."
+ },
+ "submitTestimonyForm": {
+ "viewMore": "View Question Details",
+ "chooseStance": "Choose Your Stance",
+ "overview": {
+ "title": "Share Your Perspective",
+ "description": "Your voice matters. MAPLE helps you 1) write your perspective and 2) publish it to our community so others can learn from it."
+ },
+ "keepNote": {
+ "about": "About MAPLE Perspectives",
+ "howTestimoniesWork": "How MAPLE Perspectives Work",
+ "emailPreview": "Your perspective will be published on MAPLE after you click publish.",
+ "shareEmail": "Your perspective is published on MAPLE so others can read and learn from it.",
+ "thankYou": "Thank you for sharing your perspective with the MAPLE community.",
+ "rulesHeader": "Rules for Perspectives on MAPLE:",
+ "editLimit": "You can edit your perspective up to 5 times but the previous versions will remain available.",
+ "cannotDelete": "Since MAPLE is an archive, you cannot remove your perspective from the site. You may request a deletion in certain circumstances (see our <0>FAQ page0>)"
+ },
+ "write": {
+ "header": "Write Your Perspective",
+ "testimonyLabel": "Perspective",
+ "testimonyPlaceholder": "Add your perspective here",
+ "testimonyHelp": "Perspective is limited to 10,000 characters.",
+ "attachmentLabel": "(Optional) Provide your perspective as a file attachment",
+ "tips": "Need help? Read our <0>perspective writing tips0>"
+ }
+ },
+ "quickInfo": {
+ "writingAbout": "You're writing a perspective about",
+ "reference": "Ballot Question {{ballotQuestionId}}"
+ },
+ "viewTestimony": {
+ "allPerspectives": "All Perspectives",
+ "browsePerspectives": "Browse Perspectives",
+ "noPerspectives": "There are no perspectives"
+ },
+ "yourTestimony": {
+ "title": "Your Perspective"
+ },
+ "testimonyItem": {
+ "edit": "Edit Perspective",
+ "emptyDraft": "This draft has no content. Click Edit to add your perspective."
+ },
+ "thankYouModal": "Thank You For Sharing Your Perspective!",
+ "viewAttached": "View Attached Perspective PDF",
+ "preview": {
+ "neutral": "You are <1>neutral1> on this ballot question",
+ "endorse": "You <1>support1> this ballot question",
+ "oppose": "You <1>oppose1> this ballot question"
+ },
+ "publish": {
+ "confirmAndPublish": "Confirm and Publish",
+ "instructions": "After clicking \"publish,\" your perspective will be published on MAPLE.",
+ "editLimitNotice_one": "You may edit your perspective up to 1 more time. Before you publish updates to your perspective, please provide a reason for your changes.",
+ "editLimitNotice_other": "You may edit your perspective up to {{count}} more times. Before you publish updates to your perspective, please provide a reason for your changes.",
+ "update": "Update Your Perspective"
+ }
+ },
"quickInfo": {
"bill": "Bill",
"primarySponsor": "Primary Sponsor",
@@ -213,6 +287,7 @@
"finishWithoutEmailingConfirm": "Yes, Finish Without Emailing",
"continueSharing": "Continue Sharing",
"finishedBackToBill": "Finished! Back to Bill",
+ "finishedBackToBallotQuestion": "Finished! Back to Ballot Question",
"publish": "Publish",
"publishAndSend": "Publish and Send",
"emailRecipients": "Email Recipients",
diff --git a/scripts/README.md b/scripts/README.md
new file mode 100644
index 000000000..ef835e4b2
--- /dev/null
+++ b/scripts/README.md
@@ -0,0 +1,203 @@
+# Admin Scripts
+
+One-off migration, seeding, and administrative scripts for the Maple platform. These are not part of the deployed application — they run locally against a target environment (local emulators, dev, or prod).
+
+## Prerequisites
+
+- Node.js / yarn installed (`yarn install` to get all deps including `@swc/core`)
+- For `local`: Firebase emulators running (`yarn emulators:start`)
+- For `dev` / `prod`: a Google service account key, either in `GOOGLE_APPLICATION_CREDENTIALS` or passed via `--creds `
+
+---
+
+## Firebase Admin Scripts
+
+Scripts in `scripts/firebase-admin/` share a common CLI dispatcher (`index.ts`) that initialises a Firestore `db`, Firebase `auth`, and related helpers before running the named script.
+
+### Running a script
+
+```sh
+yarn firebase-admin run-script --env [-- ]
+
+# With explicit credentials (for dev/prod)
+yarn firebase-admin run-script --env prod --creds /path/to/key.json
+
+# Interactive REPL (db, auth, etc. in scope)
+yarn firebase-admin console --env local
+```
+
+### Scripts
+
+#### `backfillTestimonyBallotQuestionId`
+
+Stamps `ballotQuestionId: null` onto every `publishedTestimony` and `archivedTestimony` document that is missing the field.
+
+**Why it exists:** The composite Firestore index used by `resolvePublication()` (which queries by `billId + court + ballotQuestionId`) excludes documents where an indexed field is absent. Without this backfill, re-publishing legacy testimony creates a duplicate record instead of updating the existing one.
+
+**Idempotent:** Documents that already have the field set (to any value) are skipped, so it is safe to re-run.
+
+**When to run:** Once against each environment after deploying the ballot-questions feature. Can be deleted from the codebase once confirmed executed everywhere.
+
+```sh
+yarn firebase-admin run-script backfillTestimonyBallotQuestionId --env local
+yarn firebase-admin run-script backfillTestimonyBallotQuestionId --env dev
+yarn firebase-admin run-script backfillTestimonyBallotQuestionId --env prod
+```
+
+---
+
+#### `syncBallotQuestions`
+
+Upserts ballot question records from local YAML files into the `ballotQuestions` Firestore collection. Each file is validated against the `BallotQuestion` type before being written; invalid files abort with an error. All writes are committed atomically in a single Firestore batch.
+
+Persisted testimony counters on existing ballot-question documents are preserved during sync so YAML refreshes do not wipe live testimony data.
+
+```sh
+# Uses ./ballotQuestions/ directory by default
+yarn firebase-admin run-script syncBallotQuestions --env local
+
+# Specify a custom directory
+yarn firebase-admin run-script syncBallotQuestions --env dev -- --dir /path/to/yaml-dir
+```
+
+YAML files must export a document whose shape matches the `BallotQuestion` type defined in `functions/src/ballotQuestions/types.ts`, including a top-level `id` field used as the Firestore document ID.
+
+---
+
+#### `backfillBallotQuestionTestimonyCounts`
+
+Computes and stores `testimonyCount`, `endorseCount`, `neutralCount`, and `opposeCount` on every ballot-question document from `publishedTestimony`.
+
+**Why it exists:** Ballot-question pages now read persisted counters from the ballot-question document instead of recomputing them on every request. Existing environments need a one-time backfill so legacy ballot questions start with correct stored counts.
+
+**Idempotent:** The script overwrites only the four counter fields based on current published testimony, so it is safe to re-run.
+
+```sh
+yarn firebase-admin run-script backfillBallotQuestionTestimonyCounts --env local
+yarn firebase-admin run-script backfillBallotQuestionTestimonyCounts --env dev
+yarn firebase-admin run-script backfillBallotQuestionTestimonyCounts --env prod
+```
+
+---
+
+#### `backfillBillCourt`
+
+
+
+#### `backfillBillNotificationEvents`
+
+
+
+#### `backfillHearingTranscription`
+
+
+
+#### `backfillNextDigestAt`
+
+
+
+#### `backfillOrganizationTestimony`
+
+
+
+#### `backfillPublishedTestimonyId`
+
+
+
+#### `backfillTestimonyBillTitle`
+
+
+
+#### `backfillUserRoles`
+
+
+
+#### `backfillWeeklyFrequency`
+
+
+
+#### `batchDeleteTestimony`
+
+
+
+#### `generateBill`
+
+
+
+#### `generateBillHistory`
+
+
+
+#### `list-all-users`
+
+
+
+#### `migrateHearingTranscription`
+
+
+
+#### `runScrapers`
+
+
+
+#### `seedActiveTopicSubscriptions`
+
+
+
+#### `seedTopicEvents`
+
+
+
+#### `sendTestEmail`
+
+
+
+#### `touchBills`
+
+
+
+#### `updateDisplayNames`
+
+
+
+#### `updateHistory`
+
+
+
+---
+
+## Typesense Admin
+
+Manages the Typesense search index. Integrates with Firebase secrets to retrieve API keys.
+
+```sh
+yarn typesense-admin --env
+```
+
+### Commands
+
+
+
+---
+
+## `generate-stories`
+
+
+
+---
+
+## `setRole` (quick alias)
+
+A convenience alias for the `setRole` firebase-admin script. See the script entry above.
+
+```sh
+yarn setRole --env [args]
+```
+
+
+
+---
+
+## CI test
+
+The `integration_tests` job in `.github/workflows/repo-checks.yml` runs `backfillTestimonyBallotQuestionId` end-to-end via `yarn firebase-admin run-script` against the Firebase emulator to verify the CLI runner is working. This covers the full path: ts-node compilation, `run-script` dispatch, dynamic script loading, and Firestore writes.
diff --git a/scripts/firebase-admin/backfillBallotQuestionTestimonyCounts.ts b/scripts/firebase-admin/backfillBallotQuestionTestimonyCounts.ts
new file mode 100644
index 000000000..66fb36070
--- /dev/null
+++ b/scripts/firebase-admin/backfillBallotQuestionTestimonyCounts.ts
@@ -0,0 +1,62 @@
+import { Script } from "./types"
+
+type Counts = {
+ testimonyCount: number
+ endorseCount: number
+ neutralCount: number
+ opposeCount: number
+}
+
+const zeroCounts = (): Counts => ({
+ testimonyCount: 0,
+ endorseCount: 0,
+ neutralCount: 0,
+ opposeCount: 0
+})
+
+export const script: Script = async ({ db }) => {
+ const writer = db.bulkWriter()
+ const countsByBallotQuestionId = new Map()
+
+ const [ballotQuestionsSnap, testimonySnap] = await Promise.all([
+ db.collection("ballotQuestions").select().get(),
+ db
+ .collectionGroup("publishedTestimony")
+ .select("ballotQuestionId", "position")
+ .get()
+ ])
+
+ testimonySnap.forEach(doc => {
+ const data = doc.data()
+ const ballotQuestionId =
+ typeof data.ballotQuestionId === "string" ? data.ballotQuestionId : null
+ if (!ballotQuestionId) return
+
+ const counts =
+ countsByBallotQuestionId.get(ballotQuestionId) ?? zeroCounts()
+ counts.testimonyCount += 1
+
+ switch (data.position) {
+ case "endorse":
+ counts.endorseCount += 1
+ break
+ case "neutral":
+ counts.neutralCount += 1
+ break
+ case "oppose":
+ counts.opposeCount += 1
+ break
+ }
+
+ countsByBallotQuestionId.set(ballotQuestionId, counts)
+ })
+
+ ballotQuestionsSnap.forEach(doc => {
+ writer.update(doc.ref, countsByBallotQuestionId.get(doc.id) ?? zeroCounts())
+ })
+
+ await writer.close()
+ console.log(
+ `Updated ${ballotQuestionsSnap.size} ballot question(s) using ${testimonySnap.size} published testimony document(s).`
+ )
+}
diff --git a/scripts/firebase-admin/backfillTestimonyBallotQuestionId.ts b/scripts/firebase-admin/backfillTestimonyBallotQuestionId.ts
new file mode 100644
index 000000000..7090bc0d8
--- /dev/null
+++ b/scripts/firebase-admin/backfillTestimonyBallotQuestionId.ts
@@ -0,0 +1,42 @@
+/*
+ Backfills ballotQuestionId: null onto all existing publishedTestimony and
+ archivedTestimony documents that do not have the field set.
+
+ Required: Firestore composite indexes exclude documents where indexed fields
+ are absent. resolvePublication() queries by billId + court + ballotQuestionId,
+ so legacy docs without the field would be duplicated on re-publish rather than
+ updated in place.
+
+ Safe to re-run: documents already having the field (any value) are skipped.
+ Can be removed after running against all environments.
+*/
+import { Script } from "./types"
+
+export const script: Script = async ({ db }) => {
+ const writer = db.bulkWriter()
+ let updated = 0,
+ skipped = 0
+
+ const [publishedSnap, archivedSnap] = await Promise.all([
+ db.collectionGroup("publishedTestimony").select("ballotQuestionId").get(),
+ db.collectionGroup("archivedTestimony").select("ballotQuestionId").get()
+ ])
+
+ for (const [collName, snap] of [
+ ["publishedTestimony", publishedSnap],
+ ["archivedTestimony", archivedSnap]
+ ] as const) {
+ console.log(`${collName}: ${snap.size} documents`)
+ snap.forEach(doc => {
+ if (!("ballotQuestionId" in doc.data())) {
+ writer.update(doc.ref, { ballotQuestionId: null })
+ updated++
+ } else {
+ skipped++
+ }
+ })
+ }
+
+ await writer.close()
+ console.log(`Done. Updated: ${updated}, Skipped (already set): ${skipped}`)
+}
diff --git a/scripts/firebase-admin/sendTestEmail.ts b/scripts/firebase-admin/sendTestEmail.ts
index 4c7ba475c..ce9f3cde8 100644
--- a/scripts/firebase-admin/sendTestEmail.ts
+++ b/scripts/firebase-admin/sendTestEmail.ts
@@ -193,7 +193,9 @@ const generateTestData = (
bills: bills.slice(0, Math.min(4, numBills)),
users: users.slice(0, Math.min(4, numUsers)),
numBillsWithNewTestimony: numBills,
- numUsersWithNewTestimony: numUsers
+ numUsersWithNewTestimony: numUsers,
+ ballotQuestions: [],
+ numBallotQuestionsWithNewTestimony: 0
}
}
diff --git a/scripts/firebase-admin/syncBallotQuestions.ts b/scripts/firebase-admin/syncBallotQuestions.ts
new file mode 100644
index 000000000..54a7ed263
--- /dev/null
+++ b/scripts/firebase-admin/syncBallotQuestions.ts
@@ -0,0 +1,48 @@
+import * as fs from "fs"
+import * as path from "path"
+import * as yaml from "js-yaml"
+import { BallotQuestion } from "../../functions/src/ballotQuestions/types"
+import { Script } from "./types"
+
+export const script: Script = async ({ db, args }) => {
+ const dir =
+ typeof args.dir === "string"
+ ? args.dir
+ : path.resolve(process.cwd(), "ballotQuestions")
+
+ // @ts-expect-error Node supports recursive readdir here; the repo's fs typings do not.
+ const files = (fs.readdirSync(dir, { recursive: true }) as string[]).filter(
+ f => f.endsWith(".yaml")
+ )
+
+ if (files.length === 0) {
+ console.log(`No YAML files found in ${dir}`)
+ return
+ }
+
+ const batch = db.batch()
+
+ for (const file of files) {
+ const raw = yaml.load(fs.readFileSync(path.join(dir, file), "utf8"))
+ const doc = BallotQuestion.checkWithDefaults(raw)
+ const ref = db.collection("ballotQuestions").doc(doc.id)
+ const current = await ref
+ .get()
+ .then(snap =>
+ snap.exists ? BallotQuestion.checkWithDefaults(snap.data()) : undefined
+ )
+ batch.set(ref, {
+ ...doc,
+ testimonyCount: current?.testimonyCount ?? doc.testimonyCount,
+ endorseCount: current?.endorseCount ?? doc.endorseCount,
+ neutralCount: current?.neutralCount ?? doc.neutralCount,
+ opposeCount: current?.opposeCount ?? doc.opposeCount
+ })
+ console.log(`Queued upsert: ballotQuestions/${doc.id}`)
+ }
+
+ await batch.commit()
+ console.log(
+ `Committed ${files.length} ballot question(s) from directory tree.`
+ )
+}
diff --git a/stories/emailTemplates/NotificationDigestEmail.stories.tsx b/stories/emailTemplates/NotificationDigestEmail.stories.tsx
index 9c332be6f..1a8321b6b 100644
--- a/stories/emailTemplates/NotificationDigestEmail.stories.tsx
+++ b/stories/emailTemplates/NotificationDigestEmail.stories.tsx
@@ -156,7 +156,9 @@ const generateTestData = (
bills: bills.slice(0, Math.min(4, numBills)),
users: users.slice(0, Math.min(4, numUsers)),
numBillsWithNewTestimony: numBills,
- numUsersWithNewTestimony: numUsers
+ numUsersWithNewTestimony: numUsers,
+ ballotQuestions: [],
+ numBallotQuestionsWithNewTestimony: 0
}
}
diff --git a/styles/bootstrap.scss b/styles/bootstrap.scss
index d526ca4f8..9412d68b0 100644
--- a/styles/bootstrap.scss
+++ b/styles/bootstrap.scss
@@ -149,3 +149,9 @@ $utilities: (
.tracking-widest {
letter-spacing: 0.1em;
}
+
+.hover-underline {
+ &:hover {
+ text-decoration: underline !important;
+ }
+}
diff --git a/styles/globals.css b/styles/globals.css
index f3a8f052e..04f082576 100644
--- a/styles/globals.css
+++ b/styles/globals.css
@@ -11,10 +11,107 @@
font-weight: bold;
}
+:root {
+ --mobile-nav-link-color: rgba(255, 255, 255, 0.92);
+ --mobile-nav-link-color-active: rgba(255, 255, 255, 1);
+}
+
.navLink-primary {
+ color: var(--mobile-nav-link-color) !important;
+ font-weight: bold;
+}
+
+.navLink-primary .dropdown-toggle {
+ color: var(--mobile-nav-link-color) !important;
font-weight: bold;
}
+.navLink-primary:hover,
+.navLink-primary:focus,
+.navLink-primary:active,
+.navLink-primary.active,
+.navLink-primary.show {
+ color: var(--mobile-nav-link-color-active) !important;
+}
+
+.navLink-primary .dropdown-toggle:hover,
+.navLink-primary .dropdown-toggle:focus,
+.navLink-primary .dropdown-toggle:active,
+.navLink-primary.show .dropdown-toggle {
+ color: var(--mobile-nav-link-color-active) !important;
+}
+
+.skip-link {
+ background: #ffffff;
+ border: 2px solid #12266f;
+ border-radius: 999px;
+ color: #12266f;
+ font-weight: 700;
+ left: 1rem;
+ padding: 0.75rem 1rem;
+ position: absolute;
+ top: -5rem;
+ z-index: 2000;
+}
+
+.skip-link:focus {
+ top: 1rem;
+}
+
+.mobile-nav-trigger {
+ align-items: center;
+ background: transparent;
+ border: 0;
+ color: white;
+ display: inline-flex;
+ justify-content: center;
+ padding: 0;
+}
+
+.mobile-nav-trigger:focus-visible {
+ border-radius: 999px;
+ outline: 2px solid white;
+ outline-offset: 4px;
+}
+
+@media (min-width: 766px) and (max-width: 1020px) {
+ .desktop-navbar {
+ gap: 0.25rem !important;
+ }
+
+ .desktop-navbar .me-auto img {
+ width: 68px !important;
+ height: 68px !important;
+ }
+
+ .desktop-navbar-link {
+ font-size: 0.95rem;
+ padding-left: 0.5rem !important;
+ padding-right: 0.5rem !important;
+ white-space: nowrap;
+ }
+
+ .desktop-navbar-dropdown {
+ font-size: 0.95rem;
+ padding-left: 0.5rem !important;
+ padding-right: 0.5rem !important;
+ }
+}
+
+@media (min-width: 766px) and (max-width: 815px) {
+ .desktop-navbar .me-auto img {
+ width: 56px !important;
+ height: 56px !important;
+ }
+}
+
+#basic-navbar-nav .dropdown-menu {
+ box-sizing: border-box;
+ max-width: calc(100vw - 3rem);
+ min-width: 0;
+ width: fit-content;
+}
+
.btn {
padding: 1rem;
}
@@ -159,3 +256,122 @@
letter-spacing: 0.015em;
padding: 7px, 8px, 7px, 8px;
}
+
+<.ballot-question-back-link {
+ color: #475569;
+ transition: color 0.15s ease, transform 0.15s ease;
+}
+
+.ballot-question-back-link:hover {
+ color: var(--bs-secondary);
+ transform: translateX(-2px);
+}
+
+.ballot-question-back-link:focus-visible {
+ outline: 3px solid rgba(94, 114, 228, 0.35);
+ outline-offset: 3px;
+ border-radius: 999px;
+}
+
+.ballot-question-pdf-link {
+ transition: transform 0.15s ease, box-shadow 0.15s ease,
+ border-color 0.15s ease, background-color 0.15s ease;
+}
+
+.ballot-question-pdf-link:hover {
+ transform: translateY(-1px);
+ border-color: rgba(94, 114, 228, 0.32) !important;
+ background-color: rgba(255, 255, 255, 1) !important;
+ box-shadow: 0 0.5rem 1.25rem rgba(94, 114, 228, 0.12);
+}
+
+.ballot-question-pdf-link:focus-visible {
+ outline: 3px solid rgba(94, 114, 228, 0.35);
+ outline-offset: 3px;
+}
+
+.ballot-question-info-alert {
+ --bs-btn-close-color: var(--bs-secondary);
+}
+
+.ballot-question-info-alert .btn-close {
+ background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%231a3185'%3e%3cpath d='M.293.293a1 1 0 0 1 1.414 0L8 6.586 14.293.293a1 1 0 1 1 1.414 1.414L9.414 8l6.293 6.293a1 1 0 0 1-1.414 1.414L8 9.414l-6.293 6.293a1 1 0 0 1-1.414-1.414L6.586 8 .293 1.707a1 1 0 0 1 0-1.414z'/%3e%3c/svg%3e");
+ opacity: 1;
+ transition: transform 0.15s ease, opacity 0.15s ease;
+}
+
+.ballot-question-info-alert .btn-close:hover {
+ transform: rotate(90deg);
+}
+
+.ballot-question-info-alert .btn-close:hover,
+.ballot-question-info-alert .btn-close:focus {
+ opacity: 1;
+}
+
+.ballot-question-nav-link {
+ cursor: pointer;
+ background-color: #f8fafc;
+ border: 1px solid rgba(15, 23, 42, 0.08);
+ color: #334155;
+ transition: transform 0.15s ease, box-shadow 0.15s ease,
+ border-color 0.15s ease, background-color 0.15s ease, color 0.15s ease;
+}
+
+.ballot-question-nav-link:hover {
+ transform: translateY(-1px);
+ border-color: rgba(94, 114, 228, 0.24) !important;
+ background-color: rgba(94, 114, 228, 0.04) !important;
+ color: var(--bs-secondary);
+ box-shadow: 0 0.5rem 1.25rem rgba(15, 23, 42, 0.08);
+}
+
+.ballot-question-nav-link:focus-visible {
+ outline: 3px solid rgba(94, 114, 228, 0.35);
+ outline-offset: 3px;
+}
+
+.ballot-question-nav-link.is-active {
+ background-color: rgba(94, 114, 228, 0.08);
+ border-color: rgba(94, 114, 228, 0.35);
+ color: var(--bs-secondary);
+}
+
+.ballot-question-nav-link.is-active:hover {
+ background-color: rgba(94, 114, 228, 0.12) !important;
+ border-color: rgba(94, 114, 228, 0.42) !important;
+}
+
+.ballot-question-nav-badge {
+ min-width: 1.5rem;
+ font-weight: 600;
+ background-color: rgba(15, 23, 42, 0.08);
+ color: #334155;
+ transition: background-color 0.15s ease, color 0.15s ease;
+}
+
+.ballot-question-nav-link:hover .ballot-question-nav-badge {
+ background-color: rgba(94, 114, 228, 0.14);
+ color: var(--bs-secondary);
+}
+
+.ballot-question-nav-badge.is-active {
+ background-color: var(--bs-secondary);
+ color: white;
+}
+
+/* Custom style in "In The News" Page */
+.in-the-news .nav-item .nav-link {
+ color: black;
+ font-weight: 600;
+ border-bottom: 2px solid;
+ border-color: #0000001a;
+}
+.in-the-news .nav-item .nav-link:hover {
+ color: #15276a;
+}
+.in-the-news .nav-item .nav-link.active {
+ color: #15276a;
+ border-bottom: 4px solid;
+ border-color: #15276a;
+}
diff --git a/tests/fixtures/ballotQuestions/bq-test-fixture.yaml b/tests/fixtures/ballotQuestions/bq-test-fixture.yaml
new file mode 100644
index 000000000..d413acd4f
--- /dev/null
+++ b/tests/fixtures/ballotQuestions/bq-test-fixture.yaml
@@ -0,0 +1,18 @@
+id: "test-99-99"
+billId: "H5099"
+court: 194
+electionYear: 2099
+type: initiative_statute
+ballotStatus: legislature
+ballotQuestionNumber: null
+relatedBillIds: []
+description: "This proposal would allow adults 21 and older to grow, possess, and use certain natural psychedelic substances under limited and regulated conditions."
+atAGlance:
+ - label: "Who"
+ value: "Adults 21 and older"
+ - label: "How"
+ value: "Limited personal use and supervised use at approved locations"
+ - label: "Retail sales"
+ value: "Not allowed"
+pdfUrl: "https://example.com/petition-test-99-99.pdf"
+fullSummary: "This proposed law would allow persons aged 21 and older to grow, possess, and use certain natural psychedelic substances in certain circumstances."
diff --git a/tests/integration/backfillTestimonyBallotQuestionId.test.ts b/tests/integration/backfillTestimonyBallotQuestionId.test.ts
new file mode 100644
index 000000000..4feb74e41
--- /dev/null
+++ b/tests/integration/backfillTestimonyBallotQuestionId.test.ts
@@ -0,0 +1,62 @@
+import { testDb, terminateFirebase } from "../testUtils"
+import { script } from "../../scripts/firebase-admin/backfillTestimonyBallotQuestionId"
+
+afterAll(async () => {
+ await Promise.all([
+ testDb
+ .doc("users/backfill-test-user/publishedTestimony/legacy-pub")
+ .delete(),
+ testDb
+ .doc("users/backfill-test-user/archivedTestimony/legacy-arch")
+ .delete(),
+ testDb
+ .doc("users/backfill-test-user2/publishedTestimony/has-null")
+ .delete(),
+ testDb
+ .doc("users/backfill-test-user2/publishedTestimony/has-value")
+ .delete()
+ ])
+ await terminateFirebase()
+})
+
+it("sets ballotQuestionId: null on docs that are missing the field", async () => {
+ const pub = testDb
+ .collection("users")
+ .doc("backfill-test-user")
+ .collection("publishedTestimony")
+ .doc("legacy-pub")
+ const arch = testDb
+ .collection("users")
+ .doc("backfill-test-user")
+ .collection("archivedTestimony")
+ .doc("legacy-arch")
+
+ await pub.set({ billId: "H1234", court: 193, content: "test" })
+ await arch.set({ billId: "H1234", court: 193, version: 1 })
+
+ await script({ db: testDb, args: { env: "local", argv: [] } } as any)
+
+ expect((await pub.get()).data()?.ballotQuestionId).toBeNull()
+ expect((await arch.get()).data()?.ballotQuestionId).toBeNull()
+})
+
+it("skips docs that already have ballotQuestionId set", async () => {
+ const pub1 = testDb
+ .collection("users")
+ .doc("backfill-test-user2")
+ .collection("publishedTestimony")
+ .doc("has-null")
+ const pub2 = testDb
+ .collection("users")
+ .doc("backfill-test-user2")
+ .collection("publishedTestimony")
+ .doc("has-value")
+
+ await pub1.set({ ballotQuestionId: null, billId: "H1234", court: 193 })
+ await pub2.set({ ballotQuestionId: "bq-123", billId: "H5678", court: 193 })
+
+ await script({ db: testDb, args: { env: "local", argv: [] } } as any)
+
+ expect((await pub1.get()).data()?.ballotQuestionId).toBeNull()
+ expect((await pub2.get()).data()?.ballotQuestionId).toBe("bq-123")
+})
diff --git a/tests/integration/ballotQuestionFollowing.test.ts b/tests/integration/ballotQuestionFollowing.test.ts
new file mode 100644
index 000000000..0e1242992
--- /dev/null
+++ b/tests/integration/ballotQuestionFollowing.test.ts
@@ -0,0 +1,79 @@
+import { collection, getDocs, query, where } from "firebase/firestore"
+import { firestore } from "../../components/firebase"
+import {
+ followBallotQuestion,
+ unfollowBallotQuestion
+} from "../../components/shared/FollowingQueries"
+import { terminateFirebase, testDb } from "../testUtils"
+import { expectPermissionDenied, signInUser1, signInUser2 } from "./common"
+
+afterAll(terminateFirebase)
+
+describe("ballot question following", () => {
+ const ballotQuestionId = "question-1"
+ const court = 193
+ const topicName = `ballot-question-${court}-${ballotQuestionId}`
+ const bq = { id: ballotQuestionId, court }
+
+ it("followBallotQuestion writes a subscription doc with correct shape", async () => {
+ const user1 = await signInUser1()
+ const uid = user1.uid
+
+ await followBallotQuestion(uid, bq)
+
+ const snap = await testDb
+ .doc(`/users/${uid}/activeTopicSubscriptions/${topicName}`)
+ .get()
+
+ expect(snap.exists).toBe(true)
+ expect(snap.data()).toEqual({
+ topicName,
+ uid,
+ type: "ballotQuestion",
+ ballotQuestionLookup: { ballotQuestionId, court }
+ })
+ })
+
+ it("unfollowBallotQuestion deletes the subscription doc", async () => {
+ const user1 = await signInUser1()
+ const uid = user1.uid
+
+ await followBallotQuestion(uid, bq)
+ await unfollowBallotQuestion(uid, bq)
+
+ const snap = await testDb
+ .doc(`/users/${uid}/activeTopicSubscriptions/${topicName}`)
+ .get()
+
+ expect(snap.exists).toBe(false)
+ })
+
+ it("querying by uid and type ballotQuestion returns correct lookup", async () => {
+ const user1 = await signInUser1()
+ const uid = user1.uid
+
+ await followBallotQuestion(uid, bq)
+
+ const q = query(
+ collection(firestore, `/users/${uid}/activeTopicSubscriptions/`),
+ where("uid", "==", uid),
+ where("type", "==", "ballotQuestion")
+ )
+ const snapshot = await getDocs(q)
+ const results: { ballotQuestionId: string; court: number }[] = []
+ snapshot.forEach(doc => {
+ if (doc.data().ballotQuestionLookup)
+ results.push(doc.data().ballotQuestionLookup)
+ })
+
+ expect(results).toHaveLength(1)
+ expect(results[0]).toEqual({ ballotQuestionId, court })
+ })
+
+ it("a user cannot write to another user's subscriptions", async () => {
+ const user1 = await signInUser1()
+ await signInUser2()
+
+ await expectPermissionDenied(followBallotQuestion(user1.uid, bq))
+ })
+})
diff --git a/tests/integration/ballotQuestions.test.ts b/tests/integration/ballotQuestions.test.ts
new file mode 100644
index 000000000..9b6eaab54
--- /dev/null
+++ b/tests/integration/ballotQuestions.test.ts
@@ -0,0 +1,36 @@
+import path from "path"
+import { testDb, terminateFirebase } from "../testUtils"
+import { script } from "../../scripts/firebase-admin/syncBallotQuestions"
+
+const FIXTURES_DIR = path.resolve(__dirname, "../fixtures/ballotQuestions")
+const TEST_ID = "test-99-99"
+
+afterAll(async () => {
+ await testDb.collection("ballotQuestions").doc(TEST_ID).delete()
+ await terminateFirebase()
+})
+
+it("syncs YAML files to Firestore", async () => {
+ await script({
+ db: testDb,
+ args: { env: "local", argv: [], dir: FIXTURES_DIR }
+ } as any)
+
+ const snap = await testDb.collection("ballotQuestions").doc(TEST_ID).get()
+ expect(snap.exists).toBe(true)
+ expect(snap.data()?.billId).toBe("H5099")
+ expect(snap.data()?.electionYear).toBe(2099)
+ expect(snap.data()?.description).toBeTruthy()
+ expect(snap.data()?.atAGlance).toBeInstanceOf(Array)
+ expect(snap.data()?.pdfUrl).toMatch(/^https?:\/\//)
+ expect(snap.data()?.fullSummary).toBeTruthy()
+})
+
+it("can query by electionYear", async () => {
+ const results = await testDb
+ .collection("ballotQuestions")
+ .where("electionYear", "==", 2099)
+ .get()
+ expect(results.docs.length).toBeGreaterThanOrEqual(1)
+ expect(results.docs[0].data().id).toBe(TEST_ID)
+})
diff --git a/tests/integration/testimony.test.ts b/tests/integration/testimony.test.ts
index 90f2dfc8e..3b90a08fc 100644
--- a/tests/integration/testimony.test.ts
+++ b/tests/integration/testimony.test.ts
@@ -24,6 +24,7 @@ type BaseTestimony = {
content: string
attachmentId: string | null | undefined
editReason?: string
+ ballotQuestionId?: string | null
}
type DraftTestimony = BaseTestimony & {
@@ -225,9 +226,9 @@ describe("publishTestimony", () => {
const res1 = await publishTestimony({ draftId })
const { uid: uid2 } = await signInTestAdmin()
- await createDraft(uid2, billId)
+ const { draftId: draftId2 } = await createDraft(uid2, billId)
- const res2 = await publishTestimony({ draftId })
+ const res2 = await publishTestimony({ draftId: draftId2 })
let bill = await getBill(billId)
expect(bill.testimonyCount).toBe(2)
@@ -368,6 +369,116 @@ describe("publishTestimony", () => {
expect(attachments.published).toBeFalsy()
})
})
+
+ describe("ballotQuestionId", () => {
+ beforeAll(async () => {
+ await testDb.collection("ballotQuestions").doc("bq-test-1").set({
+ id: "bq-test-1",
+ billId: null,
+ court: currentGeneralCourt,
+ electionYear: 2026,
+ type: "initiative_statute",
+ ballotStatus: "expectedOnBallot",
+ ballotQuestionNumber: null,
+ relatedBillIds: [],
+ description: null,
+ atAGlance: null,
+ fullSummary: null,
+ pdfUrl: null,
+ title: "Test ballot question"
+ })
+ })
+
+ afterAll(async () => {
+ await testDb.collection("ballotQuestions").doc("bq-test-1").delete()
+ })
+
+ it("Copies ballotQuestionId from draft to published testimony", async () => {
+ const { draftId } = await createDraft(uid, billId, undefined, "bq-test-1")
+ const res = await publishTestimony({ draftId })
+ const publication = await getPublication(uid, res.data.publicationId)
+ expect(publication.ballotQuestionId).toBe("bq-test-1")
+ })
+
+ it("Ballot question testimony does not overwrite existing regular bill testimony", async () => {
+ // Publish regular testimony first
+ const resA = await publishTestimony({ draftId })
+ const publicationIdA = resA.data.publicationId
+
+ // Publish ballot question testimony for same bill
+ const { draftId: bqDraftId } = await createDraft(
+ uid,
+ billId,
+ undefined,
+ "bq-test-1"
+ )
+ const resB = await publishTestimony({ draftId: bqDraftId })
+ const publicationIdB = resB.data.publicationId
+
+ expect(publicationIdA).not.toBe(publicationIdB)
+
+ const pubA = await getPublication(uid, publicationIdA)
+ const pubB = await getPublication(uid, publicationIdB)
+ expect(pubA.ballotQuestionId ?? null).toBeNull()
+ expect(pubB.ballotQuestionId).toBe("bq-test-1")
+ })
+
+ it("Regular bill testimony does not overwrite existing ballot question testimony", async () => {
+ // Publish ballot question testimony first
+ const { draftId: bqDraftId } = await createDraft(
+ uid,
+ billId,
+ undefined,
+ "bq-test-1"
+ )
+ const resA = await publishTestimony({ draftId: bqDraftId })
+ const publicationIdA = resA.data.publicationId
+
+ // Publish regular testimony for same bill
+ const resB = await publishTestimony({ draftId })
+ const publicationIdB = resB.data.publicationId
+
+ expect(publicationIdA).not.toBe(publicationIdB)
+
+ const pubA = await getPublication(uid, publicationIdA)
+ const pubB = await getPublication(uid, publicationIdB)
+ expect(pubA.ballotQuestionId).toBe("bq-test-1")
+ expect(pubB.ballotQuestionId ?? null).toBeNull()
+ })
+
+ it("Fails to publish testimony with a non-existent ballotQuestionId", async () => {
+ const { draftId: bqDraftId } = await createDraft(
+ uid,
+ billId,
+ undefined,
+ "nonexistent-bq"
+ )
+ await expect(publishTestimony({ draftId: bqDraftId })).rejects.toThrow(
+ "invalid ballotQuestionId"
+ )
+ })
+
+ it("Re-publishing ballot question testimony updates the same doc", async () => {
+ const { draftId: bqDraftId } = await createDraft(
+ uid,
+ billId,
+ undefined,
+ "bq-test-1"
+ )
+ const res1 = await publishTestimony({ draftId: bqDraftId })
+
+ await updateDoc(refs.draftTestimony(uid, bqDraftId), {
+ content: "updated ballot question content",
+ editReason: "update reason"
+ })
+ const res2 = await publishTestimony({ draftId: bqDraftId })
+
+ expect(res1.data.publicationId).toBe(res2.data.publicationId)
+ const publication = await getPublication(uid, res2.data.publicationId)
+ expect(publication.version).toBe(2)
+ expect(publication.ballotQuestionId).toBe("bq-test-1")
+ })
+ })
})
describe("deleteTestimony", () => {
@@ -507,15 +618,17 @@ async function createDraftAttachment(uid: string, id: string, content: string) {
async function createDraft(
uid: string,
billId: string,
- court = currentGeneralCourt
+ court = currentGeneralCourt,
+ ballotQuestionId?: string
) {
- const draftId = "test-draft-id"
+ const draftId = nanoid()
const draft: DraftTestimony = {
billId,
content: "test testimony",
court,
position: "endorse",
- attachmentId: null
+ attachmentId: null,
+ ballotQuestionId: ballotQuestionId ?? null
}
await setDoc(refs.draftTestimony(uid, draftId), draft)
diff --git a/tests/system/testimony.test.ts b/tests/system/testimony.test.ts
index 92387cf7c..03ac73af3 100644
--- a/tests/system/testimony.test.ts
+++ b/tests/system/testimony.test.ts
@@ -10,7 +10,7 @@ import {
import { httpsCallable } from "firebase/functions"
import { loremIpsum } from "lorem-ipsum"
import { auth, firestore, functions } from "../../components/firebase"
-import { terminateFirebase } from "../testUtils"
+import { terminateFirebase, testDb } from "../testUtils"
const publishTestimony = httpsCallable<
{ draftId: string },
@@ -36,12 +36,40 @@ beforeAll(async () => {
afterAll(terminateFirebase)
-it("can publish and delete testimony", async () => {
+it("can publish and delete bill testimony", async () => {
const draft = await expectCreateDraft()
const publication = await expectPublish(draft.draft, draft.draftRef)
await expectDelete(publication.id)
})
+describe("ballot question testimony", () => {
+ const ballotQuestionId = `bq-system-test-${Date.now()}`
+
+ beforeAll(async () => {
+ await testDb
+ .collection("ballotQuestions")
+ .doc(ballotQuestionId)
+ .set({ id: ballotQuestionId })
+ })
+
+ afterAll(async () => {
+ await testDb.collection("ballotQuestions").doc(ballotQuestionId).delete()
+ })
+
+ it("can publish ballot-question testimony", async () => {
+ const draft = await expectCreateDraft({
+ billId: "H1",
+ content: "system test ballot-question testimony",
+ court: currentGeneralCourt,
+ position: "endorse",
+ ballotQuestionId
+ })
+ const publication = await expectPublish(draft.draft, draft.draftRef)
+
+ expect(publication.data()!.ballotQuestionId).toEqual(ballotQuestionId)
+ })
+})
+
// Publish some testimony for testing purposes
it.skip("can seed fake testimony", async () => {
const billsToSeed = ["H1000", "H1001", "H1002", "H1003"]
diff --git a/yarn.lock b/yarn.lock
index 7f317e62f..67a9bdca8 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -4283,51 +4283,132 @@
"@types/express" "^4.7.0"
file-system-cache "2.3.0"
+"@swc/core-darwin-arm64@1.15.21":
+ version "1.15.21"
+ resolved "https://registry.yarnpkg.com/@swc/core-darwin-arm64/-/core-darwin-arm64-1.15.21.tgz#7153201537954b5f3b5748c315cdf0e0dcd533a8"
+ integrity sha512-SA8SFg9dp0qKRH8goWsax6bptFE2EdmPf2YRAQW9WoHGf3XKM1bX0nd5UdwxmC5hXsBUZAYf7xSciCler6/oyA==
+
"@swc/core-darwin-arm64@1.3.100":
version "1.3.100"
resolved "https://registry.yarnpkg.com/@swc/core-darwin-arm64/-/core-darwin-arm64-1.3.100.tgz#f582c5bbc9c49506f728fc1d14dff33c2cc226d5"
integrity sha512-XVWFsKe6ei+SsDbwmsuRkYck1SXRpO60Hioa4hoLwR8fxbA9eVp6enZtMxzVVMBi8ej5seZ4HZQeAWepbukiBw==
+"@swc/core-darwin-x64@1.15.21":
+ version "1.15.21"
+ resolved "https://registry.yarnpkg.com/@swc/core-darwin-x64/-/core-darwin-x64-1.15.21.tgz#05ff28c00a7045d9760c847e19604fff02b6e3ea"
+ integrity sha512-//fOVntgowz9+V90lVsNCtyyrtbHp3jWH6Rch7MXHXbcvbLmbCTmssl5DeedUWLLGiAAW1wksBdqdGYOTjaNLw==
+
"@swc/core-darwin-x64@1.3.100":
version "1.3.100"
resolved "https://registry.yarnpkg.com/@swc/core-darwin-x64/-/core-darwin-x64-1.3.100.tgz#d84f5c0bb4603c252884d011a698ed7c634b1505"
integrity sha512-KF/MXrnH1nakm1wbt4XV8FS7kvqD9TGmVxeJ0U4bbvxXMvzeYUurzg3AJUTXYmXDhH/VXOYJE5N5RkwZZPs5iA==
+"@swc/core-linux-arm-gnueabihf@1.15.21":
+ version "1.15.21"
+ resolved "https://registry.yarnpkg.com/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.15.21.tgz#d52a0fac1933fe4e4180a196417053571d6c255f"
+ integrity sha512-meNI4Sh6h9h8DvIfEc0l5URabYMSuNvyisLmG6vnoYAS43s8ON3NJR8sDHvdP7NJTrLe0q/x2XCn6yL/BeHcZg==
+
+"@swc/core-linux-arm64-gnu@1.15.21":
+ version "1.15.21"
+ resolved "https://registry.yarnpkg.com/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.15.21.tgz#32cd1b9d0d4be4d53ccfbc122ac61289f37735b9"
+ integrity sha512-QrXlNQnHeXqU2EzLlnsPoWEh8/GtNJLvfMiPsDhk+ht6Xv8+vhvZ5YZ/BokNWSIZiWPKLAqR0M7T92YF5tmD3g==
+
"@swc/core-linux-arm64-gnu@1.3.100":
version "1.3.100"
resolved "https://registry.yarnpkg.com/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.3.100.tgz#1ed4b92b373882d8f338c4e0a0aa64cdaa6106f1"
integrity sha512-p8hikNnAEJrw5vHCtKiFT4hdlQxk1V7vqPmvUDgL/qe2menQDK/i12tbz7/3BEQ4UqUPnvwpmVn2d19RdEMNxw==
+"@swc/core-linux-arm64-musl@1.15.21":
+ version "1.15.21"
+ resolved "https://registry.yarnpkg.com/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.15.21.tgz#0993e8b2ffac4f1141fa7b158e8dd982c2476c1a"
+ integrity sha512-8/yGCMO333ultDaMQivE5CjO6oXDPeeg1IV4sphojPkb0Pv0i6zvcRIkgp60xDB+UxLr6VgHgt+BBgqS959E9g==
+
"@swc/core-linux-arm64-musl@1.3.100":
version "1.3.100"
resolved "https://registry.yarnpkg.com/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.3.100.tgz#9db560f7459e42e65ec02670d6a8316e7c850cfc"
integrity sha512-BWx/0EeY89WC4q3AaIaBSGfQxkYxIlS3mX19dwy2FWJs/O+fMvF9oLk/CyJPOZzbp+1DjGeeoGFuDYpiNO91JA==
+"@swc/core-linux-ppc64-gnu@1.15.21":
+ version "1.15.21"
+ resolved "https://registry.yarnpkg.com/@swc/core-linux-ppc64-gnu/-/core-linux-ppc64-gnu-1.15.21.tgz#5f6765d9a36235d95fd5c69f6d848973e85d8180"
+ integrity sha512-ucW0HzPx0s1dgRvcvuLSPSA/2Kk/VYTv9st8qe1Kc22Gu0Q0rH9+6TcBTmMuNIp0Xs4BPr1uBttmbO1wEGI49Q==
+
+"@swc/core-linux-s390x-gnu@1.15.21":
+ version "1.15.21"
+ resolved "https://registry.yarnpkg.com/@swc/core-linux-s390x-gnu/-/core-linux-s390x-gnu-1.15.21.tgz#f96779dc2ba8d47298bca3ceaa961e0f460aa0bd"
+ integrity sha512-ulTnOGc5I7YRObE/9NreAhQg94QkiR5qNhhcUZ1iFAYjzg/JGAi1ch+s/Ixe61pMIr8bfVrF0NOaB0f8wjaAfA==
+
+"@swc/core-linux-x64-gnu@1.15.21":
+ version "1.15.21"
+ resolved "https://registry.yarnpkg.com/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.15.21.tgz#0ffe779d5fd060bfb7992176f51d317c81c6aaaf"
+ integrity sha512-D0RokxtM+cPvSqJIKR6uja4hbD+scI9ezo95mBhfSyLUs9wnPPl26sLp1ZPR/EXRdYm3F3S6RUtVi+8QXhT24Q==
+
"@swc/core-linux-x64-gnu@1.3.100":
version "1.3.100"
resolved "https://registry.yarnpkg.com/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.3.100.tgz#228826ea48879bf1e73683fbef4373e3e762e424"
integrity sha512-XUdGu3dxAkjsahLYnm8WijPfKebo+jHgHphDxaW0ovI6sTdmEGFDew7QzKZRlbYL2jRkUuuKuDGvD6lO5frmhA==
+"@swc/core-linux-x64-musl@1.15.21":
+ version "1.15.21"
+ resolved "https://registry.yarnpkg.com/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.15.21.tgz#2ea9fab26555d27c715aed6a08604a8296e4af50"
+ integrity sha512-nER8u7VeRfmU6fMDzl1NQAbbB/G7O2avmvCOwIul1uGkZ2/acbPH+DCL9h5+0yd/coNcxMBTL6NGepIew+7C2w==
+
"@swc/core-linux-x64-musl@1.3.100":
version "1.3.100"
resolved "https://registry.yarnpkg.com/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.3.100.tgz#09a234dbbf625d071ecb663680e997a62d230d49"
integrity sha512-PhoXKf+f0OaNW/GCuXjJ0/KfK9EJX7z2gko+7nVnEA0p3aaPtbP6cq1Ubbl6CMoPL+Ci3gZ7nYumDqXNc3CtLQ==
+"@swc/core-win32-arm64-msvc@1.15.21":
+ version "1.15.21"
+ resolved "https://registry.yarnpkg.com/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.15.21.tgz#b401f34f38d744ca2b800bf2574ef5f7b20ca52f"
+ integrity sha512-+/AgNBnjYugUA8C0Do4YzymgvnGbztv7j8HKSQLvR/DQgZPoXQ2B3PqB2mTtGh/X5DhlJWiqnunN35JUgWcAeQ==
+
"@swc/core-win32-arm64-msvc@1.3.100":
version "1.3.100"
resolved "https://registry.yarnpkg.com/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.3.100.tgz#add1c82884c10a9054ed6a48f884097aa85c6d2b"
integrity sha512-PwLADZN6F9cXn4Jw52FeP/MCLVHm8vwouZZSOoOScDtihjY495SSjdPnlosMaRSR4wJQssGwiD/4MbpgQPqbAw==
+"@swc/core-win32-ia32-msvc@1.15.21":
+ version "1.15.21"
+ resolved "https://registry.yarnpkg.com/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.15.21.tgz#c761e981725d137abd7abcecff88d1dc2d76baad"
+ integrity sha512-IkSZj8PX/N4HcaFhMQtzmkV8YSnuNoJ0E6OvMwFiOfejPhiKXvl7CdDsn1f4/emYEIDO3fpgZW9DTaCRMDxaDA==
+
"@swc/core-win32-ia32-msvc@1.3.100":
version "1.3.100"
resolved "https://registry.yarnpkg.com/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.3.100.tgz#e0b6c5ae7f3250adeeb88dae83558d3f45148c56"
integrity sha512-0f6nicKSLlDKlyPRl2JEmkpBV4aeDfRQg6n8mPqgL7bliZIcDahG0ej+HxgNjZfS3e0yjDxsNRa6sAqWU2Z60A==
+"@swc/core-win32-x64-msvc@1.15.21":
+ version "1.15.21"
+ resolved "https://registry.yarnpkg.com/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.15.21.tgz#4878cd851b4f98033e19fca78953201aef736edd"
+ integrity sha512-zUyWso7OOENB6e1N1hNuNn8vbvLsTdKQ5WKLgt/JcBNfJhKy/6jmBmqI3GXk/MyvQKd5SLvP7A0F36p7TeDqvw==
+
"@swc/core-win32-x64-msvc@1.3.100":
version "1.3.100"
resolved "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.3.100.tgz"
integrity sha512-b7J0rPoMkRTa3XyUGt8PwCaIBuYWsL2DqbirrQKRESzgCvif5iNpqaM6kjIjI/5y5q1Ycv564CB51YDpiS8EtQ==
+"@swc/core@^1.15.21":
+ version "1.15.21"
+ resolved "https://registry.yarnpkg.com/@swc/core/-/core-1.15.21.tgz#84e1a2dded1372efda7036a86749ded817d05ea2"
+ integrity sha512-fkk7NJcBscrR3/F8jiqlMptRHP650NxqDnspBMrRe5d8xOoCy9MLL5kOBLFXjFLfMo3KQQHhk+/jUULOMlR1uQ==
+ dependencies:
+ "@swc/counter" "^0.1.3"
+ "@swc/types" "^0.1.25"
+ optionalDependencies:
+ "@swc/core-darwin-arm64" "1.15.21"
+ "@swc/core-darwin-x64" "1.15.21"
+ "@swc/core-linux-arm-gnueabihf" "1.15.21"
+ "@swc/core-linux-arm64-gnu" "1.15.21"
+ "@swc/core-linux-arm64-musl" "1.15.21"
+ "@swc/core-linux-ppc64-gnu" "1.15.21"
+ "@swc/core-linux-s390x-gnu" "1.15.21"
+ "@swc/core-linux-x64-gnu" "1.15.21"
+ "@swc/core-linux-x64-musl" "1.15.21"
+ "@swc/core-win32-arm64-msvc" "1.15.21"
+ "@swc/core-win32-ia32-msvc" "1.15.21"
+ "@swc/core-win32-x64-msvc" "1.15.21"
+
"@swc/core@^1.3.82":
version "1.3.100"
resolved "https://registry.npmjs.org/@swc/core/-/core-1.3.100.tgz"
@@ -4351,6 +4432,11 @@
resolved "https://registry.npmjs.org/@swc/counter/-/counter-0.1.2.tgz"
integrity sha512-9F4ys4C74eSTEUNndnER3VJ15oru2NumfQxS8geE+f3eB5xvfxpWyqE5XlVnxb/R14uoXi6SLbBwwiDSkv+XEw==
+"@swc/counter@^0.1.3":
+ version "0.1.3"
+ resolved "https://registry.yarnpkg.com/@swc/counter/-/counter-0.1.3.tgz#cc7463bd02949611c6329596fccd2b0ec782b0e9"
+ integrity sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==
+
"@swc/helpers@0.5.2":
version "0.5.2"
resolved "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.2.tgz"
@@ -4365,6 +4451,20 @@
dependencies:
tslib "^2.4.0"
+"@swc/helpers@^0.5.19":
+ version "0.5.19"
+ resolved "https://registry.yarnpkg.com/@swc/helpers/-/helpers-0.5.19.tgz#9a8c8a0bdaecfdfb9b8ae5421c0c8e09246dfee9"
+ integrity sha512-QamiFeIK3txNjgUTNppE6MiG3p7TdninpZu0E0PbqVh1a9FNLT2FRhisaa4NcaX52XVhA5l7Pk58Ft7Sqi/2sA==
+ dependencies:
+ tslib "^2.8.0"
+
+"@swc/types@^0.1.25":
+ version "0.1.26"
+ resolved "https://registry.yarnpkg.com/@swc/types/-/types-0.1.26.tgz#2a976a1870caef1992316dda1464150ee36968b5"
+ integrity sha512-lyMwd7WGgG79RS7EERZV3T8wMdmPq3xwyg+1nmAM64kIhx5yl+juO2PYIHb7vTiPgPCj8LYjsNV2T5wiQHUEaw==
+ dependencies:
+ "@swc/counter" "^0.1.3"
+
"@swc/types@^0.1.5":
version "0.1.5"
resolved "https://registry.npmjs.org/@swc/types/-/types-0.1.5.tgz"
@@ -17304,6 +17404,11 @@ tslib@^2.0.0, tslib@^2.0.1, tslib@^2.0.3, tslib@^2.1.0, tslib@^2.3.0, tslib@^2.4
resolved "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz"
integrity sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==
+tslib@^2.8.0:
+ version "2.8.1"
+ resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f"
+ integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==
+
tsscmp@^1.0.6:
version "1.0.6"
resolved "https://registry.npmjs.org/tsscmp/-/tsscmp-1.0.6.tgz"