diff --git a/src/client/features/legislation/components/legislation-detail.tsx b/src/client/features/legislation/components/legislation-detail.tsx index 1bb9841..5c50a35 100644 --- a/src/client/features/legislation/components/legislation-detail.tsx +++ b/src/client/features/legislation/components/legislation-detail.tsx @@ -1,6 +1,9 @@ -import { useState } from "react" +import { useDeviceId } from "@/shared/hooks/use-device-id" +import { useEffect, useState } from "react" import { useLegislation } from "../hooks/use-legislation" import { useUserVote } from "../hooks/use-user-vote" +import { fetchLegislationResults } from "../lib/legislation-api" +import { VoteResult } from "./vote-result" import { VoteWidget } from "./vote-widget" interface LegislationDetailProps { @@ -11,6 +14,23 @@ export function LegislationDetail({ legislationId }: LegislationDetailProps) { const { legislation, loading, error } = useLegislation(legislationId) const { vote, castVote } = useUserVote(legislationId) const [showFullText, setShowFullText] = useState(false) + const deviceId = useDeviceId() + const [results, setResults] = useState<{ + pollId: number + votes: { + politicianId: number + name: string + fraction: string | null + vote: string + }[] + } | null>(null) + + useEffect(() => { + if (!deviceId) return + fetchLegislationResults(legislationId, deviceId) + .then(setResults) + .catch(() => null) + }, [legislationId, deviceId]) if (loading) { return
Laden…
@@ -84,6 +104,13 @@ export function LegislationDetail({ legislationId }: LegislationDetailProps) {

)} + + {results && vote && ( +
+

Ergebnis

+ +
+ )} ) } diff --git a/src/client/features/legislation/components/vote-result.tsx b/src/client/features/legislation/components/vote-result.tsx new file mode 100644 index 0000000..4681c11 --- /dev/null +++ b/src/client/features/legislation/components/vote-result.tsx @@ -0,0 +1,85 @@ +import type { UserVoteChoice } from "../../../../shared/legislation-types" + +interface PoliticianVote { + name: string + fraction: string | null + vote: string +} + +interface VoteResultProps { + userVote: UserVoteChoice + politicianVotes: PoliticianVote[] +} + +const VOTE_COLORS: Record = { + yes: "bg-green-100 text-green-800", + ja: "bg-green-100 text-green-800", + no: "bg-red-100 text-red-800", + nein: "bg-red-100 text-red-800", + abstain: "bg-gray-100 text-gray-600", + enthaltung: "bg-gray-100 text-gray-600", + no_show: "bg-gray-50 text-gray-400", +} + +const VOTE_LABELS: Record = { + yes: "Ja", + no: "Nein", + abstain: "Enthaltung", + no_show: "Nicht abgestimmt", + ja: "Ja", + nein: "Nein", + enthaltung: "Enthaltung", +} + +export function VoteResult({ userVote, politicianVotes }: VoteResultProps) { + const userLabel = VOTE_LABELS[userVote] ?? userVote + + return ( +
+
+

Dein Vote

+

{userLabel}

+
+ + {politicianVotes.length > 0 && ( +
+

+ Deine Abgeordneten +

+ {politicianVotes.map((pv) => { + const color = VOTE_COLORS[pv.vote] ?? "bg-gray-100 text-gray-600" + const label = VOTE_LABELS[pv.vote] ?? pv.vote + const matches = + (pv.vote === "yes" && userVote === "ja") || + (pv.vote === "no" && userVote === "nein") || + (pv.vote === "abstain" && userVote === "enthaltung") + + return ( +
+
+

{pv.name}

+ {pv.fraction && ( +

+ {pv.fraction} +

+ )} +
+
+ + {label} + + {matches && ( + = Dein Vote + )} +
+
+ ) + })} +
+ )} +
+ ) +} diff --git a/src/client/features/legislation/lib/legislation-api.ts b/src/client/features/legislation/lib/legislation-api.ts index 0d0795a..d4b4fb7 100644 --- a/src/client/features/legislation/lib/legislation-api.ts +++ b/src/client/features/legislation/lib/legislation-api.ts @@ -43,3 +43,27 @@ export async function fetchUserVote( if (!res.ok) throw new Error("Failed to fetch vote") return res.json() } + +interface PoliticianVoteResult { + politicianId: number + name: string + fraction: string | null + vote: string +} + +interface LegislationResults { + pollId: number + votes: PoliticianVoteResult[] +} + +export async function fetchLegislationResults( + id: number, + deviceId: string, +): Promise { + const res = await fetch( + `${BACKEND_URL}/legislation/${id}/results/${deviceId}`, + ) + if (res.status === 404) return null + if (!res.ok) return null + return res.json() +} diff --git a/src/server/features/legislation/router.ts b/src/server/features/legislation/router.ts index 11743d7..5177c70 100644 --- a/src/server/features/legislation/router.ts +++ b/src/server/features/legislation/router.ts @@ -3,6 +3,7 @@ import { castVoteSchema } from "./schema" import { castVote, getLegislation, + getLegislationResults, getLegislationText, getUpcomingLegislation, getUserVote, @@ -48,6 +49,17 @@ legislationRouter.post("/:id/vote", async (c) => { return c.json({ ok: true }, 201) }) +legislationRouter.get("/:id/results/:deviceId", async (c) => { + const id = Number(c.req.param("id")) + const deviceId = c.req.param("deviceId") + if (Number.isNaN(id)) return c.json({ error: "invalid id" }, 400) + + const results = await getLegislationResults(id, deviceId) + if (!results) return c.json({ error: "no results yet" }, 404) + + return c.json(results) +}) + legislationRouter.get("/:id/vote/:deviceId", async (c) => { const id = Number(c.req.param("id")) const deviceId = c.req.param("deviceId") diff --git a/src/server/features/legislation/service.ts b/src/server/features/legislation/service.ts index 9798f59..6a353f8 100644 --- a/src/server/features/legislation/service.ts +++ b/src/server/features/legislation/service.ts @@ -5,6 +5,8 @@ import { legislationTexts, userVotes, } from "../../shared/db/schema/legislation" +import { deviceFollows, politicianMandates } from "../../shared/db/schema/push" +import { fetchVotesByPoll } from "../../shared/lib/aw-api" import type { CastVoteRequest } from "./schema" export async function getUpcomingLegislation() { @@ -105,3 +107,59 @@ export async function getUserVote(legislationId: number, deviceId: string) { votedAt: rows[0].votedAt.toISOString(), } } + +export async function getLegislationResults( + legislationId: number, + deviceId: string, +) { + const rows = await db + .select() + .from(legislationTexts) + .where(eq(legislationTexts.id, legislationId)) + .limit(1) + + if (rows.length === 0 || !rows[0].pollId) return null + + const pollId = rows[0].pollId + + // get this device's followed politicians + const politicianFollows = await db + .select({ entityId: deviceFollows.entityId }) + .from(deviceFollows) + .where( + and( + eq(deviceFollows.deviceId, deviceId), + eq(deviceFollows.type, "politician"), + ), + ) + + if (politicianFollows.length === 0) return { pollId, votes: [] } + + const allVotes = await fetchVotesByPoll(pollId) + const votesByMandate = new Map(allVotes.map((v) => [v.mandate.id, v])) + + // resolve each followed politician → mandate → vote + const results = [] + for (const pf of politicianFollows) { + const cached = await db + .select() + .from(politicianMandates) + .where(eq(politicianMandates.politicianId, pf.entityId)) + .limit(1) + + const mandateId = cached[0]?.mandateId + if (!mandateId) continue + + const vote = votesByMandate.get(mandateId) + if (!vote) continue + + results.push({ + politicianId: pf.entityId, + name: vote.mandate.label, + fraction: vote.fraction?.label ?? null, + vote: vote.vote, + }) + } + + return { pollId, votes: results } +}