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:
173
server/src/shared/lib/aw-api.ts
Normal file
173
server/src/shared/lib/aw-api.ts
Normal 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
|
||||||
|
}
|
||||||
@@ -54,6 +54,22 @@ export function FeedItemCard({ item }: { item: FeedItemType }) {
|
|||||||
{item.status === "upcoming" ? "In Beratung" : "Abgeschlossen"}
|
{item.status === "upcoming" ? "In Beratung" : "Abgeschlossen"}
|
||||||
</Badge>
|
</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>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -14,13 +14,14 @@ export interface FeedTopicRef {
|
|||||||
|
|
||||||
export interface FeedItem {
|
export interface FeedItem {
|
||||||
id: string
|
id: string
|
||||||
kind: "poll" | "vorgang"
|
kind: "poll" | "vorgang" | "session" | "decision"
|
||||||
status: "upcoming" | "past"
|
status: "upcoming" | "past"
|
||||||
title: string
|
title: string
|
||||||
url: string | null
|
url: string | null
|
||||||
date: string | null
|
date: string | null
|
||||||
topics: FeedTopicRef[]
|
topics: FeedTopicRef[]
|
||||||
source: string
|
source: string
|
||||||
|
result?: "accepted" | "rejected" | "conducted" | "referred" | null
|
||||||
}
|
}
|
||||||
|
|
||||||
function classifyPoll(poll: Poll): "upcoming" | "past" {
|
function classifyPoll(poll: Poll): "upcoming" | "past" {
|
||||||
|
|||||||
92
src/features/landtag/components/landtag-feed.tsx
Normal file
92
src/features/landtag/components/landtag-feed.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,8 +1,5 @@
|
|||||||
|
import { LandtagFeed } from "./landtag-feed"
|
||||||
|
|
||||||
export function LandtagPage() {
|
export function LandtagPage() {
|
||||||
return (
|
return <LandtagFeed />
|
||||||
<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>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
138
src/features/landtag/hooks/use-landtag-feed.ts
Normal file
138
src/features/landtag/hooks/use-landtag-feed.ts
Normal 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 }
|
||||||
|
}
|
||||||
@@ -1,2 +1,3 @@
|
|||||||
export { LandtagPage } from "./components/landtag-page"
|
export { LandtagPage } from "./components/landtag-page"
|
||||||
|
export { LandtagFeed } from "./components/landtag-feed"
|
||||||
export { LandtagConfigure } from "./components/landtag-configure"
|
export { LandtagConfigure } from "./components/landtag-configure"
|
||||||
|
|||||||
57
src/features/landtag/lib/assemble-landtag-feed.ts
Normal file
57
src/features/landtag/lib/assemble-landtag-feed.ts
Normal 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))
|
||||||
|
}
|
||||||
@@ -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 AppBundestagRouteRouteImport } from './routes/app/bundestag/route'
|
||||||
import { Route as AppLandtagIndexRouteImport } from './routes/app/landtag/index'
|
import { Route as AppLandtagIndexRouteImport } from './routes/app/landtag/index'
|
||||||
import { Route as AppBundestagIndexRouteImport } from './routes/app/bundestag/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 AppLandtagConfigureRouteImport } from './routes/app/landtag/configure'
|
||||||
import { Route as AppBundestagConfigureRouteImport } from './routes/app/bundestag/configure'
|
import { Route as AppBundestagConfigureRouteImport } from './routes/app/bundestag/configure'
|
||||||
|
|
||||||
@@ -66,6 +67,12 @@ const AppBundestagIndexRoute = AppBundestagIndexRouteImport.update({
|
|||||||
path: '/',
|
path: '/',
|
||||||
getParentRoute: () => AppBundestagRouteRoute,
|
getParentRoute: () => AppBundestagRouteRoute,
|
||||||
} as any)
|
} as any)
|
||||||
|
const AppPoliticianPoliticianIdRoute =
|
||||||
|
AppPoliticianPoliticianIdRouteImport.update({
|
||||||
|
id: '/politician/$politicianId',
|
||||||
|
path: '/politician/$politicianId',
|
||||||
|
getParentRoute: () => AppRouteRoute,
|
||||||
|
} as any)
|
||||||
const AppLandtagConfigureRoute = AppLandtagConfigureRouteImport.update({
|
const AppLandtagConfigureRoute = AppLandtagConfigureRouteImport.update({
|
||||||
id: '/configure',
|
id: '/configure',
|
||||||
path: '/configure',
|
path: '/configure',
|
||||||
@@ -87,6 +94,7 @@ export interface FileRoutesByFullPath {
|
|||||||
'/app/topics': typeof AppTopicsRoute
|
'/app/topics': typeof AppTopicsRoute
|
||||||
'/app/bundestag/configure': typeof AppBundestagConfigureRoute
|
'/app/bundestag/configure': typeof AppBundestagConfigureRoute
|
||||||
'/app/landtag/configure': typeof AppLandtagConfigureRoute
|
'/app/landtag/configure': typeof AppLandtagConfigureRoute
|
||||||
|
'/app/politician/$politicianId': typeof AppPoliticianPoliticianIdRoute
|
||||||
'/app/bundestag/': typeof AppBundestagIndexRoute
|
'/app/bundestag/': typeof AppBundestagIndexRoute
|
||||||
'/app/landtag/': typeof AppLandtagIndexRoute
|
'/app/landtag/': typeof AppLandtagIndexRoute
|
||||||
}
|
}
|
||||||
@@ -98,6 +106,7 @@ export interface FileRoutesByTo {
|
|||||||
'/app/topics': typeof AppTopicsRoute
|
'/app/topics': typeof AppTopicsRoute
|
||||||
'/app/bundestag/configure': typeof AppBundestagConfigureRoute
|
'/app/bundestag/configure': typeof AppBundestagConfigureRoute
|
||||||
'/app/landtag/configure': typeof AppLandtagConfigureRoute
|
'/app/landtag/configure': typeof AppLandtagConfigureRoute
|
||||||
|
'/app/politician/$politicianId': typeof AppPoliticianPoliticianIdRoute
|
||||||
'/app/bundestag': typeof AppBundestagIndexRoute
|
'/app/bundestag': typeof AppBundestagIndexRoute
|
||||||
'/app/landtag': typeof AppLandtagIndexRoute
|
'/app/landtag': typeof AppLandtagIndexRoute
|
||||||
}
|
}
|
||||||
@@ -112,6 +121,7 @@ export interface FileRoutesById {
|
|||||||
'/app/topics': typeof AppTopicsRoute
|
'/app/topics': typeof AppTopicsRoute
|
||||||
'/app/bundestag/configure': typeof AppBundestagConfigureRoute
|
'/app/bundestag/configure': typeof AppBundestagConfigureRoute
|
||||||
'/app/landtag/configure': typeof AppLandtagConfigureRoute
|
'/app/landtag/configure': typeof AppLandtagConfigureRoute
|
||||||
|
'/app/politician/$politicianId': typeof AppPoliticianPoliticianIdRoute
|
||||||
'/app/bundestag/': typeof AppBundestagIndexRoute
|
'/app/bundestag/': typeof AppBundestagIndexRoute
|
||||||
'/app/landtag/': typeof AppLandtagIndexRoute
|
'/app/landtag/': typeof AppLandtagIndexRoute
|
||||||
}
|
}
|
||||||
@@ -127,6 +137,7 @@ export interface FileRouteTypes {
|
|||||||
| '/app/topics'
|
| '/app/topics'
|
||||||
| '/app/bundestag/configure'
|
| '/app/bundestag/configure'
|
||||||
| '/app/landtag/configure'
|
| '/app/landtag/configure'
|
||||||
|
| '/app/politician/$politicianId'
|
||||||
| '/app/bundestag/'
|
| '/app/bundestag/'
|
||||||
| '/app/landtag/'
|
| '/app/landtag/'
|
||||||
fileRoutesByTo: FileRoutesByTo
|
fileRoutesByTo: FileRoutesByTo
|
||||||
@@ -138,6 +149,7 @@ export interface FileRouteTypes {
|
|||||||
| '/app/topics'
|
| '/app/topics'
|
||||||
| '/app/bundestag/configure'
|
| '/app/bundestag/configure'
|
||||||
| '/app/landtag/configure'
|
| '/app/landtag/configure'
|
||||||
|
| '/app/politician/$politicianId'
|
||||||
| '/app/bundestag'
|
| '/app/bundestag'
|
||||||
| '/app/landtag'
|
| '/app/landtag'
|
||||||
id:
|
id:
|
||||||
@@ -151,6 +163,7 @@ export interface FileRouteTypes {
|
|||||||
| '/app/topics'
|
| '/app/topics'
|
||||||
| '/app/bundestag/configure'
|
| '/app/bundestag/configure'
|
||||||
| '/app/landtag/configure'
|
| '/app/landtag/configure'
|
||||||
|
| '/app/politician/$politicianId'
|
||||||
| '/app/bundestag/'
|
| '/app/bundestag/'
|
||||||
| '/app/landtag/'
|
| '/app/landtag/'
|
||||||
fileRoutesById: FileRoutesById
|
fileRoutesById: FileRoutesById
|
||||||
@@ -225,6 +238,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof AppBundestagIndexRouteImport
|
preLoaderRoute: typeof AppBundestagIndexRouteImport
|
||||||
parentRoute: typeof AppBundestagRouteRoute
|
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': {
|
'/app/landtag/configure': {
|
||||||
id: '/app/landtag/configure'
|
id: '/app/landtag/configure'
|
||||||
path: '/configure'
|
path: '/configure'
|
||||||
@@ -275,6 +295,7 @@ interface AppRouteRouteChildren {
|
|||||||
AppHomeRoute: typeof AppHomeRoute
|
AppHomeRoute: typeof AppHomeRoute
|
||||||
AppSettingsRoute: typeof AppSettingsRoute
|
AppSettingsRoute: typeof AppSettingsRoute
|
||||||
AppTopicsRoute: typeof AppTopicsRoute
|
AppTopicsRoute: typeof AppTopicsRoute
|
||||||
|
AppPoliticianPoliticianIdRoute: typeof AppPoliticianPoliticianIdRoute
|
||||||
}
|
}
|
||||||
|
|
||||||
const AppRouteRouteChildren: AppRouteRouteChildren = {
|
const AppRouteRouteChildren: AppRouteRouteChildren = {
|
||||||
@@ -283,6 +304,7 @@ const AppRouteRouteChildren: AppRouteRouteChildren = {
|
|||||||
AppHomeRoute: AppHomeRoute,
|
AppHomeRoute: AppHomeRoute,
|
||||||
AppSettingsRoute: AppSettingsRoute,
|
AppSettingsRoute: AppSettingsRoute,
|
||||||
AppTopicsRoute: AppTopicsRoute,
|
AppTopicsRoute: AppTopicsRoute,
|
||||||
|
AppPoliticianPoliticianIdRoute: AppPoliticianPoliticianIdRoute,
|
||||||
}
|
}
|
||||||
|
|
||||||
const AppRouteRouteWithChildren = AppRouteRoute._addFileChildren(
|
const AppRouteRouteWithChildren = AppRouteRoute._addFileChildren(
|
||||||
|
|||||||
21
src/routes/app/politician.$politicianId.tsx
Normal file
21
src/routes/app/politician.$politicianId.tsx
Normal 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,
|
||||||
|
})
|
||||||
@@ -60,6 +60,7 @@ function AppLayout() {
|
|||||||
const currentPath = matches.at(-1)?.pathname ?? "/app/home"
|
const currentPath = matches.at(-1)?.pathname ?? "/app/home"
|
||||||
const currentTab = TABS.find((t) => currentPath.startsWith(t.to)) ?? TABS[0]
|
const currentTab = TABS.find((t) => currentPath.startsWith(t.to)) ?? TABS[0]
|
||||||
const isConfigureRoute = currentPath.endsWith("/configure")
|
const isConfigureRoute = currentPath.endsWith("/configure")
|
||||||
|
const isPoliticianRoute = currentPath.startsWith("/app/politician/")
|
||||||
const isBundestag = currentPath.startsWith("/app/bundestag")
|
const isBundestag = currentPath.startsWith("/app/bundestag")
|
||||||
const isLandtag = currentPath.startsWith("/app/landtag")
|
const isLandtag = currentPath.startsWith("/app/landtag")
|
||||||
|
|
||||||
@@ -80,10 +81,10 @@ function AppLayout() {
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-dvh max-w-lg mx-auto">
|
<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">
|
<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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => navigate({ to: parentPath })}
|
onClick={() => (isPoliticianRoute ? window.history.back() : navigate({ to: parentPath ?? "" }))}
|
||||||
className="flex items-center gap-1 text-primary"
|
className="flex items-center gap-1 text-primary"
|
||||||
aria-label="Zurück"
|
aria-label="Zurück"
|
||||||
>
|
>
|
||||||
@@ -101,8 +102,10 @@ function AppLayout() {
|
|||||||
<span className="text-sm">Zurück</span>
|
<span className="text-sm">Zurück</span>
|
||||||
</button>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
<h1 className={`text-base font-semibold text-card-foreground ${isConfigureRoute ? "ml-2" : ""}`}>
|
<h1
|
||||||
{isConfigureRoute ? "Abgeordnete" : currentTab.label}
|
className={`text-base font-semibold text-card-foreground ${isConfigureRoute || isPoliticianRoute ? "ml-2" : ""}`}
|
||||||
|
>
|
||||||
|
{isPoliticianRoute ? "Abgeordnete/r" : isConfigureRoute ? "Abgeordnete" : currentTab.label}
|
||||||
</h1>
|
</h1>
|
||||||
{isConfigureRoute && (
|
{isConfigureRoute && (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { getPartyMeta } from "@/features/location/lib/parties"
|
import { getPartyMeta } from "@/features/location/lib/parties"
|
||||||
import { useFollows } from "@/shared/hooks/use-follows"
|
import { useFollows } from "@/shared/hooks/use-follows"
|
||||||
import type { MandateWithPolitician } from "@/shared/lib/aw-api"
|
import type { MandateWithPolitician } from "@/shared/lib/aw-api"
|
||||||
|
import { Link } from "@tanstack/react-router"
|
||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
import { Button } from "./ui/button"
|
import { Button } from "./ui/button"
|
||||||
import { Card, CardContent } from "./ui/card"
|
import { Card, CardContent } from "./ui/card"
|
||||||
@@ -185,16 +186,20 @@ export function RepresentativeList({
|
|||||||
const fn = mandateFunction(m)
|
const fn = mandateFunction(m)
|
||||||
const local = userCity ? isLocalConstituency(m, userCity) : false
|
const local = userCity ? isLocalConstituency(m, userCity) : false
|
||||||
return (
|
return (
|
||||||
<div key={m.id} className="flex items-center justify-between px-4 py-2.5">
|
<div key={m.id} className="flex items-center px-4 py-2.5 gap-2">
|
||||||
<div>
|
<Link
|
||||||
<p className="text-sm font-medium">{m.politician.label}</p>
|
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 && (
|
{fn && (
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{fn}
|
{fn}
|
||||||
{local && <span className="ml-1.5 text-primary font-medium">— in Deiner Nähe</span>}
|
{local && <span className="ml-1.5 text-primary font-medium">— in Deiner Nähe</span>}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</Link>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant={followed ? "default" : "outline"}
|
variant={followed ? "default" : "outline"}
|
||||||
@@ -205,9 +210,18 @@ export function RepresentativeList({
|
|||||||
}
|
}
|
||||||
aria-pressed={followed}
|
aria-pressed={followed}
|
||||||
aria-label={followed ? `${m.politician.label} entfolgen` : `${m.politician.label} folgen`}
|
aria-label={followed ? `${m.politician.label} entfolgen` : `${m.politician.label} folgen`}
|
||||||
|
className="shrink-0"
|
||||||
>
|
>
|
||||||
{followed ? "Folgst du" : "Folgen"}
|
{followed ? "Folgst du" : "Folgen"}
|
||||||
</Button>
|
</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>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -131,16 +131,14 @@ export function searchPoliticians(query: string): Promise<Politician[]> {
|
|||||||
return request("politicians", { "label[cn]": query, range_end: "50" }, politicianSchema)
|
return request("politicians", { "label[cn]": query, range_end: "50" }, politicianSchema)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fetchPolls(rangeEnd = 100): Promise<Poll[]> {
|
export function fetchPolls(rangeEnd = 100, legislatureId?: number): Promise<Poll[]> {
|
||||||
return request(
|
const params: Record<string, string> = {
|
||||||
"polls",
|
range_end: String(rangeEnd),
|
||||||
{
|
sort_by: "field_poll_date",
|
||||||
range_end: String(rangeEnd),
|
sort_direction: "desc",
|
||||||
sort_by: "field_poll_date",
|
}
|
||||||
sort_direction: "desc",
|
if (legislatureId != null) params.field_legislature = String(legislatureId)
|
||||||
},
|
return request("polls", params, pollSchema)
|
||||||
pollSchema,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchPollsByIds(ids: number[]): Promise<Poll[]> {
|
export async function fetchPollsByIds(ids: number[]): Promise<Poll[]> {
|
||||||
|
|||||||
@@ -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_BASE = "https://www.abgeordnetenwatch.de/api/v2"
|
||||||
export const AW_API_TIMEOUT_MS = 20_000
|
export const AW_API_TIMEOUT_MS = 20_000
|
||||||
|
|||||||
Reference in New Issue
Block a user