replace localStorage with pglite for persistent data, force-add previously ignored lib/ files

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-02 14:05:40 +01:00
parent 3cdcfb7266
commit c96c24a250
31 changed files with 1552 additions and 162 deletions

View File

@@ -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<FeedItem[]>(() => {
const cached = loadFeedCache()
return cached ? cached.items : []
})
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>(() => {
const cached = loadFeedCache()
return cached ? cached.updatedAt : 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).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<typeof setInterval> | 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()

View File

@@ -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)
})
})

View File

@@ -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<FeedItem[]> {
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<number, Poll>()
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<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,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")
})
})

View File

@@ -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<FeedCacheData | null> {
return loadCachedFeed(db)
}
export async function saveFeedCache(db: PGlite, items: FeedItem[]): Promise<void> {
await saveCachedFeed(db, items)
}
export async function clearFeedCache(db: PGlite): Promise<void> {
await clearCachedFeed(db)
}
export function mergeFeedItems(cached: FeedItem[], fresh: FeedItem[]): FeedItem[] {
const map = new Map<string, FeedItem>()
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)
})
}

View File

@@ -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()
})
})

View File

@@ -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<string, { label: string; parliamentPeriodId: number }> = {
"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<GeoResult | null> {
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<void> {
await clearGeoCacheDb(db)
}
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 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<string, unknown>)
result.cachedAt = Date.now()
}
return result
}

View File

@@ -0,0 +1,35 @@
interface PartyMeta {
short: string
color: string
}
// Colors based on official party CI
const PARTY_META: Record<string, PartyMeta> = {
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" }
}

View File

