diff --git a/AGENTS.md b/AGENTS.md index da8f639..a50e6ff 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,7 +12,7 @@ Abgeordnetenwatch PWA + Backend — a progressive web app that lets users follow |---|---| | Runtime | Bun | | 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) | | State | Zustand (ephemeral UI), PGlite (persistent data via IndexedDB) | | Validation | Zod | @@ -35,19 +35,28 @@ Abgeordnetenwatch PWA + Backend — a progressive web app that lets users follow ``` src/ PWA source -├── features/ Feature modules (feed, topics, politicians, location, settings) -│ └── / components/, hooks/, lib/, store.ts, index.ts +├── features/ Feature modules +│ ├── 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 -│ ├── 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 │ ├── 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 -│ ├── app/ Mobile routes (Konsta UI layout + tabbar) -│ └── index.tsx Redirect → /app/feed +│ ├── app/ Tabs: Home / Bundestag / Landtag / Einstellungen +│ │ ├── 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) ├── app.tsx RouterProvider -├── app.css Tailwind v4 + Konsta + shadcn theme +├── app.css Tailwind v4 + shadcn theme └── main.tsx Entry point server/ Backend source @@ -68,7 +77,7 @@ server/ Backend source - PWA: static files deploy to Uberspace at `/var/www/virtual//html/agw/` - 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 - Apache `.htaccess` handles SPA routing diff --git a/src/features/bundestag/components/bundestag-configure.tsx b/src/features/bundestag/components/bundestag-configure.tsx new file mode 100644 index 0000000..95b5805 --- /dev/null +++ b/src/features/bundestag/components/bundestag-configure.tsx @@ -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([]) + const [loadingMandates, setLoadingMandates] = useState(false) + + useEffect(() => { + setLoadingMandates(true) + fetchAndCacheBundestagMandates(db) + .then(setMandates) + .finally(() => setLoadingMandates(false)) + }, [db]) + + return ( +
+
+

Themen

+ +
+ +
+

+ Abgeordnete +

+ {loadingMandates ? ( +
+
+
+ ) : mandates.length > 0 ? ( + + ) : ( +

Keine Abgeordneten verfügbar.

+ )} +
+
+ ) +} diff --git a/src/features/bundestag/components/bundestag-feed.tsx b/src/features/bundestag/components/bundestag-feed.tsx new file mode 100644 index 0000000..45e1465 --- /dev/null +++ b/src/features/bundestag/components/bundestag-feed.tsx @@ -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 ( +
+ {lastUpdated && ( +
+ Aktualisiert {formatCacheAge(lastUpdated)} + +
+ )} + + {loading && !hasItems && ( + +
+ + )} + + {error && ( +
+

Fehler beim Laden

+

{error}

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

Dein Bundestag-Feed ist leer

+

+ Folge Themen oder Abgeordneten, um Abstimmungen hier zu sehen. +

+ + Bundestag konfigurieren + +
+ )} +
+ ) +} diff --git a/src/features/bundestag/hooks/use-bundestag-feed.ts b/src/features/bundestag/hooks/use-bundestag-feed.ts new file mode 100644 index 0000000..dc980d9 --- /dev/null +++ b/src/features/bundestag/hooks/use-bundestag-feed.ts @@ -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([]) + const [loading, setLoading] = useState(false) + const [refreshing, setRefreshing] = useState(false) + const [error, setError] = useState(null) + const [lastUpdated, setLastUpdated] = useState(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(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 | 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 } +} diff --git a/src/features/bundestag/index.ts b/src/features/bundestag/index.ts new file mode 100644 index 0000000..c286815 --- /dev/null +++ b/src/features/bundestag/index.ts @@ -0,0 +1,3 @@ +export { BundestagFeed } from "./components/bundestag-feed" +export { BundestagConfigure } from "./components/bundestag-configure" +export { useBundestagFeed } from "./hooks/use-bundestag-feed" diff --git a/src/features/bundestag/lib/assemble-bundestag-feed.ts b/src/features/bundestag/lib/assemble-bundestag-feed.ts new file mode 100644 index 0000000..e929cab --- /dev/null +++ b/src/features/bundestag/lib/assemble-bundestag-feed.ts @@ -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 { + 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() + 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 { + if (politicianIDs.length === 0) return [] + + const mandateResults = await Promise.all(politicianIDs.map((pid) => fetchCandidacyMandates(pid))) + const mandateIDs = mandateResults.flatMap((mandates) => mandates.slice(0, 3).map((m) => m.id)) + + const voteResults = await Promise.all(mandateIDs.map((mid) => fetchVotes(mid))) + const pollIDSet = new Set() + for (const votes of voteResults) { + for (const v of votes) { + if (v.poll?.id != null) pollIDSet.add(v.poll.id) + } + } + + return fetchPollsByIds(Array.from(pollIDSet)) +} diff --git a/src/features/bundestag/store.ts b/src/features/bundestag/store.ts new file mode 100644 index 0000000..7f2d38b --- /dev/null +++ b/src/features/bundestag/store.ts @@ -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((set) => ({ + topicSearch: "", + setTopicSearch: (query) => set({ topicSearch: query }), + politicianSearch: "", + setPoliticianSearch: (query) => set({ politicianSearch: query }), +})) diff --git a/src/features/feed/components/feed-item.tsx b/src/features/feed/components/feed-item.tsx index 3cd647a..9564599 100644 --- a/src/features/feed/components/feed-item.tsx +++ b/src/features/feed/components/feed-item.tsx @@ -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" function formatDate(iso: string | null): string { @@ -25,7 +25,7 @@ export function FeedItemCard({ item }: { item: FeedItemType }) {

{item.title}

)} {item.date && ( -
)} -

