- 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>
134 lines
3.3 KiB
TypeScript
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 }
|
|
}
|