add feed caching, developer settings section, fix deploy toolchain
- cache feed items in localStorage, merge fresh with cached, auto-refresh hourly - add refresh bar with timestamp, spinning icon during background refresh - add developer section: backend health check, test push, mass-follow buttons - add POST /push/test endpoint for test notifications - activate mise in deploy.sh so bun is always on $PATH Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -3,6 +3,9 @@
|
||||
# Usage: ./deploy.sh [uberspace-user@host] (default: serve)
|
||||
set -euo pipefail
|
||||
|
||||
# activate mise so tool versions (bun, node, etc.) from .mise.toml are on $PATH
|
||||
eval "$(mise activate bash --shims)"
|
||||
|
||||
REMOTE="${1:-serve}"
|
||||
LOCAL_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
|
||||
|
||||
@@ -1,36 +1,117 @@
|
||||
import { useFollows } from "@/shared/hooks/use-follows"
|
||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
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 { follows } = useFollows()
|
||||
const [items, setItems] = useState<FeedItem[]>([])
|
||||
|
||||
const [items, setItems] = useState<FeedItem[]>(() => {
|
||||
const cached = loadFeedCache()
|
||||
return cached ? cached.items : []
|
||||
})
|
||||
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 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 refresh = useCallback(async () => {
|
||||
if (topicIDs.length === 0 && politicianIDs.length === 0) {
|
||||
setItems([])
|
||||
return
|
||||
}
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const feed = await assembleFeed(topicIDs, politicianIDs)
|
||||
setItems(feed)
|
||||
} catch (e) {
|
||||
setError(String(e))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [topicIDs, politicianIDs])
|
||||
const refreshingRef = useRef(false)
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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(merged)
|
||||
return merged
|
||||
})
|
||||
setLastUpdated(Date.now())
|
||||
} catch (e) {
|
||||
setError(String(e))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setRefreshing(false)
|
||||
refreshingRef.current = false
|
||||
}
|
||||
},
|
||||
[topicIDs, politicianIDs],
|
||||
)
|
||||
|
||||
// initial fetch on mount / when follows change
|
||||
useEffect(() => {
|
||||
refresh()
|
||||
const cached = loadFeedCache()
|
||||
if (cached && cached.items.length > 0) {
|
||||
// have cached data — do a silent refresh
|
||||
refresh({ silent: true })
|
||||
} else {
|
||||
refresh()
|
||||
}
|
||||
}, [refresh])
|
||||
|
||||
return { items, loading, error, 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 {
|
||||
// refresh immediately if stale
|
||||
const cached = loadFeedCache()
|
||||
if (!cached || Date.now() - cached.updatedAt >= 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 }
|
||||
}
|
||||
|
||||
@@ -2,9 +2,11 @@ import { Button } from "@/shared/components/ui/button"
|
||||
import { Card, CardContent } from "@/shared/components/ui/card"
|
||||
import { Switch } from "@/shared/components/ui/switch"
|
||||
import { useDeviceId } from "@/shared/hooks/use-device-id"
|
||||
import { useFollows } from "@/shared/hooks/use-follows"
|
||||
import { usePush } from "@/shared/hooks/use-push"
|
||||
import { usePwaUpdate } from "@/shared/hooks/use-pwa-update"
|
||||
import { VAPID_PUBLIC_KEY } from "@/shared/lib/constants"
|
||||
import { fetchTopics } from "@/shared/lib/aw-api"
|
||||
import { BACKEND_URL, VAPID_PUBLIC_KEY } from "@/shared/lib/constants"
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
import { type GeoResult, clearGeoCache, detectFromCoords, loadCachedResult } from "../../location/lib/geo"
|
||||
import { NotificationGuide } from "./notification-guide"
|
||||
@@ -31,11 +33,16 @@ export function SettingsPage() {
|
||||
const deviceId = useDeviceId()
|
||||
const { needRefresh, checkForUpdate, applyUpdate } = usePwaUpdate()
|
||||
const push = usePush()
|
||||
const { follow } = useFollows()
|
||||
const [checking, setChecking] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [result, setResult] = useState<GeoResult | null>(null)
|
||||
const [errorMsg, setErrorMsg] = useState<string | null>(null)
|
||||
const [showGuide, setShowGuide] = useState(false)
|
||||
const [devHealth, setDevHealth] = useState<string | null>(null)
|
||||
const [devPush, setDevPush] = useState<string | null>(null)
|
||||
const [devTopics, setDevTopics] = useState<string | null>(null)
|
||||
const [devPoliticians, setDevPoliticians] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const cached = loadCachedResult()
|
||||
@@ -286,6 +293,128 @@ export function SettingsPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
{/* --- Developer --- */}
|
||||
<section>
|
||||
<h2 className="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-2 px-1">Entwickler</h2>
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
{/* Backend Health */}
|
||||
<div className="flex items-center justify-between px-4 py-3">
|
||||
<span className="text-sm">Backend Health</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{devHealth && (
|
||||
<span className={`text-xs ${devHealth === "ok" ? "text-green-600" : "text-destructive"}`}>
|
||||
{devHealth}
|
||||
</span>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
onClick={async () => {
|
||||
setDevHealth(null)
|
||||
try {
|
||||
const res = await fetch(`${BACKEND_URL}/health`)
|
||||
setDevHealth(res.ok ? "ok" : `${res.status}`)
|
||||
} catch (e) {
|
||||
setDevHealth(String(e))
|
||||
}
|
||||
}}
|
||||
>
|
||||
Prüfen
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border mx-4" />
|
||||
|
||||
{/* Test Push */}
|
||||
<div className="flex items-center justify-between px-4 py-3">
|
||||
<span className="text-sm">Test-Benachrichtigung</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{devPush && (
|
||||
<span className={`text-xs ${devPush === "ok" ? "text-green-600" : "text-destructive"}`}>
|
||||
{devPush}
|
||||
</span>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
onClick={async () => {
|
||||
setDevPush(null)
|
||||
try {
|
||||
const res = await fetch(`${BACKEND_URL}/push/test`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ device_id: deviceId }),
|
||||
})
|
||||
setDevPush(res.ok ? "ok" : `${res.status}`)
|
||||
} catch (e) {
|
||||
setDevPush(String(e))
|
||||
}
|
||||
}}
|
||||
>
|
||||
Senden
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border mx-4" />
|
||||
|
||||
{/* Follow all topics */}
|
||||
<div className="flex items-center justify-between px-4 py-3">
|
||||
<span className="text-sm">Alle Themen folgen</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{devTopics && <span className="text-xs text-green-600">{devTopics}</span>}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
onClick={async () => {
|
||||
setDevTopics(null)
|
||||
try {
|
||||
const topics = await fetchTopics()
|
||||
for (const t of topics) follow("topic", t.id, t.label)
|
||||
setDevTopics(`${topics.length}`)
|
||||
} catch (e) {
|
||||
setDevTopics(String(e))
|
||||
}
|
||||
}}
|
||||
>
|
||||
Folgen
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border mx-4" />
|
||||
|
||||
{/* Follow all politicians */}
|
||||
<div className="flex items-center justify-between px-4 py-3">
|
||||
<span className="text-sm">Alle Abgeordnete folgen</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{devPoliticians && <span className="text-xs text-green-600">{devPoliticians}</span>}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
onClick={() => {
|
||||
setDevPoliticians(null)
|
||||
const cached = loadCachedResult()
|
||||
if (!cached || cached.mandates.length === 0) {
|
||||
setDevPoliticians("Kein Standort-Cache")
|
||||
return
|
||||
}
|
||||
for (const m of cached.mandates) {
|
||||
follow("politician", m.politician.id, m.politician.label)
|
||||
}
|
||||
setDevPoliticians(`${cached.mandates.length}`)
|
||||
}}
|
||||
>
|
||||
Folgen
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,27 +1,71 @@
|
||||
import { FeedList, useFeed } from "@/features/feed"
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
|
||||
function formatCacheAge(timestamp: number): string {
|
||||
const minutes = Math.floor((Date.now() - timestamp) / 60_000)
|
||||
if (minutes < 1) return "gerade eben"
|
||||
if (minutes < 60) return `vor ${minutes} Min.`
|
||||
const hours = Math.floor(minutes / 60)
|
||||
if (hours < 24) return `vor ${hours} Std.`
|
||||
const days = Math.floor(hours / 24)
|
||||
return `vor ${days} T.`
|
||||
}
|
||||
|
||||
function FeedPage() {
|
||||
const { items, loading, error } = useFeed()
|
||||
const { items, loading, refreshing, error, lastUpdated, refresh } = useFeed()
|
||||
const hasItems = items.length > 0
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<output className="flex items-center justify-center h-48" aria-label="Loading feed">
|
||||
<div className="w-8 h-8 border-4 border-primary border-t-transparent rounded-full animate-spin" />
|
||||
</output>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<main className="flex flex-col h-full">
|
||||
{/* Refresh bar */}
|
||||
{lastUpdated && (
|
||||
<div className="flex items-center justify-between px-4 py-2 border-b border-border">
|
||||
<span className="text-xs text-muted-foreground">Aktualisiert {formatCacheAge(lastUpdated)}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => refresh({ silent: true })}
|
||||
disabled={refreshing}
|
||||
className="p-1.5 rounded-md hover:bg-muted transition-colors disabled:opacity-50"
|
||||
aria-label="Feed aktualisieren"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={`w-4 h-4 text-muted-foreground ${refreshing ? "animate-spin" : ""}`}
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="p-4 text-destructive" role="alert">
|
||||
<p className="font-semibold">Error loading feed</p>
|
||||
<p className="text-sm mt-1">{error}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{/* Loading spinner — only when no cached items */}
|
||||
{loading && !hasItems && (
|
||||
<output className="flex items-center justify-center h-48" aria-label="Feed wird geladen">
|
||||
<div className="w-8 h-8 border-4 border-primary border-t-transparent rounded-full animate-spin" />
|
||||
</output>
|
||||
)}
|
||||
|
||||
return <FeedList items={items} />
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="p-4 text-destructive" role="alert">
|
||||
<p className="font-semibold">Fehler beim Laden</p>
|
||||
<p className="text-sm mt-1">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Feed list */}
|
||||
{hasItems && <FeedList items={items} />}
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
export const Route = createFileRoute("/feed")({
|
||||
|
||||
Reference in New Issue
Block a user