@@ -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<GeoResult | null>(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 []

View File

@@ -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<string | null>(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() {
<Button
small
outline
onClick={() => {
onClick={async () => {
setDevPoliticians(null)
const cached = loadCachedResult()
const cached = await loadCachedResult(db)
if (!cached || cached.mandates.length === 0) {
setDevPoliticians("Kein Standort-Cache")
return

View File

@@ -1,5 +1,7 @@
import { DbProvider } from "@/shared/db/provider"
import { Outlet, createFileRoute, useMatches, useNavigate } from "@tanstack/react-router"
import { App, Page, Tabbar, TabbarLink } from "konsta/react"
import { Suspense } from "react"
interface TabDef {
to: string
@@ -53,20 +55,32 @@ function AppLayout() {
return (
<App theme="ios" safeAreas className="max-w-lg mx-auto">
<Page>
<Outlet />
<Tabbar labels icons className="left-0 bottom-0 fixed">
{TABS.map((tab) => (
<TabbarLink
key={tab.to}
active={currentPath === tab.to}
onClick={() => navigate({ to: tab.to })}
icon={<TabIcon d={tab.icon} />}
label={tab.label}
/>
))}
</Tabbar>
</Page>
<Suspense
fallback={
<Page>
<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>
</Page>
}
>
<DbProvider>
<Page>
<Outlet />
<Tabbar labels icons className="left-0 bottom-0 fixed">
{TABS.map((tab) => (
<TabbarLink
key={tab.to}
active={currentPath === tab.to}
onClick={() => navigate({ to: tab.to })}
icon={<TabIcon d={tab.icon} />}
label={tab.label}
/>
))}
</Tabbar>
</Page>
</DbProvider>
</Suspense>
</App>
)
}

36
src/shared/db/client.ts Normal file
View File

@@ -0,0 +1,36 @@
import { PGlite } from "@electric-sql/pglite"
import { migrations } from "./migrations"
let instance: PGlite | null = null
let initPromise: Promise<PGlite> | null = null
async function createDb(dataDir?: string): Promise<PGlite> {
const db = new PGlite(dataDir ?? "idb://agw")
for (const sql of migrations) {
await db.exec(sql)
}
return db
}
/** Get the singleton PGlite instance. Safe to call multiple times — only creates once. */
export function getDb(dataDir?: string): Promise<PGlite> {
if (instance) return Promise.resolve(instance)
if (!initPromise) {
initPromise = createDb(dataDir).then((db) => {
instance = db
return db
})
}
return initPromise
}
/** Create a fresh in-memory PGlite for tests. */
export async function createTestDb(): Promise<PGlite> {
return createDb(undefined)
}
/** Reset the singleton (for tests). */
export function _resetDbSingleton(): void {
instance = null
initPromise = null
}

21
src/shared/db/device.ts Normal file
View File

@@ -0,0 +1,21 @@
import type { PGlite } from "@electric-sql/pglite"
function generateUUID(): string {
if (typeof crypto !== "undefined" && crypto.randomUUID) {
return crypto.randomUUID()
}
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
const r = (Math.random() * 16) | 0
const v = c === "x" ? r : (r & 0x3) | 0x8
return v.toString(16)
})
}
export async function getOrCreateDeviceId(db: PGlite): Promise<string> {
const res = await db.query<{ id: string }>("SELECT id FROM device LIMIT 1")
if (res.rows.length > 0) return res.rows[0].id
const id = generateUUID()
await db.query("INSERT INTO device (id) VALUES ($1) ON CONFLICT DO NOTHING", [id])
return id
}

View File

@@ -0,0 +1,31 @@
import type { FeedItem } from "@/features/feed/lib/assemble-feed"
import type { PGlite } from "@electric-sql/pglite"
const 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<CacheRow>("SELECT data, updated_at FROM feed_cache WHERE id = $1", [CACHE_KEY])
if (res.rows.length === 0) return null
const row = res.rows[0]
return {
items: row.data.items,
updatedAt: new Date(row.updated_at).getTime(),
}
}
export async function saveCachedFeed(db: PGlite, items: FeedItem[]): Promise<void> {
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 })],
)
}
export async function clearCachedFeed(db: PGlite): Promise<void> {
await db.query("DELETE FROM feed_cache WHERE id = $1", [CACHE_KEY])
}

29
src/shared/db/follows.ts Normal file
View File

@@ -0,0 +1,29 @@
import type { PGlite } from "@electric-sql/pglite"
export interface Follow {
type: "topic" | "politician"
entity_id: number
label: string
}
export async function getFollows(db: PGlite): Promise<Follow[]> {
const res = await db.query<Follow>("SELECT type, entity_id, label FROM follows ORDER BY created_at")
return res.rows
}
export async function addFollow(
db: PGlite,
type: "topic" | "politician",
entityId: number,
label: string,
): Promise<void> {
await db.query("INSERT INTO follows (type, entity_id, label) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING", [
type,
entityId,
label,
])
}
export async function removeFollow(db: PGlite, type: "topic" | "politician", entityId: number): Promise<void> {
await db.query("DELETE FROM follows WHERE type = $1 AND entity_id = $2", [type, entityId])
}

View File

@@ -0,0 +1,40 @@
import type { PGlite } from "@electric-sql/pglite"
export interface GeoResultRow {
bundesland: string
data: Record<string, unknown>
cached_at: string
}
const CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1000
export async function loadGeoCache(db: PGlite, bundesland: string): Promise<Record<string, unknown> | null> {
const res = await db.query<GeoResultRow>("SELECT data, cached_at FROM geo_cache WHERE bundesland = $1", [bundesland])
if (res.rows.length === 0) return null
const row = res.rows[0]
if (Date.now() - new Date(row.cached_at).getTime() > CACHE_TTL_MS) return null
return row.data
}
export async function loadMostRecentGeoCache(
db: PGlite,
): Promise<{ data: Record<string, unknown>; cachedAt: number } | null> {
const res = await db.query<GeoResultRow>("SELECT data, cached_at FROM geo_cache ORDER BY cached_at DESC LIMIT 1")
if (res.rows.length === 0) return null
const row = res.rows[0]
const cachedAt = new Date(row.cached_at).getTime()
if (Date.now() - cachedAt > CACHE_TTL_MS) return null
return { data: row.data, cachedAt }
}
export async function saveGeoCache(db: PGlite, bundesland: string, data: Record<string, unknown>): Promise<void> {
await db.query(
`INSERT INTO geo_cache (bundesland, data, cached_at) VALUES ($1, $2, now())
ON CONFLICT (bundesland) DO UPDATE SET data = $2, cached_at = now()`,
[bundesland, JSON.stringify(data)],
)
}
export async function clearGeoCache(db: PGlite): Promise<void> {
await db.query("DELETE FROM geo_cache")
}

View File

@@ -0,0 +1,77 @@
import { STORAGE_KEYS } from "@/shared/lib/constants"
import type { PGlite } from "@electric-sql/pglite"
/** One-time migration: read existing localStorage data, insert into PGlite, remove localStorage keys. */
export async function migrateFromLocalStorage(db: PGlite): Promise<void> {
// device ID
const deviceId = localStorage.getItem(STORAGE_KEYS.deviceId)
if (deviceId) {
await db.query("INSERT INTO device (id) VALUES ($1) ON CONFLICT DO NOTHING", [deviceId])
localStorage.removeItem(STORAGE_KEYS.deviceId)
}
// follows
const followsRaw = localStorage.getItem(STORAGE_KEYS.follows)
if (followsRaw) {
try {
const follows = JSON.parse(followsRaw) as Array<{ type: string; entity_id: number; label: string }>
for (const f of follows) {
await db.query("INSERT INTO follows (type, entity_id, label) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING", [
f.type,
f.entity_id,
f.label,
])
}
} catch {
// corrupt data — skip
}
localStorage.removeItem(STORAGE_KEYS.follows)
}
// feed cache
const feedRaw = localStorage.getItem(STORAGE_KEYS.feedCache)
if (feedRaw) {
try {
const parsed = JSON.parse(feedRaw) as { items: unknown[]; updatedAt: number }
if (Array.isArray(parsed.items)) {
await db.query(
`INSERT INTO feed_cache (id, data, updated_at) VALUES ('feed_items', $1, to_timestamp($2 / 1000.0))
ON CONFLICT (id) DO UPDATE SET data = $1, updated_at = to_timestamp($2 / 1000.0)`,
[JSON.stringify({ items: parsed.items }), parsed.updatedAt],
)
}
} catch {
// corrupt data — skip
}
localStorage.removeItem(STORAGE_KEYS.feedCache)
}
// geo cache
const geoRaw = localStorage.getItem(STORAGE_KEYS.geoCache)
if (geoRaw) {
try {
const cache = JSON.parse(geoRaw) as Record<string, { timestamp: number; result: unknown }>
for (const [bundesland, entry] of Object.entries(cache)) {
await db.query(
`INSERT INTO geo_cache (bundesland, data, cached_at) VALUES ($1, $2, to_timestamp($3 / 1000.0))
ON CONFLICT (bundesland) DO UPDATE SET data = $2, cached_at = to_timestamp($3 / 1000.0)`,
[bundesland, JSON.stringify(entry.result), entry.timestamp],
)
}
} catch {
// corrupt data — skip
}
localStorage.removeItem(STORAGE_KEYS.geoCache)
}
// push enabled
const pushEnabled = localStorage.getItem(STORAGE_KEYS.pushEnabled)
if (pushEnabled) {
await db.query(
`INSERT INTO push_state (key, value) VALUES ('enabled', $1)
ON CONFLICT (key) DO UPDATE SET value = $1`,
[pushEnabled],
)
localStorage.removeItem(STORAGE_KEYS.pushEnabled)
}
}

View File

@@ -0,0 +1,34 @@
CREATE TABLE IF NOT EXISTS device (
id TEXT PRIMARY KEY,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS follows (
type TEXT NOT NULL CHECK (type IN ('topic', 'politician')),
entity_id INTEGER NOT NULL,
label TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (type, entity_id)
);
CREATE TABLE IF NOT EXISTS feed_cache (
id TEXT PRIMARY KEY,
data JSONB NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS feed_meta (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS geo_cache (
bundesland TEXT PRIMARY KEY,
data JSONB NOT NULL,
cached_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS push_state (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);

View File

@@ -0,0 +1,3 @@
import init from "./001_init.sql?raw"
export const migrations: string[] = [init]

View File

@@ -0,0 +1,22 @@
import type { PGlite } from "@electric-sql/pglite"
import { type ReactNode, createContext, use } from "react"
import { getDb } from "./client"
import { migrateFromLocalStorage } from "./migrate-from-localstorage"
const dbPromise = getDb().then(async (db) => {
await migrateFromLocalStorage(db)
return db
})
const DbContext = createContext<PGlite | null>(null)
export function DbProvider({ children }: { children: ReactNode }) {
const db = use(dbPromise)
return <DbContext value={db}>{children}</DbContext>
}
export function useDb(): PGlite {
const db = use(DbContext)
if (!db) throw new Error("useDb must be used within DbProvider")
return db
}

View File

@@ -0,0 +1,18 @@
import type { PGlite } from "@electric-sql/pglite"
export async function getPushState(db: PGlite, key: string): Promise<string | null> {
const res = await db.query<{ value: string }>("SELECT value FROM push_state WHERE key = $1", [key])
return res.rows.length > 0 ? res.rows[0].value : null
}
export async function setPushState(db: PGlite, key: string, value: string): Promise<void> {
await db.query(
`INSERT INTO push_state (key, value) VALUES ($1, $2)
ON CONFLICT (key) DO UPDATE SET value = $2`,
[key, value],
)
}
export async function removePushState(db: PGlite, key: string): Promise<void> {
await db.query("DELETE FROM push_state WHERE key = $1", [key])
}

View File

@@ -1,33 +1,23 @@
import { STORAGE_KEYS } from "@/shared/lib/constants"
import { useState } from "react"
function generateUUID(): string {
if (typeof crypto !== "undefined" && crypto.randomUUID) {
return crypto.randomUUID()
}
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
const r = (Math.random() * 16) | 0
const v = c === "x" ? r : (r & 0x3) | 0x8
return v.toString(16)
})
}
function getOrCreateDeviceId(): string {
let id = localStorage.getItem(STORAGE_KEYS.deviceId)
if (!id) {
id = generateUUID()
localStorage.setItem(STORAGE_KEYS.deviceId, id)
}
return id
}
import { getOrCreateDeviceId } from "@/shared/db/device"
import { useDb } from "@/shared/db/provider"
import { useEffect, useState } from "react"
let _cached: string | null = null
export function useDeviceId(): string {
const [deviceId] = useState<string>(() => {
if (_cached) return _cached
_cached = getOrCreateDeviceId()
return _cached
})
const db = useDb()
const [deviceId, setDeviceId] = useState<string>(_cached ?? "")
useEffect(() => {
if (_cached) {
setDeviceId(_cached)
return
}
getOrCreateDeviceId(db).then((id) => {
_cached = id
setDeviceId(id)
})
}, [db])
return deviceId
}

View File

@@ -1,56 +1,31 @@
import { STORAGE_KEYS } from "@/shared/lib/constants"
import { useCallback, useSyncExternalStore } from "react"
import { type Follow, addFollow, getFollows, removeFollow } from "@/shared/db/follows"
import { useDb } from "@/shared/db/provider"
import { useCallback, useEffect, useState } from "react"
export interface Follow {
type: "topic" | "politician"
entity_id: number
label: string
}
export type { Follow }
// Cross-component change notification
type Listener = () => void
const listeners = new Set<Listener>()
let cachedRaw: string | null = null
let cachedFollows: Follow[] = []
function emitChange() {
cachedRaw = null
for (const listener of listeners) listener()
}
function subscribe(listener: Listener): () => void {
listeners.add(listener)
return () => listeners.delete(listener)
}
function getSnapshot(): Follow[] {
const raw = localStorage.getItem(STORAGE_KEYS.follows)
if (raw === cachedRaw) return cachedFollows
cachedRaw = raw
if (!raw) {
cachedFollows = []
} else {
try {
cachedFollows = JSON.parse(raw) as Follow[]
} catch {
cachedFollows = []
}
}
return cachedFollows
}
function setFollows(follows: Follow[]) {
localStorage.setItem(STORAGE_KEYS.follows, JSON.stringify(follows))
emitChange()
}
export function _resetFollowsCache() {
cachedRaw = null
cachedFollows = []
for (const fn of listeners) fn()
}
export function useFollows() {
const follows = useSyncExternalStore(subscribe, getSnapshot, getSnapshot)
const db = useDb()
const [follows, setFollows] = useState<Follow[]>([])
const reload = useCallback(() => {
getFollows(db).then(setFollows)
}, [db])
useEffect(() => {
reload()
listeners.add(reload)
return () => {
listeners.delete(reload)
}
}, [reload])
const isFollowing = useCallback(
(type: "topic" | "politician", entityId: number) =>
@@ -58,16 +33,21 @@ export function useFollows() {
[follows],
)
const follow = useCallback((type: "topic" | "politician", entityId: number, label: string) => {
const current = getSnapshot()
if (current.some((f) => f.type === type && f.entity_id === entityId)) return
setFollows([...current, { type, entity_id: entityId, label }])
}, [])
const follow = useCallback(
async (type: "topic" | "politician", entityId: number, label: string) => {
await addFollow(db, type, entityId, label)
emitChange()
},
[db],
)
const unfollow = useCallback((type: "topic" | "politician", entityId: number) => {
const current = getSnapshot()
setFollows(current.filter((f) => !(f.type === type && f.entity_id === entityId)))
}, [])
const unfollow = useCallback(
async (type: "topic" | "politician", entityId: number) => {
await removeFollow(db, type, entityId)
emitChange()
},
[db],
)
return { follows, isFollowing, follow, unfollow }
}

View File

@@ -1,4 +1,5 @@
import { STORAGE_KEYS } from "@/shared/lib/constants"
import { useDb } from "@/shared/db/provider"
import { getPushState, removePushState, setPushState } from "@/shared/db/push-state-db"
import { isPushSubscribed, subscribeToPush, syncFollowsToBackend, unsubscribeFromPush } from "@/shared/lib/push-client"
import { useCallback, useEffect, useState } from "react"
import { useDeviceId } from "./use-device-id"
@@ -12,13 +13,13 @@ function getPermission(): PushPermission {
}
export function usePush() {
const db = useDb()
const deviceId = useDeviceId()
const { follows } = useFollows()
const [permission, setPermission] = useState<PushPermission>(getPermission)
const [subscribed, setSubscribed] = useState(false)
const [loading, setLoading] = useState(false)
// check subscription status on mount
useEffect(() => {
isPushSubscribed().then(setSubscribed)
}, [])
@@ -30,28 +31,26 @@ export function usePush() {
if (ok) {
setSubscribed(true)
setPermission(getPermission())
localStorage.setItem(STORAGE_KEYS.pushEnabled, "true")
// sync current follows immediately
await setPushState(db, "enabled", "true")
await syncFollowsToBackend(deviceId, follows)
}
return ok
} finally {
setLoading(false)
}
}, [deviceId, follows])
}, [db, deviceId, follows])
const unsubscribe = useCallback(async () => {
setLoading(true)
try {
await unsubscribeFromPush(deviceId)
setSubscribed(false)
localStorage.removeItem(STORAGE_KEYS.pushEnabled)
await removePushState(db, "enabled")
} finally {
setLoading(false)
}
}, [deviceId])
}, [db, deviceId])
// auto-sync follows to backend when they change (only if subscribed)
useEffect(() => {
if (!subscribed) return
syncFollowsToBackend(deviceId, follows)
@@ -60,9 +59,8 @@ export function usePush() {
return { permission, subscribed, loading, subscribe, unsubscribe }
}
// standalone sync function for use outside React hooks
export function triggerPushSync(deviceId: string, follows: Follow[]) {
const enabled = localStorage.getItem(STORAGE_KEYS.pushEnabled)
export async function triggerPushSync(deviceId: string, follows: Follow[], db: import("@electric-sql/pglite").PGlite) {
const enabled = await getPushState(db, "enabled")
if (enabled !== "true") return
syncFollowsToBackend(deviceId, follows)
}

View File

@@ -0,0 +1,133 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
import {
fetchCandidacyMandates,
fetchPolls,
fetchPollsByIds,
fetchTopics,
fetchVotes,
searchPoliticians,
} from "./aw-api"
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" },
})
}
describe("fetchTopics", () => {
it("returns parsed topics", async () => {
mockFetch.mockResolvedValueOnce(
okResponse([
{ id: 1, label: "Umwelt" },
{ id: 2, label: "Bildung" },
]),
)
const topics = await fetchTopics()
expect(topics).toEqual([
{ id: 1, label: "Umwelt" },
{ id: 2, label: "Bildung" },
])
expect(mockFetch).toHaveBeenCalledOnce()
const url = new URL(mockFetch.mock.calls[0][0])
expect(url.pathname).toBe("/api/v2/topics")
expect(url.searchParams.get("range_end")).toBe("200")
})
it("throws on non-ok response", async () => {
mockFetch.mockResolvedValueOnce(new Response("not found", { status: 404 }))
await expect(fetchTopics()).rejects.toThrow("AW API 404")
})
it("validates data with Zod", async () => {
mockFetch.mockResolvedValueOnce(okResponse([{ id: "not-a-number", label: "Bad" }]))
await expect(fetchTopics()).rejects.toThrow()
})
})
describe("searchPoliticians", () => {
it("passes query parameter", async () => {
mockFetch.mockResolvedValueOnce(okResponse([{ id: 10, label: "Angela Merkel", party: { id: 1, label: "CDU" } }]))
const results = await searchPoliticians("Merkel")
expect(results).toHaveLength(1)
expect(results[0].label).toBe("Angela Merkel")
const url = new URL(mockFetch.mock.calls[0][0])
expect(url.searchParams.get("label[cn]")).toBe("Merkel")
})
})
describe("fetchPolls", () => {
it("returns sorted polls", async () => {
mockFetch.mockResolvedValueOnce(
okResponse([{ id: 1, label: "Poll A", field_poll_date: "2024-01-15", field_topics: [{ id: 5 }] }]),
)
const polls = await fetchPolls(50)
expect(polls).toHaveLength(1)
expect(polls[0].field_topics[0].id).toBe(5)
const url = new URL(mockFetch.mock.calls[0][0])
expect(url.searchParams.get("range_end")).toBe("50")
expect(url.searchParams.get("sort_direction")).toBe("desc")
})
})
describe("fetchPollsByIds", () => {
it("returns empty array for empty ids", async () => {
const result = await fetchPollsByIds([])
expect(result).toEqual([])
expect(mockFetch).not.toHaveBeenCalled()
})
it("fetches each poll individually", async () => {
mockFetch
.mockResolvedValueOnce(okResponse([{ id: 1, label: "Poll A", field_poll_date: null, field_topics: [] }]))
.mockResolvedValueOnce(okResponse([{ id: 2, label: "Poll B", field_poll_date: null, field_topics: [] }]))
const polls = await fetchPollsByIds([1, 2])
expect(polls).toHaveLength(2)
expect(mockFetch).toHaveBeenCalledTimes(2)
const url1 = new URL(mockFetch.mock.calls[0][0])
expect(url1.searchParams.get("id")).toBe("1")
const url2 = new URL(mockFetch.mock.calls[1][0])
expect(url2.searchParams.get("id")).toBe("2")
})
})
describe("fetchCandidacyMandates", () => {
it("fetches mandates for a politician", async () => {
mockFetch.mockResolvedValueOnce(okResponse([{ id: 100 }, { id: 101 }]))
const mandates = await fetchCandidacyMandates(42)
expect(mandates).toEqual([{ id: 100 }, { id: 101 }])
const url = new URL(mockFetch.mock.calls[0][0])
expect(url.searchParams.get("politician")).toBe("42")
})
})
describe("fetchVotes", () => {
it("fetches votes for a mandate", async () => {
mockFetch.mockResolvedValueOnce(
okResponse([
{ id: 200, poll: { id: 10 } },
{ id: 201, poll: null },
]),
)
const votes = await fetchVotes(100)
expect(votes).toHaveLength(2)
expect(votes[0].poll?.id).toBe(10)
expect(votes[1].poll).toBeNull()
})
})

