Files
agw/src/client/features/representatives/components/representatives-page.tsx

239 lines
6.2 KiB
TypeScript

import {
type GeoResult,
fetchAndCacheBundestagMandates,
loadCachedResult,
} from "@/features/location/lib/geo"
import { getPartyMeta } from "@/features/location/lib/parties"
import { mandateFunction } from "@/shared/components/representative-list"
import { useDb } from "@/shared/db/provider"
import { useFollows } from "@/shared/hooks/use-follows"
import type { MandateWithPolitician } from "@/shared/lib/aw-api"
import { Link } from "@tanstack/react-router"
import { useEffect, useState } from "react"
interface FollowedMandate {
politicianId: number
label: string
party: string
partyColor: string
partyShort: string
function: string | null
fraction: string | null
}
function resolveFollowed(
followedIds: Set<number>,
mandates: MandateWithPolitician[],
): FollowedMandate[] {
const result: FollowedMandate[] = []
for (const m of mandates) {
if (!followedIds.has(m.politician.id)) continue
const partyLabel = m.party?.label ?? "parteilos"
const meta = getPartyMeta(partyLabel)
const currentFraction = m.fraction_membership?.find((f) => !f.valid_until)
result.push({
politicianId: m.politician.id,
label: m.politician.label,
party: partyLabel,
partyColor: meta.color,
partyShort: meta.short,
function: mandateFunction(m),
fraction: currentFraction?.fraction.label ?? null,
})
}
return result.sort((a, b) => a.label.localeCompare(b.label))
}
function ChevronRight() {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
className="w-4 h-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
aria-hidden="true"
>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
)
}
function PoliticianCard({ rep }: { rep: FollowedMandate }) {
return (
<Link
to="/app/politician/$politicianId"
params={{ politicianId: String(rep.politicianId) }}
className="flex items-center gap-3 px-4 py-3 hover:bg-muted/50 transition-colors no-underline"
>
<span
className="inline-flex items-center justify-center w-8 h-8 rounded-full text-[10px] font-bold text-white shrink-0"
style={{ backgroundColor: rep.partyColor }}
aria-hidden="true"
>
{rep.partyShort.slice(0, 3)}
</span>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-foreground">{rep.label}</p>
<p className="text-xs text-muted-foreground truncate">
{rep.party}
{rep.function ? ` · ${rep.function}` : ""}
</p>
</div>
<ChevronRight />
</Link>
)
}
function Section({
title,
subtitle,
reps,
configureLink,
emptyText,
}: {
title: string
subtitle?: string | null
reps: FollowedMandate[]
configureLink: string
emptyText: string
}) {
return (
<section>
<div className="flex items-baseline justify-between px-4 mb-2">
<div>
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">
{title}
</h2>
{subtitle && (
<p className="text-xs text-muted-foreground">{subtitle}</p>
)}
</div>
<Link to={configureLink} className="text-xs text-primary no-underline">
Bearbeiten
</Link>
</div>
<div className="rounded-lg border bg-card overflow-hidden">
{reps.length > 0 ? (
<div className="divide-y divide-border">
{reps.map((rep) => (
<PoliticianCard key={rep.politicianId} rep={rep} />
))}
</div>
) : (
<div className="px-4 py-6 text-center">
<p className="text-sm text-muted-foreground">{emptyText}</p>
<Link
to={configureLink}
className="text-sm text-primary mt-2 inline-block no-underline"
>
Abgeordnete hinzufügen
</Link>
</div>
)}
</div>
</section>
)
}
export function RepresentativesPage() {
const db = useDb()
const { follows } = useFollows()
const [bundestagMandates, setBundestagMandates] = useState<
MandateWithPolitician[]
>([])
const [geoResult, setGeoResult] = useState<GeoResult | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
let cancelled = false
async function load() {
const [bt, geo] = await Promise.all([
fetchAndCacheBundestagMandates(db),
loadCachedResult(db),
])
if (cancelled) return
setBundestagMandates(bt)
setGeoResult(geo)
setLoading(false)
}
load()
return () => {
cancelled = true
}
}, [db])
if (loading) {
return <div className="p-4 text-center text-muted-foreground">Laden</div>
}
const followedPoliticianIds = new Set(
follows.filter((f) => f.type === "politician").map((f) => f.entity_id),
)
const bundestagReps = resolveFollowed(
followedPoliticianIds,
bundestagMandates,
)
const landtagMandates = geoResult?.mandates ?? []
const landtagReps = resolveFollowed(followedPoliticianIds, landtagMandates)
// politicians followed but not found in either mandate list
const resolvedIds = new Set([
...bundestagReps.map((r) => r.politicianId),
...landtagReps.map((r) => r.politicianId),
])
const unresolved = follows.filter(
(f) => f.type === "politician" && !resolvedIds.has(f.entity_id),
)
return (
<div className="px-4 py-4 space-y-6">
<Section
title="Bund"
subtitle="Bundestag"
reps={bundestagReps}
configureLink="/app/bundestag/configure"
emptyText="Du folgst noch keinen Bundestagsabgeordneten."
/>
<Section
title="Land"
subtitle={geoResult?.landtag_label ?? "Landtag"}
reps={landtagReps}
configureLink="/app/landtag/configure"
emptyText={
geoResult
? "Du folgst noch keinen Landtagsabgeordneten."
: "Aktiviere den Standort in den Einstellungen, um deinen Landtag zu sehen."
}
/>
{unresolved.length > 0 && (
<section>
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide px-4 mb-2">
Weitere
</h2>
<div className="rounded-lg border bg-card overflow-hidden divide-y divide-border">
{unresolved.map((f) => (
<Link
key={f.entity_id}
to="/app/politician/$politicianId"
params={{ politicianId: String(f.entity_id) }}
className="flex items-center justify-between px-4 py-3 hover:bg-muted/50 transition-colors no-underline"
>
<span className="text-sm font-medium text-foreground">
{f.label}
</span>
<ChevronRight />
</Link>
))}
</div>
</section>
)}
</div>
)
}