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

+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() {