178
src/shared/lib/aw-api.ts Normal file
View File

@@ -0,0 +1,178 @@
import { z } from "zod"
import { AW_API_BASE, AW_API_TIMEOUT_MS } from "./constants"
// --- Zod Schemas ---
export const topicSchema = z.object({
id: z.number(),
label: z.string(),
abgeordnetenwatch_url: z.string().optional(),
})
export const politicianSchema = z.object({
id: z.number(),
label: z.string(),
party: z
.object({
id: z.number(),
label: z.string(),
})
.nullable()
.optional(),
})
const pollTopicSchema = z.object({
id: z.number(),
label: z.string().optional(),
abgeordnetenwatch_url: z.string().optional(),
})
export const pollSchema = z.object({
id: z.number(),
label: z.string(),
abgeordnetenwatch_url: z.string().optional(),
field_poll_date: z.string().nullable(),
field_accepted: z.boolean().nullable().optional(),
field_topics: z.array(pollTopicSchema),
})
export const candidacyMandateSchema = z.object({
id: z.number(),
})
const fractionMembershipSchema = z.object({
fraction: z.object({
label: z.string(),
}),
valid_until: z.string().nullable().optional(),
})
export const mandateWithPoliticianSchema = z.object({
id: z.number(),
politician: z.object({
id: z.number(),
label: z.string(),
}),
party: z
.object({
id: z.number(),
label: z.string(),
})
.nullable()
.optional(),
fraction_membership: z.array(fractionMembershipSchema).optional(),
electoral_data: z
.object({
constituency: z
.object({
label: z.string(),
})
.nullable()
.optional(),
mandate_won: z.string().nullable().optional(),
})
.nullable()
.optional(),
})
export const voteSchema = z.object({
id: z.number(),
poll: z
.object({
id: z.number(),
})
.nullable()
.optional(),
})
// --- Types ---
export type Topic = z.infer<typeof topicSchema>
export type Politician = z.infer<typeof politicianSchema>
export type Poll = z.infer<typeof pollSchema>
export type CandidacyMandate = z.infer<typeof candidacyMandateSchema>
export type MandateWithPolitician = z.infer<typeof mandateWithPoliticianSchema>
export type Vote = z.infer<typeof voteSchema>
// --- Fetch helper ---
async function request<T>(path: string, params: Record<string, string>, schema: z.ZodType<T>): Promise<T[]> {
const url = new URL(`${AW_API_BASE}/${path}`)
for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v)
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), AW_API_TIMEOUT_MS)
let res: Response
try {
res = await fetch(url.toString(), {
signal: controller.signal,
headers: { Accept: "application/json" },
})
} finally {
clearTimeout(timer)
}
if (!res.ok) {
const body = await res.text().catch(() => "")
throw new Error(`AW API ${res.status} for ${url}: ${body}`)
}
const json = (await res.json()) as { data: unknown[] }
return z.array(schema).parse(json.data)
}
// --- Public API ---
export function fetchTopics(): Promise<Topic[]> {
return request("topics", { range_end: "200" }, topicSchema)
}
export function searchPoliticians(query: string): Promise<Politician[]> {
return request("politicians", { "label[cn]": query, range_end: "50" }, politicianSchema)
}
export function fetchPolls(rangeEnd = 100): Promise<Poll[]> {
return request(
"polls",
{
range_end: String(rangeEnd),
sort_by: "field_poll_date",
sort_direction: "desc",
},
pollSchema,
)
}
export async function fetchPollsByIds(ids: number[]): Promise<Poll[]> {
if (ids.length === 0) return []
// AW API does not support id[in] on /polls — fetch individually and dedupe
const results = await Promise.all(ids.map((id) => request("polls", { id: String(id), range_end: "1" }, pollSchema)))
return results.flat()
}
export function fetchCandidacyMandates(politicianID: number): Promise<CandidacyMandate[]> {
return request(
"candidacies-mandates",
{
politician: String(politicianID),
range_end: "200",
},
candidacyMandateSchema,
)
}
export function fetchVotes(mandateID: number): Promise<Vote[]> {
return request("votes", { mandate: String(mandateID), range_end: "200" }, voteSchema)
}
export function fetchMandatesByParliamentPeriod(periodID: number): Promise<MandateWithPolitician[]> {
return request(
"candidacies-mandates",
{
parliament_period: String(periodID),
type: "mandate",
range_end: "800",
},
mandateWithPoliticianSchema,
)
}

