diff --git a/package.json b/package.json index 5ce9e5b..33e7cc7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "abgeordnetenwatch-pwa", - "version": "2026.03.10.1", + "version": "2026.03.10.2", "type": "module", "scripts": { "dev": "vite", diff --git a/src/client/features/representatives/components/representatives-page.tsx b/src/client/features/representatives/components/representatives-page.tsx new file mode 100644 index 0000000..47ef5bc --- /dev/null +++ b/src/client/features/representatives/components/representatives-page.tsx @@ -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, + 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 ( + + ) +} + +function PoliticianCard({ rep }: { rep: FollowedMandate }) { + return ( + + +
+

{rep.label}

+

+ {rep.party} + {rep.function ? ` · ${rep.function}` : ""} +

+
+ + + ) +} + +function Section({ + title, + subtitle, + reps, + configureLink, + emptyText, +}: { + title: string + subtitle?: string | null + reps: FollowedMandate[] + configureLink: string + emptyText: string +}) { + return ( +
+
+
+

+ {title} +

+ {subtitle && ( +

{subtitle}

+ )} +
+ + Bearbeiten + +
+
+ {reps.length > 0 ? ( +
+ {reps.map((rep) => ( + + ))} +
+ ) : ( +
+

{emptyText}

+ + Abgeordnete hinzufügen → + +
+ )} +
+
+ ) +} + +export function RepresentativesPage() { + const db = useDb() + const { follows } = useFollows() + const [bundestagMandates, setBundestagMandates] = useState< + MandateWithPolitician[] + >([]) + const [geoResult, setGeoResult] = useState(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
Laden…
+ } + + 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 ( +
+
+ +
+ + {unresolved.length > 0 && ( +
+

+ Weitere +

+
+ {unresolved.map((f) => ( + + + {f.label} + + + + ))} +
+
+ )} +
+ ) +} diff --git a/src/client/features/representatives/index.ts b/src/client/features/representatives/index.ts new file mode 100644 index 0000000..aaea6c4 --- /dev/null +++ b/src/client/features/representatives/index.ts @@ -0,0 +1 @@ +export { RepresentativesPage } from "./components/representatives-page" diff --git a/src/client/routeTree.gen.ts b/src/client/routeTree.gen.ts index 21d8efc..f1286c1 100644 --- a/src/client/routeTree.gen.ts +++ b/src/client/routeTree.gen.ts @@ -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, } diff --git a/src/client/routes/app/representatives.tsx b/src/client/routes/app/representatives.tsx new file mode 100644 index 0000000..eb09115 --- /dev/null +++ b/src/client/routes/app/representatives.tsx @@ -0,0 +1,6 @@ +import { RepresentativesPage } from "@/features/representatives" +import { createFileRoute } from "@tanstack/react-router" + +export const Route = createFileRoute("/app/representatives")({ + component: RepresentativesPage, +}) diff --git a/src/client/routes/app/route.tsx b/src/client/routes/app/route.tsx index 3694f7c..49bbe3a 100644 --- a/src/client/routes/app/route.tsx +++ b/src/client/routes/app/route.tsx @@ -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", diff --git a/src/client/shared/lib/constants.ts b/src/client/shared/lib/constants.ts index 99a69d2..f1615a4 100644 --- a/src/client/shared/lib/constants.ts +++ b/src/client/shared/lib/constants.ts @@ -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