diff --git a/server/src/shared/lib/aw-api.ts b/server/src/shared/lib/aw-api.ts new file mode 100644 index 0000000..45417e7 --- /dev/null +++ b/server/src/shared/lib/aw-api.ts @@ -0,0 +1,173 @@ +import { z } from "zod" + +const AW_API_BASE = "https://www.abgeordnetenwatch.de/api/v2" +const AW_API_TIMEOUT_MS = 30_000 + +// --- Zod Schemas --- + +const pollTopicSchema = z.object({ + id: z.number(), + label: z.string().optional(), + abgeordnetenwatch_url: z.string().optional(), +}) + +export const pollSchema = z.object({ + id: z.number(), + label: z.string(), + abgeordnetenwatch_url: z.string().optional(), + field_poll_date: z.string().nullable(), + field_topics: z.array(pollTopicSchema), +}) + +export const voteDetailSchema = z.object({ + id: z.number(), + vote: z.string(), + mandate: z.object({ + id: z.number(), + label: z.string(), + }), + fraction: z + .object({ + id: z.number(), + label: z.string(), + }) + .nullable() + .optional(), + poll: z + .object({ + id: z.number(), + label: z.string(), + abgeordnetenwatch_url: z.string().optional(), + }) + .nullable() + .optional(), +}) + +export const candidacyMandateSchema = z.object({ + id: z.number(), +}) + +export const mandateDetailSchema = z.object({ + id: z.number(), + politician: z.object({ + id: z.number(), + label: z.string(), + }), + party: z + .object({ + id: z.number(), + label: z.string(), + }) + .nullable() + .optional(), + fraction_membership: z + .array( + z.object({ + fraction: z.object({ label: z.string() }), + valid_until: z.string().nullable().optional(), + }), + ) + .optional(), + electoral_data: z + .object({ + constituency: z.object({ label: z.string() }).nullable().optional(), + mandate_won: z.string().nullable().optional(), + }) + .nullable() + .optional(), +}) + +// --- Types --- + +export type Poll = z.infer +export type VoteDetail = z.infer +export type MandateDetail = z.infer + +// --- Fetch helper --- + +async function request(path: string, params: Record, schema: z.ZodType): Promise { + const url = new URL(`${AW_API_BASE}/${path}`) + for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v) + + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), AW_API_TIMEOUT_MS) + let res: Response + try { + res = await fetch(url.toString(), { + signal: controller.signal, + headers: { Accept: "application/json" }, + }) + } finally { + clearTimeout(timer) + } + + if (!res.ok) { + const body = await res.text().catch(() => "") + throw new Error(`AW API ${res.status} for ${url}: ${body}`) + } + + const json = (await res.json()) as { data: unknown[] } + return z.array(schema).parse(json.data) +} + +// --- Public API --- + +export function fetchRecentPolls(count = 50, legislatureId?: number): Promise { + const params: Record = { + range_end: String(count), + sort_by: "field_poll_date", + sort_direction: "desc", + } + if (legislatureId != null) params.field_legislature = String(legislatureId) + return request("polls", params, pollSchema) +} + +export function fetchVotesByPoll(pollId: number): Promise { + return request( + "votes", + { + poll: String(pollId), + range_end: "800", + }, + voteDetailSchema, + ) +} + +export function fetchCandidacyMandates(politicianId: number): Promise<{ id: number }[]> { + return request( + "candidacies-mandates", + { + politician: String(politicianId), + range_end: "200", + }, + candidacyMandateSchema, + ) +} + +export function fetchMandatesForPolitician(politicianId: number): Promise { + return request( + "candidacies-mandates", + { + politician: String(politicianId), + type: "mandate", + range_end: "10", + }, + mandateDetailSchema, + ) +} + +export function fetchVotesByMandate(mandateId: number): Promise { + return request( + "votes", + { + mandate: String(mandateId), + range_end: "200", + }, + voteDetailSchema, + ) +} + +export async function fetchPollById(pollId: number): Promise { + const results = await request("polls", { id: String(pollId), range_end: "1" }, pollSchema) + return results[0] ?? null +} diff --git a/src/features/feed/components/feed-item.tsx b/src/features/feed/components/feed-item.tsx index 9564599..e48395f 100644 --- a/src/features/feed/components/feed-item.tsx +++ b/src/features/feed/components/feed-item.tsx @@ -54,6 +54,22 @@ export function FeedItemCard({ item }: { item: FeedItemType }) { {item.status === "upcoming" ? "In Beratung" : "Abgeschlossen"} )} + {item.kind === "session" && ( + + Plenarsitzung + + )} + {item.kind === "decision" && item.result && ( + + {item.result === "accepted" + ? "Beschlossen" + : item.result === "rejected" + ? "Abgelehnt" + : item.result === "conducted" + ? "Durchgeführt" + : "Überwiesen"} + + )} ) diff --git a/src/features/feed/lib/assemble-feed.ts b/src/features/feed/lib/assemble-feed.ts index b3aa9ed..89d2f7f 100644 --- a/src/features/feed/lib/assemble-feed.ts +++ b/src/features/feed/lib/assemble-feed.ts @@ -14,13 +14,14 @@ export interface FeedTopicRef { export interface FeedItem { id: string - kind: "poll" | "vorgang" + kind: "poll" | "vorgang" | "session" | "decision" status: "upcoming" | "past" title: string url: string | null date: string | null topics: FeedTopicRef[] source: string + result?: "accepted" | "rejected" | "conducted" | "referred" | null } function classifyPoll(poll: Poll): "upcoming" | "past" { diff --git a/src/features/landtag/components/landtag-feed.tsx b/src/features/landtag/components/landtag-feed.tsx new file mode 100644 index 0000000..4d583fc --- /dev/null +++ b/src/features/landtag/components/landtag-feed.tsx @@ -0,0 +1,92 @@ +import { FeedList } from "@/features/feed/components/feed-list" +import { Link } from "@tanstack/react-router" +import { useLandtagFeed } from "../hooks/use-landtag-feed" + +function formatCacheAge(timestamp: number): string { + const minutes = Math.floor((Date.now() - timestamp) / 60_000) + if (minutes < 1) return "gerade eben" + if (minutes < 60) return `vor ${minutes} Min.` + const hours = Math.floor(minutes / 60) + if (hours < 24) return `vor ${hours} Std.` + const days = Math.floor(hours / 24) + return `vor ${days} T.` +} + +export function LandtagFeed() { + const { items, loading, refreshing, error, lastUpdated, legislatureId, refresh } = useLandtagFeed() + const hasItems = items.length > 0 + + if (!legislatureId && !loading) { + return ( +
+