View File

@@ -0,0 +1,15 @@
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 VAPID_PUBLIC_KEY =
import.meta.env.VITE_VAPID_PUBLIC_KEY ??
"BBRownmmnmTqNCeiaKp_CzvfXXNhB6NG5LTXDQwnSDspGgdKaZv_0UoAo4Ify6HHqcl4kY3ABw7w6GYAqUHYcdQ"
export const STORAGE_KEYS = {
deviceId: "agw_device_id",
feedCache: "agw_feed_cache",
follows: "agw_follows",
geoCache: "agw_geo_cache",
pushEnabled: "agw_push_enabled",
} as const

View File

@@ -0,0 +1,68 @@
import { beforeEach, describe, expect, it, vi } from "vitest"
// mock constants
vi.mock("./constants", () => ({
BACKEND_URL: "https://test.example.com/api",
VAPID_PUBLIC_KEY: "BNcRdreALRFXTkOOUHK1EtK2wtaz5Ry4YfYCA_0QTpQtUbVlUls0VJXg7A8u-Ts1XbjhazAkj7I99e8p8REqnSw",
}))
describe("push-client", () => {
beforeEach(() => {
vi.restoreAllMocks()
})
describe("syncFollowsToBackend", () => {
it("sends follows to backend", async () => {
const mockFetch = vi.fn().mockResolvedValue({ ok: true })
vi.stubGlobal("fetch", mockFetch)
const { syncFollowsToBackend } = await import("./push-client")
const result = await syncFollowsToBackend("550e8400-e29b-41d4-a716-446655440000", [
{ type: "topic", entity_id: 1, label: "Test Topic" },
])
expect(result).toBe(true)
expect(mockFetch).toHaveBeenCalledWith(
"https://test.example.com/api/push/sync",
expect.objectContaining({
method: "POST",
headers: { "Content-Type": "application/json" },
}),
)
const body = JSON.parse(mockFetch.mock.calls[0][1].body)
expect(body.device_id).toBe("550e8400-e29b-41d4-a716-446655440000")
expect(body.follows).toHaveLength(1)
expect(body.follows[0].type).toBe("topic")
})
it("returns false on network error", async () => {
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("Network error")))
const { syncFollowsToBackend } = await import("./push-client")
const result = await syncFollowsToBackend("550e8400-e29b-41d4-a716-446655440000", [])
expect(result).toBe(false)
})
})
describe("isPushSubscribed", () => {
it("returns false when service worker not available", async () => {
// remove serviceWorker from navigator
const original = navigator.serviceWorker
Object.defineProperty(navigator, "serviceWorker", {
value: undefined,
configurable: true,
})
const { isPushSubscribed } = await import("./push-client")
const result = await isPushSubscribed()
expect(result).toBe(false)
Object.defineProperty(navigator, "serviceWorker", {
value: original,
configurable: true,
})
})
})
})

