normalize project structure: src/client + src/server + src/shared
- restructure from src/ + server/ to src/client/ + src/server/ + src/shared/ - switch backend runtime from Node (tsx) to Bun - merge server/package.json into root, remove @hono/node-server + tsx - convert server @/ imports to relative paths - standardize biome config (lineWidth 80, quoteStyle double) - add CLAUDE.md, .env.example at root - update vite.config, tsconfig, deploy.sh for new structure Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
export function FeedEmpty() {
|
||||
return (
|
||||
<div className="text-center mt-12 px-4">
|
||||
<p className="text-lg font-medium">Dein Feed ist leer</p>
|
||||
<p className="text-sm text-muted-foreground mt-2">
|
||||
Folge Themen oder Abgeordneten, um Abstimmungen hier zu sehen.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import type { FeedItem as FeedItemType } from "../lib/assemble-feed"
|
||||
|
||||
function formatDate(iso: string | null): string {
|
||||
if (!iso) return ""
|
||||
const d = new Date(iso)
|
||||
if (Number.isNaN(d.getTime())) return iso
|
||||
return d.toLocaleDateString("de-DE", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
})
|
||||
}
|
||||
|
||||
export function FeedItemCard({ item }: { item: FeedItemType }) {
|
||||
return (
|
||||
<article className="px-4 py-3">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
{item.url ? (
|
||||
<a
|
||||
href={item.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-[15px] font-medium leading-snug hover:underline"
|
||||
>
|
||||
{item.title}
|
||||
</a>
|
||||
) : (
|
||||
<h2 className="text-[15px] font-medium leading-snug">{item.title}</h2>
|
||||
)}
|
||||
{item.date && (
|
||||
<time
|
||||
dateTime={item.date}
|
||||
className="text-xs text-muted-foreground whitespace-nowrap shrink-0"
|
||||
>
|
||||
{formatDate(item.date)}
|
||||
</time>
|
||||
)}
|
||||
</div>
|
||||
{item.topics.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-2" aria-label="Themen">
|
||||
{item.topics.map((topic) =>
|
||||
topic.url ? (
|
||||
<a
|
||||
key={topic.label}
|
||||
href={topic.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Badge variant="secondary" className="cursor-pointer">
|
||||
{topic.label}
|
||||
</Badge>
|
||||
</a>
|
||||
) : (
|
||||
<Badge key={topic.label} variant="secondary">
|
||||
{topic.label}
|
||||
</Badge>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<p className="text-xs text-muted-foreground">{item.source}</p>
|
||||
{item.kind === "vorgang" && (
|
||||
<Badge variant="outline" className="text-[10px] px-1.5 py-0">
|
||||
{item.status === "upcoming" ? "In Beratung" : "Abgeschlossen"}
|
||||
</Badge>
|
||||
)}
|
||||
{item.kind === "session" && (
|
||||
<Badge variant="outline" className="text-[10px] px-1.5 py-0">
|
||||
Plenarsitzung
|
||||
</Badge>
|
||||
)}
|
||||
{item.kind === "decision" && item.result && (
|
||||
<Badge variant="outline" className="text-[10px] px-1.5 py-0">
|
||||
{item.result === "accepted"
|
||||
? "Beschlossen"
|
||||
: item.result === "rejected"
|
||||
? "Abgelehnt"
|
||||
: item.result === "conducted"
|
||||
? "Durchgeführt"
|
||||
: "Überwiesen"}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { useMemo } from "react"
|
||||
import type { FeedItem } from "../lib/assemble-feed"
|
||||
import { FeedEmpty } from "./feed-empty"
|
||||
import { FeedItemCard } from "./feed-item"
|
||||
|
||||
export function FeedList({ items }: { items: FeedItem[] }) {
|
||||
if (items.length === 0) return <FeedEmpty />
|
||||
|
||||
const { upcoming, past } = useMemo(() => {
|
||||
const upcoming: FeedItem[] = []
|
||||
const past: FeedItem[] = []
|
||||
for (const item of items) {
|
||||
if (item.status === "upcoming") upcoming.push(item)
|
||||
else past.push(item)
|
||||
}
|
||||
return { upcoming, past }
|
||||
}, [items])
|
||||
|
||||
return (
|
||||
<div>
|
||||
{upcoming.length > 0 && (
|
||||
<section>
|
||||
<h2 className="px-4 pt-4 pb-2 text-sm font-semibold text-muted-foreground uppercase tracking-wide">
|
||||
Anstehende Abstimmungen
|
||||
</h2>
|
||||
<div className="divide-y divide-border">
|
||||
{upcoming.map((item) => (
|
||||
<FeedItemCard key={item.id} item={item} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
{past.length > 0 && (
|
||||
<section>
|
||||
<h2 className="px-4 pt-4 pb-2 text-sm font-semibold text-muted-foreground uppercase tracking-wide">
|
||||
Vergangene Abstimmungen
|
||||
</h2>
|
||||
<div className="divide-y divide-border">
|
||||
{past.map((item) => (
|
||||
<FeedItemCard key={item.id} item={item} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
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"
|
||||
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 [loading, setLoading] = useState(false)
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
const [error, setError] = useState<string | null>(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) return
|
||||
|
||||
refreshingRef.current = true
|
||||
if (silent) {
|
||||
setRefreshing(true)
|
||||
} else {
|
||||
setLoading(true)
|
||||
}
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const fresh = await assembleFeed(topicIDs, politicianIDs)
|
||||
setItems((prev) => {
|
||||
const merged = mergeFeedItems(prev, fresh)
|
||||
saveFeedCache(db, merged)
|
||||
hasItemsRef.current = merged.length > 0
|
||||
return merged
|
||||
})
|
||||
const now = Date.now()
|
||||
setLastUpdated(now)
|
||||
lastUpdatedRef.current = now
|
||||
} catch (e) {
|
||||
setError(String(e))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setRefreshing(false)
|
||||
refreshingRef.current = false
|
||||
}
|
||||
},
|
||||
[db, topicIDs, politicianIDs],
|
||||
)
|
||||
|
||||
// Initial fetch / refresh when follows change
|
||||
useEffect(() => {
|
||||
if (hasItemsRef.current) {
|
||||
refresh({ silent: true })
|
||||
} else {
|
||||
refresh()
|
||||
}
|
||||
}, [refresh])
|
||||
|
||||
// Auto-refresh interval + visibility API
|
||||
useEffect(() => {
|
||||
let intervalId: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
function startInterval() {
|
||||
if (intervalId) return
|
||||
intervalId = setInterval(() => {
|
||||
refresh({ silent: true })
|
||||
}, REFRESH_INTERVAL_MS)
|
||||
}
|
||||
|
||||
function stopInterval() {
|
||||
if (intervalId) {
|
||||
clearInterval(intervalId)
|
||||
intervalId = null
|
||||
}
|
||||
}
|
||||
|
||||
function handleVisibility() {
|
||||
if (document.hidden) {
|
||||
stopInterval()
|
||||
} else {
|
||||
if (
|
||||
!lastUpdatedRef.current ||
|
||||
Date.now() - lastUpdatedRef.current >= REFRESH_INTERVAL_MS
|
||||
) {
|
||||
refresh({ silent: true })
|
||||
}
|
||||
startInterval()
|
||||
}
|
||||
}
|
||||
|
||||
startInterval()
|
||||
document.addEventListener("visibilitychange", handleVisibility)
|
||||
|
||||
return () => {
|
||||
stopInterval()
|
||||
document.removeEventListener("visibilitychange", handleVisibility)
|
||||
}
|
||||
}, [refresh])
|
||||
|
||||
return { items, loading, refreshing, error, lastUpdated, refresh }
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { FeedList } from "./components/feed-list"
|
||||
export { useFeed } from "./hooks/use-feed"
|
||||
@@ -0,0 +1,155 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,99 @@
|
||||
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" | "vorgang" | "session" | "decision"
|
||||
status: "upcoming" | "past"
|
||||
title: string
|
||||
url: string | null
|
||||
date: string | null
|
||||
topics: FeedTopicRef[]
|
||||
source: string
|
||||
result?: "accepted" | "rejected" | "conducted" | "referred" | null
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
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"
|
||||
|
||||
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",
|
||||
}
|
||||
}
|
||||
|
||||
let db: PGlite
|
||||
|
||||
beforeEach(async () => {
|
||||
db = await createTestDb()
|
||||
})
|
||||
|
||||
describe("feed cache persistence", () => {
|
||||
it("returns null when no cache exists", async () => {
|
||||
expect(await loadFeedCache(db)).toBeNull()
|
||||
})
|
||||
|
||||
it("round-trips save and load", async () => {
|
||||
const items = [makeItem("poll-1"), makeItem("poll-2")]
|
||||
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", async () => {
|
||||
await saveFeedCache(db, [makeItem("poll-1")])
|
||||
await clearFeedCache(db)
|
||||
expect(await loadFeedCache(db)).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")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,50 @@
|
||||
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,
|
||||
cacheKey?: string,
|
||||
): Promise<FeedCacheData | null> {
|
||||
return loadCachedFeed(db, cacheKey)
|
||||
}
|
||||
|
||||
export async function saveFeedCache(
|
||||
db: PGlite,
|
||||
items: FeedItem[],
|
||||
cacheKey?: string,
|
||||
): Promise<void> {
|
||||
await saveCachedFeed(db, items, cacheKey)
|
||||
}
|
||||
|
||||
export async function clearFeedCache(
|
||||
db: PGlite,
|
||||
cacheKey?: string,
|
||||
): Promise<void> {
|
||||
await clearCachedFeed(db, cacheKey)
|
||||
}
|
||||
|
||||
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)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user