Files
agw/src/client/features/feed/hooks/use-feed.ts
Felix Förtsch 053707d96a 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>
2026-03-04 22:55:52 +01:00

134 lines
3.3 KiB
TypeScript

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