add landtag feed with AW API polls, extend FeedItem for session/decision kinds

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-03 14:20:41 +01:00
parent dd2ffa1c71
commit 9008c57caa
14 changed files with 559 additions and 26 deletions

View File

@@ -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<typeof pollSchema>
export type VoteDetail = z.infer<typeof voteDetailSchema>
export type MandateDetail = z.infer<typeof mandateDetailSchema>
// --- Fetch helper ---
async function request<T>(path: string, params: Record<string, string>, schema: z.ZodType<T>): Promise<T[]> {
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<Poll[]> {
const params: Record<string, string> = {
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<VoteDetail[]> {
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<MandateDetail[]> {
return request(
"candidacies-mandates",
{
politician: String(politicianId),
type: "mandate",
range_end: "10",
},
mandateDetailSchema,
)
}
export function fetchVotesByMandate(mandateId: number): Promise<VoteDetail[]> {
return request(
"votes",
{
mandate: String(mandateId),
range_end: "200",
},
voteDetailSchema,
)
}
export async function fetchPollById(pollId: number): Promise<Poll | null> {
const results = await request("polls", { id: String(pollId), range_end: "1" }, pollSchema)
return results[0] ?? null
}

View File

@@ -54,6 +54,22 @@ export function FeedItemCard({ item }: { item: FeedItemType }) {
{item.status === "upcoming" ? "In Beratung" : "Abgeschlossen"}
</Badge>
)}
{item.kind === "session" && (
<Badge variant="outline" className="text-[10px] px-1.5 py-0">
Plenarsitzung
</Badge>
)}
{item.kind === "decision" && item.result && (
<Badge variant="outline" className="text-[10px] px-1.5 py-0">
{item.result === "accepted"
? "Beschlossen"
: item.result === "rejected"
? "Abgelehnt"
: item.result === "conducted"
? "Durchgeführt"
: "Überwiesen"}
</Badge>
)}
</div>
</article>
)

View File

@@ -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" {

View File

@@ -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 (
<div className="text-center mt-12 px-4">
<p className="text-lg font-medium">Kein Bundesland erkannt</p>
<p className="text-sm text-muted-foreground mt-2">
Erkenne zuerst deinen Standort, um Landtag-Abstimmungen zu sehen.
</p>
<Link to="/app/settings" className="text-primary text-sm underline mt-4 inline-block">
Zu den Einstellungen
</Link>
</div>
)
}
return (
<div className="pb-4">
{lastUpdated && (
<div className="flex items-center justify-between px-4 py-2 border-b border-border">
<span className="text-xs text-muted-foreground">Aktualisiert {formatCacheAge(lastUpdated)}</span>
<button
type="button"
onClick={() => refresh({ silent: true })}
disabled={refreshing}
className="p-1.5 rounded-md hover:bg-muted transition-colors disabled:opacity-50"
aria-label="Feed aktualisieren"
>
<svg
xmlns="http://www.w3.org/2000/svg"
className={`w-5 h-5 text-muted-foreground ${refreshing ? "animate-spin" : ""}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
</svg>
</button>
</div>
)}
{loading && !hasItems && (
<output className="flex items-center justify-center h-48" aria-label="Feed wird geladen">
<div className="w-8 h-8 border-4 border-primary border-t-transparent rounded-full animate-spin" />
</output>
)}
{error && (
<div className="p-4 text-destructive" role="alert">
<p className="font-semibold">Fehler beim Laden</p>
<p className="text-sm mt-1">{error}</p>
</div>
)}
{hasItems && <FeedList items={items} />}
{!hasItems && !loading && !error && (
<div className="text-center mt-12 px-4">
<p className="text-lg font-medium">Dein Landtag-Feed ist leer</p>
<p className="text-sm text-muted-foreground mt-2">
Für deinen Landtag liegen noch keine namentlichen Abstimmungen vor.
</p>
<Link to="/app/landtag/configure" className="text-primary text-sm underline mt-4 inline-block">
Landtag konfigurieren
</Link>
</div>
)}
</div>
)
}

View File

@@ -1,8 +1,5 @@
import { LandtagFeed } from "./landtag-feed"
export function LandtagPage() {
return (
<div className="text-center mt-12 px-4">
<p className="text-lg font-medium">Landtag</p>
<p className="text-sm text-muted-foreground mt-2">Abstimmungsdaten folgen in Kürze.</p>
</div>
)
return <LandtagFeed />
}

View File

@@ -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<FeedItem[]>([])
const [loading, setLoading] = useState(false)
const [refreshing, setRefreshing] = useState(false)
const [error, setError] = useState<string | null>(null)
const [lastUpdated, setLastUpdated] = useState<number | null>(null)
const [legislatureId, setLegislatureId] = useState<number | null>(null)
const politicianIDs = useMemo(() => follows.filter((f) => f.type === "politician").map((f) => f.entity_id), [follows])
const refreshingRef = useRef(false)
const lastUpdatedRef = useRef<number | null>(null)
const hasItemsRef = useRef(false)
const legislatureIdRef = useRef<number | null>(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<typeof setInterval> | 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 }
}

View File

@@ -1,2 +1,3 @@
export { LandtagPage } from "./components/landtag-page"
export { LandtagFeed } from "./components/landtag-feed"
export { LandtagConfigure } from "./components/landtag-configure"

View File

@@ -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<FeedItem[]> {
const [legislaturePolls, politicianPolls] = await Promise.all([
fetchPolls(100, legislatureId),
fetchPollsForPoliticians(followedPoliticianIDs),
])
const combined = new Map<number, Poll>()
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<Poll[]> {
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<number>()
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))
}

View File

@@ -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(

View File

@@ -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 (
<div className="px-4 py-12 text-center">
<p className="text-sm text-muted-foreground">Ungültige Abgeordneten-ID</p>
</div>
)
}
return <PoliticianDetail politicianId={id} />
}
export const Route = createFileRoute("/app/politician/$politicianId")({
component: PoliticianPage,
})

View File

@@ -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 (
<div className="flex flex-col h-dvh max-w-lg mx-auto">
<header className="flex items-center px-4 py-3 bg-card border-b border-border shadow-sm safe-area-top">
{isConfigureRoute && parentPath ? (
{(isConfigureRoute && parentPath) || isPoliticianRoute ? (
<button
type="button"
onClick={() => navigate({ to: parentPath })}
onClick={() => (isPoliticianRoute ? window.history.back() : navigate({ to: parentPath ?? "" }))}
className="flex items-center gap-1 text-primary"
aria-label="Zurück"
>
@@ -101,8 +102,10 @@ function AppLayout() {
<span className="text-sm">Zurück</span>
</button>
) : null}
<h1 className={`text-base font-semibold text-card-foreground ${isConfigureRoute ? "ml-2" : ""}`}>
{isConfigureRoute ? "Abgeordnete" : currentTab.label}
<h1
className={`text-base font-semibold text-card-foreground ${isConfigureRoute || isPoliticianRoute ? "ml-2" : ""}`}
>
{isPoliticianRoute ? "Abgeordnete/r" : isConfigureRoute ? "Abgeordnete" : currentTab.label}
</h1>
{isConfigureRoute && (
<button

View File

@@ -1,6 +1,7 @@
import { getPartyMeta } from "@/features/location/lib/parties"
import { useFollows } from "@/shared/hooks/use-follows"
import type { MandateWithPolitician } from "@/shared/lib/aw-api"
import { Link } from "@tanstack/react-router"
import { useState } from "react"
import { Button } from "./ui/button"
import { Card, CardContent } from "./ui/card"
@@ -185,16 +186,20 @@ export function RepresentativeList({
const fn = mandateFunction(m)
const local = userCity ? isLocalConstituency(m, userCity) : false
return (
<div key={m.id} className="flex items-center justify-between px-4 py-2.5">
<div>
<p className="text-sm font-medium">{m.politician.label}</p>
<div key={m.id} className="flex items-center px-4 py-2.5 gap-2">
<Link
to="/app/politician/$politicianId"
params={{ politicianId: String(m.politician.id) }}
className="flex-1 min-w-0 no-underline"
>
<p className="text-sm font-medium text-foreground">{m.politician.label}</p>
{fn && (
<p className="text-xs text-muted-foreground">
{fn}
{local && <span className="ml-1.5 text-primary font-medium"> in Deiner Nähe</span>}
</p>
)}
</div>
</Link>
<Button
size="sm"
variant={followed ? "default" : "outline"}
@@ -205,9 +210,18 @@ export function RepresentativeList({
}
aria-pressed={followed}
aria-label={followed ? `${m.politician.label} entfolgen` : `${m.politician.label} folgen`}
className="shrink-0"
>
{followed ? "Folgst du" : "Folgen"}
</Button>
<Link
to="/app/politician/$politicianId"
params={{ politicianId: String(m.politician.id) }}
className="shrink-0 text-muted-foreground"
aria-label={`${m.politician.label} Details`}
>
<ChevronRight />
</Link>
</div>
)
})}

View File

@@ -131,16 +131,14 @@ export function searchPoliticians(query: string): Promise<Politician[]> {
return request("politicians", { "label[cn]": query, range_end: "50" }, politicianSchema)
}
export function fetchPolls(rangeEnd = 100): Promise<Poll[]> {
return request(
"polls",
{
range_end: String(rangeEnd),
sort_by: "field_poll_date",
sort_direction: "desc",
},
pollSchema,
)
export function fetchPolls(rangeEnd = 100, legislatureId?: number): Promise<Poll[]> {
const params: Record<string, string> = {
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<Poll[]> {

View File

@@ -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