restructure app into home/bundestag/landtag/settings tabs, add DIP API for upcoming votes

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-02 21:22:35 +01:00
parent 0b7e902f0b
commit 7f8376e1e4
43 changed files with 1291 additions and 411 deletions

View File

@@ -12,7 +12,7 @@ Abgeordnetenwatch PWA + Backend — a progressive web app that lets users follow
|---|---| |---|---|
| Runtime | Bun | | Runtime | Bun |
| Build | Vite + TanStack Router Plugin + vite-plugin-pwa (injectManifest) | | Build | Vite + TanStack Router Plugin + vite-plugin-pwa (injectManifest) |
| UI | React 19 + Konsta UI (mobile) + shadcn/ui (desktop, future) + Tailwind CSS v4 | | UI | React 19 + shadcn/ui + Tailwind CSS v4 |
| Routing | TanStack Router (file-based, `src/routes/app/` for mobile) | | Routing | TanStack Router (file-based, `src/routes/app/` for mobile) |
| State | Zustand (ephemeral UI), PGlite (persistent data via IndexedDB) | | State | Zustand (ephemeral UI), PGlite (persistent data via IndexedDB) |
| Validation | Zod | | Validation | Zod |
@@ -35,19 +35,28 @@ Abgeordnetenwatch PWA + Backend — a progressive web app that lets users follow
``` ```
src/ PWA source src/ PWA source
├── features/ Feature modules (feed, topics, politicians, location, settings) ├── features/ Feature modules
── <feature>/ components/, hooks/, lib/, store.ts, index.ts ── home/ Home tab (placeholder)
│ ├── bundestag/ Bundestag tab: DIP + AW feed, configure (topics + reps)
│ ├── landtag/ Landtag tab: representatives by geo-detection, configure
│ ├── feed/ Shared feed components (FeedList, FeedItemCard, useFeed)
│ ├── location/ Geo-detection, Bundestag mandate caching, party metadata
│ ├── topics/ useTopics hook (shared by configure pages)
│ └── settings/ Settings page (notifications, location, updates)
├── shared/ Shared code ├── shared/ Shared code
│ ├── components/ui/ shadcn components (reserved for desktop routes) │ ├── components/ representative-list, topic-toggle-list
│ │ └── ui/ shadcn components
│ ├── db/ PGlite client, migrations, data-access modules, DbProvider │ ├── db/ PGlite client, migrations, data-access modules, DbProvider
│ ├── hooks/ use-device-id, use-follows, use-push, use-pwa-update │ ├── hooks/ use-device-id, use-follows, use-push, use-pwa-update
│ └── lib/ aw-api, constants, push-client, utils │ └── lib/ aw-api, dip-api, constants, push-client, utils
├── routes/ TanStack Router file-based routes ├── routes/ TanStack Router file-based routes
│ ├── app/ Mobile routes (Konsta UI layout + tabbar) │ ├── app/ Tabs: Home / Bundestag / Landtag / Einstellungen
── index.tsx Redirect → /app/feed │ ├── bundestag/ index (feed) + configure sub-route
│ │ └── landtag/ index (reps) + configure sub-route
│ └── index.tsx Redirect → /app/home
├── sw.ts Custom service worker (precache + push handlers) ├── sw.ts Custom service worker (precache + push handlers)
├── app.tsx RouterProvider ├── app.tsx RouterProvider
├── app.css Tailwind v4 + Konsta + shadcn theme ├── app.css Tailwind v4 + shadcn theme
└── main.tsx Entry point └── main.tsx Entry point
server/ Backend source server/ Backend source
@@ -68,7 +77,7 @@ server/ Backend source
- PWA: static files deploy to Uberspace at `/var/www/virtual/<user>/html/agw/` - PWA: static files deploy to Uberspace at `/var/www/virtual/<user>/html/agw/`
- Backend: deployed as a supervisord service on Uberspace at `~/services/agw-backend/` - Backend: deployed as a supervisord service on Uberspace at `~/services/agw-backend/`
- Backend API is reverse-proxied at `/agw-api/` via `uberspace web backend` - Backend API is reverse-proxied at `/agw/api/` via `uberspace web backend`
- `./deploy.sh` builds and deploys both PWA and backend - `./deploy.sh` builds and deploys both PWA and backend
- Apache `.htaccess` handles SPA routing - Apache `.htaccess` handles SPA routing

View File

@@ -0,0 +1,49 @@
import { RepresentativeList } from "@/shared/components/representative-list"
import { TopicToggleList } from "@/shared/components/topic-toggle-list"
import { useDb } from "@/shared/db/provider"
import type { MandateWithPolitician } from "@/shared/lib/aw-api"
import { useEffect, useState } from "react"
import { fetchAndCacheBundestagMandates } from "../../location/lib/geo"
import { useBundestagUI } from "../store"
export function BundestagConfigure() {
const db = useDb()
const topicSearch = useBundestagUI((s) => s.topicSearch)
const setTopicSearch = useBundestagUI((s) => s.setTopicSearch)
const politicianSearch = useBundestagUI((s) => s.politicianSearch)
const setPoliticianSearch = useBundestagUI((s) => s.setPoliticianSearch)
const [mandates, setMandates] = useState<MandateWithPolitician[]>([])
const [loadingMandates, setLoadingMandates] = useState(false)
useEffect(() => {
setLoadingMandates(true)
fetchAndCacheBundestagMandates(db)
.then(setMandates)
.finally(() => setLoadingMandates(false))
}, [db])
return (
<div className="pb-4">
<section>
<h2 className="px-4 pt-4 pb-2 text-sm font-semibold text-muted-foreground uppercase tracking-wide">Themen</h2>
<TopicToggleList searchQuery={topicSearch} onSearchChange={setTopicSearch} />
</section>
<section>
<h2 className="px-4 pt-4 pb-2 text-sm font-semibold text-muted-foreground uppercase tracking-wide">
Abgeordnete
</h2>
{loadingMandates ? (
<div className="flex items-center justify-center h-48">
<div className="w-8 h-8 border-4 border-primary border-t-transparent rounded-full animate-spin" />
</div>
) : mandates.length > 0 ? (
<RepresentativeList mandates={mandates} searchQuery={politicianSearch} onSearchChange={setPoliticianSearch} />
) : (
<p className="px-4 text-sm text-muted-foreground">Keine Abgeordneten verfügbar.</p>
)}
</section>
</div>
)
}

View File

@@ -0,0 +1,78 @@
import { FeedList } from "@/features/feed/components/feed-list"
import { Link } from "@tanstack/react-router"
import { useBundestagFeed } from "../hooks/use-bundestag-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 BundestagFeed() {
const { items, loading, refreshing, error, lastUpdated, refresh } = useBundestagFeed()
const hasItems = items.length > 0
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 Bundestag-Feed ist leer</p>
<p className="text-sm text-muted-foreground mt-2">
Folge Themen oder Abgeordneten, um Abstimmungen hier zu sehen.
</p>
<Link to="/app/bundestag/configure" className="text-primary text-sm underline mt-4 inline-block">
Bundestag konfigurieren
</Link>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,125 @@
import type { FeedItem } from "@/features/feed/lib/assemble-feed"
import { loadFeedCache, mergeFeedItems, saveFeedCache } from "@/features/feed/lib/feed-cache"
import { useDb } from "@/shared/db/provider"
import { useFollows } from "@/shared/hooks/use-follows"
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { assembleBundestagFeed } from "../lib/assemble-bundestag-feed"
const CACHE_KEY = "bundestag_feed"
const REFRESH_INTERVAL_MS = 60 * 60 * 1000
export function useBundestagFeed() {
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 topicIDs = useMemo(() => follows.filter((f) => f.type === "topic").map((f) => f.entity_id), [follows])
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)
// Load 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 silent = opts?.silent ?? false
if (topicIDs.length === 0 && politicianIDs.length === 0) return
refreshingRef.current = true
if (silent) {
setRefreshing(true)
} else {
setLoading(true)
}
setError(null)
try {
const fresh = await assembleBundestagFeed(topicIDs, 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, topicIDs, politicianIDs],
)
// Initial fetch / refresh when follows change
useEffect(() => {
if (hasItemsRef.current) {
refresh({ silent: true })
} else {
refresh()
}
}, [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, refresh }
}

