diff --git a/src/features/feed/hooks/use-feed.ts b/src/features/feed/hooks/use-feed.ts index 55d6d1c..5c9f839 100644 --- a/src/features/feed/hooks/use-feed.ts +++ b/src/features/feed/hooks/use-feed.ts @@ -1,3 +1,4 @@ +import { useDb } from "@/shared/db/provider" import { useFollows } from "@/shared/hooks/use-follows" import { useCallback, useEffect, useMemo, useRef, useState } from "react" import { type FeedItem, assembleFeed } from "../lib/assemble-feed" @@ -6,34 +7,40 @@ import { loadFeedCache, mergeFeedItems, saveFeedCache } from "../lib/feed-cache" const REFRESH_INTERVAL_MS = 60 * 60 * 1000 // 1 hour export function useFeed() { + const db = useDb() const { follows } = useFollows() - const [items, setItems] = useState(() => { - const cached = loadFeedCache() - return cached ? cached.items : [] - }) + const [items, setItems] = useState([]) const [loading, setLoading] = useState(false) const [refreshing, setRefreshing] = useState(false) const [error, setError] = useState(null) - const [lastUpdated, setLastUpdated] = useState(() => { - const cached = loadFeedCache() - return cached ? cached.updatedAt : 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).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) { - // no follows — keep cached items visible - return - } + if (topicIDs.length === 0 && politicianIDs.length === 0) return refreshingRef.current = true if (silent) { @@ -47,10 +54,13 @@ export function useFeed() { const fresh = await assembleFeed(topicIDs, politicianIDs) setItems((prev) => { const merged = mergeFeedItems(prev, fresh) - saveFeedCache(merged) + saveFeedCache(db, merged) + hasItemsRef.current = merged.length > 0 return merged }) - setLastUpdated(Date.now()) + const now = Date.now() + setLastUpdated(now) + lastUpdatedRef.current = now } catch (e) { setError(String(e)) } finally { @@ -59,21 +69,19 @@ export function useFeed() { refreshingRef.current = false } }, - [topicIDs, politicianIDs], + [db, topicIDs, politicianIDs], ) - // initial fetch on mount / when follows change + // Initial fetch / refresh when follows change useEffect(() => { - const cached = loadFeedCache() - if (cached && cached.items.length > 0) { - // have cached data — do a silent refresh + if (hasItemsRef.current) { refresh({ silent: true }) } else { refresh() } }, [refresh]) - // auto-refresh interval + visibility API + // Auto-refresh interval + visibility API useEffect(() => { let intervalId: ReturnType | null = null @@ -95,9 +103,7 @@ export function useFeed() { if (document.hidden) { stopInterval() } else { - // refresh immediately if stale - const cached = loadFeedCache() - if (!cached || Date.now() - cached.updatedAt >= REFRESH_INTERVAL_MS) { + if (!lastUpdatedRef.current || Date.now() - lastUpdatedRef.current >= REFRESH_INTERVAL_MS) { refresh({ silent: true }) } startInterval() diff --git a/src/features/feed/lib/assemble-feed.test.ts b/src/features/feed/lib/assemble-feed.test.ts new file mode 100644 index 0000000..595d445 --- /dev/null +++ b/src/features/feed/lib/assemble-feed.test.ts @@ -0,0 +1,119 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" +import { assembleFeed } from "./assemble-feed" + +const mockFetch = vi.fn() + +beforeEach(() => { + vi.stubGlobal("fetch", mockFetch) +}) + +afterEach(() => { + vi.restoreAllMocks() +}) + +function okResponse(data: unknown) { + return new Response(JSON.stringify({ data }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }) +} + +const TOPICS = [ + { id: 1, label: "Umwelt", abgeordnetenwatch_url: "https://www.abgeordnetenwatch.de/themen-dip21/umwelt" }, + { id: 2, label: "Bildung" }, + { id: 3, label: "Wirtschaft" }, +] + +const POLLS = [ + { + id: 100, + label: "Klimaschutzgesetz", + abgeordnetenwatch_url: "https://www.abgeordnetenwatch.de/bundestag/21/abstimmungen/klimaschutzgesetz", + field_poll_date: "2024-06-15", + field_topics: [ + { id: 1, label: "Umwelt", abgeordnetenwatch_url: "https://www.abgeordnetenwatch.de/themen-dip21/umwelt" }, + ], + }, + { id: 101, label: "Schulreform", field_poll_date: "2024-06-10", field_topics: [{ id: 2 }] }, + { id: 102, label: "Steuerreform", field_poll_date: "2024-06-05", field_topics: [{ id: 3 }] }, +] + +describe("assembleFeed", () => { + it("returns empty feed when no follows", async () => { + mockFetch.mockResolvedValueOnce(okResponse(TOPICS)).mockResolvedValueOnce(okResponse(POLLS)) + + const feed = await assembleFeed([], []) + expect(feed).toEqual([]) + }) + + it("filters polls by followed topic IDs", async () => { + mockFetch.mockResolvedValueOnce(okResponse(TOPICS)).mockResolvedValueOnce(okResponse(POLLS)) + + const feed = await assembleFeed([1, 2], []) + expect(feed).toHaveLength(2) + expect(feed[0].title).toBe("Klimaschutzgesetz") + expect(feed[1].title).toBe("Schulreform") + }) + + it("sorts by date descending", async () => { + mockFetch.mockResolvedValueOnce(okResponse(TOPICS)).mockResolvedValueOnce(okResponse(POLLS)) + + const feed = await assembleFeed([1, 2, 3], []) + expect(feed[0].date).toBe("2024-06-15") + expect(feed[1].date).toBe("2024-06-10") + expect(feed[2].date).toBe("2024-06-05") + }) + + it("includes topic labels and URLs", async () => { + mockFetch.mockResolvedValueOnce(okResponse(TOPICS)).mockResolvedValueOnce(okResponse(POLLS)) + + const feed = await assembleFeed([1], []) + expect(feed[0].topics).toEqual([{ label: "Umwelt", url: "https://www.abgeordnetenwatch.de/themen-dip21/umwelt" }]) + expect(feed[0].url).toBe("https://www.abgeordnetenwatch.de/bundestag/21/abstimmungen/klimaschutzgesetz") + }) + + it("fetches polls for followed politicians", async () => { + mockFetch + .mockResolvedValueOnce(okResponse(TOPICS)) + .mockResolvedValueOnce(okResponse(POLLS)) + .mockResolvedValueOnce(okResponse([{ id: 500 }])) + .mockResolvedValueOnce(okResponse([{ id: 900, poll: { id: 100 } }])) + .mockResolvedValueOnce( + okResponse([ + { + id: 100, + label: "Klimaschutzgesetz", + field_poll_date: "2024-06-15", + field_topics: [{ id: 1 }], + }, + ]), + ) + + const feed = await assembleFeed([], [42]) + expect(feed).toHaveLength(1) + expect(feed[0].title).toBe("Klimaschutzgesetz") + }) + + it("deduplicates polls appearing from both topics and politicians", async () => { + mockFetch + .mockResolvedValueOnce(okResponse(TOPICS)) + .mockResolvedValueOnce(okResponse(POLLS)) + .mockResolvedValueOnce(okResponse([{ id: 500 }])) + .mockResolvedValueOnce(okResponse([{ id: 900, poll: { id: 100 } }])) + .mockResolvedValueOnce( + okResponse([ + { + id: 100, + label: "Klimaschutzgesetz", + field_poll_date: "2024-06-15", + field_topics: [{ id: 1 }], + }, + ]), + ) + + const feed = await assembleFeed([1], [42]) + const ids = feed.map((f) => f.id) + const unique = new Set(ids) + expect(ids.length).toBe(unique.size) + }) +}) diff --git a/src/features/feed/lib/assemble-feed.ts b/src/features/feed/lib/assemble-feed.ts new file mode 100644 index 0000000..cfa99c4 --- /dev/null +++ b/src/features/feed/lib/assemble-feed.ts @@ -0,0 +1,83 @@ +import { + type Poll, + fetchCandidacyMandates, + fetchPolls, + fetchPollsByIds, + fetchTopics, + fetchVotes, +} from "@/shared/lib/aw-api" + +export interface FeedTopicRef { + label: string + url: string | null +} + +export interface FeedItem { + id: string + kind: "poll" + status: "upcoming" | "past" + title: string + url: string | null + date: string | null + topics: FeedTopicRef[] + source: string +} + +function classifyPoll(poll: Poll): "upcoming" | "past" { + // field_accepted is only present on completed votes + 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" +} + +export async function assembleFeed(followedTopicIDs: number[], followedPoliticianIDs: number[]): Promise { + const [topics, polls] = await Promise.all([fetchTopics(), fetchPolls(150)]) + const topicMap = new Map(topics.map((t) => [t.id, t.label])) + + 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 items: 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", + })) + + 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) + }) +} + +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/feed/lib/feed-cache.test.ts b/src/features/feed/lib/feed-cache.test.ts new file mode 100644 index 0000000..bf3fa83 --- /dev/null +++ b/src/features/feed/lib/feed-cache.test.ts @@ -0,0 +1,71 @@ +import type { FeedItem } from "./assemble-feed" +import { clearFeedCache, loadFeedCache, mergeFeedItems, saveFeedCache } from "./feed-cache" + +function makeItem(id: string, date: string | null = "2025-01-15", title = `Poll ${id}`): FeedItem { + return { id, kind: "poll", status: "past", title, url: null, date, topics: [], source: "Bundestag" } +} + +beforeEach(() => localStorage.clear()) + +describe("feed cache persistence", () => { + it("returns null when no cache exists", () => { + expect(loadFeedCache()).toBeNull() + }) + + it("round-trips save and load", () => { + const items = [makeItem("poll-1"), makeItem("poll-2")] + saveFeedCache(items) + const loaded = loadFeedCache() + expect(loaded).not.toBeNull() + expect(loaded?.items).toEqual(items) + expect(typeof loaded?.updatedAt).toBe("number") + }) + + it("clears cache", () => { + saveFeedCache([makeItem("poll-1")]) + clearFeedCache() + expect(loadFeedCache()).toBeNull() + }) + + it("returns null for corrupted data", () => { + localStorage.setItem("agw_feed_cache", "not-json") + expect(loadFeedCache()).toBeNull() + }) + + it("returns null for missing items array", () => { + localStorage.setItem("agw_feed_cache", JSON.stringify({ updatedAt: 123 })) + expect(loadFeedCache()).toBeNull() + }) +}) + +describe("mergeFeedItems", () => { + it("keeps old items and adds new ones", () => { + const cached = [makeItem("poll-1", "2025-01-10"), makeItem("poll-2", "2025-01-11")] + const fresh = [makeItem("poll-3", "2025-01-12")] + const merged = mergeFeedItems(cached, fresh) + expect(merged).toHaveLength(3) + expect(merged.map((i) => i.id)).toContain("poll-1") + expect(merged.map((i) => i.id)).toContain("poll-3") + }) + + it("deduplicates by id, preferring fresh", () => { + const cached = [makeItem("poll-1", "2025-01-10", "Old Title")] + const fresh = [makeItem("poll-1", "2025-01-10", "New Title")] + const merged = mergeFeedItems(cached, fresh) + expect(merged).toHaveLength(1) + expect(merged[0].title).toBe("New Title") + }) + + it("sorts by date descending", () => { + const cached = [makeItem("poll-1", "2025-01-01")] + const fresh = [makeItem("poll-2", "2025-01-15"), makeItem("poll-3", "2025-01-10")] + const merged = mergeFeedItems(cached, fresh) + expect(merged.map((i) => i.date)).toEqual(["2025-01-15", "2025-01-10", "2025-01-01"]) + }) + + it("sorts null dates after dated items", () => { + const items = mergeFeedItems([makeItem("poll-1", null, "Zebra")], [makeItem("poll-2", "2025-01-01")]) + expect(items[0].id).toBe("poll-2") + expect(items[1].id).toBe("poll-1") + }) +}) diff --git a/src/features/feed/lib/feed-cache.ts b/src/features/feed/lib/feed-cache.ts new file mode 100644 index 0000000..0f59a03 --- /dev/null +++ b/src/features/feed/lib/feed-cache.ts @@ -0,0 +1,33 @@ +import { clearCachedFeed, loadCachedFeed, saveCachedFeed } from "@/shared/db/feed-cache-db" +import type { PGlite } from "@electric-sql/pglite" +import type { FeedItem } from "./assemble-feed" + +export interface FeedCacheData { + items: FeedItem[] + updatedAt: number +} + +export async function loadFeedCache(db: PGlite): Promise { + return loadCachedFeed(db) +} + +export async function saveFeedCache(db: PGlite, items: FeedItem[]): Promise { + await saveCachedFeed(db, items) +} + +export async function clearFeedCache(db: PGlite): Promise { + await clearCachedFeed(db) +} + +export function mergeFeedItems(cached: FeedItem[], fresh: FeedItem[]): FeedItem[] { + const map = new Map() + for (const item of cached) map.set(item.id, item) + for (const item of fresh) map.set(item.id, item) + + return Array.from(map.values()).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) + }) +} diff --git a/src/features/location/lib/geo.test.ts b/src/features/location/lib/geo.test.ts new file mode 100644 index 0000000..045f507 --- /dev/null +++ b/src/features/location/lib/geo.test.ts @@ -0,0 +1,142 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" +import { BUNDESLAND_TO_PARLIAMENT, clearGeoCache, detectFromCoords, loadCachedResult } from "./geo" + +const mockFetch = vi.fn() + +beforeEach(() => { + vi.stubGlobal("fetch", mockFetch) + localStorage.clear() +}) + +afterEach(() => { + vi.restoreAllMocks() +}) + +describe("BUNDESLAND_TO_PARLIAMENT", () => { + it("contains all 16 Bundesländer", () => { + expect(Object.keys(BUNDESLAND_TO_PARLIAMENT)).toHaveLength(16) + }) + + it("maps Bayern correctly", () => { + expect(BUNDESLAND_TO_PARLIAMENT.Bayern).toEqual({ + label: "Bayerischer Landtag", + parliamentPeriodId: 149, + }) + }) + + it("maps Nordrhein-Westfalen correctly", () => { + expect(BUNDESLAND_TO_PARLIAMENT["Nordrhein-Westfalen"]).toEqual({ + label: "Landtag Nordrhein-Westfalen", + parliamentPeriodId: 139, + }) + }) +}) + +const MANDATE_RESPONSE = [ + { + id: 500, + politician: { id: 1, label: "Max Mustermann" }, + party: { id: 10, label: "CSU" }, + electoral_data: { constituency: { label: "217 - München-Ost (BT 2025)" }, mandate_won: "constituency" }, + }, +] + +describe("detectFromCoords", () => { + it("returns bundesland, landtag info, and mandates for valid coordinates", async () => { + mockFetch + .mockResolvedValueOnce(new Response(JSON.stringify({ address: { state: "Bayern" } }), { status: 200 })) + .mockResolvedValueOnce(new Response(JSON.stringify({ data: MANDATE_RESPONSE }), { status: 200 })) + + const result = await detectFromCoords(48.1351, 11.582) + expect(result.bundesland).toBe("Bayern") + expect(result.landtag_label).toBe("Bayerischer Landtag") + expect(result.landtag_parliament_period_id).toBe(149) + expect(result.mandates).toHaveLength(1) + expect(result.mandates[0].electoral_data?.mandate_won).toBe("constituency") + }) + + it("returns cached result on second call for same Bundesland", async () => { + mockFetch + .mockResolvedValueOnce(new Response(JSON.stringify({ address: { state: "Bayern" } }), { status: 200 })) + .mockResolvedValueOnce(new Response(JSON.stringify({ data: MANDATE_RESPONSE }), { status: 200 })) + + await detectFromCoords(48.1351, 11.582) + + mockFetch.mockResolvedValueOnce(new Response(JSON.stringify({ address: { state: "Bayern" } }), { status: 200 })) + + const result = await detectFromCoords(48.2, 11.6) + expect(result.mandates).toHaveLength(1) + expect(mockFetch).toHaveBeenCalledTimes(3) + }) + + it("skips cache when skipCache=true", async () => { + mockFetch + .mockResolvedValueOnce(new Response(JSON.stringify({ address: { state: "Bayern" } }), { status: 200 })) + .mockResolvedValueOnce(new Response(JSON.stringify({ data: MANDATE_RESPONSE }), { status: 200 })) + + await detectFromCoords(48.1351, 11.582) + + mockFetch + .mockResolvedValueOnce(new Response(JSON.stringify({ address: { state: "Bayern" } }), { status: 200 })) + .mockResolvedValueOnce(new Response(JSON.stringify({ data: MANDATE_RESPONSE }), { status: 200 })) + + await detectFromCoords(48.2, 11.6, true) + // 4 fetches: Nominatim + mandates + Nominatim + mandates (cache skipped) + expect(mockFetch).toHaveBeenCalledTimes(4) + }) + + it("returns null for unknown state", async () => { + mockFetch.mockResolvedValueOnce(new Response(JSON.stringify({ address: { state: "Unknown" } }), { status: 200 })) + + const result = await detectFromCoords(0, 0) + expect(result.bundesland).toBe("Unknown") + expect(result.landtag_label).toBeNull() + expect(result.mandates).toEqual([]) + }) + + it("returns null bundesland when address has no state", async () => { + mockFetch.mockResolvedValueOnce(new Response(JSON.stringify({ address: {} }), { status: 200 })) + + const result = await detectFromCoords(0, 0) + expect(result.bundesland).toBeNull() + expect(result.mandates).toEqual([]) + }) + + it("throws on nominatim error", async () => { + mockFetch.mockResolvedValueOnce(new Response("error", { status: 500 })) + await expect(detectFromCoords(0, 0)).rejects.toThrow("Nominatim error 500") + }) +}) + +describe("loadCachedResult", () => { + it("returns null when no cache exists", () => { + expect(loadCachedResult()).toBeNull() + }) + + it("returns cached result after a successful detect", async () => { + mockFetch + .mockResolvedValueOnce(new Response(JSON.stringify({ address: { state: "Bayern" } }), { status: 200 })) + .mockResolvedValueOnce(new Response(JSON.stringify({ data: MANDATE_RESPONSE }), { status: 200 })) + + await detectFromCoords(48.1351, 11.582) + + const cached = loadCachedResult() + expect(cached).not.toBeNull() + expect(cached?.bundesland).toBe("Bayern") + expect(cached?.mandates).toHaveLength(1) + }) +}) + +describe("clearGeoCache", () => { + it("removes cached results", async () => { + mockFetch + .mockResolvedValueOnce(new Response(JSON.stringify({ address: { state: "Bayern" } }), { status: 200 })) + .mockResolvedValueOnce(new Response(JSON.stringify({ data: MANDATE_RESPONSE }), { status: 200 })) + + await detectFromCoords(48.1351, 11.582) + expect(loadCachedResult()).not.toBeNull() + + clearGeoCache() + expect(loadCachedResult()).toBeNull() + }) +}) diff --git a/src/features/location/lib/geo.ts b/src/features/location/lib/geo.ts new file mode 100644 index 0000000..4b6fcc6 --- /dev/null +++ b/src/features/location/lib/geo.ts @@ -0,0 +1,100 @@ +import { + clearGeoCache as clearGeoCacheDb, + loadGeoCache, + loadMostRecentGeoCache, + saveGeoCache, +} from "@/shared/db/geo-cache-db" +import { type MandateWithPolitician, fetchMandatesByParliamentPeriod } from "@/shared/lib/aw-api" +import type { PGlite } from "@electric-sql/pglite" + +const BUNDESLAND_TO_PARLIAMENT: Record = { + "Baden-Württemberg": { label: "Landtag Baden-Württemberg", parliamentPeriodId: 163 }, + Bayern: { label: "Bayerischer Landtag", parliamentPeriodId: 149 }, + Berlin: { label: "Abgeordnetenhaus Berlin", parliamentPeriodId: 133 }, + Brandenburg: { label: "Landtag Brandenburg", parliamentPeriodId: 158 }, + Bremen: { label: "Bremische Bürgerschaft", parliamentPeriodId: 146 }, + Hamburg: { label: "Hamburgische Bürgerschaft", parliamentPeriodId: 162 }, + Hessen: { label: "Hessischer Landtag", parliamentPeriodId: 150 }, + "Mecklenburg-Vorpommern": { label: "Landtag Mecklenburg-Vorpommern", parliamentPeriodId: 134 }, + Niedersachsen: { label: "Niedersächsischer Landtag", parliamentPeriodId: 143 }, + "Nordrhein-Westfalen": { label: "Landtag Nordrhein-Westfalen", parliamentPeriodId: 139 }, + "Rheinland-Pfalz": { label: "Landtag Rheinland-Pfalz", parliamentPeriodId: 164 }, + Saarland: { label: "Landtag des Saarlandes", parliamentPeriodId: 137 }, + Sachsen: { label: "Sächsischer Landtag", parliamentPeriodId: 157 }, + "Sachsen-Anhalt": { label: "Landtag Sachsen-Anhalt", parliamentPeriodId: 131 }, + "Schleswig-Holstein": { label: "Schleswig-Holsteinischer Landtag", parliamentPeriodId: 138 }, + Thüringen: { label: "Thüringer Landtag", parliamentPeriodId: 156 }, +} + +export { BUNDESLAND_TO_PARLIAMENT } + +interface NominatimAddress { + state?: string +} + +interface NominatimResponse { + address?: NominatimAddress +} + +export interface GeoResult { + bundesland: string | null + landtag_label: string | null + landtag_parliament_period_id: number | null + mandates: MandateWithPolitician[] + cachedAt: number | null +} + +/** Return the most recent cached result (any Bundesland), or null. */ +export async function loadCachedResult(db: PGlite): Promise { + const cached = await loadMostRecentGeoCache(db) + if (!cached) return null + return cached.data as unknown as GeoResult +} + +/** Clear all cached geo results. */ +export async function clearGeoCache(db: PGlite): Promise { + await clearGeoCacheDb(db) +} + +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, { + headers: { "User-Agent": "AbgeordnetenwatchPWA/1.0" }, + }) + if (!res.ok) throw new Error(`Nominatim error ${res.status}`) + const data = (await res.json()) as NominatimResponse + + const state = data.address?.state ?? 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 } + } + + if (!skipCache) { + const cached = await loadGeoCache(db, state) + if (cached) return cached as unknown as GeoResult + } + + let mandates: MandateWithPolitician[] = [] + try { + mandates = await fetchMandatesByParliamentPeriod(entry.parliamentPeriodId) + } catch { + mandates = [] + } + + const result: GeoResult = { + bundesland: state, + landtag_label: entry.label, + landtag_parliament_period_id: entry.parliamentPeriodId, + mandates, + cachedAt: null, + } + + if (mandates.length > 0) { + await saveGeoCache(db, state, result as unknown as Record) + result.cachedAt = Date.now() + } + + return result +} diff --git a/src/features/location/lib/parties.ts b/src/features/location/lib/parties.ts new file mode 100644 index 0000000..5884daa --- /dev/null +++ b/src/features/location/lib/parties.ts @@ -0,0 +1,35 @@ +interface PartyMeta { + short: string + color: string +} + +// Colors based on official party CI +const PARTY_META: Record = { + CDU: { short: "CDU", color: "#000000" }, + CSU: { short: "CSU", color: "#008ac5" }, + "CDU/CSU": { short: "CDU", color: "#000000" }, + SPD: { short: "SPD", color: "#e3000f" }, + "BÜNDNIS 90/DIE GRÜNEN": { short: "Grüne", color: "#46962b" }, + GRÜNE: { short: "Grüne", color: "#46962b" }, + FDP: { short: "FDP", color: "#ffed00" }, + AfD: { short: "AfD", color: "#009ee0" }, + "Die Linke": { short: "Linke", color: "#be3075" }, + BSW: { short: "BSW", color: "#572b81" }, + SSW: { short: "SSW", color: "#003c8f" }, + "Freie Wähler": { short: "FW", color: "#f08e1a" }, + "FREIE WÄHLER": { short: "FW", color: "#f08e1a" }, + Volt: { short: "Volt", color: "#502379" }, + ÖDP: { short: "ÖDP", color: "#ff6600" }, + Fraktionslos: { short: "FL", color: "#999999" }, + parteilos: { short: "PL", color: "#999999" }, +} + +/** Strip soft hyphens and normalize for lookup. */ +function normalize(label: string): string { + return label.replace(/\u00ad/g, "") +} + +export function getPartyMeta(partyLabel: string): PartyMeta { + const clean = normalize(partyLabel) + return PARTY_META[clean] ?? PARTY_META[partyLabel] ?? { short: clean.slice(0, 5), color: "#6b7280" } +} diff --git a/src/features/politicians/components/politician-search.tsx b/src/features/politicians/components/politician-search.tsx index b588a2e..23579f6 100644 --- a/src/features/politicians/components/politician-search.tsx +++ b/src/features/politicians/components/politician-search.tsx @@ -1,3 +1,4 @@ +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" @@ -52,14 +53,16 @@ function mandateFunction(m: MandateWithPolitician): string | null { } export function PoliticianSearch() { + const db = useDb() const [result, setResult] = useState(null) const [search, setSearch] = useState("") const { isFollowing, follow, unfollow } = useFollows() useEffect(() => { - const cached = loadCachedResult() - if (cached) setResult(cached) - }, []) + loadCachedResult(db).then((cached) => { + if (cached) setResult(cached) + }) + }, [db]) const groups = useMemo(() => { if (!result) return [] diff --git a/src/features/settings/components/settings-page.tsx b/src/features/settings/components/settings-page.tsx index eaf5f3b..8a401bc 100644 --- a/src/features/settings/components/settings-page.tsx +++ b/src/features/settings/components/settings-page.tsx @@ -1,3 +1,4 @@ +import { useDb } from "@/shared/db/provider" import { useDeviceId } from "@/shared/hooks/use-device-id" import { useFollows } from "@/shared/hooks/use-follows" import { usePush } from "@/shared/hooks/use-push" @@ -28,6 +29,7 @@ function isStandalone(): boolean { } export function SettingsPage() { + const db = useDb() const deviceId = useDeviceId() const { needRefresh, checkForUpdate, applyUpdate } = usePwaUpdate() const push = usePush() @@ -43,37 +45,41 @@ export function SettingsPage() { const [devPoliticians, setDevPoliticians] = useState(null) useEffect(() => { - const cached = loadCachedResult() - if (cached) setResult(cached) - }, []) + loadCachedResult(db).then((cached) => { + if (cached) setResult(cached) + }) + }, [db]) - const detect = useCallback((skipCache: boolean) => { - if (!navigator.geolocation) { - setErrorMsg("Standortbestimmung wird nicht unterstützt") - return - } - setLoading(true) - setErrorMsg(null) - navigator.geolocation.getCurrentPosition( - async (pos) => { - try { - const r = await detectFromCoords(pos.coords.latitude, pos.coords.longitude, skipCache) - setResult(r) - } catch (e) { - setErrorMsg(String(e)) - } finally { + const detect = useCallback( + (skipCache: boolean) => { + if (!navigator.geolocation) { + setErrorMsg("Standortbestimmung wird nicht unterstützt") + return + } + setLoading(true) + setErrorMsg(null) + navigator.geolocation.getCurrentPosition( + async (pos) => { + try { + const r = await detectFromCoords(db, pos.coords.latitude, pos.coords.longitude, skipCache) + setResult(r) + } catch (e) { + setErrorMsg(String(e)) + } finally { + setLoading(false) + } + }, + (err) => { + setErrorMsg(err.message) setLoading(false) - } - }, - (err) => { - setErrorMsg(err.message) - setLoading(false) - }, - ) - }, []) + }, + ) + }, + [db], + ) function handleClearCache() { - clearGeoCache() + clearGeoCache(db) setResult(null) } @@ -310,9 +316,9 @@ export function SettingsPage() {