add vote result comparison view, wire into legislation detail page

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-10 17:08:54 +01:00
parent 2a81893683
commit 378459930a
5 changed files with 207 additions and 1 deletions

View File

@@ -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 <div className="p-4 text-center text-muted-foreground">Laden</div>
@@ -84,6 +104,13 @@ export function LegislationDetail({ legislationId }: LegislationDetailProps) {
</p>
)}
</div>
{results && vote && (
<div className="space-y-3">
<h2 className="text-sm font-medium">Ergebnis</h2>
<VoteResult userVote={vote} politicianVotes={results.votes} />
</div>
)}
</div>
)
}

View File

@@ -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<string, string> = {
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<string, string> = {
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 (
<div className="space-y-4">
<div className="p-3 rounded-lg bg-blue-50 border border-blue-200">
<p className="text-xs text-blue-600 font-medium">Dein Vote</p>
<p className="text-sm font-semibold mt-1">{userLabel}</p>
</div>
{politicianVotes.length > 0 && (
<div className="space-y-2">
<h3 className="text-xs font-medium text-muted-foreground">
Deine Abgeordneten
</h3>
{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 (
<div
key={pv.name}
className="flex items-center justify-between p-2 rounded-lg border"
>
<div>
<p className="text-sm font-medium">{pv.name}</p>
{pv.fraction && (
<p className="text-xs text-muted-foreground">
{pv.fraction}
</p>
)}
</div>
<div className="flex items-center gap-2">
<span className={`text-xs px-2 py-1 rounded-full ${color}`}>
{label}
</span>
{matches && (
<span className="text-xs text-green-600">= Dein Vote</span>
)}
</div>
</div>
)
})}
</div>
)}
</div>
)
}

View File

@@ -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<LegislationResults | null> {
const res = await fetch(
`${BACKEND_URL}/legislation/${id}/results/${deviceId}`,
)
if (res.status === 404) return null
if (!res.ok) return null
return res.json()
}

View File

@@ -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")

View File

@@ -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 }
}