Skip to content

Commit

Permalink
feat: [WIP]: Vote list page
Browse files Browse the repository at this point in the history
  • Loading branch information
Ackuq committed Oct 1, 2023
1 parent 0e4a068 commit 1599d66
Show file tree
Hide file tree
Showing 15 changed files with 554 additions and 3 deletions.
61 changes: 61 additions & 0 deletions apps/web/app/votering/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import PageTitle from "@components/common/page-title";
import type { FilterToggle } from "@components/filter/filter-context";
import { FilterContextProvider } from "@components/filter/filter-context";
import { ScaleIcon } from "@heroicons/react/24/solid";
import { getVotes } from "@lib/api/vote/get-votes";
import {
parseNumberSearchParam,
parseStringArraySearchParam,
parseStringSearchParam,
} from "@lib/utils/search-params";
import VoteList from "./vote-list";
import Filter from "@components/filter";
import { Committee, committeeInfo } from "@lib/committes";

export const metadata = {
title: "Voteringar | Partiguiden",
description: "Hur har partierna röstat i voteringar? Ta reda på det här",
};

function initialFilterToggles(committees: string[]): FilterToggle<Committee> {
return Object.values(Committee).reduce(
(prev, committee) => ({
...prev,
[committee]: {
title: committeeInfo[committee].desc,
value: committees.includes(committee),
},
}),
{} as FilterToggle<Committee>,
);
}

interface Props {
searchParams: {
sok?: string | string[];
sida?: string | string[];
utskott?: string | string[];
};
}

export default async function Votes({ searchParams }: Props) {
const page = parseNumberSearchParam(searchParams.sida);
const search = parseStringSearchParam(searchParams.sok);
const committees = parseStringArraySearchParam(searchParams.utskott);
const votes = await getVotes({ search, page, committees });

const filterToggles = initialFilterToggles(committees);

return (
<main>
<PageTitle Icon={ScaleIcon}>Voteringar</PageTitle>

<div className="mx-4 mb-4 flex gap-2 2xl:container 2xl:mx-auto">
<FilterContextProvider initialToggles={filterToggles}>
<VoteList votes={votes} />
<Filter />
</FilterContextProvider>
</div>
</main>
);
}
52 changes: 52 additions & 0 deletions apps/web/app/votering/vote-list.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
"use client";
import { useFilterContext } from "@components/filter/filter-context";
import type { VoteList } from "@lib/api/vote/types";
import { routes } from "@lib/navigation";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
import Vote from "./vote";

interface Props {
votes: VoteList;
}

export default function VoteList({ votes }: Props) {
const router = useRouter();
const { search, toggles } = useFilterContext();

useEffect(() => {
const debounce = setTimeout(() => {
const query = new URLSearchParams();
const activeToggles = Object.entries(toggles)
.filter(([, value]) => value.value)
.map(([key]) => key);
for (const toggle of activeToggles) {
query.append("utskott", toggle);
}
if (search) {
query.set("sok", search);
}

router.replace(`${routes.votes}?${query}`);
}, 500);
return () => {
clearTimeout(debounce);
};
}, [router, search, toggles]);

if (votes.pages === 0) {
return (
<p className="flex-1 text-center text-xl sm:text-2xl">
Inga voteringar hittades
</p>
);
}

return (
<div className="grid flex-1 gap-4">
{votes.votes.map((vote) => (
<Vote key={`${vote.documentId}:${vote.proposition}`} vote={vote} />
))}
</div>
);
}
20 changes: 20 additions & 0 deletions apps/web/app/votering/vote.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { Card, CommitteeHeader } from "@components/common/card";
import type { VoteListEntry } from "@lib/api/vote/types";

interface Props {
vote: VoteListEntry;
}

export default function Vote({ vote }: Props) {
return (
<Card className="p-0">
<CommitteeHeader committee={vote.committee} />
<div className="p-4">
<p className="text-sm text-slate-600 dark:text-slate-400">
{vote.title}
</p>
<p>{vote.subtitle}</p>
</div>
</Card>
);
}
17 changes: 17 additions & 0 deletions apps/web/lib/api/vote/get-vote-results.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { PARLIAMENT_BASE_URL } from "@lib/constants";
import stripJsonComments from "strip-json-comments";
import type { VoteResultsResponse } from "./types";
import parseVoteResult from "./parsers/vote-result";

export default async function getVoteResult(
id: string,
num: number,
): Promise<VoteResultsResponse> {
const response = await fetch(
`${PARLIAMENT_BASE_URL}/dokumentstatus/${id}.json`,
);
const text = await response.text();
const data = JSON.parse(stripJsonComments(text));

return parseVoteResult(data, num);
}
35 changes: 35 additions & 0 deletions apps/web/lib/api/vote/get-votes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { PARLIAMENT_BASE_URL } from "@lib/constants";
import { parseVotes } from "./parsers/votes";