View File

@@ -0,0 +1,98 @@
import type { Follow } from "@/shared/hooks/use-follows"
import { BACKEND_URL, VAPID_PUBLIC_KEY } from "./constants"
function urlBase64ToUint8Array(base64String: string): Uint8Array {
const padding = "=".repeat((4 - (base64String.length % 4)) % 4)
const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/")
const raw = atob(base64)
const out = new Uint8Array(raw.length)
for (let i = 0; i < raw.length; i++) {
out[i] = raw.charCodeAt(i)
}
return out
}
async function getServiceWorkerRegistration(): Promise<ServiceWorkerRegistration | null> {
if (!navigator.serviceWorker) return null
return navigator.serviceWorker.ready
}
export async function subscribeToPush(deviceId: string): Promise<boolean> {
if (!VAPID_PUBLIC_KEY) {
console.warn("[push] VAPID_PUBLIC_KEY not configured")
return false
}
const reg = await getServiceWorkerRegistration()
if (!reg) return false
const permission = await Notification.requestPermission()
if (permission !== "granted") return false
const keyArray = urlBase64ToUint8Array(VAPID_PUBLIC_KEY)
const subscription = await reg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: keyArray.buffer as ArrayBuffer,
})
const json = subscription.toJSON()
if (!json.endpoint || !json.keys) return false
const res = await fetch(`${BACKEND_URL}/push/subscribe`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
device_id: deviceId,
subscription: {
endpoint: json.endpoint,
keys: {
p256dh: json.keys.p256dh,
auth: json.keys.auth,
},
},
}),
})
return res.ok
}
export async function unsubscribeFromPush(deviceId: string): Promise<boolean> {
const reg = await getServiceWorkerRegistration()
if (!reg) return false
const subscription = await reg.pushManager.getSubscription()
if (subscription) {
await subscription.unsubscribe()
}
const res = await fetch(`${BACKEND_URL}/push/unsubscribe`, {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ device_id: deviceId }),
})
return res.ok
}
export async function syncFollowsToBackend(deviceId: string, follows: Follow[]): Promise<boolean> {
try {
const res = await fetch(`${BACKEND_URL}/push/sync`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
device_id: deviceId,
follows,
}),
})
return res.ok
} catch {
return false
}
}
export async function isPushSubscribed(): Promise<boolean> {
const reg = await getServiceWorkerRegistration()
if (!reg) return false
const sub = await reg.pushManager.getSubscription()
return sub !== null
}

6
src/shared/lib/utils.ts Normal file
View File

@@ -0,0 +1,6 @@
import { type ClassValue, clsx } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

View File

@@ -49,7 +49,8 @@ export default defineConfig({
],
},
injectManifest: {
globPatterns: ["**/*.{js,css,html,svg,png,woff2}"],
globPatterns: ["**/*.{js,css,html,svg,png,woff2,wasm}"],
maximumFileSizeToCacheInBytes: 10 * 1024 * 1024,
},
}),
],