View File

@@ -0,0 +1,3 @@
export { BundestagFeed } from "./components/bundestag-feed"
export { BundestagConfigure } from "./components/bundestag-configure"
export { useBundestagFeed } from "./hooks/use-bundestag-feed"

View File

@@ -0,0 +1,97 @@
import type { FeedItem } from "@/features/feed/lib/assemble-feed"
import {
type Poll,
fetchCandidacyMandates,
fetchPollsByIds,
fetchPollsByLegislature,
fetchTopics,
fetchVotes,
} from "@/shared/lib/aw-api"
import { BUNDESTAG_LEGISLATURE_ID } from "@/shared/lib/constants"
import { fetchUpcomingVorgaenge } from "@/shared/lib/dip-api"
export async function assembleBundestagFeed(
followedTopicIDs: number[],
followedPoliticianIDs: number[],
): Promise<FeedItem[]> {
const [topics, polls, vorgaenge] = await Promise.all([
fetchTopics(),
fetchPollsByLegislature(BUNDESTAG_LEGISLATURE_ID, 150),
fetchUpcomingVorgaenge().catch(() => []),
])
const topicMap = new Map(topics.map((t) => [t.id, t.label]))
const topicLabelSet = new Set(topics.filter((t) => followedTopicIDs.includes(t.id)).map((t) => t.label.toLowerCase()))
// --- Past: AW API polls filtered by followed topics + politicians ---
const topicSet = new Set(followedTopicIDs)
const filteredByTopics = topicSet.size > 0 ? polls.filter((p) => p.field_topics.some((t) => topicSet.has(t.id))) : []
const politicianPolls = await fetchPollsForPoliticians(followedPoliticianIDs)
const combined = new Map<number, Poll>()
for (const p of [...filteredByTopics, ...politicianPolls]) combined.set(p.id, p)
const pollItems: FeedItem[] = Array.from(combined.values()).map((poll) => ({
id: `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.flatMap((t) => {
const label = t.label ?? topicMap.get(t.id)
return label ? [{ label, url: t.abgeordnetenwatch_url ?? null }] : []
}),
source: "Bundestag",
}))
// --- Upcoming: DIP API Vorgänge, filtered by sachgebiet matching followed topic labels ---
const vorgangItems: FeedItem[] = vorgaenge
.filter((v) => {
if (topicLabelSet.size === 0) return false
return v.sachgebiet?.some((s) => topicLabelSet.has(s.toLowerCase())) ?? false
})
.map((v) => ({
id: `vorgang-${v.id}`,
kind: "vorgang" as const,
status: "upcoming" as const,
title: v.titel,
url: null,
date: v.datum ?? null,
topics: (v.sachgebiet ?? []).map((s) => ({ label: s, url: null })),
source: "DIP Bundestag",
}))
const items = [...pollItems, ...vorgangItems]
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

@@ -0,0 +1,15 @@
import { create } from "zustand"
interface BundestagUIState {
topicSearch: string
setTopicSearch: (query: string) => void
politicianSearch: string
setPoliticianSearch: (query: string) => void
}
export const useBundestagUI = create<BundestagUIState>((set) => ({
topicSearch: "",
setTopicSearch: (query) => set({ topicSearch: query }),
politicianSearch: "",
setPoliticianSearch: (query) => set({ politicianSearch: query }),
}))

View File

@@ -1,4 +1,4 @@
import { Chip } from "konsta/react" import { Badge } from "@/shared/components/ui/badge"
import type { FeedItem as FeedItemType } from "../lib/assemble-feed" import type { FeedItem as FeedItemType } from "../lib/assemble-feed"
function formatDate(iso: string | null): string { function formatDate(iso: string | null): string {
@@ -25,7 +25,7 @@ export function FeedItemCard({ item }: { item: FeedItemType }) {
<h2 className="text-[15px] font-medium leading-snug">{item.title}</h2> <h2 className="text-[15px] font-medium leading-snug">{item.title}</h2>
)} )}
{item.date && ( {item.date && (
<time dateTime={item.date} className="text-xs text-black/55 dark:text-white/55 whitespace-nowrap shrink-0"> <time dateTime={item.date} className="text-xs text-muted-foreground whitespace-nowrap shrink-0">
{formatDate(item.date)} {formatDate(item.date)}
</time> </time>
)} )}
@@ -35,15 +35,26 @@ export function FeedItemCard({ item }: { item: FeedItemType }) {
{item.topics.map((topic) => {item.topics.map((topic) =>
topic.url ? ( topic.url ? (
<a key={topic.label} href={topic.url} target="_blank" rel="noopener noreferrer"> <a key={topic.label} href={topic.url} target="_blank" rel="noopener noreferrer">
<Chip className="cursor-pointer">{topic.label}</Chip> <Badge variant="secondary" className="cursor-pointer">
{topic.label}
</Badge>
</a> </a>
) : ( ) : (
<Chip key={topic.label}>{topic.label}</Chip> <Badge key={topic.label} variant="secondary">
{topic.label}
</Badge>
), ),
)} )}
</div> </div>
)} )}
<p className="text-xs text-black/55 dark:text-white/55 mt-1">{item.source}</p> <div className="flex items-center gap-2 mt-1">
<p className="text-xs text-muted-foreground">{item.source}</p>
{item.kind === "vorgang" && (
<Badge variant="outline" className="text-[10px] px-1.5 py-0">
{item.status === "upcoming" ? "In Beratung" : "Abgeschlossen"}
</Badge>
)}
</div>
</article> </article>
) )
} }

View File

@@ -14,7 +14,7 @@ export interface FeedTopicRef {
export interface FeedItem { export interface FeedItem {
id: string id: string
kind: "poll" kind: "poll" | "vorgang"
status: "upcoming" | "past" status: "upcoming" | "past"
title: string title: string
url: string | null url: string | null

View File

@@ -7,16 +7,16 @@ export interface FeedCacheData {
updatedAt: number updatedAt: number
} }
export async function loadFeedCache(db: PGlite): Promise<FeedCacheData | null> { export async function loadFeedCache(db: PGlite, cacheKey?: string): Promise<FeedCacheData | null> {
return loadCachedFeed(db) return loadCachedFeed(db, cacheKey)
} }
export async function saveFeedCache(db: PGlite, items: FeedItem[]): Promise<void> { export async function saveFeedCache(db: PGlite, items: FeedItem[], cacheKey?: string): Promise<void> {
await saveCachedFeed(db, items) await saveCachedFeed(db, items, cacheKey)
} }
export async function clearFeedCache(db: PGlite): Promise<void> { export async function clearFeedCache(db: PGlite, cacheKey?: string): Promise<void> {
await clearCachedFeed(db) await clearCachedFeed(db, cacheKey)
} }
export function mergeFeedItems(cached: FeedItem[], fresh: FeedItem[]): FeedItem[] { export function mergeFeedItems(cached: FeedItem[], fresh: FeedItem[]): FeedItem[] {

View File

@@ -0,0 +1,8 @@
export function HomePage() {
return (
<div className="text-center mt-12 px-4">
<p className="text-lg font-medium">Willkommen</p>
<p className="text-sm text-muted-foreground mt-2">Verfolge Abstimmungen im Bundestag und deinem Landtag.</p>
</div>
)
}

View File

@@ -0,0 +1 @@
export { HomePage } from "./components/home-page"

View File

@@ -0,0 +1,52 @@
import { RepresentativeList } from "@/shared/components/representative-list"
import { useDb } from "@/shared/db/provider"
import type { MandateWithPolitician } from "@/shared/lib/aw-api"
import { Link } from "@tanstack/react-router"
import { useEffect, useState } from "react"
import { loadCachedResult } from "../../location/lib/geo"
import { useLandtagUI } from "../store"
export function LandtagConfigure() {
const db = useDb()
const search = useLandtagUI((s) => s.politicianSearch)
const setSearch = useLandtagUI((s) => s.setPoliticianSearch)
const [mandates, setMandates] = useState<MandateWithPolitician[]>([])
const [loaded, setLoaded] = useState(false)
useEffect(() => {
loadCachedResult(db).then((cached) => {
if (cached) setMandates(cached.mandates)
setLoaded(true)
})
}, [db])
if (loaded && mandates.length === 0) {
return (
<div className="text-center mt-12 px-4">
<p className="text-muted-foreground text-sm mb-4">
Noch keine Abgeordneten geladen. Erkenne zuerst deinen Standort in den Einstellungen.
</p>
<Link to="/app/settings" className="text-primary text-sm underline">
Zu den Einstellungen
</Link>
</div>
)
}
return (
<div className="pb-4">
<section>
<h2 className="px-4 pt-4 pb-2 text-sm font-semibold text-muted-foreground uppercase tracking-wide">
Abgeordnete
</h2>
{!loaded ? (
<div className="flex items-center justify-center h-48">
<div className="w-8 h-8 border-4 border-primary border-t-transparent rounded-full animate-spin" />
</div>
) : (
<RepresentativeList mandates={mandates} searchQuery={search} onSearchChange={setSearch} />
)}
</section>
</div>
)
}

View File

@@ -0,0 +1,55 @@
import { RepresentativeList } from "@/shared/components/representative-list"
import { useDb } from "@/shared/db/provider"
import type { MandateWithPolitician } from "@/shared/lib/aw-api"
import { Link } from "@tanstack/react-router"
import { useEffect, useState } from "react"
import { type GeoResult, loadCachedResult } from "../../location/lib/geo"
import { useLandtagUI } from "../store"
export function LandtagPage() {
const db = useDb()
const [result, setResult] = useState<GeoResult | null>(null)
const [mandates, setMandates] = useState<MandateWithPolitician[]>([])
const search = useLandtagUI((s) => s.politicianSearch)
const setSearch = useLandtagUI((s) => s.setPoliticianSearch)
useEffect(() => {
loadCachedResult(db).then((cached) => {
if (cached) {
setResult(cached)
setMandates(cached.mandates)
}
})
}, [db])
if (!result || mandates.length === 0) {
return (
<div className="text-center mt-12 px-4">
<p className="text-lg font-medium">Landtag</p>
<p className="text-muted-foreground text-sm mt-2 mb-4">
Erkenne zuerst deinen Standort in den Einstellungen, um deine Landtagsabgeordneten zu sehen.
</p>
<Link to="/app/settings" className="text-primary text-sm underline">
Zu den Einstellungen
</Link>
</div>
)
}
return (
<div className="pb-4">
{result.landtag_label && (
<div className="px-4 py-3 border-b border-border">
<p className="text-sm font-medium">{result.landtag_label}</p>
{result.bundesland && <p className="text-xs text-muted-foreground">{result.bundesland}</p>}
</div>
)}
<RepresentativeList mandates={mandates} searchQuery={search} onSearchChange={setSearch} />
<div className="px-4 py-6 text-center">
<p className="text-sm text-muted-foreground">Abstimmungsdaten folgen in Kürze</p>
</div>
</div>
)
}

View File

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

View File

@@ -0,0 +1,11 @@
import { create } from "zustand"
interface LandtagUIState {
politicianSearch: string
setPoliticianSearch: (query: string) => void
}
export const useLandtagUI = create<LandtagUIState>((set) => ({
politicianSearch: "",
setPoliticianSearch: (query) => set({ politicianSearch: query }),
}))

View File

@@ -1,3 +1,9 @@
export { loadCachedResult, clearGeoCache, detectFromCoords } from "./lib/geo" export {
loadCachedResult,
clearGeoCache,
detectFromCoords,
loadBundestagMandates,
fetchAndCacheBundestagMandates,
} from "./lib/geo"
export type { GeoResult } from "./lib/geo" export type { GeoResult } from "./lib/geo"
export { getPartyMeta } from "./lib/parties" export { getPartyMeta } from "./lib/parties"

View File

@@ -5,6 +5,7 @@ import {
saveGeoCache, saveGeoCache,
} from "@/shared/db/geo-cache-db" } from "@/shared/db/geo-cache-db"
import { type MandateWithPolitician, fetchMandatesByParliamentPeriod } from "@/shared/lib/aw-api" import { type MandateWithPolitician, fetchMandatesByParliamentPeriod } from "@/shared/lib/aw-api"
import { BUNDESTAG_LEGISLATURE_ID } from "@/shared/lib/constants"
import type { PGlite } from "@electric-sql/pglite" import type { PGlite } from "@electric-sql/pglite"
const BUNDESLAND_TO_PARLIAMENT: Record<string, { label: string; parliamentPeriodId: number }> = { const BUNDESLAND_TO_PARLIAMENT: Record<string, { label: string; parliamentPeriodId: number }> = {
@@ -56,6 +57,34 @@ export async function clearGeoCache(db: PGlite): Promise<void> {
await clearGeoCacheDb(db) await clearGeoCacheDb(db)
} }
const BUNDESTAG_CACHE_KEY = "Bundestag"
/** Load cached Bundestag mandates from geo_cache. */
export async function loadBundestagMandates(db: PGlite): Promise<MandateWithPolitician[] | null> {
const cached = await loadGeoCache(db, BUNDESTAG_CACHE_KEY)
if (!cached) return null
return (cached as unknown as { mandates: MandateWithPolitician[] }).mandates
}
/** Fetch Bundestag mandates from API and cache them. */
export async function fetchAndCacheBundestagMandates(db: PGlite): Promise<MandateWithPolitician[]> {
const cached = await loadBundestagMandates(db)
if (cached) return cached
let mandates: MandateWithPolitician[] = []
try {
mandates = await fetchMandatesByParliamentPeriod(BUNDESTAG_LEGISLATURE_ID)
} catch {
mandates = []
}
if (mandates.length > 0) {
await saveGeoCache(db, BUNDESTAG_CACHE_KEY, { mandates } as unknown as Record<string, unknown>)
}
return mandates
}
export async function detectFromCoords(db: PGlite, lat: number, lon: number, skipCache = false): Promise<GeoResult> { export async function detectFromCoords(db: PGlite, lat: number, lon: number, skipCache = false): Promise<GeoResult> {
const url = `https://nominatim.openstreetmap.org/reverse?lat=${lat}&lon=${lon}&format=json` const url = `https://nominatim.openstreetmap.org/reverse?lat=${lat}&lon=${lon}&format=json`
const res = await fetch(url, { const res = await fetch(url, {

View File

@@ -1,161 +0,0 @@
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 { Block, Button, Card, List, ListItem, Navbar, Searchbar } from "konsta/react"
import { useEffect, useMemo, useState } from "react"
import { type GeoResult, loadCachedResult } from "../../location/lib/geo"
import { getPartyMeta } from "../../location/lib/parties"
import { usePoliticiansUI } from "../store"
interface PartyGroup {
partyLabel: string
members: MandateWithPolitician[]
}
/** Extract party/fraction label for grouping. Prefers party, falls back to current fraction. */
function partyLabel(m: MandateWithPolitician): string {
if (m.party?.label) return m.party.label
const current = m.fraction_membership?.find((f) => !f.valid_until)
if (current) return current.fraction.label.replace(/\s*\([^)]+\)\s*$/, "")
return "parteilos"
}
function groupByParty(mandates: MandateWithPolitician[]): PartyGroup[] {
const map = new Map<string, MandateWithPolitician[]>()
for (const m of mandates) {
const key = partyLabel(m)
const list = map.get(key)
if (list) {
list.push(m)
} else {
map.set(key, [m])
}
}
return Array.from(map.entries())
.sort((a, b) => b[1].length - a[1].length)
.map(([partyLabel, members]) => ({
partyLabel,
members: members.sort((a, b) => a.politician.label.localeCompare(b.politician.label)),
}))
}
function mandateFunction(m: MandateWithPolitician): string | null {
const won = m.electoral_data?.mandate_won
const constituency = m.electoral_data?.constituency?.label
if (!won) return null
if (won === "constituency" && constituency) {
const clean = constituency.replace(/\s*\([^)]+\)\s*$/, "")
return `Wahlkreis ${clean}`
}
if (won === "list") return "Landesliste"
if (won === "moved_up") return "Nachgerückt"
return won
}
export function PoliticianSearch() {
const db = useDb()
const [result, setResult] = useState<GeoResult | null>(null)
const search = usePoliticiansUI((s) => s.searchQuery)
const setSearch = usePoliticiansUI((s) => s.setSearchQuery)
const { isFollowing, follow, unfollow } = useFollows()
useEffect(() => {
loadCachedResult(db).then((cached) => {
if (cached) setResult(cached)
})
}, [db])
const groups = useMemo(() => {
if (!result) return []
const filtered = search
? result.mandates.filter((m) => m.politician.label.toLowerCase().includes(search.toLowerCase()))
: result.mandates
return groupByParty(filtered)
}, [result, search])
if (!result || result.mandates.length === 0) {
return (
<>
<Navbar title="Abgeordnete" />
<Block className="text-center mt-12">
<p className="text-black/55 dark:text-white/55 text-sm mb-4">
Noch keine Abgeordneten geladen. Erkenne zuerst deinen Standort in den Einstellungen.
</p>
<Link to="/app/settings" className="text-primary text-sm underline">
Zu den Einstellungen
</Link>
</Block>
</>
)
}
return (
<>
<Navbar title="Abgeordnete" />
<Searchbar
value={search}
onInput={(e: React.ChangeEvent<HTMLInputElement>) => setSearch(e.target.value)}
onClear={() => setSearch("")}
placeholder="Name filtern…"
/>
<div className="px-4 pb-20 space-y-3">
{groups.map((group) => {
const meta = getPartyMeta(group.partyLabel)
return (
<Card key={group.partyLabel} outline contentWrap={false}>
<div
className="flex items-center gap-2.5 px-4 py-2.5"
style={{ borderLeftWidth: 4, borderLeftColor: meta.color }}
>
<span
className="inline-flex items-center justify-center w-7 h-7 rounded-full text-[10px] font-bold text-white shrink-0"
style={{ backgroundColor: meta.color }}
aria-hidden="true"
>
{meta.short.slice(0, 3)}
</span>
<span className="text-sm font-semibold">{group.partyLabel}</span>
<span className="text-xs text-black/55 dark:text-white/55 ml-auto">{group.members.length}</span>
</div>
<List nested dividers>
{group.members.map((m) => {
const followed = isFollowing("politician", m.politician.id)
const fn = mandateFunction(m)
return (
<ListItem
key={m.id}
title={m.politician.label}
subtitle={fn}
after={
<Button
small
rounded
outline={!followed}
onClick={() =>
followed
? unfollow("politician", m.politician.id)
: follow("politician", m.politician.id, m.politician.label)
}
aria-pressed={followed}
aria-label={followed ? `${m.politician.label} entfolgen` : `${m.politician.label} folgen`}
>
{followed ? "Folgst du" : "Folgen"}
</Button>
}
/>
)
})}
</List>
</Card>
)
})}
{groups.length === 0 && search && (
<p className="text-center text-sm text-black/55 dark:text-white/55 py-6">Keine Abgeordneten für {search}"</p>
)}
</div>
</>
)
}

View File

@@ -1 +0,0 @@
export { PoliticianSearch } from "./components/politician-search"

View File

@@ -1,11 +0,0 @@
import { create } from "zustand"
interface PoliticiansUIState {
searchQuery: string
setSearchQuery: (query: string) => void
}
export const usePoliticiansUI = create<PoliticiansUIState>((set) => ({
searchQuery: "",
setSearchQuery: (query) => set({ searchQuery: query }),
}))

View File

@@ -1,62 +0,0 @@
import { useFollows } from "@/shared/hooks/use-follows"
import { Button, List, ListItem, Navbar, Preloader, Searchbar } from "konsta/react"
import { useTopics } from "../hooks/use-topics"
import { useTopicsUI } from "../store"
export function TopicList() {
const { topics, loading, error } = useTopics()
const { isFollowing, follow, unfollow } = useFollows()
const search = useTopicsUI((s) => s.searchQuery)
const setSearch = useTopicsUI((s) => s.setSearchQuery)
const filtered = topics.filter((t) => t.label.toLowerCase().includes(search.toLowerCase()))
return (
<>
<Navbar title="Themen" />
<Searchbar
value={search}
onInput={(e: React.ChangeEvent<HTMLInputElement>) => setSearch(e.target.value)}
onClear={() => setSearch("")}
placeholder="Themen suchen…"
/>
{loading && (
<div className="flex items-center justify-center h-48">
<Preloader />
</div>
)}
{error && (
<div className="p-4 text-red-500" role="alert">
{error}
</div>
)}
<List dividers className="pb-20">
{filtered.map((topic) => {
const followed = isFollowing("topic", topic.id)
return (
<ListItem
key={topic.id}
title={topic.label}
after={
<Button
small
rounded
outline={!followed}
onClick={() => (followed ? unfollow("topic", topic.id) : follow("topic", topic.id, topic.label))}
aria-pressed={followed}
aria-label={followed ? `${topic.label} entfolgen` : `${topic.label} folgen`}
>
{followed ? "Folgst du" : "Folgen"}
</Button>
}
/>
)
})}
</List>
</>
)
}

View File

@@ -1 +0,0 @@
export { TopicList } from "./components/topic-list"

View File

@@ -1,11 +0,0 @@
import { create } from "zustand"
interface TopicsUIState {
searchQuery: string
setSearchQuery: (query: string) => void
}
export const useTopicsUI = create<TopicsUIState>((set) => ({
searchQuery: "",
setSearchQuery: (query) => set({ searchQuery: query }),
}))

277
src/routeTree.gen.ts Normal file
View File

@@ -0,0 +1,277 @@
/* eslint-disable */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// This file was automatically generated by TanStack Router.
// You should NOT make any changes in this file as it will be overwritten.
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
import { Route as rootRouteImport } from './routes/__root'
import { Route as AppRouteRouteImport } from './routes/app/route'
import { Route as IndexRouteImport } from './routes/index'
import { Route as AppSettingsRouteImport } from './routes/app/settings'
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 AppLandtagConfigureRouteImport } from './routes/app/landtag/configure'
import { Route as AppBundestagConfigureRouteImport } from './routes/app/bundestag/configure'
const AppRouteRoute = AppRouteRouteImport.update({
id: '/app',
path: '/app',
getParentRoute: () => rootRouteImport,
} as any)
const IndexRoute = IndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => rootRouteImport,
} as any)
const AppSettingsRoute = AppSettingsRouteImport.update({
id: '/settings',
path: '/settings',
getParentRoute: () => AppRouteRoute,
} as any)
const AppHomeRoute = AppHomeRouteImport.update({
id: '/home',
path: '/home',
getParentRoute: () => AppRouteRoute,
} as any)
const AppLandtagRouteRoute = AppLandtagRouteRouteImport.update({
id: '/landtag',
path: '/landtag',
getParentRoute: () => AppRouteRoute,
} as any)
const AppBundestagRouteRoute = AppBundestagRouteRouteImport.update({
id: '/bundestag',
path: '/bundestag',
getParentRoute: () => AppRouteRoute,
} as any)
const AppLandtagIndexRoute = AppLandtagIndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => AppLandtagRouteRoute,
} as any)
const AppBundestagIndexRoute = AppBundestagIndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => AppBundestagRouteRoute,
} as any)
const AppLandtagConfigureRoute = AppLandtagConfigureRouteImport.update({
id: '/configure',
path: '/configure',
getParentRoute: () => AppLandtagRouteRoute,
} as any)
const AppBundestagConfigureRoute = AppBundestagConfigureRouteImport.update({
id: '/configure',
path: '/configure',
getParentRoute: () => AppBundestagRouteRoute,
} as any)
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/app': typeof AppRouteRouteWithChildren
'/app/bundestag': typeof AppBundestagRouteRouteWithChildren
'/app/landtag': typeof AppLandtagRouteRouteWithChildren
'/app/home': typeof AppHomeRoute
'/app/settings': typeof AppSettingsRoute
'/app/bundestag/configure': typeof AppBundestagConfigureRoute
'/app/landtag/configure': typeof AppLandtagConfigureRoute
'/app/bundestag/': typeof AppBundestagIndexRoute
'/app/landtag/': typeof AppLandtagIndexRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/app': typeof AppRouteRouteWithChildren
'/app/home': typeof AppHomeRoute
'/app/settings': typeof AppSettingsRoute
'/app/bundestag/configure': typeof AppBundestagConfigureRoute
'/app/landtag/configure': typeof AppLandtagConfigureRoute
'/app/bundestag': typeof AppBundestagIndexRoute
'/app/landtag': typeof AppLandtagIndexRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
'/app': typeof AppRouteRouteWithChildren
'/app/bundestag': typeof AppBundestagRouteRouteWithChildren
'/app/landtag': typeof AppLandtagRouteRouteWithChildren
'/app/home': typeof AppHomeRoute
'/app/settings': typeof AppSettingsRoute
'/app/bundestag/configure': typeof AppBundestagConfigureRoute
'/app/landtag/configure': typeof AppLandtagConfigureRoute
'/app/bundestag/': typeof AppBundestagIndexRoute
'/app/landtag/': typeof AppLandtagIndexRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths:
| '/'
| '/app'
| '/app/bundestag'
| '/app/landtag'
| '/app/home'
| '/app/settings'
| '/app/bundestag/configure'
| '/app/landtag/configure'
| '/app/bundestag/'
| '/app/landtag/'
fileRoutesByTo: FileRoutesByTo
to:
| '/'
| '/app'
| '/app/home'
| '/app/settings'
| '/app/bundestag/configure'
| '/app/landtag/configure'
| '/app/bundestag'
| '/app/landtag'
id:
| '__root__'
| '/'
| '/app'
| '/app/bundestag'
| '/app/landtag'
| '/app/home'
| '/app/settings'
| '/app/bundestag/configure'
| '/app/landtag/configure'
| '/app/bundestag/'
| '/app/landtag/'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
AppRouteRoute: typeof AppRouteRouteWithChildren
}
declare module '@tanstack/react-router' {
interface FileRoutesByPath {
'/app': {
id: '/app'
path: '/app'
fullPath: '/app'
preLoaderRoute: typeof AppRouteRouteImport
parentRoute: typeof rootRouteImport
}
'/': {
id: '/'
path: '/'
fullPath: '/'
preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport
}
'/app/settings': {
id: '/app/settings'
path: '/settings'
fullPath: '/app/settings'
preLoaderRoute: typeof AppSettingsRouteImport
parentRoute: typeof AppRouteRoute
}
'/app/home': {
id: '/app/home'
path: '/home'
fullPath: '/app/home'
preLoaderRoute: typeof AppHomeRouteImport
parentRoute: typeof AppRouteRoute
}
'/app/landtag': {
id: '/app/landtag'
path: '/landtag'
fullPath: '/app/landtag'
preLoaderRoute: typeof AppLandtagRouteRouteImport
parentRoute: typeof AppRouteRoute
}
'/app/bundestag': {
id: '/app/bundestag'
path: '/bundestag'
fullPath: '/app/bundestag'
preLoaderRoute: typeof AppBundestagRouteRouteImport
parentRoute: typeof AppRouteRoute
}
'/app/landtag/': {
id: '/app/landtag/'
path: '/'
fullPath: '/app/landtag/'
preLoaderRoute: typeof AppLandtagIndexRouteImport
parentRoute: typeof AppLandtagRouteRoute
}
'/app/bundestag/': {
id: '/app/bundestag/'
path: '/'
fullPath: '/app/bundestag/'
preLoaderRoute: typeof AppBundestagIndexRouteImport
parentRoute: typeof AppBundestagRouteRoute
}
'/app/landtag/configure': {
id: '/app/landtag/configure'
path: '/configure'
fullPath: '/app/landtag/configure'
preLoaderRoute: typeof AppLandtagConfigureRouteImport
parentRoute: typeof AppLandtagRouteRoute
}
'/app/bundestag/configure': {
id: '/app/bundestag/configure'
path: '/configure'
fullPath: '/app/bundestag/configure'
preLoaderRoute: typeof AppBundestagConfigureRouteImport
parentRoute: typeof AppBundestagRouteRoute
}
}
}
interface AppBundestagRouteRouteChildren {
AppBundestagConfigureRoute: typeof AppBundestagConfigureRoute
AppBundestagIndexRoute: typeof AppBundestagIndexRoute
}
const AppBundestagRouteRouteChildren: AppBundestagRouteRouteChildren = {
AppBundestagConfigureRoute: AppBundestagConfigureRoute,
AppBundestagIndexRoute: AppBundestagIndexRoute,
}
const AppBundestagRouteRouteWithChildren =
AppBundestagRouteRoute._addFileChildren(AppBundestagRouteRouteChildren)
interface AppLandtagRouteRouteChildren {
AppLandtagConfigureRoute: typeof AppLandtagConfigureRoute
AppLandtagIndexRoute: typeof AppLandtagIndexRoute
}
const AppLandtagRouteRouteChildren: AppLandtagRouteRouteChildren = {
AppLandtagConfigureRoute: AppLandtagConfigureRoute,
AppLandtagIndexRoute: AppLandtagIndexRoute,
}
const AppLandtagRouteRouteWithChildren = AppLandtagRouteRoute._addFileChildren(
AppLandtagRouteRouteChildren,
)
interface AppRouteRouteChildren {
AppBundestagRouteRoute: typeof AppBundestagRouteRouteWithChildren
AppLandtagRouteRoute: typeof AppLandtagRouteRouteWithChildren
AppHomeRoute: typeof AppHomeRoute
AppSettingsRoute: typeof AppSettingsRoute
}
const AppRouteRouteChildren: AppRouteRouteChildren = {
AppBundestagRouteRoute: AppBundestagRouteRouteWithChildren,
AppLandtagRouteRoute: AppLandtagRouteRouteWithChildren,
AppHomeRoute: AppHomeRoute,
AppSettingsRoute: AppSettingsRoute,
}
const AppRouteRouteWithChildren = AppRouteRoute._addFileChildren(
AppRouteRouteChildren,
)
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
AppRouteRoute: AppRouteRouteWithChildren,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)
._addFileTypes<FileRouteTypes>()

