add Abgeordnete tab with federal/state representative sections, bump to 2026.03.10.2

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-10 17:58:18 +01:00
parent cd2d51ecbe
commit e6a51c7999
7 changed files with 295 additions and 2 deletions

View File

@@ -1,6 +1,6 @@
{
"name": "abgeordnetenwatch-pwa",
"version": "2026.03.10.1",
"version": "2026.03.10.2",
"type": "module",
"scripts": {
"dev": "vite",

View File

@@ -0,0 +1,238 @@
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>
)
}

View File

@@ -0,0 +1 @@
export { RepresentativesPage } from "./components/representatives-page"

View File

@@ -13,12 +13,14 @@ import { Route as AppRouteRouteImport } from './routes/app/route'
import { Route as IndexRouteImport } from './routes/index'
import { Route as AppTopicsRouteImport } from './routes/app/topics'
import { Route as AppSettingsRouteImport } from './routes/app/settings'
import { Route as AppRepresentativesRouteImport } from './routes/app/representatives'
import { Route as AppHomeRouteImport } from './routes/app/home'
import { Route as AppLandtagRouteRouteImport } from './routes/app/landtag/route'
import { Route as AppBundestagRouteRouteImport } from './routes/app/bundestag/route'
import { Route as AppLandtagIndexRouteImport } from './routes/app/landtag/index'
import { Route as AppBundestagIndexRouteImport } from './routes/app/bundestag/index'
import { Route as AppPoliticianPoliticianIdRouteImport } from './routes/app/politician.$politicianId'
import { Route as AppLegislationLegislationIdRouteImport } from './routes/app/legislation.$legislationId'
import { Route as AppLandtagConfigureRouteImport } from './routes/app/landtag/configure'
import { Route as AppBundestagConfigureRouteImport } from './routes/app/bundestag/configure'
@@ -42,6 +44,11 @@ const AppSettingsRoute = AppSettingsRouteImport.update({
path: '/settings',
getParentRoute: () => AppRouteRoute,
} as any)
const AppRepresentativesRoute = AppRepresentativesRouteImport.update({
id: '/representatives',
path: '/representatives',
getParentRoute: () => AppRouteRoute,
} as any)
const AppHomeRoute = AppHomeRouteImport.update({
id: '/home',
path: '/home',
@@ -73,6 +80,12 @@ const AppPoliticianPoliticianIdRoute =
path: '/politician/$politicianId',
getParentRoute: () => AppRouteRoute,
} as any)
const AppLegislationLegislationIdRoute =
AppLegislationLegislationIdRouteImport.update({
id: '/legislation/$legislationId',
path: '/legislation/$legislationId',
getParentRoute: () => AppRouteRoute,
} as any)
const AppLandtagConfigureRoute = AppLandtagConfigureRouteImport.update({
id: '/configure',
path: '/configure',
@@ -90,10 +103,12 @@ export interface FileRoutesByFullPath {
'/app/bundestag': typeof AppBundestagRouteRouteWithChildren
'/app/landtag': typeof AppLandtagRouteRouteWithChildren
'/app/home': typeof AppHomeRoute
'/app/representatives': typeof AppRepresentativesRoute
'/app/settings': typeof AppSettingsRoute
'/app/topics': typeof AppTopicsRoute
'/app/bundestag/configure': typeof AppBundestagConfigureRoute
'/app/landtag/configure': typeof AppLandtagConfigureRoute
'/app/legislation/$legislationId': typeof AppLegislationLegislationIdRoute
'/app/politician/$politicianId': typeof AppPoliticianPoliticianIdRoute
'/app/bundestag/': typeof AppBundestagIndexRoute
'/app/landtag/': typeof AppLandtagIndexRoute
@@ -102,10 +117,12 @@ export interface FileRoutesByTo {
'/': typeof IndexRoute
'/app': typeof AppRouteRouteWithChildren
'/app/home': typeof AppHomeRoute
'/app/representatives': typeof AppRepresentativesRoute
'/app/settings': typeof AppSettingsRoute
'/app/topics': typeof AppTopicsRoute
'/app/bundestag/configure': typeof AppBundestagConfigureRoute
'/app/landtag/configure': typeof AppLandtagConfigureRoute
'/app/legislation/$legislationId': typeof AppLegislationLegislationIdRoute
'/app/politician/$politicianId': typeof AppPoliticianPoliticianIdRoute
'/app/bundestag': typeof AppBundestagIndexRoute
'/app/landtag': typeof AppLandtagIndexRoute
@@ -117,10 +134,12 @@ export interface FileRoutesById {
'/app/bundestag': typeof AppBundestagRouteRouteWithChildren
'/app/landtag': typeof AppLandtagRouteRouteWithChildren
'/app/home': typeof AppHomeRoute
'/app/representatives': typeof AppRepresentativesRoute
'/app/settings': typeof AppSettingsRoute
'/app/topics': typeof AppTopicsRoute
'/app/bundestag/configure': typeof AppBundestagConfigureRoute
'/app/landtag/configure': typeof AppLandtagConfigureRoute
'/app/legislation/$legislationId': typeof AppLegislationLegislationIdRoute
'/app/politician/$politicianId': typeof AppPoliticianPoliticianIdRoute
'/app/bundestag/': typeof AppBundestagIndexRoute
'/app/landtag/': typeof AppLandtagIndexRoute
@@ -133,10 +152,12 @@ export interface FileRouteTypes {
| '/app/bundestag'
| '/app/landtag'
| '/app/home'
| '/app/representatives'
| '/app/settings'
| '/app/topics'
| '/app/bundestag/configure'
| '/app/landtag/configure'
| '/app/legislation/$legislationId'
| '/app/politician/$politicianId'
| '/app/bundestag/'
| '/app/landtag/'
@@ -145,10 +166,12 @@ export interface FileRouteTypes {
| '/'
| '/app'
| '/app/home'
| '/app/representatives'
| '/app/settings'
| '/app/topics'
| '/app/bundestag/configure'
| '/app/landtag/configure'
| '/app/legislation/$legislationId'
| '/app/politician/$politicianId'
| '/app/bundestag'
| '/app/landtag'
@@ -159,10 +182,12 @@ export interface FileRouteTypes {
| '/app/bundestag'
| '/app/landtag'
| '/app/home'
| '/app/representatives'
| '/app/settings'
| '/app/topics'
| '/app/bundestag/configure'
| '/app/landtag/configure'
| '/app/legislation/$legislationId'
| '/app/politician/$politicianId'
| '/app/bundestag/'
| '/app/landtag/'
@@ -203,6 +228,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AppSettingsRouteImport
parentRoute: typeof AppRouteRoute
}
'/app/representatives': {
id: '/app/representatives'
path: '/representatives'
fullPath: '/app/representatives'
preLoaderRoute: typeof AppRepresentativesRouteImport
parentRoute: typeof AppRouteRoute
}
'/app/home': {
id: '/app/home'
path: '/home'
@@ -245,6 +277,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AppPoliticianPoliticianIdRouteImport
parentRoute: typeof AppRouteRoute
}
'/app/legislation/$legislationId': {
id: '/app/legislation/$legislationId'
path: '/legislation/$legislationId'
fullPath: '/app/legislation/$legislationId'
preLoaderRoute: typeof AppLegislationLegislationIdRouteImport
parentRoute: typeof AppRouteRoute
}
'/app/landtag/configure': {
id: '/app/landtag/configure'
path: '/configure'
@@ -293,8 +332,10 @@ interface AppRouteRouteChildren {
AppBundestagRouteRoute: typeof AppBundestagRouteRouteWithChildren
AppLandtagRouteRoute: typeof AppLandtagRouteRouteWithChildren
AppHomeRoute: typeof AppHomeRoute
AppRepresentativesRoute: typeof AppRepresentativesRoute
AppSettingsRoute: typeof AppSettingsRoute
AppTopicsRoute: typeof AppTopicsRoute
AppLegislationLegislationIdRoute: typeof AppLegislationLegislationIdRoute
AppPoliticianPoliticianIdRoute: typeof AppPoliticianPoliticianIdRoute
}
@@ -302,8 +343,10 @@ const AppRouteRouteChildren: AppRouteRouteChildren = {
AppBundestagRouteRoute: AppBundestagRouteRouteWithChildren,
AppLandtagRouteRoute: AppLandtagRouteRouteWithChildren,
AppHomeRoute: AppHomeRoute,
AppRepresentativesRoute: AppRepresentativesRoute,
AppSettingsRoute: AppSettingsRoute,
AppTopicsRoute: AppTopicsRoute,
AppLegislationLegislationIdRoute: AppLegislationLegislationIdRoute,
AppPoliticianPoliticianIdRoute: AppPoliticianPoliticianIdRoute,
}

View File

@@ -0,0 +1,6 @@
import { RepresentativesPage } from "@/features/representatives"
import { createFileRoute } from "@tanstack/react-router"
export const Route = createFileRoute("/app/representatives")({
component: RepresentativesPage,
})

View File

@@ -32,6 +32,11 @@ const TABS: TabDef[] = [
label: "Landtag",
icon: "M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z",
},
{
to: "/app/representatives",
label: "Abgeordnete",
icon: "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z",
},
{
to: "/app/topics",
label: "Themen",

View File

@@ -1,4 +1,4 @@
export const APP_VERSION = "2026.03.10.1"
export const APP_VERSION = "2026.03.10.2"
export const AW_API_BASE = "https://www.abgeordnetenwatch.de/api/v2"
export const AW_API_TIMEOUT_MS = 20_000