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:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
85
src/client/features/legislation/components/vote-result.tsx
Normal file
85
src/client/features/legislation/components/vote-result.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user