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:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "abgeordnetenwatch-pwa",
|
||||
"version": "2026.03.10.1",
|
||||
"version": "2026.03.10.2",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
1
src/client/features/representatives/index.ts
Normal file
1
src/client/features/representatives/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { RepresentativesPage } from "./components/representatives-page"
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
6
src/client/routes/app/representatives.tsx
Normal file
6
src/client/routes/app/representatives.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
import { RepresentativesPage } from "@/features/representatives"
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
|
||||
export const Route = createFileRoute("/app/representatives")({
|
||||
component: RepresentativesPage,
|
||||
})
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user