From 378459930a5929dd3b14e72756830daa50f8ea8d Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Felix=20F=C3=B6rtsch?=
Date: Tue, 10 Mar 2026 17:08:54 +0100
Subject: [PATCH] add vote result comparison view, wire into legislation detail
page
Co-Authored-By: Claude Sonnet 4.6
---
.../components/legislation-detail.tsx | 29 ++++++-
.../legislation/components/vote-result.tsx | 85 +++++++++++++++++++
.../legislation/lib/legislation-api.ts | 24 ++++++
src/server/features/legislation/router.ts | 12 +++
src/server/features/legislation/service.ts | 58 +++++++++++++
5 files changed, 207 insertions(+), 1 deletion(-)
create mode 100644 src/client/features/legislation/components/vote-result.tsx
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 }
+}