Kein Bundesland erkannt

+

+ Erkenne zuerst deinen Standort, um Landtag-Abstimmungen zu sehen. +

+ + Zu den Einstellungen + +
+ ) + } + + return ( +
+ {lastUpdated && ( +
+ Aktualisiert {formatCacheAge(lastUpdated)} + +
+ )} + + {loading && !hasItems && ( + +
+ + )} + + {error && ( +
+

Fehler beim Laden

+

{error}

+
+ )} + + {hasItems && } + + {!hasItems && !loading && !error && ( +
+

Dein Landtag-Feed ist leer

+

+ Für deinen Landtag liegen noch keine namentlichen Abstimmungen vor. +

+ + Landtag konfigurieren + +
+ )} +
+ ) +} diff --git a/src/features/landtag/components/landtag-page.tsx b/src/features/landtag/components/landtag-page.tsx index 3cb622e..bbcf129 100644 --- a/src/features/landtag/components/landtag-page.tsx +++ b/src/features/landtag/components/landtag-page.tsx @@ -1,8 +1,5 @@ +import { LandtagFeed } from "./landtag-feed" + export function LandtagPage() { - return ( -
-

Landtag

-

Abstimmungsdaten folgen in Kürze.

-
- ) + return } diff --git a/src/features/landtag/hooks/use-landtag-feed.ts b/src/features/landtag/hooks/use-landtag-feed.ts new file mode 100644 index 0000000..ea892af --- /dev/null +++ b/src/features/landtag/hooks/use-landtag-feed.ts @@ -0,0 +1,138 @@ +import type { FeedItem } from "@/features/feed/lib/assemble-feed" +import { loadFeedCache, mergeFeedItems, saveFeedCache } from "@/features/feed/lib/feed-cache" +import { loadCachedResult } from "@/features/location/lib/geo" +import { useDb } from "@/shared/db/provider" +import { useFollows } from "@/shared/hooks/use-follows" +import { useCallback, useEffect, useMemo, useRef, useState } from "react" +import { assembleLandtagFeed } from "../lib/assemble-landtag-feed" + +const CACHE_KEY = "landtag_feed" +const REFRESH_INTERVAL_MS = 60 * 60 * 1000 + +export function useLandtagFeed() { + const db = useDb() + const { follows } = useFollows() + + const [items, setItems] = useState([]) + const [loading, setLoading] = useState(false) + const [refreshing, setRefreshing] = useState(false) + const [error, setError] = useState(null) + const [lastUpdated, setLastUpdated] = useState(null) + const [legislatureId, setLegislatureId] = useState(null) + + const politicianIDs = useMemo(() => follows.filter((f) => f.type === "politician").map((f) => f.entity_id), [follows]) + + const refreshingRef = useRef(false) + const lastUpdatedRef = useRef(null) + const hasItemsRef = useRef(false) + const legislatureIdRef = useRef(null) + + // Load geo cache to get legislatureId + useEffect(() => { + loadCachedResult(db).then((cached) => { + if (cached?.landtag_parliament_period_id) { + setLegislatureId(cached.landtag_parliament_period_id) + legislatureIdRef.current = cached.landtag_parliament_period_id + } + }) + }, [db]) + + // Load feed cache on mount + useEffect(() => { + loadFeedCache(db, CACHE_KEY).then((cached) => { + if (cached && cached.items.length > 0) { + setItems(cached.items) + hasItemsRef.current = true + setLastUpdated(cached.updatedAt) + lastUpdatedRef.current = cached.updatedAt + } + }) + }, [db]) + + const refresh = useCallback( + async (opts?: { silent?: boolean }) => { + if (refreshingRef.current) return + const lid = legislatureIdRef.current + if (!lid) return + + refreshingRef.current = true + const silent = opts?.silent ?? false + if (silent) { + setRefreshing(true) + } else { + setLoading(true) + } + setError(null) + + try { + const fresh = await assembleLandtagFeed(lid, politicianIDs) + setItems((prev) => { + const merged = mergeFeedItems(prev, fresh) + saveFeedCache(db, merged, CACHE_KEY) + hasItemsRef.current = merged.length > 0 + return merged + }) + const now = Date.now() + setLastUpdated(now) + lastUpdatedRef.current = now + } catch (e) { + setError(String(e)) + } finally { + setLoading(false) + setRefreshing(false) + refreshingRef.current = false + } + }, + [db, politicianIDs], + ) + + // Trigger refresh when legislatureId becomes available or follows change + useEffect(() => { + if (!legislatureId) return + if (hasItemsRef.current) { + refresh({ silent: true }) + } else { + refresh() + } + }, [legislatureId, refresh]) + + // Auto-refresh interval + visibility API + useEffect(() => { + let intervalId: ReturnType | null = null + + function startInterval() { + if (intervalId) return + intervalId = setInterval(() => { + refresh({ silent: true }) + }, REFRESH_INTERVAL_MS) + } + + function stopInterval() { + if (intervalId) { + clearInterval(intervalId) + intervalId = null + } + } + + function handleVisibility() { + if (document.hidden) { + stopInterval() + } else { + if (!lastUpdatedRef.current || Date.now() - lastUpdatedRef.current >= REFRESH_INTERVAL_MS) { + refresh({ silent: true }) + } + startInterval() + } + } + + startInterval() + document.addEventListener("visibilitychange", handleVisibility) + + return () => { + stopInterval() + document.removeEventListener("visibilitychange", handleVisibility) + } + }, [refresh]) + + return { items, loading, refreshing, error, lastUpdated, legislatureId, refresh } +} diff --git a/src/features/landtag/index.ts b/src/features/landtag/index.ts index 4bbbaf0..66b7514 100644 --- a/src/features/landtag/index.ts +++ b/src/features/landtag/index.ts @@ -1,2 +1,3 @@ export { LandtagPage } from "./components/landtag-page" +export { LandtagFeed } from "./components/landtag-feed" export { LandtagConfigure } from "./components/landtag-configure" diff --git a/src/features/landtag/lib/assemble-landtag-feed.ts b/src/features/landtag/lib/assemble-landtag-feed.ts new file mode 100644 index 0000000..cc3c1dd --- /dev/null +++ b/src/features/landtag/lib/assemble-landtag-feed.ts @@ -0,0 +1,57 @@ +import type { FeedItem } from "@/features/feed/lib/assemble-feed" +import { type Poll, fetchCandidacyMandates, fetchPolls, fetchPollsByIds, fetchVotes } from "@/shared/lib/aw-api" + +export async function assembleLandtagFeed(legislatureId: number, followedPoliticianIDs: number[]): Promise { + const [legislaturePolls, politicianPolls] = await Promise.all([ + fetchPolls(100, legislatureId), + fetchPollsForPoliticians(followedPoliticianIDs), + ]) + + const combined = new Map() + for (const p of [...legislaturePolls, ...politicianPolls]) combined.set(p.id, p) + + const items: FeedItem[] = Array.from(combined.values()).map((poll) => ({ + id: `lt-poll-${poll.id}`, + kind: "poll", + status: classifyPoll(poll), + title: poll.label, + url: poll.abgeordnetenwatch_url ?? null, + date: poll.field_poll_date, + topics: poll.field_topics.map((t) => ({ + label: t.label ?? "", + url: t.abgeordnetenwatch_url ?? null, + })), + source: "Landtag", + })) + + return items.sort((a, b) => { + if (a.date && b.date) return b.date.localeCompare(a.date) + if (!a.date && b.date) return 1 + if (a.date && !b.date) return -1 + return a.title.localeCompare(b.title) + }) +} + +function classifyPoll(poll: Poll): "upcoming" | "past" { + if (poll.field_accepted != null) return "past" + if (!poll.field_poll_date) return "upcoming" + const today = new Date().toISOString().slice(0, 10) + return poll.field_poll_date > today ? "upcoming" : "past" +} + +async function fetchPollsForPoliticians(politicianIDs: number[]): Promise { + if (politicianIDs.length === 0) return [] + + const mandateResults = await Promise.all(politicianIDs.map((pid) => fetchCandidacyMandates(pid))) + const mandateIDs = mandateResults.flatMap((mandates) => mandates.slice(0, 3).map((m) => m.id)) + + const voteResults = await Promise.all(mandateIDs.map((mid) => fetchVotes(mid))) + const pollIDSet = new Set() + for (const votes of voteResults) { + for (const v of votes) { + if (v.poll?.id != null) pollIDSet.add(v.poll.id) + } + } + + return fetchPollsByIds(Array.from(pollIDSet)) +} diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index 99cf653..21d8efc 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -18,6 +18,7 @@ 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 AppLandtagConfigureRouteImport } from './routes/app/landtag/configure' import { Route as AppBundestagConfigureRouteImport } from './routes/app/bundestag/configure' @@ -66,6 +67,12 @@ const AppBundestagIndexRoute = AppBundestagIndexRouteImport.update({ path: '/', getParentRoute: () => AppBundestagRouteRoute, } as any) +const AppPoliticianPoliticianIdRoute = + AppPoliticianPoliticianIdRouteImport.update({ + id: '/politician/$politicianId', + path: '/politician/$politicianId', + getParentRoute: () => AppRouteRoute, + } as any) const AppLandtagConfigureRoute = AppLandtagConfigureRouteImport.update({ id: '/configure', path: '/configure', @@ -87,6 +94,7 @@ export interface FileRoutesByFullPath { '/app/topics': typeof AppTopicsRoute '/app/bundestag/configure': typeof AppBundestagConfigureRoute '/app/landtag/configure': typeof AppLandtagConfigureRoute + '/app/politician/$politicianId': typeof AppPoliticianPoliticianIdRoute '/app/bundestag/': typeof AppBundestagIndexRoute '/app/landtag/': typeof AppLandtagIndexRoute } @@ -98,6 +106,7 @@ export interface FileRoutesByTo { '/app/topics': typeof AppTopicsRoute '/app/bundestag/configure': typeof AppBundestagConfigureRoute '/app/landtag/configure': typeof AppLandtagConfigureRoute + '/app/politician/$politicianId': typeof AppPoliticianPoliticianIdRoute '/app/bundestag': typeof AppBundestagIndexRoute '/app/landtag': typeof AppLandtagIndexRoute } @@ -112,6 +121,7 @@ export interface FileRoutesById { '/app/topics': typeof AppTopicsRoute '/app/bundestag/configure': typeof AppBundestagConfigureRoute '/app/landtag/configure': typeof AppLandtagConfigureRoute + '/app/politician/$politicianId': typeof AppPoliticianPoliticianIdRoute '/app/bundestag/': typeof AppBundestagIndexRoute '/app/landtag/': typeof AppLandtagIndexRoute } @@ -127,6 +137,7 @@ export interface FileRouteTypes { | '/app/topics' | '/app/bundestag/configure' | '/app/landtag/configure' + | '/app/politician/$politicianId' | '/app/bundestag/' | '/app/landtag/' fileRoutesByTo: FileRoutesByTo @@ -138,6 +149,7 @@ export interface FileRouteTypes { | '/app/topics' | '/app/bundestag/configure' | '/app/landtag/configure' + | '/app/politician/$politicianId' | '/app/bundestag' | '/app/landtag' id: @@ -151,6 +163,7 @@ export interface FileRouteTypes { | '/app/topics' | '/app/bundestag/configure' | '/app/landtag/configure' + | '/app/politician/$politicianId' | '/app/bundestag/' | '/app/landtag/' fileRoutesById: FileRoutesById @@ -225,6 +238,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AppBundestagIndexRouteImport parentRoute: typeof AppBundestagRouteRoute } + '/app/politician/$politicianId': { + id: '/app/politician/$politicianId' + path: '/politician/$politicianId' + fullPath: '/app/politician/$politicianId' + preLoaderRoute: typeof AppPoliticianPoliticianIdRouteImport + parentRoute: typeof AppRouteRoute + } '/app/landtag/configure': { id: '/app/landtag/configure' path: '/configure' @@ -275,6 +295,7 @@ interface AppRouteRouteChildren { AppHomeRoute: typeof AppHomeRoute AppSettingsRoute: typeof AppSettingsRoute AppTopicsRoute: typeof AppTopicsRoute + AppPoliticianPoliticianIdRoute: typeof AppPoliticianPoliticianIdRoute } const AppRouteRouteChildren: AppRouteRouteChildren = { @@ -283,6 +304,7 @@ const AppRouteRouteChildren: AppRouteRouteChildren = { AppHomeRoute: AppHomeRoute, AppSettingsRoute: AppSettingsRoute, AppTopicsRoute: AppTopicsRoute, + AppPoliticianPoliticianIdRoute: AppPoliticianPoliticianIdRoute, } const AppRouteRouteWithChildren = AppRouteRoute._addFileChildren( diff --git a/src/routes/app/politician.$politicianId.tsx b/src/routes/app/politician.$politicianId.tsx new file mode 100644 index 0000000..2aea076 --- /dev/null +++ b/src/routes/app/politician.$politicianId.tsx @@ -0,0 +1,21 @@ +import { PoliticianDetail } from "@/features/politician" +import { createFileRoute } from "@tanstack/react-router" + +function PoliticianPage() { + const { politicianId } = Route.useParams() + const id = Number(politicianId) + + if (!Number.isFinite(id) || id <= 0) { + return ( +
+

Ungültige Abgeordneten-ID

+
+ ) + } + + return +} + +export const Route = createFileRoute("/app/politician/$politicianId")({ + component: PoliticianPage, +}) diff --git a/src/routes/app/route.tsx b/src/routes/app/route.tsx index 97d7f77..dbaf87a 100644 --- a/src/routes/app/route.tsx +++ b/src/routes/app/route.tsx @@ -60,6 +60,7 @@ function AppLayout() { const currentPath = matches.at(-1)?.pathname ?? "/app/home" const currentTab = TABS.find((t) => currentPath.startsWith(t.to)) ?? TABS[0] const isConfigureRoute = currentPath.endsWith("/configure") + const isPoliticianRoute = currentPath.startsWith("/app/politician/") const isBundestag = currentPath.startsWith("/app/bundestag") const isLandtag = currentPath.startsWith("/app/landtag") @@ -80,10 +81,10 @@ function AppLayout() { return (
- {isConfigureRoute && parentPath ? ( + {(isConfigureRoute && parentPath) || isPoliticianRoute ? ( ) : null} -

- {isConfigureRoute ? "Abgeordnete" : currentTab.label} +

+ {isPoliticianRoute ? "Abgeordnete/r" : isConfigureRoute ? "Abgeordnete" : currentTab.label}

{isConfigureRoute && ( + + +
) })} diff --git a/src/shared/lib/aw-api.ts b/src/shared/lib/aw-api.ts index d468c52..fa7b99d 100644 --- a/src/shared/lib/aw-api.ts +++ b/src/shared/lib/aw-api.ts @@ -131,16 +131,14 @@ export function searchPoliticians(query: string): Promise { return request("politicians", { "label[cn]": query, range_end: "50" }, politicianSchema) } -export function fetchPolls(rangeEnd = 100): Promise { - return request( - "polls", - { - range_end: String(rangeEnd), - sort_by: "field_poll_date", - sort_direction: "desc", - }, - pollSchema, - ) +export function fetchPolls(rangeEnd = 100, legislatureId?: number): Promise { + const params: Record = { + range_end: String(rangeEnd), + sort_by: "field_poll_date", + sort_direction: "desc", + } + if (legislatureId != null) params.field_legislature = String(legislatureId) + return request("polls", params, pollSchema) } export async function fetchPollsByIds(ids: number[]): Promise { diff --git a/src/shared/lib/constants.ts b/src/shared/lib/constants.ts index 28ceb83..dad6e06 100644 --- a/src/shared/lib/constants.ts +++ b/src/shared/lib/constants.ts @@ -1,4 +1,4 @@ -export const APP_VERSION = "2026.03.02.6" +export const APP_VERSION = "2026.03.03" export const AW_API_BASE = "https://www.abgeordnetenwatch.de/api/v2" export const AW_API_TIMEOUT_MS = 20_000