sort constituency representatives matching user's city to top, show "Dein Wahlkreis" badge

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-02 22:05:37 +01:00
parent 085a473047
commit 5337214d18
4 changed files with 61 additions and 13 deletions

View File

@@ -2,7 +2,7 @@ import { RepresentativeList } from "@/shared/components/representative-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 { fetchAndCacheBundestagMandates, loadCachedResult } from "../../location/lib/geo"
import { useBundestagUI } from "../store"
export function BundestagConfigure() {
@@ -11,12 +11,16 @@ export function BundestagConfigure() {
const setPoliticianSearch = useBundestagUI((s) => s.setPoliticianSearch)
const [mandates, setMandates] = useState<MandateWithPolitician[]>([])
const [userCity, setUserCity] = useState<string | null>(null)
const [loadingMandates, setLoadingMandates] = useState(false)
useEffect(() => {
setLoadingMandates(true)
fetchAndCacheBundestagMandates(db)
.then(setMandates)
Promise.all([fetchAndCacheBundestagMandates(db), loadCachedResult(db)])
.then(([m, geo]) => {
setMandates(m)
if (geo?.userCity) setUserCity(geo.userCity)
})
.finally(() => setLoadingMandates(false))
}, [db])
@@ -27,7 +31,12 @@ export function BundestagConfigure() {
<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} />
<RepresentativeList
mandates={mandates}
userCity={userCity}
searchQuery={politicianSearch}
onSearchChange={setPoliticianSearch}
/>
) : (
<p className="px-4 py-6 text-sm text-muted-foreground text-center">Keine Abgeordneten verfügbar.</p>
)}

View File

@@ -11,11 +11,15 @@ export function LandtagConfigure() {
const search = useLandtagUI((s) => s.politicianSearch)
const setSearch = useLandtagUI((s) => s.setPoliticianSearch)
const [mandates, setMandates] = useState<MandateWithPolitician[]>([])
const [userCity, setUserCity] = useState<string | null>(null)
const [loaded, setLoaded] = useState(false)
useEffect(() => {
loadCachedResult(db).then((cached) => {
if (cached) setMandates(cached.mandates)
if (cached) {
setMandates(cached.mandates)
setUserCity(cached.userCity)
}
setLoaded(true)
})
}, [db])
@@ -40,7 +44,7 @@ export function LandtagConfigure() {
<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} />
<RepresentativeList mandates={mandates} userCity={userCity} searchQuery={search} onSearchChange={setSearch} />
)}
</div>
)

View File

@@ -31,6 +31,11 @@ export { BUNDESLAND_TO_PARLIAMENT }
interface NominatimAddress {
state?: string
city?: string
town?: string
village?: string
county?: string
suburb?: string
}
interface NominatimResponse {
@@ -39,6 +44,7 @@ interface NominatimResponse {
export interface GeoResult {
bundesland: string | null
userCity: string | null
landtag_label: string | null
landtag_parliament_period_id: number | null
mandates: MandateWithPolitician[]
@@ -94,10 +100,18 @@ export async function detectFromCoords(db: PGlite, lat: number, lon: number, ski
const data = (await res.json()) as NominatimResponse
const state = data.address?.state ?? null
const userCity = data.address?.city ?? data.address?.town ?? data.address?.village ?? data.address?.county ?? null
const entry = state ? (BUNDESLAND_TO_PARLIAMENT[state] ?? null) : null
if (!state || !entry) {
return { bundesland: state, landtag_label: null, landtag_parliament_period_id: null, mandates: [], cachedAt: null }
return {
bundesland: state,
userCity,
landtag_label: null,
landtag_parliament_period_id: null,
mandates: [],
cachedAt: null,
}
}
if (!skipCache) {
@@ -114,6 +128,7 @@ export async function detectFromCoords(db: PGlite, lat: number, lon: number, ski
const result: GeoResult = {
bundesland: state,
userCity,
landtag_label: entry.label,
landtag_parliament_period_id: entry.parliamentPeriodId,
mandates,

View File

@@ -18,7 +18,14 @@ function partyLabel(m: MandateWithPolitician): string {
return "parteilos"
}
export function groupByParty(mandates: MandateWithPolitician[]): PartyGroup[] {
/** Check if a mandate's constituency label contains the user's city name. */
function isLocalConstituency(m: MandateWithPolitician, city: string): boolean {
const label = m.electoral_data?.constituency?.label
if (!label) return false
return label.toLowerCase().includes(city.toLowerCase())
}
export function groupByParty(mandates: MandateWithPolitician[], userCity?: string | null): PartyGroup[] {
const map = new Map<string, MandateWithPolitician[]>()
for (const m of mandates) {
const key = partyLabel(m)
@@ -33,7 +40,14 @@ export function groupByParty(mandates: MandateWithPolitician[]): PartyGroup[] {
.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)),
members: members.sort((a, b) => {
if (userCity) {
const aLocal = isLocalConstituency(a, userCity) ? 0 : 1
const bLocal = isLocalConstituency(b, userCity) ? 0 : 1
if (aLocal !== bLocal) return aLocal - bLocal
}
return a.politician.label.localeCompare(b.politician.label)
}),
}))
}
@@ -52,18 +66,18 @@ export function mandateFunction(m: MandateWithPolitician): string | null {
interface RepresentativeListProps {
mandates: MandateWithPolitician[]
userBundesland?: string | null
userCity?: string | null
searchQuery: string
onSearchChange: (query: string) => void
}
export function RepresentativeList({ mandates, searchQuery, onSearchChange }: RepresentativeListProps) {
export function RepresentativeList({ mandates, userCity, 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)
const groups = groupByParty(filtered, userCity)
return (
<div className="pb-4">
@@ -100,11 +114,17 @@ export function RepresentativeList({ mandates, searchQuery, onSearchChange }: Re
{group.members.map((m) => {
const followed = isFollowing("politician", m.politician.id)
const fn = mandateFunction(m)
const local = userCity ? isLocalConstituency(m, userCity) : false
return (
<div key={m.id} className="flex items-center justify-between px-4 py-2.5">
<div>
<p className="text-sm font-medium">{m.politician.label}</p>
{fn && <p className="text-xs text-muted-foreground">{fn}</p>}
{fn && (
<p className="text-xs text-muted-foreground">
{fn}
{local && <span className="ml-1.5 text-primary font-medium"> Dein Wahlkreis</span>}
</p>
)}
</div>
<Button
size="sm"