{item.source}

+
+

{item.source}

+ {item.kind === "vorgang" && ( + + {item.status === "upcoming" ? "In Beratung" : "Abgeschlossen"} + + )} +
) } diff --git a/src/features/feed/lib/assemble-feed.ts b/src/features/feed/lib/assemble-feed.ts index cfa99c4..b3aa9ed 100644 --- a/src/features/feed/lib/assemble-feed.ts +++ b/src/features/feed/lib/assemble-feed.ts @@ -14,7 +14,7 @@ export interface FeedTopicRef { export interface FeedItem { id: string - kind: "poll" + kind: "poll" | "vorgang" status: "upcoming" | "past" title: string url: string | null diff --git a/src/features/feed/lib/feed-cache.ts b/src/features/feed/lib/feed-cache.ts index 0f59a03..f16d70e 100644 --- a/src/features/feed/lib/feed-cache.ts +++ b/src/features/feed/lib/feed-cache.ts @@ -7,16 +7,16 @@ export interface FeedCacheData { updatedAt: number } -export async function loadFeedCache(db: PGlite): Promise { - return loadCachedFeed(db) +export async function loadFeedCache(db: PGlite, cacheKey?: string): Promise { + return loadCachedFeed(db, cacheKey) } -export async function saveFeedCache(db: PGlite, items: FeedItem[]): Promise { - await saveCachedFeed(db, items) +export async function saveFeedCache(db: PGlite, items: FeedItem[], cacheKey?: string): Promise { + await saveCachedFeed(db, items, cacheKey) } -export async function clearFeedCache(db: PGlite): Promise { - await clearCachedFeed(db) +export async function clearFeedCache(db: PGlite, cacheKey?: string): Promise { + await clearCachedFeed(db, cacheKey) } export function mergeFeedItems(cached: FeedItem[], fresh: FeedItem[]): FeedItem[] { diff --git a/src/features/home/components/home-page.tsx b/src/features/home/components/home-page.tsx new file mode 100644 index 0000000..7c81647 --- /dev/null +++ b/src/features/home/components/home-page.tsx @@ -0,0 +1,8 @@ +export function HomePage() { + return ( +
+

Willkommen

+

Verfolge Abstimmungen im Bundestag und deinem Landtag.

+
+ ) +} diff --git a/src/features/home/index.ts b/src/features/home/index.ts new file mode 100644 index 0000000..b4fafcb --- /dev/null +++ b/src/features/home/index.ts @@ -0,0 +1 @@ +export { HomePage } from "./components/home-page" diff --git a/src/features/landtag/components/landtag-configure.tsx b/src/features/landtag/components/landtag-configure.tsx new file mode 100644 index 0000000..e67350b --- /dev/null +++ b/src/features/landtag/components/landtag-configure.tsx @@ -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([]) + const [loaded, setLoaded] = useState(false) + + useEffect(() => { + loadCachedResult(db).then((cached) => { + if (cached) setMandates(cached.mandates) + setLoaded(true) + }) + }, [db]) + + if (loaded && mandates.length === 0) { + return ( +
+

+ Noch keine Abgeordneten geladen. Erkenne zuerst deinen Standort in den Einstellungen. +

+ + Zu den Einstellungen + +
+ ) + } + + return ( +
+
+

+ Abgeordnete +

+ {!loaded ? ( +
+
+
+ ) : ( + + )} +
+
+ ) +} diff --git a/src/features/landtag/components/landtag-page.tsx b/src/features/landtag/components/landtag-page.tsx new file mode 100644 index 0000000..c5b4e1f --- /dev/null +++ b/src/features/landtag/components/landtag-page.tsx @@ -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(null) + const [mandates, setMandates] = useState([]) + 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 ( +
+

Landtag

+

+ Erkenne zuerst deinen Standort in den Einstellungen, um deine Landtagsabgeordneten zu sehen. +

+ + Zu den Einstellungen + +
+ ) + } + + return ( +
+ {result.landtag_label && ( +
+

{result.landtag_label}

+ {result.bundesland &&

{result.bundesland}

} +
+ )} + + + +
+

Abstimmungsdaten folgen in Kürze

+
+
+ ) +} diff --git a/src/features/landtag/index.ts b/src/features/landtag/index.ts new file mode 100644 index 0000000..4bbbaf0 --- /dev/null +++ b/src/features/landtag/index.ts @@ -0,0 +1,2 @@ +export { LandtagPage } from "./components/landtag-page" +export { LandtagConfigure } from "./components/landtag-configure" diff --git a/src/features/landtag/store.ts b/src/features/landtag/store.ts new file mode 100644 index 0000000..c12ee64 --- /dev/null +++ b/src/features/landtag/store.ts @@ -0,0 +1,11 @@ +import { create } from "zustand" + +interface LandtagUIState { + politicianSearch: string + setPoliticianSearch: (query: string) => void +} + +export const useLandtagUI = create((set) => ({ + politicianSearch: "", + setPoliticianSearch: (query) => set({ politicianSearch: query }), +})) diff --git a/src/features/location/index.ts b/src/features/location/index.ts index c7ef2cd..5bc73ee 100644 --- a/src/features/location/index.ts +++ b/src/features/location/index.ts @@ -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 { getPartyMeta } from "./lib/parties" diff --git a/src/features/location/lib/geo.ts b/src/features/location/lib/geo.ts index 4b6fcc6..81dffb9 100644 --- a/src/features/location/lib/geo.ts +++ b/src/features/location/lib/geo.ts @@ -5,6 +5,7 @@ import { saveGeoCache, } from "@/shared/db/geo-cache-db" import { type MandateWithPolitician, fetchMandatesByParliamentPeriod } from "@/shared/lib/aw-api" +import { BUNDESTAG_LEGISLATURE_ID } from "@/shared/lib/constants" import type { PGlite } from "@electric-sql/pglite" const BUNDESLAND_TO_PARLIAMENT: Record = { @@ -56,6 +57,34 @@ export async function clearGeoCache(db: PGlite): Promise { await clearGeoCacheDb(db) } +const BUNDESTAG_CACHE_KEY = "Bundestag" + +/** Load cached Bundestag mandates from geo_cache. */ +export async function loadBundestagMandates(db: PGlite): Promise { + 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 { + 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) + } + + return mandates +} + export async function detectFromCoords(db: PGlite, lat: number, lon: number, skipCache = false): Promise { const url = `https://nominatim.openstreetmap.org/reverse?lat=${lat}&lon=${lon}&format=json` const res = await fetch(url, { diff --git a/src/features/politicians/components/politician-search.tsx b/src/features/politicians/components/politician-search.tsx deleted file mode 100644 index dd4f932..0000000 --- a/src/features/politicians/components/politician-search.tsx +++ /dev/null @@ -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() - 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(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 ( - <> - - -

- Noch keine Abgeordneten geladen. Erkenne zuerst deinen Standort in den Einstellungen. -

- - Zu den Einstellungen - -
- - ) - } - - return ( - <> - - - ) => setSearch(e.target.value)} - onClear={() => setSearch("")} - placeholder="Name filtern…" - /> - -
- {groups.map((group) => { - const meta = getPartyMeta(group.partyLabel) - return ( - -
- - {group.partyLabel} - {group.members.length} -
- - {group.members.map((m) => { - const followed = isFollowing("politician", m.politician.id) - const fn = mandateFunction(m) - return ( - - 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"} - - } - /> - ) - })} - -
- ) - })} - {groups.length === 0 && search && ( -

Keine Abgeordneten für „{search}"

- )} -
- - ) -} diff --git a/src/features/politicians/index.ts b/src/features/politicians/index.ts deleted file mode 100644 index 6d0b6d7..0000000 --- a/src/features/politicians/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { PoliticianSearch } from "./components/politician-search" diff --git a/src/features/politicians/store.ts b/src/features/politicians/store.ts deleted file mode 100644 index b010e79..0000000 --- a/src/features/politicians/store.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { create } from "zustand" - -interface PoliticiansUIState { - searchQuery: string - setSearchQuery: (query: string) => void -} - -export const usePoliticiansUI = create((set) => ({ - searchQuery: "", - setSearchQuery: (query) => set({ searchQuery: query }), -})) diff --git a/src/features/topics/components/topic-list.tsx b/src/features/topics/components/topic-list.tsx deleted file mode 100644 index 746641d..0000000 --- a/src/features/topics/components/topic-list.tsx +++ /dev/null @@ -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 ( - <> - - - ) => setSearch(e.target.value)} - onClear={() => setSearch("")} - placeholder="Themen suchen…" - /> - - {loading && ( -
- -
- )} - - {error && ( -
- {error} -
- )} - - - {filtered.map((topic) => { - const followed = isFollowing("topic", topic.id) - return ( - (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"} - - } - /> - ) - })} - - - ) -} diff --git a/src/features/topics/index.ts b/src/features/topics/index.ts deleted file mode 100644 index 0a4ebce..0000000 --- a/src/features/topics/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { TopicList } from "./components/topic-list" diff --git a/src/features/topics/store.ts b/src/features/topics/store.ts deleted file mode 100644 index 7807093..0000000 --- a/src/features/topics/store.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { create } from "zustand" - -interface TopicsUIState { - searchQuery: string - setSearchQuery: (query: string) => void -} - -export const useTopicsUI = create((set) => ({ - searchQuery: "", - setSearchQuery: (query) => set({ searchQuery: query }), -})) diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts new file mode 100644 index 0000000..0ca7240 --- /dev/null +++ b/src/routeTree.gen.ts @@ -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() diff --git a/src/routes/app/bundestag/configure.tsx b/src/routes/app/bundestag/configure.tsx new file mode 100644 index 0000000..c16f6e8 --- /dev/null +++ b/src/routes/app/bundestag/configure.tsx @@ -0,0 +1,6 @@ +import { BundestagConfigure } from "@/features/bundestag" +import { createFileRoute } from "@tanstack/react-router" + +export const Route = createFileRoute("/app/bundestag/configure")({ + component: BundestagConfigure, +}) diff --git a/src/routes/app/bundestag/index.tsx b/src/routes/app/bundestag/index.tsx new file mode 100644 index 0000000..023643a --- /dev/null +++ b/src/routes/app/bundestag/index.tsx @@ -0,0 +1,6 @@ +import { BundestagFeed } from "@/features/bundestag" +import { createFileRoute } from "@tanstack/react-router" + +export const Route = createFileRoute("/app/bundestag/")({ + component: BundestagFeed, +}) diff --git a/src/routes/app/bundestag/route.tsx b/src/routes/app/bundestag/route.tsx new file mode 100644 index 0000000..7367b05 --- /dev/null +++ b/src/routes/app/bundestag/route.tsx @@ -0,0 +1,5 @@ +import { Outlet, createFileRoute } from "@tanstack/react-router" + +export const Route = createFileRoute("/app/bundestag")({ + component: () => , +}) diff --git a/src/routes/app/feed.tsx b/src/routes/app/feed.tsx deleted file mode 100644 index 6c47cb0..0000000 --- a/src/routes/app/feed.tsx +++ /dev/null @@ -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 ( - <> - refresh({ silent: true })} - disabled={refreshing} - className="p-1.5 rounded-md hover:bg-muted transition-colors disabled:opacity-50" - aria-label="Feed aktualisieren" - > - - - ) : undefined - } - subtitle={lastUpdated ? `Aktualisiert ${formatCacheAge(lastUpdated)}` : undefined} - /> - -
- {/* Loading spinner — only when no cached items */} - {loading && !hasItems && ( - -
- - )} - - {/* Error */} - {error && ( -
-

Fehler beim Laden

-

{error}

-
- )} - - {/* Feed list */} - {hasItems && } -
- - ) -} - -export const Route = createFileRoute("/app/feed")({ - component: FeedPage, -}) diff --git a/src/routes/app/home.tsx b/src/routes/app/home.tsx new file mode 100644 index 0000000..844ae9c --- /dev/null +++ b/src/routes/app/home.tsx @@ -0,0 +1,6 @@ +import { HomePage } from "@/features/home" +import { createFileRoute } from "@tanstack/react-router" + +export const Route = createFileRoute("/app/home")({ + component: HomePage, +}) diff --git a/src/routes/app/landtag/configure.tsx b/src/routes/app/landtag/configure.tsx new file mode 100644 index 0000000..4236271 --- /dev/null +++ b/src/routes/app/landtag/configure.tsx @@ -0,0 +1,6 @@ +import { LandtagConfigure } from "@/features/landtag" +import { createFileRoute } from "@tanstack/react-router" + +export const Route = createFileRoute("/app/landtag/configure")({ + component: LandtagConfigure, +}) diff --git a/src/routes/app/landtag/index.tsx b/src/routes/app/landtag/index.tsx new file mode 100644 index 0000000..9e62088 --- /dev/null +++ b/src/routes/app/landtag/index.tsx @@ -0,0 +1,6 @@ +import { LandtagPage } from "@/features/landtag" +import { createFileRoute } from "@tanstack/react-router" + +export const Route = createFileRoute("/app/landtag/")({ + component: LandtagPage, +}) diff --git a/src/routes/app/landtag/route.tsx b/src/routes/app/landtag/route.tsx new file mode 100644 index 0000000..fa48780 --- /dev/null +++ b/src/routes/app/landtag/route.tsx @@ -0,0 +1,5 @@ +import { Outlet, createFileRoute } from "@tanstack/react-router" + +export const Route = createFileRoute("/app/landtag")({ + component: () => , +}) diff --git a/src/routes/app/politicians.tsx b/src/routes/app/politicians.tsx deleted file mode 100644 index 97a335c..0000000 --- a/src/routes/app/politicians.tsx +++ /dev/null @@ -1,6 +0,0 @@ -import { PoliticianSearch } from "@/features/politicians" -import { createFileRoute } from "@tanstack/react-router" - -export const Route = createFileRoute("/app/politicians")({ - component: PoliticianSearch, -}) diff --git a/src/routes/app/route.tsx b/src/routes/app/route.tsx index 24005a9..6c6295a 100644 --- a/src/routes/app/route.tsx +++ b/src/routes/app/route.tsx @@ -1,6 +1,5 @@ import { DbProvider } from "@/shared/db/provider" -import { Outlet, createFileRoute, useMatches, useNavigate } from "@tanstack/react-router" -import { App, Page, Tabbar, TabbarLink } from "konsta/react" +import { Link, Outlet, createFileRoute, useMatches, useNavigate } from "@tanstack/react-router" import { Suspense } from "react" interface TabDef { @@ -11,18 +10,18 @@ interface TabDef { const TABS: TabDef[] = [ { - to: "/app/feed", - label: "Feed", + to: "/app/home", + 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", }, { - to: "/app/topics", - label: "Themen", - 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", + to: "/app/landtag", + label: "Landtag", icon: "M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z", }, { @@ -36,7 +35,7 @@ function TabIcon({ d }: { d: string }) { return ( 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 ( - +
+
+ {isConfigureRoute && parentPath ? ( + + ) : null} +

+ {isConfigureRoute ? `${currentTab.label} konfigurieren` : currentTab.label} +

+ {!isConfigureRoute && configureTarget && ( + + + + )} +
+ -
-
-
- +
+
+
} > - +
- - {TABS.map((tab) => ( - { - const go = () => navigate({ to: tab.to }) - if ( - document.startViewTransition && - !window.matchMedia("(prefers-reduced-motion: reduce)").matches - ) { - document.startViewTransition(go) - } else { - go() - } - }} - icon={} - label={tab.label} - /> - ))} - - +
- + + +
) } diff --git a/src/routes/app/topics.tsx b/src/routes/app/topics.tsx deleted file mode 100644 index bc0c8bf..0000000 --- a/src/routes/app/topics.tsx +++ /dev/null @@ -1,6 +0,0 @@ -import { TopicList } from "@/features/topics" -import { createFileRoute } from "@tanstack/react-router" - -export const Route = createFileRoute("/app/topics")({ - component: TopicList, -}) diff --git a/src/routes/index.tsx b/src/routes/index.tsx index 386ee20..58cc292 100644 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -1,5 +1,5 @@ import { Navigate, createFileRoute } from "@tanstack/react-router" export const Route = createFileRoute("/")({ - component: () => , + component: () => , }) diff --git a/src/shared/components/representative-list.tsx b/src/shared/components/representative-list.tsx new file mode 100644 index 0000000..553f718 --- /dev/null +++ b/src/shared/components/representative-list.tsx @@ -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() + 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 ( +
+
+ onSearchChange(e.target.value)} + placeholder="Name filtern…" + type="search" + /> +
+ +
+ {groups.map((group) => { + const meta = getPartyMeta(group.partyLabel) + return ( + +
+ + {group.partyLabel} + {group.members.length} +
+ +
+ {group.members.map((m) => { + const followed = isFollowing("politician", m.politician.id) + const fn = mandateFunction(m) + return ( +
+
+

{m.politician.label}

+ {fn &&

{fn}

} +
+ +
+ ) + })} +
+
+
+ ) + })} + {groups.length === 0 && searchQuery && ( +

Keine Abgeordneten für „{searchQuery}"

+ )} +
+
+ ) +} diff --git a/src/shared/components/topic-toggle-list.tsx b/src/shared/components/topic-toggle-list.tsx new file mode 100644 index 0000000..0541f6b --- /dev/null +++ b/src/shared/components/topic-toggle-list.tsx @@ -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 ( +
+
+ onSearchChange(e.target.value)} + placeholder="Themen suchen…" + type="search" + /> +
+ + {loading && ( +
+
+
+ )} + + {error && ( +
+ {error} +
+ )} + +
+ {filtered.map((topic) => { + const followed = isFollowing("topic", topic.id) + return ( +
+ {topic.label} + +
+ ) + })} +
+
+ ) +} diff --git a/src/shared/db/feed-cache-db.ts b/src/shared/db/feed-cache-db.ts index 9ebd15d..4f94c83 100644 --- a/src/shared/db/feed-cache-db.ts +++ b/src/shared/db/feed-cache-db.ts @@ -1,15 +1,18 @@ import type { FeedItem } from "@/features/feed/lib/assemble-feed" import type { PGlite } from "@electric-sql/pglite" -const CACHE_KEY = "feed_items" +const DEFAULT_CACHE_KEY = "feed_items" interface CacheRow { data: { items: FeedItem[] } updated_at: string } -export async function loadCachedFeed(db: PGlite): Promise<{ items: FeedItem[]; updatedAt: number } | null> { - const res = await db.query("SELECT data, updated_at FROM feed_cache WHERE id = $1", [CACHE_KEY]) +export async function loadCachedFeed( + db: PGlite, + cacheKey = DEFAULT_CACHE_KEY, +): Promise<{ items: FeedItem[]; updatedAt: number } | null> { + const res = await db.query("SELECT data, updated_at FROM feed_cache WHERE id = $1", [cacheKey]) if (res.rows.length === 0) return null const row = res.rows[0] return { @@ -18,14 +21,14 @@ export async function loadCachedFeed(db: PGlite): Promise<{ items: FeedItem[]; u } } -export async function saveCachedFeed(db: PGlite, items: FeedItem[]): Promise { +export async function saveCachedFeed(db: PGlite, items: FeedItem[], cacheKey = DEFAULT_CACHE_KEY): Promise { await db.query( `INSERT INTO feed_cache (id, data, updated_at) VALUES ($1, $2, 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 { - await db.query("DELETE FROM feed_cache WHERE id = $1", [CACHE_KEY]) +export async function clearCachedFeed(db: PGlite, cacheKey = DEFAULT_CACHE_KEY): Promise { + await db.query("DELETE FROM feed_cache WHERE id = $1", [cacheKey]) } diff --git a/src/shared/lib/aw-api.ts b/src/shared/lib/aw-api.ts index d468c52..503079c 100644 --- a/src/shared/lib/aw-api.ts +++ b/src/shared/lib/aw-api.ts @@ -165,6 +165,19 @@ export function fetchVotes(mandateID: number): Promise { return request("votes", { mandate: String(mandateID), range_end: "200" }, voteSchema) } +export function fetchPollsByLegislature(legislatureId: number, rangeEnd = 150): Promise { + 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 { return request( "candidacies-mandates", diff --git a/src/shared/lib/constants.ts b/src/shared/lib/constants.ts index 884a662..e496452 100644 --- a/src/shared/lib/constants.ts +++ b/src/shared/lib/constants.ts @@ -1,7 +1,12 @@ export const AW_API_BASE = "https://www.abgeordnetenwatch.de/api/v2" 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 = import.meta.env.VITE_VAPID_PUBLIC_KEY ?? "BBRownmmnmTqNCeiaKp_CzvfXXNhB6NG5LTXDQwnSDspGgdKaZv_0UoAo4Ify6HHqcl4kY3ABw7w6GYAqUHYcdQ" diff --git a/src/shared/lib/dip-api.ts b/src/shared/lib/dip-api.ts new file mode 100644 index 0000000..e19559f --- /dev/null +++ b/src/shared/lib/dip-api.ts @@ -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 + +// --- Fetch helper --- + +async function dipRequest(path: string, params: Record, schema: z.ZodType): Promise { + 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 { + return dipRequest( + "vorgang", + { + "f.beratungsstand": "Beschlussempfehlung liegt vor", + "f.vorgangstyp": "Gesetzgebung", + "f.wahlperiode": String(BUNDESTAG_WAHLPERIODE), + format: "json", + }, + vorgangSchema, + ) +}