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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions backend/howtheyvote/api/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,14 @@ class BaseVoteDict(TypedDict):
id: Annotated[int, 157420]
"""ID as published in the official roll-call vote results"""

is_main: Annotated[bool, False]
"""Whether this vote is a main vote. We classify certain votes as main votes based
on the text description in the voting records published by Parliament. For example,
if Parliament has voted on amendments, only the vote on the text as a whole is
classified as a main vote. Certain votes such as votes on the agenda are not classified
as main votes. This is not an official classification by the European Parliament
and there may be false negatives."""

timestamp: Annotated[datetime.datetime, "2023-07-12T12:44:14"]
"""Date and time of the vote"""

Expand Down Expand Up @@ -312,6 +320,7 @@ def serialize_base_vote(vote: Vote) -> BaseVoteDict:

return {
"id": vote.id,
"is_main": vote.is_main,
"timestamp": vote.timestamp,
"display_title": vote.display_title,
"description": vote.description,
Expand Down
1 change: 1 addition & 0 deletions backend/tests/api/test_votes_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -444,6 +444,7 @@ def test_votes_api_show(records, db_session, api):

expected = {
"id": 1,
"is_main": False,
"display_title": "Should we have pizza for lunch?",
"timestamp": "2023-01-01T00:00:00",
"reference": None,
Expand Down
9 changes: 9 additions & 0 deletions frontend/src/api/generated/types.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,15 @@ export type BaseVote = {
* ID as published in the official roll-call vote results
*/
id: number;
/**
* Whether this vote is a main vote. We classify certain votes as main votes based
* on the text description in the voting records published by Parliament. For example,
* if Parliament has voted on amendments, only the vote on the text as a whole is
* classified as a main vote. Certain votes such as votes on the agenda are not classified
* as main votes. This is not an official classification by the European Parliament
* and there may be false negatives.
*/
is_main: boolean;
/**
* Date and time of the vote
*/
Expand Down
34 changes: 34 additions & 0 deletions frontend/src/components/AmendmentHeader.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import type { Vote } from "../api";
import { formatDate } from "../lib/dates";
import Stack from "./Stack";
import Wrapper from "./Wrapper";
import "./VoteHeader.css";

type VoteHeaderProps = {
vote: Vote;
};

export default function AmendmentHeader({ vote }: VoteHeaderProps) {
// Sometimes, vote titles contain non-breaking spaces. In many cases these
// do not work well for large headings, especially on small screens
const title = vote.display_title?.replace(/\u00A0/g, " ");

return (
<header className="vote-header">
<Wrapper>
<Stack space="sm">
<h2 className="beta">Amendments</h2>
<h1 className="alpha">{title}</h1>
<p>
<strong>
<time datetime={vote.timestamp}>
{formatDate(vote.timestamp)}
</time>
{vote.reference && ` · ${vote.reference}`}
</strong>
</p>
</Stack>
</Wrapper>
</header>
);
}
19 changes: 19 additions & 0 deletions frontend/src/components/AmendmentVoteCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import type { RelatedVote } from "../api";
import { formatDate } from "../lib/dates";

import "./VoteCard.css";

type AmendmentVoteCardProps = {
vote: RelatedVote;
};

export default function AmendmentVoteCard({ vote }: AmendmentVoteCardProps) {
return (
<article class="vote-card">
<h2 class="vote-card__title">
<a href={`/votes/${vote.id}`}>{vote.description}</a>
</h2>
<div class="vote-card__meta">{formatDate(new Date(vote.timestamp))}</div>
</article>
);
}
63 changes: 63 additions & 0 deletions frontend/src/components/AmendmentVoteCards.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import type { RelatedVote } from "../api";
import { formatDate } from "../lib/dates";
import AmendmentVoteCard from "./AmendmentVoteCard";
import Stack from "./Stack";

import "./VoteCards.css";

type AmendmentVoteCardsProps = {
votes: Array<RelatedVote>;
groupByDate?: boolean;
};

type GroupProps = {
votes: Array<RelatedVote>;
// To group by political Group later
title?: string;
};

function Group({ votes, title }: GroupProps) {
return (
<Stack space="sm">
{title && <h2 class="delta">{title}</h2>}

{votes.map((vote: RelatedVote) => (
<AmendmentVoteCard key={vote.id} vote={vote} />
))}
</Stack>
);
}

export default function AmendmentVoteCards({
votes,
groupByDate,
}: AmendmentVoteCardsProps) {
if (!groupByDate) {
return (
<div class="vote-cards">
<Group votes={votes} />
</div>
);
}

const groups = new Map<string, Array<RelatedVote>>();

for (const vote of votes) {
const key = formatDate(new Date(vote.timestamp));

if (!key) {
continue;
}

const otherVotes = groups.get(key) || [];
groups.set(key, [...otherVotes, vote]);
}

return (
<div class="vote-cards">
{Array.from(groups.entries()).map(([formattedDate, votes]) => (
<Group key={formattedDate} votes={votes} title={formattedDate} />
))}
</div>
);
}
5 changes: 5 additions & 0 deletions frontend/src/components/Callout.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
.callout {
border-radius: var(--border-radius);
background-color: var(--gray-light);
padding: var(--space-xxs) var(--space-xs);
}
22 changes: 22 additions & 0 deletions frontend/src/components/Callout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import type { ComponentChildren } from "preact";

import "./Callout.css";

type CalloutProps = {
title?: string;
children?: ComponentChildren;
className?: string;
};

export default function Disclosure({
title,
children,
className,
}: CalloutProps) {
return (
<div className={`callout ${className || ""}`}>
{title && <p>{title}</p>}
{children}
</div>
);
}
57 changes: 57 additions & 0 deletions frontend/src/pages/ShowAmendmentVotesPage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { getVote, type Vote } from "../api";
import AmendmentHeader from "../components/AmendmentHeader";
import AmendmentVoteCards from "../components/AmendmentVoteCards";
import App from "../components/App";
import BaseLayout from "../components/BaseLayout";
import Callout from "../components/Callout";
import Stack from "../components/Stack";
import Wrapper from "../components/Wrapper";
import { HTTPException } from "../lib/http";
import type { Loader, Page, Request } from "../lib/server";

export const loader: Loader<Vote> = async (request: Request) => {
const { data } = await getVote({ path: { vote_id: request.params.id } });

// `/amendments` opened on non-main vote which cannot have amendments
if (!data.is_main) throw new HTTPException(404);
// `/amendments` opened on a main vote which does not have amendments
if (!(data.related.length > 1)) throw new HTTPException(404);

// The last element of `related` is the vote itself
if (
Number(data.related[data.related.length - 1].id) ===
Number(request.params.id)
)
data.related = data.related.slice(0, -1);

return data;
};

export const ShowAmendmentVotesPage: Page<Vote> = ({ data }) => {
return (
<App title={[data.display_title, "Amendments"]}>
<BaseLayout>
<Stack space="lg">
<AmendmentHeader vote={data} />
<Wrapper>
<Callout>
<p>
The votes on this page were on <b>amendments</b>.<br />
Amendments aim at changing the text of reports. The outcome of
amendments do not dictate the outcome of the vote on a report.
<br /> Successful amendments do not become part of official
Parliament positions if the report is not accepted in its{" "}
<a href={`/votes/${data.id}`}>final vote</a>.
</p>
</Callout>
</Wrapper>
<Wrapper>
<div className="px">
<AmendmentVoteCards votes={data.related} />
</div>
</Wrapper>
</Stack>
</BaseLayout>
</App>
);
};
20 changes: 20 additions & 0 deletions frontend/src/pages/ShowVotePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ export const ShowVotePage: Page<Vote> = ({ data }) => {
const csvUrl = getDownloadUrl(data.id, "csv");
const jsonUrl = getDownloadUrl(data.id, "json");

const hasAmendments = data.is_main && data.related.length > 1;

return (
<App
title={[data.display_title, "Vote Results"]}
Expand All @@ -47,6 +49,9 @@ export const ShowVotePage: Page<Vote> = ({ data }) => {
<VoteHeader vote={data} />
<PageNav>
<PageNavItem href="#result">Vote result</PageNavItem>
{hasAmendments && (
<PageNavItem href="#amendments">Amendments</PageNavItem>
)}
<PageNavItem href="#more-information">More information</PageNavItem>
<PageNavItem href="#open-data">Open data</PageNavItem>
<PageNavItem href="#sources">Sources</PageNavItem>
Expand All @@ -71,6 +76,21 @@ export const ShowVotePage: Page<Vote> = ({ data }) => {
)}
</Wrapper>
</div>
{hasAmendments && (
<div class="px">
<Wrapper>
<h2 id="amendments" class="delta mb--xs">
Amendments
</h2>
<p class="mb--xs">
This vote's report has had prior votes on amendments. <br />
<a href={`/votes/${data.id}/amendments`}>
See roll-call votes on amendments here.
</a>
</p>
</Wrapper>
</div>
)}
{data.links.length > 0 && (
<div class="px">
<Wrapper>
Expand Down
9 changes: 9 additions & 0 deletions frontend/src/server.entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ import { DevelopersPage } from "./pages/DevelopersPage";
import { HomePage, loader as homeLoader } from "./pages/HomePage";
import { ImprintPage } from "./pages/ImprintPage";
import { SearchPage, loader as searchLoader } from "./pages/SearchPage";
import {
ShowAmendmentVotesPage,
loader as showAmendmentVotesLoader,
} from "./pages/ShowAmendmentVotesPage";
import { ShowVotePage, loader as showVoteLoader } from "./pages/ShowVotePage";
import {
VoteSharepicPage,
Expand All @@ -24,6 +28,11 @@ app.use(logRequests);
app.registerPage("/", HomePage, homeLoader);
app.registerPage("/votes", SearchPage, searchLoader);
app.registerPage("/votes/:id", ShowVotePage, showVoteLoader);
app.registerPage(
"/votes/:id/amendments",
ShowAmendmentVotesPage,
showAmendmentVotesLoader,
);
app.registerPage("/votes/:id/sharepic", VoteSharepicPage, voteSharepicLoader);
app.registerPage(["/developers", "/developers/*"], DevelopersPage);
app.registerPage("/imprint", ImprintPage);
Expand Down