Fix 5 bugs: analytics range keys, stale visitor rows, NuggetsPage init state, episodes timer leak, unsanitized nuggets object; v2.0.1
Publish Container / docker (push) Successful in 14m54s

- analytics: union byDayReal+byDayBot keys so bot-only days aren't dropped from range totals
- analytics: exclude timestamp-less visitor rows from range filters (was unconditionally including them)
- App.tsx: init `configured` to false so a network failure doesn't show a misleading "no episodes" state; add r.ok guard before r.json()
- episodes.js: wrap fetch in try/catch so clearTimeout is called on network failure, matching the nuggets.js pattern
- helpers.js: add sanitizeNuggets + wire it into sanitizeSiteContent so corrupted hosts/urls can't crash NuggetsPage

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 4.6 committed 2026-09-08 08:21:02 -04:00
1 parent 31e79ad542
commit 4378fad66f
5 files changed
+50 -14

No files matched your search

+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "siteforge",
"private": true,
"version": "2.0.0",
"version": "2.0.1",
"type": "module",
"scripts": {
"dev": "vite",
+27
View File
@@ -330,6 +330,32 @@ function sanitizeArchivedSeries(value) {
)
}
function sanitizeNuggets(raw) {
const n = raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : {}
const hosts = Array.isArray(n.hosts)
? n.hosts
.filter(h => h && typeof h === 'object' && typeof h.id === 'string' && h.id.trim())
.map(h => ({
id: h.id.trim(),
name: typeof h.name === 'string' ? h.name.trim().slice(0, 120) : '',
role: typeof h.role === 'string' ? h.role.trim().slice(0, 120) : '',
bio: typeof h.bio === 'string' ? h.bio.trim().slice(0, 600) : '',
imageUrl: sanitizeUrl(h.imageUrl) || '',
archived: Boolean(h.archived),
}))
: []
return {
title: typeof n.title === 'string' ? n.title.trim().slice(0, 120) : '',
tagline: typeof n.tagline === 'string' ? n.tagline.trim().slice(0, 240) : '',
description: typeof n.description === 'string' ? n.description.trim().slice(0, 600) : '',
podcastArtUrl: sanitizeUrl(n.podcastArtUrl) || '',
rssFeedUrl: sanitizeUrl(n.rssFeedUrl) || '',
spotifyUrl: sanitizeUrl(n.spotifyUrl) || '',
appleUrl: sanitizeUrl(n.appleUrl) || '',
hosts,
}
}
export function sanitizeSiteContent(siteContent) {
if (!siteContent || typeof siteContent !== 'object' || Array.isArray(siteContent)) return {}
@@ -345,6 +371,7 @@ export function sanitizeSiteContent(siteContent) {
colossiansStudySections: sanitizeColossiansStudySections(siteContent.colossiansStudySections),
redirects: sanitizeRedirectRules(siteContent.redirects),
podcastFeaturedLinks: sanitizeFeaturedLinks(siteContent.podcastFeaturedLinks ?? DEFAULT_PODCAST_FEATURED_LINKS),
nuggets: sanitizeNuggets(siteContent.nuggets),
episodesSeoIntro: typeof siteContent.episodesSeoIntro === 'string' && siteContent.episodesSeoIntro.trim() ? siteContent.episodesSeoIntro.trim().slice(0, 600) : '',
welcomeEmailSpotifyBtnLabel: typeof siteContent.welcomeEmailSpotifyBtnLabel === 'string' ? siteContent.welcomeEmailSpotifyBtnLabel.trim().slice(0, 80) : '',
welcomeEmailAppleBtnLabel: typeof siteContent.welcomeEmailAppleBtnLabel === 'string' ? siteContent.welcomeEmailAppleBtnLabel.trim().slice(0, 80) : '',
+6 -2
View File
@@ -213,7 +213,11 @@ export function register(app) {
})()
// Filter byDayReal/byDayBot keys to only those within the range
const filteredDayKeys = Object.keys(state.hitStats.byDayReal ?? {}).filter(day => day >= cutoffDate)
const allDayKeys = new Set([
...Object.keys(state.hitStats.byDayReal ?? {}),
...Object.keys(state.hitStats.byDayBot ?? {}),
])
const filteredDayKeys = [...allDayKeys].filter(day => day >= cutoffDate)
// Aggregate hits for the range
const rangeRealHits = filteredDayKeys.reduce((sum, day) => sum + (state.hitStats.byDayReal?.[day] ?? 0), 0)
@@ -222,7 +226,7 @@ export function register(app) {
// Filter per-path stats by range — approximate using recent visitor rows scoped to range
const rangeVisitorRows = state.visitorStats.recentVisits.filter(row => {
if (!row.at) return true // include if no timestamp
if (!row.at) return false // exclude rows with no timestamp from range views
return row.at >= cutoffDate
})
+14 -9
View File
@@ -14,15 +14,20 @@ export async function fetchAllEpisodes() {
}
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), 8000)
const response = await fetch(RSS_FEED_URL, { signal: controller.signal })
clearTimeout(timeout)
if (!response.ok) throw new Error(`RSS fetch failed: ${response.status}`)
const xml = await response.text()
rawXmlCache = xml
const episodes = parseRssItems(xml)
episodesCache = episodes
episodesCacheAt = now
return episodes
try {
const response = await fetch(RSS_FEED_URL, { signal: controller.signal })
clearTimeout(timeout)
if (!response.ok) throw new Error(`RSS fetch failed: ${response.status}`)
const xml = await response.text()
rawXmlCache = xml
const episodes = parseRssItems(xml)
episodesCache = episodes
episodesCacheAt = now
return episodes
} catch (err) {
clearTimeout(timeout)
throw err
}
}
export async function fetchRawFeedXml() {
+2 -2
View File
@@ -1885,11 +1885,11 @@ function NuggetsPage({ content }: { content: SiteContent }) {
const [episodes, setEpisodes] = useState<NuggetsEpisode[]>([])
const [loading, setLoading] = useState(true)
const [configured, setConfigured] = useState(true)
const [configured, setConfigured] = useState(false)
useEffect(() => {
fetch('/api/nuggets/episodes/all')
.then(r => r.json())
.then(r => r.ok ? r.json() : Promise.reject(r.status))
.then((d: { episodes?: NuggetsEpisode[]; configured?: boolean }) => {
setEpisodes(Array.isArray(d.episodes) ? d.episodes : [])
setConfigured(d.configured !== false)