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:
@@ -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()
|
||||
|
||||
119
src/features/feed/lib/assemble-feed.test.ts
Normal file
119
src/features/feed/lib/assemble-feed.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
83
src/features/feed/lib/assemble-feed.ts
Normal file
83
src/features/feed/lib/assemble-feed.ts
Normal 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))
|
||||
}
|
||||
71
src/features/feed/lib/feed-cache.test.ts
Normal file
71
src/features/feed/lib/feed-cache.test.ts
Normal 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")
|
||||
})
|
||||
})
|
||||
33
src/features/feed/lib/feed-cache.ts
Normal file
33
src/features/feed/lib/feed-cache.ts
Normal 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)
|
||||
})
|
||||
}
|
||||
142
src/features/location/lib/geo.test.ts
Normal file
142
src/features/location/lib/geo.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
100
src/features/location/lib/geo.ts
Normal file
100
src/features/location/lib/geo.ts
Normal 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
|
||||
}
|
||||
35
src/features/location/lib/parties.ts
Normal file
35
src/features/location/lib/parties.ts
Normal 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" }
|
||||
}
|
||||
@@ -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 []
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
36
src/shared/db/client.ts
Normal 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
21
src/shared/db/device.ts
Normal 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
|
||||
}
|
||||
31
src/shared/db/feed-cache-db.ts
Normal file
31
src/shared/db/feed-cache-db.ts
Normal 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
29
src/shared/db/follows.ts
Normal 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])
|
||||
}
|
||||
40
src/shared/db/geo-cache-db.ts
Normal file
40
src/shared/db/geo-cache-db.ts
Normal 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")
|
||||
}
|
||||
77
src/shared/db/migrate-from-localstorage.ts
Normal file
77
src/shared/db/migrate-from-localstorage.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
34
src/shared/db/migrations/001_init.sql
Normal file
34
src/shared/db/migrations/001_init.sql
Normal 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
|
||||
);
|
||||
3
src/shared/db/migrations/index.ts
Normal file
3
src/shared/db/migrations/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import init from "./001_init.sql?raw"
|
||||
|
||||
export const migrations: string[] = [init]
|
||||
22
src/shared/db/provider.tsx
Normal file
22
src/shared/db/provider.tsx
Normal 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
|
||||
}
|
||||
18
src/shared/db/push-state-db.ts
Normal file
18
src/shared/db/push-state-db.ts
Normal 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])
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
133
src/shared/lib/aw-api.test.ts
Normal file
133
src/shared/lib/aw-api.test.ts
Normal 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
178
src/shared/lib/aw-api.ts
Normal 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,
|
||||
)
|
||||
}
|
||||
15
src/shared/lib/constants.ts
Normal file
15
src/shared/lib/constants.ts
Normal 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
|
||||
68
src/shared/lib/push-client.test.ts
Normal file
68
src/shared/lib/push-client.test.ts
Normal 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,
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
98
src/shared/lib/push-client.ts
Normal file
98
src/shared/lib/push-client.ts
Normal 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
6
src/shared/lib/utils.ts
Normal 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))
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
}),
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user