View File

@@ -0,0 +1,6 @@
import { BundestagConfigure } from "@/features/bundestag"
import { createFileRoute } from "@tanstack/react-router"
export const Route = createFileRoute("/app/bundestag/configure")({
component: BundestagConfigure,
})

View File

@@ -0,0 +1,6 @@
import { BundestagFeed } from "@/features/bundestag"
import { createFileRoute } from "@tanstack/react-router"
export const Route = createFileRoute("/app/bundestag/")({
component: BundestagFeed,
})

View File

@@ -0,0 +1,5 @@
import { Outlet, createFileRoute } from "@tanstack/react-router"
export const Route = createFileRoute("/app/bundestag")({
component: () => <Outlet />,
})

View File

@@ -1,78 +0,0 @@
import { FeedList, useFeed } from "@/features/feed"
import { createFileRoute } from "@tanstack/react-router"
import { Navbar } from "konsta/react"
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.`
}
function FeedPage() {
const { items, loading, refreshing, error, lastUpdated, refresh } = useFeed()
const hasItems = items.length > 0
return (
<>
<Navbar
title="Feed"
right={
lastUpdated ? (
<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>
) : undefined
}
subtitle={lastUpdated ? `Aktualisiert ${formatCacheAge(lastUpdated)}` : undefined}
/>
<div className="pb-20">
{/* Loading spinner — only when no cached items */}
{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 */}
{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>
)}
{/* Feed list */}
{hasItems && <FeedList items={items} />}
</div>
</>
)
}
export const Route = createFileRoute("/app/feed")({
component: FeedPage,
})

6
src/routes/app/home.tsx Normal file
View File

@@ -0,0 +1,6 @@
import { HomePage } from "@/features/home"
import { createFileRoute } from "@tanstack/react-router"
export const Route = createFileRoute("/app/home")({
component: HomePage,
})

View File

@@ -0,0 +1,6 @@
import { LandtagConfigure } from "@/features/landtag"
import { createFileRoute } from "@tanstack/react-router"
export const Route = createFileRoute("/app/landtag/configure")({
component: LandtagConfigure,
})

View File

@@ -0,0 +1,6 @@
import { LandtagPage } from "@/features/landtag"
import { createFileRoute } from "@tanstack/react-router"
export const Route = createFileRoute("/app/landtag/")({
component: LandtagPage,
})

View File

@@ -0,0 +1,5 @@
import { Outlet, createFileRoute } from "@tanstack/react-router"
export const Route = createFileRoute("/app/landtag")({
component: () => <Outlet />,
})

View File

@@ -1,6 +0,0 @@
import { PoliticianSearch } from "@/features/politicians"
import { createFileRoute } from "@tanstack/react-router"
export const Route = createFileRoute("/app/politicians")({
component: PoliticianSearch,
})

View File

@@ -1,6 +1,5 @@
import { DbProvider } from "@/shared/db/provider" import { DbProvider } from "@/shared/db/provider"
import { Outlet, createFileRoute, useMatches, useNavigate } from "@tanstack/react-router" import { Link, Outlet, createFileRoute, useMatches, useNavigate } from "@tanstack/react-router"
import { App, Page, Tabbar, TabbarLink } from "konsta/react"
import { Suspense } from "react" import { Suspense } from "react"
interface TabDef { interface TabDef {
@@ -11,18 +10,18 @@ interface TabDef {
const TABS: TabDef[] = [ const TABS: TabDef[] = [
{ {
to: "/app/feed", to: "/app/home",
label: "Feed", label: "Home",
icon: "M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-4 0a1 1 0 01-1-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 01-1 1m-2 0h2",
},
{
to: "/app/bundestag",
label: "Bundestag",
icon: "M3 7h18M3 12h18M3 17h18", icon: "M3 7h18M3 12h18M3 17h18",
}, },
{ {
to: "/app/topics", to: "/app/landtag",
label: "Themen", label: "Landtag",
icon: "M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2",
},
{
to: "/app/politicians",
label: "Abgeordnete",
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", 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",
}, },
{ {
@@ -36,7 +35,7 @@ function TabIcon({ d }: { d: string }) {
return ( return (
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
className="w-6 h-6" className="w-5 h-5"
fill="none" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"
stroke="currentColor" stroke="currentColor"
@@ -51,47 +50,115 @@ function TabIcon({ d }: { d: string }) {
function AppLayout() { function AppLayout() {
const navigate = useNavigate() const navigate = useNavigate()
const matches = useMatches() const matches = useMatches()
const currentPath = matches.at(-1)?.pathname ?? "/app/feed" 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 isBundestag = currentPath.startsWith("/app/bundestag")
const isLandtag = currentPath.startsWith("/app/landtag")
// Determine parent path for back navigation from configure routes
const parentPath = isConfigureRoute ? currentPath.replace(/\/configure$/, "") : null
// Determine configure link target for typed navigation
const configureTarget = isBundestag ? "/app/bundestag/configure" : isLandtag ? "/app/landtag/configure" : null
return ( return (
<App theme="ios" safeAreas className="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">
{isConfigureRoute && parentPath ? (
<button
type="button"
onClick={() => navigate({ to: parentPath })}
className="flex items-center gap-1 text-primary"
aria-label="Zurück"
>
<svg
xmlns="http://www.w3.org/2000/svg"
className="w-5 h-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
aria-hidden="true"
>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
</svg>
<span className="text-sm">Zurück</span>
</button>
) : null}
<h1 className={`text-base font-semibold text-card-foreground ${isConfigureRoute ? "ml-2" : ""}`}>
{isConfigureRoute ? `${currentTab.label} konfigurieren` : currentTab.label}
</h1>
{!isConfigureRoute && configureTarget && (
<Link
to={configureTarget}
className="ml-auto p-1.5 rounded-md hover:bg-muted transition-colors"
aria-label={`${currentTab.label} konfigurieren`}
>
<svg
xmlns="http://www.w3.org/2000/svg"
className="w-5 h-5 text-muted-foreground"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4"
/>
</svg>
</Link>
)}
</header>
<Suspense <Suspense
fallback={ fallback={
<Page> <div className="flex-1 flex items-center justify-center">
<div className="flex items-center justify-center h-full">
<div className="w-8 h-8 border-4 border-primary border-t-transparent rounded-full animate-spin" /> <div className="w-8 h-8 border-4 border-primary border-t-transparent rounded-full animate-spin" />
</div> </div>
</Page>
} }
> >
<DbProvider> <DbProvider>
<Page> <div className="flex-1 overflow-y-auto">
<Outlet /> <Outlet />
<Tabbar labels icons className="left-0 bottom-0 fixed"> </div>
{TABS.map((tab) => ( </DbProvider>
<TabbarLink </Suspense>
<nav className="flex bg-card border-t border-border safe-area-bottom" role="tablist" aria-label="Hauptnavigation">
{TABS.map((tab) => {
const active = currentPath.startsWith(tab.to)
return (
<Link
key={tab.to} key={tab.to}
active={currentPath === tab.to} to={tab.to}
onClick={() => { role="tab"
aria-selected={active}
aria-label={tab.label}
onClick={(e) => {
if (active) return
e.preventDefault()
const go = () => navigate({ to: tab.to }) const go = () => navigate({ to: tab.to })
if ( if (document.startViewTransition && !window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
document.startViewTransition &&
!window.matchMedia("(prefers-reduced-motion: reduce)").matches
) {
document.startViewTransition(go) document.startViewTransition(go)
} else { } else {
go() go()
} }
}} }}
icon={<TabIcon d={tab.icon} />} className={`flex flex-col items-center justify-center flex-1 py-2 gap-0.5 transition-colors no-underline ${
label={tab.label} active ? "text-primary" : "text-muted-foreground hover:text-foreground"
/> }`}
))} >
</Tabbar> <TabIcon d={tab.icon} />
</Page> <span className="text-[10px] font-medium">{tab.label}</span>
</DbProvider> </Link>
</Suspense> )
</App> })}
</nav>
</div>
) )
} }

View File

@@ -1,6 +0,0 @@
import { TopicList } from "@/features/topics"
import { createFileRoute } from "@tanstack/react-router"
export const Route = createFileRoute("/app/topics")({
component: TopicList,
})

View File

@@ -1,5 +1,5 @@
import { Navigate, createFileRoute } from "@tanstack/react-router" import { Navigate, createFileRoute } from "@tanstack/react-router"
export const Route = createFileRoute("/")({ export const Route = createFileRoute("/")({
component: () => <Navigate to="/app/feed" />, component: () => <Navigate to="/app/home" />,
}) })

View File

@@ -0,0 +1,136 @@
import { getPartyMeta } from "@/features/location/lib/parties"
import { useFollows } from "@/shared/hooks/use-follows"
import type { MandateWithPolitician } from "@/shared/lib/aw-api"
import { Button } from "./ui/button"
import { Card, CardContent } from "./ui/card"
import { Input } from "./ui/input"
export interface PartyGroup {
partyLabel: string
members: MandateWithPolitician[]
}
/** Extract party/fraction label for grouping. Prefers party, falls back to current fraction. */
function partyLabel(m: MandateWithPolitician): string {
if (m.party?.label) return m.party.label
const current = m.fraction_membership?.find((f) => !f.valid_until)
if (current) return current.fraction.label.replace(/\s*\([^)]+\)\s*$/, "")
return "parteilos"
}
export function groupByParty(mandates: MandateWithPolitician[]): PartyGroup[] {
const map = new Map<string, MandateWithPolitician[]>()
for (const m of mandates) {
const key = partyLabel(m)
const list = map.get(key)
if (list) {
list.push(m)
} else {
map.set(key, [m])
}
}
return Array.from(map.entries())
.sort((a, b) => b[1].length - a[1].length)
.map(([partyLabel, members]) => ({
partyLabel,
members: members.sort((a, b) => a.politician.label.localeCompare(b.politician.label)),
}))
}
export function mandateFunction(m: MandateWithPolitician): string | null {
const won = m.electoral_data?.mandate_won
const constituency = m.electoral_data?.constituency?.label
if (!won) return null
if (won === "constituency" && constituency) {
const clean = constituency.replace(/\s*\([^)]+\)\s*$/, "")
return `Wahlkreis ${clean}`
}
if (won === "list") return "Landesliste"
if (won === "moved_up") return "Nachgerückt"
return won
}
interface RepresentativeListProps {
mandates: MandateWithPolitician[]
userBundesland?: string | null
searchQuery: string
onSearchChange: (query: string) => void
}
export function RepresentativeList({ mandates, searchQuery, onSearchChange }: RepresentativeListProps) {
const { isFollowing, follow, unfollow } = useFollows()
const filtered = searchQuery
? mandates.filter((m) => m.politician.label.toLowerCase().includes(searchQuery.toLowerCase()))
: mandates
const groups = groupByParty(filtered)
return (
<div className="pb-4">
<div className="px-4 py-3">
<Input
value={searchQuery}
onChange={(e) => onSearchChange(e.target.value)}
placeholder="Name filtern…"
type="search"
/>
</div>
<div className="px-4 space-y-3">
{groups.map((group) => {
const meta = getPartyMeta(group.partyLabel)
return (
<Card key={group.partyLabel} className="py-0 gap-0 overflow-hidden">
<div
className="flex items-center gap-2.5 px-4 py-2.5"
style={{ borderLeftWidth: 4, borderLeftColor: meta.color }}
>
<span
className="inline-flex items-center justify-center w-7 h-7 rounded-full text-[10px] font-bold text-white shrink-0"
style={{ backgroundColor: meta.color }}
aria-hidden="true"
>
{meta.short.slice(0, 3)}
</span>
<span className="text-sm font-semibold">{group.partyLabel}</span>
<span className="text-xs text-muted-foreground ml-auto">{group.members.length}</span>
</div>
<CardContent className="p-0">
<div className="divide-y divide-border">
{group.members.map((m) => {
const followed = isFollowing("politician", m.politician.id)
const fn = mandateFunction(m)
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>
{fn && <p className="text-xs text-muted-foreground">{fn}</p>}
</div>
<Button
size="sm"
variant={followed ? "default" : "outline"}
onClick={() =>
followed
? unfollow("politician", m.politician.id)
: follow("politician", m.politician.id, m.politician.label)
}
aria-pressed={followed}
aria-label={followed ? `${m.politician.label} entfolgen` : `${m.politician.label} folgen`}
>
{followed ? "Folgst du" : "Folgen"}
</Button>
</div>
)
})}
</div>
</CardContent>
</Card>
)
})}
{groups.length === 0 && searchQuery && (
<p className="text-center text-sm text-muted-foreground py-6">Keine Abgeordneten für {searchQuery}"</p>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,61 @@
import { useTopics } from "@/features/topics/hooks/use-topics"
import { useFollows } from "@/shared/hooks/use-follows"
import { Button } from "./ui/button"
import { Input } from "./ui/input"
interface TopicToggleListProps {
searchQuery: string
onSearchChange: (query: string) => void
}
export function TopicToggleList({ searchQuery, onSearchChange }: TopicToggleListProps) {
const { topics, loading, error } = useTopics()
const { isFollowing, follow, unfollow } = useFollows()
const filtered = topics.filter((t) => t.label.toLowerCase().includes(searchQuery.toLowerCase()))
return (
<div className="pb-4">
<div className="px-4 py-3">
<Input
value={searchQuery}
onChange={(e) => onSearchChange(e.target.value)}
placeholder="Themen suchen…"
type="search"
/>
</div>
{loading && (
<div className="flex items-center justify-center h-48">
<div className="w-8 h-8 border-4 border-primary border-t-transparent rounded-full animate-spin" />
</div>
)}
{error && (
<div className="p-4 text-destructive" role="alert">
{error}
</div>
)}
<div className="divide-y divide-border">
{filtered.map((topic) => {
const followed = isFollowing("topic", topic.id)
return (
<div key={topic.id} className="flex items-center justify-between px-4 py-3">
<span className="text-sm">{topic.label}</span>
<Button
size="sm"
variant={followed ? "default" : "outline"}
onClick={() => (followed ? unfollow("topic", topic.id) : follow("topic", topic.id, topic.label))}
aria-pressed={followed}
aria-label={followed ? `${topic.label} entfolgen` : `${topic.label} folgen`}
>
{followed ? "Folgst du" : "Folgen"}
</Button>
</div>
)
})}
</div>
</div>
)
}

View File

@@ -1,15 +1,18 @@
import type { FeedItem } from "@/features/feed/lib/assemble-feed" import type { FeedItem } from "@/features/feed/lib/assemble-feed"
import type { PGlite } from "@electric-sql/pglite" import type { PGlite } from "@electric-sql/pglite"
const CACHE_KEY = "feed_items" const DEFAULT_CACHE_KEY = "feed_items"
interface CacheRow { interface CacheRow {
data: { items: FeedItem[] } data: { items: FeedItem[] }
updated_at: string updated_at: string
} }
export async function loadCachedFeed(db: PGlite): Promise<{ items: FeedItem[]; updatedAt: number } | null> { export async function loadCachedFeed(
const res = await db.query<CacheRow>("SELECT data, updated_at FROM feed_cache WHERE id = $1", [CACHE_KEY]) db: PGlite,
cacheKey = DEFAULT_CACHE_KEY,
): Promise<{ items: FeedItem[]; updatedAt: number } | null> {
const res = await db.query<CacheRow>("SELECT data, updated_at FROM feed_cache WHERE id = $1", [cacheKey])
if (res.rows.length === 0) return null if (res.rows.length === 0) return null
const row = res.rows[0] const row = res.rows[0]
return { return {
@@ -18,14 +21,14 @@ export async function loadCachedFeed(db: PGlite): Promise<{ items: FeedItem[]; u
} }
} }
export async function saveCachedFeed(db: PGlite, items: FeedItem[]): Promise<void> { export async function saveCachedFeed(db: PGlite, items: FeedItem[], cacheKey = DEFAULT_CACHE_KEY): Promise<void> {
await db.query( await db.query(
`INSERT INTO feed_cache (id, data, updated_at) VALUES ($1, $2, now()) `INSERT INTO feed_cache (id, data, updated_at) VALUES ($1, $2, now())
ON CONFLICT (id) DO UPDATE SET data = $2, updated_at = now()`, ON CONFLICT (id) DO UPDATE SET data = $2, updated_at = now()`,
[CACHE_KEY, JSON.stringify({ items })], [cacheKey, JSON.stringify({ items })],
) )
} }
export async function clearCachedFeed(db: PGlite): Promise<void> { export async function clearCachedFeed(db: PGlite, cacheKey = DEFAULT_CACHE_KEY): Promise<void> {
await db.query("DELETE FROM feed_cache WHERE id = $1", [CACHE_KEY]) await db.query("DELETE FROM feed_cache WHERE id = $1", [cacheKey])
} }

View File

@@ -165,6 +165,19 @@ export function fetchVotes(mandateID: number): Promise<Vote[]> {
return request("votes", { mandate: String(mandateID), range_end: "200" }, voteSchema) return request("votes", { mandate: String(mandateID), range_end: "200" }, voteSchema)
} }
export function fetchPollsByLegislature(legislatureId: number, rangeEnd = 150): Promise<Poll[]> {
return request(
"polls",
{
parliament_period: String(legislatureId),
range_end: String(rangeEnd),
sort_by: "field_poll_date",
sort_direction: "desc",
},
pollSchema,
)
}
export function fetchMandatesByParliamentPeriod(periodID: number): Promise<MandateWithPolitician[]> { export function fetchMandatesByParliamentPeriod(periodID: number): Promise<MandateWithPolitician[]> {
return request( return request(
"candidacies-mandates", "candidacies-mandates",

View File

@@ -1,7 +1,12 @@
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
export const BACKEND_URL = import.meta.env.VITE_BACKEND_URL ?? "https://serve.uber.space/agw-api" export const DIP_API_BASE = "https://search.dip.bundestag.de/api/v1"
export const DIP_API_KEY = "GmEPb1B.bfqJLIhcGAsH9fTJevTglhFpCoZyAAAdhp"
export const BUNDESTAG_LEGISLATURE_ID = 161
export const BUNDESTAG_WAHLPERIODE = 21
export const BACKEND_URL = import.meta.env.VITE_BACKEND_URL ?? "https://serve.uber.space/agw/api"
export const VAPID_PUBLIC_KEY = export const VAPID_PUBLIC_KEY =
import.meta.env.VITE_VAPID_PUBLIC_KEY ?? import.meta.env.VITE_VAPID_PUBLIC_KEY ??
"BBRownmmnmTqNCeiaKp_CzvfXXNhB6NG5LTXDQwnSDspGgdKaZv_0UoAo4Ify6HHqcl4kY3ABw7w6GYAqUHYcdQ" "BBRownmmnmTqNCeiaKp_CzvfXXNhB6NG5LTXDQwnSDspGgdKaZv_0UoAo4Ify6HHqcl4kY3ABw7w6GYAqUHYcdQ"

64
src/shared/lib/dip-api.ts Normal file
View File

@@ -0,0 +1,64 @@
import { z } from "zod"
import { BUNDESTAG_WAHLPERIODE, DIP_API_BASE, DIP_API_KEY } from "./constants"
const AW_API_TIMEOUT_MS = 20_000
// --- Zod Schemas ---
export const vorgangSchema = z.object({
id: z.number(),
titel: z.string(),
beratungsstand: z.string().nullable().optional(),
datum: z.string().nullable().optional(),
vorgangstyp: z.string().nullable().optional(),
sachgebiet: z.array(z.string()).nullable().optional(),
})
// --- Types ---
export type Vorgang = z.infer<typeof vorgangSchema>
// --- Fetch helper ---
async function dipRequest<T>(path: string, params: Record<string, string>, schema: z.ZodType<T>): Promise<T[]> {
const url = new URL(`${DIP_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",
Authorization: `ApiKey ${DIP_API_KEY}`,
},
})
} finally {
clearTimeout(timer)
}
if (!res.ok) {
const body = await res.text().catch(() => "")
throw new Error(`DIP API ${res.status} for ${url}: ${body}`)
}
const json = (await res.json()) as { documents: unknown[] }
return z.array(schema).parse(json.documents)
}
// --- Public API ---
export function fetchUpcomingVorgaenge(): Promise<Vorgang[]> {
return dipRequest(
"vorgang",
{
"f.beratungsstand": "Beschlussempfehlung liegt vor",
"f.vorgangstyp": "Gesetzgebung",
"f.wahlperiode": String(BUNDESTAG_WAHLPERIODE),
format: "json",
},
vorgangSchema,
)
}