diff --git a/apps/web/app/ledamot/[id]/profile.tsx b/apps/web/app/ledamot/[id]/profile.tsx index 1004945ac..183e70850 100644 --- a/apps/web/app/ledamot/[id]/profile.tsx +++ b/apps/web/app/ledamot/[id]/profile.tsx @@ -12,7 +12,7 @@ export default function Profile({ member }: ProfileProps) {
{member.status} diff --git a/apps/web/app/ledamot/member-card.tsx b/apps/web/app/ledamot/member-card.tsx index 44f2d6e04..dd086aa38 100644 --- a/apps/web/app/ledamot/member-card.tsx +++ b/apps/web/app/ledamot/member-card.tsx @@ -27,7 +27,7 @@ export default function MemberCard({ member }: Props) {
{member.party !== "-" && ( diff --git a/apps/web/app/parti/[party]/page.tsx b/apps/web/app/parti/[party]/page.tsx index 64093c622..7d195859b 100644 --- a/apps/web/app/parti/[party]/page.tsx +++ b/apps/web/app/parti/[party]/page.tsx @@ -49,7 +49,11 @@ export default async function PartyPage({
( - + )} > {party.name} diff --git a/apps/web/app/votering/[id]/[bet]/page.tsx b/apps/web/app/votering/[id]/[bet]/page.tsx new file mode 100644 index 000000000..48698bbf8 --- /dev/null +++ b/apps/web/app/votering/[id]/[bet]/page.tsx @@ -0,0 +1,26 @@ +import Container from "@components/common/container"; +import PageTitle from "@components/common/page-title"; + +interface Props { + params: { + id: string; + bet: string; + }; +} + +export function generateMetadata({ params: { id, bet } }: Props) { + return { + title: `${id} ${bet} | Votering | Partiguiden`, + description: `Hur har partiernat röstat i voteringen ${id}`, + }; +} + +export default function Vote() { + // TODO: Implement + return ( +
+ + +
+ ); +} diff --git a/apps/web/app/votering/page.tsx b/apps/web/app/votering/page.tsx new file mode 100644 index 000000000..a01671365 --- /dev/null +++ b/apps/web/app/votering/page.tsx @@ -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 { + return Object.values(Committee).reduce( + (prev, committee) => ({ + ...prev, + [committee]: { + title: committeeInfo[committee].desc, + value: committees.includes(committee), + }, + }), + {} as FilterToggle, + ); +} + +interface Props { + searchParams: { + sok?: string | string[]; + sida?: string | string[]; + utskott?: string | string[]; + }; +} + +export default async function Votes({ searchParams }: Props) { + const page = parseNumberSearchParam(searchParams.sida) ?? 1; + const search = parseStringSearchParam(searchParams.sok); + const committees = parseStringArraySearchParam(searchParams.utskott); + const votes = await getVotes({ search, page, committees }); + + const filterToggles = initialFilterToggles(committees); + + return ( +
+ Voteringar + +
+ + + + +
+
+ ); +} diff --git a/apps/web/app/votering/vote-list.tsx b/apps/web/app/votering/vote-list.tsx new file mode 100644 index 000000000..1c60d0a60 --- /dev/null +++ b/apps/web/app/votering/vote-list.tsx @@ -0,0 +1,85 @@ +"use client"; +import type { FilterToggle } from "@components/filter/filter-context"; +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"; +import Pagination from "@components/common/pagination"; + +interface QueryParameters { + search: string; + toggles: FilterToggle; + page?: number; +} + +function buildQueryParameters({ search, toggles, page }: QueryParameters) { + 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); + } + + if (page) { + query.set("sida", page.toString()); + } + + return query; +} + +interface Props { + currentPage: number; + votes: VoteList; +} + +export default function VoteList({ votes, currentPage }: Props) { + const router = useRouter(); + const { search, toggles } = useFilterContext(); + + useEffect(() => { + const debounce = setTimeout(() => { + const query = buildQueryParameters({ search, toggles }); + router.replace(`${routes.votes}?${query}`); + }, 500); + return () => { + clearTimeout(debounce); + }; + }, [router, search, toggles]); + + function onChangePage(newPage: number) { + const query = buildQueryParameters({ search, toggles, page: newPage }); + router.push(`${routes.votes}?${query}`); + } + + if (votes.pages === 0) { + return ( +

+ Inga voteringar hittades +

+ ); + } + + return ( +
+ + {votes.votes.map((vote) => ( + + ))} + +
+ ); +} diff --git a/apps/web/app/votering/vote-result.tsx b/apps/web/app/votering/vote-result.tsx new file mode 100644 index 000000000..c8e94764e --- /dev/null +++ b/apps/web/app/votering/vote-result.tsx @@ -0,0 +1,60 @@ +import PartyIcon from "@components/party/icon"; +import type { VotingResult } from "@lib/api/vote/types"; +import type { Party } from "@partiguiden/party-data/types"; + +import { twMerge } from "tailwind-merge"; + +interface ResultColumnProps { + votes: Party[]; + title: string; + className: string; +} + +function ResultColumn({ votes, title, className }: ResultColumnProps) { + return ( +
+

{title}

+
+ {votes.map((party) => ( + + ))} +
+
+ ); +} + +interface Props { + votes: VotingResult; +} + +export default function VoteResult({ votes }: Props) { + if (!votes.no.length || !votes.yes.length) { + return

Ingen voteringsdata hittades

; + } + + return ( +
+ + +
+ ); +} diff --git a/apps/web/app/votering/vote.tsx b/apps/web/app/votering/vote.tsx new file mode 100644 index 000000000..1b5f6b95d --- /dev/null +++ b/apps/web/app/votering/vote.tsx @@ -0,0 +1,26 @@ +import { Card, CommitteeHeader } from "@components/common/card"; +import type { VoteListEntry } from "@lib/api/vote/types"; +import VoteResult from "./vote-result"; +import Link from "next/link"; +import { routes } from "@lib/navigation"; + +interface Props { + vote: VoteListEntry; +} + +export default function Vote({ vote }: Props) { + return ( + + + +
+

+ {vote.title} +

+

{vote.subtitle}

+
+ +
+ + ); +} diff --git a/apps/web/components/parliament/member-image.tsx b/apps/web/components/parliament/member-image.tsx index bb769d1cc..772d199c1 100644 --- a/apps/web/components/parliament/member-image.tsx +++ b/apps/web/components/parliament/member-image.tsx @@ -21,7 +21,7 @@ export default function MemberImage({ member, className, children, - sizes = "(min-width: 640px) 10rem, 6rem", + sizes = "(min-width: 640px) 160px, 96px", }: MemberImageProps) { const [fallback, setFallback] = useState(false); diff --git a/apps/web/components/party/icon.tsx b/apps/web/components/party/icon.tsx index cdf124b00..f1959a05c 100644 --- a/apps/web/components/party/icon.tsx +++ b/apps/web/components/party/icon.tsx @@ -1,25 +1,28 @@ import Image from "next/image"; import { partyLogo } from "@lib/assets"; import type { Party } from "@partiguiden/party-data/types"; +import { twMerge } from "tailwind-merge"; interface PartyIconProps { party: Party; - size?: number; className?: string; + sizes?: string; } export default function PartyIcon({ party, - size = 25, className, + sizes = "24px", }: PartyIconProps) { return ( - {`${party} +
+ {`${party} +
); } diff --git a/apps/web/lib/api/vote/get-vote-results.ts b/apps/web/lib/api/vote/get-vote-results.ts new file mode 100644 index 000000000..56539a593 --- /dev/null +++ b/apps/web/lib/api/vote/get-vote-results.ts @@ -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 { + 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); +} diff --git a/apps/web/lib/api/vote/get-votes.ts b/apps/web/lib/api/vote/get-votes.ts new file mode 100644 index 000000000..e5431b083 --- /dev/null +++ b/apps/web/lib/api/vote/get-votes.ts @@ -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); +} diff --git a/apps/web/lib/api/vote/parsers/vote-result.ts b/apps/web/lib/api/vote/parsers/vote-result.ts new file mode 100644 index 000000000..93eff5572 --- /dev/null +++ b/apps/web/lib/api/vote/parsers/vote-result.ts @@ -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 ((votingTable).tbody !== undefined) { + return (votingTable).tbody.tr; + } + return (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, + }; +} diff --git a/apps/web/lib/api/vote/parsers/votes.ts b/apps/web/lib/api/vote/parsers/votes.ts new file mode 100644 index 000000000..33e719ea5 --- /dev/null +++ b/apps/web/lib/api/vote/parsers/votes.ts @@ -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 { + 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 { + 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, + })); +} diff --git a/apps/web/lib/api/vote/types.ts b/apps/web/lib/api/vote/types.ts new file mode 100644 index 000000000..a3fd9ae67 --- /dev/null +++ b/apps/web/lib/api/vote/types.ts @@ -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; + +export type VotingGroup = Party | "noParty" | "total"; + +export type VotingDict = Record; + +export type VotingResult = { + yes: Party[]; + no: Party[]; + 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; +} diff --git a/apps/web/lib/api/vote/utilts/create-references.ts b/apps/web/lib/api/vote/utilts/create-references.ts new file mode 100644 index 000000000..701e665e0 --- /dev/null +++ b/apps/web/lib/api/vote/utilts/create-references.ts @@ -0,0 +1,68 @@ +import type { DocumentReference } from "@lib/api/parliament/types"; +import type { ProcessedDocument } from "../types"; + +interface ReferencesResponse { + processedDocuments: ProcessedDocument[]; + propositionText: string; +} + +export default function createReferences( + unparsedProposition: string, + references: DocumentReference[], +): ReferencesResponse { + /* Remove newlines */ + let proposition = unparsedProposition.replace(/(
)|/gm, " "); + /* Regex to find references in suggestion text */ + /* Matches for example: 2019/20:3635 */ + const referenceRegex = /[0-9]{4}\/[0-9]{2}:[A-ö]{0,4}[0-9]{0,4}/gm; + + const referencedDocuments: Array = []; + + let match; + // eslint-disable-next-line no-cond-assign + while ((match = referenceRegex.exec(proposition))) { + if (!referencedDocuments.includes(match[0])) { + referencedDocuments.push(match[0]); + } + } + + const processedDocuments: Array = []; + + for (let i = 0; i < referencedDocuments.length; i += 1) { + const sectionStart = proposition.indexOf(referencedDocuments[i]); + const sectionEnd = + i < referencedDocuments.length - 1 + ? proposition.indexOf(referencedDocuments[i + 1]) + : proposition.length; + + const id = + references.find( + (reference) => + `${reference.ref_dok_rm}:${reference.ref_dok_bet}` === + referencedDocuments[i], + )?.ref_dok_id ?? ""; + + const section = proposition.slice(sectionStart, sectionEnd); + + if (section.includes(")")) { + /* Replace EX: "2019/20:3642 av Helena Lindahl m.fl. (C)"" */ + const endIndex = section.indexOf(")") + 1; + const label = section.substring(0, endIndex); + processedDocuments.push({ id, label }); + + proposition = proposition.split(label).join(`[${i}]`); + } else { + /* Just replace the ID, EX: "2019/20:3642" */ + processedDocuments.push({ + id, + label: referencedDocuments[i], + }); + proposition = proposition.split(referencedDocuments[i]).join(`[${i}]`); + } + } + + return { + processedDocuments, + propositionText: proposition, + }; +} diff --git a/apps/web/lib/api/vote/utilts/extract-votes.ts b/apps/web/lib/api/vote/utilts/extract-votes.ts new file mode 100644 index 000000000..23124a457 --- /dev/null +++ b/apps/web/lib/api/vote/utilts/extract-votes.ts @@ -0,0 +1,90 @@ +import type { NewVotingRow, OldVotingRow } from "@lib/api/parliament/types"; +import type { VotingDict, VotingEntry, VotingGroup } from "../types"; +import { Party } from "@partiguiden/party-data/types"; + +function votingGroupRemap(partyName: string): VotingGroup { + switch (partyName) { + case "fp": + return Party.L; + case "-": + return "noParty"; + case "Totalt": + return "total"; + default: + return partyName.toUpperCase() as Party; + } +} + +const votingGroup = [...Object.values(Party), "noParty", "total"] as const; + +const defaultVotingEntry: VotingEntry = { + yes: 0, + no: 0, + absent: 0, + refrain: 0, +} as const; + +const defaultVotes: VotingDict = votingGroup.reduce( + (prev, curr) => ({ ...prev, [curr]: defaultVotingEntry }), + {} as VotingDict, +); + +function extractVotesNew(row: NewVotingRow[]): VotingDict { + const voting = {} as VotingDict; + + const total: VotingEntry = { + yes: 0, + no: 0, + absent: 0, + refrain: 0, + }; + + row.forEach(({ th, td }) => { + const votingGroupName = votingGroupRemap(th); + const partyVotes = { + yes: +td[0], + no: +td[1], + refrain: +td[2], + absent: +td[3], + }; + total["yes"] = total["yes"] + partyVotes["yes"]; + total["no"] = total["no"] + partyVotes["no"]; + total["refrain"] = total["refrain"] + partyVotes["refrain"]; + total["absent"] = total["absent"] + partyVotes["absent"]; + voting[votingGroupName] = partyVotes; + }); + voting["total"] = total; + return voting; +} + +export default function extractVotes( + row: NewVotingRow[] | OldVotingRow | undefined, +): VotingDict { + if (!row) { + return defaultVotes; + } + // New only contains `td` + if (row.every((col) => Object.hasOwn(col, "td"))) { + return extractVotesNew(row); + } + const voting = {} as VotingDict; + const [, , ...entries] = row; + + entries.forEach((entry) => { + const { td } = entry; + + if (Array.isArray(td)) { + const votingGroupName = votingGroupRemap(td[0]); + + const partyVotes = { + yes: +td[1], + no: +td[2], + refrain: +td[3], + absent: +td[4], + }; + voting[votingGroupName] = partyVotes; + } + }); + + return voting; +} diff --git a/apps/web/lib/api/vote/utilts/get-max-vote.ts b/apps/web/lib/api/vote/utilts/get-max-vote.ts new file mode 100644 index 000000000..88332eb99 --- /dev/null +++ b/apps/web/lib/api/vote/utilts/get-max-vote.ts @@ -0,0 +1,36 @@ +import { Party } from "@partiguiden/party-data/types"; +import type { VoteDescription, VotingDict, VotingResult } from "../types"; + +const decisions: VoteDescription[] = ["yes", "no", "refrain"]; + +export const getMaxVote = (votes: VotingDict): VotingResult => { + const result: VotingResult = { yes: [], no: [], winner: "draw" }; + + // Want to isolate so just the parties are in the parties constant + + const yesTotal = votes.total.yes; + const noTotal = votes.total.no; + + /* Get the winner */ + if (yesTotal !== noTotal) { + result.winner = yesTotal > noTotal ? "yes" : "no"; + } + + /* Decide on what parties voted for */ + for (const party of Object.values(Party)) { + if (!(party in votes)) { + continue; + } + + const partyVotes = votes[party]; + const decision = decisions.reduce((a, b) => + partyVotes[a] > partyVotes[b] ? a : b, + ); + + if (decision === "yes" || decision === "no") { + result[decision].push(party); + } + } + + return result; +}; diff --git a/apps/web/lib/api/vote/utilts/title-trim.ts b/apps/web/lib/api/vote/utilts/title-trim.ts new file mode 100644 index 000000000..2abac73c1 --- /dev/null +++ b/apps/web/lib/api/vote/utilts/title-trim.ts @@ -0,0 +1,3 @@ +export default function titleTrim(title: string) { + return title.split(/([0-9]{4}\/[0-9]{2}:[A-ö]{0,4}[0-9]{0,4})/)[2].trim(); +} diff --git a/apps/web/lib/navigation.tsx b/apps/web/lib/navigation.tsx index 2c89364f3..5ef2c7fa2 100644 --- a/apps/web/lib/navigation.tsx +++ b/apps/web/lib/navigation.tsx @@ -18,8 +18,10 @@ export const routes = { cookiePolicy: "/cookie-policy", aboutUs: "/om-oss", polls: "/polls", - votes: "/voteringar", - vote: "/vote/[id]/[bet]", + votes: "/votering", + vote(id: string, bet: number) { + return `/votering/${id}/${bet}`; + }, decisions: "/decisions", standpoints: "/standpunkter", standpoint(id: string) { diff --git a/apps/web/lib/utils/search-params.ts b/apps/web/lib/utils/search-params.ts new file mode 100644 index 000000000..06f5cf6e9 --- /dev/null +++ b/apps/web/lib/utils/search-params.ts @@ -0,0 +1,36 @@ +export function parseNumberSearchParam( + param?: string | string[], +): number | undefined { + if (!param) { + return undefined; + } + const parsed = parseInt(param.toString()); + if (Number.isNaN(parsed)) { + return undefined; + } + return parsed; +} + +export function parseStringSearchParam( + param?: string | string[], +): string | undefined { + if (!param) { + return undefined; + } + if (Array.isArray(param)) { + return param[0]; + } + return param; +} + +export function parseStringArraySearchParam( + param?: string | string[], +): string[] { + if (!param) { + return []; + } + if (Array.isArray(param)) { + return param; + } + return [param]; +} diff --git a/apps/web/src/api/helpers/voteUtils.ts b/apps/web/src/api/helpers/voteUtils.ts index 506fc73e0..c3ef4e151 100644 --- a/apps/web/src/api/helpers/voteUtils.ts +++ b/apps/web/src/api/helpers/voteUtils.ts @@ -131,7 +131,6 @@ export const extractVotesNew = (row: NewVotingRow[]): VotingDict => { voting[votingGroupName] = partyVotes; }); voting["total"] = total; - console.log(voting["total"]); return voting; };