interface Query {
search?: string;
committees: string[];
page?: number;
}

export async function getVotes({ search, committees, page }: Query) {
const query = new URLSearchParams({
doktyp: "votering",
sortorder: "desc",
utformat: "json",
sok: search || "",
sort: search ? "rel" : "datum",
p: page?.toString() || "",
});
for (const committe of committees) {
query.append("org", committe);
}

const response = await fetch(
`${PARLIAMENT_BASE_URL}/dokumentlista/?${query}`,
{
next: {
revalidate: 60 * 60,
},
},
);

const data = await response.json();

return parseVotes(data);
}
38 changes: 38 additions & 0 deletions apps/web/lib/api/vote/parsers/vote-result.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import type {
NewVotingRow,
NewVotingTable,
OldVotingRow,
OldVotingTable,
VoteDocumentStatus,
VotingTable,
} from "@lib/api/parliament/types";
import type { VoteResultsResponse } from "../types";
import { getMaxVote } from "../utilts/get-max-vote";
import extractVotes from "../utilts/extract-votes";

function getVotingRow(votingTable: VotingTable): NewVotingRow[] | OldVotingRow {
if ((<NewVotingTable>votingTable).tbody !== undefined) {
return (<NewVotingTable>votingTable).tbody.tr;
}
return (<OldVotingTable>votingTable).tr;
}

export default function parseVoteResult(
data: VoteDocumentStatus,
num: number,
): VoteResultsResponse {
const { dokumentstatus } = data;
const { utskottsforslag } = dokumentstatus.dokutskottsforslag;
const voteObject = Array.isArray(utskottsforslag)
? utskottsforslag[num - 1]
: utskottsforslag;

const { table } = voteObject.votering_sammanfattning_html;
const singleTable = Array.isArray(table) ? table[table.length - 1] : table;
const tableRow = getVotingRow(singleTable);

return {
results: getMaxVote(extractVotes(tableRow)),
subtitle: voteObject.rubrik,
};
}
46 changes: 46 additions & 0 deletions apps/web/lib/api/vote/parsers/votes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import type {
DocumentList,
DocumentListEntry,
} from "@lib/api/parliament/types";
import type { VoteList, VoteListEntry } from "../types";
import titleTrim from "../utilts/title-trim";
import getVoteResult from "../get-vote-results";
import { Committee } from "@lib/committes";

async function parseVote(data: DocumentListEntry): Promise<VoteListEntry> {
const { beteckning: denomination, id } = data;
const title: string = titleTrim(data.sokdata.titel);
const proposition = parseInt(data.tempbeteckning, 10);

const documentId = `${id.substring(0, 2)}01${denomination.split("p")[0]}`;

const committee = Object.values(Committee).includes(data.organ)
? data.organ
: undefined;

const { results, subtitle } = await getVoteResult(documentId, proposition);

return { title, results, committee, documentId, proposition, subtitle };
}

export function parseVotes(data: DocumentList): Promise<VoteList> {
const { dokumentlista: document } = data;

const pages = parseInt(document["@sidor"], 10);

const votes = document.dokument;

if (!votes || pages === 0) {
return Promise.resolve({
pages,
votes: [],
});
}

const votesPromises = votes.map(parseVote);

return Promise.all(votesPromises).then((votes) => ({
pages,
votes,
}));
}
56 changes: 56 additions & 0 deletions apps/web/lib/api/vote/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import type { Party } from "@partiguiden/party-data/types";
import type { DocumentAttachment } from "../parliament/types";
import type { Committee } from "@lib/committes";

export type VoteDescription = "yes" | "no" | "refrain" | "absent";

export type VotingEntry = Record<VoteDescription, number>;

export type VotingGroup = Party | "noParty" | "total";

export type VotingDict = Record<VotingGroup, VotingEntry>;

export type VotingResult = {
yes: string[];
no: string[];
winner: "yes" | "no" | "draw";
};

export interface VoteResultsResponse {
results: VotingResult;
subtitle: string;
}
export interface VoteListEntry extends VoteResultsResponse {
title: string;
committee?: Committee;
documentId: string;
proposition: number;
}

export interface ProcessedDocument {
id: string;
label: string;
proposals?: string;
}

export interface VoteAppendixItem {
titel: string;
dok_id: string;
fil_url: string;
}

export interface VoteList {
pages: number;
votes: VoteListEntry[];
}

export interface Vote {
title: string;
description: string;
committee: Committee;
propositionText: string;
processedDocuments: ProcessedDocument[];
appendix: DocumentAttachment[];
decision: string;
voting: VotingDict;
}
Loading

0 comments on commit 1599d66

Please sign in to comment.