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 { useLegislation } from "../hooks/use-legislation"
|
||||||
import { useUserVote } from "../hooks/use-user-vote"
|
import { useUserVote } from "../hooks/use-user-vote"
|
||||||
|
import { fetchLegislationResults } from "../lib/legislation-api"
|
||||||
|
import { VoteResult } from "./vote-result"
|
||||||
import { VoteWidget } from "./vote-widget"
|
import { VoteWidget } from "./vote-widget"
|
||||||
|
|
||||||
interface LegislationDetailProps {
|
interface LegislationDetailProps {
|
||||||
@@ -11,6 +14,23 @@ export function LegislationDetail({ legislationId }: LegislationDetailProps) {
|
|||||||
const { legislation, loading, error } = useLegislation(legislationId)
|
const { legislation, loading, error } = useLegislation(legislationId)
|
||||||
const { vote, castVote } = useUserVote(legislationId)
|
const { vote, castVote } = useUserVote(legislationId)
|
||||||
const [showFullText, setShowFullText] = useState(false)
|
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) {
|
if (loading) {
|
||||||
return <div className="p-4 text-center text-muted-foreground">Laden…</div>
|
return <div className="p-4 text-center text-muted-foreground">Laden…</div>
|
||||||
@@ -84,6 +104,13 @@ export function LegislationDetail({ legislationId }: LegislationDetailProps) {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{results && vote && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h2 className="text-sm font-medium">Ergebnis</h2>
|
||||||
|
<VoteResult userVote={vote} politicianVotes={results.votes} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</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")
|
if (!res.ok) throw new Error("Failed to fetch vote")
|
||||||
return res.json()
|
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 {
|
import {
|
||||||
castVote,
|
castVote,
|
||||||
getLegislation,
|
getLegislation,
|
||||||
|
getLegislationResults,
|
||||||
getLegislationText,
|
getLegislationText,
|
||||||
getUpcomingLegislation,
|
getUpcomingLegislation,
|
||||||
getUserVote,
|
getUserVote,
|
||||||
@@ -48,6 +49,17 @@ legislationRouter.post("/:id/vote", async (c) => {
|
|||||||
return c.json({ ok: true }, 201)
|
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) => {
|
legislationRouter.get("/:id/vote/:deviceId", async (c) => {
|
||||||
const id = Number(c.req.param("id"))
|
const id = Number(c.req.param("id"))
|
||||||
const deviceId = c.req.param("deviceId")
|
const deviceId = c.req.param("deviceId")
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import {
|
|||||||
legislationTexts,
|
legislationTexts,
|
||||||
userVotes,
|
userVotes,
|
||||||
} from "../../shared/db/schema/legislation"
|
} 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"
|
import type { CastVoteRequest } from "./schema"
|
||||||
|
|
||||||
export async function getUpcomingLegislation() {
|
export async function getUpcomingLegislation() {
|
||||||
@@ -105,3 +107,59 @@ export async function getUserVote(legislationId: number, deviceId: string) {
|
|||||||
votedAt: rows[0].votedAt.toISOString(),
|
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