239 lines
6.2 KiB
TypeScript
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>
|
|
)
|
|
}
|