add view transitions, update tests for pglite, remove dead safe-area css, update AGENTS.md

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-02 14:52:37 +01:00
parent 765543920c
commit 0b7e902f0b
7 changed files with 99 additions and 116 deletions

View File

@@ -101,11 +101,3 @@
-webkit-font-smoothing: antialiased;
}
}
.safe-area-top {
padding-top: env(safe-area-inset-top);
}
.safe-area-bottom {
padding-bottom: env(safe-area-inset-bottom);
}

View File

@@ -1,3 +1,6 @@
import { createTestDb } from "@/shared/db/client"
import type { PGlite } from "@electric-sql/pglite"
import { beforeEach, describe, expect, it } from "vitest"
import type { FeedItem } from "./assemble-feed"
import { clearFeedCache, loadFeedCache, mergeFeedItems, saveFeedCache } from "./feed-cache"
@@ -5,36 +8,30 @@ function makeItem(id: string, date: string | null = "2025-01-15", title = `Poll
return { id, kind: "poll", status: "past", title, url: null, date, topics: [], source: "Bundestag" }
}
beforeEach(() => localStorage.clear())
let db: PGlite
beforeEach(async () => {
db = await createTestDb()
})
describe("feed cache persistence", () => {
it("returns null when no cache exists", () => {
expect(loadFeedCache()).toBeNull()
it("returns null when no cache exists", async () => {
expect(await loadFeedCache(db)).toBeNull()
})
it("round-trips save and load", () => {
it("round-trips save and load", async () => {
const items = [makeItem("poll-1"), makeItem("poll-2")]
saveFeedCache(items)
const loaded = loadFeedCache()
await saveFeedCache(db, items)
const loaded = await loadFeedCache(db)
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()
it("clears cache", async () => {
await saveFeedCache(db, [makeItem("poll-1")])
await clearFeedCache(db)
expect(await loadFeedCache(db)).toBeNull()
})
})

View File

@@ -1,11 +1,14 @@
import { createTestDb } from "@/shared/db/client"
import type { PGlite } from "@electric-sql/pglite"
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
import { BUNDESLAND_TO_PARLIAMENT, clearGeoCache, detectFromCoords, loadCachedResult } from "./geo"
const mockFetch = vi.fn()
let db: PGlite
beforeEach(() => {
beforeEach(async () => {
vi.stubGlobal("fetch", mockFetch)
localStorage.clear()
db = await createTestDb()
})
afterEach(() => {
@@ -47,7 +50,7 @@ describe("detectFromCoords", () => {
.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)
const result = await detectFromCoords(db, 48.1351, 11.582)
expect(result.bundesland).toBe("Bayern")
expect(result.landtag_label).toBe("Bayerischer Landtag")
expect(result.landtag_parliament_period_id).toBe(149)
@@ -60,11 +63,11 @@ describe("detectFromCoords", () => {
.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)
await detectFromCoords(db, 48.1351, 11.582)
mockFetch.mockResolvedValueOnce(new Response(JSON.stringify({ address: { state: "Bayern" } }), { status: 200 }))
const result = await detectFromCoords(48.2, 11.6)
const result = await detectFromCoords(db, 48.2, 11.6)
expect(result.mandates).toHaveLength(1)
expect(mockFetch).toHaveBeenCalledTimes(3)
})
@@ -74,13 +77,13 @@ describe("detectFromCoords", () => {
.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)
await detectFromCoords(db, 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)
await detectFromCoords(db, 48.2, 11.6, true)
// 4 fetches: Nominatim + mandates + Nominatim + mandates (cache skipped)
expect(mockFetch).toHaveBeenCalledTimes(4)
})
@@ -88,7 +91,7 @@ describe("detectFromCoords", () => {
it("returns null for unknown state", async () => {
mockFetch.mockResolvedValueOnce(new Response(JSON.stringify({ address: { state: "Unknown" } }), { status: 200 }))
const result = await detectFromCoords(0, 0)
const result = await detectFromCoords(db, 0, 0)
expect(result.bundesland).toBe("Unknown")
expect(result.landtag_label).toBeNull()
expect(result.mandates).toEqual([])
@@ -97,20 +100,20 @@ describe("detectFromCoords", () => {
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)
const result = await detectFromCoords(db, 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")
await expect(detectFromCoords(db, 0, 0)).rejects.toThrow("Nominatim error 500")
})
})
describe("loadCachedResult", () => {
it("returns null when no cache exists", () => {
expect(loadCachedResult()).toBeNull()
it("returns null when no cache exists", async () => {
expect(await loadCachedResult(db)).toBeNull()
})
it("returns cached result after a successful detect", async () => {
@@ -118,9 +121,9 @@ describe("loadCachedResult", () => {
.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)
await detectFromCoords(db, 48.1351, 11.582)
const cached = loadCachedResult()
const cached = await loadCachedResult(db)
expect(cached).not.toBeNull()
expect(cached?.bundesland).toBe("Bayern")
expect(cached?.mandates).toHaveLength(1)
@@ -133,10 +136,10 @@ describe("clearGeoCache", () => {
.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()
await detectFromCoords(db, 48.1351, 11.582)
expect(await loadCachedResult(db)).not.toBeNull()
clearGeoCache()
expect(loadCachedResult()).toBeNull()
await clearGeoCache(db)
expect(await loadCachedResult(db)).toBeNull()
})
})

View File

@@ -72,7 +72,17 @@ function AppLayout() {
<TabbarLink
key={tab.to}
active={currentPath === tab.to}
onClick={() => navigate({ to: tab.to })}
onClick={() => {
const go = () => navigate({ to: tab.to })
if (
document.startViewTransition &&
!window.matchMedia("(prefers-reduced-motion: reduce)").matches
) {
document.startViewTransition(go)
} else {
go()
}
}}
icon={<TabIcon d={tab.icon} />}
label={tab.label}
/>

View File

@@ -4,8 +4,7 @@ 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")
async function initDb(db: PGlite): Promise<PGlite> {
for (const sql of migrations) {
await db.exec(sql)
}
@@ -13,10 +12,10 @@ async function createDb(dataDir?: string): Promise<PGlite> {
}
/** Get the singleton PGlite instance. Safe to call multiple times — only creates once. */
export function getDb(dataDir?: string): Promise<PGlite> {
export function getDb(): Promise<PGlite> {
if (instance) return Promise.resolve(instance)
if (!initPromise) {
initPromise = createDb(dataDir).then((db) => {
initPromise = initDb(new PGlite("idb://agw")).then((db) => {
instance = db
return db
})
@@ -26,7 +25,7 @@ export function getDb(dataDir?: string): Promise<PGlite> {
/** Create a fresh in-memory PGlite for tests. */
export async function createTestDb(): Promise<PGlite> {
return createDb(undefined)
return initDb(new PGlite())
}
/** Reset the singleton (for tests). */

View File

@@ -1,74 +1,53 @@
import { STORAGE_KEYS } from "@/shared/lib/constants"
import { act, renderHook } from "@testing-library/react"
import { createTestDb } from "@/shared/db/client"
import { addFollow, getFollows, removeFollow } from "@/shared/db/follows"
import type { PGlite } from "@electric-sql/pglite"
import { beforeEach, describe, expect, it } from "vitest"
import { _resetFollowsCache, useFollows } from "./use-follows"
beforeEach(() => {
localStorage.clear()
_resetFollowsCache()
let db: PGlite
beforeEach(async () => {
db = await createTestDb()
})
describe("useFollows", () => {
it("starts with empty follows", () => {
const { result } = renderHook(() => useFollows())
expect(result.current.follows).toEqual([])
describe("follows data access", () => {
it("starts with empty follows", async () => {
const follows = await getFollows(db)
expect(follows).toEqual([])
})
it("can follow a topic", () => {
const { result } = renderHook(() => useFollows())
act(() => {
result.current.follow("topic", 1, "Umwelt")
})
expect(result.current.follows).toEqual([{ type: "topic", entity_id: 1, label: "Umwelt" }])
expect(result.current.isFollowing("topic", 1)).toBe(true)
it("can add a topic follow", async () => {
await addFollow(db, "topic", 1, "Umwelt")
const follows = await getFollows(db)
expect(follows).toEqual([{ type: "topic", entity_id: 1, label: "Umwelt" }])
})
it("can follow a politician", () => {
const { result } = renderHook(() => useFollows())
act(() => {
result.current.follow("politician", 42, "Angela Merkel")
})
expect(result.current.isFollowing("politician", 42)).toBe(true)
expect(result.current.isFollowing("topic", 42)).toBe(false)
it("can add a politician follow", async () => {
await addFollow(db, "politician", 42, "Angela Merkel")
const follows = await getFollows(db)
expect(follows).toHaveLength(1)
expect(follows[0].type).toBe("politician")
expect(follows[0].entity_id).toBe(42)
})
it("can unfollow", () => {
const { result } = renderHook(() => useFollows())
act(() => {
result.current.follow("topic", 1, "Umwelt")
result.current.follow("topic", 2, "Bildung")
})
act(() => {
result.current.unfollow("topic", 1)
})
expect(result.current.follows).toEqual([{ type: "topic", entity_id: 2, label: "Bildung" }])
expect(result.current.isFollowing("topic", 1)).toBe(false)
it("can remove a follow", async () => {
await addFollow(db, "topic", 1, "Umwelt")
await addFollow(db, "topic", 2, "Bildung")
await removeFollow(db, "topic", 1)
const follows = await getFollows(db)
expect(follows).toEqual([{ type: "topic", entity_id: 2, label: "Bildung" }])
})
it("does not duplicate follows", () => {
const { result } = renderHook(() => useFollows())
act(() => {
result.current.follow("topic", 1, "Umwelt")
result.current.follow("topic", 1, "Umwelt")
})
expect(result.current.follows).toHaveLength(1)
it("does not duplicate follows (ON CONFLICT DO NOTHING)", async () => {
await addFollow(db, "topic", 1, "Umwelt")
await addFollow(db, "topic", 1, "Umwelt")
const follows = await getFollows(db)
expect(follows).toHaveLength(1)
})
it("persists to localStorage", () => {
const { result } = renderHook(() => useFollows())
act(() => {
result.current.follow("topic", 5, "Wirtschaft")
})
const raw = localStorage.getItem(STORAGE_KEYS.follows)
expect(raw).toBeTruthy()
// biome-ignore lint/style/noNonNullAssertion: asserted truthy above
const parsed = JSON.parse(raw!)
expect(parsed).toEqual([{ type: "topic", entity_id: 5, label: "Wirtschaft" }])
})
it("loads from existing localStorage", () => {
localStorage.setItem(STORAGE_KEYS.follows, JSON.stringify([{ type: "politician", entity_id: 99, label: "Test" }]))
const { result } = renderHook(() => useFollows())
expect(result.current.follows).toEqual([{ type: "politician", entity_id: 99, label: "Test" }])
it("supports mixed types", async () => {
await addFollow(db, "topic", 5, "Wirtschaft")
await addFollow(db, "politician", 99, "Test")
const follows = await getFollows(db)
expect(follows).toHaveLength(2)
})
})